Refresh player with native block editor preview and local assets

Preserve shortcode rendering, normalize options, bundle the player locally,
and add reproducible packaging and WordPress integration tests.
This commit is contained in:
2026-10-12 17:56:08 +08:00
parent 40e2bf0733
commit f04600c37b
90 changed files with 28835 additions and 2090 deletions
+67
View File
@@ -0,0 +1,67 @@
# Real WordPress browser integration
These tests run Chromium against WordPress itself. They do not replace WordPress,
PHP rendering, the editor iframe, the media parser, or the player with static mocks.
Recording files are small local fixtures; playback does not depend on a public CDN.
## Run locally (Docker)
Use Node 24 and Docker, then run from the repository root:
```sh
npm ci
npm run build
npm test
npm run package
npm run wp-env -- start
npm run wp-env -- run cli wp plugin install /var/www/html/wp-content/asciiplayer-packages/wp-asciiplayer.zip --activate --force
npx --no-install playwright install chromium
npm run test:e2e
npm run wp-env -- stop
```
The default site is http://localhost:8888 with wp-env's development-only account
`admin` / `password`. Never use these credentials on a public site. `WP_BASE_URL`,
`WP_USERNAME` and `WP_PASSWORD` can override browser-test settings. Set
`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` to use an already-installed Chromium binary.
The plugin's source tree is deliberately not mounted as an active plugin in the
committed wp-env configuration. The build ZIP is installed into WordPress through
WP-CLI, so missing package files cause a real integration failure. Fixtures are
mapped under uploads; the package directory is mapped separately for WP-CLI.
## Coverage
- A real iframe editor loads and plays the real local player after applying a URL
- The iframe player actually plays; Space pauses/resumes it and resizing stays inside the canvas
- An inspector speed/theme change rebuilds the player without duplicate instances
- Duplicate, delete, undo, save, reload, and front-end playback preserve the block
- Posts and Pages work, including a mobile-width published Page
- Multiple legacy shortcodes preserve false booleans, hidden controls, and camelCase options
- HTTP 404, malformed asciicast, and an actual no-CORS second origin recover after a valid URL
- Actual terminal output proves successful front-end parsing/playback
- Editor and front-end requests must not load plugin assets from jsDelivr
The preview intentionally requires an Apply URL / Load preview action for each
editing session before contacting the recording server. Tests respect that gate.
## CI evidence and limits
GitHub Actions checks the minimum WordPress 6.3 / PHP 7.4 combination and the latest
stable WordPress / PHP 8.2. It logs the actual core version, installs the ZIP, and
retains screenshots, failure traces/video, WordPress logs, and the package. A
separate job checks PHP 7.4 and 8.3 syntax and focused regression tests.
Latest WordPress also runs Plugin Check and retains its report as an advisory
step. Advisory output is not a passing directory review. Review any findings
before submission, and do not interpret a green browser job as marketplace
approval. Tests do not cover every browser, theme, or third-party plugin.
WordPress Playground is also usable for local smoke tests when Docker is absent,
but its SQLite/PHP-WASM runtime is additional evidence, not a substitute for the
Docker/MySQL matrix above.
References:
- https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/
- https://developer.wordpress.org/block-editor/reference-guides/block-api/block-api-versions/block-migration-for-iframe-editor-compatibility/
- https://github.com/WordPress/plugin-check/blob/trunk/docs/CLI.md
+261
View File
@@ -0,0 +1,261 @@
import { test, expect } from '@playwright/test';
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const blockName = 'lamgc/asciiplayer';
const fixturePath = '/wp-content/uploads/asciiplayer-fixtures/hello.cast';
async function logIn(page) {
await page.goto('/wp-login.php');
await page.locator('#user_login').fill(process.env.WP_USERNAME || 'admin');
await page.locator('#user_pass').fill(process.env.WP_PASSWORD || 'password');
await page.locator('#wp-submit').click();
await expect(page).toHaveURL(/\/wp-admin\//);
}
async function openEditor(page, postType = 'post') {
await page.goto(`/wp-admin/post-new.php?post_type=${postType}`);
await page.waitForFunction((name) => Boolean(
window.wp?.blocks?.getBlockType(name) &&
window.wp?.data?.select('core/editor')?.getCurrentPostId()
), blockName);
await page.evaluate(() => {
for (const scope of ['core/edit-post', 'core/editor']) {
window.wp.data.dispatch('core/preferences').set(scope, 'welcomeGuide', false);
}
});
// A block-pattern picker can appear on a new Page in some core releases.
const closeDialog = page.getByRole('dialog').getByRole('button', { name: /close/i });
if (await closeDialog.count()) await closeDialog.first().click();
await expect(page.locator('iframe[name="editor-canvas"]')).toBeVisible();
}
async function insertPlayer(page, attributes = {}) {
return page.evaluate(({ name, attrs }) => {
const block = window.wp.blocks.createBlock(name, attrs);
window.wp.data.dispatch('core/block-editor').insertBlocks(block);
window.wp.data.dispatch('core/block-editor').selectBlock(block.clientId);
return block.clientId;
}, { name: blockName, attrs: attributes });
}
function canvas(page) {
return page.frameLocator('iframe[name="editor-canvas"]');
}
function playerBlock(page, clientId) {
return canvas(page).locator(`[data-block="${clientId}"]`);
}
async function readyPreview(block) {
await expect(block.locator('.asciiplayer-editor__preview')).toHaveAttribute('data-preview-status', 'ready');
await expect(block.locator('.asciiplayer-editor__player .ap-wrapper')).toHaveCount(1);
await expect(block.locator('.ap-wrapper')).toBeVisible();
}
async function loadPreview(block) {
await expect(block).toBeVisible();
const load = block.getByRole('button', { name: 'Load preview', exact: true });
if (await load.count()) await load.click();
await readyPreview(block);
}
async function publish(page, title) {
return page.evaluate(async (postTitle) => {
const editor = window.wp.data.dispatch('core/editor');
editor.editPost({ title: postTitle, status: 'publish' });
await editor.savePost();
const state = window.wp.data.select('core/editor');
if (state.didPostSaveRequestFail()) throw new Error('WordPress could not save the test post.');
return { id: state.getCurrentPostId(), url: state.getPermalink() };
}, title);
}
async function createContent(page, postType, content) {
return page.evaluate(async ({ type, html }) => window.wp.apiFetch({
path: `/wp/v2/${type === 'page' ? 'pages' : 'posts'}`,
method: 'POST',
data: { title: `Asciiplayer integration ${Date.now()}`, status: 'publish', content: html },
}), { type: postType, html: content });
}
async function expectFrontendPlayer(container) {
await expect(container.locator('.ap-wrapper')).toBeVisible();
// This starts actual playback and checks parsed terminal output. Merely
// finding the player's outer DOM would miss fetch/CORS/parser failures.
const start = container.locator('.ap-overlay-start .ap-play-button');
if (await start.isVisible()) await start.click();
await expect(container).toContainText('Hello from wp-asciiplayer');
}
async function screenshot(page, testInfo, name) {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path, fullPage: true });
await testInfo.attach(name, { path, contentType: 'image/png' });
}
test.beforeEach(async ({ page }) => {
await logIn(page);
});
test('real iframe preview survives edits, duplicate, delete, undo, save and reload', async ({ page }, testInfo) => {
const cdnRequests = [];
const pageErrors = [];
page.on('request', (request) => {
if (new URL(request.url()).hostname.endsWith('jsdelivr.net')) cdnRequests.push(request.url());
});
page.on('pageerror', (error) => pageErrors.push(error.message));
await openEditor(page);
const source = new URL(fixturePath, page.url()).href;
const id = await insertPlayer(page);
const block = playerBlock(page, id);
await block.getByRole('textbox', { name: 'Recording URL', exact: true }).fill(source);
await block.getByRole('button', { name: 'Apply URL', exact: true }).click();
await readyPreview(block);
await block.locator('.ap-overlay-start .ap-play-button').click();
await expect(block.locator('.ap-term-text')).toContainText('Hello from wp-asciiplayer');
const playback = block.locator('.ap-playback-button');
await expect(playback).toHaveAttribute('aria-label', 'Pause');
await block.locator('.ap-term-text').press('Space');
await expect(playback).toHaveAttribute('aria-label', 'Play');
await block.locator('.ap-term-text').press('Space');
await expect(playback).toHaveAttribute('aria-label', 'Pause');
await block.locator('.ap-term-text').press('Space');
await page.setViewportSize({ width: 900, height: 800 });
await expect(block.locator('.ap-wrapper')).toBeVisible();
const canvasWidth = await page.locator('iframe[name="editor-canvas"]').evaluate((element) => element.clientWidth);
await expect.poll(async () => (await block.locator('.ap-wrapper').boundingBox()).width).toBeLessThanOrEqual(canvasWidth);
await page.setViewportSize({ width: 1280, height: 720 });
await page.evaluate(() => window.wp.data.dispatch('core/edit-post').openGeneralSidebar('edit-post/block'));
// Exercise a real inspector input; data-store operations below cover core's
// block lifecycle without depending on changing toolbar/menu copy.
const speed = page.getByRole('spinbutton', { name: 'Playback speed', exact: true });
await speed.fill('1.5');
await speed.press('Tab');
await expect.poll(() => page.evaluate((clientId) =>
window.wp.data.select('core/block-editor').getBlockAttributes(clientId).speed, id
)).toBe(1.5);
await readyPreview(block);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption('solarized-dark');
await readyPreview(block);
await expect(block.locator('.ap-player')).toHaveClass(/asciinema-player-theme-solarized-dark/);
const duplicateId = await page.evaluate(async (clientId) => {
await window.wp.data.dispatch('core/block-editor').duplicateBlocks([clientId]);
return window.wp.data.select('core/block-editor').getBlocks().find((entry) => entry.name === 'lamgc/asciiplayer' && entry.clientId !== clientId).clientId;
}, id);
await loadPreview(playerBlock(page, duplicateId));
await expect(canvas(page).locator('.asciiplayer-editor__player .ap-wrapper')).toHaveCount(2);
await page.evaluate((clientId) => window.wp.data.dispatch('core/block-editor').removeBlocks([clientId]), duplicateId);
await expect(canvas(page).locator('.asciiplayer-editor__player .ap-wrapper')).toHaveCount(1);
await page.evaluate(() => window.wp.data.dispatch('core/editor').undo());
await loadPreview(playerBlock(page, duplicateId));
await expect(canvas(page).locator('.asciiplayer-editor__player .ap-wrapper')).toHaveCount(2);
await page.evaluate((clientId) => window.wp.data.dispatch('core/block-editor').removeBlocks([clientId]), duplicateId);
const saved = await publish(page, 'Inline preview lifecycle integration');
await screenshot(page, testInfo, 'real-wordpress-editor');
await page.reload();
await page.waitForFunction(() => window.wp?.data?.select('core/block-editor')?.getBlocks().length > 0);
const reloaded = await page.evaluate(() => window.wp.data.select('core/block-editor').getBlocks().find((entry) => entry.name === 'lamgc/asciiplayer'));
expect(reloaded.name).toBe(blockName);
expect(reloaded.attributes).toMatchObject({ source, speed: 1.5, theme: 'solarized-dark' });
await loadPreview(playerBlock(page, reloaded.clientId));
await page.goto(saved.url);
const container = page.locator('.asciiplayer-container');
await expect(container).toHaveCount(1);
expect(JSON.parse(await container.getAttribute('data-ap-options'))).toMatchObject({ speed: 1.5, theme: 'solarized-dark' });
await expectFrontendPlayer(container);
await screenshot(page, testInfo, 'real-wordpress-post');
expect(cdnRequests, 'No plugin runtime may be loaded from jsDelivr').toEqual([]);
expect(pageErrors).toEqual([]);
});
test('a published Page plays its block at desktop and mobile widths', async ({ page }, testInfo) => {
await openEditor(page, 'page');
const source = new URL(fixturePath, page.url()).href;
const id = await insertPlayer(page, { source, controls: 'always', preload: true });
await loadPreview(playerBlock(page, id));
const saved = await publish(page, 'Page playback integration');
await page.goto(saved.url);
const container = page.locator('.asciiplayer-container');
await expectFrontendPlayer(container);
await page.setViewportSize({ width: 375, height: 812 });
await expect(container.locator('.ap-wrapper')).toBeVisible();
const bounds = await container.locator('.ap-wrapper').boundingBox();
expect(bounds.width).toBeLessThanOrEqual(375);
await screenshot(page, testInfo, 'real-wordpress-page-mobile');
});
for (const postType of ['post', 'page']) {
test(`legacy shortcodes play multiple recordings on a ${postType}`, async ({ page }) => {
await openEditor(page, postType);
const source = new URL(fixturePath, page.url()).href;
const content = `[asciiplayer controls="false" autoPlay="true" startAt="0.1" idleTimeLimit="1" speed="1.5" loop="false" style="max-width: 640px;"]${source}[/asciiplayer]\n` +
`[asciiplayer controls="true" theme="solarized-dark" preload="true"]${source}[/asciiplayer]`;
const saved = await createContent(page, postType, content);
await page.goto(saved.link);
const containers = page.locator('.asciiplayer-container');
await expect(containers).toHaveCount(2);
const options = JSON.parse(await containers.nth(0).getAttribute('data-ap-options'));
expect(options).toMatchObject({ autoPlay: true, startAt: 0.1, idleTimeLimit: 1, speed: 1.5, loop: false });
expect(options.controls).toBe(false);
const otherOptions = JSON.parse(await containers.nth(1).getAttribute('data-ap-options'));
expect(otherOptions.controls).toBe(true);
await expectFrontendPlayer(containers.nth(0));
await expect(containers.nth(0).locator('.ap-control-bar')).toHaveCount(0);
await expectFrontendPlayer(containers.nth(1));
});
}
for (const filename of ['missing.cast', 'malformed.cast']) {
test(`a ${filename} recording produces a recoverable inline error`, async ({ page }) => {
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await openEditor(page);
const source = new URL(`/wp-content/uploads/asciiplayer-fixtures/${filename}`, page.url()).href;
const id = await insertPlayer(page);
const block = playerBlock(page, id);
await block.getByRole('textbox', { name: 'Recording URL', exact: true }).fill(source);
await block.getByRole('button', { name: 'Apply URL', exact: true }).click();
await expect(block.locator('.asciiplayer-editor__preview')).toHaveAttribute('data-preview-status', 'error');
await expect(block).toContainText('Unable to load the recording.');
await expect(block.getByRole('button', { name: 'Retry preview', exact: true })).toBeVisible();
await block.getByRole('textbox', { name: 'Recording URL', exact: true }).fill(new URL(fixturePath, page.url()).href);
await block.getByRole('button', { name: 'Apply URL', exact: true }).click();
await readyPreview(block);
expect(pageErrors).toEqual([]);
});
}
test('a real cross-origin server without CORS permission shows an error and recovers', async ({ page }) => {
const recording = await readFile(new URL('../fixtures/hello.cast', import.meta.url));
const server = createServer((_request, response) => {
response.writeHead(200, { 'Content-Type': 'text/plain' });
response.end(recording);
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
try {
await openEditor(page);
const id = await insertPlayer(page);
const block = playerBlock(page, id);
const address = server.address();
const crossOriginSource = `http://127.0.0.1:${address.port}/hello.cast`;
await block.getByRole('textbox', { name: 'Recording URL', exact: true }).fill(crossOriginSource);
await block.getByRole('button', { name: 'Apply URL', exact: true }).click();
await expect(block.locator('.asciiplayer-editor__preview')).toHaveAttribute('data-preview-status', 'error');
await expect(block).toContainText('CORS permissions');
await block.getByRole('textbox', { name: 'Recording URL', exact: true }).fill(new URL(fixturePath, page.url()).href);
await block.getByRole('button', { name: 'Apply URL', exact: true }).click();
await readyPreview(block);
} finally {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
}
});
+4
View File
@@ -0,0 +1,4 @@
{"version":2,"width":64,"height":12,"timestamp":1700000000,"env":{"TERM":"xterm-256color","SHELL":"/bin/sh"},"title":"Local integration fixture"}
[0.0,"o","$ printf 'Hello from wp-asciiplayer'\r\n"]
[0.1,"o","Hello from wp-asciiplayer\r\n"]
[12.0,"o","$ "]
+1
View File
@@ -0,0 +1 @@
This is deliberately not an asciicast recording.
+123
View File
@@ -0,0 +1,123 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { attributesFromShortcode, booleanValue, canTransformShortcode, finiteNumber, normalizeControls, normalizePlayerOptions, normalizeSource, normalizeWidth, previewHeight, secondsValue } from '../../src/options.js';
test( 'source validation permits explicit web URLs and site-relative cast paths', () => {
for ( const value of [ 'https://example.com/recording.cast', 'http://localhost:8888/a.cast?x=1&y=2', '/uploads/hello.cast', "https://example.com/it's.cast" ] ) assert.equal( normalizeSource( ` ${ value } ` ), value );
} );
test( 'source validation rejects executable, credentialed, ambiguous and malformed URLs', () => {
for ( const value of [ '', '//evil.test/cast', '/\\evil.test/cast', 'javascript:alert(1)', 'data:text/plain,cast', 'blob:https://example.com/a', 'file:///tmp/a.cast', 'https://user:[email protected]/a.cast', 'https://example.com/\na.cast', 'https://example.com/<script>', 'relative.cast', {}, null ] ) assert.equal( normalizeSource( value ), '' );
} );
test( 'source validation rejects a missing HTTP authority separator', () => {
assert.equal( normalizeSource( 'https:example.com/a.cast' ), '' );
} );
test( 'source validation rejects a single HTTP authority slash', () => {
assert.equal( normalizeSource( 'https:/example.com/a.cast' ), '' );
} );
test( 'source validation rejects an extra HTTP authority slash', () => {
assert.equal( normalizeSource( 'https:///example.com/a.cast' ), '' );
} );
test( 'string false options never become true through truthiness', () => {
for ( const value of [ false, 0, 'false', '0', 'off', 'no', ' FALSE ' ] ) assert.equal( booleanValue( value, true ), false );
for ( const value of [ true, 1, 'true', '1', 'on', 'yes' ] ) assert.equal( booleanValue( value ), true );
assert.equal( booleanValue( 'wrong', null ), null );
} );
test( 'controls convert editor choices to the actual player API types', () => {
assert.equal( normalizeControls( 'always' ), true );
assert.equal( normalizeControls( 'true' ), true );
assert.equal( normalizeControls( 'never' ), false );
assert.equal( normalizeControls( 'false' ), false );
assert.equal( normalizeControls( 'auto' ), 'auto' );
assert.equal( normalizeControls( 'unexpected' ), 'auto' );
} );
test( 'numeric validation rejects non-finite, empty and boolean values', () => {
for ( const value of [ Infinity, NaN, '', ' ', {}, null, true, '-1', '101', '0x10', '0b10' ] ) assert.equal( finiteNumber( value, 5, 0, 100 ), 5 );
assert.equal( finiteNumber( '0', 5 ), 0 );
assert.equal( finiteNumber( '1.5', 5 ), 1.5 );
} );
test( 'timecodes match the PHP supported syntax and preserve zero', () => {
assert.equal( secondsValue( '1:02:03.5' ), 3723.5 );
assert.equal( secondsValue( '2:03.5' ), 123.5 );
assert.equal( secondsValue( ' 2:03 ' ), 123 );
for ( const value of [ '1:99', '1:90:00', '1:2:3:4', -1, null ] ) assert.equal( secondsValue( value ), 0 );
} );
test( 'preview forces only its own preload and autoplay settings', () => {
const attributes = { autoPlay: true, preload: false, speed: 2, controls: 'always', loop: true };
const saved = normalizePlayerOptions( attributes );
const preview = normalizePlayerOptions( attributes, { preview: true } );
assert.equal( saved.autoPlay, true );
assert.equal( saved.preload, false );
assert.equal( preview.autoPlay, false );
assert.equal( preview.preload, true );
assert.equal( preview.speed, 2 );
assert.equal( preview.controls, true );
assert.equal( attributes.autoPlay, true );
} );
test( 'zero idle limit omits the override and finite legacy loops survive playback', () => {
assert.equal( Object.hasOwn( normalizePlayerOptions( { idleTimeLimit: 0 } ), 'idleTimeLimit' ), false );
assert.equal( normalizePlayerOptions( { idleTimeLimit: 2 } ).idleTimeLimit, 2 );
assert.equal( normalizePlayerOptions( { loop: 3 } ).loop, 3 );
assert.equal( normalizePlayerOptions( { fit: false } ).fit, 'none' );
} );
test( 'optional dimensions, fonts and posters are validated consistently', () => {
const options = normalizePlayerOptions( { cols: 120, rows: 35, terminalLineHeight: 0.1, terminalFontFamily: '"Fira Code", monospace', terminalFontSize: '1.2rem', poster: 'npt:2:03' } );
assert.equal( options.cols, 120 );
assert.equal( options.rows, 35 );
assert.equal( options.terminalLineHeight, 0.1 );
assert.equal( options.terminalFontSize, '1.2rem' );
assert.equal( options.poster, 'npt:2:03' );
const invalid = normalizePlayerOptions( { cols: 1.5, rows: 1001, terminalLineHeight: 11, terminalFontFamily: 'x; color:red', terminalFontSize: 'url(https://evil.test)', poster: 'https://evil.test/poster' } );
for ( const key of [ 'cols', 'rows', 'terminalLineHeight', 'terminalFontFamily', 'terminalFontSize', 'poster' ] ) assert.equal( Object.hasOwn( invalid, key ), false );
} );
test( 'width accepts safe CSS lengths only', () => {
for ( const value of [ '0', '80%', '640px', '.5rem', '80vw', '30ch' ] ) assert.equal( normalizeWidth( value ), value );
for ( const value of [ 'calc(100% - 1px)', '80%;color:red', 'expression(alert(1))', 'auto', null ] ) assert.equal( normalizeWidth( value ), '100%' );
} );
test( 'legacy height fitting gets a usable preview without changing saved styles', () => {
assert.equal( previewHeight( { fit: 'width' } ), undefined );
assert.equal( previewHeight( { fit: 'height' } ), '320px' );
assert.equal( previewHeight( { fit: 'both', legacyStyle: 'width:80%; height:400px;' } ), '400px' );
assert.equal( previewHeight( { fit: 'height', legacyStyle: 'height:expression(alert(1))' } ), '320px' );
} );
test( 'shortcode conversion maps lowercased legacy names and typed booleans', () => {
const attrs = attributesFromShortcode( { controls: 'false', autoplay: 'true', preload: 'false', startat: '1:23', idletimelimit: '2', fontsize: 'small', fit: 'false', style: 'width: 80%; height: 300px;' }, 'https://example.com/test.cast' );
assert.equal( attrs.source, 'https://example.com/test.cast' );
assert.equal( attrs.controls, 'never' );
assert.equal( attrs.autoPlay, true );
assert.equal( attrs.preload, false );
assert.equal( attrs.startAt, 83 );
assert.equal( attrs.idleTimeLimit, 2 );
assert.equal( attrs.terminalFontSize, 'small' );
assert.equal( attrs.fit, 'none' );
assert.equal( attrs.width, '80%' );
assert.equal( attrs.legacyStyle, 'width: 80%; height: 300px;' );
} );
test( 'shortcode conversion normalizes legacy option case and whitespace like PHP', () => {
const attrs = attributesFromShortcode( { fit: ' HEIGHT ', fontsize: ' 15PX ', fontfamily: ' "Fira Code", monospace ' }, '/example.cast' );
assert.equal( attrs.fit, 'height' );
assert.equal( attrs.terminalFontSize, '15px' );
assert.equal( attrs.terminalFontFamily, '"Fira Code", monospace' );
} );
test( 'lossy transforms are refused, so unsupported legacy shortcodes remain untouched', () => {
assert.equal( canTransformShortcode( { named: { loop: '2' } } ), false );
assert.equal( canTransformShortcode( { named: { customOption: 'x' } } ), false );
assert.equal( canTransformShortcode( { named: { style: 'width:calc(100% - 1px)' } } ), false );
assert.equal( canTransformShortcode( { named: {}, numeric: [ 'unexpected' ] } ), false );
assert.equal( canTransformShortcode( { named: { loop: 'true', controls: 'false', style: 'width:80%;height:300px' } } ), true );
} );
+115
View File
@@ -0,0 +1,115 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { playerForDocument } from '../../src/player-bridge.js';
const assets = { script: 'https://site.test/player.js', style: 'https://site.test/player.css' };
const flush = () => new Promise( ( resolve ) => setImmediate( resolve ) );
function fakeDocument() {
const nodes = [];
const document = {
defaultView: { setTimeout, clearTimeout },
head: { appendChild( node ) { nodes.push( node ); } },
get scripts() { return nodes.filter( ( node ) => node.tag === 'script' ); },
querySelectorAll() { return nodes.filter( ( node ) => node.tag === 'link' ); },
createElement( tag ) {
const events = new Map();
return {
tag, dataset: {},
addEventListener( name, callback ) { events.set( name, callback ); },
removeEventListener( name ) { events.delete( name ); },
dispatch( name ) { events.get( name )?.(); },
remove() { const index = nodes.indexOf( this ); if ( index >= 0 ) nodes.splice( index, 1 ); },
};
},
};
return { document, nodes };
}
test( 'bridge waits for CSS even if the player global already exists', async () => {
const { document, nodes } = fakeDocument();
const api = { create() {} };
document.defaultView.AsciinemaPlayer = api;
let resolved = false;
const pending = playerForDocument( document, assets ).then( ( value ) => { resolved = true; return value; } );
await flush();
assert.equal( resolved, false );
assert.equal( nodes.length, 1 );
nodes[ 0 ].sheet = {};
nodes[ 0 ].dispatch( 'load' );
assert.equal( await pending, api );
assert.equal( nodes.length, 1 );
} );
test( 'parallel previews deduplicate local CSS and script and use the canvas realm', async () => {
const { document, nodes } = fakeDocument();
const first = playerForDocument( document, assets );
const second = playerForDocument( document, assets );
assert.equal( nodes.length, 1 );
nodes[ 0 ].sheet = {};
nodes[ 0 ].dispatch( 'load' );
await flush();
assert.equal( document.scripts.length, 1 );
const canvasAPI = { create() {} };
document.defaultView.AsciinemaPlayer = canvasAPI;
document.scripts[ 0 ].dispatch( 'load' );
assert.equal( await first, canvasAPI );
assert.equal( await second, canvasAPI );
assert.equal( await playerForDocument( document, assets ), canvasAPI );
} );
test( 'failed owned script is removed and retry starts a new request', async () => {
const { document, nodes } = fakeDocument();
const pending = playerForDocument( document, assets );
const rejection = assert.rejects( pending, /script could not be loaded/ );
nodes[ 0 ].sheet = {};
nodes[ 0 ].dispatch( 'load' );
await flush();
document.scripts[ 0 ].dispatch( 'error' );
await rejection;
assert.equal( document.scripts.length, 0 );
const retry = playerForDocument( document, assets );
await flush();
assert.equal( document.scripts.length, 1 );
document.defaultView.AsciinemaPlayer = { create() {} };
document.scripts[ 0 ].dispatch( 'load' );
assert.equal( await retry, document.defaultView.AsciinemaPlayer );
} );
test( 'failed stylesheet is retryable and never starts a script before CSS', async () => {
const { document, nodes } = fakeDocument();
const pending = playerForDocument( document, assets );
const rejection = assert.rejects( pending, /stylesheet could not be loaded/ );
nodes[ 0 ].dispatch( 'error' );
await rejection;
assert.equal( nodes.length, 0 );
const retry = playerForDocument( document, assets );
assert.equal( nodes.length, 1 );
nodes[ 0 ].sheet = {};
nodes[ 0 ].dispatch( 'load' );
await flush();
document.defaultView.AsciinemaPlayer = { create() {} };
document.scripts[ 0 ].dispatch( 'load' );
await retry;
} );
test( 'existing enqueued assets are reused while still loading', async () => {
const { document, nodes } = fakeDocument();
const style = document.createElement( 'link' );
style.href = assets.style;
style.sheet = {};
const script = document.createElement( 'script' );
script.src = assets.script;
document.head.appendChild( style );
document.head.appendChild( script );
const pending = playerForDocument( document, assets );
await flush();
assert.equal( nodes.length, 2 );
document.defaultView.AsciinemaPlayer = { create() {} };
script.dispatch( 'load' );
assert.equal( await pending, document.defaultView.AsciinemaPlayer );
} );
test( 'document with no window fails cleanly', async () => {
await assert.rejects( playerForDocument( { defaultView: null }, assets ), /canvas is unavailable/ );
} );
+107
View File
@@ -0,0 +1,107 @@
<?php
/**
* Minimal WordPress API doubles for focused, dependency-free plugin tests.
* These verify plugin contracts, not the implementation of WordPress sanitizers.
* Real WordPress/browser integration tests cover the latter separately.
*/
define( 'ABSPATH', __DIR__ . '/' );
define( 'PLUGIN_VERSION', 'some-other-plugin' );
$GLOBALS['ap_test'] = array();
function ap_record( $key, $value ) {
$GLOBALS['ap_test'][ $key ][] = $value;
}
function add_action( $hook, $callback, $priority = 10 ) {
ap_record( 'actions', array( $hook, $callback, $priority ) );
}
function add_shortcode( $tag, $callback ) {
ap_record( 'shortcodes', array( $tag, $callback ) );
}
function add_option() {
throw new RuntimeException( 'Plugin must not write options on requests.' );
}
function update_option() {
throw new RuntimeException( 'Plugin must not write options on requests.' );
}
function plugin_dir_url( $file ) {
return 'https://wordpress.test/wp-content/plugins/wp-asciiplayer/';
}
function add_query_arg( $key, $value, $url ) {
return $url . ( false === strpos( $url, '?' ) ? '?' : '&' ) . rawurlencode( $key ) . '=' . rawurlencode( $value );
}
function wp_parse_url( $value ) {
return parse_url( $value );
}
function esc_url_raw( $value, $protocols = null ) {
return $value;
}
function esc_url( $value, $protocols = null ) {
return htmlspecialchars( $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' );
}
function esc_attr( $value ) {
return htmlspecialchars( $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' );
}
function get_block_wrapper_attributes( $attributes ) {
ap_record( 'block_wrappers', $attributes );
$output = array();
foreach ( $attributes as $key => $value ) {
if ( '' !== $value ) {
$output[] = $key . '="' . esc_attr( $value ) . '"';
}
}
return implode( ' ', $output );
}
function wp_json_encode( $value, $flags = 0 ) {
return json_encode( $value, $flags );
}
function safecss_filter_attr( $style ) {
ap_record( 'filtered_css', $style );
$safe = array();
foreach ( explode( ';', $style ) as $declaration ) {
if ( preg_match( '/^\s*(width|height|color)\s*:\s*([a-z0-9.%# -]+)\s*$/i', $declaration ) ) {
$safe[] = trim( $declaration );
}
}
return implode( ';', $safe );
}
function wp_register_script( $handle, $src, $deps, $version, $footer ) {
$GLOBALS['ap_test']['scripts'][ $handle ] = compact( 'src', 'deps', 'version', 'footer' );
}
function wp_register_style( $handle, $src, $deps, $version ) {
$GLOBALS['ap_test']['styles'][ $handle ] = compact( 'src', 'deps', 'version' );
}
function wp_add_inline_script( $handle, $source, $position ) {
ap_record( 'inline_scripts', compact( 'handle', 'source', 'position' ) );
}
function wp_set_script_translations( $handle, $domain, $path ) {
ap_record( 'translations', compact( 'handle', 'domain', 'path' ) );
}
function register_block_type( $path, $args ) {
ap_record( 'blocks', compact( 'path', 'args' ) );
}
function wp_enqueue_script( $handle ) {
$GLOBALS['ap_test']['enqueued_scripts'][ $handle ] = true;
}
function wp_enqueue_style( $handle ) {
$GLOBALS['ap_test']['enqueued_styles'][ $handle ] = true;
}
function wp_style_is( $handle, $status ) {
return ! empty( $GLOBALS['ap_test'][ 'done' === $status ? 'printed_styles' : 'enqueued_styles' ][ $handle ] );
}
function wp_print_styles( $handles ) {
foreach ( $handles as $handle ) {
$GLOBALS['ap_test']['printed_styles'][ $handle ] = true;
ap_record( 'style_print_calls', $handle );
}
}
function has_shortcode( $content, $tag ) {
return false !== strpos( $content, '[' . $tag );
}
function has_block( $name, $content ) {
return false !== strpos( $content, '<!-- wp:' . $name );
}
require dirname( __DIR__, 2 ) . '/asciicast-player.php';
+160
View File
@@ -0,0 +1,160 @@
<?php
/** Run with PHP 7.4+: php tests/php/run.php */
require __DIR__ . '/bootstrap.php';
use function LamGC\Asciiplayer\enqueue_content_assets;
use function LamGC\Asciiplayer\normalize_attributes;
use function LamGC\Asciiplayer\parse_boolean;
use function LamGC\Asciiplayer\parse_number;
use function LamGC\Asciiplayer\parse_time;
use function LamGC\Asciiplayer\player_options;
use function LamGC\Asciiplayer\player_style;
use function LamGC\Asciiplayer\print_late_styles;
use function LamGC\Asciiplayer\register_plugin;
use function LamGC\Asciiplayer\render_block;
use function LamGC\Asciiplayer\render_player;
use function LamGC\Asciiplayer\render_shortcode;
use function LamGC\Asciiplayer\sanitize_source;
$assertions = 0;
function same( $expected, $actual, $message ) {
global $assertions;
++$assertions;
if ( $expected !== $actual ) {
throw new RuntimeException( $message . '\nExpected: ' . var_export( $expected, true ) . '\nActual: ' . var_export( $actual, true ) );
}
}
function contains( $needle, $haystack, $message ) {
same( true, false !== strpos( $haystack, $needle ), $message );
}
function options_from( $html ) {
if ( ! preg_match( '/ data-ap-options="([^"]*)"/', $html, $matches ) ) {
throw new RuntimeException( 'Missing JSON data-ap-options.' );
}
return json_decode( html_entity_decode( $matches[1], ENT_QUOTES, 'UTF-8' ), true );
}
function reset_enqueues() {
$GLOBALS['ap_test']['enqueued_scripts'] = array();
$GLOBALS['ap_test']['enqueued_styles'] = array();
}
try {
// All registration happens in init, without global option writes/collisions.
same( 'some-other-plugin', PLUGIN_VERSION, 'No shared global version constant.' );
same( false, function_exists( 'is_assoc' ), 'No generic global helper functions.' );
same( 3, count( $GLOBALS['ap_test']['actions'] ), 'Register init, enqueue and late-style hooks.' );
foreach ( $GLOBALS['ap_test']['actions'] as $action ) {
same( true, is_callable( $action[1] ), 'Every registered callback is callable.' );
}
register_plugin();
same( 'asciiplayer', $GLOBALS['ap_test']['shortcodes'][0][0], 'Legacy shortcode stays registered.' );
contains( '/build', $GLOBALS['ap_test']['blocks'][0]['path'], 'Block uses local metadata.' );
same( 'LamGC\\Asciiplayer\\render_block', $GLOBALS['ap_test']['blocks'][0]['args']['render_callback'], 'Block is server rendered.' );
foreach ( $GLOBALS['ap_test']['scripts'] as $script ) {
contains( 'https://wordpress.test/wp-content/plugins/wp-asciiplayer/build/', $script['src'], 'Runtime JavaScript is locally bundled.' );
same( true, $script['footer'], 'Scripts load in footer.' );
}
same( true, in_array( 'lamgc-asciiplayer-player', $GLOBALS['ap_test']['scripts']['lamgc-asciiplayer-view']['deps'], true ), 'Frontend adapter depends on local player.' );
same( true, in_array( 'wp-i18n', $GLOBALS['ap_test']['scripts']['lamgc-asciiplayer-view']['deps'], true ), 'Frontend translations dependency registered.' );
same( array( 'lamgc-asciiplayer-player-style' ), $GLOBALS['ap_test']['styles']['lamgc-asciiplayer-style']['deps'], 'Frontend style depends on player stylesheet.' );
contains( 'window.wpAsciiplayerSettings = ', $GLOBALS['ap_test']['inline_scripts'][0]['source'], 'Editor iframe receives trusted runtime URLs.' );
same( 'before', $GLOBALS['ap_test']['inline_scripts'][0]['position'], 'Settings are available before editor boot.' );
contains( 'player.js?ver=1.1.0', $GLOBALS['ap_test']['inline_scripts'][0]['source'], 'Bridge JavaScript cache-buster matches registered asset version.' );
contains( 'player.css?ver=1.1.0', $GLOBALS['ap_test']['inline_scripts'][0]['source'], 'Bridge CSS cache-buster matches registered asset version.' );
// Source validation covers script, HTML, credential and relative-URL attacks.
foreach ( array( '', 'javascript:alert(1)', 'data:text/html,test', 'file:///tmp/a.cast', '//evil.test/a.cast', '/\\evil.test/a.cast', "https://example.test/a\n.cast", 'https://x.test/" onmouseover="alert(1)', 'https://user:[email protected]/a.cast', 'https:///a.cast', 'a.cast', array( 'bad' ) ) as $source ) {
same( '', sanitize_source( $source ), 'Reject unsafe or unsupported source.' );
}
foreach ( array( 'https://example.test/a.cast', 'http://example.test:8080/a.cast?x=1&y=2', '/wp-content/uploads/demo.cast', '/cast/demo.cast?x=1&y=2' ) as $source ) {
same( $source, sanitize_source( $source ), 'Allow HTTP(S) or root-relative source.' );
}
same( 'https://example.test/a.cast?a=1&b=2', sanitize_source( ' https://example.test/a.cast?a=1&amp;b=2 ' ), 'Decode editor entities and trim outer whitespace.' );
foreach ( array( false, 'false', '0', 0, 'off', 'no' ) as $value ) {
same( false, parse_boolean( $value ), 'Explicit false remains false.' );
}
foreach ( array( true, 'true', '1', 1, 'on', 'yes' ) as $value ) {
same( true, parse_boolean( $value ), 'Recognized true values.' );
}
same( null, parse_boolean( 'garbage' ), 'Unrecognized bool is ignored.' );
same( null, parse_number( 'INF', 0, 100 ), 'Infinity is rejected.' );
same( null, parse_number( true, 0, 100 ), 'Boolean is not a numeric option.' );
same( null, parse_number( -1, 0, 100 ), 'Negative time is rejected.' );
same( 0.0, parse_time( '0' ), 'Zero start time is retained.' );
same( 123.0, parse_time( '2:03' ), 'Legacy mm:ss start time.' );
same( 3723.5, parse_time( '1:02:03.5' ), 'Legacy hh:mm:ss fractional time.' );
same( null, parse_time( '1:99' ), 'Invalid time components are rejected.' );
$attrs = normalize_attributes( array( 'autoplay' => 'false', 'startat' => '2:03', 'idle-time-limit' => '2', 'fontSize' => '15px', 'fontfamily' => "'Fira Code', monospace", 'lineheight' => 1.4, 'logger' => 'console', 'unknown' => 'bad', 0 => 'bare', 'rows' => array( 2 ) ) );
same( false, isset( $attrs['unknown'] ), 'Unknown attributes are discarded.' );
same( false, isset( $attrs['logger'] ), 'No executable logger objects.' );
same( false, isset( $attrs['rows'] ), 'Nested option values are discarded.' );
$options = player_options( $attrs );
same( false, $options['autoPlay'], 'Lowercase autoplay normalizes and respects false.' );
same( 123.0, $options['startAt'], 'Lowercase startAt normalizes.' );
same( 2.0, $options['idleTimeLimit'], 'Hyphenated idleTimeLimit normalizes.' );
same( '15px', $options['terminalFontSize'], 'Legacy font aliases map to current player options.' );
same( "'Fira Code', monospace", $options['terminalFontFamily'], 'Font lists survive safely.' );
same( 1.4, $options['terminalLineHeight'], 'Legacy lineHeight maps to current name.' );
$options = player_options( normalize_attributes( array( 'controls' => 'false', 'preload' => 'false', 'loop' => 'false', 'fit' => 'false' ) ) );
same( false, $options['controls'], 'controls=false is a boolean, never a truthy string.' );
same( false, $options['preload'], 'preload=false is retained.' );
same( false, $options['loop'], 'loop=false is retained.' );
same( 'none', $options['fit'], 'Legacy fit=false maps to none.' );
foreach ( array( 'always' => true, 'never' => false, 'auto' => 'auto' ) as $input => $expected ) {
same( $expected, player_options( array( 'controls' => $input ) )['controls'], 'Block control enum converts to player API.' );
}
same( 3, player_options( array( 'loop' => '3' ) )['loop'], 'Finite replay counts stay supported in shortcodes.' );
$options = player_options( array( 'speed' => -1, 'idleTimeLimit' => 0, 'cols' => 4.5, 'rows' => 1001, 'startAt' => -1, 'terminalFontSize' => '12px; color:red', 'terminalFontFamily' => 'url(https://bad.test)', 'theme' => 'x" onclick=bad', 'poster' => 'javascript:alert(1)' ) );
same( array( 'autoPlay' => false, 'preload' => true, 'fit' => 'width' ), $options, 'Invalid options omitted, safe defaults retained.' );
same( 'auto/dracula', player_options( array( 'theme' => 'auto/dracula' ) )['theme'], 'Player theme fallback syntax retained.' );
same( 'npt:1:23', player_options( array( 'poster' => 'npt:1:23' ) )['poster'], 'Safe NPT poster supported.' );
same( 'data:text/plain,<Hello>', player_options( array( 'poster' => 'data:text/plain,<Hello>' ) )['poster'], 'Plain terminal-text posters are supported.' );
reset_enqueues();
same( '', render_shortcode( array() ), 'Unclosed/empty shortcode returns no player.' );
same( '', render_player( array( 'source' => 'javascript:alert(1)' ) ), 'Invalid source emits no HTML.' );
same( array(), $GLOBALS['ap_test']['enqueued_scripts'], 'Invalid source does not enqueue assets.' );
$html = render_shortcode( array( 'controls' => 'false', 'autoPlay' => 'true', 'startAt' => '12', 'idleTimeLimit' => '2' ), 'https://example.test/demo.cast?a=1&b=2' );
contains( 'class="asciiplayer-container"', $html, 'Render adapter class.' );
contains( 'data-ap-src="https://example.test/demo.cast?a=1&amp;b=2"', $html, 'URL is escaped for an HTML attribute.' );
same( false, options_from( $html )['controls'], 'Markup has typed JSON false.' );
same( true, options_from( $html )['autoPlay'], 'CamelCase boolean handled.' );
same( 12, options_from( $html )['startAt'], 'JSON numeric startAt.' );
same( 2, options_from( $html )['idleTimeLimit'], 'JSON numeric idleTimeLimit.' );
same( true, isset( $GLOBALS['ap_test']['enqueued_scripts']['lamgc-asciiplayer-view'] ), 'Every valid render requests the frontend adapter.' );
same( true, isset( $GLOBALS['ap_test']['enqueued_styles']['lamgc-asciiplayer-style'] ), 'Every valid render requests CSS.' );
$html = render_block( array( 'source' => '/demo.cast', 'poster' => 'data:text/plain,"><script>alert(1)</script>', 'legacyStyle' => 'width:80%;color:red;background:url(javascript:alert(1))' ) );
same( false, false !== strpos( $html, '<script>' ), 'Poster cannot escape JSON/HTML attribute.' );
contains( 'style="width:80%;color:red"', $html, 'Legacy style passes through WordPress safe CSS filtering.' );
contains( 'background:url(javascript:alert(1))', end( $GLOBALS['ap_test']['filtered_css'] ), 'The original style is supplied to the WordPress sanitizer.' );
same( 'width:80%;height:200px;width:50%;', player_style( array( 'legacyStyle' => 'width:80%;height:200px', 'width' => '50%' ) ), 'Dedicated width control overrides legacy width.' );
same( '', player_style( array( 'width' => '100%;background:red' ) ), 'Width cannot inject extra declarations.' );
same( render_player( array( 'source' => '/demo.cast', 'speed' => 2 ) ), render_block( array( 'source' => '/demo.cast', 'speed' => 2 ) ), 'Block uses shared renderer.' );
same( 'asciiplayer-container', end( $GLOBALS['ap_test']['block_wrappers'] )['class'], 'Block wrapper classes/layout pass through WordPress block supports.' );
// No is_single gate: query lookahead supports Pages and archive results.
reset_enqueues();
$GLOBALS['wp_query'] = (object) array( 'posts' => array( (object) array( 'post_content' => 'Ordinary content' ), (object) array( 'post_content' => '[asciiplayer]/demo.cast[/asciiplayer]' ) ) );
enqueue_content_assets();
same( true, isset( $GLOBALS['ap_test']['enqueued_scripts']['lamgc-asciiplayer-view'] ), 'Shortcode in any queried post receives assets.' );
reset_enqueues();
$GLOBALS['wp_query']->posts = array( (object) array( 'post_content' => '<!-- wp:lamgc/asciiplayer {"source":"/demo.cast"} /-->' ) );
enqueue_content_assets();
same( true, isset( $GLOBALS['ap_test']['enqueued_scripts']['lamgc-asciiplayer-view'] ), 'Block in query receives assets.' );
reset_enqueues();
$GLOBALS['wp_query']->posts = array( (object) array( 'post_content' => 'No players' ), 12 );
enqueue_content_assets();
same( array(), $GLOBALS['ap_test']['enqueued_scripts'], 'Ordinary content does not enqueue runtime.' );
render_shortcode( array(), '/widget.cast' );
print_late_styles();
print_late_styles();
same( array( 'lamgc-asciiplayer-style' ), $GLOBALS['ap_test']['style_print_calls'], 'Late/widget styles print once in the footer.' );
echo "OK: {$assertions} assertions passed.\n";
} catch ( Throwable $error ) {
fwrite( STDERR, $error->getMessage() . "\n" );
exit( 1 );
}