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