'runtime-publish/demo.txt', ]); mkdir($root . '/plugin/publish-demo/stc/runtime', 0777, true); file_put_contents($root . '/plugin/publish-demo/stc/runtime/demo.txt', "publish\n"); createPluginPackage( $root, 'system', 'vendor/system-plugin', 'plugin\system\Service', [ '20241010000001_install_system20241010.php', '20241010000002_install_storage20241010.php', ] ); createPluginPackage( $root, 'worker', 'vendor/worker-plugin', 'plugin\worker\Service', '20241010000008_install_worker20241010.php' ); $app = new App($root); RuntimeService::init($app); $app->config->set([ 'default' => 'file', 'stores' => [ 'file' => ['type' => 'File', 'path' => $root . '/runtime/cache'], ], ], 'cache'); $command = new PublishCommand(); $command->setApp($app); $code = $command->run(new Input([]), new Output('buffer')); assertSameValue(0, $code, 'publish command should exit with code 0'); $services = require $root . '/vendor/services.php'; $versions = require $root . '/vendor/versions.php'; $manifest = json_decode((string)file_get_contents($root . '/database/migrations/.published.json'), true, 512, JSON_THROW_ON_ERROR); $resourceManifest = json_decode((string)file_get_contents($root . '/vendor/.published.json'), true, 512, JSON_THROW_ON_ERROR); foreach (['plugin\demo\Service', 'plugin\system\Service', 'plugin\worker\Service'] as $service) { assertTrue(in_array($service, $services, true), "missing published service {$service}"); } assertSameValue('System', $versions['vendor/system-plugin']['name'] ?? null, 'system plugin name should be published'); assertSameValue('plugin', $versions['vendor/system-plugin']['type'] ?? null, 'system plugin type should be published'); assertTrue(!isset($versions['vendor/storage-plugin']), 'storage plugin should no longer be published separately'); foreach ([ '20241010000001_install_system20241010.php', '20241010000002_install_storage20241010.php', '20241010000008_install_worker20241010.php', ] as $migration) { assertTrue(is_file($root . '/database/migrations/' . $migration), "missing migration {$migration}"); } assertTrue( is_file($root . '/database/migrations/20241011000001_install_wechat20241011.php'), 'unmanaged unique migration should be preserved' ); assertSameValue( 'plugin/system/stc/database/20241010000001_install_system20241010.php', $manifest['20241010000001_install_system20241010.php']['source'] ?? null, 'system migration manifest should point to plugin source' ); assertTrue(is_file($root . '/runtime-publish/demo.txt'), 'missing published runtime resource'); assertSameValue('vendor/publish-demo', $resourceManifest['runtime-publish/demo.txt']['package'] ?? null, 'runtime resource manifest package mismatch'); removeTree($root . '/plugin/publish-demo'); $command = new PublishCommand(); $command->setApp($app); $code = $command->run(new Input([]), new Output('buffer')); assertSameValue(0, $code, 'second publish command should exit with code 0'); $resourceManifest = json_decode((string)file_get_contents($root . '/vendor/.published.json'), true, 512, JSON_THROW_ON_ERROR); assertTrue(!is_file($root . '/runtime-publish/demo.txt'), 'stale published runtime resource should be removed'); assertTrue(!isset($resourceManifest['runtime-publish/demo.txt']), 'stale runtime resource manifest should be removed'); } finally { removeTree($root); } } function runThinkListSmoke(string $projectRoot): void { $command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($projectRoot . '/think') . ' list'; exec($command . ' 2>&1', $output, $status); assertSameValue(0, $status, "think list failed:\n" . implode("\n", $output)); } function runInstallSmoke(string $projectRoot): void { $root = sys_get_temp_dir() . '/thinkadmin-install-' . bin2hex(random_bytes(6)); try { mkdir($root, 0777, true); copyTree($projectRoot . '/app', $root . '/app'); copyTree($projectRoot . '/config', $root . '/config'); copyTree($projectRoot . '/plugin', $root . '/plugin'); copyTree($projectRoot . '/vendor', $root . '/vendor'); copyFile($projectRoot . '/think', $root . '/think'); copyFile($projectRoot . '/composer.json', $root . '/composer.json'); foreach (['database', 'public', 'runtime'] as $path) { mkdir($root . '/' . $path, 0777, true); } $command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($root . '/think') . ' xadmin:publish --migrate'; exec($command . ' 2>&1', $output, $status); assertSameValue(0, $status, "install smoke failed:\n" . implode("\n", $output)); foreach ([ 'database/migrations/20241010000001_install_system20241010.php', 'database/migrations/20241010000008_install_worker20241010.php', 'public/static/system.js', 'config/database.php', ] as $path) { assertTrue(is_file($root . '/' . $path), "missing installed artifact {$path}"); } $db = new PDO('sqlite:' . $root . '/database/sqlite.db'); foreach ([ 'system_auth', 'system_auth_node', 'system_menu', 'system_user', 'system_data', 'system_base', 'system_oplog', 'system_file', 'system_queue', ] as $table) { $count = $db->query("select count(*) from sqlite_master where type='table' and name='{$table}'")->fetchColumn(); assertTrue(!empty($count), "missing installed table {$table}"); } $dataRows = intval($db->query('select count(*) from system_data')->fetchColumn()); assertTrue($dataRows >= 4, 'system_data seed rows should be initialized'); $db = null; } finally { removeTree($root); } } function assertHelperOwner(string $name, string $expectedFile): void { assertTrue(function_exists($name), "{$name} should be defined"); $reflection = new ReflectionFunction($name); assertSameValue( realpath($expectedFile), realpath((string)$reflection->getFileName()), "{$name} should be loaded from {$expectedFile}" ); } /** * @param null|string|list $migration */ function createPluginPackage(string $root, string $code, string $name, string $service, null|string|array $migration = null, array $publish = []): void { $path = "{$root}/plugin/{$code}"; mkdir($path, 0777, true); $extra = [ 'think' => ['services' => [$service]], 'xadmin' => ['app' => ['code' => $code, 'name' => ucfirst($code)]], ]; if ($publish !== []) { $extra['xadmin']['publish'] = ['copy' => $publish]; } file_put_contents($path . '/composer.json', json_encode([ 'name' => $name, 'type' => 'think-admin-plugin', 'version' => '1.0.0', 'description' => ucfirst($code), 'extra' => $extra, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); if ($migration) { mkdir($path . '/stc/database', 0777, true); foreach ((array)$migration as $filename) { file_put_contents($path . '/stc/database/' . $filename, "getPathname(), 0777); if ($item->isDir()) { @rmdir($item->getPathname()); } else { @unlink($item->getPathname()); } } @rmdir($path); } function copyTree(string $source, string $target): void { if (!is_dir($source)) { return; } mkdir($target, 0777, true); $items = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($items as $item) { $relative = substr($item->getPathname(), strlen($source) + 1); $pathname = $target . '/' . $relative; if (is_link($item->getPathname())) { $real = realpath($item->getPathname()); if ($real === false) { continue; } if (is_dir($real)) { copyTree($real, $pathname); } else { copyFile($real, $pathname); } continue; } if ($item->isDir()) { is_dir($pathname) || mkdir($pathname, 0777, true); } else { copyFile($item->getPathname(), $pathname); } } } function copyFile(string $source, string $target): void { $real = realpath($source); if ($real !== false && is_dir($real)) { copyTree($real, $target); return; } if ($real !== false && is_file($real)) { $source = $real; } if (is_dir($source)) { copyTree($source, $target); return; } is_dir(dirname($target)) || mkdir(dirname($target), 0777, true); copy($source, $target); } function assertTrue(bool $condition, string $message): void { if ($condition) { return; } throw new RuntimeException($message); } function assertSameValue($expected, $actual, string $message): void { if ($expected === $actual) { return; } throw new RuntimeException( $message . ' | expected: ' . var_export($expected, true) . ' actual: ' . var_export($actual, true) ); } function writeLine(string $message): void { fwrite(STDOUT, $message . PHP_EOL); }