/*
* Copyright (C) 2019 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see .
*/
import {fireEvent, render, screen, waitFor} from '@testing-library/react'
import React from 'react'
import {vi} from 'vitest'
import saveMediaRecording, {saveClosedCaptionsForAttachment} from '../saveMediaRecording'
import UploadMedia from '../UploadMedia'
vi.mock('../saveMediaRecording', () => ({
default: vi.fn(),
saveClosedCaptionsForAttachment: vi.fn(),
}))
vi.mock('@instructure/studio-player', () => ({
StudioPlayer: vi.fn(() =>
),
}))
vi.mock('../MediaRecorder', () => ({
default: ({onSave}: {onSave: (file: File) => void}) => (
),
}))
const uploadMediaTranslations = {
UploadMediaStrings: {
ADD_CLOSED_CAPTIONS_OR_SUBTITLES: 'Add CC/Subtitles',
CLEAR_FILE_TEXT: 'Remove',
CLOSE_TEXT: 'Close',
CLOSED_CAPTIONS_CHOOSE_FILE: 'Choose caption file',
CLOSED_CAPTIONS_SELECT_LANGUAGE: 'Select Language',
COMPUTER_PANEL_TITLE: 'Computer',
DRAG_DROP_CLICK_TO_BROWSE: 'Drag and drop, or click to browse your computer',
DRAG_FILE_TEXT: 'Drag a file here',
INVALID_FILE_TEXT: 'Invalid File',
LOADING_MEDIA: 'Loading...',
MEDIA_RECORD_NOT_AVAILABLE: 'Recording is not available',
PROGRESS_LABEL: 'Uploading',
RECORD_PANEL_TITLE: 'Record',
SUBMIT_TEXT: 'Upload',
UPLOADING_ERROR: 'Upload Error',
UPLOAD_MEDIA_LABEL: 'Upload Media',
SELECT_SUPPORTED_FILE_TYPE: 'Please select a file of a supported type',
CHOOSE_FILE_TO_UPLOAD: 'Please choose a file',
ENTER_FILE_NAME: 'Please enter a file name',
NO_PREVIEW_AVAILABLE: 'No preview is available for this file.',
FILE_NAME: 'File name',
},
MediaCaptureStrings: {
ARIA_VIDEO_LABEL: 'Video Player',
},
SelectStrings: {
USE_ARROWS: 'Use arrow keys to navigate options.',
LIST_COLLAPSED: 'List collapsed.',
LIST_EXPANDED: 'List expanded.',
OPTION_SELECTED: '{option} selected.',
},
}
interface UploadMediaProps {
open: boolean
tabs: {record: boolean; upload: boolean}
onDismiss: () => void
onStartUpload?: (file: File) => void
onUploadComplete?: (err: unknown, data: unknown, captions?: unknown) => void
computerFile?: File | null
disableSubmitWhileUploading?: boolean
rcsConfig?: object
}
function renderModal(overrideProps: Partial = {}) {
return render(
{}}
uploadMediaTranslations={uploadMediaTranslations}
rcsConfig={{contextId: '1', contextType: 'course'}}
{...overrideProps}
/>,
)
}
function waitForFileDrop() {
return screen.findByLabelText(/Drag a file here/, {selector: 'input'})
}
function waitForFilePreview() {
return screen.findByPlaceholderText('File name')
}
const uploadedFile = () => vi.mocked(saveMediaRecording).mock.lastCall?.[0] as File
describe('UploadMediaModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('panel selection', () => {
it('selects the computer panel by default when both tabs are enabled', async () => {
renderModal({tabs: {record: true, upload: true}})
expect(await waitForFileDrop()).toBeInTheDocument()
expect(screen.getByRole('tab', {name: 'Computer'})).toHaveAttribute('aria-selected', 'true')
expect(screen.queryByText('Save recording')).not.toBeInTheDocument()
})
it('renders only the computer panel when the record tab is disabled', async () => {
renderModal({tabs: {record: false, upload: true}})
expect(await waitForFileDrop()).toBeInTheDocument()
expect(screen.queryByRole('tab', {name: 'Record'})).not.toBeInTheDocument()
expect(screen.queryByText('Save recording')).not.toBeInTheDocument()
})
it('renders only the record panel when the upload tab is disabled', async () => {
renderModal({tabs: {record: true, upload: false}})
expect(await screen.findByText('Save recording')).toBeInTheDocument()
expect(screen.queryByRole('tab', {name: 'Computer'})).not.toBeInTheDocument()
expect(
screen.queryByLabelText(/Drag a file here/, {selector: 'input'}),
).not.toBeInTheDocument()
})
it('renders no tabs or panels while closed', () => {
renderModal({open: false, tabs: {record: true, upload: true}})
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
expect(screen.queryByText('Save recording')).not.toBeInTheDocument()
expect(
screen.queryByLabelText(/Drag a file here/, {selector: 'input'}),
).not.toBeInTheDocument()
})
})
describe('submitting from the computer panel', () => {
it('does not upload when no file has been chosen', async () => {
renderModal()
await waitForFileDrop()
fireEvent.click(screen.getByText('Upload'))
expect(await screen.findByText('Please choose a file')).toBeVisible()
expect(saveMediaRecording).not.toHaveBeenCalled()
})
it('uploads the chosen file', async () => {
const aFile = new File(['foo'], 'foo.mp4', {type: 'video/mp4'})
const onStartUpload = vi.fn()
renderModal({computerFile: aFile, onStartUpload})
await waitForFilePreview()
fireEvent.click(screen.getByText('Upload'))
await waitFor(() => expect(saveMediaRecording).toHaveBeenCalled())
expect(uploadedFile().name).toBe('foo.mp4')
expect(onStartUpload).toHaveBeenCalledWith(uploadedFile())
})
it('appends the extension for the file type when the name has none', async () => {
const aFile = new File(['foo'], 'recording', {type: 'video/quicktime'})
renderModal({computerFile: aFile})
await waitForFilePreview()
fireEvent.click(screen.getByText('Upload'))
await waitFor(() => expect(saveMediaRecording).toHaveBeenCalled())
expect(uploadedFile().name).toBe('recording.mov')
})
it('falls back to webm when the file type maps to no extension', async () => {
const aFile = new File(['foo'], 'recording', {type: 'video/nonsense'})
renderModal({computerFile: aFile})
await waitForFilePreview()
fireEvent.click(screen.getByText('Upload'))
await waitFor(() => expect(saveMediaRecording).toHaveBeenCalled())
expect(uploadedFile().name).toBe('recording.webm')
})
it('appends the extension to the user-entered title as well', async () => {
const aFile = new File(['foo'], 'recording', {type: 'video/mp4'})
renderModal({computerFile: aFile})
await waitForFilePreview()
fireEvent.change(screen.getByPlaceholderText('File name'), {
target: {value: 'My holiday video'},
})
fireEvent.click(screen.getByText('Upload'))
await waitFor(() => expect(saveMediaRecording).toHaveBeenCalled())
expect(uploadedFile().userEnteredTitle).toBe('My holiday video.mp4')
})
it('leaves a user-entered title that already has an extension alone', async () => {
const aFile = new File(['foo'], 'foo.mp4', {type: 'video/mp4'})
renderModal({computerFile: aFile})
await waitForFilePreview()
fireEvent.change(screen.getByPlaceholderText('File name'), {target: {value: 'holiday.mp4'}})
fireEvent.click(screen.getByText('Upload'))
await waitFor(() => expect(saveMediaRecording).toHaveBeenCalled())
expect(uploadedFile().userEnteredTitle).toBe('holiday.mp4')
})
})
describe('upload completion', () => {
const mediaObject = {mediaObject: {media_object: {attachment_id: '7'}}}
async function submitAndCapture(overrideProps: Partial = {}) {
const aFile = new File(['foo'], 'foo.mp4', {type: 'video/mp4'})
renderModal({computerFile: aFile, ...overrideProps})
await waitForFilePreview()
fireEvent.click(screen.getByText('Upload'))
await waitFor(() => expect(saveMediaRecording).toHaveBeenCalled())
return vi.mocked(saveMediaRecording).mock.lastCall?.[2] as (
err: unknown,
data: unknown,
) => Promise
}
it('reports the failure and dismisses when the upload errors', async () => {
const onUploadComplete = vi.fn()
const onDismiss = vi.fn()
const uploadFinished = await submitAndCapture({onUploadComplete, onDismiss})
const err = new Error('kaboom')
await uploadFinished(err, null)
expect(onUploadComplete).toHaveBeenCalledWith(err, null)
expect(onDismiss).toHaveBeenCalled()
})
it('reports the media object and dismisses when the upload succeeds', async () => {
const onUploadComplete = vi.fn()
const onDismiss = vi.fn()
const uploadFinished = await submitAndCapture({onUploadComplete, onDismiss})
await uploadFinished(null, mediaObject)
expect(onUploadComplete).toHaveBeenCalledWith(null, mediaObject, undefined)
expect(onDismiss).toHaveBeenCalled()
expect(saveClosedCaptionsForAttachment).not.toHaveBeenCalled()
})
it('reports a malformed response through onUploadComplete', async () => {
const onUploadComplete = vi.fn()
const uploadFinished = await submitAndCapture({onUploadComplete})
await uploadFinished(null, {mediaObject: null})
expect(onUploadComplete).toHaveBeenCalledWith(expect.any(Error), null)
})
})
describe('footer', () => {
it('dismisses when close is clicked', async () => {
const onDismiss = vi.fn()
renderModal({onDismiss})
await waitForFileDrop()
const [, footerClose] = screen.getAllByRole('button', {name: 'Close'})
fireEvent.click(footerClose)
expect(onDismiss).toHaveBeenCalled()
})
it('disables the submit button while uploading when asked to', async () => {
const aFile = new File(['foo'], 'foo.mp4', {type: 'video/mp4'})
renderModal({computerFile: aFile, disableSubmitWhileUploading: true})
await waitForFilePreview()
const submit = screen.getByText('Upload').closest('button') as HTMLButtonElement
expect(submit).not.toBeDisabled()
fireEvent.click(submit)
await waitFor(() => expect(saveMediaRecording).toHaveBeenCalled())
expect(screen.getByText('Upload').closest('button')).toBeDisabled()
})
it('leaves the submit button enabled while uploading by default', async () => {
const aFile = new File(['foo'], 'foo.mp4', {type: 'video/mp4'})
renderModal({computerFile: aFile})
await waitForFilePreview()
fireEvent.click(screen.getByText('Upload').closest('button') as HTMLButtonElement)
await waitFor(() => expect(saveMediaRecording).toHaveBeenCalled())
expect(screen.getByText('Upload').closest('button')).not.toBeDisabled()
})
it('shows upload progress', async () => {
const aFile = new File(['foo'], 'foo.mp4', {type: 'video/mp4'})
renderModal({computerFile: aFile})
await waitForFilePreview()
fireEvent.click(screen.getByText('Upload'))
await waitFor(() => expect(saveMediaRecording).toHaveBeenCalled())
const onProgress = vi.mocked(saveMediaRecording).mock.lastCall?.[3] as (n: number) => void
onProgress(42)
expect(await screen.findByText('42%')).toBeInTheDocument()
})
})
describe('submitting from the record panel', () => {
it('uploads the recording as soon as it is saved', async () => {
renderModal({tabs: {record: true, upload: false}})
fireEvent.click(await screen.findByText('Save recording'))
await waitFor(() => expect(saveMediaRecording).toHaveBeenCalled())
expect(uploadedFile().name).toBe('recording.mp4')
})
})
})