/* * Copyright (C) 2025 - 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 {HttpResponse, http} from 'msw' import {setupServer} from 'msw/node' import {vi} from 'vitest' import {ClosedCaptionPanelV2} from '../ClosedCaptionPanelV2' import {defaultClosedCaptionPanelTranslations} from '../translations' import type {Subtitle} from '../types' const server = setupServer() const LIVE_REGION_ID = 'flash_screenreader_holder' const TEST_UPLOAD_CONFIG = { mediaObjectId: 'media123', origin: 'http://localhost:3000', headers: {Authorization: 'Bearer token'}, } function createValidFile(name = 'captions.vtt', size = 1000): File { const content = new Array(size).fill('a').join('') return new File([content], name, {type: 'text/vtt'}) } function renderComponent(overrideProps = {}) { const defaultProps = { liveRegion: () => document.getElementById(LIVE_REGION_ID), onUpdateSubtitles: vi.fn(), subtitles: [], userLocale: 'en', } return { ...render(), props: {...defaultProps, ...overrideProps}, } } describe('', () => { beforeAll(() => server.listen({onUnhandledRequest: 'warn'})) beforeEach(() => { const liveRegion = document.createElement('div') liveRegion.id = LIVE_REGION_ID liveRegion.setAttribute('role', 'alert') document.body.appendChild(liveRegion) server.use(http.post('**/asr', () => new HttpResponse(null, {status: 202}))) }) const ADD_NEW_BUTTON_TEXT = 'Add New' const REQUEST_BUTTON_TEXT = 'Request' afterEach(() => { const liveRegion = document.getElementById(LIVE_REGION_ID) if (liveRegion) { document.body.removeChild(liveRegion) } server.resetHandlers() vi.clearAllMocks() }) afterAll(() => server.close()) it('shows add new and request buttons by default', () => { renderComponent() expect(screen.getByText(ADD_NEW_BUTTON_TEXT)).toBeInTheDocument() expect(screen.getByText(REQUEST_BUTTON_TEXT)).toBeInTheDocument() }) it('hides request button when auto-caption already exists', () => { const subtitlesWithAsr: Subtitle[] = [ {locale: 'en', file: {name: 'auto-generated-en.vtt'}, asr: true}, ] renderComponent({subtitles: subtitlesWithAsr}) expect(screen.getByText(ADD_NEW_BUTTON_TEXT)).toBeInTheDocument() expect(screen.queryByText(REQUEST_BUTTON_TEXT)).not.toBeInTheDocument() }) it('hides request button when inherited auto-caption exists', () => { const subtitlesWithInheritedAsr: Subtitle[] = [ {locale: 'en', file: {name: 'auto-generated-en.vtt'}, asr: true, inherited: true}, ] renderComponent({subtitles: subtitlesWithInheritedAsr}) // Request button should be hidden even for inherited ASR expect(screen.getByText(ADD_NEW_BUTTON_TEXT)).toBeInTheDocument() expect(screen.queryByText(REQUEST_BUTTON_TEXT)).not.toBeInTheDocument() }) it('shows request button when only manual captions exist', () => { const subtitlesManualOnly: Subtitle[] = [{locale: 'en', file: {name: 'english.vtt'}}] renderComponent({subtitles: subtitlesManualOnly}) expect(screen.getByText(ADD_NEW_BUTTON_TEXT)).toBeInTheDocument() expect(screen.getByText(REQUEST_BUTTON_TEXT)).toBeInTheDocument() }) it('clicking add new shows manual caption creator', () => { renderComponent() const addNewButton = screen.getByText(ADD_NEW_BUTTON_TEXT) fireEvent.click(addNewButton) // Manual caption creator should be visible expect(screen.getByPlaceholderText('Select Language')).toBeInTheDocument() expect(screen.getByText(/choose file/i)).toBeInTheDocument() expect(screen.getByText('Upload')).toBeInTheDocument() expect(screen.getByText('Cancel')).toBeInTheDocument() }) it('clicking cancel in manual caption creator hides it and shows the picker again', () => { renderComponent() // Open manual caption creator fireEvent.click(screen.getByText(ADD_NEW_BUTTON_TEXT)) // Manual creator should be visible expect( screen.getByText('Upload a subtitle track in either the SRT or WebVTT format.'), ).toBeInTheDocument() // Click cancel const cancelButton = screen.getByText('Cancel') fireEvent.click(cancelButton) // Should go back to showing picker buttons expect(screen.getByText(ADD_NEW_BUTTON_TEXT)).toBeInTheDocument() expect(screen.getByText(REQUEST_BUTTON_TEXT)).toBeInTheDocument() }) it('if language and file selected it uploads and shows the language in the list', async () => { // Mock successful upload server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) renderComponent({uploadConfig: TEST_UPLOAD_CONFIG}) // Open manual caption creator const addNewButton = screen.getByText(ADD_NEW_BUTTON_TEXT) fireEvent.click(addNewButton) // Select a language const selectPlaceholder = screen.getByPlaceholderText('Select Language') fireEvent.click(selectPlaceholder) fireEvent.click(screen.getByText('English')) // Select a file const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const validFile = createValidFile() fireEvent.change(fileInput, {target: {files: [validFile]}}) // Click Upload const uploadButton = screen.getByText('Upload') fireEvent.click(uploadButton) // Wait for English to appear in the list (uploaded) expect(await screen.findByText('English')).toBeInTheDocument() }) it('filters asr languages from manual upload dropdown', () => { const subtitlesWithAsr: Subtitle[] = [ {locale: 'en', file: {name: 'auto-generated-en.vtt'}, asr: true}, ] renderComponent({subtitles: subtitlesWithAsr}) // Verify the ASR caption shows with "(Automatic)" label expect(screen.getByText('English (Automatic)')).toBeInTheDocument() // Open manual caption creator fireEvent.click(screen.getByText(ADD_NEW_BUTTON_TEXT)) // Open the language dropdown const selectPlaceholder = screen.getByPlaceholderText('Select Language') fireEvent.click(selectPlaceholder) // English should not be in the dropdown (filtered out) expect(screen.queryByText('English')).not.toBeInTheDocument() }) it('filters manual languages from asr request dropdown', () => { const subtitlesManualOnly: Subtitle[] = [{locale: 'en', file: {name: 'english.vtt'}}] renderComponent({subtitles: subtitlesManualOnly}) // Open auto-caption request form fireEvent.click(screen.getByText(REQUEST_BUTTON_TEXT)) // Open the language dropdown const selectPlaceholder = screen.getByPlaceholderText('Select Language') fireEvent.click(selectPlaceholder) // English should not be in the ASR dropdown (filtered out) const dropdownOptions = screen.queryAllByText('English') // Should only find 1 instance (the caption row) expect(dropdownOptions).toHaveLength(1) }) it('once a language is added and uploaded, it is removed from the available options dropdown', async () => { // Mock successful upload server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) renderComponent({uploadConfig: TEST_UPLOAD_CONFIG}) // Open manual caption creator const addNewButton = screen.getByText(ADD_NEW_BUTTON_TEXT) fireEvent.click(addNewButton) // Select English and upload let selectPlaceholder = screen.getByPlaceholderText('Select Language') fireEvent.click(selectPlaceholder) fireEvent.click(screen.getByText('English')) const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement fireEvent.change(fileInput, {target: {files: [createValidFile()]}}) const uploadButton = screen.getByText('Upload') fireEvent.click(uploadButton) // Wait for English caption to be in the list (uploaded) expect(await screen.findByText('English')).toBeInTheDocument() // Open manual creator again to add another caption const addNewButtonAgain = screen.getByText(ADD_NEW_BUTTON_TEXT) fireEvent.click(addNewButtonAgain) // Open the language dropdown selectPlaceholder = screen.getByPlaceholderText('Select Language') fireEvent.click(selectPlaceholder) // English should not be in the dropdown anymore const dropdownOptions = screen.queryAllByText('English') // Should only find 1 instance (the caption row), not in dropdown expect(dropdownOptions).toHaveLength(1) }) it('canceling auto-caption request returns to button picker without creating caption', () => { renderComponent() // Click Request button fireEvent.click(screen.getByText(REQUEST_BUTTON_TEXT)) // Form should be visible expect(screen.getAllByText('Language Spoken in This Media*').length).toBeGreaterThan(0) // Select a language const selectPlaceholder = screen.getByPlaceholderText('Select Language') fireEvent.click(selectPlaceholder) fireEvent.click(screen.getByText('German')) // Click Cancel const cancelButton = screen.getByText('Cancel') fireEvent.click(cancelButton) // Both buttons should be visible again expect(screen.getByText(ADD_NEW_BUTTON_TEXT)).toBeInTheDocument() expect(screen.getByText(REQUEST_BUTTON_TEXT)).toBeInTheDocument() // No caption should have been created (no "(Automatic)" label) expect(screen.queryByText('German (Automatic)')).not.toBeInTheDocument() }) it('auto-caption request creates caption with asr flag', () => { renderComponent() // Click Request button fireEvent.click(screen.getByText(REQUEST_BUTTON_TEXT)) // Select a language from ASR dropdown const selectPlaceholder = screen.getByPlaceholderText('Select Language') fireEvent.click(selectPlaceholder) fireEvent.click(screen.getByText('Spanish')) // Click Request in the form const requestButtonInForm = screen.getByText('Request') fireEvent.click(requestButtonInForm) // Caption should appear with "(Automatic)" label expect(screen.getByText('Spanish (Automatic)')).toBeInTheDocument() // Buttons should be visible again (but not Request since ASR exists) expect(screen.getByText(ADD_NEW_BUTTON_TEXT)).toBeInTheDocument() expect(screen.queryByText(REQUEST_BUTTON_TEXT)).not.toBeInTheDocument() }) it('if captions are provided as props, they are rendered with delete icon', () => { const initialSubtitles: Subtitle[] = [ {locale: 'en', file: {name: 'english.vtt', url: '/url/en'}}, {locale: 'es', file: {name: 'spanish.vtt', url: '/url/es'}}, ] renderComponent({subtitles: initialSubtitles}) // Both captions should be rendered expect(screen.getByText('English')).toBeInTheDocument() expect(screen.getByText('Spanish')).toBeInTheDocument() // Delete buttons should be present (look for trash icon buttons) expect(screen.getByText('Delete English')).toBeInTheDocument() expect(screen.getByText('Delete Spanish')).toBeInTheDocument() }) it('delete icon works and that language is removed from the list', async () => { server.use( http.put('/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) const onUpdateSubtitles = vi.fn() const initialSubtitles: Subtitle[] = [ {locale: 'en', file: {name: 'english.vtt', url: '/url/en'}}, {locale: 'es', file: {name: 'spanish.vtt', url: '/url/es'}}, ] renderComponent({ subtitles: initialSubtitles, onUpdateSubtitles, uploadConfig: TEST_UPLOAD_CONFIG, }) // Both captions should be rendered expect(screen.getByText('English')).toBeInTheDocument() expect(screen.getByText('Spanish')).toBeInTheDocument() // Click the first delete button (English) fireEvent.click(screen.getByText('Delete English')) // English should be removed from the list await waitFor(() => { expect(screen.queryByText('English')).not.toBeInTheDocument() }) // Spanish should still be there expect(screen.getByText('Spanish')).toBeInTheDocument() }) describe('a11y', () => { it('a11y: renders captions as a semantic list', () => { const initialSubtitles: Subtitle[] = [ {locale: 'en', file: {name: 'english.vtt', url: '/url/en'}}, {locale: 'es', file: {name: 'spanish.vtt', url: '/url/es'}}, ] renderComponent({subtitles: initialSubtitles}) const list = screen.getByLabelText('Captions') expect(list.tagName).toBe('UL') const items = list.querySelectorAll('li') expect(items).toHaveLength(2) }) it('a11y: focuses Add New button after canceling manual caption creation', () => { renderComponent() fireEvent.click(screen.getByText(ADD_NEW_BUTTON_TEXT)) fireEvent.click(screen.getByText('Cancel')) expect(screen.getByText(ADD_NEW_BUTTON_TEXT).closest('button')).toHaveFocus() }) it('a11y: focuses Add New button after canceling auto-caption request', () => { renderComponent() fireEvent.click(screen.getByText(REQUEST_BUTTON_TEXT)) fireEvent.click(screen.getByText('Cancel')) expect(screen.getByText(ADD_NEW_BUTTON_TEXT).closest('button')).toHaveFocus() }) it('a11y: focuses Add New button after uploading a caption', async () => { server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) renderComponent({uploadConfig: TEST_UPLOAD_CONFIG}) fireEvent.click(screen.getByText(ADD_NEW_BUTTON_TEXT)) fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('English')) const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement fireEvent.change(fileInput, {target: {files: [createValidFile()]}}) fireEvent.click(screen.getByText('Upload')) await screen.findByText('English') expect(screen.getByText(ADD_NEW_BUTTON_TEXT).closest('button')).toHaveFocus() }) it('a11y: focuses next row delete button after deleting a caption', async () => { server.use( http.put('/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) const initialSubtitles: Subtitle[] = [ {locale: 'en', file: {name: 'english.vtt', url: '/url/en'}}, {locale: 'es', file: {name: 'spanish.vtt', url: '/url/es'}}, ] renderComponent({subtitles: initialSubtitles, uploadConfig: TEST_UPLOAD_CONFIG}) fireEvent.click(screen.getByText('Delete English')) await waitFor(() => { expect(screen.queryByText('English')).not.toBeInTheDocument() }) expect(screen.getByText('Delete Spanish').closest('button')).toHaveFocus() }) it('a11y: focuses creation form when deleting a caption while creation form is open', async () => { server.use( http.put('/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) const initialSubtitles: Subtitle[] = [ {locale: 'en', file: {name: 'english.vtt', url: '/url/en'}}, ] renderComponent({subtitles: initialSubtitles, uploadConfig: TEST_UPLOAD_CONFIG}) // Open the manual creation form fireEvent.click(screen.getByText(ADD_NEW_BUTTON_TEXT)) expect(screen.getByPlaceholderText('Select Language')).toBeInTheDocument() // Delete the caption while creation form is open fireEvent.click(screen.getByText('Delete English')) await waitFor(() => { expect(screen.queryByText('English')).not.toBeInTheDocument() }) // Focus should land on the creation form container (not lost) const creationForm = screen.getByText('Add New Caption').closest('[tabindex="-1"]') expect(creationForm).toHaveFocus() }) it('a11y: focuses Add New button after deleting the last caption', async () => { server.use( http.put('/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) const initialSubtitles: Subtitle[] = [ {locale: 'en', file: {name: 'english.vtt', url: '/url/en'}}, ] renderComponent({subtitles: initialSubtitles, uploadConfig: TEST_UPLOAD_CONFIG}) fireEvent.click(screen.getByText('Delete English')) await waitFor(() => { expect(screen.queryByText('English')).not.toBeInTheDocument() }) expect(screen.getByText(ADD_NEW_BUTTON_TEXT).closest('button')).toHaveFocus() }) it('a11y: announces file selection in the manual caption creator', () => { renderComponent() fireEvent.click(screen.getByText(ADD_NEW_BUTTON_TEXT)) const fileStatus = document.getElementById('cc-file-status') expect(fileStatus).toHaveAttribute('role', 'status') expect(fileStatus).toHaveTextContent('No file chosen') const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement fireEvent.change(fileInput, {target: {files: [createValidFile('my-captions.vtt')]}}) expect(fileStatus).toHaveTextContent('my-captions.vtt') }) it('a11y: announces when upload starts', () => { renderComponent({uploadConfig: TEST_UPLOAD_CONFIG}) fireEvent.click(screen.getByText(ADD_NEW_BUTTON_TEXT)) fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('English')) const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement fireEvent.change(fileInput, {target: {files: [createValidFile()]}}) fireEvent.click(screen.getByText('Upload')) expect(screen.getByText('Uploading English caption')).toBeInTheDocument() }) it('a11y: announces when ASR request starts', () => { renderComponent({uploadConfig: TEST_UPLOAD_CONFIG}) fireEvent.click(screen.getByText(REQUEST_BUTTON_TEXT)) fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('Spanish')) fireEvent.click(screen.getByText('Request')) expect(screen.getByText('Requesting Spanish caption')).toBeInTheDocument() }) it('a11y: announces when delete starts', () => { const initialSubtitles: Subtitle[] = [ {locale: 'en', file: {name: 'english.vtt', url: '/url/en'}}, ] renderComponent({subtitles: initialSubtitles, uploadConfig: TEST_UPLOAD_CONFIG}) fireEvent.click(screen.getByText('Delete English')) expect(screen.getByText('Deleting English')).toBeInTheDocument() }) it('a11y: announces when a caption is added', async () => { server.use( http.put('/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) renderComponent({ uploadConfig: TEST_UPLOAD_CONFIG, }) // Open manual caption creator fireEvent.click(screen.getByText(ADD_NEW_BUTTON_TEXT)) // Select a language and file const selectPlaceholder = screen.getByPlaceholderText('Select Language') fireEvent.click(selectPlaceholder) fireEvent.click(screen.getByText('Spanish')) const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement fireEvent.change(fileInput, {target: {files: [createValidFile()]}}) // Click Upload const uploadButton = screen.getByText('Upload') fireEvent.click(uploadButton) // Wait for a11y announcement for upload to complete await screen.findByText('Captions have been added for Spanish') }) it('a11y: announces when a caption is deleted', async () => { server.use( http.put('/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) const initialSubtitles: Subtitle[] = [ {locale: 'en', file: {name: 'english.vtt', url: '/url/en'}}, {locale: 'fr', file: {name: 'french.vtt', url: '/url/fr'}}, ] renderComponent({subtitles: initialSubtitles, uploadConfig: TEST_UPLOAD_CONFIG}) // Click the first delete button (English) fireEvent.click(screen.getByText('Delete English')) await screen.findByText('Captions have been deleted for English') }) it('a11y: announces when a caption upload fails', async () => { server.use( http.put('/api/media_objects/*/media_tracks', () => HttpResponse.json({error: 'Anything'}, {status: 400}), ), ) renderComponent({ uploadConfig: TEST_UPLOAD_CONFIG, }) // Open manual caption creator fireEvent.click(screen.getByText(ADD_NEW_BUTTON_TEXT)) // Select a language and file const selectPlaceholder = screen.getByPlaceholderText('Select Language') fireEvent.click(selectPlaceholder) fireEvent.click(screen.getByText('German')) fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, { target: {files: [createValidFile()]}, }) // Click Upload const uploadButton = screen.getByText('Upload') fireEvent.click(uploadButton) expect(await screen.findByText('Upload failed')).toBeInTheDocument() await screen.findByText('German caption upload failed') }) it('a11y: announces when a caption delete fails', async () => { server.use( http.put('/api/media_objects/*/media_tracks', () => HttpResponse.json({error: 'Delete Failed'}, {status: 400}), ), ) const initialSubtitles: Subtitle[] = [ {locale: 'fr', file: {name: 'french.vtt', url: '/url/fr'}}, ] renderComponent({ subtitles: initialSubtitles, uploadConfig: TEST_UPLOAD_CONFIG, }) // Click delete button fireEvent.click(screen.getByText('Delete French')) // Wait for a11y announcement with formatted error message (using template) await screen.findByText('French caption delete failed') // Caption should still be visible with error message expect(screen.getByText('French')).toBeInTheDocument() expect(screen.getByText('Delete failed')).toBeInTheDocument() }) it('a11y: retry upload re-announces and succeeds on retry', async () => { // Phase 1: first upload attempt always fails server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({error: 'Server error'}, {status: 500}), ), ) renderComponent({uploadConfig: TEST_UPLOAD_CONFIG}) // Open manual caption creator fireEvent.click(screen.getByText(ADD_NEW_BUTTON_TEXT)) const selectPlaceholder = screen.getByPlaceholderText('Select Language') fireEvent.click(selectPlaceholder) fireEvent.click(screen.getByText('German')) fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, { target: {files: [createValidFile()]}, }) fireEvent.click(screen.getByText('Upload')) // Wait for upload failure — the upload path involves FileReader + // nested promises, so give it extra time under CI load. await waitFor(() => expect(screen.getByText('Upload failed')).toBeInTheDocument(), { timeout: 10000, }) expect(screen.getByText('Retry German')).toBeInTheDocument() // Phase 2: switch to success handler before retrying server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) // Click retry fireEvent.click(screen.getByText('Retry German')) // Should succeed on second attempt — use waitFor so both conditions // must be true simultaneously, avoiding races where the live region // is briefly cleared between the old and new announcement. await waitFor( () => { expect(screen.queryByText('Captions have been added for German')).toBeInTheDocument() expect(screen.queryByText('Upload failed')).not.toBeInTheDocument() }, {timeout: 10000}, ) }) it('retrying one caption does not clear the failed state of another caption', async () => { // Phase 1: French delete fails server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({error: 'Server error'}, {status: 500}), ), ) const initialSubtitles: Subtitle[] = [ {locale: 'fr', file: {name: 'french.vtt', url: '/url/fr'}}, ] renderComponent({subtitles: initialSubtitles, uploadConfig: TEST_UPLOAD_CONFIG}) // Delete French → fails fireEvent.click(screen.getByText('Delete French')) await screen.findByText('Delete failed') // Now upload German → also fails server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({error: 'Server error'}, {status: 500}), ), ) fireEvent.click(screen.getByText(ADD_NEW_BUTTON_TEXT)) fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('German')) fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, { target: {files: [createValidFile()]}, }) fireEvent.click(screen.getByText('Upload')) await screen.findByText('Upload failed') // Both failures should be visible simultaneously expect(screen.getByText('French')).toBeInTheDocument() expect(screen.getByText('Delete failed')).toBeInTheDocument() expect(screen.getByText('German')).toBeInTheDocument() expect(screen.getByText('Upload failed')).toBeInTheDocument() // Retry German upload (succeeds) server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) fireEvent.click(screen.getByText('Retry German')) await screen.findByText('Captions have been added for German') // French should still show its delete-failed state expect(screen.getByText('French')).toBeInTheDocument() expect(screen.getByText('Delete failed')).toBeInTheDocument() }) it('server-side failed caption shows delete but not retry', () => { const initialSubtitles: Subtitle[] = [{locale: 'en', workflow_state: 'failed', asr: true}] renderComponent({subtitles: initialSubtitles, uploadConfig: TEST_UPLOAD_CONFIG}) expect(screen.getByText('English (Automatic)')).toBeInTheDocument() expect(screen.getByText('Delete English (Automatic)')).toBeInTheDocument() expect(screen.queryByText(/retry/i)).not.toBeInTheDocument() }) it('deleting a server-side failed ASR caption re-enables the Request button', async () => { server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) const initialSubtitles: Subtitle[] = [{locale: 'en', workflow_state: 'failed', asr: true}] renderComponent({subtitles: initialSubtitles, uploadConfig: TEST_UPLOAD_CONFIG}) // Request button should be hidden (asr caption exists) expect(screen.queryByText(REQUEST_BUTTON_TEXT)).not.toBeInTheDocument() // Delete the failed ASR caption fireEvent.click(screen.getByText('Delete English (Automatic)')) // Caption should be removed and Request button should reappear expect(await screen.findByText(REQUEST_BUTTON_TEXT)).toBeInTheDocument() expect(screen.queryByText('English (Automatic)')).not.toBeInTheDocument() }) it('a11y: announces when ASR caption generation fails', async () => { server.use( http.post('/api/v1/media_objects/*/asr', () => HttpResponse.json({error: 'ASR failed'}, {status: 500}), ), ) renderComponent({uploadConfig: TEST_UPLOAD_CONFIG}) fireEvent.click(screen.getByText(REQUEST_BUTTON_TEXT)) fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('Spanish')) fireEvent.click(screen.getByText('Request')) expect(await screen.findByText('Generation failed')).toBeInTheDocument() await screen.findByText('Spanish caption generation failed') expect(screen.getByText('Retry Spanish (Automatic)')).toBeInTheDocument() }) it('a11y: retry ASR request goes back to processing on success', async () => { // Phase 1: first ASR request fails server.use( http.post('/api/v1/media_objects/*/asr', () => HttpResponse.json({error: 'Server error'}, {status: 500}), ), ) renderComponent({uploadConfig: TEST_UPLOAD_CONFIG}) fireEvent.click(screen.getByText(REQUEST_BUTTON_TEXT)) fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('Spanish')) fireEvent.click(screen.getByText('Request')) await screen.findByText(/Spanish Caption generation failed/i) // Phase 2: retry succeeds server.use( http.post('/api/v1/media_objects/*/asr', () => new HttpResponse(null, {status: 202})), ) fireEvent.click(screen.getByText('Retry Spanish (Automatic)')) await waitFor(() => { expect(screen.queryByText('Generation failed')).not.toBeInTheDocument() }) expect(screen.getByText('Spanish (Automatic)')).toBeInTheDocument() }) it('a11y: retry delete re-announces and succeeds on retry', async () => { // Phase 1: first delete attempt fails server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({error: 'Server error'}, {status: 500}), ), ) const initialSubtitles: Subtitle[] = [ {locale: 'fr', file: {name: 'french.vtt', url: '/url/fr'}}, ] renderComponent({subtitles: initialSubtitles, uploadConfig: TEST_UPLOAD_CONFIG}) // Click delete (fails on first attempt) fireEvent.click(screen.getByText('Delete French')) await screen.findByText('Delete failed') expect(screen.getByText('Retry French')).toBeInTheDocument() // Phase 2: retry succeeds server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) // Click retry (succeeds on second attempt) fireEvent.click(screen.getByText('Retry French')) await waitFor(() => { expect(screen.queryByText('French')).not.toBeInTheDocument() }) }) }) describe('Pendo tracking', () => { const mockTrack = vi.fn() beforeEach(() => { ;(window as any).canvasUsageMetrics = {track: mockTrack} }) afterEach(() => { delete (window as any).canvasUsageMetrics }) it('fires canvas_caption_submitted when manual caption upload is confirmed', async () => { server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) renderComponent({uploadConfig: TEST_UPLOAD_CONFIG}) fireEvent.click(screen.getByText(ADD_NEW_BUTTON_TEXT)) fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('English')) const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement fireEvent.change(fileInput, {target: {files: [createValidFile()]}}) fireEvent.click(screen.getByText('Upload')) await waitFor(() => { expect(mockTrack).toHaveBeenCalledWith('canvas_caption_submitted', { type: 'track', flow_type: 'upload_file', language: 'en', }) }) }) it('fires canvas_caption_submitted when ASR request is confirmed', async () => { renderComponent({uploadConfig: TEST_UPLOAD_CONFIG}) fireEvent.click(screen.getByText(REQUEST_BUTTON_TEXT)) fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('Spanish')) fireEvent.click(screen.getByText('Request')) await waitFor(() => { expect(mockTrack).toHaveBeenCalledWith('canvas_caption_submitted', { type: 'track', flow_type: 'request_auto', language: 'es', }) }) }) it('fires canvas_caption_result success when upload succeeds', async () => { server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) renderComponent({uploadConfig: TEST_UPLOAD_CONFIG}) fireEvent.click(screen.getByText(ADD_NEW_BUTTON_TEXT)) fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('English')) fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, { target: {files: [createValidFile()]}, }) fireEvent.click(screen.getByText('Upload')) await waitFor(() => { expect(mockTrack).toHaveBeenCalledWith('canvas_caption_result', { type: 'track', flow_type: 'upload_file', result: 'success', language: 'en', }) }) }) it('fires canvas_caption_result failed when upload fails', async () => { server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({error: 'fail'}, {status: 400}), ), ) renderComponent({uploadConfig: TEST_UPLOAD_CONFIG}) fireEvent.click(screen.getByText(ADD_NEW_BUTTON_TEXT)) fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('German')) fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, { target: {files: [createValidFile()]}, }) fireEvent.click(screen.getByText('Upload')) await screen.findByText(/German Caption Upload Failed/i) expect(mockTrack).toHaveBeenCalledWith('canvas_caption_result', { type: 'track', flow_type: 'upload_file', result: 'failed', }) }) it('fires canvas_caption_retry_clicked when retry is clicked after upload failure', async () => { server.use( http.put('**/api/media_objects/*/media_tracks', () => { return HttpResponse.json({error: 'fail'}, {status: 500}) }), ) renderComponent({uploadConfig: TEST_UPLOAD_CONFIG}) fireEvent.click(screen.getByText(ADD_NEW_BUTTON_TEXT)) fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('German')) fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, { target: {files: [createValidFile()]}, }) fireEvent.click(screen.getByText('Upload')) await waitFor(() => expect(screen.getByText('Retry German')).toBeInTheDocument(), { timeout: 10000, }) fireEvent.click(screen.getByText('Retry German')) await waitFor(() => { expect(mockTrack).toHaveBeenCalledWith('canvas_caption_retry_clicked', { type: 'track', flow_type: 'upload_file', }) }) // Drain the in-flight retry PUT so it doesn't leak into subsequent tests. await screen.findByText('Upload failed') }) it('fires canvas_caption_item_action delete when delete button clicked', async () => { server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({data: 'success'})), ) const initialSubtitles: Subtitle[] = [ {locale: 'en', file: {name: 'english.vtt', url: '/url/en'}}, ] renderComponent({subtitles: initialSubtitles, uploadConfig: TEST_UPLOAD_CONFIG}) fireEvent.click(screen.getByText('Delete English')) await waitFor(() => { expect(mockTrack).toHaveBeenCalledWith('canvas_caption_item_action', { type: 'track', action: 'delete', caption_source: 'uploaded', language: 'en', }) }) }) it('fires canvas_caption_validation_error with delete_failed on delete error', async () => { server.use( http.put('**/api/media_objects/*/media_tracks', () => HttpResponse.json({error: 'fail'}, {status: 400}), ), ) const initialSubtitles: Subtitle[] = [ {locale: 'fr', file: {name: 'french.vtt', url: '/url/fr'}}, ] renderComponent({subtitles: initialSubtitles, uploadConfig: TEST_UPLOAD_CONFIG}) fireEvent.click(screen.getByText('Delete French')) await screen.findByText('Delete failed') expect(mockTrack).toHaveBeenCalledWith('canvas_caption_validation_error', { type: 'track', flow_type: 'upload_file', error_type: 'delete_failed', }) }) }) describe('caption download', () => { it('shows download button for each existing caption row', () => { const initialSubtitles: Subtitle[] = [ {locale: 'en', url: 'https://example.com/en.srt', file: {name: 'english.vtt'}}, {locale: 'es', url: 'https://example.com/es.srt', file: {name: 'spanish.vtt'}}, ] renderComponent({subtitles: initialSubtitles}) expect(screen.getByText('Download English')).toBeInTheDocument() expect(screen.getByText('Download Spanish')).toBeInTheDocument() }) it('download button is a link with href when subtitle has url', () => { const initialSubtitles: Subtitle[] = [ {locale: 'en', url: 'https://example.com/en.srt', file: {name: 'english.vtt'}}, ] renderComponent({subtitles: initialSubtitles}) const downloadLink = screen.getByText('Download English').closest('a') expect(downloadLink).toBeInTheDocument() expect(downloadLink).toHaveAttribute('href', 'https://example.com/en.srt') }) it('download button has no href when subtitle has no url', () => { const initialSubtitles: Subtitle[] = [{locale: 'en', file: {name: 'english.vtt'}}] renderComponent({subtitles: initialSubtitles}) expect(screen.getByText('Download English')).toBeInTheDocument() expect(screen.getByText('Download English').closest('a')).toBeNull() }) it('sets href and filename on the download link', () => { const initialSubtitles: Subtitle[] = [ { locale: 'fr', url: 'https://example.com/fr.srt', filename: 'french_fr.srt', file: {name: 'french.vtt'}, }, ] renderComponent({subtitles: initialSubtitles}) const downloadLink = screen.getByText('Download French').closest('a') expect(downloadLink).toHaveAttribute('href', 'https://example.com/fr.srt') expect(downloadLink).toHaveAttribute('download', 'french_fr.srt') }) }) // Skipped: ARC-11427 it.skip('updates displayed subtitles when prop changes after mount', async () => { const onUpdateSubtitles = vi.fn() // Start with empty subtitles const {rerender, props} = renderComponent({ subtitles: [], onUpdateSubtitles, }) // Should show "Add New" button (no subtitles yet) expect(screen.getByText(ADD_NEW_BUTTON_TEXT)).toBeInTheDocument() expect(screen.queryByText('English')).not.toBeInTheDocument() // Simulate async subtitle loading (like iframe response) const newSubtitles: Subtitle[] = [ {locale: 'en', file: {name: 'English'}}, {locale: 'es', file: {name: 'Spanish'}}, ] rerender() // Should now show both captions await waitFor(() => expect(screen.getByText('English')).toBeInTheDocument()) expect(screen.getByText('Spanish')).toBeInTheDocument() }) describe('translations prop', () => { const customTranslations = { ...defaultClosedCaptionPanelTranslations, addNew: () => 'CUSTOM_ADD_NEW', request: () => 'CUSTOM_REQUEST', addNewCaption: () => 'CUSTOM_ADD_NEW_CAPTION', autoCaptioningHeading: () => 'CUSTOM_AUTO_HEADING', captionsListLabel: () => 'CUSTOM_CAPTIONS_LIST', } it('threads a custom translations object through to every child component', () => { const initialSubtitles: Subtitle[] = [{locale: 'en', file: {name: 'English'}}] renderComponent({subtitles: initialSubtitles, translations: customTranslations}) expect(screen.getByLabelText('CUSTOM_CAPTIONS_LIST')).toBeInTheDocument() expect(screen.getByText('CUSTOM_ADD_NEW')).toBeInTheDocument() expect(screen.getByText('CUSTOM_REQUEST')).toBeInTheDocument() fireEvent.click(screen.getByText('CUSTOM_ADD_NEW')) expect(screen.getByText('CUSTOM_ADD_NEW_CAPTION')).toBeInTheDocument() }) it('falls back to English defaults when no translations prop is passed', () => { renderComponent() expect(screen.getByText(ADD_NEW_BUTTON_TEXT)).toBeInTheDocument() expect(screen.getByText(REQUEST_BUTTON_TEXT)).toBeInTheDocument() }) }) })