| 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 {Hash} from '../../types'; |
| 9 | |
| 10 | import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react'; |
| 11 | import App from '../../App'; |
| 12 | import {Dag, DagCommitInfo} from '../../dag/dag'; |
| 13 | import {RebaseOperation} from '../../operations/RebaseOperation'; |
| 14 | import {CommitPreview} from '../../previews'; |
| 15 | import {ignoreRTL} from '../../testQueries'; |
| 16 | import { |
| 17 | closeCommitInfoSidebar, |
| 18 | COMMIT, |
| 19 | dragAndDropCommits, |
| 20 | dragCommits, |
| 21 | dropCommits, |
| 22 | expectMessageNOTSentToServer, |
| 23 | expectMessageSentToServer, |
| 24 | resetTestMessages, |
| 25 | scanForkedBranchHashes, |
| 26 | simulateCommits, |
| 27 | simulateUncommittedChangedFiles, |
| 28 | TEST_COMMIT_HISTORY, |
| 29 | } from '../../testUtils'; |
| 30 | import {CommandRunner, succeedableRevset} from '../../types'; |
| 31 | |
| 32 | /*eslint-disable @typescript-eslint/no-non-null-assertion */ |
| 33 | |
| 34 | describe('rebase operation', () => { |
| 35 | // Extend with an obsoleted commit. |
| 36 | const testHistory = TEST_COMMIT_HISTORY.concat([ |
| 37 | COMMIT('ff1', 'Commit FF1 (obsoleted)', 'z', {successorInfo: {hash: 'ff2', type: 'amend'}}), |
| 38 | COMMIT('ff2', 'Commit FF2', 'z'), |
| 39 | ]); |
| 40 | |
| 41 | beforeEach(() => { |
| 42 | jest.useFakeTimers(); |
| 43 | resetTestMessages(); |
| 44 | render(<App />); |
| 45 | act(() => { |
| 46 | closeCommitInfoSidebar(); |
| 47 | expectMessageSentToServer({ |
| 48 | type: 'subscribe', |
| 49 | kind: 'smartlogCommits', |
| 50 | subscriptionID: expect.anything(), |
| 51 | }); |
| 52 | simulateCommits({ |
| 53 | value: testHistory, |
| 54 | }); |
| 55 | }); |
| 56 | }); |
| 57 | |
| 58 | afterEach(() => { |
| 59 | jest.useRealTimers(); |
| 60 | }); |
| 61 | |
| 62 | const getCommitWithPreview = (hash: Hash, preview: CommitPreview): HTMLElement => { |
| 63 | const previewOfCommit = screen |
| 64 | .queryAllByTestId(`commit-${hash}`) |
| 65 | .map(commit => within(commit).queryByTestId('draggable-commit')) |
| 66 | .find(commit => commit?.classList.contains(`commit-preview-${preview}`)); |
| 67 | expect(previewOfCommit).toBeInTheDocument(); |
| 68 | return previewOfCommit!; |
| 69 | }; |
| 70 | |
| 71 | it('previews a rebase on drag & drop onto a public commit', () => { |
| 72 | expect(screen.getAllByText('Commit D')).toHaveLength(1); |
| 73 | dragAndDropCommits('d', '2'); |
| 74 | |
| 75 | // original commit AND previewed commit are now in the document |
| 76 | expect(screen.getAllByText('Commit D')).toHaveLength(2); |
| 77 | // also includes descendants |
| 78 | expect(screen.getAllByText('Commit E')).toHaveLength(2); |
| 79 | |
| 80 | // one of them is a rebase preview |
| 81 | expect( |
| 82 | screen |
| 83 | .queryAllByTestId('commit-d') |
| 84 | .some(commit => commit.querySelector('.commit-preview-rebase-root')), |
| 85 | ).toEqual(true); |
| 86 | }); |
| 87 | |
| 88 | it('sets all descendants as the right preview type', () => { |
| 89 | expect(screen.getAllByText('Commit D')).toHaveLength(1); |
| 90 | dragAndDropCommits('a', '2'); |
| 91 | |
| 92 | expect(document.querySelectorAll('.commit-preview-rebase-old')).toHaveLength(5); |
| 93 | expect(document.querySelectorAll('.commit-preview-rebase-root')).toHaveLength(1); |
| 94 | expect(document.querySelectorAll('.commit-preview-rebase-descendant')).toHaveLength(4); |
| 95 | }); |
| 96 | |
| 97 | it('previews onto correct branch', () => { |
| 98 | expect(screen.getAllByText('Commit D')).toHaveLength(1); |
| 99 | dragAndDropCommits('d', 'x'); |
| 100 | expect(scanForkedBranchHashes('x')).toEqual(['d', 'e']); |
| 101 | }); |
| 102 | |
| 103 | it('cannot drag public commits', () => { |
| 104 | dragAndDropCommits('1', '2'); |
| 105 | |
| 106 | // only see one copy of commit 1 |
| 107 | expect(screen.queryAllByTestId('commit-1')).toHaveLength(1); |
| 108 | }); |
| 109 | |
| 110 | it('runs rebase operation', async () => { |
| 111 | dragAndDropCommits('d', '2'); |
| 112 | |
| 113 | const runRebaseButton = screen.getByText('Run Rebase'); |
| 114 | expect(runRebaseButton).toBeInTheDocument(); |
| 115 | |
| 116 | fireEvent.click(runRebaseButton); |
| 117 | await waitFor(() => { |
| 118 | expectMessageSentToServer({ |
| 119 | type: 'runOperation', |
| 120 | operation: { |
| 121 | args: ['rebase', '-s', succeedableRevset('d'), '-d', succeedableRevset('remote/master')], |
| 122 | id: expect.anything(), |
| 123 | runner: CommandRunner.Sapling, |
| 124 | trackEventName: 'RebaseOperation', |
| 125 | }, |
| 126 | }); |
| 127 | }); |
| 128 | }); |
| 129 | |
| 130 | it('shows optimistic preview of rebase', async () => { |
| 131 | dragAndDropCommits('d', '2'); |
| 132 | |
| 133 | fireEvent.click(screen.getByText('Run Rebase')); |
| 134 | |
| 135 | // original commit is hidden, we only see optimistic commit |
| 136 | expect(screen.queryAllByTestId('commit-d')).toHaveLength(1); |
| 137 | // also includes descendants |
| 138 | expect(screen.queryAllByTestId('commit-e')).toHaveLength(1); |
| 139 | |
| 140 | await waitFor(() => { |
| 141 | expect(screen.getByText('rebasing...')).toBeInTheDocument(); |
| 142 | }); |
| 143 | |
| 144 | expect( |
| 145 | screen.queryByTestId('commit-d')?.querySelector('.commit-preview-rebase-optimistic-root'), |
| 146 | ).toBeInTheDocument(); |
| 147 | }); |
| 148 | |
| 149 | it('allows re-dragging a previously dragged commit back onto the same parent', async () => { |
| 150 | // Drag and drop 'e' normally onto 'c' |
| 151 | dragCommits('e', 'd'); |
| 152 | expect(screen.queryByText('Run Rebase')).not.toBeInTheDocument(); // dragging on original parent is noop |
| 153 | dragAndDropCommits('e', 'c'); |
| 154 | expect(screen.getByText('Run Rebase')).toBeInTheDocument(); |
| 155 | |
| 156 | // Drag the previously preview'd 'e' from 'c' onto 'b', without dropping |
| 157 | dragCommits(getCommitWithPreview('e', CommitPreview.REBASE_ROOT), 'b'); |
| 158 | // Keep dragging the previously preview'd 'e' from 'b' back to 'c' |
| 159 | dragCommits(getCommitWithPreview('e', CommitPreview.REBASE_ROOT), 'c'); |
| 160 | // Finally, drop on 'c'. This should work, even though the preview'd 'e' started on top of 'c', so it's the "parent" |
| 161 | dropCommits(getCommitWithPreview('e', CommitPreview.REBASE_ROOT), 'c'); |
| 162 | |
| 163 | fireEvent.click(screen.getByText('Run Rebase')); |
| 164 | await waitFor(() => { |
| 165 | expectMessageSentToServer({ |
| 166 | type: 'runOperation', |
| 167 | operation: { |
| 168 | args: ['rebase', '-s', succeedableRevset('e'), '-d', succeedableRevset('c')], |
| 169 | id: expect.anything(), |
| 170 | runner: CommandRunner.Sapling, |
| 171 | trackEventName: 'RebaseOperation', |
| 172 | }, |
| 173 | }); |
| 174 | }); |
| 175 | }); |
| 176 | |
| 177 | it('cancel cancels the preview', () => { |
| 178 | dragAndDropCommits('d', '2'); |
| 179 | |
| 180 | const cancelButton = screen.getByText('Cancel'); |
| 181 | expect(cancelButton).toBeInTheDocument(); |
| 182 | |
| 183 | act(() => { |
| 184 | fireEvent.click(cancelButton); |
| 185 | }); |
| 186 | |
| 187 | // now the preview doesn't exist |
| 188 | expect(screen.queryAllByTestId('commit-d')).toHaveLength(1); |
| 189 | |
| 190 | // we didn't run any operation |
| 191 | expectMessageNOTSentToServer({ |
| 192 | type: 'runOperation', |
| 193 | operation: expect.anything(), |
| 194 | }); |
| 195 | }); |
| 196 | |
| 197 | it('cannot drag with uncommitted changes', () => { |
| 198 | act(() => simulateUncommittedChangedFiles({value: [{path: 'file1.txt', status: 'M'}]})); |
| 199 | dragAndDropCommits('d', '2'); |
| 200 | |
| 201 | expect(screen.queryByText('Run Rebase')).not.toBeInTheDocument(); |
| 202 | expect(screen.getByText('Cannot drag to rebase with uncommitted changes.')).toBeInTheDocument(); |
| 203 | }); |
| 204 | |
| 205 | it('cannot drag obsoleted commits', () => { |
| 206 | dragAndDropCommits('ff1', 'e'); |
| 207 | |
| 208 | expect(screen.queryByText('Run Rebase')).not.toBeInTheDocument(); |
| 209 | expect(screen.getByText('Cannot rebase obsoleted commits.')).toBeInTheDocument(); |
| 210 | }); |
| 211 | |
| 212 | it('can drag if uncommitted changes are optimistically removed', async () => { |
| 213 | act(() => simulateUncommittedChangedFiles({value: [{path: 'file1.txt', status: 'M'}]})); |
| 214 | act(() => { |
| 215 | fireEvent.click(screen.getByTestId('quick-commit-button')); |
| 216 | }); |
| 217 | await waitFor(() => { |
| 218 | expect(screen.queryByText(ignoreRTL('file1.txt'))).not.toBeInTheDocument(); |
| 219 | }); |
| 220 | dragAndDropCommits('d', '2'); |
| 221 | |
| 222 | expect( |
| 223 | screen.queryByText('Cannot drag to rebase with uncommitted changes.'), |
| 224 | ).not.toBeInTheDocument(); |
| 225 | }); |
| 226 | |
| 227 | it('can drag with untracked changes', () => { |
| 228 | act(() => simulateUncommittedChangedFiles({value: [{path: 'file1.txt', status: '?'}]})); |
| 229 | dragAndDropCommits('d', '2'); |
| 230 | |
| 231 | expect(screen.queryByText('Run Rebase')).toBeInTheDocument(); |
| 232 | }); |
| 233 | |
| 234 | it('handles partial rebase in optimistic dag', () => { |
| 235 | const dag = new Dag().add(TEST_COMMIT_HISTORY.map(c => DagCommitInfo.fromCommitInfo(c))); |
| 236 | |
| 237 | const type = 'succeedable-revset'; |
| 238 | // Rebase a-b-c-d-e to z |
| 239 | const rebaseOp = new RebaseOperation({type, revset: 'a'}, {type, revset: 'z'}); |
| 240 | // Count commits with the given title in a dag. |
| 241 | const count = (dag: Dag, title: string): number => |
| 242 | dag.getBatch([...dag]).filter(c => c.title === title).length; |
| 243 | // Emulate partial rebased: a-b was rebased to z, but not c-d-e |
| 244 | const partialRebased = dag.rebase(['a', 'b'], 'z'); |
| 245 | // There are 2 "Commit A"s in the partially rebased dag - one obsolsted. |
| 246 | expect(count(partialRebased, 'Commit A')).toBe(2); |
| 247 | expect(count(partialRebased, 'Commit B')).toBe(2); |
| 248 | expect(partialRebased.descendants('z').size).toBe(dag.descendants('z').size + 2); |
| 249 | |
| 250 | // Calculate the optimistic dag from a partial rebase state. |
| 251 | const optimisticDag = rebaseOp.optimisticDag(partialRebased); |
| 252 | // should be only 1 "Commit A"s. |
| 253 | expect(count(optimisticDag, 'Commit A')).toBe(1); |
| 254 | expect(count(optimisticDag, 'Commit B')).toBe(1); |
| 255 | expect(count(optimisticDag, 'Commit E')).toBe(1); |
| 256 | // check the Commit A..E branch is completed rebased. |
| 257 | expect(dag.children(dag.parents('a')).size).toBe( |
| 258 | optimisticDag.children(dag.parents('a')).size + 1, |
| 259 | ); |
| 260 | expect(optimisticDag.descendants('z').size).toBe(dag.descendants('a').size + 1); |
| 261 | }); |
| 262 | |
| 263 | describe('stacking optimistic state', () => { |
| 264 | it('cannot drag and drop preview descendants', () => { |
| 265 | dragAndDropCommits('d', 'a'); |
| 266 | expect(scanForkedBranchHashes('a')).toEqual(['d', 'e']); |
| 267 | |
| 268 | dragAndDropCommits(getCommitWithPreview('e', CommitPreview.REBASE_DESCENDANT), 'b'); |
| 269 | |
| 270 | // we still see same commit preview |
| 271 | expect(scanForkedBranchHashes('a')).toEqual(['d', 'e']); |
| 272 | }); |
| 273 | |
| 274 | it('can drag preview root again', () => { |
| 275 | dragAndDropCommits('d', 'a'); |
| 276 | |
| 277 | dragAndDropCommits(getCommitWithPreview('d', CommitPreview.REBASE_ROOT), 'b'); |
| 278 | |
| 279 | // preview is updated to be based on b |
| 280 | expect(scanForkedBranchHashes('b')).toEqual(['d', 'e']); |
| 281 | }); |
| 282 | |
| 283 | it('can preview drag drop while previous rebase running', async () => { |
| 284 | // c |
| 285 | // c | e |
| 286 | // e b |/ |
| 287 | // d | e b |
| 288 | // c -> | d -> | d |
| 289 | // b |/ |/ |
| 290 | // a a a |
| 291 | dragAndDropCommits('d', 'a'); |
| 292 | fireEvent.click(screen.getByText('Run Rebase')); |
| 293 | await waitFor(() => { |
| 294 | expect(screen.getByText('rebasing...')).toBeInTheDocument(); |
| 295 | }); |
| 296 | |
| 297 | dragAndDropCommits( |
| 298 | getCommitWithPreview('e', CommitPreview.REBASE_OPTIMISTIC_DESCENDANT), |
| 299 | 'b', |
| 300 | ); |
| 301 | |
| 302 | // original optimistic is still there |
| 303 | expect(scanForkedBranchHashes('a')).toContain('d'); |
| 304 | // also previewing new drag |
| 305 | expect(scanForkedBranchHashes('b')).toEqual(['e']); |
| 306 | }); |
| 307 | |
| 308 | it('can see optimistic drag drop while previous rebase running', async () => { |
| 309 | // c |
| 310 | // c | e |
| 311 | // e b |/ |
| 312 | // d | e b |
| 313 | // c -> | d -> | d |
| 314 | // b |/ |/ |
| 315 | // a a a |
| 316 | dragAndDropCommits('d', 'a'); |
| 317 | fireEvent.click(screen.getByText('Run Rebase')); |
| 318 | await waitFor(() => { |
| 319 | expect(screen.getByText('rebasing...')).toBeInTheDocument(); |
| 320 | }); |
| 321 | dragAndDropCommits( |
| 322 | getCommitWithPreview('e', CommitPreview.REBASE_OPTIMISTIC_DESCENDANT), |
| 323 | 'b', |
| 324 | ); |
| 325 | fireEvent.click(screen.getByText('Run Rebase')); |
| 326 | |
| 327 | // original optimistic is still there |
| 328 | expect(scanForkedBranchHashes('a')).toContain('d'); |
| 329 | // new optimistic state is also there |
| 330 | expect(scanForkedBranchHashes('b')).toEqual(['e']); |
| 331 | }); |
| 332 | }); |
| 333 | }); |
| 334 | |