From 0696419e8e88fcc829dba8aa1d00f7cb91388d6a Mon Sep 17 00:00:00 2001 From: Ryan Leeson Date: Tue, 7 Jul 2026 12:20:16 -0400 Subject: [PATCH 1/6] test: cover the build command and debug diagnostics; add loader timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing: - Add tests/Bin/GenerateClassCacheTest: shells out to the actual tenup-framework-generate-class-cache script (as CI would) and covers the generate path, no-args usage/exit-1, a missing directory failing while valid ones still cache, and multi-directory runs. Backed by small, salient example loader directories under tests/examples/ (a real ModuleInterface module, a plain support class, and a second directory). - Cover the previously untested LoaderDebug branches: legacy_files() detection, every cache_state() variant, and the staleness "up to date" path. - Replace the vacuous assertGreaterThanOrEqual(0, ...) in test_it_can_find_classes_to_register with an assertion that the registered set is non-empty and contains only ModuleInterface implementations. Loader timing: - ModuleInitialization::init_classes() now times discovery (cache read or live scan) and class lookup (reflection/instantiation/registration) separately and records both on the loader debug record. - The debug page shows both timings per loader, and the staleness check reports how long its live discovery ran — so the cache's saving on a given site is measurable. New format_duration() helper picks a sensible unit. phpcs, phpstan (level 10) and phpunit all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + docs/Debugging.md | 9 +- src/Debug/LoaderDebug.php | 48 +++- src/ModuleInitialization.php | 39 ++- tests/Bin/GenerateClassCacheTest.php | 232 ++++++++++++++++++ tests/Debug/LoaderDebugTest.php | 187 +++++++++++++- tests/ModuleInitializationTest.php | 36 ++- tests/examples/README.md | 20 ++ .../plugin-inc/Modules/GreetingModule.php | 40 +++ .../examples/plugin-inc/Support/Formatter.php | 28 +++ tests/examples/second-inc/Widgets/Card.php | 26 ++ 11 files changed, 638 insertions(+), 28 deletions(-) create mode 100644 tests/Bin/GenerateClassCacheTest.php create mode 100644 tests/examples/README.md create mode 100644 tests/examples/plugin-inc/Modules/GreetingModule.php create mode 100644 tests/examples/plugin-inc/Support/Formatter.php create mode 100644 tests/examples/second-inc/Widgets/Card.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 127735c..f4820f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file, per [the Ke ### Added - Build-time class-cache generation: a `tenup-framework-generate-class-cache` command (installed to `vendor/bin/`) and a `composer generate-class-cache` alias that build the cache in CI without bootstrapping WordPress. See [Build and Deployment](docs/Build-and-Deployment.md) ([#30](https://github.com/10up/wp-framework/issues/30)). - Hidden admin page (`admin.php?page=tenup-framework-loaders`, `manage_options`) that aggregates every class-loader cache on the site — across all framework copies — and shows each cache's path, status, loaded classes, and an on-demand live-vs-cache staleness check. Admin-only (no front-end overhead) and read-only. Disable with the `tenup_framework_enable_loader_debug` filter or the `TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG` constant. See [Debugging class loaders](docs/Debugging.md). +- The loader debug page reports per-loader timing: how long class **discovery** took (a cache read when cached, a live filesystem scan otherwise) and how long **class lookup** (reflection, instantiation and registration) took. The staleness check also reports how long its live discovery ran, so the cache's saving on a given site is measurable. ### Changed - The class-loader cache is now **read-only at runtime** and opt-in. The framework reads a pre-built cache if present and discovers live otherwise, but never writes one on the server — fixing stale caches that could only be cleared by hand ([#30](https://github.com/10up/wp-framework/issues/30)). diff --git a/docs/Debugging.md b/docs/Debugging.md index bf219e4..a1f3c71 100644 --- a/docs/Debugging.md +++ b/docs/Debugging.md @@ -33,6 +33,11 @@ page aggregates every loader recorded across all of them — even copies that ar one (usually leftovers from an older framework version). - **Classes loaded** — every class the loader resolved, with the file each one lives in. A class that no longer resolves is flagged as a likely stale entry. +- **Discovery time** — how long this request spent obtaining the class list. With a cache present + this is the cost of reading it; uncached it is the cost of a live filesystem scan, so the two + states can be compared directly. +- **Class lookup time** — how long reflecting, instantiating and registering the discovered + classes took. ## Staleness check @@ -42,7 +47,9 @@ directory and diffs the result against what the cache loaded, listing: - classes **on disk but missing from the cache** (the cache is behind), and - classes **in the cache but no longer on disk** (renamed/removed). -The check runs only when clicked, so the page itself stays cheap. If it reports drift, the cache +It also reports **how long the live discovery took**, which — compared against the cached +**Discovery time** above — shows what the cache is actually saving on this site. The check runs +only when clicked, so the page itself stays cheap. If it reports drift, the cache is stale: regenerate it in your build (`composer generate-class-cache`) or remove the file and redeploy. The page is **read-only** — it never deletes or rewrites a cache, consistent with the read-only runtime. diff --git a/src/Debug/LoaderDebug.php b/src/Debug/LoaderDebug.php index 1edaa02..c898307 100644 --- a/src/Debug/LoaderDebug.php +++ b/src/Debug/LoaderDebug.php @@ -253,6 +253,8 @@ protected static function render_loader( array $loader, string $check ) { self::render_row( __( 'Framework version', 'tenup-framework' ), self::version_label( $loader ) ); self::render_row( __( 'Cache file', 'tenup-framework' ), '' !== $cache_file ? $cache_file : '—' ); self::render_row( __( 'Cache detail', 'tenup-framework' ), self::cache_detail( $loader ) ); + self::render_row( __( 'Discovery time', 'tenup-framework' ), self::format_duration( $loader['discovery_seconds'] ?? null ) ); + self::render_row( __( 'Class lookup time', 'tenup-framework' ), self::format_duration( $loader['lookup_seconds'] ?? null ) ); echo ''; echo '
'; @@ -340,18 +342,27 @@ protected static function render_staleness( string $directory, array $classes, s return; } - $live = ModuleInitialization::instance()->discover_live( $directory ); + $live_start = microtime( true ); + $live = ModuleInitialization::instance()->discover_live( $directory ); + $live_seconds = microtime( true ) - $live_start; + $loaded = array_values( $classes ); $removed = array_diff( $loaded, $live ); // In cache but no longer on disk. $added = array_diff( $live, $loaded ); // On disk but missing from the cache. + $timing = sprintf( + /* translators: %s: formatted duration. */ + __( 'Live discovery took %s.', 'tenup-framework' ), + self::format_duration( $live_seconds ) + ); + if ( empty( $removed ) && empty( $added ) ) { - echo '
' . esc_html__( 'Up to date — the cache matches a live scan.', 'tenup-framework' ) . '
'; + echo '
' . esc_html__( 'Up to date — the cache matches a live scan.', 'tenup-framework' ) . ' ' . esc_html( $timing ) . '
'; return; } echo '
'; - echo '' . esc_html__( 'Stale — the cache differs from a live scan.', 'tenup-framework' ) . ''; + echo '' . esc_html__( 'Stale — the cache differs from a live scan.', 'tenup-framework' ) . ' ' . esc_html( $timing ); if ( ! empty( $added ) ) { echo '

' . esc_html__( 'On disk but missing from the cache:', 'tenup-framework' ) . '

    '; @@ -385,6 +396,37 @@ protected static function to_string( $value ): string { return is_scalar( $value ) ? (string) $value : ''; } + /** + * Format a duration in seconds for display, choosing a sensible unit. Values arrive through + * a filter as mixed, so anything non-numeric or non-positive renders as a placeholder. + * + * @param mixed $seconds The duration in seconds. + * + * @return string + */ + protected static function format_duration( $seconds ): string { + $seconds = is_numeric( $seconds ) ? (float) $seconds : 0.0; + + if ( $seconds <= 0.0 ) { + return '—'; + } + + $milliseconds = $seconds * 1000; + + if ( $milliseconds < 1 ) { + /* translators: %s: duration in milliseconds. */ + return sprintf( __( '%s ms', 'tenup-framework' ), number_format( $milliseconds, 3 ) ); + } + + if ( $milliseconds < 1000 ) { + /* translators: %s: duration in milliseconds. */ + return sprintf( __( '%s ms', 'tenup-framework' ), number_format( $milliseconds, 2 ) ); + } + + /* translators: %s: duration in seconds. */ + return sprintf( __( '%s s', 'tenup-framework' ), number_format( $seconds, 2 ) ); + } + /** * A stable, opaque token identifying a loader directory in the check link. * diff --git a/src/ModuleInitialization.php b/src/ModuleInitialization.php index 9f00ed0..f009401 100644 --- a/src/ModuleInitialization.php +++ b/src/ModuleInitialization.php @@ -217,12 +217,14 @@ public function discover_live( $dir ) { * gathered there. The is_admin() check happens before LoaderDebug is referenced, so that * class never autoloads on the front end. * - * @param string $dir The directory that was discovered. - * @param array $classes The discovered class names. + * @param string $dir The directory that was discovered. + * @param array $classes The discovered class names. + * @param float $discovery_seconds Seconds spent obtaining the class list (cache read or live scan). + * @param float $lookup_seconds Seconds spent reflecting, instantiating and registering the classes. * * @return void */ - protected function record_loader_debug( $dir, array $classes ) { + protected function record_loader_debug( $dir, array $classes, float $discovery_seconds = 0.0, float $lookup_seconds = 0.0 ) { if ( ! function_exists( 'is_admin' ) || ! is_admin() ) { return; } @@ -233,14 +235,16 @@ protected function record_loader_debug( $dir, array $classes ) { LoaderDebug::record( [ - 'directory' => $dir, - 'cache_file' => $cache_file, - 'cache_exists' => $cache_exists, - 'cache_used' => $cache_exists && ! $disabled, - 'cache_disabled' => $disabled, - 'classes' => $classes, - 'version' => $this->framework_version(), - 'reference' => $this->framework_reference(), + 'directory' => $dir, + 'cache_file' => $cache_file, + 'cache_exists' => $cache_exists, + 'cache_used' => $cache_exists && ! $disabled, + 'cache_disabled' => $disabled, + 'classes' => $classes, + 'version' => $this->framework_version(), + 'reference' => $this->framework_reference(), + 'discovery_seconds' => $discovery_seconds, + 'lookup_seconds' => $lookup_seconds, ] ); } @@ -303,9 +307,14 @@ protected function directory_check( $dir ): bool { public function init_classes( $dir = '' ) { $this->directory_check( $dir ); - $classes = $this->get_classes( $dir ); + // Time discovery (a cache read when a cache is present, a live filesystem scan + // otherwise) separately from the reflection/instantiation work below, so the debug + // page can show where the request's time actually goes. + $discovery_start = microtime( true ); + $classes = $this->get_classes( $dir ); + $discovery_seconds = microtime( true ) - $discovery_start; - $this->record_loader_debug( $dir, $classes ); + $lookup_start = microtime( true ); $load_class_order = []; foreach ( $classes as $class ) { @@ -366,6 +375,10 @@ public function init_classes( $dir = '' ) { } } } + + $lookup_seconds = microtime( true ) - $lookup_start; + + $this->record_loader_debug( $dir, $classes, $discovery_seconds, $lookup_seconds ); } /** diff --git a/tests/Bin/GenerateClassCacheTest.php b/tests/Bin/GenerateClassCacheTest.php new file mode 100644 index 0000000..81d10db --- /dev/null +++ b/tests/Bin/GenerateClassCacheTest.php @@ -0,0 +1,232 @@ + + */ + private $temp_dirs = []; + + /** + * Remove any temporary directories created during the test. + * + * @return void + */ + protected function tearDown(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + foreach ( $this->temp_dirs as $dir ) { + $this->remove_dir( $dir ); + } + $this->temp_dirs = []; + + parent::tearDown(); + } + + /** + * Running the command against a directory writes a readable cache of its classes. + * + * @return void + */ + public function test_generates_a_cache_for_a_directory() { + $dir = $this->example_copy( 'plugin-inc' ); + + $result = $this->run_bin( [ $dir ] ); + + $this->assertSame( 0, $result['exit'], $result['stderr'] ); + $this->assertStringContainsString( 'Cached', $result['stdout'] ); + + $cache_file = $this->cache_file_path( $dir ); + $this->assertFileExists( $cache_file ); + + $cached = require $cache_file; + $this->assertContains( 'TenupFrameworkExamples\\Modules\\GreetingModule', $cached ); + $this->assertContains( 'TenupFrameworkExamples\\Support\\Formatter', $cached ); + } + + /** + * With no arguments the command prints usage to stderr and exits non-zero. + * + * @return void + */ + public function test_reports_usage_and_fails_without_arguments() { + $result = $this->run_bin( [] ); + + $this->assertSame( 1, $result['exit'] ); + $this->assertStringContainsString( 'Usage:', $result['stderr'] ); + } + + /** + * A missing directory fails that directory (non-zero exit, error on stderr) but the command + * still processes the directories that are valid. + * + * @return void + */ + public function test_missing_directory_fails_but_valid_directories_still_cache() { + $good = $this->example_copy( 'plugin-inc' ); + $missing = sys_get_temp_dir() . '/tenup_bin_missing_' . uniqid( '', true ); + + $result = $this->run_bin( [ $missing, $good ] ); + + $this->assertSame( 1, $result['exit'] ); + $this->assertStringContainsString( $missing, $result['stderr'] ); + $this->assertStringContainsString( 'Failed to generate cache', $result['stderr'] ); + + // The valid directory was still cached despite the earlier failure. + $this->assertFileExists( $this->cache_file_path( $good ) ); + } + + /** + * Several directories can be cached in a single invocation. + * + * @return void + */ + public function test_caches_multiple_directories() { + $first = $this->example_copy( 'plugin-inc' ); + $second = $this->example_copy( 'second-inc' ); + + $result = $this->run_bin( [ $first, $second ] ); + + $this->assertSame( 0, $result['exit'], $result['stderr'] ); + $this->assertFileExists( $this->cache_file_path( $first ) ); + $this->assertFileExists( $this->cache_file_path( $second ) ); + + $cached = require $this->cache_file_path( $second ); + $this->assertContains( 'TenupFrameworkExamples\\Widgets\\Card', $cached ); + } + + /** + * Run the bin script with the given arguments, returning its stdout, stderr and exit code. + * + * @param array $args The arguments to pass after the script name. + * + * @return array{stdout: string, stderr: string, exit: int} + */ + private function run_bin( array $args ): array { + $script = dirname( __DIR__, 2 ) . '/bin/tenup-framework-generate-class-cache'; + $command = array_map( 'escapeshellarg', array_merge( [ PHP_BINARY, $script ], $args ) ); + + $descriptors = [ + 1 => [ 'pipe', 'w' ], + 2 => [ 'pipe', 'w' ], + ]; + + $process = proc_open( implode( ' ', $command ), $descriptors, $pipes ); + $this->assertIsResource( $process ); + + $stdout = (string) stream_get_contents( $pipes[1] ); + $stderr = (string) stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + + $exit = proc_close( $process ); + + return [ + 'stdout' => $stdout, + 'stderr' => $stderr, + 'exit' => $exit, + ]; + } + + /** + * Copy an example directory into a fresh temp directory so the command can write a cache + * into it without touching the committed examples. + * + * @param string $name The example directory name under tests/examples. + * + * @return string The path to the temp copy. + */ + private function example_copy( string $name ): string { + $source = __DIR__ . '/../examples/' . $name; + $target = sys_get_temp_dir() . '/tenup_bin_' . $name . '_' . uniqid( '', true ); + + $this->copy_dir( $source, $target ); + $this->temp_dirs[] = $target; + + return $target; + } + + /** + * The absolute path to the cache file the command writes for a directory. + * + * @param string $dir The discovery directory. + * + * @return string + */ + private function cache_file_path( string $dir ): string { + return $dir . '/' . ModuleInitialization::CACHE_DIR_NAME . '/' . ModuleInitialization::CACHE_FILENAME; + } + + /** + * Recursively copy a directory. + * + * @param string $source The source directory. + * @param string $target The target directory. + * + * @return void + */ + private function copy_dir( string $source, string $target ): void { + mkdir( $target, 0777, true ); + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $source, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ( $items as $item ) { + $destination = $target . '/' . $items->getSubPathname(); + if ( $item->isDir() ) { + mkdir( $destination, 0777, true ); + } else { + copy( $item->getPathname(), $destination ); + } + } + } + + /** + * Recursively remove a directory. + * + * @param string $dir The directory to remove. + * + * @return void + */ + private function remove_dir( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $items as $item ) { + if ( $item->isDir() ) { + rmdir( $item->getPathname() ); + } else { + unlink( $item->getPathname() ); + } + } + + rmdir( $dir ); + } +} diff --git a/tests/Debug/LoaderDebugTest.php b/tests/Debug/LoaderDebugTest.php index d315a66..8d8f5fe 100644 --- a/tests/Debug/LoaderDebugTest.php +++ b/tests/Debug/LoaderDebugTest.php @@ -30,14 +30,16 @@ class LoaderDebugTest extends TestCase { */ private function sample_record( string $directory = '/srv/site/wp-content/plugins/demo/inc' ): array { return [ - 'directory' => $directory, - 'cache_file' => $directory . '/class-loader-cache/class-loader-cache-v2.php', - 'cache_exists' => false, - 'cache_used' => false, - 'cache_disabled' => false, - 'classes' => [ 'TenupTmp\\Widget' ], - 'version' => '1.3.0', - 'reference' => 'abcdef1234567890', + 'directory' => $directory, + 'cache_file' => $directory . '/class-loader-cache/class-loader-cache-v2.php', + 'cache_exists' => false, + 'cache_used' => false, + 'cache_disabled' => false, + 'classes' => [ 'TenupTmp\\Widget' ], + 'version' => '1.3.0', + 'reference' => 'abcdef1234567890', + 'discovery_seconds' => 0.0123, + 'lookup_seconds' => 0.0456, ]; } @@ -171,6 +173,12 @@ public function test_render_page_lists_loaders_and_classes() { $this->assertStringContainsString( '/srv/site/wp-content/plugins/demo/inc', $output ); $this->assertStringContainsString( 'TenupTmp\\Widget', $output ); $this->assertStringContainsString( 'Check this cache for staleness', $output ); + + // The recorded discovery/lookup timings are surfaced on the page. + $this->assertStringContainsString( 'Discovery time', $output ); + $this->assertStringContainsString( 'Class lookup time', $output ); + $this->assertStringContainsString( '12.30 ms', $output ); // 0.0123s discovery. + $this->assertStringContainsString( '45.60 ms', $output ); // 0.0456s lookup. } /** @@ -222,6 +230,169 @@ public function test_render_page_reports_staleness_drift() { $this->assertStringContainsString( 'TenupTmp\\Old', $output ); // In cache, gone from disk. } + /** + * render_page() confirms an up-to-date cache and reports how long the live scan took when a + * staleness check is requested and the loaded list matches disk. + * + * @return void + */ + public function test_render_page_reports_up_to_date_and_timing() { + $this->stub_render_environment(); + when( 'wp_verify_nonce' )->justReturn( true ); + + $dir = $this->make_temp_class_dir(); + + // The loaded list matches what is actually on disk (the single Widget class). + $record = $this->sample_record( $dir ); + $record['classes'] = [ 'TenupTmp\\Widget' ]; + LoaderDebug::record( $record ); + + $_GET['check'] = md5( $dir ); + $_GET['_wpnonce'] = 'test'; + + $output = $this->capture_render(); + + unset( $_GET['check'], $_GET['_wpnonce'] ); + $this->remove_temp_dir( $dir ); + + $this->assertStringContainsString( 'Up to date', $output ); + $this->assertStringContainsString( 'Live discovery took', $output ); + } + + /** + * cache_state() maps each combination of the record flags to the expected severity and badge. + * + * @dataProvider cache_state_provider + * + * @param array $flags The cache_* flags to set on the record. + * @param string $expected_sev The expected severity. + * @param string $expected_snippet A substring expected in the badge. + * + * @return void + */ + public function test_cache_state_resolves_expected_states( array $flags, string $expected_sev, string $expected_snippet ) { + $state = $this->invoke_protected( 'cache_state', [ array_merge( $this->sample_record(), $flags ) ] ); + + $this->assertSame( $expected_sev, $state['severity'] ); + $this->assertStringContainsString( $expected_snippet, $state['badge'] ); + } + + /** + * Data for test_cache_state_resolves_expected_states. + * + * @return array, 1: string, 2: string}> + */ + public function cache_state_provider(): array { + return [ + 'disabled' => [ [ 'cache_disabled' => true ], 'warn', 'disabled' ], + 'uncached' => [ + [ + 'cache_exists' => false, + 'cache_used' => false, + ], + 'warn', + 'Uncached', + ], + 'present but unused' => [ + [ + 'cache_exists' => true, + 'cache_used' => false, + ], + 'error', + 'not used', + ], + 'in use' => [ + [ + 'cache_exists' => true, + 'cache_used' => true, + ], + 'ok', + 'in use', + ], + ]; + } + + /** + * legacy_files() reports files in the cache directory that are not the current cache file, + * and nothing when the directory is clean or absent. + * + * @return void + */ + public function test_legacy_files_detects_unexpected_files() { + $dir = $this->make_temp_class_dir(); + $cache_dir = $dir . '/class-loader-cache'; + mkdir( $cache_dir ); + + $current = $cache_dir . '/class-loader-cache-v2.php'; + file_put_contents( $current, 'assertSame( [], $this->invoke_protected( 'legacy_files', [ $current ] ) ); + + // A leftover file from an older version is reported. + file_put_contents( $cache_dir . '/discoverer-cache-TenupFramework', 'x' ); + $found = $this->invoke_protected( 'legacy_files', [ $current ] ); + $this->assertContains( 'discoverer-cache-TenupFramework', $found ); + $this->assertNotContains( 'class-loader-cache-v2.php', $found ); + + $this->remove_temp_dir( $dir ); + } + + /** + * legacy_files() is empty when the cache directory does not exist. + * + * @return void + */ + public function test_legacy_files_empty_when_directory_absent() { + $missing = sys_get_temp_dir() . '/tenup_missing_' . uniqid( '', true ) . '/class-loader-cache-v2.php'; + + $this->assertSame( [], $this->invoke_protected( 'legacy_files', [ $missing ] ) ); + } + + /** + * format_duration() picks a sensible unit and renders a placeholder for non-positive input. + * + * @dataProvider duration_provider + * + * @param mixed $seconds The duration in seconds. + * @param string $expected The expected rendered string. + * + * @return void + */ + public function test_format_duration( $seconds, string $expected ) { + $this->assertSame( $expected, $this->invoke_protected( 'format_duration', [ $seconds ] ) ); + } + + /** + * Data for test_format_duration. + * + * @return array + */ + public function duration_provider(): array { + return [ + 'zero' => [ 0.0, '—' ], + 'non-numeric' => [ 'nope', '—' ], + 'sub-milli' => [ 0.0004, '0.400 ms' ], + 'milliseconds' => [ 0.0123, '12.30 ms' ], + 'seconds' => [ 1.5, '1.50 s' ], + ]; + } + + /** + * Invoke a protected static method on LoaderDebug via reflection. + * + * @param string $method The method name. + * @param array $args The arguments. + * + * @return mixed + */ + private function invoke_protected( string $method, array $args ) { + $reflection = ( new \ReflectionClass( LoaderDebug::class ) )->getMethod( $method ); + $reflection->setAccessible( true ); + + return $reflection->invokeArgs( null, $args ); + } + /** * Stub everything render_page() touches, with the tooling enabled and the current user * capable. apply_filters returns this copy's records for the aggregation filter. diff --git a/tests/ModuleInitializationTest.php b/tests/ModuleInitializationTest.php index 6a5e683..9b349f9 100644 --- a/tests/ModuleInitializationTest.php +++ b/tests/ModuleInitializationTest.php @@ -42,11 +42,14 @@ public function test_it_can_find_classes() { */ public function test_it_can_find_classes_to_register() { $class = \TenupFramework\ModuleInitialization::instance(); - $class->init_classes( dirname( __DIR__, 1 ) . '/src/' ); + $class->init_classes( dirname( __DIR__, 1 ) . '/fixtures/classes' ); $classes = $class->get_all_classes(); - // Check that we have only classes that extend Module and more than 0. - $this->assertGreaterThanOrEqual( 0, count( $classes ) ); + // The registered set is non-empty and contains only ModuleInterface implementations. + $this->assertNotEmpty( $classes ); + foreach ( $classes as $registered ) { + $this->assertInstanceOf( \TenupFramework\ModuleInterface::class, $registered ); + } } /** @@ -276,6 +279,33 @@ public function test_init_classes_records_a_loader_in_admin() { $this->remove_temp_dir( $dir ); } + /** + * In the admin, init_classes() records how long discovery and class lookup took. + * + * @return void + */ + public function test_init_classes_records_timing_in_admin() { + when( 'is_admin' )->justReturn( true ); + when( 'add_action' )->justReturn( true ); + when( 'add_filter' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + + $dir = $this->make_temp_class_dir(); + + \TenupFramework\ModuleInitialization::instance()->init_classes( $dir ); + + $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders(); + $this->assertCount( 1, $loaders ); + $this->assertArrayHasKey( 'discovery_seconds', $loaders[0] ); + $this->assertArrayHasKey( 'lookup_seconds', $loaders[0] ); + $this->assertIsFloat( $loaders[0]['discovery_seconds'] ); + $this->assertIsFloat( $loaders[0]['lookup_seconds'] ); + $this->assertGreaterThanOrEqual( 0.0, $loaders[0]['discovery_seconds'] ); + $this->assertGreaterThanOrEqual( 0.0, $loaders[0]['lookup_seconds'] ); + + $this->remove_temp_dir( $dir ); + } + /** * On the front end, init_classes() records nothing (the data is only viewable in the admin). * diff --git a/tests/examples/README.md b/tests/examples/README.md new file mode 100644 index 0000000..ff67582 --- /dev/null +++ b/tests/examples/README.md @@ -0,0 +1,20 @@ +# Example loader directories + +Small, self-contained directories that mirror what a real plugin/theme passes to +`ModuleInitialization::init_classes()` — the same directories you would point the +`tenup-framework-generate-class-cache` build command at. + +They exist so the class-cache tooling can be exercised end-to-end against realistic +input rather than throwaway inline strings: + +- `plugin-inc/` — a typical plugin `inc/` directory: one `ModuleInterface` module + (`Modules\GreetingModule`) plus a plain support class (`Support\Formatter`) that is + discovered but never registered. +- `second-inc/` — a second directory, used to prove the build command caches several + directories in a single run (as a multi-package project would). + +The classes are intentionally tiny. Discovery reads them with a tokenizer and never +loads them, so they do not need to be autoloadable to be cached. + +Generated `class-loader-cache/` directories are git-ignored build artefacts; the tests +create them in a temporary copy and clean them up. diff --git a/tests/examples/plugin-inc/Modules/GreetingModule.php b/tests/examples/plugin-inc/Modules/GreetingModule.php new file mode 100644 index 0000000..808e678 --- /dev/null +++ b/tests/examples/plugin-inc/Modules/GreetingModule.php @@ -0,0 +1,40 @@ + Date: Tue, 7 Jul 2026 14:32:46 -0400 Subject: [PATCH 2/6] test: address code-review feedback on timing and diagnostics - format_duration(): reject non-finite input (INF/NAN) alongside non-positive, so a misbehaving contributor to the tenup_framework_debug_loaders filter can no longer render "inf s" / "nan s". Makes the "untrusted mixed input" contract in the docblock actually hold. Cover with negative, NAN and INF data cases. - FrameworkTestSetup: reset the ModuleInitialization singleton in setUp(). The trait-level @runTestsInSeparateProcesses annotation does not take effect (PHPUnit ignores it on a used trait; confirmed by suite wall-time and the author's explicit method-level @runInSeparateProcess on the two define() tests), so the singleton's accumulated $classes previously leaked between tests. Prevents order-dependent flakiness as more tests are added. phpcs, phpstan (level 10) and phpunit all green (57 tests / 148 assertions). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Debug/LoaderDebug.php | 4 +++- tests/Debug/LoaderDebugTest.php | 3 +++ tests/FrameworkTestSetup.php | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/Debug/LoaderDebug.php b/src/Debug/LoaderDebug.php index c898307..8254f83 100644 --- a/src/Debug/LoaderDebug.php +++ b/src/Debug/LoaderDebug.php @@ -407,7 +407,9 @@ protected static function to_string( $value ): string { protected static function format_duration( $seconds ): string { $seconds = is_numeric( $seconds ) ? (float) $seconds : 0.0; - if ( $seconds <= 0.0 ) { + // Values arrive through the cross-copy filter as mixed, so reject non-positive and + // non-finite (INF/NAN) input rather than rendering "inf s" / "nan s". + if ( $seconds <= 0.0 || ! is_finite( $seconds ) ) { return '—'; } diff --git a/tests/Debug/LoaderDebugTest.php b/tests/Debug/LoaderDebugTest.php index 8d8f5fe..097078d 100644 --- a/tests/Debug/LoaderDebugTest.php +++ b/tests/Debug/LoaderDebugTest.php @@ -371,7 +371,10 @@ public function test_format_duration( $seconds, string $expected ) { public function duration_provider(): array { return [ 'zero' => [ 0.0, '—' ], + 'negative' => [ -0.005, '—' ], 'non-numeric' => [ 'nope', '—' ], + 'not-a-number' => [ NAN, '—' ], + 'infinite' => [ INF, '—' ], 'sub-milli' => [ 0.0004, '0.400 ms' ], 'milliseconds' => [ 0.0123, '12.30 ms' ], 'seconds' => [ 1.5, '1.50 s' ], diff --git a/tests/FrameworkTestSetup.php b/tests/FrameworkTestSetup.php index 14a7cae..d879f85 100644 --- a/tests/FrameworkTestSetup.php +++ b/tests/FrameworkTestSetup.php @@ -72,9 +72,28 @@ protected function setUp(): void { // phpcs:ignore WordPress.NamingConventions.V stubEscapeFunctions(); stubTranslationFunctions(); + $this->reset_module_initialization(); $this->reset_loader_debug(); } + /** + * Reset the ModuleInitialization singleton so its accumulated `$classes` do not leak between + * tests. The suite is not process-isolated (the trait-level annotation does not take effect), + * so without this a class registered in one test would be seen as "already initialized" in a + * later one. + * + * @return void + */ + protected function reset_module_initialization(): void { + if ( ! class_exists( \TenupFramework\ModuleInitialization::class ) ) { + return; + } + + $instance = ( new \ReflectionClass( \TenupFramework\ModuleInitialization::class ) )->getProperty( 'instance' ); + $instance->setAccessible( true ); + $instance->setValue( null, null ); + } + /** * Reset the static state of the LoaderDebug registry so each test starts clean, * independent of test execution order or process isolation. From 3fcfa846b7328cc119da2d97b3224369f565ff1c Mon Sep 17 00:00:00 2001 From: Ryan Leeson Date: Fri, 10 Jul 2026 13:00:47 -0400 Subject: [PATCH 3/6] harden cache resilience, fix timing correctness/tests, document mono-repo & opcache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a Fable re-review of the build-time-cache PR plus the timing addition, targeting mono-repo flexibility, genuine live-timing measurement, and security/perf. Resilience (security/perf): - get_classes() now catches a corrupt/truncated shipped cache (the cache is executable PHP loaded via `require`) and falls back to a live discovery instead of fataling every request until redeploy. Same spirit as #30: a bad cache must never take the site down. Covered by a new test. - record_loader_debug() bails on wp_doing_ajax(): is_admin() is also true on admin-ajax.php, and the debug page re-records on its own GET, so ajax recording was pure waste (often front-end triggered). Timing correctness: - Switch discovery/lookup timing from microtime(true) to the monotonic hrtime(true), so an NTP adjustment mid-request cannot skew a delta. - Rewrite the timing tests that were vacuous (assertGreaterThanOrEqual(0.0) passed the never-wired 0.0 default; the staleness string also matched the "took —." failure rendering). Now: the no-cache case asserts cache_used===false and strictly-positive live discovery time; a new cached case asserts cache_used===true and positive cache-read time; the staleness checks assert a real duration via regex. Mono-repo dedupe: - LoaderDebug::record() keeps one record per directory, so a repeated init_classes() for the same directory refreshes rather than duplicating a card. Docs: - Build-and-Deployment: opcache in-place-deploy staleness caveat, corrupt-cache fallback behaviour, and mixed-framework-version generation guidance for mono-repos. - Debugging: known limitations (per-request visibility, oldest-UI renders on mixed versions). CHANGELOG: fallback note; "identifier" -> "filename" wording fix. phpcs, phpstan (level 10) and phpunit all green (59 tests / 153 assertions). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 +- docs/Build-and-Deployment.md | 22 +++++++++- docs/Debugging.md | 9 ++++ src/Debug/LoaderDebug.php | 12 ++++++ src/ModuleInitialization.php | 32 ++++++++++---- tests/Debug/LoaderDebugTest.php | 6 ++- tests/ModuleInitializationTest.php | 67 +++++++++++++++++++++++++++--- 7 files changed, 134 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4820f4..3772021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ All notable changes to this project will be documented in this file, per [the Ke ### Changed - The class-loader cache is now **read-only at runtime** and opt-in. The framework reads a pre-built cache if present and discovers live otherwise, but never writes one on the server — fixing stale caches that could only be cleared by hand ([#30](https://github.com/10up/wp-framework/issues/30)). -- Bumped the cache identifier so a cache written by an older version is ignored after upgrade rather than served stale. +- A corrupt or truncated shipped cache is caught at runtime and the request falls back to a live scan instead of fataling, so a bad cache degrades performance rather than taking the site down. +- Bumped the cache filename so a cache written by an older version is ignored after upgrade rather than served stale. - `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` now forces live discovery (ignores any shipped cache). ### Removed diff --git a/docs/Build-and-Deployment.md b/docs/Build-and-Deployment.md index 2f6c3e3..ef0bd6a 100644 --- a/docs/Build-and-Deployment.md +++ b/docs/Build-and-Deployment.md @@ -158,7 +158,19 @@ workflows: If the build can't run the generate step for some reason, the deploy still works — it just runs uncached. A broken cache after a build means the build is the thing to fix, not the -server. +server. A cache file that is corrupt or truncated (a half-finished rsync, an interrupted +build) is caught at runtime and the request falls back to a live scan, so a bad cache slows +the site rather than taking it down. + +### Opcache and in-place deploys + +The cache is a PHP file loaded with `require`, so PHP's opcache caches it like any other +source file. On hosts with `opcache.validate_timestamps=0` (common on the managed hosts where +issue #30 was reported), overwriting `class-loader-cache-v2.php` **in place** keeps serving the +previously compiled array until opcache is reset — which would reintroduce the very staleness +this design removes. Either deploy to a fresh path (atomic symlink swap, the default on most +zero-downtime deployers) or reset opcache as part of the deploy. The loader debug page's +staleness check live-scans and will flag this if it happens. ## The per-package model @@ -184,6 +196,14 @@ vendor/bin/tenup-framework-generate-class-cache \ wp-content/plugins/bar/inc ``` +One caveat for a mono-repo where packages pin **different** framework versions: the single +invocation above uses one package's `vendor/bin` copy to write every directory's cache. That +copy determines the cache filename and the Spatie discoverer version used. Today the payload is +a plain array of class-name strings and the filename is identical across versions, so this is +safe — but a future cache-format or filename bump would silently mismatch. When packages are on +different framework versions, run **each package's own** `vendor/bin/tenup-framework-generate-class-cache` +against its own directory so the writer and the reader are always the same version. + ## See also - [Docs Home](README.md) - [Autoloading and Modules](Autoloading.md) diff --git a/docs/Debugging.md b/docs/Debugging.md index a1f3c71..0f29a1c 100644 --- a/docs/Debugging.md +++ b/docs/Debugging.md @@ -59,6 +59,15 @@ read-only runtime. The recording and the page are **admin-only**. On front-end requests nothing is recorded, no hooks are added, and the debug class is never even loaded. +## Known limitations + +- **Per request** — the page shows loaders recorded on the current admin request. A plugin whose + `init_classes()` did not run on this request will not appear. +- **Mixed framework versions render with the oldest UI** — when a mono-repo runs several framework + copies on different versions, the first copy to record a loader registers and renders the page, + so newer per-loader fields degrade to blank rather than showing. Aligning framework versions + across packages avoids this; the data itself is still aggregated correctly across all copies. + ## Disabling it Enabled by default in the admin. Turn it off with either: diff --git a/src/Debug/LoaderDebug.php b/src/Debug/LoaderDebug.php index 8254f83..4ea4334 100644 --- a/src/Debug/LoaderDebug.php +++ b/src/Debug/LoaderDebug.php @@ -88,6 +88,18 @@ public static function record( array $record ) { return; } + // Keep one record per directory: if init_classes() runs more than once for the same + // directory in a request, the latest call (with fresh timing) replaces the earlier one + // rather than producing a duplicate card. + $directory = isset( $record['directory'] ) && is_string( $record['directory'] ) ? $record['directory'] : ''; + foreach ( self::$loaders as $index => $existing ) { + if ( ( $existing['directory'] ?? null ) === $directory ) { + self::$loaders[ $index ] = $record; + self::boot(); + return; + } + } + self::$loaders[] = $record; self::boot(); diff --git a/src/ModuleInitialization.php b/src/ModuleInitialization.php index f009401..e69c8e1 100644 --- a/src/ModuleInitialization.php +++ b/src/ModuleInitialization.php @@ -113,10 +113,18 @@ public function get_classes( $dir ) { ); } - $classes = array_filter( $class_finder->get(), fn( $cl ) => is_string( $cl ) ); + try { + $discovered = $class_finder->get(); + } catch ( \Throwable $e ) { + // A shipped cache file that is corrupt or truncated — a partial deploy, an + // interrupted build, a half-written rsync — would otherwise fatal on every request + // (the cache is executable PHP loaded with `require`). Fall back to a fresh live + // discovery so the site keeps working, uncached, until the cache is rebuilt. This + // is the same spirit as issue #30: a bad cache must never take the site down. + $discovered = $this->build_discoverer( $dir )->get(); + } - // Return the classes - return $classes; + return array_filter( $discovered, fn( $cl ) => is_string( $cl ) ); } /** @@ -229,6 +237,13 @@ protected function record_loader_debug( $dir, array $classes, float $discovery_s return; } + // is_admin() is also true for admin-ajax.php. The debug page is a normal admin GET that + // re-runs discovery and records afresh, so recording on ajax requests is pure waste + // (often triggered from the front end). Skip them. + if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) { + return; + } + $cache_file = $this->get_cache_directory( $dir ) . '/' . self::CACHE_FILENAME; $cache_exists = file_exists( $cache_file ); $disabled = $this->cache_disabled(); @@ -309,12 +324,13 @@ public function init_classes( $dir = '' ) { // Time discovery (a cache read when a cache is present, a live filesystem scan // otherwise) separately from the reflection/instantiation work below, so the debug - // page can show where the request's time actually goes. - $discovery_start = microtime( true ); + // page can show where the request's time actually goes. hrtime() is monotonic, so an + // NTP adjustment mid-request cannot produce a negative or wildly wrong delta. + $discovery_start = hrtime( true ); $classes = $this->get_classes( $dir ); - $discovery_seconds = microtime( true ) - $discovery_start; + $discovery_seconds = ( hrtime( true ) - $discovery_start ) / 1e9; - $lookup_start = microtime( true ); + $lookup_start = hrtime( true ); $load_class_order = []; foreach ( $classes as $class ) { @@ -376,7 +392,7 @@ public function init_classes( $dir = '' ) { } } - $lookup_seconds = microtime( true ) - $lookup_start; + $lookup_seconds = ( hrtime( true ) - $lookup_start ) / 1e9; $this->record_loader_debug( $dir, $classes, $discovery_seconds, $lookup_seconds ); } diff --git a/tests/Debug/LoaderDebugTest.php b/tests/Debug/LoaderDebugTest.php index 097078d..fcf7a84 100644 --- a/tests/Debug/LoaderDebugTest.php +++ b/tests/Debug/LoaderDebugTest.php @@ -228,6 +228,8 @@ public function test_render_page_reports_staleness_drift() { $this->assertStringContainsString( 'Stale', $output ); $this->assertStringContainsString( 'TenupTmp\\Widget', $output ); // On disk, missing from cache. $this->assertStringContainsString( 'TenupTmp\\Old', $output ); // In cache, gone from disk. + // The drift notice also reports a real, positive live-discovery duration. + $this->assertMatchesRegularExpression( '/Live discovery took \d[\d.,]* (ms|s)\./', $output ); } /** @@ -256,7 +258,9 @@ public function test_render_page_reports_up_to_date_and_timing() { $this->remove_temp_dir( $dir ); $this->assertStringContainsString( 'Up to date', $output ); - $this->assertStringContainsString( 'Live discovery took', $output ); + // Require a real, positive duration — this must NOT match the "Live discovery took —." + // placeholder that format_duration() emits for a non-positive/absent value. + $this->assertMatchesRegularExpression( '/Live discovery took \d[\d.,]* (ms|s)\./', $output ); } /** diff --git a/tests/ModuleInitializationTest.php b/tests/ModuleInitializationTest.php index 9b349f9..1fb5644 100644 --- a/tests/ModuleInitializationTest.php +++ b/tests/ModuleInitializationTest.php @@ -231,6 +231,33 @@ public function test_get_classes_ignores_legacy_cache_file() { $this->remove_temp_dir( $dir ); } + /** + * A corrupt or truncated cache file does not fatal the request: get_classes() catches the + * error and falls back to a live discovery, so a bad cache degrades to uncached rather than + * taking the site down. + * + * @return void + */ + public function test_get_classes_falls_back_to_live_when_cache_is_corrupt() { + $dir = $this->make_temp_class_dir(); + $cache_dir = $dir . '/' . \TenupFramework\ModuleInitialization::CACHE_DIR_NAME; + mkdir( $cache_dir ); + + // A truncated / syntactically broken cache file — `require` on this throws a ParseError. + $this->write_file( + $cache_dir . '/' . \TenupFramework\ModuleInitialization::CACHE_FILENAME, + "get_classes( $dir ); + + // Fell back to a live scan and still found the real class on disk. + $this->assertContains( 'TenupTmp\\Widget', $read ); + + $this->remove_temp_dir( $dir ); + } + /** * Defining TENUP_FRAMEWORK_DISABLE_CLASS_CACHE forces live discovery even when a * cache file is present. @@ -280,11 +307,12 @@ public function test_init_classes_records_a_loader_in_admin() { } /** - * In the admin, init_classes() records how long discovery and class lookup took. + * With no cache present, init_classes() records the time of a genuine live (uncached) + * discovery — a real filesystem scan, so the recorded duration is strictly positive. * * @return void */ - public function test_init_classes_records_timing_in_admin() { + public function test_init_classes_records_live_discovery_timing_in_admin() { when( 'is_admin' )->justReturn( true ); when( 'add_action' )->justReturn( true ); when( 'add_filter' )->justReturn( true ); @@ -296,12 +324,39 @@ public function test_init_classes_records_timing_in_admin() { $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders(); $this->assertCount( 1, $loaders ); - $this->assertArrayHasKey( 'discovery_seconds', $loaders[0] ); - $this->assertArrayHasKey( 'lookup_seconds', $loaders[0] ); + $this->assertFalse( $loaders[0]['cache_used'], 'No cache exists, so discovery must be live.' ); $this->assertIsFloat( $loaders[0]['discovery_seconds'] ); $this->assertIsFloat( $loaders[0]['lookup_seconds'] ); - $this->assertGreaterThanOrEqual( 0.0, $loaders[0]['discovery_seconds'] ); - $this->assertGreaterThanOrEqual( 0.0, $loaders[0]['lookup_seconds'] ); + // A live filesystem scan and the reflection loop both take measurable time; the never-wired + // default is 0.0, so asserting strictly-positive proves the instrumentation actually ran. + $this->assertGreaterThan( 0.0, $loaders[0]['discovery_seconds'] ); + $this->assertGreaterThan( 0.0, $loaders[0]['lookup_seconds'] ); + + $this->remove_temp_dir( $dir ); + } + + /** + * With a pre-built cache present, init_classes() reads it (cache_used) and still records a + * positive discovery duration — the cache-read cost rather than a live scan. + * + * @return void + */ + public function test_init_classes_records_cache_read_timing_in_admin() { + when( 'is_admin' )->justReturn( true ); + when( 'add_action' )->justReturn( true ); + when( 'add_filter' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + + $dir = $this->make_temp_class_dir(); + $module = \TenupFramework\ModuleInitialization::instance(); + $module->generate_cache( $dir ); + + $module->init_classes( $dir ); + + $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders(); + $this->assertCount( 1, $loaders ); + $this->assertTrue( $loaders[0]['cache_used'], 'A cache file exists, so it should be used.' ); + $this->assertGreaterThan( 0.0, $loaders[0]['discovery_seconds'] ); $this->remove_temp_dir( $dir ); } From 0fac09cb8be8baa21866167a7546fc203fbffb3c Mon Sep 17 00:00:00 2001 From: Ryan Leeson Date: Fri, 10 Jul 2026 17:43:31 -0400 Subject: [PATCH 4/6] show absolute UTC build time in the loader cache detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache detail now reads "Built ago · · ", using gmdate() so the build timestamp is unambiguous regardless of site or server timezone. Covered by a cache_detail() test asserting the size and trailing UTC segment. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Debug/LoaderDebug.php | 20 +++++++++++++++----- tests/Debug/LoaderDebugTest.php | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/Debug/LoaderDebug.php b/src/Debug/LoaderDebug.php index 4ea4334..f675a40 100644 --- a/src/Debug/LoaderDebug.php +++ b/src/Debug/LoaderDebug.php @@ -573,7 +573,8 @@ protected static function cache_state( array $loader ): array { } /** - * A short description of the cache file on disk (age and size), or a placeholder when none. + * A short description of the cache file on disk — relative age, size, and the absolute build + * time in UTC — or a placeholder when none. Format: "Built ago · ()". * * @param array $loader The loader record. * @@ -589,11 +590,20 @@ protected static function cache_detail( array $loader ): string { $mtime = (int) filemtime( $cache_file ); $size = (int) filesize( $cache_file ); + if ( ! $mtime ) { + return sprintf( + /* translators: %s: file size. */ + __( 'Built at an unknown time · %s', 'tenup-framework' ), + size_format( $size ) + ); + } + return sprintf( - /* translators: 1: relative age, 2: file size. */ - __( 'Built %1$s ago · %2$s', 'tenup-framework' ), - $mtime ? human_time_diff( $mtime ) : __( 'unknown time', 'tenup-framework' ), - size_format( $size ) + /* translators: 1: relative age (e.g. "5 minutes"); 2: file size; 3: absolute build time in UTC. */ + __( 'Built %1$s ago · %2$s · %3$s', 'tenup-framework' ), + human_time_diff( $mtime ), + size_format( $size ), + gmdate( 'Y-m-d H:i:s', $mtime ) . ' UTC' ); } diff --git a/tests/Debug/LoaderDebugTest.php b/tests/Debug/LoaderDebugTest.php index fcf7a84..da25199 100644 --- a/tests/Debug/LoaderDebugTest.php +++ b/tests/Debug/LoaderDebugTest.php @@ -385,6 +385,32 @@ public function duration_provider(): array { ]; } + /** + * cache_detail() renders "Built ago · · " with the build time in UTC. + * + * @return void + */ + public function test_cache_detail_shows_size_and_utc_build_time() { + when( 'human_time_diff' )->justReturn( '5 minutes' ); + when( 'size_format' )->alias( static fn( $bytes ) => $bytes . ' B' ); + + $dir = $this->make_temp_class_dir(); + $cache_dir = $dir . '/class-loader-cache'; + mkdir( $cache_dir ); + $cache_file = $cache_dir . '/class-loader-cache-v2.php'; + file_put_contents( $cache_file, 'invoke_protected( 'cache_detail', [ [ 'cache_file' => $cache_file ] ] ); + + $this->assertStringContainsString( 'Built 5 minutes ago', $detail ); + // The absolute build time is the file mtime rendered in UTC as the trailing segment. + $expected_utc = gmdate( 'Y-m-d H:i:s', (int) filemtime( $cache_file ) ) . ' UTC'; + $this->assertStringContainsString( '· ' . $expected_utc, $detail ); + $this->assertMatchesRegularExpression( '/·\s*\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/', $detail ); + + $this->remove_temp_dir( $dir ); + } + /** * Invoke a protected static method on LoaderDebug via reflection. * From 1c5ed6f925605c3b505c5254ffb0ebefa6562948 Mon Sep 17 00:00:00 2001 From: Ryan Leeson Date: Fri, 10 Jul 2026 17:56:35 -0400 Subject: [PATCH 5/6] grow the loader card to fit an expanded class list The class table's long file paths pushed it past the card's right border. The card now sizes to its content (min 60em, capped at the admin content width) so it widens when a class list is expanded, and long paths in the meta/class tables wrap (overflow-wrap: anywhere) so nothing spills once the width cap is reached. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Debug/LoaderDebug.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Debug/LoaderDebug.php b/src/Debug/LoaderDebug.php index f675a40..20bb500 100644 --- a/src/Debug/LoaderDebug.php +++ b/src/Debug/LoaderDebug.php @@ -614,7 +614,7 @@ protected static function cache_detail( array $loader ): string { */ protected static function render_styles() { echo ''; } From 14befa74926a5c208ba15caa196c33e4c2f01bc0 Mon Sep 17 00:00:00 2001 From: Ryan Leeson Date: Fri, 10 Jul 2026 18:01:27 -0400 Subject: [PATCH 6/6] Remove unused namespace reference --- src/ModuleInitialization.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ModuleInitialization.php b/src/ModuleInitialization.php index e69c8e1..2f021cf 100644 --- a/src/ModuleInitialization.php +++ b/src/ModuleInitialization.php @@ -12,7 +12,6 @@ use Composer\InstalledVersions; use ReflectionClass; use Spatie\StructureDiscoverer\Cache\FileDiscoverCacheDriver; -use Spatie\StructureDiscoverer\Data\DiscoveredStructure; use Spatie\StructureDiscoverer\Discover; use TenupFramework\Cache\ReadOnlyFileDiscoverCacheDriver; use TenupFramework\Debug\LoaderDebug;