forked from fa/breadcrumb-the-shire
feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,7 +43,10 @@ final class ModuleRegistryTest extends TestCase
|
||||
'id' => 'testmod',
|
||||
'version' => '1.0.0',
|
||||
'load_order' => 10,
|
||||
'permissions' => ['test.view', 'test.edit'],
|
||||
'permissions' => [
|
||||
['key' => 'test.view', 'description' => 'Test view'],
|
||||
['key' => 'test.edit', 'description' => 'Test edit'],
|
||||
],
|
||||
'search_resources' => [],
|
||||
]);
|
||||
|
||||
@@ -51,7 +54,7 @@ final class ModuleRegistryTest extends TestCase
|
||||
|
||||
self::assertTrue($registry->hasModule('testmod'));
|
||||
self::assertSame('1.0.0', $registry->getModule('testmod')->version);
|
||||
self::assertSame(['test.view', 'test.edit'], $registry->getPermissions());
|
||||
self::assertSame(['test.view', 'test.edit'], $registry->getPermissionKeys());
|
||||
}
|
||||
|
||||
public function testDeterministicOrdering(): void
|
||||
@@ -80,8 +83,16 @@ final class ModuleRegistryTest extends TestCase
|
||||
|
||||
public function testPermissionConflictThrows(): void
|
||||
{
|
||||
$this->createModuleManifest('mod-a', ['id' => 'mod-a', 'load_order' => 1, 'permissions' => ['shared.perm']]);
|
||||
$this->createModuleManifest('mod-b', ['id' => 'mod-b', 'load_order' => 2, 'permissions' => ['shared.perm']]);
|
||||
$this->createModuleManifest('mod-a', [
|
||||
'id' => 'mod-a',
|
||||
'load_order' => 1,
|
||||
'permissions' => [['key' => 'shared.perm', 'description' => 'Shared permission']],
|
||||
]);
|
||||
$this->createModuleManifest('mod-b', [
|
||||
'id' => 'mod-b',
|
||||
'load_order' => 2,
|
||||
'permissions' => [['key' => 'shared.perm', 'description' => 'Shared permission duplicate']],
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage("Permission conflict: 'shared.perm'");
|
||||
@@ -108,6 +119,137 @@ final class ModuleRegistryTest extends TestCase
|
||||
ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
|
||||
}
|
||||
|
||||
public function testRoutePathConflictThrows(): void
|
||||
{
|
||||
$this->createModuleManifest('mod-a', [
|
||||
'id' => 'mod-a',
|
||||
'load_order' => 1,
|
||||
'routes' => [['path' => '/shared-path', 'target' => 'a/index']],
|
||||
]);
|
||||
$this->createModuleManifest('mod-b', [
|
||||
'id' => 'mod-b',
|
||||
'load_order' => 2,
|
||||
'routes' => [['path' => '/shared-path', 'target' => 'b/index']],
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Route path conflict');
|
||||
|
||||
ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
|
||||
}
|
||||
|
||||
public function testUiSlotConflictThrows(): void
|
||||
{
|
||||
$panelTemplateA = $this->createFixtureFile('mod-a-panel.phtml', '<ul></ul>');
|
||||
$panelTemplateB = $this->createFixtureFile('mod-b-panel.phtml', '<ul></ul>');
|
||||
|
||||
$this->createModuleManifest('mod-a', [
|
||||
'id' => 'mod-a',
|
||||
'load_order' => 1,
|
||||
'ui_slots' => [
|
||||
'aside.tab_panel' => [
|
||||
[
|
||||
'key' => 'people',
|
||||
'label' => 'People',
|
||||
'icon' => 'bi-people',
|
||||
'permission' => 'can_view_people',
|
||||
'panel_template' => $panelTemplateA,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$this->createModuleManifest('mod-b', [
|
||||
'id' => 'mod-b',
|
||||
'load_order' => 2,
|
||||
'ui_slots' => [
|
||||
'aside.tab_panel' => [
|
||||
[
|
||||
'key' => 'people',
|
||||
'label' => 'People B',
|
||||
'icon' => 'bi-people',
|
||||
'permission' => 'can_view_people',
|
||||
'panel_template' => $panelTemplateB,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage("UI slot conflict: slot 'aside.tab_panel' key 'people'");
|
||||
|
||||
ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
|
||||
}
|
||||
|
||||
public function testInvalidRouteContractThrows(): void
|
||||
{
|
||||
$this->createModuleManifest('mod-a', [
|
||||
'id' => 'mod-a',
|
||||
'routes' => [['path' => '', 'target' => 'address-book/index']],
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage("must define non-empty 'path' and 'target'");
|
||||
|
||||
ModuleRegistry::boot($this->fixturesDir, ['mod-a']);
|
||||
}
|
||||
|
||||
public function testInvalidUiSlotContractThrows(): void
|
||||
{
|
||||
$this->createModuleManifest('mod-a', [
|
||||
'id' => 'mod-a',
|
||||
'ui_slots' => [
|
||||
'aside.tab_panel' => [
|
||||
['label' => 'Missing Key'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage("must define a non-empty 'key'");
|
||||
|
||||
ModuleRegistry::boot($this->fixturesDir, ['mod-a']);
|
||||
}
|
||||
|
||||
public function testUiSlotsAreSortedDeterministicallyByOrderModuleAndKey(): void
|
||||
{
|
||||
$this->createModuleManifest('mod-z', [
|
||||
'id' => 'mod-z',
|
||||
'load_order' => 1,
|
||||
'ui_slots' => [
|
||||
'search.resource_item' => [
|
||||
['key' => 'z-second', 'label' => 'Z Second', 'base_url' => 'z-second', 'permission' => 'can.z', 'order' => 20],
|
||||
['key' => 'z-first', 'label' => 'Z First', 'base_url' => 'z-first', 'permission' => 'can.z', 'order' => 20],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$this->createModuleManifest('mod-a', [
|
||||
'id' => 'mod-a',
|
||||
'load_order' => 1,
|
||||
'ui_slots' => [
|
||||
'search.resource_item' => [
|
||||
['key' => 'a-mid', 'label' => 'A Mid', 'base_url' => 'a-mid', 'permission' => 'can.a', 'order' => 20],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$this->createModuleManifest('mod-b', [
|
||||
'id' => 'mod-b',
|
||||
'load_order' => 1,
|
||||
'ui_slots' => [
|
||||
'search.resource_item' => [
|
||||
['key' => 'b-low', 'label' => 'B Low', 'base_url' => 'b-low', 'permission' => 'can.b', 'order' => 10],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$registry = ModuleRegistry::boot($this->fixturesDir, ['mod-z', 'mod-b', 'mod-a']);
|
||||
$slotItems = $registry->getSlotContributions('search.resource_item');
|
||||
|
||||
self::assertSame(['b-low', 'a-mid', 'z-first', 'z-second'], array_column($slotItems, 'key'));
|
||||
foreach ($slotItems as $slotItem) {
|
||||
self::assertArrayNotHasKey('__module_id', $slotItem);
|
||||
}
|
||||
}
|
||||
|
||||
public function testMissingManifestFileThrows(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
@@ -126,16 +268,21 @@ final class ModuleRegistryTest extends TestCase
|
||||
ModuleRegistry::boot($this->fixturesDir, ['wrongdir']);
|
||||
}
|
||||
|
||||
public function testResolveEnabledModulesFromConfig(): void
|
||||
public function testResolveEnabledModulesFromConfigWhenEnvUnset(): void
|
||||
{
|
||||
$result = ModuleRegistry::resolveEnabledModules([
|
||||
'enabled_modules' => ['mod-a', 'mod-b'],
|
||||
]);
|
||||
putenv('APP_ENABLED_MODULES');
|
||||
try {
|
||||
$result = ModuleRegistry::resolveEnabledModules([
|
||||
'enabled_modules' => ['mod-a', 'mod-b'],
|
||||
]);
|
||||
|
||||
self::assertSame(['mod-a', 'mod-b'], $result);
|
||||
self::assertSame(['mod-a', 'mod-b'], $result);
|
||||
} finally {
|
||||
putenv('APP_ENABLED_MODULES');
|
||||
}
|
||||
}
|
||||
|
||||
public function testResolveEnabledModulesFromEnv(): void
|
||||
public function testResolveEnabledModulesFromEnvList(): void
|
||||
{
|
||||
putenv('APP_ENABLED_MODULES=env-a,env-b');
|
||||
|
||||
@@ -149,13 +296,32 @@ final class ModuleRegistryTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testResolveEnabledModulesFromEmptyEnvReturnsNoModules(): void
|
||||
{
|
||||
putenv('APP_ENABLED_MODULES=');
|
||||
|
||||
try {
|
||||
$result = ModuleRegistry::resolveEnabledModules([
|
||||
'enabled_modules' => ['config-a', 'config-b'],
|
||||
]);
|
||||
self::assertSame([], $result);
|
||||
} finally {
|
||||
putenv('APP_ENABLED_MODULES');
|
||||
}
|
||||
}
|
||||
|
||||
public function testResolveEnabledModulesDeduplicates(): void
|
||||
{
|
||||
$result = ModuleRegistry::resolveEnabledModules([
|
||||
'enabled_modules' => ['mod-a', 'mod-a', 'mod-b'],
|
||||
]);
|
||||
putenv('APP_ENABLED_MODULES');
|
||||
try {
|
||||
$result = ModuleRegistry::resolveEnabledModules([
|
||||
'enabled_modules' => ['mod-a', 'mod-a', 'mod-b'],
|
||||
]);
|
||||
|
||||
self::assertSame(['mod-a', 'mod-b'], $result);
|
||||
self::assertSame(['mod-a', 'mod-b'], $result);
|
||||
} finally {
|
||||
putenv('APP_ENABLED_MODULES');
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetNonExistentModuleThrows(): void
|
||||
@@ -212,6 +378,17 @@ final class ModuleRegistryTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
private function createFixtureFile(string $relativePath, string $content): string
|
||||
{
|
||||
$path = $this->fixturesDir . '/' . ltrim($relativePath, '/');
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0777, true);
|
||||
}
|
||||
file_put_contents($path, $content);
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function removeDir(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
|
||||
Reference in New Issue
Block a user