root = sys_get_temp_dir() . '/thinkadmin-composer-install-' . bin2hex(random_bytes(6)); mkdir($this->root . '/vendor/composer', 0777, true); file_put_contents($this->root . '/think', "#!/usr/bin/env php\n"); } protected function tearDown(): void { $this->removeTree($this->root); } public function testDiscoverInstalledPluginsReadsConfiguredMigrations(): void { $this->createInstalledPlugin('system', 'vendor/system-plugin', '系统管理', '20241010000001_install_system20241010.php'); $this->createInstalledPlugin('static', 'vendor/static-plugin', '静态资源'); $service = new ComposerLifecycleService($this->root); $plugins = $service->discoverInstalledPlugins(); $this->assertCount(2, $plugins); $this->assertTrue($plugins['vendor/system-plugin']['migrate']['configured']); $this->assertSame('20241010000001_install_system20241010.php', $plugins['vendor/system-plugin']['migrate']['file']); $this->assertFalse($plugins['vendor/static-plugin']['migrate']['configured']); $this->assertArrayNotHasKey('signature', $plugins['vendor/system-plugin']['migrate']); $this->assertNotSame('', $plugins['vendor/system-plugin']['signature']); } public function testDiscoverInstalledPluginsIncludesWorkspacePluginsOutsideInstalledJson(): void { $this->createWorkspacePlugin('worker', 'vendor/worker-plugin', '运行时服务', '20241010000008_install_worker20241010.php'); $service = new ComposerLifecycleService($this->root); $plugins = $service->discoverInstalledPlugins(); $this->assertArrayHasKey('vendor/worker-plugin', $plugins); $this->assertTrue($plugins['vendor/worker-plugin']['migrate']['configured']); $this->assertSame('运行时服务', $plugins['vendor/worker-plugin']['title']); } public function testInstallFromCommandRunsAutoMigrateWhenConfigured(): void { $this->createInstalledPlugin('system', 'vendor/system-plugin', '系统管理', '20241010000001_install_system20241010.php'); $messages = []; $commands = []; $service = new ComposerLifecycleService( $this->root, function (string $message) use (&$messages): void { $messages[] = $message; }, function (array $command, string $cwd) use (&$commands): int { $commands[] = [$command, $cwd]; return 0; } ); $status = $service->installFromCommand(); $this->assertSame(0, $status); $this->assertSame([ [[PHP_BINARY, $this->root . '/think', 'service:discover'], $this->root], [[PHP_BINARY, $this->root . '/think', 'xadmin:publish', '--migrate'], $this->root], ], $commands); $this->assertContains('Install database migrations for: 系统管理', $messages); $this->assertFileExists($this->root . '/vendor/' . ComposerLifecycleService::STATE_FILE); } public function testInstallFromCommandWritesForceMessageWhenForced(): void { $messages = []; $commands = []; $service = new ComposerLifecycleService( $this->root, function (string $message) use (&$messages): void { $messages[] = $message; }, function (array $command, string $cwd) use (&$commands): int { $commands[] = [$command, $cwd]; return 0; } ); $status = $service->installFromCommand(false, true); $this->assertSame(0, $status); $this->assertSame([ [[PHP_BINARY, $this->root . '/think', 'service:discover'], $this->root], [[PHP_BINARY, $this->root . '/think', 'xadmin:publish', '--migrate'], $this->root], ], $commands); $this->assertContains('Force database migration for all published scripts.', $messages); } public function testWriteStateNormalizesLegacyPayload(): void { $service = new ComposerLifecycleService($this->root); $service->writeState([ 'vendor/system-plugin' => [ 'name' => 'vendor/system-plugin', 'title' => '系统管理', 'version' => '1.0.0', 'reference' => 'ref-demo', 'signature' => 'sig-demo', 'migrate' => [ 'configured' => true, 'file' => 'legacy.php', ], ], ]); $state = json_decode((string)file_get_contents($this->root . '/vendor/' . ComposerLifecycleService::STATE_FILE), true, 512, JSON_THROW_ON_ERROR); $this->assertSame([ 'vendor/system-plugin' => [ 'name' => 'vendor/system-plugin', 'title' => '系统管理', 'signature' => 'sig-demo', 'migrate' => [ 'configured' => true, ], ], ], $state); } public function testSyncAfterComposerWarnsForMissingAndRemovedMigrations(): void { $this->createInstalledPlugin('system', 'vendor/system-plugin', '系统管理', '20241010000001_install_system20241010.php', '1.0.0', 'ref-new'); $this->createInstalledPlugin('static', 'vendor/static-plugin', '静态资源', null, '1.0.0', 'ref-static'); $messages = []; $commands = []; $service = new ComposerLifecycleService( $this->root, function (string $message) use (&$messages): void { $messages[] = $message; }, function (array $command, string $cwd) use (&$commands): int { $commands[] = [$command, $cwd]; return 0; } ); $service->writeState([ 'vendor/system-plugin' => [ 'name' => 'vendor/system-plugin', 'title' => '系统管理', 'signature' => 'ref-old', 'migrate' => ['configured' => true], ], 'vendor/payment-plugin' => [ 'name' => 'vendor/payment-plugin', 'title' => '支付管理', 'signature' => 'ref-pay', 'migrate' => ['configured' => true], ], ]); $status = $service->syncAfterComposer(); $this->assertSame(0, $status); $this->assertSame([ [[PHP_BINARY, $this->root . '/think', 'service:discover'], $this->root], [[PHP_BINARY, $this->root . '/think', 'xadmin:publish', '--migrate'], $this->root], ], $commands); $this->assertContains('Auto migrate plugins: 系统管理', $messages); $this->assertContains('Skip database migration for plugins without extra.xadmin.migrate: 静态资源', $messages); $this->assertContains('Removed plugins keep historical tables; rollback is not automatic: 支付管理', $messages); } public function testSyncAfterComposerDefersInitialMigrationUntilExplicitInstall(): void { $this->createInstalledPlugin('system', 'vendor/system-plugin', '系统管理', '20241010000001_install_system20241010.php', '1.0.0', 'ref-new'); $this->createInstalledPlugin('static', 'vendor/static-plugin', '静态资源', null, '1.0.0', 'ref-static'); $messages = []; $commands = []; $service = new ComposerLifecycleService( $this->root, function (string $message) use (&$messages): void { $messages[] = $message; }, function (array $command, string $cwd) use (&$commands): int { $commands[] = [$command, $cwd]; return 0; } ); $status = $service->syncAfterComposer(); $this->assertSame(0, $status); $this->assertSame([ [[PHP_BINARY, $this->root . '/think', 'service:discover'], $this->root], [[PHP_BINARY, $this->root . '/think', 'xadmin:publish'], $this->root], ], $commands); $this->assertContains('Skip automatic database migration on initial Composer install. Run `php think xadmin:install` after configuring the environment: 系统管理', $messages); $this->assertContains('Skip database migration for plugins without extra.xadmin.migrate: 静态资源', $messages); } private function createInstalledPlugin( string $code, string $name, string $title, ?string $migration = null, string $version = '1.0.0', string $reference = 'ref-demo' ): void { $vendorPath = $this->root . '/vendor/' . dirname($name) . '/' . basename($name); mkdir($vendorPath . '/src', 0777, true); $manifest = [ 'name' => $name, 'version' => $version, 'type' => 'think-admin-plugin', 'autoload' => ['psr-4' => ["plugin\\{$code}\\" => 'src']], 'extra' => [ 'think' => ['services' => ["plugin\\{$code}\\Service"]], 'xadmin' => [ 'app' => ['code' => $code, 'name' => $title], ], ], ]; if ($migration !== null) { mkdir($vendorPath . '/stc/database', 0777, true); file_put_contents($vendorPath . '/stc/database/' . $migration, " $migration, 'class' => 'Install' . ucfirst(str_replace('-', '', $code)), 'name' => ucfirst(str_replace('-', '', $code)) . 'Plugin', ]; } file_put_contents($vendorPath . '/composer.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); $installedFile = $this->root . '/vendor/composer/installed.json'; $data = is_file($installedFile) ? json_decode((string)file_get_contents($installedFile), true) : []; $packages = is_array($data['packages'] ?? null) ? $data['packages'] : []; $packages[] = [ 'name' => $name, 'version' => $version, 'type' => 'think-admin-plugin', 'dist' => ['reference' => $reference], 'extra' => $manifest['extra'], 'install-path' => '../' . dirname($name) . '/' . basename($name), ]; file_put_contents($installedFile, json_encode(['packages' => $packages], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); } private function createWorkspacePlugin( string $code, string $name, string $title, ?string $migration = null ): void { $pluginPath = $this->root . '/plugin/' . $code; mkdir($pluginPath . '/src', 0777, true); $manifest = [ 'name' => $name, 'version' => '1.0.0', 'type' => 'think-admin-plugin', 'autoload' => ['psr-4' => ["plugin\\{$code}\\" => 'src']], 'extra' => [ 'think' => ['services' => ["plugin\\{$code}\\Service"]], 'xadmin' => [ 'app' => ['code' => $code, 'name' => $title], ], ], ]; if ($migration !== null) { mkdir($pluginPath . '/stc/database', 0777, true); file_put_contents($pluginPath . '/stc/database/' . $migration, " $migration, 'class' => 'Install' . ucfirst(str_replace('-', '', $code)), 'name' => ucfirst(str_replace('-', '', $code)) . 'Plugin', ]; } file_put_contents($pluginPath . '/composer.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); } private function removeTree(string $path): void { if (!file_exists($path)) { return; } if (is_file($path) || is_link($path)) { @unlink($path); return; } foreach (scandir($path) ?: [] as $item) { if ($item === '.' || $item === '..') { continue; } $this->removeTree($path . '/' . $item); } @rmdir($path); } }