assertIsArray($json); $require = is_array($json['require'] ?? null) ? $json['require'] : []; $requireDev = is_array($json['require-dev'] ?? null) ? $json['require-dev'] : []; $allowPlugins = is_array($json['config']['allow-plugins'] ?? null) ? $json['config']['allow-plugins'] : []; $this->assertArrayNotHasKey('zoujingli/think-install', $require); $this->assertArrayNotHasKey('zoujingli/think-install', $requireDev); $this->assertArrayNotHasKey('zoujingli/think-install', $allowPlugins); } public function testRootComposerAllowsInstallComposerPluginAndDoesNotKeepLegacyHook(): void { $json = json_decode((string)file_get_contents(TEST_PROJECT_ROOT . '/composer.json'), true); $this->assertIsArray($json); $require = is_array($json['require'] ?? null) ? $json['require'] : []; $allowPlugins = is_array($json['config']['allow-plugins'] ?? null) ? $json['config']['allow-plugins'] : []; $scripts = $json['scripts'] ?? []; $this->assertIsArray($scripts); $this->assertArrayHasKey('zoujingli/think-plugs-install', $require); $this->assertTrue(boolval($allowPlugins['zoujingli/think-plugs-install'] ?? false)); $this->assertArrayNotHasKey('post-autoload-dump', $scripts); } public function testInstallPackageIsRegisteredAsComposerPlugin(): void { $json = json_decode((string)file_get_contents(TEST_PROJECT_ROOT . '/plugin/think-plugs-install/composer.json'), true); $this->assertIsArray($json); $require = is_array($json['require'] ?? null) ? $json['require'] : []; $services = (array)($json['extra']['think']['services'] ?? []); $this->assertSame('composer-plugin', $json['type'] ?? null); $this->assertSame('plugin\\install\\composer\\Plugin', $json['extra']['class'] ?? null); $this->assertContains('plugin\\install\\Service', $services); $this->assertArrayHasKey('composer-plugin-api', $require); } public function testComposerLockKeepsInstallPackageAsComposerPlugin(): void { $lock = json_decode((string)file_get_contents(TEST_PROJECT_ROOT . '/composer.lock'), true); $this->assertIsArray($lock); $package = null; foreach ((array)($lock['packages'] ?? []) as $item) { if (($item['name'] ?? null) === 'zoujingli/think-plugs-install') { $package = $item; break; } } $this->assertIsArray($package, 'composer.lock is missing zoujingli/think-plugs-install'); $require = is_array($package['require'] ?? null) ? $package['require'] : []; $this->assertSame('composer-plugin', $package['type'] ?? null); $this->assertSame('plugin\\install\\composer\\Plugin', $package['extra']['class'] ?? null); $this->assertArrayHasKey('composer-plugin-api', $require); } public function testComposerPluginClassStaysSafeOutsideComposerRuntime(): void { $loaded = class_exists('plugin\\install\\composer\\Plugin'); $this->assertIsBool($loaded); if (!interface_exists('Composer\\Plugin\\PluginInterface', false)) { $this->assertFalse($loaded); } } public function testPluginServicesDoNotDeclareMenuMethod(): void { $violations = []; foreach (glob(TEST_PROJECT_ROOT . '/plugin/*/src/Service.php') ?: [] as $file) { $source = (string)file_get_contents($file); if (preg_match('/function\s+menu\s*\(/i', $source) === 1) { $violations[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file); } } $this->assertSame([], $violations, 'Plugin menus must be declared in composer.json: ' . implode(', ', $violations)); } public function testRuntimePluginServicesDoNotDeclareMetadataProperties(): void { $violations = []; $pattern = '/protected\s+(?:string|array|bool)\s+\$(appCode|appName|appPrefix|appPrefixes|package|appAlias|appDocument|appDescription|appPlatforms|appLicense|appVersion|appHomepage)\b/'; foreach (glob(TEST_PROJECT_ROOT . '/plugin/*/composer.json') ?: [] as $file) { $manifest = json_decode((string)file_get_contents($file), true); if (!is_array($manifest)) { continue; } $services = (array)($manifest['extra']['think']['services'] ?? []); $service = strval($services[0] ?? ''); if ($service === '' || !class_exists($service) || !is_subclass_of($service, Plugin::class)) { continue; } $serviceFile = dirname($file) . '/src/Service.php'; if (!is_file($serviceFile)) { continue; } $source = (string)file_get_contents($serviceFile); if (preg_match_all($pattern, $source, $matches) < 1) { continue; } $names = array_values(array_unique($matches[1])); $label = str_replace(TEST_PROJECT_ROOT . '/', '', $serviceFile); $violations[] = "{$label} declares service metadata properties: " . implode(', ', $names); } $this->assertSame([], $violations, 'Runtime plugin metadata must only come from composer.json: ' . implode(', ', $violations)); } public function testPluginComposerMetadataUsesXadminAppOnly(): void { $required = ['code', 'name']; $stringFields = ['code', 'name', 'prefix', 'alias', 'space', 'document', 'description', 'icon', 'cover']; $arrayFields = ['prefixes', 'platforms', 'license']; $allowed = array_merge($stringFields, $arrayFields, ['super']); $legacy = []; $missing = []; $invalid = []; foreach (glob(TEST_PROJECT_ROOT . '/plugin/*/composer.json') ?: [] as $file) { $manifest = json_decode((string)file_get_contents($file), true); if (!is_array($manifest)) { continue; } if (isset($manifest['extra']['config'])) { $legacy[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file) . ' uses extra.config'; } if (isset($manifest['extra']['xadmin']['service'])) { $legacy[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file) . ' uses extra.xadmin.service'; } if (isset($manifest['extra']['xadmin']['config'])) { $legacy[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file) . ' uses extra.xadmin.config'; } $services = (array)($manifest['extra']['think']['services'] ?? []); $service = strval($services[0] ?? ''); if ($service === '' || !class_exists($service) || !is_subclass_of($service, Plugin::class)) { continue; } $app = $manifest['extra']['xadmin']['app'] ?? null; if (!is_array($app) || $app === []) { $missing[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file); continue; } foreach ($required as $key) { if (!is_string($app[$key] ?? null) || trim($app[$key]) === '') { $missing[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file) . " missing {$key}"; } } foreach (array_keys($app) as $key) { if (!in_array($key, $allowed, true)) { $invalid[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file) . " contains unsupported xadmin.app.{$key}"; } } foreach ($stringFields as $key) { if (!array_key_exists($key, $app)) { continue; } if (!is_string($app[$key])) { $invalid[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file) . " requires xadmin.app.{$key} to be a string"; continue; } if (!in_array($key, $required, true) && trim($app[$key]) === '') { $invalid[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file) . " requires xadmin.app.{$key} to be non-empty when declared"; } } foreach ($arrayFields as $key) { if (!array_key_exists($key, $app)) { continue; } if (!is_array($app[$key])) { $invalid[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file) . " requires xadmin.app.{$key} to be an array"; continue; } foreach ($app[$key] as $index => $value) { if (!is_string($value) || trim($value) === '') { $invalid[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file) . " requires xadmin.app.{$key}[{$index}] to be a non-empty string"; } } } if (array_key_exists('super', $app) && !is_bool($app['super'])) { $invalid[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file) . ' requires xadmin.app.super to be a boolean'; } if (isset($app['prefix'], $app['prefixes']) && is_string($app['prefix']) && is_array($app['prefixes'])) { if (!in_array($app['prefix'], $app['prefixes'], true)) { $invalid[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file) . ' requires xadmin.app.prefix to be included in xadmin.app.prefixes'; } } } $this->assertSame([], $legacy, 'Legacy plugin metadata blocks are not allowed: ' . implode(', ', $legacy)); $this->assertSame([], $missing, 'Runtime plugin metadata must be declared in extra.xadmin.app: ' . implode(', ', $missing)); $this->assertSame([], $invalid, 'Unsupported xadmin.app fields found: ' . implode(', ', $invalid)); } public function testPluginComposerPublishRulesUseCopyOnlyAndNoLegacyKeys(): void { $invalid = []; foreach (glob(TEST_PROJECT_ROOT . '/plugin/*/composer.json') ?: [] as $file) { $manifest = json_decode((string)file_get_contents($file), true); if (!is_array($manifest)) { continue; } $publish = $manifest['extra']['xadmin']['publish'] ?? null; if ($publish === null) { continue; } if (!is_array($publish)) { $invalid[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file) . ' requires extra.xadmin.publish to be an object'; continue; } foreach (array_keys($publish) as $key) { if ($key !== 'copy') { $invalid[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file) . " contains unsupported xadmin.publish.{$key}"; } } $copy = $publish['copy'] ?? null; if ($copy === null) { continue; } if (!is_array($copy)) { $invalid[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file) . ' requires extra.xadmin.publish.copy to be an array/object'; continue; } foreach ($copy as $source => $target) { $label = str_replace(TEST_PROJECT_ROOT . '/', '', $file); if (is_array($target)) { $keys = array_keys($target); $isKeyValueObject = is_string($source); $allowed = $isKeyValueObject ? ['to', 'force', 'exclude'] : ['from', 'to', 'force', 'exclude']; foreach ($keys as $key) { if (!in_array($key, $allowed, true)) { $invalid[] = "{$label} contains unsupported publish rule key {$key}"; } } if ($isKeyValueObject) { if (!is_string($source) || trim($source) === '') { $invalid[] = "{$label} requires key-value publish source to be non-empty"; } if (!is_string($target['to'] ?? null) || trim($target['to']) === '') { $invalid[] = "{$label} requires key-value publish object rules to declare non-empty to"; } } else { if (!is_string($target['from'] ?? null) || trim($target['from']) === '') { $invalid[] = "{$label} requires object publish rules to declare non-empty from"; } if (!is_string($target['to'] ?? null) || trim($target['to']) === '') { $invalid[] = "{$label} requires object publish rules to declare non-empty to"; } } if (array_key_exists('force', $target) && !is_bool($target['force'])) { $invalid[] = "{$label} requires publish rule force to be boolean"; } if (array_key_exists('exclude', $target) && !is_string($target['exclude']) && !is_array($target['exclude'])) { $invalid[] = "{$label} requires publish rule exclude to be string or array"; } continue; } if (!is_string($source) || trim($source) === '') { $invalid[] = "{$label} requires publish source to be non-empty"; } if (!is_string($target) || trim($target) === '') { $invalid[] = "{$label} requires publish target to be non-empty"; } } } $this->assertSame([], $invalid, 'Plugin publish rules must only use copy with current keys: ' . implode(', ', $invalid)); } public function testWorkspacePluginsDoNotUseLegacyExtraPluginInstallerBlock(): void { $violations = []; foreach (glob(TEST_PROJECT_ROOT . '/plugin/*/composer.json') ?: [] as $file) { $manifest = json_decode((string)file_get_contents($file), true); if (!is_array($manifest)) { continue; } if (isset($manifest['extra']['plugin'])) { $violations[] = str_replace(TEST_PROJECT_ROOT . '/', '', $file); } } $this->assertSame([], $violations, 'Legacy extra.plugin installer blocks are not allowed: ' . implode(', ', $violations)); } public function testRuntimePluginComposerManifestProvidesMinimalSkeleton(): void { $invalid = []; foreach (glob(TEST_PROJECT_ROOT . '/plugin/*/composer.json') ?: [] as $file) { $manifest = json_decode((string)file_get_contents($file), true); if (!is_array($manifest)) { continue; } $services = (array)($manifest['extra']['think']['services'] ?? []); $service = strval($services[0] ?? ''); if ($service === '' || !class_exists($service) || !is_subclass_of($service, Plugin::class)) { continue; } $label = str_replace(TEST_PROJECT_ROOT . '/', '', $file); if (strval($manifest['type'] ?? '') !== 'think-admin-plugin') { $invalid[] = "{$label} requires type=think-admin-plugin"; } if (!is_string($manifest['name'] ?? null) || trim($manifest['name']) === '') { $invalid[] = "{$label} requires composer.name"; } if (!is_string($manifest['description'] ?? null) || trim($manifest['description']) === '') { $invalid[] = "{$label} requires composer.description"; } if (!is_array($manifest['autoload']['psr-4'] ?? null) || ($manifest['autoload']['psr-4'] ?? []) === []) { $invalid[] = "{$label} requires autoload.psr-4"; } if (count($services) !== 1) { $invalid[] = "{$label} requires exactly one extra.think.services entry"; } if (!is_subclass_of($service, Plugin::class)) { $invalid[] = "{$label} service must extend think\\admin\\Plugin"; } $autoload = (array)($manifest['autoload']['psr-4'] ?? []); $matched = false; foreach ($autoload as $namespace => $directory) { $namespace = trim(strval($namespace), '\\'); $directory = trim(strval($directory), '\/'); if ($namespace === '' || $directory === '') { continue; } if (str_starts_with(trim($service, '\\'), $namespace . '\\') && $directory === 'src') { $matched = true; break; } } if (!$matched) { $invalid[] = "{$label} requires service namespace to be mapped to src in autoload.psr-4"; } } $this->assertSame([], $invalid, 'Runtime plugin composer skeleton violations found: ' . implode(', ', $invalid)); } }