From 0bd065973b2c2c26c4e8deed14aff5c2ea7746bc Mon Sep 17 00:00:00 2001 From: DanMat Date: Tue, 21 Jul 2026 15:11:44 -0400 Subject: [PATCH] fix: require nimbuscms/nimbus at runtime, not only in development MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin's production classes implement Nimbus interfaces — Plugin, PluginContext, FieldType, Field — but core was listed under require-dev. Composer would therefore have installed this package without checking that a compatible Nimbus was present, and the failure would have surfaced as a class not found at boot rather than as a resolution error at install time. Also adopts the narrowed registrar API from core: the plugin no longer passes its own provider id, because the loader binds it. A plugin that could name its own provider could name someone else's. Adds PackageIntegrationTest, which proves the package boundary rather than the field. It drives the real PluginLoader with this package's real manifest: discovery registers the type into the shared registry under this plugin's id and leaves core types alone; disabling leaves it unregistered; with it disabled write paths refuse the type while the admin still shows the stored source and blocks saves; re-enabling restores it; and a second package cannot take this plugin's id. --- composer.json | 6 +- src/MarkdownPlugin.php | 2 +- tests/MarkdownFieldTypeTest.php | 12 +- tests/PackageIntegrationTest.php | 192 +++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 10 deletions(-) create mode 100644 tests/PackageIntegrationTest.php diff --git a/composer.json b/composer.json index afb2557..9b2612f 100644 --- a/composer.json +++ b/composer.json @@ -16,12 +16,12 @@ ], "require": { "php": ">=8.2", - "ext-mbstring": "*" + "ext-mbstring": "*", + "nimbuscms/nimbus": "dev-main" }, "require-dev": { "phpstan/phpstan": "^2.2", - "phpunit/phpunit": "^11.0", - "nimbuscms/nimbus": "dev-main" + "phpunit/phpunit": "^11.0" }, "repositories": [ { diff --git a/src/MarkdownPlugin.php b/src/MarkdownPlugin.php index b1d31a5..7e970fc 100644 --- a/src/MarkdownPlugin.php +++ b/src/MarkdownPlugin.php @@ -23,6 +23,6 @@ final class MarkdownPlugin implements Plugin public function register(PluginContext $context): void { - $context->fieldTypes()->register(new MarkdownFieldType(), self::ID); + $context->fieldTypes()->register(new MarkdownFieldType()); } } diff --git a/tests/MarkdownFieldTypeTest.php b/tests/MarkdownFieldTypeTest.php index 454acd5..1b964a8 100644 --- a/tests/MarkdownFieldTypeTest.php +++ b/tests/MarkdownFieldTypeTest.php @@ -34,7 +34,7 @@ private function field(array $options = [], bool $required = false): Field public function test_the_plugin_registers_its_field_type(): void { $registry = new FieldTypeRegistry(); - (new MarkdownPlugin())->register(new PluginContext($registry)); + (new MarkdownPlugin())->register(new PluginContext($registry, MarkdownPlugin::ID)); self::assertTrue($registry->has('markdown')); self::assertSame('markdown', $registry->get('markdown')->type()); @@ -44,7 +44,7 @@ public function test_the_plugin_registers_its_field_type(): void public function test_the_type_appears_in_the_field_picker(): void { $registry = new FieldTypeRegistry(); - (new MarkdownPlugin())->register(new PluginContext($registry)); + (new MarkdownPlugin())->register(new PluginContext($registry, MarkdownPlugin::ID)); self::assertArrayHasKey('markdown', $registry->choices()); self::assertSame('Markdown', $registry->choices()['markdown']); @@ -62,10 +62,10 @@ public function test_the_plugin_id_matches_the_composer_manifest(): void public function test_registering_twice_is_rejected_by_core(): void { $registry = new FieldTypeRegistry(); - (new MarkdownPlugin())->register(new PluginContext($registry)); + (new MarkdownPlugin())->register(new PluginContext($registry, MarkdownPlugin::ID)); $this->expectException(\Nimbus\Content\DuplicateFieldType::class); - (new MarkdownPlugin())->register(new PluginContext($registry)); + (new MarkdownPlugin())->register(new PluginContext($registry, MarkdownPlugin::ID)); } // -------------------------------------------------------- normalization @@ -125,7 +125,7 @@ public function test_max_length_counts_characters_not_bytes(): void public function test_required_empty_is_handled_by_core_not_here(): void { $registry = new FieldTypeRegistry(); - (new MarkdownPlugin())->register(new PluginContext($registry)); + (new MarkdownPlugin())->register(new PluginContext($registry, MarkdownPlugin::ID)); $collection = new Collection(1, 'posts', 'Posts', '#', '', [$this->field(required: true)], ['kind' => 'collection']); $errors = (new Validator($registry))->validate($collection, ['body' => $this->type->normalize('')]); @@ -137,7 +137,7 @@ public function test_required_empty_is_handled_by_core_not_here(): void public function test_a_valid_required_value_passes_through_core_validation(): void { $registry = new FieldTypeRegistry(); - (new MarkdownPlugin())->register(new PluginContext($registry)); + (new MarkdownPlugin())->register(new PluginContext($registry, MarkdownPlugin::ID)); $collection = new Collection(1, 'posts', 'Posts', '#', '', [$this->field(required: true)], ['kind' => 'collection']); $errors = (new Validator($registry))->validate($collection, ['body' => $this->type->normalize('# Hello')]); diff --git a/tests/PackageIntegrationTest.php b/tests/PackageIntegrationTest.php new file mode 100644 index 0000000..5015fab --- /dev/null +++ b/tests/PackageIntegrationTest.php @@ -0,0 +1,192 @@ +installedJson = tempnam(sys_get_temp_dir(), 'nb-installed-') ?: ''; + } + + protected function tearDown(): void + { + @unlink($this->installedJson); + } + + /** @return array this package's actual composer manifest */ + private function manifest(): array + { + $manifest = json_decode((string) file_get_contents(__DIR__ . '/../composer.json'), true); + self::assertIsArray($manifest); + + return $manifest; + } + + /** + * An installed.json describing this package exactly as Composer would, + * straight from the real manifest. + */ + private function installedAs(): string + { + $manifest = $this->manifest(); + file_put_contents($this->installedJson, json_encode([ + 'packages' => [[ + 'name' => $manifest['name'], + 'type' => $manifest['type'], + 'extra' => $manifest['extra'], + ]], + ], JSON_THROW_ON_ERROR)); + + return $this->installedJson; + } + + // ------------------------------------------------------- the manifest + + public function test_the_package_declares_nimbus_as_a_runtime_dependency(): void + { + $manifest = $this->manifest(); + + // The plugin's production classes implement Nimbus interfaces, so core + // is a runtime requirement. In require-dev, Composer would happily + // install this package without a compatible Nimbus present. + self::assertArrayHasKey('nimbuscms/nimbus', $manifest['require']); + self::assertArrayNotHasKey('nimbuscms/nimbus', $manifest['require-dev'] ?? []); + } + + public function test_the_package_is_typed_as_a_nimbus_plugin(): void + { + self::assertSame('nimbuscms-plugin', $this->manifest()['type']); + } + + // -------------------------------------------------- discovery to registry + + public function test_composer_discovery_registers_the_field_type(): void + { + $registry = new FieldTypeRegistry(); + $loader = new PluginLoader($this->installedAs()); + $diagnostics = $loader->load($registry); + + self::assertSame([], $diagnostics, 'a correctly installed package must load cleanly'); + self::assertSame( + [MarkdownPlugin::ID => $this->manifest()['name']], + $loader->registered(), + ); + + // Registered into the *shared* registry, under this plugin's id. + self::assertTrue($registry->has('markdown')); + self::assertSame(MarkdownPlugin::ID, $registry->providerOf('markdown')); + self::assertArrayHasKey('markdown', $registry->choices()); + } + + public function test_core_field_types_are_untouched_by_installation(): void + { + $registry = new FieldTypeRegistry(); + (new PluginLoader($this->installedAs()))->load($registry); + + foreach (['text', 'textarea', 'number', 'boolean', 'relation'] as $core) { + self::assertSame('core', $registry->providerOf($core)); + } + } + + // ---------------------------------------------------------- disabling + + public function test_disabling_the_package_leaves_the_type_unregistered(): void + { + $registry = new FieldTypeRegistry(); + $loader = new PluginLoader($this->installedAs(), [MarkdownPlugin::ID => false]); + $diagnostics = $loader->load($registry); + + self::assertSame([], $loader->registered()); + self::assertFalse($registry->has('markdown')); + self::assertCount(1, $diagnostics); + self::assertSame(PluginDiagnostic::DISABLED, $diagnostics[0]->reason); + self::assertFalse($diagnostics[0]->isFailure(), 'disabled is a choice, not a fault'); + } + + public function test_with_the_package_disabled_writes_are_blocked_and_content_is_kept(): void + { + $registry = new FieldTypeRegistry(); + (new PluginLoader($this->installedAs(), [MarkdownPlugin::ID => false]))->load($registry); + + $field = new Field('body', 'Body', 'markdown'); + $stored = "# Still here\n\nWith **bold** text."; + + // Write paths refuse the type outright... + try { + $registry->get('markdown'); + self::fail('write paths must not resolve an unavailable type'); + } catch (UnknownFieldType $e) { + self::assertSame('markdown', $e->type); + } + + // ...while the admin degrades: the source is shown, never rewritten, + // and saving is refused until the package is back. + $fallback = $registry->forDisplay('markdown'); + self::assertSame($stored, $fallback->normalize($stored), 'stored source must survive byte for byte'); + self::assertNotNull($fallback->validate($field, $stored), 'saving must be blocked'); + self::assertStringContainsString('Still here', $fallback->renderInput($field, $stored)); + self::assertStringContainsString('markdown', $fallback->renderInput($field, $stored)); + } + + public function test_re_enabling_restores_the_field_type(): void + { + $path = $this->installedAs(); + + $disabled = new FieldTypeRegistry(); + (new PluginLoader($path, [MarkdownPlugin::ID => false]))->load($disabled); + self::assertFalse($disabled->has('markdown')); + + $enabled = new FieldTypeRegistry(); + (new PluginLoader($path, [MarkdownPlugin::ID => true]))->load($enabled); + + self::assertTrue($enabled->has('markdown'), 'flipping the switch back is all it takes'); + self::assertSame('markdown', $enabled->get('markdown')->type()); + } + + // ----------------------------------------------------------- conflicts + + public function test_a_second_package_cannot_take_this_plugins_id(): void + { + $manifest = $this->manifest(); + file_put_contents($this->installedJson, json_encode(['packages' => [ + ['name' => $manifest['name'], 'type' => $manifest['type'], 'extra' => $manifest['extra']], + ['name' => 'squatter/markdown', 'type' => 'nimbuscms-plugin', 'extra' => $manifest['extra']], + ]], JSON_THROW_ON_ERROR)); + + $registry = new FieldTypeRegistry(); + $loader = new PluginLoader($this->installedJson); + $diagnostics = $loader->load($registry); + + self::assertSame([MarkdownPlugin::ID => $manifest['name']], $loader->registered()); + self::assertCount(1, $diagnostics); + self::assertSame(PluginDiagnostic::DUPLICATE_ID, $diagnostics[0]->reason); + self::assertSame('squatter/markdown', $diagnostics[0]->package); + } +}