16.7 KB441 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 {CodeReviewSystem} from '../types';
9
10import {act, fireEvent, render, screen, waitFor} from '@testing-library/react';
11import userEvent from '@testing-library/user-event';
12import {nextTick} from 'shared/testUtils';
13import * as utils from 'shared/utils';
14import App from '../App';
15import {CommitInfoTestUtils} from '../testQueries';
16import {
17 COMMIT,
18 expectMessageNOTSentToServer,
19 expectMessageSentToServer,
20 fireMouseEvent,
21 getLastMessageOfTypeSentToServer,
22 resetTestMessages,
23 simulateCommits,
24 simulateMessageFromServer,
25 simulateUncommittedChangedFiles,
26} from '../testUtils';
27
28describe('Image upload inside TextArea ', () => {
29 beforeEach(() => {
30 resetTestMessages();
31 });
32
33 beforeEach(() => {
34 render(<App />);
35 act(() => {
36 expectMessageSentToServer({
37 type: 'subscribe',
38 kind: 'smartlogCommits',
39 subscriptionID: expect.anything(),
40 });
41 simulateCommits({
42 value: [
43 COMMIT('1', 'some public base', '0', {phase: 'public'}),
44 COMMIT('b', 'My Commit', '1'),
45 COMMIT('a', 'My Commit', 'b', {isDot: true}),
46 ],
47 });
48 });
49 act(() => {
50 CommitInfoTestUtils.clickToEditTitle();
51 CommitInfoTestUtils.clickToEditDescription();
52 });
53 });
54
55 const mockFile = new File(['Hello'], 'file.png', {type: 'image/png'});
56 const mockFileContentInBase64 = btoa('Hello'); // SGVsbG8=
57
58 const dataTransfer = {
59 files: [mockFile] as unknown as FileList,
60 } as DataTransfer;
61
62 describe('Drag and drop image', () => {
63 it('renders highlight while dragging image', () => {
64 const textarea = CommitInfoTestUtils.getDescriptionEditor();
65
66 act(() => void fireMouseEvent('dragenter', textarea, 0, 0, {dataTransfer}));
67 expect(document.querySelector('.hovering-to-drop')).not.toBeNull();
68 act(() => void fireMouseEvent('dragleave', textarea, 0, 0, {dataTransfer}));
69 expect(document.querySelector('.hovering-to-drop')).toBeNull();
70 });
71
72 it('does not try to upload other things being dragged', () => {
73 const textarea = CommitInfoTestUtils.getDescriptionEditor();
74 act(() => {
75 fireMouseEvent('dragenter', textarea, 0, 0, {
76 dataTransfer: {
77 files: [],
78 items: [],
79 } as unknown as DataTransfer,
80 });
81 }); // drag without files is ignored
82 expect(document.querySelector('.hovering-to-drop')).toBeNull();
83 });
84
85 it('lets you drag an image to upload it', async () => {
86 const textarea = CommitInfoTestUtils.getDescriptionEditor();
87 act(() => void fireMouseEvent('dragenter', textarea, 0, 0, {dataTransfer}));
88 act(() => {
89 fireMouseEvent('drop', textarea, 0, 0, {dataTransfer});
90 });
91
92 await waitFor(() => {
93 expectMessageSentToServer(expect.objectContaining({type: 'uploadFile'}));
94 });
95 });
96 });
97
98 describe('Paste image to upload', () => {
99 it('lets you paste an image to upload it', async () => {
100 const textarea = CommitInfoTestUtils.getDescriptionEditor();
101 act(() => {
102 fireEvent.paste(textarea, {clipboardData: dataTransfer});
103 });
104 await waitFor(() => {
105 expectMessageSentToServer(expect.objectContaining({type: 'uploadFile'}));
106 });
107 });
108 it('pastes without images are handled normally', async () => {
109 const textarea = CommitInfoTestUtils.getDescriptionEditor();
110 act(() => void fireEvent.paste(textarea));
111 await nextTick(); // allow file upload to await arrayBuffer()
112 expectMessageNOTSentToServer(expect.objectContaining({type: 'uploadFile'}));
113 });
114 });
115
116 describe('file picker to upload file', () => {
117 it('lets you pick a file to upload', async () => {
118 const uploadButton = screen.getAllByTestId('attach-file-input')[0];
119 act(() => {
120 userEvent.upload(uploadButton, [mockFile]);
121 });
122
123 await waitFor(() => {
124 expectMessageSentToServer(expect.objectContaining({type: 'uploadFile'}));
125 });
126 });
127 });
128
129 describe('Image upload UI', () => {
130 async function startFileUpload() {
131 // Get the previous upload message (if any) to avoid race conditions
132 const previousMessage = getLastMessageOfTypeSentToServer('uploadFile');
133 const previousId = previousMessage?.id;
134
135 const uploadButton = screen.getAllByTestId('attach-file-input')[0];
136 act(() => void userEvent.upload(uploadButton, [mockFile]));
137
138 // Wait for a NEW upload message that's different from the previous one
139 const message = await waitFor(() => {
140 const latestMessage = utils.nullthrows(getLastMessageOfTypeSentToServer('uploadFile'));
141 // If this is the first upload or the ID is different from the previous one, we're good
142 if (!previousId || latestMessage.id !== previousId) {
143 return latestMessage;
144 }
145 // Otherwise, throw to keep waiting
146 throw new Error('Still waiting for new upload message');
147 });
148
149 const id = message.id;
150 expectMessageSentToServer(expect.objectContaining({type: 'uploadFile', id}));
151 return id;
152 }
153
154 async function simulateUploadSucceeded(id: string) {
155 await act(async () => {
156 simulateMessageFromServer({
157 type: 'uploadFileResult',
158 id,
159 result: {value: `https://image.example.com/${id}`},
160 });
161 await nextTick();
162 });
163 }
164
165 async function simulateUploadFailed(id: string) {
166 await act(async () => {
167 simulateMessageFromServer({
168 type: 'uploadFileResult',
169 id,
170 result: {error: new Error('upload failed')},
171 });
172 await nextTick();
173 });
174 }
175
176 const {descriptionTextContent, getDescriptionEditor} = CommitInfoTestUtils;
177
178 it('shows placeholder when uploading an image', async () => {
179 expect(descriptionTextContent()).not.toContain('Uploading');
180 await startFileUpload();
181 expect(descriptionTextContent()).toContain('Uploading #1');
182 });
183
184 it('sends a message to the server to upload the file', async () => {
185 const id = await startFileUpload();
186 expectMessageSentToServer({
187 type: 'uploadFile',
188 filename: 'file.png',
189 id,
190 b64Content: mockFileContentInBase64,
191 });
192 });
193
194 it('removes placeholder when upload succeeds', async () => {
195 const id = await startFileUpload();
196 expect(descriptionTextContent()).toContain('Uploading #1');
197 await simulateUploadSucceeded(id);
198 expect(descriptionTextContent()).not.toContain('Uploading #1');
199 expect(descriptionTextContent()).toContain(`https://image.example.com/${id}`);
200 });
201
202 it('removes placeholder when upload fails', async () => {
203 const id = await startFileUpload();
204 expect(descriptionTextContent()).toContain('Uploading #1');
205 await simulateUploadFailed(id);
206 expect(descriptionTextContent()).not.toContain('Uploading #1');
207 expect(descriptionTextContent()).not.toContain('https://image.example.com');
208 });
209
210 it('shows progress of ongoing uploads', async () => {
211 await startFileUpload();
212 expect(screen.getByText('Uploading 1 file')).toBeInTheDocument();
213 });
214
215 it('click to cancel upload', async () => {
216 await startFileUpload();
217 expect(screen.getByText('Uploading 1 file')).toBeInTheDocument();
218 act(() => {
219 fireEvent.mouseOver(screen.getByText('Uploading 1 file'));
220 });
221 expect(screen.getByText('Click to cancel')).toBeInTheDocument();
222 act(() => {
223 fireEvent.click(screen.getByText('Click to cancel'));
224 });
225
226 expect(descriptionTextContent()).not.toContain('Uploading #1');
227 expect(screen.queryByText('Uploading 1 file')).not.toBeInTheDocument();
228 });
229
230 it('clears hover state when cancelling', async () => {
231 await startFileUpload();
232 act(() => void fireEvent.mouseOver(screen.getByText('Uploading 1 file')));
233 act(() => void fireEvent.click(screen.getByText('Click to cancel')));
234 await startFileUpload();
235 expect(screen.queryByText('Uploading 1 file')).toBeInTheDocument();
236 });
237
238 it('shows upload errors', async () => {
239 const id = await startFileUpload();
240 await simulateUploadFailed(id);
241 expect(screen.getByText('1 file upload failed')).toBeInTheDocument();
242 fireEvent.click(screen.getByTestId('dismiss-upload-errors'));
243 expect(screen.queryByText('1 file upload failed')).not.toBeInTheDocument();
244 });
245
246 it('handles multiple placeholders', async () => {
247 const id1 = await startFileUpload();
248 expect(screen.getByText('Uploading 1 file')).toBeInTheDocument();
249 const id2 = await startFileUpload();
250 expect(screen.getByText('Uploading 2 files')).toBeInTheDocument();
251 expect(id1).not.toEqual(id2);
252
253 expect(descriptionTextContent()).toContain('Uploading #1');
254 expect(descriptionTextContent()).toContain('Uploading #2');
255 await simulateUploadSucceeded(id1);
256 expect(descriptionTextContent()).not.toContain('Uploading #1');
257 expect(descriptionTextContent()).toContain('Uploading #2');
258
259 expect(descriptionTextContent()).toContain(`https://image.example.com/${id1}`);
260 expect(descriptionTextContent()).not.toContain(`https://image.example.com/${id2}`);
261
262 await simulateUploadSucceeded(id2);
263 expect(descriptionTextContent()).not.toContain('Uploading #2');
264 expect(descriptionTextContent()).toContain(`https://image.example.com/${id2}`);
265 });
266
267 it('inserts whitespace before inserted placeholder when necessary', async () => {
268 act(() => {
269 userEvent.type(getDescriptionEditor(), 'Hello!\n');
270 // ^ cursor
271 getDescriptionEditor().selectionStart = 6;
272 getDescriptionEditor().selectionEnd = 6;
273 });
274 await startFileUpload();
275 expect(descriptionTextContent()).toEqual('Hello! 【 Uploading #1 】\n');
276 // ^ inserted space ^ no extra space
277 });
278
279 it('inserts whitespace after inserted placeholder when necessary', async () => {
280 act(() => {
281 userEvent.type(getDescriptionEditor(), 'Hello!\n');
282 // ^ cursor
283 getDescriptionEditor().selectionStart = 0;
284 getDescriptionEditor().selectionEnd = 0;
285 });
286 await startFileUpload();
287 expect(descriptionTextContent()).toEqual('【 Uploading #1 】 Hello!\n');
288 // ^ no space ^ inserted space
289 });
290
291 it('preserves selection when setting placeholders', async () => {
292 act(() => {
293 userEvent.type(getDescriptionEditor(), 'Hello, world!\n');
294 // ^-----^ selection
295 getDescriptionEditor().selectionStart = 2;
296 getDescriptionEditor().selectionEnd = 8;
297 });
298 await startFileUpload();
299 expect(descriptionTextContent()).toEqual('He 【 Uploading #1 】 orld!\n');
300 // ^ inserted spaces ^
301
302 // now cursor is after Uploading
303 expect(getDescriptionEditor().selectionStart).toEqual(20);
304 expect(getDescriptionEditor().selectionEnd).toEqual(20);
305 });
306
307 it('preserves selection when replacing placeholders', async () => {
308 act(() => {
309 userEvent.type(getDescriptionEditor(), 'fob\nbar\nbaz');
310 // ^ cursor
311 getDescriptionEditor().selectionStart = 4;
312 getDescriptionEditor().selectionEnd = 4;
313 });
314 const id = await startFileUpload();
315 expect(descriptionTextContent()).toEqual('fob\n【 Uploading #1 】 bar\nbaz');
316 // start new selection: ^--------------------------^
317 getDescriptionEditor().selectionStart = 2;
318 getDescriptionEditor().selectionEnd = 26;
319 // make sure my indices are correct
320 expect(descriptionTextContent()[getDescriptionEditor().selectionStart]).toEqual('b');
321 expect(descriptionTextContent()[getDescriptionEditor().selectionEnd]).toEqual('a');
322
323 await simulateUploadSucceeded(id);
324 expect(descriptionTextContent()).toEqual(`fob\nhttps://image.example.com/${id} bar\nbaz`);
325 // selection is preserved: ^---------------------------------------^
326
327 // now cursor is after Uploading
328 expect(getDescriptionEditor().selectionStart).toEqual(2);
329 expect(getDescriptionEditor().selectionEnd).toEqual(36 + id.length);
330 expect(descriptionTextContent()[getDescriptionEditor().selectionStart]).toEqual('b');
331 expect(descriptionTextContent()[getDescriptionEditor().selectionEnd]).toEqual('a');
332 });
333
334 describe('disable commit info view buttons while uploading', () => {
335 beforeEach(() => {
336 act(() => {
337 simulateUncommittedChangedFiles({
338 value: [{path: 'src/file1.js', status: 'M'}],
339 });
340 });
341 });
342
343 it('disables amend message button', async () => {
344 CommitInfoTestUtils.clickToSelectCommit('b');
345 CommitInfoTestUtils.clickToEditDescription();
346 expect(
347 CommitInfoTestUtils.withinCommitActionBar().getByText('Amend Message'),
348 ).not.toBeDisabled();
349 const id = await startFileUpload();
350 expect(
351 CommitInfoTestUtils.withinCommitActionBar().getByText('Amend Message'),
352 ).toBeDisabled();
353 await simulateUploadSucceeded(id);
354 expect(
355 CommitInfoTestUtils.withinCommitActionBar().getByText('Amend Message'),
356 ).not.toBeDisabled();
357 });
358
359 it('disables amend button', async () => {
360 expect(CommitInfoTestUtils.withinCommitActionBar().getByText('Amend')).not.toBeDisabled();
361 const id = await startFileUpload();
362 expect(CommitInfoTestUtils.withinCommitActionBar().getByText('Amend')).toBeDisabled();
363 await simulateUploadSucceeded(id);
364 expect(CommitInfoTestUtils.withinCommitActionBar().getByText('Amend')).not.toBeDisabled();
365 });
366
367 it('disables commit button', async () => {
368 CommitInfoTestUtils.clickCommitMode();
369 expect(CommitInfoTestUtils.withinCommitActionBar().getByText('Commit')).not.toBeDisabled();
370 const id = await startFileUpload();
371 expect(CommitInfoTestUtils.withinCommitActionBar().getByText('Commit')).toBeDisabled();
372 await simulateUploadSucceeded(id);
373 expect(CommitInfoTestUtils.withinCommitActionBar().getByText('Commit')).not.toBeDisabled();
374 });
375
376 it('disables commit and submit button', async () => {
377 act(() => {
378 simulateMessageFromServer({
379 type: 'repoInfo',
380 info: {
381 codeReviewSystem: {type: 'github'} as CodeReviewSystem,
382 command: 'sl',
383 repoRoot: '/repo',
384 dotdir: '/repo/.sl',
385 type: 'success',
386 pullRequestDomain: undefined,
387 preferredSubmitCommand: undefined,
388 isEdenFs: false,
389 },
390 });
391 });
392 // Get around internally-disabled button
393 fireEvent.click(screen.getByTestId('settings-gear-button'));
394 const enableSubmit = screen.queryByTestId('force-enable-github-submit');
395 enableSubmit && fireEvent.click(enableSubmit);
396 CommitInfoTestUtils.clickCommitMode();
397 expect(
398 CommitInfoTestUtils.withinCommitActionBar().getByText('Commit and Submit'),
399 ).not.toBeDisabled();
400 const id = await startFileUpload();
401 expect(
402 CommitInfoTestUtils.withinCommitActionBar().getByText('Commit and Submit'),
403 ).toBeDisabled();
404 await simulateUploadSucceeded(id);
405 expect(
406 CommitInfoTestUtils.withinCommitActionBar().getByText('Commit and Submit'),
407 ).not.toBeDisabled();
408 });
409 });
410
411 it('emits uploads to underlying store', async () => {
412 CommitInfoTestUtils.clickCommitMode();
413 act(() => {
414 simulateUncommittedChangedFiles({value: [{path: 'foo.txt', status: 'M'}]});
415 });
416 act(() => {
417 userEvent.type(CommitInfoTestUtils.getTitleEditor(), 'hi');
418 userEvent.type(CommitInfoTestUtils.getDescriptionEditor(), 'hey\n');
419 });
420 const id = await startFileUpload();
421 await simulateUploadSucceeded(id);
422 expect(descriptionTextContent()).toContain(`https://image.example.com/${id}`);
423
424 act(() => {
425 fireEvent.click(CommitInfoTestUtils.withinCommitActionBar().getByText('Commit'));
426 });
427 await waitFor(() =>
428 expectMessageSentToServer({
429 type: 'runOperation',
430 operation: expect.objectContaining({
431 args: expect.arrayContaining([
432 'commit',
433 expect.stringMatching(`hi\n+(Summary:\n)?hey\nhttps://image.example.com/${id}`),
434 ]),
435 }),
436 }),
437 );
438 });
439 });
440});
441