diff --git a/src/Framework/Bootloader/StorageSnapshotsBootloader.php b/src/Framework/Bootloader/StorageSnapshotsBootloader.php new file mode 100644 index 000000000..41447fc7c --- /dev/null +++ b/src/Framework/Bootloader/StorageSnapshotsBootloader.php @@ -0,0 +1,47 @@ + [self::class, 'storageSnapshot'], + SnapshotterInterface::class => StorageSnapshooter::class, + ]; + + private function storageSnapshot(EnvironmentInterface $env, FactoryInterface $factory): StorageSnapshot + { + $bucket = $env->get('SNAPSHOTS_BUCKET'); + + if ($bucket === null) { + throw new \RuntimeException( + 'Please, configure a bucket for storing snapshots using the environment variable `SNAPSHOTS_BUCKET`.' + ); + } + + return $factory->make(StorageSnapshot::class, [ + 'bucket' => $bucket, + 'directory' => $env->get('SNAPSHOTS_DIRECTORY', null), + ]); + } +} diff --git a/src/Framework/Exceptions/Reporter/StorageReporter.php b/src/Framework/Exceptions/Reporter/StorageReporter.php new file mode 100644 index 000000000..d8479315b --- /dev/null +++ b/src/Framework/Exceptions/Reporter/StorageReporter.php @@ -0,0 +1,21 @@ +storageSnapshot->create($exception); + } +} diff --git a/src/Framework/Snapshots/StorageSnapshooter.php b/src/Framework/Snapshots/StorageSnapshooter.php new file mode 100644 index 000000000..d28502420 --- /dev/null +++ b/src/Framework/Snapshots/StorageSnapshooter.php @@ -0,0 +1,18 @@ +storageSnapshot->create($e); + } +} diff --git a/src/Snapshots/composer.json b/src/Snapshots/composer.json index de89b0ca1..eba8215c6 100644 --- a/src/Snapshots/composer.json +++ b/src/Snapshots/composer.json @@ -33,6 +33,7 @@ "symfony/finder": "^5.3.7|^6.0" }, "require-dev": { + "spiral/storage": "^3.9", "phpunit/phpunit": "^10.1", "vimeo/psalm": "^5.9" }, @@ -51,6 +52,9 @@ "dev-master": "3.9.x-dev" } }, + "suggest": { + "spiral/storage": "For storing snapshots using storage abstraction" + }, "config": { "sort-packages": true }, diff --git a/src/Snapshots/src/StorageSnapshot.php b/src/Snapshots/src/StorageSnapshot.php new file mode 100644 index 000000000..d987743cf --- /dev/null +++ b/src/Snapshots/src/StorageSnapshot.php @@ -0,0 +1,57 @@ +getID($e), $e); + + $this->saveSnapshot($snapshot); + + return $snapshot; + } + + protected function saveSnapshot(SnapshotInterface $snapshot): void + { + $filename = $this->getFilename($snapshot, new \DateTime()); + + $this->storage + ->bucket($this->bucket) + ->create($this->directory !== null ? $this->directory . DIRECTORY_SEPARATOR . $filename : $filename) + ->write($this->renderer->render($snapshot->getException(), $this->verbosity)); + } + + /** + * @throws \Exception + */ + protected function getFilename(SnapshotInterface $snapshot, \DateTimeInterface $time): string + { + return \sprintf( + '%s-%s.txt', + $time->format('d.m.Y-Hi.s'), + (new \ReflectionClass($snapshot->getException()))->getShortName() + ); + } + + protected function getID(\Throwable $e): string + { + return \md5(\implode('|', [$e->getMessage(), $e->getFile(), $e->getLine()])); + } +} diff --git a/src/Snapshots/tests/StorageSnapshotTest.php b/src/Snapshots/tests/StorageSnapshotTest.php new file mode 100644 index 000000000..91de79c11 --- /dev/null +++ b/src/Snapshots/tests/StorageSnapshotTest.php @@ -0,0 +1,83 @@ +renderer = $this->createMock(ExceptionRendererInterface::class); + $this->renderer + ->expects($this->once()) + ->method('render') + ->willReturn('foo'); + + $this->file = $this->createMock(FileInterface::class); + $this->file + ->expects($this->once()) + ->method('write') + ->with('foo'); + + $this->bucket = $this->createMock(BucketInterface::class); + + $this->storage = $this->createMock(StorageInterface::class); + $this->storage + ->expects($this->once()) + ->method('bucket') + ->willReturn($this->bucket); + } + + public function testCreate(): void + { + $this->bucket + ->expects($this->once()) + ->method('create') + ->with($this->callback(static fn (string $filename) => \str_contains($filename, 'Error.txt'))) + ->willReturn($this->file); + + $e = new \Error('message'); + $s = (new StorageSnapshot('foo', $this->storage, Verbosity::VERBOSE, $this->renderer))->create($e); + + $this->assertSame($e, $s->getException()); + + $this->assertStringContainsString('Error', $s->getMessage()); + $this->assertStringContainsString('message', $s->getMessage()); + $this->assertStringContainsString(__FILE__, $s->getMessage()); + $this->assertStringContainsString('53', $s->getMessage()); + } + + public function testCreateWithDirectory(): void + { + $this->bucket + ->expects($this->once()) + ->method('create') + ->with($this->callback(static fn (string $filename) => \str_starts_with($filename, 'foo/bar'))) + ->willReturn($this->file); + + $e = new \Error('message'); + $s = (new StorageSnapshot('foo', $this->storage, Verbosity::VERBOSE, $this->renderer, 'foo/bar')) + ->create($e); + + $this->assertSame($e, $s->getException()); + + $this->assertStringContainsString('Error', $s->getMessage()); + $this->assertStringContainsString('message', $s->getMessage()); + $this->assertStringContainsString(__FILE__, $s->getMessage()); + $this->assertStringContainsString('72', $s->getMessage()); + } +} diff --git a/tests/Framework/Bootloader/Exceptions/StorageSnapshotsBootloaderTest.php b/tests/Framework/Bootloader/Exceptions/StorageSnapshotsBootloaderTest.php new file mode 100644 index 000000000..73d389728 --- /dev/null +++ b/tests/Framework/Bootloader/Exceptions/StorageSnapshotsBootloaderTest.php @@ -0,0 +1,41 @@ + 'foo', + ]; + + public function createAppInstance(Container $container = new Container()): TestApp + { + return TestApp::create( + directories: $this->defineDirectories( + $this->rootDirectory(), + ), + handleErrors: false, + container: $container, + )->withBootloaders([StorageSnapshotsBootloader::class]); + } + + public function testSnapshotterInterfaceBinding(): void + { + $this->assertContainerBoundAsSingleton(SnapshotterInterface::class, StorageSnapshooter::class); + } + + public function testStorageSnapshotBinding(): void + { + $this->assertContainerBoundAsSingleton(StorageSnapshot::class, StorageSnapshot::class); + } +}