From 3c7977cd861f00a64131f46b5622b33854608814 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Wed, 19 Aug 2026 08:51:50 -0400 Subject: [PATCH 1/3] feat(tests): add unit tests for panel snapshots and enhance existing test coverage. --- CHANGELOG.md | 1 + tests/Helper/TextTest.php | 8 + tests/Panel/CapturedSnapshotTest.php | 284 ++++++++++++++++++ tests/Panel/Profile/ProfilingSnapshotTest.php | 79 ++++- .../Request/RequestSectionRendererTest.php | 23 ++ tests/Panel/SnapshotHydrationTest.php | 187 ++++++++++++ tests/Panel/Timeline/TimelineGeometryTest.php | 15 + tests/Panel/Timeline/TimelineRendererTest.php | 23 ++ tests/Storage/DebugSnapshotTest.php | 18 ++ tests/Storage/JsonTest.php | 16 + tests/Storage/PayloadTest.php | 8 + tests/Storage/RequestSummaryTest.php | 16 + tests/Storage/SnapshotStoreTest.php | 44 +++ 13 files changed, 720 insertions(+), 2 deletions(-) create mode 100644 tests/Panel/CapturedSnapshotTest.php create mode 100644 tests/Panel/SnapshotHydrationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 682e1d1..2575aa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,3 +19,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add shared UI parity contracts, Asset composition, and cross-adapter acceptance documentation. - feat(ui): share database EXPLAIN markup across debugger adapters. - feat(ui): add shared profiler normalization and Timeline rendering contracts. +- feat(tests): add unit tests for panel snapshots and enhance existing test coverage. diff --git a/tests/Helper/TextTest.php b/tests/Helper/TextTest.php index 4dbde40..dfbf691 100644 --- a/tests/Helper/TextTest.php +++ b/tests/Helper/TextTest.php @@ -31,4 +31,12 @@ public function testCamel2idLowercasesUnicodeLetters(): void 'Unicode uppercase letters must be lowercased with multibyte semantics.', ); } + public function testCamel2idPreservesEmptyInput(): void + { + self::assertSame( + '', + Text::camel2id(''), + 'Empty input must remain empty.', + ); + } } diff --git a/tests/Panel/CapturedSnapshotTest.php b/tests/Panel/CapturedSnapshotTest.php new file mode 100644 index 0000000..8cb238f --- /dev/null +++ b/tests/Panel/CapturedSnapshotTest.php @@ -0,0 +1,284 @@ + '/app/index.php']]], + 'invalid', + ], + ); + $payload = $captured->jsonSerialize(); + + $snapshot = DumpSnapshot::fromArray($payload, '$.panels.dump'); + $capturedRow = $captured->entries()[0] ?? self::fail('Expected one captured dump row.'); + $row = $snapshot->entries()[0] ?? self::fail('Expected one hydrated dump row.'); + + self::assertSame( + $payload, + $snapshot->jsonSerialize(), + 'Dump payload must round-trip exactly.', + ); + self::assertSame( + $capturedRow->jsonSerialize(), + $row->jsonSerialize(), + 'Typed dump rows must remain accessible.', + ); + } + + public function testInertiaSnapshotCapturesAndHydratesResponseData(): void + { + $captured = InertiaSnapshot::capture( + '/dashboard', + ['component' => 'Dashboard'], + ['X-Inertia' => 'true'], + ['authenticated' => true], + 303, + ); + + $payload = $captured->jsonSerialize(); + + $snapshot = InertiaSnapshot::fromArray($payload, '$.panels.inertia'); + + self::assertSame( + $payload, + $snapshot->jsonSerialize(), + 'Inertia payload must round-trip exactly.', + ); + self::assertSame( + [ + 'location' => '/dashboard', + 'page' => ['component' => 'Dashboard'], + 'requestHeaders' => ['X-Inertia' => 'true'], + 'sharedKeys' => ['authenticated' => true], + 'statusCode' => 303, + ], + $snapshot->data(), + 'Inertia response data must be restored for display.', + ); + } + + public function testLogSnapshotCapturesLinksAndHydratesRows(): void + { + $captured = LogSnapshot::capture( + [ + ['first', LogLevel::INFO, 'application', 100.0, [], 1_024], + 'invalid', + ['second', LogLevel::WARNING, 'application', 100.25, [], 2_048], + ], + ); + $payload = $captured->jsonSerialize(); + + $snapshot = LogSnapshot::fromArray($payload, '$.panels.log'); + $first = $snapshot->entries()[0] ?? self::fail('Expected the first hydrated log row.'); + $second = $snapshot->entries()[1] ?? self::fail('Expected the second hydrated log row.'); + + self::assertSame( + $payload, + $snapshot->jsonSerialize(), + 'Log payload must round-trip exactly.', + ); + self::assertSame( + 2, + $first->idOfNext, + 'First row must link to the second row.', + ); + self::assertSame( + 1, + $second->idOfPrevious, + 'Second row must link to the first row.', + ); + } + + public function testMailSnapshotCapturesAndHydratesMessages(): void + { + $captured = MailSnapshot::capture( + [ + [ + 'from' => 'sender@example.test', + 'to' => 'one@example.test, two@example.test', + 'subject' => 'Subject', + 'isSuccessful' => true, + 'time' => 1_700_000_000, + ], + 'invalid', + ], + ); + + $payload = $captured->jsonSerialize(); + + $snapshot = MailSnapshot::fromArray($payload, '$.panels.mail'); + $capturedMessage = $captured->entries()[0] ?? self::fail('Expected one captured mail message.'); + $message = $snapshot->entries()[0] ?? self::fail('Expected one hydrated mail message.'); + + self::assertSame( + $payload, + $snapshot->jsonSerialize(), + 'Mail payload must round-trip exactly.', + ); + self::assertSame( + $capturedMessage->jsonSerialize(), + $message->jsonSerialize(), + 'Typed mail messages must remain accessible.', + ); + } + + public function testQueueSnapshotHydratesCapturedRecords(): void + { + $captured = QueueSnapshot::capture( + [ + [ + 'eventType' => 'exec', + 'componentId' => 'queue', + 'driverName' => 'Redis', + 'driverClass' => 'yii\\queue\\redis\\Queue', + 'isAsync' => true, + 'jobClass' => 'app\\jobs\\SendMail', + 'payloadFields' => ['messageId' => 42], + 'time' => 100.5, + 'jobId' => 'job-1', + 'attempt' => 1, + 'duration' => 0.25, + ], + 'invalid', + ], + ); + + $payload = $captured->jsonSerialize(); + + $snapshot = QueueSnapshot::fromArray($payload, '$.panels.queue'); + $capturedRecord = $captured->entries()[0] ?? self::fail('Expected one captured queue record.'); + $record = $snapshot->entries()[0] ?? self::fail('Expected one hydrated queue record.'); + + self::assertSame( + $payload, + $snapshot->jsonSerialize(), + 'Queue payload must round-trip exactly.', + ); + self::assertSame( + $capturedRecord->jsonSerialize(), + $record->jsonSerialize(), + 'Typed queue records must remain accessible.', + ); + } + + public function testRequestSnapshotCapturesAndHydratesRequestData(): void + { + $captured = RequestSnapshot::capture(['statusCode' => 201, 'method' => 'POST']); + + $payload = $captured->jsonSerialize(); + + $snapshot = RequestSnapshot::fromArray($payload, '$.panels.request'); + + self::assertSame( + $payload, + $snapshot->jsonSerialize(), + 'Request payload must round-trip exactly.', + ); + self::assertSame( + ['statusCode' => 201, 'method' => 'POST'], + $snapshot->data(), + 'Request data must be restored for display.', + ); + } + + public function testRequestSnapshotRejectsMismatchedHydratedStatusCode(): void + { + $payload = RequestSnapshot::capture(['statusCode' => 200])->jsonSerialize(); + + $payload['statusCode'] = 500; + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '$.panels.request.statusCode'", + ); + + RequestSnapshot::fromArray($payload, '$.panels.request'); + } + + public function testRequestSnapshotRejectsMissingStatusCode(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '$.panels.request.statusCode'", + ); + + RequestSnapshot::capture(['method' => 'GET']); + } + + public function testRouterSnapshotCapturesAndHydratesRouteTrace(): void + { + $captured = RouterSnapshot::capture( + 'site/index', + [ + ['Route resolved.', LogLevel::TRACE], + [['rule' => 'yii\\rest\\UrlRule', 'parent' => '', 'match' => false], LogLevel::INFO], + [['rule' => 'app\\rules\\ViewRule', 'parent' => 'yii\\rest\\UrlRule', 'match' => true], LogLevel::INFO], + [['rule' => 'yii\\rest\\UrlRule', 'match' => false], LogLevel::INFO], + [['invalid' => true], LogLevel::INFO], + ], + 'post/42', + ); + + $payload = $captured->jsonSerialize(); + + $snapshot = RouterSnapshot::fromArray($payload, '$.panels.router'); + + self::assertSame( + $payload, + $snapshot->jsonSerialize(), + 'Router payload must round-trip exactly.', + ); + self::assertTrue( + $snapshot->hasMatch(), + 'A successful routing rule must be detected.', + ); + self::assertSame( + 'Route resolved.', + $snapshot->message, + 'Trace message must be retained.', + ); + self::assertSame( + 2, + count($snapshot->entries()), + 'Nested REST duplicate must be omitted.', + ); + } + + public function testRouterSnapshotReportsNoMatchWithoutSuccessfulRows(): void + { + $snapshot = RouterSnapshot::capture(null, [], 'missing'); + + self::assertFalse( + $snapshot->hasMatch(), + 'An empty routing trace must not report a match.', + ); + self::assertSame( + [], + $snapshot->entries(), + 'An empty routing trace must not create rows.', + ); + } +} diff --git a/tests/Panel/Profile/ProfilingSnapshotTest.php b/tests/Panel/Profile/ProfilingSnapshotTest.php index ce0ab25..3088cbd 100644 --- a/tests/Panel/Profile/ProfilingSnapshotTest.php +++ b/tests/Panel/Profile/ProfilingSnapshotTest.php @@ -4,6 +4,8 @@ namespace PHPForge\Debug\Tests\Panel\Profile; +use PHPForge\Debug\Helper\LogLevel; +use PHPForge\Debug\Panel\MemorySample; use PHPForge\Debug\Panel\Profile\ProfilingSnapshot; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -104,7 +106,80 @@ public function testCaptureCompletedSkipsMessagesWithoutUsableTiming(): void ], ); - self::assertSame([], $snapshot->entries(), 'Incomplete messages must not produce profile rows.'); - self::assertSame([], $snapshot->samples(), 'Incomplete messages without memory must not produce samples.'); + self::assertSame( + [], + $snapshot->entries(), + 'Incomplete messages must not produce profile rows.', + ); + self::assertSame( + [], + $snapshot->samples(), + 'Incomplete messages without memory must not produce samples.', + ); + } + public function testCapturePairsLoggerMessagesAndHydratesTheResult(): void + { + $captured = ProfilingSnapshot::capture( + 4_096, + 0.1, + [ + ['missing', LogLevel::PROFILE_END, 'application', 0.9, [], 50], + ['outer', LogLevel::PROFILE_BEGIN, 'application', 1.0, [['file' => '/app/index.php']], 100], + ['noise', LogLevel::INFO, 'application', 1.01, [], 110], + ['inner', LogLevel::PROFILE_BEGIN, 'database', 1.02, [], 120], + ['inner', LogLevel::PROFILE_END, 'database', 1.05, [], 140], + ['outer', LogLevel::PROFILE_END, 'application', 1.1, [], 200], + 'invalid', + ], + ); + + $payload = $captured->jsonSerialize(); + + $snapshot = ProfilingSnapshot::fromArray($payload, '$.panels.profiling'); + + self::assertSame( + $payload, + $snapshot->jsonSerialize(), + 'Profiling payload must round-trip exactly.', + ); + self::assertSame( + [ + [ + 'timestamp' => 1_000.0, + 'duration' => 100.00000000000009, + 'category' => 'application', + 'info' => 'outer', + 'level' => 0, + 'seq' => 0, + 'memory' => 200, + 'memoryDiff' => 100, + 'trace' => [['file' => '/app/index.php']], + ], + [ + 'timestamp' => 1_020.0, + 'duration' => 30.00000000000003, + 'category' => 'database', + 'info' => 'inner', + 'level' => 1, + 'seq' => 1, + 'memory' => 140, + 'memoryDiff' => 20, + 'trace' => [], + ], + ], + array_map(static fn($row): array => $row->jsonSerialize(), $snapshot->entries()), + 'Profile begin/end pairs must retain ordering, nesting, timing, memory, and traces.', + ); + self::assertSame( + array_map( + static fn(MemorySample $sample): array => ['time' => $sample->time, 'memory' => $sample->memory], + $captured->samples(), + ), + array_map( + static fn(MemorySample $sample): array => ['time' => $sample->time, 'memory' => $sample->memory], + $snapshot->samples(), + ), + 'Memory samples must survive hydration.', + ); } } diff --git a/tests/Panel/Request/RequestSectionRendererTest.php b/tests/Panel/Request/RequestSectionRendererTest.php index 31d7bcc..7d98415 100644 --- a/tests/Panel/Request/RequestSectionRendererTest.php +++ b/tests/Panel/Request/RequestSectionRendererTest.php @@ -217,6 +217,29 @@ public function testRenderTabsMarksFirstTabActive(): void ); } + public function testRenderTabsRendersNestedSections(): void + { + $tabs = [ + new RequestTab( + label: 'Parameters', + sections: [new RequestSection(caption: 'Query parameters', entries: ['page' => 1])], + ), + ]; + + $html = RequestSectionRenderer::renderTabs($tabs); + + self::assertSame( + 1, + substr_count($html, 'Query parameters'), + 'A nested section must render exactly once.', + ); + self::assertSame( + 1, + substr_count($html, 'page'), + 'A nested section row must render exactly once.', + ); + } + public function testRenderTabsWiresPanelIdsAndAriaControls(): void { $tabs = [ diff --git a/tests/Panel/SnapshotHydrationTest.php b/tests/Panel/SnapshotHydrationTest.php new file mode 100644 index 0000000..c1de224 --- /dev/null +++ b/tests/Panel/SnapshotHydrationTest.php @@ -0,0 +1,187 @@ + [ + [ + 'name' => 'app\\Asset', + 'sourcePath' => '@app/assets', + 'basePath' => '/public/assets', + 'baseUrl' => '/assets', + 'css' => ['app.css'], + 'js' => ['app.js'], + 'depends' => ['yii\\web\\YiiAsset'], + ], + ], + 'vite' => [ + 'baseUrl' => '/build', + 'devMode' => false, + 'devServerUrl' => null, + 'manifestPath' => '/public/build/manifest.json', + 'chunks' => [ + [ + 'name' => 'src/main.js', + 'file' => 'assets/main.js', + 'cssCount' => 1, + 'imports' => 2, + 'isEntry' => true, + ], + ], + ], + ]; + + $snapshot = AssetSnapshot::fromArray($payload, '$.panels.asset'); + $bundle = $snapshot->bundles()[0] ?? self::fail('Expected one hydrated asset bundle.'); + + self::assertSame( + $payload, + $snapshot->jsonSerialize(), + 'Asset payload must round-trip exactly.', + ); + self::assertSame( + $payload['bundles'][0], + $bundle->jsonSerialize(), + 'Typed asset bundles must remain accessible.', + ); + self::assertSame( + $payload['vite'], + $snapshot->vite()?->jsonSerialize(), + 'Typed Vite data must remain accessible.', + ); + } + + public function testConfigurationSnapshotRoundTripsDynamicData(): void + { + $captured = ConfigSnapshot::capture(['debug' => true, 'aliases' => ['@app' => '/app']]); + + $payload = $captured->jsonSerialize(); + + $snapshot = ConfigSnapshot::fromArray($payload, '$.panels.config'); + + self::assertSame( + $payload, + $snapshot->jsonSerialize(), + 'Configuration payload must round-trip exactly.', + ); + self::assertSame( + ['debug' => true, 'aliases' => ['@app' => '/app']], + $snapshot->data(), + 'Configuration values must be restored for display.', + ); + } + + public function testDatabaseSnapshotHydratesQueryRows(): void + { + $payload = [ + 'entries' => [ + [ + 'type' => 'SELECT', + 'query' => 'SELECT 1', + 'duration' => 1.5, + 'trace' => [['file' => '/app/index.php', 'line' => 12]], + 'traceHash' => 'trace-hash', + 'timestamp' => 1_700_000_000_000.0, + 'seq' => 0, + 'duplicate' => 1, + 'rows' => null, + ], + ], + ]; + + $snapshot = DbSnapshot::fromArray($payload, '$.panels.db'); + $query = $snapshot->entries()[0] ?? self::fail('Expected one hydrated query row.'); + + self::assertSame( + $payload, + $snapshot->jsonSerialize(), + 'Database payload must round-trip exactly.', + ); + self::assertSame( + $payload['entries'][0], + $query->jsonSerialize(), + 'Typed query rows must remain accessible.', + ); + } + + public function testEventSnapshotHydratesEventRows(): void + { + $payload = [ + 'entries' => [ + [ + 'time' => 1_700_000_000.5, + 'name' => 'EVENT_AFTER_REQUEST', + 'class' => 'app\\Event', + 'isStatic' => '0', + 'senderClass' => 'app\\Application', + ], + ], + ]; + + $snapshot = EventSnapshot::fromArray($payload, '$.panels.event'); + $event = $snapshot->entries()[0] ?? self::fail('Expected one hydrated event row.'); + + self::assertSame( + $payload, + $snapshot->jsonSerialize(), + 'Event payload must round-trip exactly.', + ); + self::assertSame( + $payload['entries'][0], + $event->jsonSerialize(), + 'Typed event rows must remain accessible.', + ); + } + + public function testTimelineSnapshotRoundTripsMetrics(): void + { + $payload = ['start' => 100.1, 'end' => 100.3, 'memory' => 4_096]; + + $snapshot = TimelineSnapshot::fromArray($payload, '$.panels.timeline'); + + self::assertSame( + $payload, + $snapshot->jsonSerialize(), + 'Timeline metrics must round-trip exactly.', + ); + } + + public function testUserSnapshotRoundTripsDynamicData(): void + { + $captured = UserSnapshot::capture(['id' => 42, 'roles' => ['admin']]); + + $payload = $captured->jsonSerialize(); + + $snapshot = UserSnapshot::fromArray($payload, '$.panels.user'); + + self::assertSame( + $payload, + $snapshot->jsonSerialize(), + 'User payload must round-trip exactly.', + ); + self::assertSame( + ['id' => 42, 'roles' => ['admin']], + $snapshot->data(), + 'User values must be restored for display.', + ); + } +} diff --git a/tests/Panel/Timeline/TimelineGeometryTest.php b/tests/Panel/Timeline/TimelineGeometryTest.php index 28b006b..2b9bd46 100644 --- a/tests/Panel/Timeline/TimelineGeometryTest.php +++ b/tests/Panel/Timeline/TimelineGeometryTest.php @@ -33,6 +33,21 @@ public function testRulersUseAdaptiveRoundSteps(): void TimelineGeometry::rulers(100.0, 0), 'A disabled ruler must not emit ticks.', ); + self::assertSame( + [0 => 0.0, 5 => 27.77777777777778, 10 => 55.55555555555556, 15 => 83.33333333333334], + TimelineGeometry::rulers(18.0), + 'A normalized duration up to five must use five-unit ticks.', + ); + self::assertSame( + [0 => 0.0, 10 => 32.25806451612903, 20 => 64.51612903225806], + TimelineGeometry::rulers(31.0), + 'A normalized duration above five must use ten-unit ticks.', + ); + self::assertSame( + [0 => 0.0], + TimelineGeometry::rulers(1.0), + 'A duration shorter than one complete step must keep only the origin.', + ); } public function testSpansUseTheSharedRequestGeometry(): void diff --git a/tests/Panel/Timeline/TimelineRendererTest.php b/tests/Panel/Timeline/TimelineRendererTest.php index a077a21..8892947 100644 --- a/tests/Panel/Timeline/TimelineRendererTest.php +++ b/tests/Panel/Timeline/TimelineRendererTest.php @@ -16,6 +16,29 @@ #[Group('timeline')] final class TimelineRendererTest extends TestCase { + public function testRenderChartFormatsSecondsAndOmitsSingleCategoryLegend(): void + { + $rows = TimelineGeometry::spans( + [ + new ProfileRow(1_000.0, 1_500.0, 'Yii3\\Application::handle', 'GET /slow', 0, 0, 0, 0, []), + ], + 1_000.0, + 2_000.0, + ); + + $html = TimelineRenderer::renderChart($rows, [1_500 => 75.0]); + + self::assertSame( + 1, + substr_count($html, '>1.5 s'), + 'Second-based ruler label must render once.', + ); + self::assertSame( + 0, + substr_count($html, 'yii-debug-tl-legend-item'), + 'A single category must not render a redundant legend.', + ); + } public function testRenderChartProducesExactSharedMarkup(): void { $rows = TimelineGeometry::spans( diff --git a/tests/Storage/DebugSnapshotTest.php b/tests/Storage/DebugSnapshotTest.php index 1292273..c7e30dc 100644 --- a/tests/Storage/DebugSnapshotTest.php +++ b/tests/Storage/DebugSnapshotTest.php @@ -56,6 +56,24 @@ public function testJsonSerializeProjectsPanelFailuresToArrays(): void 'The serialized exception payload must be retained.', ); } + public function testSnapshotHydratesPanelsAndFailures(): void + { + $captured = new DebugSnapshot( + $this->summary(), + ['request' => ['statusCode' => 200]], + ['log' => PanelFailure::fromThrowable(PanelFailure::CAPTURE, new RuntimeException('boom'))], + ); + + $payload = $captured->jsonSerialize(); + + $snapshot = DebugSnapshot::fromArray($payload); + + self::assertSame( + $payload, + $snapshot->jsonSerialize(), + 'Snapshot envelope must round-trip exactly.', + ); + } public function testThrowHydrationExceptionWhenTheStorageVersionDoesNotMatch(): void { diff --git a/tests/Storage/JsonTest.php b/tests/Storage/JsonTest.php index 4cf7d83..d1c7d8e 100644 --- a/tests/Storage/JsonTest.php +++ b/tests/Storage/JsonTest.php @@ -7,6 +7,7 @@ use PHPForge\Debug\Storage\Json; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use ReflectionClass; /** * Unit tests for {@see Json} covering UTF-8 preservation and binary representation. @@ -14,6 +15,21 @@ #[Group('storage')] final class JsonTest extends TestCase { + public function testPrivateConstructorContainsNoInitializationBehavior(): void + { + $reflection = new ReflectionClass(Json::class); + + $instance = $reflection->newInstanceWithoutConstructor(); + + $reflection->getConstructor()?->invoke($instance); + + self::assertSame( + Json::class, + $instance::class, + 'Invoking the private constructor must preserve helper type.', + ); + } + public function testSafeStringPreservesUtf8(): void { self::assertSame( diff --git a/tests/Storage/PayloadTest.php b/tests/Storage/PayloadTest.php index 92b541e..b426a18 100644 --- a/tests/Storage/PayloadTest.php +++ b/tests/Storage/PayloadTest.php @@ -32,6 +32,14 @@ public function testNullableNumberReturnsIntegerInputAsFloat(): void ); } + public function testNullableStringReturnsNull(): void + { + self::assertNull( + Payload::object(['action' => null])->nullableString('action'), + 'A nullable string must preserve null.', + ); + } + public function testObjectAcceptsAnEmptyArrayAsAnEmptyObject(): void { self::assertSame( diff --git a/tests/Storage/RequestSummaryTest.php b/tests/Storage/RequestSummaryTest.php index e3b2e86..4c72db5 100644 --- a/tests/Storage/RequestSummaryTest.php +++ b/tests/Storage/RequestSummaryTest.php @@ -88,6 +88,22 @@ public function testThrowHydrationExceptionWhenAMailFileEntryIsNotAString(): voi ); } + public function testWithProfilingReturnsAnEnrichedCopy(): void + { + $summary = RequestSummary::fromArray($this->payload()); + $profiled = $summary->withProfiling(0.125, 2_097_152); + + self::assertSame( + [ + ...$summary->jsonSerialize(), + 'processingTime' => 0.125, + 'peakMemory' => 2_097_152, + ], + $profiled->jsonSerialize(), + 'Profiling metrics must replace only the optional timing fields.', + ); + } + /** * Returns representative decoded request metadata. * diff --git a/tests/Storage/SnapshotStoreTest.php b/tests/Storage/SnapshotStoreTest.php index 3411359..c87b072 100644 --- a/tests/Storage/SnapshotStoreTest.php +++ b/tests/Storage/SnapshotStoreTest.php @@ -60,6 +60,34 @@ public function testClearRemovesSnapshotsAndManifest(): void ); } + public function testClearThrowsWhenAStoredFileCannotBeRemoved(): void + { + $store = $this->store(); + + $store->writeSnapshot( + new DebugSnapshot($this->summary('current', 1_700_000_000.0), [], []), + 10, + ); + + chmod($this->path, 0o555); + + try { + $store->clear(); + self::fail( + 'Read-only storage must reject snapshot removal.', + ); + } catch (StorageException $exception) { + self::assertSame( + "Unable to remove debug data file: {$this->path}/current.json", + $exception->getMessage(), + 'Removal failure must identify the file that could not be deleted.', + ); + } finally { + chmod($this->path, 0o777); + } + } + + public function testGarbageCollectionReportsEveryRemovedSummary(): void { $store = $this->store(); @@ -691,6 +719,22 @@ public function testThrowStorageExceptionWhenWriteCannotOpenTheLockFile(): void } } + public function testWriteAppliesConfiguredFileMode(): void + { + $store = new SnapshotStore($this->path, 0o777, 0o600); + + $store->writeSnapshot( + new DebugSnapshot($this->summary('current', 1_700_000_000.0), [], []), + 10, + ); + + self::assertSame( + 0o600, + fileperms("{$this->path}/current.json") & 0o777, + 'Configured file mode must be applied to persisted snapshots.', + ); + } + /** * Creates an isolated temporary storage path. */ From 368c9df63406648fbbd2af5ff8d87d8d646e20be Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Wed, 19 Aug 2026 08:59:22 -0400 Subject: [PATCH 2/3] Fix Build ci. --- src/Storage/SnapshotStore.php | 2 - tests/Storage/SnapshotStoreTest.php | 59 ++++++++++++++++++++--------- tests/Support/MockerExtension.php | 2 +- 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/Storage/SnapshotStore.php b/src/Storage/SnapshotStore.php index 5005917..c2e27d9 100644 --- a/src/Storage/SnapshotStore.php +++ b/src/Storage/SnapshotStore.php @@ -10,7 +10,6 @@ use function array_diff; use function array_keys; use function array_reverse; -use function chmod; use function count; use function fclose; use function file_get_contents; @@ -22,7 +21,6 @@ use function mkdir; use function pathinfo; use function preg_match; -use function unlink; /** * Provides JSON filesystem storage, manifest locking, atomic writes, and snapshot garbage collection. diff --git a/tests/Storage/SnapshotStoreTest.php b/tests/Storage/SnapshotStoreTest.php index c87b072..5706269 100644 --- a/tests/Storage/SnapshotStoreTest.php +++ b/tests/Storage/SnapshotStoreTest.php @@ -69,22 +69,20 @@ public function testClearThrowsWhenAStoredFileCannotBeRemoved(): void 10, ); - chmod($this->path, 0o555); + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'unlink', + [], + false, + true, + ); - try { - $store->clear(); - self::fail( - 'Read-only storage must reject snapshot removal.', - ); - } catch (StorageException $exception) { - self::assertSame( - "Unable to remove debug data file: {$this->path}/current.json", - $exception->getMessage(), - 'Removal failure must identify the file that could not be deleted.', - ); - } finally { - chmod($this->path, 0o777); - } + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + "Unable to remove debug data file: {$this->path}/current.json", + ); + + $store->clear(); } @@ -728,10 +726,35 @@ public function testWriteAppliesConfiguredFileMode(): void 10, ); + $modes = []; + + foreach (MockerState::getTraces('PHPForge\Debug\Storage', 'chmod') as $trace) { + self::assertIsArray( + $trace, + 'Each chmod trace must expose its arguments.', + ); + + $arguments = $trace['arguments'] ?? null; + + self::assertIsArray( + $arguments, + 'Each chmod trace must expose an argument list.', + ); + + $mode = $arguments[1] ?? null; + + self::assertIsInt( + $mode, + 'Each chmod call must receive an integer mode.', + ); + + $modes[] = $mode; + } + self::assertSame( - 0o600, - fileperms("{$this->path}/current.json") & 0o777, - 'Configured file mode must be applied to persisted snapshots.', + [0o600, 0o600], + $modes, + 'Configured file mode must be applied to the snapshot and manifest temporary files.', ); } diff --git a/tests/Support/MockerExtension.php b/tests/Support/MockerExtension.php index a7147ef..7583468 100644 --- a/tests/Support/MockerExtension.php +++ b/tests/Support/MockerExtension.php @@ -68,7 +68,7 @@ public static function load(): void ]; } - foreach (['file_put_contents', 'flock', 'fopen', 'mkdir', 'rename', 'tempnam'] as $name) { + foreach (['chmod', 'file_put_contents', 'flock', 'fopen', 'mkdir', 'rename', 'tempnam', 'unlink'] as $name) { $mocks[] = [ 'namespace' => 'PHPForge\Debug\Storage', 'name' => $name, From 33514190966ec4c221d6278913e157ea9ec780a4 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Wed, 19 Aug 2026 09:03:23 -0400 Subject: [PATCH 3/3] Fix Build ci. --- src/Storage/SnapshotStore.php | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/Storage/SnapshotStore.php b/src/Storage/SnapshotStore.php index c2e27d9..be9ec6e 100644 --- a/src/Storage/SnapshotStore.php +++ b/src/Storage/SnapshotStore.php @@ -62,24 +62,27 @@ public function clear(): void $this->initialize(); $lock = $this->acquireLock(LOCK_EX); - $patterns = [ - "{$this->path}/*.json", - "{$this->path}/.debug-*", - ]; - - foreach ($patterns as $pattern) { - $files = glob($pattern); - - foreach ($files === false ? [] : $files as $file) { - if (is_file($file) && !@unlink($file)) { - throw new StorageException( - "Unable to remove debug data file: {$file}", - ); + + try { + $patterns = [ + "{$this->path}/*.json", + "{$this->path}/.debug-*", + ]; + + foreach ($patterns as $pattern) { + $files = glob($pattern); + + foreach ($files === false ? [] : $files as $file) { + if (is_file($file) && !@unlink($file)) { + throw new StorageException( + "Unable to remove debug data file: {$file}", + ); + } } } + } finally { + fclose($lock); } - - fclose($lock); } /**