addons/isl/src/__tests__/ImageUpload.test.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {CodeReviewSystem} from '../types';
b69ab319
b69ab3110import {act, fireEvent, render, screen, waitFor} from '@testing-library/react';
b69ab3111import userEvent from '@testing-library/user-event';
b69ab3112import {nextTick} from 'shared/testUtils';
b69ab3113import * as utils from 'shared/utils';
b69ab3114import App from '../App';
b69ab3115import {CommitInfoTestUtils} from '../testQueries';
b69ab3116import {
b69ab3117 COMMIT,
b69ab3118 expectMessageNOTSentToServer,
b69ab3119 expectMessageSentToServer,
b69ab3120 fireMouseEvent,
b69ab3121 getLastMessageOfTypeSentToServer,
b69ab3122 resetTestMessages,
b69ab3123 simulateCommits,
b69ab3124 simulateMessageFromServer,
b69ab3125 simulateUncommittedChangedFiles,
b69ab3126} from '../testUtils';
b69ab3127
b69ab3128describe('Image upload inside TextArea ', () => {
b69ab3129 beforeEach(() => {
b69ab3130 resetTestMessages();
b69ab3131 });
b69ab3132
b69ab3133 beforeEach(() => {
b69ab3134 render(<App />);
b69ab3135 act(() => {
b69ab3136 expectMessageSentToServer({
b69ab3137 type: 'subscribe',
b69ab3138 kind: 'smartlogCommits',
b69ab3139 subscriptionID: expect.anything(),
b69ab3140 });
b69ab3141 simulateCommits({
b69ab3142 value: [
b69ab3143 COMMIT('1', 'some public base', '0', {phase: 'public'}),
b69ab3144 COMMIT('b', 'My Commit', '1'),
b69ab3145 COMMIT('a', 'My Commit', 'b', {isDot: true}),
b69ab3146 ],
b69ab3147 });
b69ab3148 });
b69ab3149 act(() => {
b69ab3150 CommitInfoTestUtils.clickToEditTitle();
b69ab3151 CommitInfoTestUtils.clickToEditDescription();
b69ab3152 });
b69ab3153 });
b69ab3154
b69ab3155 const mockFile = new File(['Hello'], 'file.png', {type: 'image/png'});
b69ab3156 const mockFileContentInBase64 = btoa('Hello'); // SGVsbG8=
b69ab3157
b69ab3158 const dataTransfer = {
b69ab3159 files: [mockFile] as unknown as FileList,
b69ab3160 } as DataTransfer;
b69ab3161
b69ab3162 describe('Drag and drop image', () => {
b69ab3163 it('renders highlight while dragging image', () => {
b69ab3164 const textarea = CommitInfoTestUtils.getDescriptionEditor();
b69ab3165
b69ab3166 act(() => void fireMouseEvent('dragenter', textarea, 0, 0, {dataTransfer}));
b69ab3167 expect(document.querySelector('.hovering-to-drop')).not.toBeNull();
b69ab3168 act(() => void fireMouseEvent('dragleave', textarea, 0, 0, {dataTransfer}));
b69ab3169 expect(document.querySelector('.hovering-to-drop')).toBeNull();
b69ab3170 });
b69ab3171
b69ab3172 it('does not try to upload other things being dragged', () => {
b69ab3173 const textarea = CommitInfoTestUtils.getDescriptionEditor();
b69ab3174 act(() => {
b69ab3175 fireMouseEvent('dragenter', textarea, 0, 0, {
b69ab3176 dataTransfer: {
b69ab3177 files: [],
b69ab3178 items: [],
b69ab3179 } as unknown as DataTransfer,
b69ab3180 });
b69ab3181 }); // drag without files is ignored
b69ab3182 expect(document.querySelector('.hovering-to-drop')).toBeNull();
b69ab3183 });
b69ab3184
b69ab3185 it('lets you drag an image to upload it', async () => {
b69ab3186 const textarea = CommitInfoTestUtils.getDescriptionEditor();
b69ab3187 act(() => void fireMouseEvent('dragenter', textarea, 0, 0, {dataTransfer}));
b69ab3188 act(() => {
b69ab3189 fireMouseEvent('drop', textarea, 0, 0, {dataTransfer});
b69ab3190 });
b69ab3191
b69ab3192 await waitFor(() => {
b69ab3193 expectMessageSentToServer(expect.objectContaining({type: 'uploadFile'}));
b69ab3194 });
b69ab3195 });
b69ab3196 });
b69ab3197
b69ab3198 describe('Paste image to upload', () => {
b69ab3199 it('lets you paste an image to upload it', async () => {
b69ab31100 const textarea = CommitInfoTestUtils.getDescriptionEditor();
b69ab31101 act(() => {
b69ab31102 fireEvent.paste(textarea, {clipboardData: dataTransfer});
b69ab31103 });
b69ab31104 await waitFor(() => {
b69ab31105 expectMessageSentToServer(expect.objectContaining({type: 'uploadFile'}));
b69ab31106 });
b69ab31107 });
b69ab31108 it('pastes without images are handled normally', async () => {
b69ab31109 const textarea = CommitInfoTestUtils.getDescriptionEditor();
b69ab31110 act(() => void fireEvent.paste(textarea));
b69ab31111 await nextTick(); // allow file upload to await arrayBuffer()
b69ab31112 expectMessageNOTSentToServer(expect.objectContaining({type: 'uploadFile'}));
b69ab31113 });
b69ab31114 });
b69ab31115
b69ab31116 describe('file picker to upload file', () => {
b69ab31117 it('lets you pick a file to upload', async () => {
b69ab31118 const uploadButton = screen.getAllByTestId('attach-file-input')[0];
b69ab31119 act(() => {
b69ab31120 userEvent.upload(uploadButton, [mockFile]);
b69ab31121 });
b69ab31122
b69ab31123 await waitFor(() => {
b69ab31124 expectMessageSentToServer(expect.objectContaining({type: 'uploadFile'}));
b69ab31125 });
b69ab31126 });
b69ab31127 });
b69ab31128
b69ab31129 describe('Image upload UI', () => {
b69ab31130 async function startFileUpload() {
b69ab31131 // Get the previous upload message (if any) to avoid race conditions
b69ab31132 const previousMessage = getLastMessageOfTypeSentToServer('uploadFile');
b69ab31133 const previousId = previousMessage?.id;
b69ab31134
b69ab31135 const uploadButton = screen.getAllByTestId('attach-file-input')[0];
b69ab31136 act(() => void userEvent.upload(uploadButton, [mockFile]));
b69ab31137
b69ab31138 // Wait for a NEW upload message that's different from the previous one
b69ab31139 const message = await waitFor(() => {
b69ab31140 const latestMessage = utils.nullthrows(getLastMessageOfTypeSentToServer('uploadFile'));
b69ab31141 // If this is the first upload or the ID is different from the previous one, we're good
b69ab31142 if (!previousId || latestMessage.id !== previousId) {
b69ab31143 return latestMessage;
b69ab31144 }
b69ab31145 // Otherwise, throw to keep waiting
b69ab31146 throw new Error('Still waiting for new upload message');
b69ab31147 });
b69ab31148
b69ab31149 const id = message.id;
b69ab31150 expectMessageSentToServer(expect.objectContaining({type: 'uploadFile', id}));
b69ab31151 return id;
b69ab31152 }
b69ab31153
b69ab31154 async function simulateUploadSucceeded(id: string) {
b69ab31155 await act(async () => {
b69ab31156 simulateMessageFromServer({
b69ab31157 type: 'uploadFileResult',
b69ab31158 id,
b69ab31159 result: {value: `https://image.example.com/${id}`},
b69ab31160 });
b69ab31161 await nextTick();
b69ab31162 });
b69ab31163 }
b69ab31164
b69ab31165 async function simulateUploadFailed(id: string) {
b69ab31166 await act(async () => {
b69ab31167 simulateMessageFromServer({
b69ab31168 type: 'uploadFileResult',
b69ab31169 id,
b69ab31170 result: {error: new Error('upload failed')},
b69ab31171 });
b69ab31172 await nextTick();
b69ab31173 });
b69ab31174 }
b69ab31175
b69ab31176 const {descriptionTextContent, getDescriptionEditor} = CommitInfoTestUtils;
b69ab31177
b69ab31178 it('shows placeholder when uploading an image', async () => {
b69ab31179 expect(descriptionTextContent()).not.toContain('Uploading');
b69ab31180 await startFileUpload();
b69ab31181 expect(descriptionTextContent()).toContain('Uploading #1');
b69ab31182 });
b69ab31183
b69ab31184 it('sends a message to the server to upload the file', async () => {
b69ab31185 const id = await startFileUpload();
b69ab31186 expectMessageSentToServer({
b69ab31187 type: 'uploadFile',
b69ab31188 filename: 'file.png',
b69ab31189 id,
b69ab31190 b64Content: mockFileContentInBase64,
b69ab31191 });
b69ab31192 });
b69ab31193
b69ab31194 it('removes placeholder when upload succeeds', async () => {
b69ab31195 const id = await startFileUpload();
b69ab31196 expect(descriptionTextContent()).toContain('Uploading #1');
b69ab31197 await simulateUploadSucceeded(id);
b69ab31198 expect(descriptionTextContent()).not.toContain('Uploading #1');
b69ab31199 expect(descriptionTextContent()).toContain(`https://image.example.com/${id}`);
b69ab31200 });
b69ab31201
b69ab31202 it('removes placeholder when upload fails', async () => {
b69ab31203 const id = await startFileUpload();
b69ab31204 expect(descriptionTextContent()).toContain('Uploading #1');
b69ab31205 await simulateUploadFailed(id);
b69ab31206 expect(descriptionTextContent()).not.toContain('Uploading #1');
b69ab31207 expect(descriptionTextContent()).not.toContain('https://image.example.com');
b69ab31208 });
b69ab31209
b69ab31210 it('shows progress of ongoing uploads', async () => {
b69ab31211 await startFileUpload();
b69ab31212 expect(screen.getByText('Uploading 1 file')).toBeInTheDocument();
b69ab31213 });
b69ab31214
b69ab31215 it('click to cancel upload', async () => {
b69ab31216 await startFileUpload();
b69ab31217 expect(screen.getByText('Uploading 1 file')).toBeInTheDocument();
b69ab31218 act(() => {
b69ab31219 fireEvent.mouseOver(screen.getByText('Uploading 1 file'));
b69ab31220 });
b69ab31221 expect(screen.getByText('Click to cancel')).toBeInTheDocument();
b69ab31222 act(() => {
b69ab31223 fireEvent.click(screen.getByText('Click to cancel'));
b69ab31224 });
b69ab31225
b69ab31226 expect(descriptionTextContent()).not.toContain('Uploading #1');
b69ab31227 expect(screen.queryByText('Uploading 1 file')).not.toBeInTheDocument();
b69ab31228 });
b69ab31229
b69ab31230 it('clears hover state when cancelling', async () => {
b69ab31231 await startFileUpload();
b69ab31232 act(() => void fireEvent.mouseOver(screen.getByText('Uploading 1 file')));
b69ab31233 act(() => void fireEvent.click(screen.getByText('Click to cancel')));
b69ab31234 await startFileUpload();
b69ab31235 expect(screen.queryByText('Uploading 1 file')).toBeInTheDocument();
b69ab31236 });
b69ab31237
b69ab31238 it('shows upload errors', async () => {
b69ab31239 const id = await startFileUpload();
b69ab31240 await simulateUploadFailed(id);
b69ab31241 expect(screen.getByText('1 file upload failed')).toBeInTheDocument();
b69ab31242 fireEvent.click(screen.getByTestId('dismiss-upload-errors'));
b69ab31243 expect(screen.queryByText('1 file upload failed')).not.toBeInTheDocument();
b69ab31244 });
b69ab31245
b69ab31246 it('handles multiple placeholders', async () => {
b69ab31247 const id1 = await startFileUpload();
b69ab31248 expect(screen.getByText('Uploading 1 file')).toBeInTheDocument();
b69ab31249 const id2 = await startFileUpload();
b69ab31250 expect(screen.getByText('Uploading 2 files')).toBeInTheDocument();
b69ab31251 expect(id1).not.toEqual(id2);
b69ab31252
b69ab31253 expect(descriptionTextContent()).toContain('Uploading #1');
b69ab31254 expect(descriptionTextContent()).toContain('Uploading #2');
b69ab31255 await simulateUploadSucceeded(id1);
b69ab31256 expect(descriptionTextContent()).not.toContain('Uploading #1');
b69ab31257 expect(descriptionTextContent()).toContain('Uploading #2');
b69ab31258
b69ab31259 expect(descriptionTextContent()).toContain(`https://image.example.com/${id1}`);
b69ab31260 expect(descriptionTextContent()).not.toContain(`https://image.example.com/${id2}`);
b69ab31261
b69ab31262 await simulateUploadSucceeded(id2);
b69ab31263 expect(descriptionTextContent()).not.toContain('Uploading #2');
b69ab31264 expect(descriptionTextContent()).toContain(`https://image.example.com/${id2}`);
b69ab31265 });
b69ab31266
b69ab31267 it('inserts whitespace before inserted placeholder when necessary', async () => {
b69ab31268 act(() => {
b69ab31269 userEvent.type(getDescriptionEditor(), 'Hello!\n');
b69ab31270 // ^ cursor
b69ab31271 getDescriptionEditor().selectionStart = 6;
b69ab31272 getDescriptionEditor().selectionEnd = 6;
b69ab31273 });
b69ab31274 await startFileUpload();
b69ab31275 expect(descriptionTextContent()).toEqual('Hello! 【 Uploading #1 】\n');
b69ab31276 // ^ inserted space ^ no extra space
b69ab31277 });
b69ab31278
b69ab31279 it('inserts whitespace after inserted placeholder when necessary', async () => {
b69ab31280 act(() => {
b69ab31281 userEvent.type(getDescriptionEditor(), 'Hello!\n');
b69ab31282 // ^ cursor
b69ab31283 getDescriptionEditor().selectionStart = 0;
b69ab31284 getDescriptionEditor().selectionEnd = 0;
b69ab31285 });
b69ab31286 await startFileUpload();
b69ab31287 expect(descriptionTextContent()).toEqual('【 Uploading #1 】 Hello!\n');
b69ab31288 // ^ no space ^ inserted space
b69ab31289 });
b69ab31290
b69ab31291 it('preserves selection when setting placeholders', async () => {
b69ab31292 act(() => {
b69ab31293 userEvent.type(getDescriptionEditor(), 'Hello, world!\n');
b69ab31294 // ^-----^ selection
b69ab31295 getDescriptionEditor().selectionStart = 2;
b69ab31296 getDescriptionEditor().selectionEnd = 8;
b69ab31297 });
b69ab31298 await startFileUpload();
b69ab31299 expect(descriptionTextContent()).toEqual('He 【 Uploading #1 】 orld!\n');
b69ab31300 // ^ inserted spaces ^
b69ab31301
b69ab31302 // now cursor is after Uploading
b69ab31303 expect(getDescriptionEditor().selectionStart).toEqual(20);
b69ab31304 expect(getDescriptionEditor().selectionEnd).toEqual(20);
b69ab31305 });
b69ab31306
b69ab31307 it('preserves selection when replacing placeholders', async () => {
b69ab31308 act(() => {
b69ab31309 userEvent.type(getDescriptionEditor(), 'fob\nbar\nbaz');
b69ab31310 // ^ cursor
b69ab31311 getDescriptionEditor().selectionStart = 4;
b69ab31312 getDescriptionEditor().selectionEnd = 4;
b69ab31313 });
b69ab31314 const id = await startFileUpload();
b69ab31315 expect(descriptionTextContent()).toEqual('fob\n【 Uploading #1 】 bar\nbaz');
b69ab31316 // start new selection: ^--------------------------^
b69ab31317 getDescriptionEditor().selectionStart = 2;
b69ab31318 getDescriptionEditor().selectionEnd = 26;
b69ab31319 // make sure my indices are correct
b69ab31320 expect(descriptionTextContent()[getDescriptionEditor().selectionStart]).toEqual('b');
b69ab31321 expect(descriptionTextContent()[getDescriptionEditor().selectionEnd]).toEqual('a');
b69ab31322
b69ab31323 await simulateUploadSucceeded(id);
b69ab31324 expect(descriptionTextContent()).toEqual(`fob\nhttps://image.example.com/${id} bar\nbaz`);
b69ab31325 // selection is preserved: ^---------------------------------------^
b69ab31326
b69ab31327 // now cursor is after Uploading
b69ab31328 expect(getDescriptionEditor().selectionStart).toEqual(2);
b69ab31329 expect(getDescriptionEditor().selectionEnd).toEqual(36 + id.length);
b69ab31330 expect(descriptionTextContent()[getDescriptionEditor().selectionStart]).toEqual('b');
b69ab31331 expect(descriptionTextContent()[getDescriptionEditor().selectionEnd]).toEqual('a');
b69ab31332 });
b69ab31333
b69ab31334 describe('disable commit info view buttons while uploading', () => {
b69ab31335 beforeEach(() => {
b69ab31336 act(() => {
b69ab31337 simulateUncommittedChangedFiles({
b69ab31338 value: [{path: 'src/file1.js', status: 'M'}],
b69ab31339 });
b69ab31340 });
b69ab31341 });
b69ab31342
b69ab31343 it('disables amend message button', async () => {
b69ab31344 CommitInfoTestUtils.clickToSelectCommit('b');
b69ab31345 CommitInfoTestUtils.clickToEditDescription();
b69ab31346 expect(
b69ab31347 CommitInfoTestUtils.withinCommitActionBar().getByText('Amend Message'),
b69ab31348 ).not.toBeDisabled();
b69ab31349 const id = await startFileUpload();
b69ab31350 expect(
b69ab31351 CommitInfoTestUtils.withinCommitActionBar().getByText('Amend Message'),
b69ab31352 ).toBeDisabled();
b69ab31353 await simulateUploadSucceeded(id);
b69ab31354 expect(
b69ab31355 CommitInfoTestUtils.withinCommitActionBar().getByText('Amend Message'),
b69ab31356 ).not.toBeDisabled();
b69ab31357 });
b69ab31358
b69ab31359 it('disables amend button', async () => {
b69ab31360 expect(CommitInfoTestUtils.withinCommitActionBar().getByText('Amend')).not.toBeDisabled();
b69ab31361 const id = await startFileUpload();
b69ab31362 expect(CommitInfoTestUtils.withinCommitActionBar().getByText('Amend')).toBeDisabled();
b69ab31363 await simulateUploadSucceeded(id);
b69ab31364 expect(CommitInfoTestUtils.withinCommitActionBar().getByText('Amend')).not.toBeDisabled();
b69ab31365 });
b69ab31366
b69ab31367 it('disables commit button', async () => {
b69ab31368 CommitInfoTestUtils.clickCommitMode();
b69ab31369 expect(CommitInfoTestUtils.withinCommitActionBar().getByText('Commit')).not.toBeDisabled();
b69ab31370 const id = await startFileUpload();
b69ab31371 expect(CommitInfoTestUtils.withinCommitActionBar().getByText('Commit')).toBeDisabled();
b69ab31372 await simulateUploadSucceeded(id);
b69ab31373 expect(CommitInfoTestUtils.withinCommitActionBar().getByText('Commit')).not.toBeDisabled();
b69ab31374 });
b69ab31375
b69ab31376 it('disables commit and submit button', async () => {
b69ab31377 act(() => {
b69ab31378 simulateMessageFromServer({
b69ab31379 type: 'repoInfo',
b69ab31380 info: {
b69ab31381 codeReviewSystem: {type: 'github'} as CodeReviewSystem,
b69ab31382 command: 'sl',
b69ab31383 repoRoot: '/repo',
b69ab31384 dotdir: '/repo/.sl',
b69ab31385 type: 'success',
b69ab31386 pullRequestDomain: undefined,
b69ab31387 preferredSubmitCommand: undefined,
b69ab31388 isEdenFs: false,
b69ab31389 },
b69ab31390 });
b69ab31391 });
b69ab31392 // Get around internally-disabled button
b69ab31393 fireEvent.click(screen.getByTestId('settings-gear-button'));
b69ab31394 const enableSubmit = screen.queryByTestId('force-enable-github-submit');
b69ab31395 enableSubmit && fireEvent.click(enableSubmit);
b69ab31396 CommitInfoTestUtils.clickCommitMode();
b69ab31397 expect(
b69ab31398 CommitInfoTestUtils.withinCommitActionBar().getByText('Commit and Submit'),
b69ab31399 ).not.toBeDisabled();
b69ab31400 const id = await startFileUpload();
b69ab31401 expect(
b69ab31402 CommitInfoTestUtils.withinCommitActionBar().getByText('Commit and Submit'),
b69ab31403 ).toBeDisabled();
b69ab31404 await simulateUploadSucceeded(id);
b69ab31405 expect(
b69ab31406 CommitInfoTestUtils.withinCommitActionBar().getByText('Commit and Submit'),
b69ab31407 ).not.toBeDisabled();
b69ab31408 });
b69ab31409 });
b69ab31410
b69ab31411 it('emits uploads to underlying store', async () => {
b69ab31412 CommitInfoTestUtils.clickCommitMode();
b69ab31413 act(() => {
b69ab31414 simulateUncommittedChangedFiles({value: [{path: 'foo.txt', status: 'M'}]});
b69ab31415 });
b69ab31416 act(() => {
b69ab31417 userEvent.type(CommitInfoTestUtils.getTitleEditor(), 'hi');
b69ab31418 userEvent.type(CommitInfoTestUtils.getDescriptionEditor(), 'hey\n');
b69ab31419 });
b69ab31420 const id = await startFileUpload();
b69ab31421 await simulateUploadSucceeded(id);
b69ab31422 expect(descriptionTextContent()).toContain(`https://image.example.com/${id}`);
b69ab31423
b69ab31424 act(() => {
b69ab31425 fireEvent.click(CommitInfoTestUtils.withinCommitActionBar().getByText('Commit'));
b69ab31426 });
b69ab31427 await waitFor(() =>
b69ab31428 expectMessageSentToServer({
b69ab31429 type: 'runOperation',
b69ab31430 operation: expect.objectContaining({
b69ab31431 args: expect.arrayContaining([
b69ab31432 'commit',
b69ab31433 expect.stringMatching(`hi\n+(Summary:\n)?hey\nhttps://image.example.com/${id}`),
b69ab31434 ]),
b69ab31435 }),
b69ab31436 }),
b69ab31437 );
b69ab31438 });
b69ab31439 });
b69ab31440});