Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions assets/js/exelearning-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
} );
Expand Down
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
136 changes: 136 additions & 0 deletions tests/js/exelearning_editor.test.js
Original file line number Diff line number Diff line change
@@ -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 <a href> 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 =
'<div id="exelearning-editor-modal" style="display:none">' +
'<iframe id="exelearning-editor-iframe"></iframe>' +
'<button id="exelearning-editor-save"></button>' +
'<button id="exelearning-editor-close"></button>' +
'</div>';

const BUTTON_MARKUP =
'<a href="/wp-admin/admin.php?page=exelearning-editor&attachment_id=42"' +
' class="button exelearning-edit-page-button" data-attachment-id="42">Edit in eXeLearning</a>';

// 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<Object>} 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( [] );
} );
} );
3 changes: 2 additions & 1 deletion vitest.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading