import React from 'react'; import {act, fireEvent, screen, waitFor} from '@testing-library/react'; import {renderWithProviders} from '../test-utils'; import { audioCodecOptionsFor, chooseCodecs, containerFor, currentCodecs, currentContainer, EditVideoModal, JobProgress, preferredTarget, TranscodeModal, useJob, VideoEditMenu, videoCodecOptionsFor, } from './VideoEditMenu'; import { audioContainerForCodec, TRANSCODE_COPY, TRANSCODE_REMOVE_AUDIO, TRANSCODE_REMOVE_VIDEO, transcodeAudioCodecOptions, transcodeVideoCodecOptions, } from './Vars'; jest.mock('../api', () => ({ cancelJob: jest.fn(), fetchVideoDownloadDefaults: jest.fn(), getJob: jest.fn(), transcodeVideo: jest.fn(), updateVideo: jest.fn(), })); import {cancelJob, fetchVideoDownloadDefaults, getJob, transcodeVideo, updateVideo} from '../api'; // The ui index re-exports `toast` from here; a spy lets tests see what the page tells the user. jest.mock('./ui/toast', () => ({...jest.requireActual('./ui/toast'), toast: jest.fn()})); import {toast} from './ui/toast'; const video = { codec_names: ['vp9', 'mjpeg', 'opus'], codec_types: ['video', 'video', 'audio'], video_path: 'videos/movie.webm', }; const videoFile = {id: 7, url: 'https://example.com/watch?v=1', mimetype: 'video/webm', title: 'Old Title', video}; const audio = {codec_names: ['aac'], codec_types: ['audio'], video_path: 'videos/song.m4a'}; const audioFile = {id: 8, url: null, mimetype: 'audio/mp4', title: 'Song', video: audio}; // Mantine's Select renders a hidden input next to the visible one, both tied to the label. const selectInput = (label) => screen.getAllByLabelText(label).find(i => i.type !== 'hidden'); // `renderWithProviders`' `settings` option replaces the SettingsContext value; the WROL flag lives // one level down in its `settings`. const wrolMode = {settings: {settings: {wrol_mode: true}}}; const pendingJob = { id: 'transcode_video-abc', status: 'pending', progress: null, log: [], error: null, cancel_requested: false, description: 'Transcode movie.webm', }; const runningJob = {...pendingJob, status: 'running', progress: 40, log: ['out_time_us=1']}; const completeJob = {...pendingJob, status: 'complete', progress: 100, log: ['done']}; const failedJob = {...pendingJob, status: 'failed', progress: 12, error: 'ffmpeg exited with 1', log: ['boom']}; const notFound = Object.assign(new Error('Job does not exist'), {status: 404}); describe('helpers', () => { test('currentCodecs ignores embedded thumbnail streams', () => { expect(currentCodecs(video)).toEqual({video: ['vp9'], audio: ['opus']}); expect(currentCodecs(null)).toEqual({video: [], audio: []}); }); test('currentContainer reads the file suffix, only for containers we can write', () => { expect(currentContainer({video_path: 'videos/a/movie.MP4'})).toBe('mp4'); expect(currentContainer({video_path: 'videos/a/movie.mkv'})).toBe('mkv'); expect(currentContainer({video_path: 'videos/a/song.m4a'})).toBe('m4a'); expect(currentContainer({video_path: 'videos/a/movie.webm'})).toBeNull(); expect(currentContainer(null)).toBeNull(); }); test('audioContainerForCodec mirrors the backend default', () => { expect(audioContainerForCodec('aac')).toBe('m4a'); expect(audioContainerForCodec('mp3')).toBe('mp3'); expect(audioContainerForCodec('opus')).toBe('ogg'); expect(audioContainerForCodec('vorbis')).toBe('ogg'); expect(audioContainerForCodec(undefined)).toBe('m4a'); }); test('containerFor follows the audio codec for audio output, the file for video output', () => { // Removing the video from a vp9/opus webm, copying the audio: opus lives in ogg. expect(containerFor({audioOutput: true, audioCodec: TRANSCODE_COPY, video})).toBe('ogg'); expect(containerFor({audioOutput: true, audioCodec: 'aac', video})).toBe('m4a'); expect(containerFor({audioOutput: true, audioCodec: 'mp3', video})).toBe('mp3'); // Video output keeps a container we can write, else mp4. expect(containerFor({audioOutput: false, audioCodec: 'aac', video})).toBe('mp4'); expect(containerFor({audioOutput: false, audioCodec: 'aac', video: {video_path: 'a.mkv'}})).toBe('mkv'); // An audio file's own suffix is not a video container. expect(containerFor({audioOutput: false, audioCodec: TRANSCODE_COPY, video: audio})).toBe('mp4'); }); test('preferredTarget picks the first preference ffmpeg can produce', () => { expect(preferredTarget(['vp9', 'h264'], transcodeVideoCodecOptions)).toBe('h264'); expect(preferredTarget(['vp9', 'av1'], transcodeVideoCodecOptions)).toBe(TRANSCODE_COPY); expect(preferredTarget(['opus', 'aac'], transcodeAudioCodecOptions)).toBe('opus'); expect(preferredTarget([], transcodeAudioCodecOptions)).toBe(TRANSCODE_COPY); expect(preferredTarget(undefined, transcodeAudioCodecOptions)).toBe(TRANSCODE_COPY); }); }); describe('TranscodeModal', () => { beforeEach(() => { jest.clearAllMocks(); fetchVideoDownloadDefaults.mockResolvedValue({video_codecs: ['h264'], audio_codecs: ['vorbis', 'aac']}); transcodeVideo.mockResolvedValue('transcode_video-abc'); }); test('pre-populates from the preferred codecs and queues the Job', async () => { const onQueued = jest.fn(); renderWithProviders( , ); expect(screen.getByText(/Current codecs: video vp9, audio opus/)).toBeInTheDocument(); // The defaults arrive: h264 is producible, vorbis is not, so aac is the audio target. await waitFor(() => expect(selectInput('Video codec')).toHaveValue('h264 (avc1)')); expect(selectInput('Audio codec')).toHaveValue('aac'); expect(selectInput('Container')).toHaveValue('mp4'); fireEvent.click(screen.getByRole('button', {name: /Transcode/})); await waitFor(() => expect(transcodeVideo).toHaveBeenCalledWith(7, { video_codec: 'h264', audio_codec: 'aac', container: 'mp4', fragmented: false, })); await waitFor(() => expect(onQueued).toHaveBeenCalledWith('transcode_video-abc')); }); test('shows the Job instead of the form once one is queued', () => { renderWithProviders( , ); expect(screen.queryAllByLabelText('Video codec')).toHaveLength(0); expect(screen.getByText('running')).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Close'})).toBeInTheDocument(); expect(screen.queryByRole('button', {name: /Transcode/})).not.toBeInTheDocument(); }); test('keeping every stream is a remux into the chosen container', async () => { fetchVideoDownloadDefaults.mockResolvedValue({video_codecs: [], audio_codecs: []}); // A webm cannot be kept (not a container we write), so the select starts at mp4: a change. renderWithProviders( , ); await waitFor(() => expect(selectInput('Container')).toHaveValue('mp4')); expect(await screen.findByText(/only the container changes to mp4/)).toBeInTheDocument(); const button = screen.getByRole('button', {name: 'Remux'}); expect(button).toBeEnabled(); fireEvent.click(button); await waitFor(() => expect(transcodeVideo).toHaveBeenCalledWith(7, { video_codec: null, audio_codec: null, container: 'mp4', fragmented: false, })); }); test('Fragmented is offered for mp4 and sent with the request', async () => { fetchVideoDownloadDefaults.mockResolvedValue({video_codecs: [], audio_codecs: []}); const mp4 = {...video, video_path: 'videos/movie.mp4'}; renderWithProviders( , ); await waitFor(() => expect(selectInput('Container')).toHaveValue('mp4')); const toggle = screen.getByRole('switch', {name: /Fragmented/}); expect(toggle).not.toBeChecked(); fireEvent.click(toggle); expect(await screen.findByText(/rewritten as-is in fragments/)).toBeInTheDocument(); expect(screen.queryByText(/fast start/)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', {name: 'Remux'})); await waitFor(() => expect(transcodeVideo).toHaveBeenCalledWith(7, { video_codec: null, audio_codec: null, container: 'mp4', fragmented: true, })); }); test('Fragmented is not offered for mkv', async () => { fetchVideoDownloadDefaults.mockResolvedValue({video_codecs: [], audio_codecs: []}); const mkv = {...video, video_path: 'videos/movie.mkv'}; renderWithProviders(); await waitFor(() => expect(selectInput('Container')).toHaveValue('mkv')); expect(screen.queryByRole('switch', {name: /Fragmented/})).not.toBeInTheDocument(); }); test('a remux into the same container is described as a fast-start rewrite', async () => { fetchVideoDownloadDefaults.mockResolvedValue({video_codecs: [], audio_codecs: []}); const mkv = {...video, video_path: 'videos/movie.mkv'}; renderWithProviders(); await waitFor(() => expect(selectInput('Container')).toHaveValue('mkv')); // mkv has no fast start; the hint must not promise one. expect(await screen.findByText(/Both streams are kept: the file is rewritten as-is\./)).toBeInTheDocument(); expect(screen.queryByText(/fast start/)).not.toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Remux'})).toBeEnabled(); }); test('an audio file offers audio codecs and containers only', async () => { fetchVideoDownloadDefaults.mockResolvedValue({video_codecs: ['h264'], audio_codecs: []}); renderWithProviders( , ); expect(screen.getByText('Transcode Audio')).toBeInTheDocument(); expect(screen.getByText(/Current codec: audio aac/)).toBeInTheDocument(); // The aac source, copied, belongs in m4a; the file already is one. await waitFor(() => expect(selectInput('Container')).toHaveValue('m4a (aac)')); expect(screen.queryAllByLabelText('Video codec')).toHaveLength(0); expect(await screen.findByText(/The audio is kept: the file is rewritten as-is with fast start/)) .toBeInTheDocument(); expect(screen.queryByText(/Both streams/)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', {name: 'Remux'})); // No video codec is sent for an audio file, whatever the video preferences say. await waitFor(() => expect(transcodeVideo).toHaveBeenCalledWith(8, { video_codec: null, audio_codec: null, container: 'm4a', fragmented: false, })); }); test('the Remove video option is offered for videos', async () => { renderWithProviders( , ); await waitFor(() => expect(selectInput('Video codec')).toHaveValue('h264 (avc1)')); expect(transcodeVideoCodecOptions.some(i => i.value === TRANSCODE_REMOVE_VIDEO)).toBe(true); // Selecting it is exercised through containerFor (Mantine's dropdown is not driveable in jsdom). }); test('the Remove audio option is offered for videos, not audio files, and never with Remove video', () => { expect(transcodeAudioCodecOptions.some(i => i.value === TRANSCODE_REMOVE_AUDIO)).toBe(true); const has = (options, value) => options.some(i => i.value === value); expect(has(audioCodecOptionsFor({audioOnly: false, removeVideo: false}), TRANSCODE_REMOVE_AUDIO)).toBe(true); expect(has(audioCodecOptionsFor({audioOnly: true, removeVideo: false}), TRANSCODE_REMOVE_AUDIO)).toBe(false); expect(has(audioCodecOptionsFor({audioOnly: false, removeVideo: true}), TRANSCODE_REMOVE_AUDIO)).toBe(false); // The real codecs and Keep are never withdrawn. expect(audioCodecOptionsFor({audioOnly: true, removeVideo: false}).map(i => i.value)) .toEqual([TRANSCODE_COPY, 'aac', 'opus', 'mp3']); expect(has(videoCodecOptionsFor({removeAudio: false}), TRANSCODE_REMOVE_VIDEO)).toBe(true); expect(has(videoCodecOptionsFor({removeAudio: true}), TRANSCODE_REMOVE_VIDEO)).toBe(false); expect(has(videoCodecOptionsFor({removeAudio: true}), 'h264')).toBe(true); }); test('Remove video and Remove audio can never be chosen together', () => { const keepBoth = {videoCodec: TRANSCODE_COPY, audioCodec: TRANSCODE_COPY}; // Removing one stream after the other was chosen puts the other back to Keep. expect(chooseCodecs({videoCodec: TRANSCODE_REMOVE_VIDEO, audioCodec: 'aac'}, {audio: TRANSCODE_REMOVE_AUDIO})) .toEqual({videoCodec: TRANSCODE_COPY, audioCodec: TRANSCODE_REMOVE_AUDIO}); expect(chooseCodecs({videoCodec: 'h264', audioCodec: TRANSCODE_REMOVE_AUDIO}, {video: TRANSCODE_REMOVE_VIDEO})) .toEqual({videoCodec: TRANSCODE_REMOVE_VIDEO, audioCodec: TRANSCODE_COPY}); // Ordinary choices are untouched; a cleared select means Keep. expect(chooseCodecs(keepBoth, {video: 'h264'})).toEqual({videoCodec: 'h264', audioCodec: TRANSCODE_COPY}); expect(chooseCodecs({videoCodec: 'h264', audioCodec: 'aac'}, {audio: null})) .toEqual({videoCodec: 'h264', audioCodec: TRANSCODE_COPY}); expect(chooseCodecs(keepBoth, {audio: TRANSCODE_REMOVE_AUDIO})) .toEqual({videoCodec: TRANSCODE_COPY, audioCodec: TRANSCODE_REMOVE_AUDIO}); }); test('WROL Mode disables starting a transcode', async () => { renderWithProviders( , wrolMode, ); await waitFor(() => expect(selectInput('Video codec')).toHaveValue('h264 (avc1)')); expect(screen.getByRole('button', {name: /Transcode/})).toBeDisabled(); }); }); describe('EditVideoModal', () => { beforeEach(() => { jest.clearAllMocks(); updateVideo.mockResolvedValue({...videoFile, title: 'New Title'}); }); test('starts from the current title and saves a changed one', async () => { const onSaved = jest.fn().mockResolvedValue(undefined); const onClose = jest.fn(); renderWithProviders( , ); const input = screen.getByLabelText('Title'); expect(input).toHaveValue('Old Title'); expect(screen.getByLabelText('Description')).toHaveValue('Old words'); // Nothing changed yet: Save is disabled. expect(screen.getByRole('button', {name: /Save/})).toBeDisabled(); fireEvent.change(input, {target: {value: ' New Title '}}); fireEvent.click(screen.getByRole('button', {name: /Save/})); // Only the changed field is sent. await waitFor(() => expect(updateVideo).toHaveBeenCalledWith(7, {title: 'New Title'})); await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); expect(onClose).toHaveBeenCalledTimes(1); }); test('saves a changed description on its own, including clearing it', async () => { renderWithProviders( , ); fireEvent.change(screen.getByLabelText('Description'), {target: {value: ''}}); fireEvent.click(screen.getByRole('button', {name: /Save/})); await waitFor(() => expect(updateVideo).toHaveBeenCalledWith(7, {description: ''})); }); test('a draft typed while the description was still loading survives its arrival', () => { // The page passes null while the description is pending, then '' when the server has none. const {rerender} = renderWithProviders( , ); fireEvent.change(screen.getByLabelText('Description'), {target: {value: 'my new words'}}); rerender(); expect(screen.getByLabelText('Description')).toHaveValue('my new words'); }); test('a description that arrives while the field is untouched is adopted', () => { const {rerender} = renderWithProviders( , ); expect(screen.getByLabelText('Description')).toHaveValue(''); rerender(); expect(screen.getByLabelText('Description')).toHaveValue('From the server'); }); test('WROL Mode disables saving', async () => { renderWithProviders( , wrolMode, ); fireEvent.change(screen.getByLabelText('Title'), {target: {value: 'Changed'}}); expect(screen.getByRole('button', {name: /Save/})).toBeDisabled(); expect(screen.getByText(/WROL Mode/)).toBeInTheDocument(); }); test('an empty title cannot be saved', () => { renderWithProviders(); fireEvent.change(screen.getByLabelText('Title'), {target: {value: ' '}}); expect(screen.getByText('A title is required')).toBeInTheDocument(); expect(screen.getByRole('button', {name: /Save/})).toBeDisabled(); expect(updateVideo).not.toHaveBeenCalled(); }); test('a failed save keeps the modal open', async () => { updateVideo.mockRejectedValue(new Error('boom')); const onClose = jest.fn(); renderWithProviders(); fireEvent.change(screen.getByLabelText('Title'), {target: {value: 'Other'}}); fireEvent.click(screen.getByRole('button', {name: /Save/})); await waitFor(() => expect(updateVideo).toHaveBeenCalled()); expect(onClose).not.toHaveBeenCalled(); expect(screen.getByLabelText('Title')).toHaveValue('Other'); }); }); // A tiny harness so the hook can be driven like the menu drives it. function JobHarness({jobId, onFinished, onLost}) { const {job, cancel} = useJob(jobId, onFinished, onLost); return ; } // Let pending promise callbacks run (a settled request schedules its next poll), then advance // the fake clock past the poll interval. const nextPoll = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); jest.advanceTimersByTime(2000); }); }; describe('useJob + JobProgress', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); test('polls until the Job finishes, then reports once', async () => { const onFinished = jest.fn(); getJob.mockResolvedValueOnce(runningJob).mockResolvedValueOnce(completeJob); renderWithProviders(); await waitFor(() => expect(screen.getByText('running')).toBeInTheDocument()); expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '40'); expect(screen.getByTestId('job-log')).toHaveTextContent('out_time_us=1'); expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument(); await act(async () => { jest.advanceTimersByTime(2000); }); await waitFor(() => expect(screen.getByText('complete')).toBeInTheDocument()); expect(onFinished).toHaveBeenCalledTimes(1); expect(onFinished.mock.calls[0][0].status).toBe('complete'); // A finished Job is not polled again, and cannot be cancelled. expect(getJob).toHaveBeenCalledTimes(2); expect(screen.queryByRole('button', {name: 'Cancel'})).not.toBeInTheDocument(); }); test('a failed request is retried instead of ending the polling', async () => { getJob .mockRejectedValueOnce(new Error('network')) .mockResolvedValueOnce(runningJob) .mockRejectedValueOnce(new Error('network')) .mockResolvedValueOnce(completeJob); const onFinished = jest.fn(); renderWithProviders(); // First poll failed: still "Starting...", but another poll is scheduled. await waitFor(() => expect(getJob).toHaveBeenCalledTimes(1)); expect(screen.getByText('Starting...')).toBeInTheDocument(); await nextPoll(); await waitFor(() => expect(screen.getByText('running')).toBeInTheDocument()); // A failure mid-way keeps the last good state and keeps polling. await nextPoll(); await waitFor(() => expect(getJob).toHaveBeenCalledTimes(3)); expect(screen.getByText('running')).toBeInTheDocument(); await nextPoll(); await waitFor(() => expect(screen.getByText('complete')).toBeInTheDocument()); expect(onFinished).toHaveBeenCalledTimes(1); }); test('a 404 means the API forgot the Job: give up at once', async () => { getJob.mockRejectedValue(notFound); const onLost = jest.fn(); const onFinished = jest.fn(); renderWithProviders(); await waitFor(() => expect(onLost).toHaveBeenCalledTimes(1)); expect(onLost.mock.calls[0][0].status).toBe(404); await nextPoll(); expect(getJob).toHaveBeenCalledTimes(1); expect(onFinished).not.toHaveBeenCalled(); }); test('repeated failures give up after a few polls', async () => { getJob.mockRejectedValue(new Error('network')); const onLost = jest.fn(); renderWithProviders(); for (let i = 0; i < 6; i++) { await nextPoll(); } await waitFor(() => expect(onLost).toHaveBeenCalledTimes(1)); expect(getJob).toHaveBeenCalledTimes(5); }); test('Cancel asks the API to cancel the Job', async () => { getJob.mockResolvedValue({...pendingJob, status: 'running', progress: 10}); cancelJob.mockResolvedValue({...pendingJob, status: 'running', progress: 10, cancel_requested: true}); renderWithProviders(); await waitFor(() => expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument()); fireEvent.click(screen.getByRole('button', {name: 'Cancel'})); await waitFor(() => expect(cancelJob).toHaveBeenCalledWith('transcode_video-abc')); await waitFor(() => expect(screen.getByRole('button', {name: 'Cancelling...'})).toBeDisabled()); }); test('shows the error of a failed Job', async () => { getJob.mockResolvedValue({...pendingJob, status: 'failed', error: 'ffmpeg exited with 1', log: ['boom']}); renderWithProviders(); await waitFor(() => expect(screen.getByText('failed')).toBeInTheDocument()); expect(screen.getByText('ffmpeg exited with 1')).toBeInTheDocument(); }); }); describe('VideoEditMenu', () => { beforeEach(() => { jest.clearAllMocks(); fetchVideoDownloadDefaults.mockResolvedValue({video_codecs: ['h264'], audio_codecs: ['aac']}); transcodeVideo.mockResolvedValue('transcode_video-abc'); }); // Mantine's Menu in jsdom: after a modal has closed, the first click on the target can land // while the dropdown still counts as open (and so toggles it shut). Click until items show. const openMenu = async () => { for (let attempt = 0; attempt < 3; attempt++) { fireEvent.click(screen.getByRole('button', {name: /Edit/})); try { return await screen.findByRole('menuitem', {name: /Refresh/}, {timeout: 300}); } catch (e) { // Toggled shut; try again. } } throw new Error('The Edit menu did not open'); }; const openMenuItem = async (name) => { await openMenu(); fireEvent.click(screen.getByRole('menuitem', {name})); }; test('Refresh calls onRefresh; Transcode opens the modal', async () => { const onRefresh = jest.fn().mockResolvedValue(undefined); renderWithProviders(); await openMenuItem(/Refresh/); await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1)); await openMenuItem(/Transcode/); await waitFor(() => expect(screen.getByText('Transcode Video')).toBeInTheDocument()); }); test('Edit opens the details modal', async () => { renderWithProviders(); await openMenuItem(/^Edit/); expect(await screen.findByText('Edit Video')).toBeInTheDocument(); expect(screen.getByLabelText('Title')).toHaveValue('Old Title'); }); test('Refresh is unavailable without a URL; audio files can be transcoded', async () => { renderWithProviders(); fireEvent.click(screen.getByRole('button', {name: /Edit/})); const refresh = await screen.findByRole('menuitem', {name: /Refresh/}); expect(refresh).toBeDisabled(); expect(screen.getByRole('menuitem', {name: /Transcode/})).toBeEnabled(); fireEvent.click(screen.getByRole('menuitem', {name: /Transcode/})); expect(await screen.findByText('Transcode Audio')).toBeInTheDocument(); }); test('Transcode is unavailable for files that are neither video nor audio', async () => { const other = {...videoFile, mimetype: 'application/pdf'}; renderWithProviders(); fireEvent.click(screen.getByRole('button', {name: /Edit/})); expect(await screen.findByRole('menuitem', {name: /Transcode/})).toBeDisabled(); }); test('WROL Mode disables every item', async () => { renderWithProviders( , wrolMode, ); fireEvent.click(screen.getByRole('button', {name: /Edit/})); expect(await screen.findByRole('menuitem', {name: /Refresh/})).toBeDisabled(); expect(screen.getByRole('menuitem', {name: /Transcode/})).toBeDisabled(); expect(screen.getByRole('menuitem', {name: /Delete/})).toBeDisabled(); expect(screen.getByRole('menuitem', {name: /^Edit/})).toBeDisabled(); }); test('Delete asks for confirmation before calling onDelete', async () => { const onDelete = jest.fn().mockResolvedValue(undefined); renderWithProviders( , ); await openMenuItem(/Delete/); expect(await screen.findByText('Delete video?')).toBeInTheDocument(); expect(onDelete).not.toHaveBeenCalled(); fireEvent.click(await screen.findByRole('button', {name: 'Delete'})); await waitFor(() => expect(onDelete).toHaveBeenCalledTimes(1)); }); test('backing out of the Delete confirmation deletes nothing', async () => { const onDelete = jest.fn(); renderWithProviders( , ); await openMenuItem(/Delete/); expect(await screen.findByText('Delete video?')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', {name: 'Cancel'})); await waitFor(() => expect(screen.queryByText('Delete video?')).not.toBeInTheDocument()); expect(onDelete).not.toHaveBeenCalled(); }); test('closing the modal keeps watching the Job and reports completion to the page', async () => { jest.useFakeTimers(); try { getJob.mockResolvedValueOnce(runningJob).mockResolvedValueOnce(completeJob); const onTranscodeComplete = jest.fn(); renderWithProviders( , ); await openMenuItem(/Transcode/); await waitFor(() => expect(selectInput('Video codec')).toHaveValue('h264 (avc1)')); fireEvent.click(screen.getByRole('button', {name: 'Transcode'})); await waitFor(() => expect(screen.getByText('running')).toBeInTheDocument()); // Close while it runs. The modal is gone but the Job is still watched. fireEvent.click(screen.getByRole('button', {name: 'Close'})); await waitFor(() => expect(screen.queryByText('Transcode Video')).not.toBeInTheDocument()); await nextPoll(); await waitFor(() => expect(onTranscodeComplete).toHaveBeenCalledTimes(1)); expect(onTranscodeComplete.mock.calls[0][0].status).toBe('complete'); expect(getJob).toHaveBeenCalledTimes(2); } finally { jest.useRealTimers(); } }); test('while a Job runs, Transcode shows its progress and Delete waits', async () => { getJob.mockResolvedValue(runningJob); renderWithProviders( , ); await waitFor(() => expect(getJob).toHaveBeenCalled()); await openMenu(); expect(screen.getByRole('menuitem', {name: 'Transcoding...'})).toBeEnabled(); expect(screen.getByRole('menuitem', {name: /Delete/})).toBeDisabled(); fireEvent.click(screen.getByRole('menuitem', {name: 'Transcoding...'})); expect(await screen.findByText('running')).toBeInTheDocument(); expect(screen.queryAllByLabelText('Video codec')).toHaveLength(0); expect(transcodeVideo).not.toHaveBeenCalled(); }); test('a Job that fails after the modal was closed still tells the user', async () => { getJob.mockResolvedValue(failedJob); renderWithProviders( , ); await waitFor(() => expect(toast).toHaveBeenCalledTimes(1)); expect(toast.mock.calls[0][0]).toMatchObject({type: 'error', title: 'Transcode failed'}); expect(toast.mock.calls[0][0].description).toMatch(/ffmpeg exited with 1/); expect(toast.mock.calls[0][0].description).toMatch(/original file was kept/); // Forgotten: the menu is unlocked again. await openMenu(); expect(screen.getByRole('menuitem', {name: 'Transcode...'})).toBeEnabled(); expect(screen.getByRole('menuitem', {name: /Delete/})).toBeEnabled(); }); test('a Job the API no longer knows unlocks the menu with a warning', async () => { getJob.mockRejectedValue(notFound); renderWithProviders( , ); await waitFor(() => expect(toast).toHaveBeenCalledTimes(1)); expect(toast.mock.calls[0][0]).toMatchObject({type: 'warning', title: 'Lost track of the transcode'}); await openMenu(); expect(screen.getByRole('menuitem', {name: 'Transcode...'})).toBeEnabled(); expect(screen.getByRole('menuitem', {name: /Delete/})).toBeEnabled(); }); test('a Job that finished unwatched is forgotten, so the next open offers the form', async () => { getJob.mockResolvedValue(completeJob); renderWithProviders( , ); await waitFor(() => expect(getJob).toHaveBeenCalled()); await openMenu(); expect(await screen.findByRole('menuitem', {name: 'Transcode...'})).toBeEnabled(); expect(screen.getByRole('menuitem', {name: /Delete/})).toBeEnabled(); fireEvent.click(screen.getByRole('menuitem', {name: 'Transcode...'})); await waitFor(() => expect(selectInput('Video codec')).toBeInTheDocument()); }); });