From 81748e500bb2480f858a579b4546f57cf70348c0 Mon Sep 17 00:00:00 2001 From: Ben Dwyer Date: Mon, 17 Aug 2026 19:11:43 +0100 Subject: [PATCH] Simplify playlist block edit tests --- .../playlist-track/test/edit.jsdom.test.jsx | 485 +++++++++++------- .../test/edit-component.jsdom.test.jsx | 128 ++--- 2 files changed, 345 insertions(+), 268 deletions(-) diff --git a/packages/block-library/src/playlist-track/test/edit.jsdom.test.jsx b/packages/block-library/src/playlist-track/test/edit.jsdom.test.jsx index b568084d84c7aa..52dc3072b53ee4 100644 --- a/packages/block-library/src/playlist-track/test/edit.jsdom.test.jsx +++ b/packages/block-library/src/playlist-track/test/edit.jsdom.test.jsx @@ -1,57 +1,26 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, fireEvent, render, screen, within } from '@testing-library/react'; -import { useDispatch } from '@wordpress/data'; -import PlaylistTrackEdit from '../edit'; -import { PlaylistContext } from '../../playlist/context'; -import { useUploadMediaFromBlobURL } from '../../utils/hooks'; - -let mockMediaReplaceFlowProps; - -vi.mock( '@wordpress/block-editor', async () => { - const { default: PlainText } = await import( - '../../../../block-editor/src/components/plain-text' - ); - - return { - BlockControls: ( { children } ) =>
{ children }
, - BlockIcon: () => , - InspectorControls: ( { children } ) =>
{ children }
, - MediaPlaceholder: () =>
, - MediaReplaceFlow: ( props ) => { - mockMediaReplaceFlowProps = props; - const { name, onSelect } = props; - return ; - }, - MediaUpload: ( { render: renderMediaUpload } ) => - renderMediaUpload( { open: vi.fn() } ), - MediaUploadCheck: ( { children } ) =>
{ children }
, - PlainText, - useBlockProps: vi.fn( () => ( {} ) ), - }; -} ); - -vi.mock( import( '@wordpress/data' ), async ( importOriginal ) => { - const data = await importOriginal(); - const mockUseDispatch = vi.fn(); - - return new Proxy( data, { - get( target, property ) { - if ( property === 'useDispatch' ) { - return mockUseDispatch; - } - - return target[ property ]; - }, - } ); -} ); - -vi.mock( '@wordpress/notices', () => ( { - store: 'core/notices', +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createBlobURL } from '@wordpress/blob'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { createBlock } from '@wordpress/blocks'; +import { useRegistry } from '@wordpress/data'; +import { addFilter, removeFilter } from '@wordpress/hooks'; +import { initializeEditor } from '@wordpress/integration-tests/helpers/integration-test-editor'; +import { registerCoreBlocks } from '@wordpress/block-library'; + +globalThis.wpVitest.mockMatchMedia(); +globalThis.wpVitest.mockResizeObserver(); + +// The parent playlist renders a third-party waveform widget that drives +// canvas, media element and Web Audio APIs JSDOM does not implement. Nothing +// here asserts on it. +vi.mock( '../../utils/waveform-player', () => ( { + WaveformPlayer: () =>
, } ) ); -vi.mock( '../../utils/hooks', () => ( { - useUploadMediaFromBlobURL: vi.fn(), -} ) ); +const MEDIA_UPLOAD_FILTER = 'core/playlist-track/test/media-upload'; +const REGISTRY_FILTER = 'core/playlist-track/test/registry'; // The handler only forwards the message to a notice, so its text is // irrelevant to what these tests check. @@ -68,77 +37,157 @@ const defaultAttributes = { title: 'Song One', }; -function renderEdit( props = {} ) { - const setAttributes = vi.fn(); - const setCurrentTrackClientId = props.setCurrentTrackClientId || vi.fn(); - const removeTrack = props.removeTrack || vi.fn(); - - render( - - - +const secondTrackAttributes = { + ...defaultAttributes, + id: 2, + src: 'https://example.com/song-two.mp3', + title: 'Song Two', +}; + +// What the stubbed Media Library hands back when a test opens it. +let mediaUploadSelection; + +// `BlockEditorProvider` runs on a sub-registry, so the blocks under test are +// not in the default one. Grab the editor's own registry from inside it. +let editorRegistry; + +function addRegistryCapture() { + addFilter( + 'editor.BlockEdit', + REGISTRY_FILTER, + ( BlockEdit ) => + function CaptureRegistry( props ) { + editorRegistry = useRegistry(); + return ; + } + ); +} + +function addMediaUpload() { + addFilter( 'editor.MediaUpload', MEDIA_UPLOAD_FILTER, () => { + return ( { onSelect, render } ) => + render( { + open: () => onSelect( mediaUploadSelection ), + } ); + } ); +} + +/** + * Renders a playlist holding the given tracks, which is how a playlist track + * block is always used: it reads the playlist's context and reports the track + * it is playing back to it. + * + * @param {Array} tracks Attributes for each `core/playlist-track` block. + * @param {Object} settings Additional block editor settings. + */ +async function setup( tracks, settings = { mediaUpload: () => {} } ) { + return initializeEditor( + createBlock( + 'core/playlist', + {}, + tracks.map( ( attributes ) => + createBlock( 'core/playlist-track', attributes ) + ) + ), + false, + settings ); +} + +function getTrackClientIds() { + const [ playlist ] = editorRegistry.select( blockEditorStore ).getBlocks(); + return playlist.innerBlocks.map( ( block ) => block.clientId ); +} - return { setAttributes, setCurrentTrackClientId, removeTrack }; +function getTrackAttributes( index = 0 ) { + const [ playlist ] = editorRegistry.select( blockEditorStore ).getBlocks(); + return playlist.innerBlocks[ index ].attributes; +} + +async function selectTrack( index = 0 ) { + const clientId = getTrackClientIds()[ index ]; + await act( async () => { + editorRegistry.dispatch( blockEditorStore ).selectBlock( clientId ); + } ); +} + +async function multiSelectTracks() { + const clientIds = getTrackClientIds(); + await act( async () => { + editorRegistry + .dispatch( blockEditorStore ) + .multiSelect( clientIds[ 0 ], clientIds[ clientIds.length - 1 ] ); + } ); +} + +async function openReplaceMediaLibrary() { + await userEvent.click( + screen.getByRole( 'button', { expanded: false, name: 'Replace' } ) + ); + await userEvent.click( + screen.getByRole( 'menuitem', { name: 'Open Media Library' } ) + ); } describe( 'PlaylistTrackEdit', () => { beforeEach( () => { - mockMediaReplaceFlowProps = undefined; - useDispatch.mockReturnValue( { - createErrorNotice: vi.fn(), - } ); - useUploadMediaFromBlobURL.mockClear(); + // JSDOM's `createObjectURL` only accepts its own `File`, which is not + // the `File` global this test file sees. `restoreMocks` puts these + // back after every test, so they are stubbed per test. + let blobCount = 0; + vi.spyOn( window.URL, 'createObjectURL' ).mockImplementation( + () => `blob:https://example.com/track-${ ++blobCount }` + ); + vi.spyOn( window.URL, 'revokeObjectURL' ).mockImplementation( + () => {} + ); + + mediaUploadSelection = undefined; + editorRegistry = undefined; + registerCoreBlocks(); + addMediaUpload(); + addRegistryCapture(); + } ); + + afterEach( () => { + removeFilter( 'editor.MediaUpload', MEDIA_UPLOAD_FILTER ); + removeFilter( 'editor.BlockEdit', REGISTRY_FILTER ); } ); - it( 'shows the title control for a single selected playlist track', () => { - renderEdit( { isSelected: true } ); + it( 'shows the title control for a single selected playlist track', async () => { + await setup( [ defaultAttributes ] ); + + await selectTrack(); expect( screen.getByRole( 'textbox', { name: 'Title' } ) ).toBeInTheDocument(); } ); - it( 'shows the replace track control for a single selected playlist track', () => { - renderEdit( { isSelected: true } ); + it( 'shows the replace track control for a single selected playlist track', async () => { + await setup( [ defaultAttributes ] ); - expect( mockMediaReplaceFlowProps ).toBeDefined(); - } ); + await selectTrack(); - it( 'does not show the replace track control when multiple playlist tracks are selected', () => { - renderEdit( { isSelected: false } ); - - expect( mockMediaReplaceFlowProps ).toBeUndefined(); + expect( + screen.getByRole( 'button', { expanded: false, name: 'Replace' } ) + ).toBeInTheDocument(); } ); - it( 'does not show the title control when multiple playlist tracks are selected', () => { - // The track inspector only renders for the selected block, or for the - // first block of a same-type multi-selection, where `isSelected` is - // false. - renderEdit( { isSelected: false } ); + it( 'does not show the title or replace controls when multiple playlist tracks are selected', async () => { + await setup( [ defaultAttributes, secondTrackAttributes ] ); + await multiSelectTracks(); + + // The track inspector still renders for the first block of a same-type + // multi-selection, but without the controls that only make sense for a + // single track. expect( screen.queryByRole( 'textbox', { name: 'Title' } ) ).not.toBeInTheDocument(); + expect( + screen.queryByRole( 'button', { expanded: false, name: 'Replace' } ) + ).not.toBeInTheDocument(); expect( screen.getByRole( 'textbox', { name: 'Artist' } ) ).toBeInTheDocument(); @@ -147,8 +196,10 @@ describe( 'PlaylistTrackEdit', () => { ).toBeInTheDocument(); } ); - it( 'allows the track image alternative text to be edited', () => { - const { setAttributes } = renderEdit(); + it( 'allows the track image alternative text to be edited', async () => { + await setup( [ defaultAttributes ] ); + + await selectTrack(); expect( screen.getByRole( 'link', { @@ -162,64 +213,95 @@ describe( 'PlaylistTrackEdit', () => { screen.queryByText( 'Leave empty if decorative.' ) ).not.toBeInTheDocument(); - fireEvent.change( screen.getByLabelText( 'Alternative text' ), { - target: { value: 'A silver microphone on a red background' }, - } ); + await userEvent.clear( screen.getByLabelText( 'Alternative text' ) ); + await userEvent.type( + screen.getByLabelText( 'Alternative text' ), + 'A silver microphone on a red background' + ); - expect( setAttributes ).toHaveBeenCalledWith( { - imageAlt: 'A silver microphone on a red background', - } ); + expect( screen.getByLabelText( 'Alternative text' ) ).toHaveValue( + 'A silver microphone on a red background' + ); } ); - it( 'does not show the alternative text control without a track image', () => { - renderEdit( { - attributes: { + it( 'does not show the alternative text control without a track image', async () => { + await setup( [ + { + ...defaultAttributes, image: undefined, imageAlt: undefined, }, - } ); + ] ); + + await selectTrack(); expect( screen.queryByLabelText( 'Alternative text' ) ).not.toBeInTheDocument(); } ); - it( 'sets the selected track as the current track', () => { - const { setCurrentTrackClientId } = renderEdit( { - currentTrackClientId: 'another-track-client-id', - isSelected: true, - } ); + it( 'sets the selected track as the current track', async () => { + await setup( [ defaultAttributes, secondTrackAttributes ] ); + + await waitFor( () => + expect( + screen.getByRole( 'button', { name: /Song One/ } ) + ).toHaveAttribute( 'aria-current', 'true' ) + ); + + await selectTrack( 1 ); - expect( setCurrentTrackClientId ).toHaveBeenCalledWith( - 'playlist-track-client-id' + await waitFor( () => + expect( + screen.getByRole( 'button', { name: /Song Two/ } ) + ).toHaveAttribute( 'aria-current', 'true' ) ); } ); - it( 'does not set a selected placeholder track as the current track', () => { - const { setCurrentTrackClientId } = renderEdit( { - attributes: { - blob: undefined, - src: undefined, - }, - currentTrackClientId: 'another-track-client-id', - isSelected: true, - } ); + it( 'does not set a selected placeholder track as the current track', async () => { + await setup( [ defaultAttributes, { title: 'Placeholder track' } ] ); + + await waitFor( () => + expect( + screen.getByRole( 'button', { name: /Song One/ } ) + ).toHaveAttribute( 'aria-current', 'true' ) + ); + + await selectTrack( 1 ); - expect( setCurrentTrackClientId ).not.toHaveBeenCalled(); + expect( + screen.getByRole( 'button', { name: /Song One/ } ) + ).toHaveAttribute( 'aria-current', 'true' ); } ); - it( 'uploads temporary blob tracks', () => { - renderEdit( { - attributes: { - blob: 'blob:https://example.com/temporary-track', - length: undefined, - src: undefined, - }, + it( 'uploads temporary blob tracks', async () => { + let upload; + const file = new File( [ 'audio' ], 'temporary-track.mp3', { + type: 'audio/mpeg', } ); + const blob = createBlobURL( file ); + + await setup( + [ + { + ...defaultAttributes, + blob, + length: undefined, + src: undefined, + }, + ], + { + mediaUpload: ( args ) => { + upload = args; + }, + } + ); - expect( useUploadMediaFromBlobURL ).toHaveBeenCalledWith( + await waitFor( () => expect( upload ).toBeDefined() ); + expect( upload ).toEqual( expect.objectContaining( { - url: 'blob:https://example.com/temporary-track', + filesList: [ file ], + allowedTypes: [ 'audio' ], } ) ); const trackButton = screen.getByRole( 'button', { @@ -231,87 +313,96 @@ describe( 'PlaylistTrackEdit', () => { ).toBeInTheDocument(); } ); - it( 'removes the track when its initial upload fails', () => { - const createErrorNotice = vi.fn(); - const removeBlock = vi.fn(); - useDispatch.mockReturnValue( { createErrorNotice, removeBlock } ); - - const { removeTrack } = renderEdit( { - attributes: { - blob: 'blob:https://example.com/temporary-track', - id: undefined, - length: undefined, - src: undefined, - }, - clientId: 'temporary-track', + it( 'removes the track when its initial upload fails', async () => { + const file = new File( [ 'audio' ], 'temporary-track.mp3', { + type: 'audio/mpeg', } ); + const blob = createBlobURL( file ); - const { onError } = useUploadMediaFromBlobURL.mock.calls[ 0 ][ 0 ]; - act( () => { - onError( UPLOAD_ERROR ); - } ); + await setup( + [ defaultAttributes, { blob, title: 'Temporary track' } ], + { + mediaUpload: ( { onError } ) => onError( UPLOAD_ERROR ), + } + ); - expect( createErrorNotice ).toHaveBeenCalled(); - // The playlist owns its inner blocks: removing the track directly - // would take an unmodified playlist with it. - expect( removeTrack ).toHaveBeenCalledWith( 'temporary-track' ); - expect( removeBlock ).not.toHaveBeenCalled(); + // The playlist owns its inner blocks: removing the track with + // `removeBlock` would take an unmodified playlist with it. + await waitFor( () => expect( getTrackClientIds() ).toHaveLength( 1 ) ); + expect( + screen.queryByRole( 'button', { name: /Temporary track/ } ) + ).not.toBeInTheDocument(); + expect( + screen.getByRole( 'button', { name: /Song One/ } ) + ).toBeInTheDocument(); } ); // `onUploadError` also fires when replacing an existing track's media, so // removing the block unconditionally would discard the original track. - it( 'keeps a track that already has a source when an upload fails', () => { - const createErrorNotice = vi.fn(); - const removeBlock = vi.fn(); - useDispatch.mockReturnValue( { createErrorNotice, removeBlock } ); - - const { setAttributes } = renderEdit( { - clientId: 'existing-track', - isSelected: true, + it( 'keeps a track that already has a source when an upload fails', async () => { + await setup( [ defaultAttributes ], { + mediaUpload: ( { onError } ) => onError( UPLOAD_ERROR ), } ); - // A failed replacement is reported by MediaReplaceFlow, not the - // blob uploader, which never runs for a track that has a source. - act( () => { - mockMediaReplaceFlowProps.onError( UPLOAD_ERROR ); - } ); + await selectTrack(); + await userEvent.click( + screen.getByRole( 'button', { expanded: false, name: 'Replace' } ) + ); + // `FormFileUpload`'s input is hidden, so it has no accessible role. + // eslint-disable-next-line testing-library/no-node-access + const fileInput = document.querySelector( 'input[type="file"]' ); + await userEvent.upload( + fileInput, + new File( [ 'audio' ], 'replacement.mp3', { type: 'audio/mpeg' } ) + ); - expect( createErrorNotice ).toHaveBeenCalled(); - expect( removeBlock ).not.toHaveBeenCalled(); // The original track is left untouched, so its source still plays. - expect( setAttributes ).not.toHaveBeenCalled(); + expect( getTrackClientIds() ).toHaveLength( 1 ); expect( screen.getByRole( 'button', { name: /Song One/ } ) ).toBeInTheDocument(); } ); - it( 'preserves the current track source when a replacement upload fails', () => { - const { setAttributes } = renderEdit( { isSelected: true } ); - - mockMediaReplaceFlowProps.onSelect(); - - expect( setAttributes ).toHaveBeenCalledTimes( 1 ); - expect( setAttributes.mock.calls[ 0 ][ 0 ] ).not.toHaveProperty( - 'src' + it( 'preserves the current track source when a replacement upload fails', async () => { + // An empty selection is what `MediaReplaceFlow` reports back when a + // replacement upload fails. + mediaUploadSelection = {}; + await setup( [ defaultAttributes ] ); + + await selectTrack(); + await openReplaceMediaLibrary(); + + // The rest of the track's metadata is cleared, but its source is kept + // so the track still plays. + await waitFor( () => + expect( getTrackAttributes( 0 ) ).toEqual( + expect.objectContaining( { + src: 'https://example.com/song.mp3', + title: undefined, + } ) + ) ); } ); - it( 'accepts raw uploaded attachment data when replacing a track', () => { - const { setAttributes } = renderEdit( { isSelected: true } ); - - mockMediaReplaceFlowProps.onSelect( { + it( 'accepts raw uploaded attachment data when replacing a track', async () => { + mediaUploadSelection = { id: 2, source_url: 'https://example.com/replacement.mp3', title: { raw: 'Replacement & Track' }, - } ); + }; + await setup( [ defaultAttributes ] ); - expect( setAttributes ).toHaveBeenCalledWith( - expect.objectContaining( { - blob: undefined, - id: 2, - src: 'https://example.com/replacement.mp3', - title: 'Replacement & Track', - } ) + await selectTrack(); + await openReplaceMediaLibrary(); + + expect( + await screen.findByRole( 'button', { name: /Replacement & Track/ } ) + ).toBeInTheDocument(); + expect( screen.getByRole( 'textbox', { name: 'Title' } ) ).toHaveValue( + 'Replacement & Track' ); + expect( + screen.queryByRole( 'button', { name: /Song One/ } ) + ).not.toBeInTheDocument(); } ); } ); diff --git a/packages/block-library/src/playlist/test/edit-component.jsdom.test.jsx b/packages/block-library/src/playlist/test/edit-component.jsdom.test.jsx index a54f8eef5a3685..71e4f1d157911f 100644 --- a/packages/block-library/src/playlist/test/edit-component.jsdom.test.jsx +++ b/packages/block-library/src/playlist/test/edit-component.jsdom.test.jsx @@ -8,6 +8,22 @@ let mediaReplaceFlowProps; // The tracks the playlist holds. The inner blocks mock renders these, so a // test can look at what ended up in the tracklist. let mockTracks = []; +// Blocks the playlist creates need distinct client IDs, because a single +// Media Library selection can hold more than one track. +let mockCreatedBlockCount; + +const mockLibraryTracks = [ + { + id: 2, + url: 'https://example.com/second-track.mp3', + title: 'Second track', + }, + { + id: 3, + url: 'https://example.com/third-track.mp3', + title: 'Third track', + }, +]; vi.mock( '@wordpress/block-editor', () => ( { store: {}, @@ -16,7 +32,11 @@ vi.mock( '@wordpress/block-editor', () => ( { InspectorControls: ( { children } ) =>
{ children }
, MediaPlaceholder: ( props ) => { mediaPlaceholderProps = props; - return
; + return ( + + ); }, MediaReplaceFlow: ( props ) => { mediaReplaceFlowProps = props; @@ -56,14 +76,10 @@ vi.mock( '@wordpress/blocks', () => ( { createBlock: vi.fn( ( name, attributes ) => ( { name, attributes, - clientId: 'new-track', + clientId: `new-track-${ ++mockCreatedBlockCount }`, } ) ), } ) ); -vi.mock( '@wordpress/blob', () => ( { - createBlobURL: vi.fn( () => 'blob:track' ), -} ) ); - vi.mock( '@wordpress/components', () => ( { Disabled: ( { children } ) =>
{ children }
, SelectControl: () =>
, @@ -77,15 +93,6 @@ vi.mock( '@wordpress/data', () => ( { useSelect: vi.fn(), } ) ); -vi.mock( '@wordpress/i18n', () => ( { - __: ( text ) => text, - _x: ( text ) => text, -} ) ); - -vi.mock( '@wordpress/icons', () => ( { - playlist: 'playlist', -} ) ); - vi.mock( '@wordpress/notices', () => ( { store: {}, } ) ); @@ -112,6 +119,18 @@ const defaultAttributes = { showTrackLength: true, }; +function renderEdit( attributes = {} ) { + return render( + + ); +} + describe( 'PlaylistEdit', () => { let replaceInnerBlocks; let insertBlocks; @@ -142,6 +161,7 @@ describe( 'PlaylistEdit', () => { beforeEach( () => { mediaPlaceholderProps = undefined; mediaReplaceFlowProps = undefined; + mockCreatedBlockCount = 0; replaceInnerBlocks = vi.fn(); insertBlocks = vi.fn(); selectBlock = vi.fn(); @@ -167,46 +187,32 @@ describe( 'PlaylistEdit', () => { it( 'lets users select audio tracks individually from the Media Library', () => { trackStore( [] ); - render( - - ); + renderEdit(); expect( mediaPlaceholderProps.multiple ).toBe( 'add' ); + + fireEvent.click( + screen.getByRole( 'button', { name: 'Media Library' } ) + ); + + // Each selected file becomes its own track, rather than one track + // holding the first of them. + expect( trackClientIds() ).toEqual( [ 'new-track-1', 'new-track-2' ] ); + expect( + screen + .getAllByTestId( 'playlist-track' ) + .map( ( track ) => track.textContent ) + ).toEqual( [ 'Second track', 'Third track' ] ); } ); it( 'lets users select additional audio tracks individually from the Media Library', () => { - render( - - ); + renderEdit(); expect( mediaReplaceFlowProps.multiple ).toBe( 'add' ); } ); it( 'keeps track blocks mounted when the tracklist is hidden', () => { - render( - - ); + renderEdit( { showTracklist: false } ); const tracklist = screen.getByRole( 'list' ); @@ -230,43 +236,23 @@ describe( 'PlaylistEdit', () => { { clientId: 'placeholder-track', attributes: {} }, ] ); - render( - - ); + renderEdit(); fireEvent.click( screen.getByRole( 'button', { name: 'Add track' } ) ); expect( trackClientIds() ).toEqual( [ 'track-1', 'placeholder-track', - 'new-track', + 'new-track-1', ] ); } ); it( 'adds tracks from the add track control', () => { - render( - - ); + renderEdit(); - fireEvent.click( - screen.getByRole( 'button', { - name: 'Add track', - } ) - ); + fireEvent.click( screen.getByRole( 'button', { name: 'Add track' } ) ); - expect( trackClientIds() ).toEqual( [ 'track-1', 'new-track' ] ); - expect( selectBlock ).toHaveBeenCalledWith( 'new-track' ); + expect( trackClientIds() ).toEqual( [ 'track-1', 'new-track-1' ] ); + expect( selectBlock ).toHaveBeenCalledWith( 'new-track-1' ); } ); } );