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
+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 );
}