feat(extensions): 新增系统工具页面与前台投稿分片上传

This commit is contained in:
2026-10-06 11:09:05 +08:00
parent 613b8a57a3
commit b75bf197a3
25 changed files with 1982 additions and 58 deletions
@@ -58,6 +58,7 @@ namespace {
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsAudit.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsPush.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsSecurity.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsSystem.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensions.php';
}
@@ -0,0 +1,167 @@
<?php
/** Split upload (分片上传) runtime contract harness: chunk params, part naming, upload/status/merge protocol. */
declare(strict_types=1);
namespace {
define('ABSPATH', __DIR__ . '/');
define('HOUR_IN_SECONDS', 3600);
define('WP_CONTENT_DIR', sys_get_temp_dir() . '/aksrt-split-test-' . getmypid());
function sanitize_file_name(string $name): string { return preg_replace('/[^A-Za-z0-9._\-]/', '', $name) ?? ''; }
function __(string $text, string $domain = ''): string { return $text; }
function sanitize_key(string $key): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower($key)) ?? ''; }
function get_current_blog_id(): int { return 1; }
function get_current_user_id(): int { return 7; }
function apply_filters(string $tag, $value) { return $value; }
function wp_mkdir_p(string $dir): bool { return is_dir($dir) || mkdir($dir, 0777, true); }
function is_wp_error($thing): bool { return $thing instanceof \WP_Error; }
function size_format(int $bytes): string { return $bytes . 'B'; }
function get_allowed_mime_types(): array { return ['mp4' => 'video/mp4']; }
function wp_check_filetype_and_ext(string $file, string $name, array $mimes = []): array {
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
return ['ext' => $ext, 'type' => $mimes[$ext] ?? ''];
}
class WP_Error {
private array $errors;
public function __construct($code = '', $message = '') { $this->errors = [$code => $message]; }
public function get_error_message(): string { return (string) reset($this->errors); }
public function get_error_code() { return (string) array_key_first($this->errors); }
}
class WP_REST_Server {
const READABLE = 'GET';
}
class WP_REST_Response {
private array $data;
private int $status;
public function __construct($data = [], int $status = 200) { $this->data = $data; $this->status = $status; }
public function get_data(): array { return $this->data; }
public function get_status(): int { return $this->status; }
}
class WP_REST_Request {
}
class FakeRestRequest extends \WP_REST_Request {
private array $params;
private array $files;
public function __construct(array $params = [], array $files = []) {
$this->params = $params;
$this->files = $files;
}
public function get_param(string $key) { return $this->params[$key] ?? null; }
public function get_file_params(): array { return $this->files; }
}
function split_upload_check(bool $condition, string $message): void {
if (!$condition) throw new \RuntimeException($message);
}
require dirname(__DIR__, 2) . '/src/Core/FrontSubmitChunk.php';
}
namespace AKSRT\Core {
final class FakeFrontSubmit {
use \AKSRT\Core\FrontSubmitChunk;
public static int $limit_bytes = 10 * 1048576;
public static array $persisted = [];
public static function upload_limit_bytes(string $kind): int {
return self::$limit_bytes;
}
/** Stand-in for FrontSubmit::persist_uploaded_file(): records and echoes merged content. */
public function persist_uploaded_file(string $kind, array $file, bool $sideload = false) {
self::$persisted[] = ['kind' => $kind, 'file' => $file, 'sideload' => $sideload];
return 'persisted:' . (string) file_get_contents((string) $file['tmp_name']);
}
}
}
namespace {
use AKSRT\Core\FakeFrontSubmit;
$submit = new FakeFrontSubmit();
$temp_dir = WP_CONTENT_DIR . '/aksrt-temp';
// 1) Chunk parameter validation.
$err = $submit->rest_upload_chunk(new FakeRestRequest(['name' => '', 'chunks' => 3, 'chunk' => 0, 'size' => 10]));
split_upload_check($err instanceof \WP_Error, 'empty name should be rejected');
$err = $submit->rest_upload_chunk(new FakeRestRequest(['name' => 'a.mp4', 'chunks' => 0, 'chunk' => 0, 'size' => 10]));
split_upload_check($err instanceof \WP_Error, 'zero chunk count should be rejected');
$err = $submit->rest_upload_chunk(new FakeRestRequest(['name' => 'a.mp4', 'chunks' => 3, 'chunk' => 5, 'size' => 10]));
split_upload_check($err instanceof \WP_Error, 'out-of-range chunk index should be rejected');
// 2) Chunk upload lands per-index .part files, oversized total is refused.
$make_file = static function (string $content): string {
$path = tempnam(sys_get_temp_dir(), 'chunk');
file_put_contents($path, $content);
return $path;
};
$params = static fn (int $chunk, int $chunks, int $size): array => [
'kind' => 'video', 'chunk' => $chunk, 'chunks' => $chunks, 'name' => 'movie.mp4', 'size' => $size,
];
$response = $submit->rest_upload_chunk(new FakeRestRequest($params(0, 3, 6), ['file' => ['tmp_name' => $make_file('AAA'), 'error' => 0]]));
split_upload_check($response instanceof \WP_REST_Response, 'first chunk should upload');
split_upload_check($response->get_data()['uploaded_size'] === 3, 'uploaded_size should count the stored chunk');
$submit->rest_upload_chunk(new FakeRestRequest($params(2, 3, 6), ['file' => ['tmp_name' => $make_file('CCC'), 'error' => 0]]));
$parts = glob($temp_dir . '/*.part');
split_upload_check(count($parts) === 2, 'two part files should exist');
foreach ($parts as $part) {
split_upload_check(strpos(basename($part), '1-') === 0, 'part name should be blog-id prefixed');
}
// 3) Status reports the uploaded indexes (resume contract).
$response = $submit->rest_upload_chunk_status(new FakeRestRequest(['kind' => 'video', 'name' => 'movie.mp4', 'chunks' => 3]));
split_upload_check($response instanceof \WP_REST_Response, 'status should respond');
split_upload_check($response->get_data()['uploaded'] === [0, 2], 'status should list uploaded chunk indexes');
// 4) Merge refuses when a chunk is missing.
$err = $submit->rest_upload_chunk_merge(new FakeRestRequest(['kind' => 'video', 'name' => 'movie.mp4', 'chunks' => 3, 'size' => 9]));
split_upload_check($err instanceof \WP_Error && $err->get_error_code() === 'aksrt_chunk_missing', 'merge should refuse with missing chunks');
// 5) Merge with a size mismatch is refused.
$submit->rest_upload_chunk(new FakeRestRequest($params(1, 3, 6), ['file' => ['tmp_name' => $make_file('BBB'), 'error' => 0]]));
$err = $submit->rest_upload_chunk_merge(new FakeRestRequest(['kind' => 'video', 'name' => 'movie.mp4', 'chunks' => 3, 'size' => 999]));
split_upload_check($err instanceof \WP_Error && $err->get_error_code() === 'aksrt_chunk_size_mismatch', 'size mismatch should refuse the merge');
// 6) Happy-path merge concatenates in order, flags sideload, and cleans up.
$response = $submit->rest_upload_chunk_merge(new FakeRestRequest(['kind' => 'video', 'name' => 'movie.mp4', 'chunks' => 3, 'size' => 9]));
split_upload_check(is_string($response) && $response === 'persisted:AAABBBCCC', 'merge should concatenate chunks in order');
split_upload_check(count(FakeFrontSubmit::$persisted) === 1, 'merge should persist exactly once');
$call = FakeFrontSubmit::$persisted[0];
split_upload_check($call['kind'] === 'video' && $call['sideload'] === true, 'merge should persist as a sideloaded video');
split_upload_check($call['file']['name'] === 'movie.mp4' && $call['file']['size'] === 9, 'merge should rebuild the $_FILES-style payload');
split_upload_check(glob($temp_dir . '/*.part') === [], 'merge should clean up all parts');
// 7) Per-user keying: a different file name never sees another name's parts.
$response = $submit->rest_upload_chunk_status(new FakeRestRequest(['kind' => 'video', 'name' => 'other.mp4', 'chunks' => 3]));
split_upload_check($response instanceof \WP_REST_Response && $response->get_data()['uploaded'] === [], 'a different name should start with no uploaded chunks');
// 8) Chunk size cap refuses oversized chunk blobs.
$big = $make_file(str_repeat('x', 3 * 1048576));
$err = $submit->rest_upload_chunk(new FakeRestRequest($params(0, 1, 3 * 1048576), ['file' => ['tmp_name' => $big, 'error' => 0]]));
split_upload_check($err instanceof \WP_Error && $err->get_error_code() === 'aksrt_chunk_invalid_size', 'oversized chunk blobs should be refused');
// 9) Kind size limit caps cumulative uploads.
FakeFrontSubmit::$limit_bytes = 4;
$err = $submit->rest_upload_chunk(new FakeRestRequest($params(0, 2, 9), ['file' => ['tmp_name' => $make_file('AAAAA'), 'error' => 0]]));
split_upload_check($err instanceof \WP_Error && $err->get_error_code() === 'aksrt_upload_too_large', 'cumulative size over the kind limit should be refused');
// Cleanup.
FakeFrontSubmit::$persisted = [];
foreach ((array) glob(WP_CONTENT_DIR . '/*.part') as $leftover) {
@unlink((string) $leftover);
}
@unlink(WP_CONTENT_DIR . '/aksrt-temp/index.php');
@rmdir(WP_CONTENT_DIR . '/aksrt-temp');
@rmdir(WP_CONTENT_DIR);
echo "Split upload harness passed\n";
}
@@ -0,0 +1,229 @@
'use strict';
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const SYSTEM_FIELDS = [
'hide_admin_bar', 'remove_emoji', 'remove_open_sans', 'remove_more_wp_head',
'remove_dashboard_widgets', 'display_wp_update',
'split_upload_s', 'split_minimum_size', 'newfilename', 'newfilename_type',
'disabled_large_thumbnail',
'admin_user_del_fields', 'admin_frontend_set', 'close_gutenberg', 'disabled_autoembed',
'mce_float_toolbar', 'time_ago_s', 'time_format',
];
test('system tools schema registers the 系统工具 page under 扩展&增强', () => {
const schema = read('src/Admin/Schema/SchemaExtensionsSystem.php');
const registry = read('src/Admin/Schema/SchemaExtensions.php');
const clientMirror = read('assets/admin/src/schema/sections/extensions/system.js');
const clientIndex = read('assets/admin/src/schema/sections/extensions/index.js');
const nav = read('assets/admin/src/constants/sections/extensions.js');
const sectionDefaults = read('assets/admin/src/constants/sections/index.js');
assert.match(registry, /'system'\s+=> SchemaExtensionsSystem::build\(\)/);
assert.match(schema, /'key'\s+=> 'system'/);
assert.match(schema, /'title'\s+=> '系统工具'/);
for (const key of SYSTEM_FIELDS) {
assert.match(schema, new RegExp(`'${key}'`), `missing schema field ${key}`);
assert.match(clientMirror, new RegExp(`${key}`), `missing client mirror field ${key}`);
}
// Conditional fields mirror the reference theme's dependency rules.
assert.match(schema, /'newfilename_type'[^[]*'dependsOn'\s+=> \['key' => 'newfilename', 'value' => true\]/s);
assert.match(schema, /'split_minimum_size'[^[]*'dependsOn'\s+=> \['key' => 'split_upload_s', 'value' => true\]/s);
assert.match(schema, /'time_format'[^[]*'dependsOn'\s+=> \['key' => 'time_ago_s', 'operator' => 'falsy'\]/s);
assert.match(clientMirror, /split_minimum_size[\s\S]*dependsOn:\s*\{\s*key:\s*'split_upload_s',\s*value:\s*true\s*\}/);
assert.match(clientMirror, /time_format[\s\S]*dependsOn:\s*\{\s*key:\s*'time_ago_s',\s*operator:\s*'falsy'\s*\}/);
assert.match(clientIndex, /import \{ systemSchema \} from '\.\/system\.js'/);
assert.match(clientIndex, /system: systemSchema/);
assert.match(nav, /key: 'extensions-system'/);
assert.match(nav, /route: '\/extensions\/system'/);
assert.match(nav, /section: 'system'/);
// 系统工具 is the first child of 扩展&增强 (reference theme order) and the default landing page.
assert.ok(nav.indexOf('extensions-system') < nav.indexOf('extensions-ip'), '系统工具 should precede IP归属地');
assert.match(sectionDefaults, /extensions:\s*'extensions-system'/);
});
test('system tools settings are consumed at runtime and initialized on boot', () => {
const runtime = read('src/Settings/ExtensionRuntime.php');
const bootstrap = read('functions.php');
assert.match(runtime, /public static function get_system_tools_settings\(\): array/);
assert.match(runtime, /'newfilename_type'\s+=> \$rename_type/);
assert.match(runtime, /'split_minimum_size'\s+=> max\(4, min\(100, \(int\) \(\$settings\['split_minimum_size'\] \?\? 20\)\)\)/);
assert.match(bootstrap, /\\AKSRT\\Extensions\\SystemTools::get_instance\(\)->init\(\)/);
assert.match(bootstrap, /\\AKSRT\\Frontend\\PageAdminSet::get_instance\(\)->init\(\)/);
});
test('system tools switches wire the reference theme hook suite', () => {
const tools = read('src/Extensions/SystemTools.php');
assert.match(tools, /!empty\(\$settings\['hide_admin_bar'\]\) \|\| !is_super_admin\(\)/);
assert.match(tools, /remove_action\('wp_head', 'print_emoji_detection_script', 7\)/);
assert.match(tools, /remove_filter\('wp_mail', 'wp_staticize_emoji_for_email'\)/);
assert.match(tools, /add_action\('init', \[\$this, 'remove_open_sans'\]\)/);
assert.match(tools, /wp_deregister_style\('open-sans'\)/);
assert.match(tools, /add_action\('after_setup_theme', \[\$this, 'remove_more_wp_head'\]\)/);
assert.match(tools, /remove_action\('wp_head', 'wp_generator'\)/);
assert.match(tools, /add_action\('do_meta_boxes', \[\$this, 'remove_dashboard_widgets'\], 10, 3\)/);
assert.match(tools, /unset\(\$wp_meta_boxes\['dashboard'\]\['normal'\]\['high'\]\['dashboard_php_nag'\]\)/);
assert.match(tools, /add_filter\('site_status_tests', \[\$this, 'filter_site_status_tests'\]\)/);
assert.match(tools, /add_filter\('pre_http_request', \[\$this, 'block_site_health_requests'\], 10, 3\)/);
assert.match(tools, /'api\.wordpress\.org\/core\/serve-happy\/'/);
assert.match(tools, /remove_action\('admin_init', '_maybe_update_core'\)/);
assert.match(tools, /add_filter\('automatic_updater_disabled', '__return_true'\)/);
assert.match(tools, /remove_action\('wp_version_check', 'wp_version_check'\)/);
assert.match(tools, /add_action\('admin_menu', \[\$this, 'hide_update_notices'\]\)/);
assert.match(tools, /remove_action\('admin_notices', 'update_nag', 3\)/);
assert.match(tools, /remove_image_size\('1536x1536'\)/);
assert.match(tools, /add_filter\('intermediate_image_sizes', static function \(\$sizes\)/);
assert.match(tools, /add_filter\('use_block_editor_for_post', '__return_false'\)/);
assert.match(tools, /remove_filter\('the_content', \[\$GLOBALS\['wp_embed'\], 'autoembed'\], 8\)/);
assert.match(tools, /add_action\('show_user_profile', \[\$this, 'hide_user_profile_fields'\]\)/);
assert.match(tools, /add_action\('edit_user_profile', \[\$this, 'hide_user_profile_fields'\]\)/);
});
test('upload rename settings are canonical to 系统工具 and consumed by the upload pipeline', () => {
const permissionsSchema = read('src/Admin/Schema/SchemaPermissions.php');
const permissionsMirror = read('assets/admin/src/schema/sections/permissions/index.js');
const systemSchema = read('src/Admin/Schema/SchemaExtensionsSystem.php');
const uploadLimits = read('src/Attachment/UploadLimits.php');
assert.doesNotMatch(permissionsSchema, /newfilename/);
assert.doesNotMatch(permissionsMirror, /newfilename/);
assert.match(systemSchema, /'newfilename'/);
assert.match(uploadLimits, /add_filter\('wp_handle_upload_prefilter', \[\$this, 'rename_upload_file'\]/);
assert.match(uploadLimits, /\$settings\['newfilename_type'\] \?\? 'time'\) === 'random'/);
});
test('split upload ships a full chunk protocol: routes, merge pipeline, and client uploader', () => {
const chunk = read('src/Core/FrontSubmitChunk.php');
const frontSubmit = read('src/Core/FrontSubmit.php');
const editorJs = read('assets/js/front-submit-editor.js');
const submitPage = read('templates/pages/page-submit.php');
const schema = read('src/Admin/Schema/SchemaExtensionsSystem.php');
// The submit class consumes the chunk trait and registers its routes.
assert.match(frontSubmit, /use FrontSubmitChunk;/);
assert.match(frontSubmit, /\$this->register_chunk_routes\(\)/);
assert.match(frontSubmit, /public function persist_uploaded_file\(string \$kind, array \$file, bool \$sideload = false\)/);
// Backend mirrors the reference theme's user_split_upload protocol.
assert.match(chunk, /\/front-submit\/upload-chunk\/\(\?P<kind>image\|video\|resource\)/);
assert.match(chunk, /\/front-submit\/upload-merge\(\?P<kind>image\|video\|resource\)|\/front-submit\/upload-merge\/\(\?P<kind>image\|video\|resource\)/);
assert.match(chunk, /\/front-submit\/upload-status\/\(\?P<kind>image\|video\|resource\)/);
assert.match(chunk, /permission_callback' => \[\$this, 'can_upload_attachment'\]/);
assert.match(chunk, /const CHUNK_SIZE_BYTES = 2 \* 1048576;/);
assert.match(chunk, /md5\(get_current_blog_id\(\) \. '\|' \. get_current_user_id\(\) \. '\|' \. \$name\)/);
assert.match(chunk, /\$this->persist_uploaded_file\(\$params\['kind'\], \[/);
assert.match(chunk, /apply_filters\('aksrt_temp_dir', WP_CONTENT_DIR \. '\/aksrt-temp'\)/);
assert.match(chunk, /\$this->chunk_temp_cleanup\(\$temp_dir\)/);
// Whole-file upload and chunk merge share one persistence pipeline; merged
// files are not HTTP uploads, so the pipeline must use the sideload action.
assert.match(frontSubmit, /return \$this->persist_uploaded_file\(\$kind, \$file, false\);/);
assert.match(frontSubmit, /'action' => 'wp_handle_sideload'/);
assert.match(frontSubmit, /media_handle_upload\('aksrt_front_submit_file', 0, \[\], \$overrides\)/);
// Client uploader picks the chunked path past the threshold and resumes from status.
assert.match(editorJs, /function uploadInChunks\(file, type\)/);
assert.match(editorJs, /splitUploadConfig\(\)/);
assert.match(editorJs, /file\.size > split\.minSize \* 1048576/);
assert.match(editorJs, /chunkUrl\('upload-status'\)/);
assert.match(editorJs, /chunkUrl\('upload-chunk'\)/);
assert.match(editorJs, /chunkUrl\('upload-merge'\)/);
assert.match(editorJs, /'\/front-submit\/' \+ action \+ '\/' \+ uploadKey/);
// Config flows from the settings page into aksrtSubmitData.
assert.match(submitPage, /'splitUpload' => \[/);
assert.match(submitPage, /'enabled' => !empty\(\$system_tools\['split_upload_s'\]\)/);
assert.match(submitPage, /'minSize' => \(int\) \(\$system_tools\['split_minimum_size'\] \?\? 20\)/);
assert.match(schema, /'split_upload_s'/);
assert.match(schema, /'split_minimum_size'/);
// The legacy dead config keys stay out of the shared script payload.
assert.doesNotMatch(read('src/Core/ScriptsPart1c.php'), /is_split_upload|split_minimum_size/);
});
test('frontend page edit ships gate, float button, modal, and whitelisted save', () => {
const pageAdminSet = read('src/Frontend/PageAdminSet.php');
const pageAdminJs = read('assets/js/page-admin-set.js');
const floatButtons = read('inc/helpers/aksrt-float-buttons.php');
const bootstrap = read('functions.php');
assert.match(bootstrap, /\\AKSRT\\Frontend\\PageAdminSet::get_instance\(\)->init\(\)/);
assert.match(pageAdminSet, /public static function enabled\(\): bool/);
assert.match(pageAdminSet, /empty\(\$settings\['admin_frontend_set'\]/);
assert.match(pageAdminSet, /current_user_can\('manage_options'\)/);
assert.match(pageAdminSet, /is_page_template\('templates\/pages\/page-submit\.php'\)/);
assert.match(pageAdminSet, /is_page_template\('templates\/pages\/page-posts-navs\.php'\)/);
// Float bar gains the same extension point the reference theme hooks into.
assert.match(floatButtons, /apply_filters\('aksrt_float_right_buttons', \$btn, \$is_mobile\)/);
assert.match(pageAdminSet, /add_filter\('aksrt_float_right_buttons', \[\$this, 'float_button'\], 10, 2\)/);
assert.match(pageAdminSet, /data-aksrt-modal="remote"/);
assert.match(pageAdminSet, /check_ajax_referer\('aksrt_frontend_set', '_wpnonce'\)/);
// Modal + save endpoints; save is whitelisted and preserves other meta keys.
assert.match(pageAdminSet, /add_action\('wp_ajax_aksrt_frontend_set_modal', \[\$this, 'ajax_modal'\]\)/);
assert.match(pageAdminSet, /add_action\('wp_ajax_aksrt_frontend_set_save', \[\$this, 'ajax_save'\]\)/);
assert.match(pageAdminSet, /\\AKSRT\\Core\\PostMetaBox::META_KEY/);
assert.match(pageAdminSet, /\\AKSRT\\Core\\DomainModel::POST_VIEW_COUNT_KEY/);
assert.match(pageAdminSet, /update_post_meta\(\$post_id, '_aksrt_like_count', max\(0, \(int\) \$_POST\['like'\]\)\)/);
assert.match(pageAdminSet, /wp_update_term\(\$term_id, \(string\) \$term->taxonomy, \$data\)/);
assert.match(pageAdminSet, /set_post_format\(\$post_id, \$format === 'standard' \? '' : \$format\)/);
// Client submits through the shared modal runtime contract; the ajax action
// comes from the form's hidden field that the modal endpoint embeds.
assert.match(pageAdminJs, /form\.aksrt-admin-set-form/);
assert.match(pageAdminJs, /input\[name="action"\]/);
assert.match(pageAdminJs, /window\.location\.reload/);
assert.match(pageAdminSet, /'html' => \$html/);
});
test('float toolbar config reaches the tinymce plugin with reference-context buttons', () => {
const editorJs = read('assets/js/front-submit-editor.js');
const submitPage = read('templates/pages/page-submit.php');
const editorTemplate = read('templates/parts/submit/editor-main.php');
const schema = read('src/Admin/Schema/SchemaExtensionsSystem.php');
assert.match(submitPage, /\$float_toolbar = wp_is_mobile\(\) \? in_array\('m_s', \$float_scenes, true\) : in_array\('pc_s', \$float_scenes, true\)/);
assert.match(submitPage, /'floatToolbar'\s+=> \$float_toolbar/);
assert.match(editorJs, /if \(editorCfg\.floatToolbar\) \{\s*\n\s*registerFloatToolbar\(editor\);/);
assert.match(editorJs, /editor\.on\('wptoolbar', function \(event\)/);
assert.match(editorJs, /editor\.wp\._createToolbar\(bars\)/);
assert.match(editorJs, /editor\.addButton\('aksrt_h2'/);
assert.match(editorJs, /editor\.addButton\('aksrt_h3'/);
assert.match(editorJs, /editor\.addButton\('aksrt_remove'/);
assert.match(editorJs, /function floatToolbarButtons\(editor, event\)/);
assert.match(schema, /'mce_float_toolbar', 'label' => '经典编辑器增强浮动工具栏', 'type' => 'checkbox'/);
// The editor template still wires the plugin into wp_editor.
assert.match(editorTemplate, /aksrt_editor_tools/);
});
test('countdown display switch gates aksrt_time_ago with the custom format fallback', () => {
const helper = read('inc/helpers/template-tags/search-meta.php');
const coreHooks = read('inc/core-hooks.php');
assert.match(helper, /empty\(\$system\['time_ago_s'\]\) && \(\$system\['time_format'\] \?\? ''\) !== ''/);
assert.match(helper, /return date\(\$system\['time_format'\], \$time\)/);
// Admin bar gating moved out of the unconditional core hook into SystemTools.
assert.doesNotMatch(coreHooks, /add_filter\('show_admin_bar', '__return_false'\)/);
});
test('system tools runtime contract harness passes', () => {
const harness = path.join(root, 'tests', 'widget-system', 'system-tools-harness.php');
const output = execFileSync('php', [harness], { encoding: 'utf8' });
assert.match(output, /System tools harness passed/);
});
test('split upload runtime contract harness passes', () => {
const harness = path.join(root, 'tests', 'widget-system', 'split-upload-harness.php');
const output = execFileSync('php', [harness], { encoding: 'utf8' });
assert.match(output, /Split upload harness passed/);
});
@@ -0,0 +1,268 @@
<?php
/** System tools (系统工具) runtime contract harness: settings defaults, hook wiring, schema, time-format gate. */
declare(strict_types=1);
namespace {
define('ABSPATH', __DIR__ . '/');
$GLOBALS['aksrt_system_settings'] = [];
$GLOBALS['aksrt_system_added'] = [];
$GLOBALS['aksrt_system_removed'] = [];
function __(string $text, string $domain = ''): string { return $text; }
function sanitize_key(string $key): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower($key)) ?? ''; }
function is_super_admin(): bool { return (bool) $GLOBALS['aksrt_system_is_super_admin']; }
function current_time(string $type): string { return date('Y-m-d H:i:s'); }
function add_filter(string $hook, $callback, int $priority = 10, int $args = 1): void {
$GLOBALS['aksrt_system_added'][] = ['filter', $hook, $callback, $priority];
}
function add_action(string $hook, $callback, int $priority = 10, int $args = 1): void {
$GLOBALS['aksrt_system_added'][] = ['action', $hook, $callback, $priority];
}
function remove_filter(string $hook, $callback, int $priority = 10): void {
$GLOBALS['aksrt_system_removed'][] = ['filter', $hook, $callback, $priority];
}
function remove_action(string $hook, $callback, int $priority = 10): void {
$GLOBALS['aksrt_system_removed'][] = ['action', $hook, $callback, $priority];
}
function wp_deregister_style(string $handle): void {}
function wp_register_style(string $handle, $src): void {}
function wp_enqueue_style(string $handle, $src = ''): void {}
function remove_image_size(string $name): void {
$GLOBALS['aksrt_system_added'][] = ['remove_image_size', $name, null, 10];
}
function system_tools_check(bool $condition, string $message): void {
if (!$condition) throw new \RuntimeException($message);
}
function system_tools_added(string $type, string $hook): bool {
foreach ($GLOBALS['aksrt_system_added'] as $row) {
if ($row[0] === $type && $row[1] === $hook) return true;
}
return false;
}
function system_tools_removed(string $type, string $hook, string $needle = ''): bool {
foreach ($GLOBALS['aksrt_system_removed'] as $row) {
if ($row[0] !== $type || $row[1] !== $hook) continue;
if ($needle === '') return true;
if (is_array($row[2])) {
$parts = [];
foreach ($row[2] as $part) {
$parts[] = is_object($part) ? get_class($part) : (string) $part;
}
$callback = implode('|', $parts);
} elseif (is_object($row[2])) {
$callback = get_class($row[2]);
} else {
$callback = (string) $row[2];
}
if (strpos($callback, $needle) !== false) return true;
}
return false;
}
function system_tools_reset(): void {
$GLOBALS['aksrt_system_added'] = [];
$GLOBALS['aksrt_system_removed'] = [];
}
require dirname(__DIR__, 2) . '/src/Settings/ExtensionRuntime.php';
require dirname(__DIR__, 2) . '/src/Extensions/SystemTools.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsIP.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsAudit.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsPush.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsSecurity.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsSystem.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensions.php';
require dirname(__DIR__, 2) . '/inc/helpers/template-tags/search-meta.php';
}
namespace AKSRT\Settings {
final class Settings {
public static function get_all(): array { return $GLOBALS['aksrt_system_settings']; }
}
}
namespace AKSRT\SystemToolsHarness {
final class WpEmbedStub {
public function autoembed($content) { return $content; }
public function maybe_run_ajax_cache(): void {}
}
}
namespace {
use AKSRT\Admin\Schema\SchemaExtensions;
use AKSRT\Admin\Schema\SchemaExtensionsSystem;
use AKSRT\Extensions\SystemTools;
use AKSRT\Settings\ExtensionRuntime;
$GLOBALS['aksrt_system_is_super_admin'] = false;
$GLOBALS['wp_embed'] = new \AKSRT\SystemToolsHarness\WpEmbedStub();
// 1) Defaults match the reference theme's 系统工具 defaults.
$defaults = ExtensionRuntime::get_system_tools_settings();
$expected = [
'hide_admin_bar' => true,
'remove_emoji' => true,
'remove_open_sans' => true,
'remove_more_wp_head' => true,
'remove_dashboard_widgets' => true,
'display_wp_update' => true,
'split_upload_s' => true,
'split_minimum_size' => 20,
'newfilename' => false,
'newfilename_type' => 'time',
'disabled_large_thumbnail' => true,
'admin_user_del_fields' => true,
'admin_frontend_set' => true,
'close_gutenberg' => false,
'disabled_autoembed' => true,
'mce_float_toolbar' => ['pc_s', 'm_s'],
'time_ago_s' => true,
'time_format' => 'n月j日 H:i',
];
foreach ($expected as $key => $value) {
system_tools_check(array_key_exists($key, $defaults), "system tools setting missing: {$key}");
system_tools_check($defaults[$key] === $value, "system tools default wrong for {$key}");
}
// 2) Stored values sanitize: rename type whitelist, switch coercion, passthrough format.
$GLOBALS['aksrt_system_settings'] = [
'newfilename_type' => 'bogus',
'newfilename' => 1,
'time_ago_s' => 0,
'time_format' => 'Y/m/d H:i',
'close_gutenberg' => 'yes',
'split_minimum_size' => 500,
'mce_float_toolbar' => ['m_s', 'bogus', 'm_s'],
];
$clean = ExtensionRuntime::get_system_tools_settings();
system_tools_check($clean['newfilename_type'] === 'time', 'bogus rename type should fall back to time');
system_tools_check($clean['newfilename'] === true, 'truthy rename switch should coerce to true');
system_tools_check($clean['time_ago_s'] === false, 'falsy time_ago_s should coerce to false');
system_tools_check($clean['time_format'] === 'Y/m/d H:i', 'time format should pass through');
system_tools_check($clean['close_gutenberg'] === true, 'truthy gutenberg switch should coerce to true');
system_tools_check($clean['split_minimum_size'] === 100, 'split minimum size should clamp to the 4..100 range');
system_tools_check($clean['mce_float_toolbar'] === ['m_s'], 'float toolbar scenes should whitelist and dedupe');
// 2b) Unsaved float toolbar key keeps the both-scenes default; explicit empty stays empty.
$GLOBALS['aksrt_system_settings'] = ['hide_admin_bar' => 0];
$clean = ExtensionRuntime::get_system_tools_settings();
system_tools_check($clean['mce_float_toolbar'] === ['pc_s', 'm_s'], 'float toolbar should default to pc+mobile');
system_tools_check($clean['hide_admin_bar'] === false, 'stored falsy value should not fall back to default');
$GLOBALS['aksrt_system_settings'] = ['mce_float_toolbar' => []];
$clean = ExtensionRuntime::get_system_tools_settings();
system_tools_check($clean['mce_float_toolbar'] === [], 'explicitly empty float toolbar should stay empty');
// 3) Hook wiring with every switch enabled.
$GLOBALS['aksrt_system_settings'] = array_fill_keys(array_keys($expected), true);
$GLOBALS['aksrt_system_settings']['time_format'] = 'n月j日 H:i';
$GLOBALS['aksrt_system_is_super_admin'] = false;
system_tools_reset();
SystemTools::get_instance()->init();
system_tools_check(system_tools_added('filter', 'show_admin_bar'), 'hide_admin_bar should gate show_admin_bar');
system_tools_check(system_tools_added('action', 'init'), 'remove_open_sans should hook init');
system_tools_check(system_tools_added('action', 'after_setup_theme'), 'head cleanup and thumbnail pruning should hook after_setup_theme');
system_tools_check(system_tools_added('action', 'do_meta_boxes'), 'dashboard widget removal should hook do_meta_boxes');
system_tools_check(system_tools_added('filter', 'site_status_tests'), 'site status tests should be filtered');
system_tools_check(system_tools_added('filter', 'pre_http_request'), 'site health http requests should be blocked');
system_tools_check(system_tools_added('action', 'admin_menu'), 'update notices should hook admin_menu');
system_tools_check(system_tools_added('filter', 'automatic_updater_disabled'), 'automatic updates should be disabled');
system_tools_check(system_tools_added('filter', 'use_block_editor_for_post'), 'close_gutenberg should gate the block editor');
system_tools_check(system_tools_added('action', 'show_user_profile'), 'user profile cleanup should hook show_user_profile');
system_tools_check(system_tools_added('action', 'edit_user_profile'), 'user profile cleanup should hook edit_user_profile');
system_tools_check(system_tools_removed('action', 'admin_print_scripts', 'print_emoji_detection_script'), 'emoji scripts should be removed');
system_tools_check(system_tools_removed('filter', 'the_content', 'autoembed'), 'auto embed should be removed from the_content');
system_tools_check(system_tools_removed('action', 'admin_init', '_maybe_update_core'), 'core update checks should be removed');
system_tools_check(system_tools_removed('action', 'init', 'wp_schedule_update_checks'), 'update cron schedule should be removed');
// 4) hide_admin_bar off keeps the admin bar for super admins.
$GLOBALS['aksrt_system_settings']['hide_admin_bar'] = false;
$GLOBALS['aksrt_system_is_super_admin'] = true;
system_tools_reset();
SystemTools::get_instance()->init();
system_tools_check(!system_tools_added('filter', 'show_admin_bar'), 'super admin should keep the admin bar when hide_admin_bar is off');
// 5) Dashboard widget removal edits the dashboard meta boxes only in the normal context.
$tools = SystemTools::get_instance();
$GLOBALS['wp_meta_boxes'] = [
'dashboard' => [
'normal' => [
'high' => ['dashboard_php_nag' => ['id' => 'dashboard_php_nag']],
'core' => ['dashboard_site_health' => ['id' => 'dashboard_site_health']],
],
'side' => [
'core' => ['dashboard_primary' => ['id' => 'dashboard_primary']],
],
],
];
$tools->remove_dashboard_widgets('dashboard', 'normal');
system_tools_check(!isset($GLOBALS['wp_meta_boxes']['dashboard']['normal']['high']['dashboard_php_nag']), 'php nag widget should be removed');
system_tools_check(!isset($GLOBALS['wp_meta_boxes']['dashboard']['normal']['core']['dashboard_site_health']), 'site health widget should be removed');
system_tools_check(!isset($GLOBALS['wp_meta_boxes']['dashboard']['side']['core']['dashboard_primary']), 'primary widget should be removed');
$GLOBALS['wp_meta_boxes']['dashboard']['side']['core']['dashboard_primary'] = ['id' => 'dashboard_primary'];
$tools->remove_dashboard_widgets('dashboard', 'side');
system_tools_check(isset($GLOBALS['wp_meta_boxes']['dashboard']['side']['core']['dashboard_primary']), 'non-normal context should be ignored');
// 6) Site status tests drop page cache and php version probes.
$tests = $tools->filter_site_status_tests([
'async' => ['page_cache' => [], 'dilation' => []],
'direct' => ['php_version' => [], 'updates' => []],
]);
system_tools_check(!isset($tests['async']['page_cache']) && isset($tests['async']['dilation']), 'page_cache test should be dropped');
system_tools_check(!isset($tests['direct']['php_version']) && isset($tests['direct']['updates']), 'php_version test should be dropped');
// 7) Health endpoint blocking is scoped to version/browser/credits checks.
$blocked = $tools->block_site_health_requests(null, [], 'https://api.wordpress.org/core/serve-happy/1.0/');
system_tools_check(is_array($blocked) && $blocked['body'] === '', 'serve-happy requests should be blocked');
$blocked = $tools->block_site_health_requests(null, [], 'https://api.wordpress.org/core/browse-happy/1.0/');
system_tools_check(is_array($blocked) && $blocked['body'] === '', 'browse-happy requests should be blocked');
$passed = $tools->block_site_health_requests('passthrough', [], 'https://api.wordpress.org/plugins/update-check/1.0/');
system_tools_check($passed === 'passthrough', 'update checks should pass through the blocker');
// 8) Large thumbnail pruning removes the reference theme's size list.
$GLOBALS['aksrt_system_added'] = [];
$tools->disable_large_thumbnail();
$sizes_filter = null;
foreach ($GLOBALS['aksrt_system_added'] as $row) {
if ($row[0] === 'filter' && $row[1] === 'intermediate_image_sizes') {
$sizes_filter = $row[2];
}
}
system_tools_check(is_callable($sizes_filter), 'intermediate_image_sizes filter should be registered');
system_tools_check($sizes_filter(['medium_large', '1536x1536', '2048x2048', 'thumbnail']) === ['thumbnail'], 'medium_large/1536x1536/2048x2048 should be pruned');
// 9) Schema registry exposes the 系统工具 page with all fields.
$sections = SchemaExtensions::build()['sections'];
system_tools_check(isset($sections['system']) && $sections['system']['title'] === '系统工具', 'system section should register under extensions');
$keys = [];
foreach ($sections['system']['groups'] as $group) {
foreach ($group['fields'] as $field) {
$keys[] = $field['key'];
}
}
system_tools_check(count($keys) === count(array_unique($keys)), 'system schema field keys must be unique');
foreach (array_keys($expected) as $key) {
system_tools_check(in_array($key, $keys, true), "system schema missing field {$key}");
}
$client_defaults = array_keys($expected);
system_tools_check(count(SchemaExtensionsSystem::build()['groups']) === 3, 'system schema should keep three groups');
// 10) aksrt_time_ago honors the 倒计时显示 switch and custom format.
$past = date('Y-m-d H:i:s', strtotime('-3 days'));
$GLOBALS['aksrt_system_settings'] = [];
system_tools_check(strpos(aksrt_time_ago($past), '天前') !== false, 'relative time should be used by default');
$GLOBALS['aksrt_system_settings'] = ['time_ago_s' => 0, 'time_format' => 'Y年n月j日'];
$stamp = strtotime('-3 days');
system_tools_check(aksrt_time_ago(date('Y-m-d H:i:s', $stamp)) === date('Y年n月j日', $stamp), 'custom time format should apply when countdown display is off');
$GLOBALS['aksrt_system_settings'] = ['time_ago_s' => 0, 'time_format' => ''];
system_tools_check(strpos(aksrt_time_ago($past), '天前') !== false, 'empty custom format should fall back to relative time');
$GLOBALS['aksrt_system_settings'] = ['time_ago_s' => 1];
system_tools_check(strpos(aksrt_time_ago($past), '天前') !== false, 'countdown display should stay relative when enabled');
echo "System tools harness passed\n";
}