diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 508349b..3317f9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,16 @@ jobs: tools: composer:v2 coverage: none + # === NODE SETUP === + # Pinned, not inherited from the runner image: vite (via Vitest) declares + # engines ^20.19.0 || >=22.12.0, so whatever ubuntu-latest happens to ship + # is not a guarantee. `cache: npm` keys on package-lock.json. + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + # === CACHING === - name: Get Composer cache directory id: composer-cache @@ -59,13 +69,6 @@ jobs: key: composer-${{ hashFiles('composer.lock') }} restore-keys: composer- - - name: Cache npm dependencies - uses: actions/cache@v6 - with: - path: ~/.npm - key: npm-${{ hashFiles('package-lock.json') }} - restore-keys: npm- - - name: Cache Playwright browsers uses: actions/cache@v6 id: playwright-cache diff --git a/assets/js/exelearning-editor.js b/assets/js/exelearning-editor.js index 748f61f..0c22a05 100644 --- a/assets/js/exelearning-editor.js +++ b/assets/js/exelearning-editor.js @@ -96,6 +96,18 @@ self.close(); } ); + // Open the in-page editor modal from any trigger button that carries a + // data-attachment-id (e.g. the attachment edit-screen meta box), instead of + // navigating to the standalone editor page. If no modal exists on this + // screen, the button's href is followed as a fallback. + $( document ).on( 'click', '.exelearning-edit-page-button[data-attachment-id]', function( e ) { + if ( ! self.modal.length || ! self.iframe.length ) { + return; + } + e.preventDefault(); + self.open( $( this ).data( 'attachment-id' ) ); + } ); + window.addEventListener( 'message', function( event ) { self.handleMessage( event ); } ); diff --git a/package-lock.json b/package-lock.json index a1677fe..7b5e90b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@playwright/test": "^1.62.0", "@wordpress/env": "^11.11.0", "happy-dom": "^20.10.2", + "jquery": "^3.7.1", "nunjucks": "^3.2.4", "vitest": "^4.1.8" } @@ -3930,6 +3931,13 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", diff --git a/package.json b/package.json index 6d058dc..3617da2 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@playwright/test": "^1.62.0", "@wordpress/env": "^11.11.0", "happy-dom": "^20.10.2", + "jquery": "^3.7.1", "nunjucks": "^3.2.4", "vitest": "^4.1.8" }, diff --git a/tests/js/exelearning_editor.test.js b/tests/js/exelearning_editor.test.js new file mode 100644 index 0000000..dac9a04 --- /dev/null +++ b/tests/js/exelearning_editor.test.js @@ -0,0 +1,136 @@ +// Unit tests for the "Edit in eXeLearning" trigger in assets/js/exelearning-editor.js. +// +// The button is printed by the attachment meta box (includes/integrations/class-media-library.php) +// as a real to the standalone editor page, carrying data-attachment-id. On any screen +// that already has the editor modal, following that href throws away the modal the plugin +// just rendered; on a screen without one, the href is the only thing that works. Both halves +// are asserted here, because the fix is only correct if it keeps the fallback. +// +// exelearning-editor.js is a jQuery IIFE with no exports: it is loaded for its side effects and +// publishes itself as window.ExeLearningEditor on document ready. Each test therefore imports a +// fresh copy against a fresh DOM, under a cache-busting URL so the module actually re-executes. + +const path = require( 'path' ); +const { pathToFileURL } = require( 'url' ); + +const EDITOR_SCRIPT = pathToFileURL( path.resolve( __dirname, '../../assets/js/exelearning-editor.js' ) ).href; + +const MODAL_MARKUP = + ''; + +const BUTTON_MARKUP = + 'Edit in eXeLearning'; + +// WordPress puts jQuery on the page as a global; the IIFE closes over it by name. +const $ = require( 'jquery' ); +global.jQuery = $; + +// Each copy of the script binds a native `message` listener on window and never +// unbinds it, so record every registration in order to drop them between tests. +const windowMessageListeners = []; +const realAddEventListener = window.addEventListener.bind( window ); +window.addEventListener = ( type, listener, options ) => { + if ( type === 'message' ) { + windowMessageListeners.push( { listener, options } ); + } + realAddEventListener( type, listener, options ); +}; + +let loadCount = 0; + +/** + * Load a fresh copy of the editor script against the given markup. + * + * @param {string} markup Body markup for the screen under test. + * @return {Promise} The published ExeLearningEditor controller. + */ +async function loadEditorOn( markup ) { + document.body.innerHTML = markup; + global.exelearningEditorVars = { + editorPageUrl: '/wp-admin/admin.php?page=exelearning-editor', + editorNonce: 'editor-nonce', + restUrl: '/wp-json/exelearning/v1', + nonce: 'rest-nonce', + i18n: {}, + }; + + await import( /* @vite-ignore */ `${ EDITOR_SCRIPT }?load=${ ++loadCount }` ); + + // Registered after the script's own ready callback, so it resolves after init(). + await new Promise( ( resolve ) => $( document ).ready( resolve ) ); + return window.ExeLearningEditor; +} + +/** + * Click the meta box button the way a browser would, and report whether the + * default navigation survived. + * + * @return {boolean} True when the handler called preventDefault(). + */ +function clickEditButton() { + const event = new window.MouseEvent( 'click', { bubbles: true, cancelable: true } ); + document.querySelector( '.exelearning-edit-page-button' ).dispatchEvent( event ); + return event.defaultPrevented; +} + +/** + * Undo everything a copy of the script left on the shared window/document, so the + * next test starts from a clean environment. + */ +function cleanupEditor() { + // jQuery keeps one native listener per event type on document, so a handler bound by + // the previous copy of the script would still fire during the next test. + $( document ).off(); + while ( windowMessageListeners.length ) { + const { listener, options } = windowMessageListeners.pop(); + window.removeEventListener( 'message', listener, options ); + } + document.body.innerHTML = ''; + delete window.ExeLearningEditor; + delete global.exelearningEditorVars; +} + +afterEach( cleanupEditor ); + +describe( 'exelearning-editor: the meta box "Edit in eXeLearning" button', () => { + it( 'opens the in-page modal instead of navigating away', async () => { + const editor = await loadEditorOn( MODAL_MARKUP + BUTTON_MARKUP ); + + const opened = []; + editor.open = ( attachmentId ) => opened.push( attachmentId ); + + const prevented = clickEditButton(); + + expect( opened ).toEqual( [ 42 ] ); + expect( prevented ).toBe( true ); + } ); + + it( 'follows its href on a screen that has no modal', async () => { + const editor = await loadEditorOn( BUTTON_MARKUP ); + + const opened = []; + editor.open = ( attachmentId ) => opened.push( attachmentId ); + + const prevented = clickEditButton(); + + expect( opened ).toEqual( [] ); + expect( prevented ).toBe( false ); + } ); + + it( 'stops listening for window messages once the test cleanup runs', async () => { + const editor = await loadEditorOn( MODAL_MARKUP + BUTTON_MARKUP ); + + const handled = []; + editor.handleMessage = ( event ) => handled.push( event.data ); + + cleanupEditor(); + window.dispatchEvent( new window.MessageEvent( 'message', { data: { type: 'DOCUMENT_CHANGED' } } ) ); + + expect( handled ).toEqual( [] ); + } ); +} ); diff --git a/vitest.config.mts b/vitest.config.mts index 1ebd9c4..d3db1df 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,7 +3,8 @@ import { defineConfig } from 'vitest/config'; // Vitest config for the plugin's JavaScript unit tests. The scripts under // assets/js/ are plain IIFEs loaded by wp_enqueue_script, so the suite gives them a // window/document (happy-dom) and exercises them through the DOM, exactly as -// WordPress does. Scripts that also need jQuery will need it stubbed per-test. +// WordPress does. Scripts that also need jQuery load the real jquery package as a +// global per-test. export default defineConfig( { test: { globals: true,