diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b898e30..c42401f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # QuickInstall Changelog +## Version 1.8.0-dev + +- [Feature] Added a new local โ€œDashboard UIโ€ (browser-based) workflow for the QuickInstall CLI. +- [Feature] Added new QI update available notifications in the CLI and Dashboard UI. +- [Feature] Added a new `doctor` command to diagnose host requirements. +- [Change] Improved workspace safety and Windows compatibility. + ## Version 1.7.0 - [Feature] QuickInstall CLI โ€“ A brand new Docker-based command-line-interface tool for creating local phpBB test boards. diff --git a/README.md b/README.md index 51d71489..d1c6fd7c 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,6 @@ QuickInstall is a tool we built to support the community of phpBB extension deve - ๐Ÿ“`sources/` - ๐Ÿ“`settings/` -> If you are upgrading from QuickInstall 1.6.16 (or older) you will need to copy the new ๐Ÿ“`customisations/` folder. - > If you are upgrading from QuickInstall 1.1.8 (or older) you MUST review and re-save your old Profile settings. ## ๐Ÿ’ป Requirements diff --git a/src/QuickInstall/Sandbox/Application.php b/src/QuickInstall/Sandbox/Application.php index c68cbaad..2842442c 100644 --- a/src/QuickInstall/Sandbox/Application.php +++ b/src/QuickInstall/Sandbox/Application.php @@ -14,6 +14,7 @@ use RuntimeException; use Throwable; +/** CLI command dispatcher and presentation layer. */ class Application { private Project $project; @@ -40,6 +41,8 @@ public function run(array $argv): int $operationLock = null; try { + // CLI and Dashboard mutations share one lock so their multi-file work + // cannot interleave. Read-only commands deliberately remain concurrent. if ($this->mutatesWorkspace($command)) { $operationLock = $this->project->lockOperations(); diff --git a/src/QuickInstall/Sandbox/BoardRefreshService.php b/src/QuickInstall/Sandbox/BoardRefreshService.php index 81c79fcc..330264f7 100644 --- a/src/QuickInstall/Sandbox/BoardRefreshService.php +++ b/src/QuickInstall/Sandbox/BoardRefreshService.php @@ -10,6 +10,7 @@ namespace QuickInstall\Sandbox; +/** Regenerates runtime files and recreates a board only when it is running. */ class BoardRefreshService { private Project $project; diff --git a/src/QuickInstall/Sandbox/BoardRunner.php b/src/QuickInstall/Sandbox/BoardRunner.php index ee90c7ce..a7ad77ae 100644 --- a/src/QuickInstall/Sandbox/BoardRunner.php +++ b/src/QuickInstall/Sandbox/BoardRunner.php @@ -13,6 +13,7 @@ use InvalidArgumentException; use RuntimeException; +/** Executes Docker lifecycle, install, debug, and seed operations for a board. */ class BoardRunner { private Project $project; @@ -26,6 +27,7 @@ public function __construct(Project $project, ?Output $output = null, ?ProcessRu $this->processRunner = $processRunner ?: new ProcessRunner($this->output); } + /** Starts containers, completes first install, applies options, and waits for HTTP. */ public function start(string $name): void { $board = $this->project->board($name); @@ -51,6 +53,7 @@ public function stop(string $name): void $this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'stop']); } + /** Removes Docker resources before deleting all persisted board state. */ public function destroy(string $name): void { $this->project->board($name); @@ -83,6 +86,7 @@ private function removeContainers(string $name, bool $removeImage): void } } + /** Returns missing, stopped, partial, running, or error. */ public function status(string $name): string { $compose = $this->project->composePath($name); @@ -159,6 +163,7 @@ protected function seedIfNeeded(string $name, string $preset): void return; } + // The host marker makes automatic population idempotent across restarts. $marker = $this->seedMarker($name, $preset); if (file_exists($marker)) { diff --git a/src/QuickInstall/Sandbox/BoardService.php b/src/QuickInstall/Sandbox/BoardService.php index 608f3b4c..409a40aa 100644 --- a/src/QuickInstall/Sandbox/BoardService.php +++ b/src/QuickInstall/Sandbox/BoardService.php @@ -14,6 +14,7 @@ use RuntimeException; use Throwable; +/** Owns board definitions and coordinates source, runtime, and Docker services. */ class BoardService { private Project $project; @@ -25,6 +26,10 @@ public function __construct(Project $project, ?Output $output = null) $this->output = $output; } + /** + * Creates a board definition and runtime scaffold without starting Docker. + * Existing state is restored if a replacement fails. + */ public function create(string $name, string $version = 'latest', string $db = 'mariadb', int $port = 8080, string $populate = 'none', bool $debug = false, bool $replace = false): array { $this->project->init(); @@ -66,6 +71,8 @@ public function create(string $name, string $version = 'latest', string $db = 'm throw new InvalidArgumentException("Port $port is already in use on this host."); } + // Resolve and fetch first; a failed download must not disturb a board + // that is being replaced. $source = $this->createSourceProvider()->ensure($version); $php = $source['php'] ?? $selection['php'] ?? null; if ($php === null || $php === '') @@ -91,6 +98,7 @@ public function create(string $name, string $version = 'latest', string $db = 'm $backups = []; if ($existingName !== null) { + // Rename existing state aside so any later failure can restore it. $this->createBoardRunner()->prepareReplacement($name); $backups = $this->backupBoardState($name); } diff --git a/src/QuickInstall/Sandbox/BufferedOutput.php b/src/QuickInstall/Sandbox/BufferedOutput.php index c71b4cd3..ee010576 100644 --- a/src/QuickInstall/Sandbox/BufferedOutput.php +++ b/src/QuickInstall/Sandbox/BufferedOutput.php @@ -10,6 +10,7 @@ namespace QuickInstall\Sandbox; +/** Captures subprocess output for Dashboard responses and tests. */ class BufferedOutput implements Output { private string $output = ''; diff --git a/src/QuickInstall/Sandbox/CommandLine.php b/src/QuickInstall/Sandbox/CommandLine.php index 4df9b8eb..8ea2a7d6 100644 --- a/src/QuickInstall/Sandbox/CommandLine.php +++ b/src/QuickInstall/Sandbox/CommandLine.php @@ -10,6 +10,7 @@ namespace QuickInstall\Sandbox; +/** Parses positional arguments, long options, and boolean flags. */ class CommandLine { private array $args = []; diff --git a/src/QuickInstall/Sandbox/CustomisationMountService.php b/src/QuickInstall/Sandbox/CustomisationMountService.php index f757c064..7ad88706 100644 --- a/src/QuickInstall/Sandbox/CustomisationMountService.php +++ b/src/QuickInstall/Sandbox/CustomisationMountService.php @@ -13,6 +13,7 @@ use InvalidArgumentException; use RuntimeException; +/** Coordinates single or recursive extension/style mounts and board refreshes. */ class CustomisationMountService { private Project $project; @@ -24,6 +25,7 @@ public function __construct(Project $project, ?Output $output = null) $this->output = $output; } + /** Returns mounted items and per-item errors; recursive mounts are best effort. */ public function mount(object $manager, string $board, string $source, bool $copy = false, bool $recursive = false, bool $allowExternal = false): array { if ($recursive) diff --git a/src/QuickInstall/Sandbox/DockerComposeWriter.php b/src/QuickInstall/Sandbox/DockerComposeWriter.php index 100c7ab6..52a3f83d 100644 --- a/src/QuickInstall/Sandbox/DockerComposeWriter.php +++ b/src/QuickInstall/Sandbox/DockerComposeWriter.php @@ -12,6 +12,7 @@ use RuntimeException; +/** Generates the complete Docker runtime scaffold for one board. */ class DockerComposeWriter { private Project $project; @@ -21,6 +22,7 @@ public function __construct(Project $project) $this->project = $project; } + /** Writes compose, image, entrypoint, and installer files for one board. */ public function write(string $name, array $config): array { $runtimeDir = $this->project->workspacePath('runtime/' . $name); @@ -53,6 +55,7 @@ public function write(string $name, array $config): array private function writeFile(string $path, string $contents): void { + // Containers execute these files as Linux files even when generated on Windows. $contents = str_replace(["\r\n", "\r"], "\n", $contents); if (file_put_contents($path, $contents, LOCK_EX) !== strlen($contents)) { @@ -129,6 +132,7 @@ private function compose(string $name, array $config): string PHP_VERSION: "{$config['php']}" working_dir: /var/www/html ports: + # Boards are intentionally unavailable to other network devices. - "127.0.0.1:{$config['port']}:80" volumes: {$this->bindVolume($sourcePath, '/opt/phpbb-source', true)}{$this->bindVolume($boardPath, '/var/www/html')}{$extensionVolumes}{$styleVolumes}{$this->bindVolume('./install-config.yml', '/opt/quickinstall/install-config.yml', true)}{$this->bindVolume('./entrypoint.sh', '/opt/quickinstall/entrypoint.sh', true)} diff --git a/src/QuickInstall/Sandbox/DoctorService.php b/src/QuickInstall/Sandbox/DoctorService.php index 38aca709..29d8e4de 100644 --- a/src/QuickInstall/Sandbox/DoctorService.php +++ b/src/QuickInstall/Sandbox/DoctorService.php @@ -10,6 +10,7 @@ namespace QuickInstall\Sandbox; +/** Reports host requirements without changing project or system state. */ class DoctorService { private Project $project; diff --git a/src/QuickInstall/Sandbox/ExtensionManager.php b/src/QuickInstall/Sandbox/ExtensionManager.php index 057d17b8..dbaafce3 100644 --- a/src/QuickInstall/Sandbox/ExtensionManager.php +++ b/src/QuickInstall/Sandbox/ExtensionManager.php @@ -13,6 +13,7 @@ use InvalidArgumentException; use RuntimeException; +/** Discovers, copies, binds, lists, and removes phpBB extensions. */ class ExtensionManager { private Project $project; @@ -22,6 +23,7 @@ public function __construct(Project $project) $this->project = $project; } + /** Mounts one extension; its canonical name comes from composer.json. */ public function mount(string $board, string $source, bool $copy = false, bool $allowExternal = false): array { $boardConfig = $this->project->board($board); @@ -90,6 +92,7 @@ public function discover(string $source, bool $allowExternal = false): array return $found; } + /** Removes copied files or registry state for a bind mount. */ public function unmount(string $board, string $name): string { $boardConfig = $this->project->board($board); diff --git a/src/QuickInstall/Sandbox/Output.php b/src/QuickInstall/Sandbox/Output.php index 330137d2..68975e11 100644 --- a/src/QuickInstall/Sandbox/Output.php +++ b/src/QuickInstall/Sandbox/Output.php @@ -10,6 +10,7 @@ namespace QuickInstall\Sandbox; +/** Minimal output boundary shared by CLI, Dashboard, and subprocess services. */ interface Output { public function write(string $message): void; diff --git a/src/QuickInstall/Sandbox/ProcessRunner.php b/src/QuickInstall/Sandbox/ProcessRunner.php index 82fa4caf..5b922ca9 100644 --- a/src/QuickInstall/Sandbox/ProcessRunner.php +++ b/src/QuickInstall/Sandbox/ProcessRunner.php @@ -12,6 +12,7 @@ use RuntimeException; +/** Runs portable subprocesses with streaming, timeouts, and safe diagnostics. */ class ProcessRunner { private Output $output; @@ -25,6 +26,7 @@ public function __construct(?Output $output = null, ?string $osFamily = null, fl $this->timeoutSeconds = $timeoutSeconds; } + /** Streams a command and throws when it fails or times out. */ public function run(array $command, ?string $cwd = null): void { $displayCommand = array_map([$this, 'redactArgument'], $command); @@ -37,6 +39,7 @@ public function run(array $command, ?string $cwd = null): void } } + /** Returns an exit code and combined output without throwing for command failure. */ public function capture(array $command, ?string $cwd = null): array { return $this->execute($command, false, $cwd); @@ -134,6 +137,8 @@ private function execute(array $command, bool $stream, ?string $cwd): array private function executeWithFiles(array $command, bool $stream, ?string $cwd): array { + // Windows pipe reads can block after a child exits. Files make polling + // and timeout enforcement deterministic for long Composer/Docker jobs. $stdoutPath = tempnam(sys_get_temp_dir(), 'qi-process-out-'); $stderrPath = tempnam(sys_get_temp_dir(), 'qi-process-err-'); if ($stdoutPath === false || $stderrPath === false) diff --git a/src/QuickInstall/Sandbox/Project.php b/src/QuickInstall/Sandbox/Project.php index 99acc985..752a13f0 100644 --- a/src/QuickInstall/Sandbox/Project.php +++ b/src/QuickInstall/Sandbox/Project.php @@ -14,6 +14,7 @@ use JsonException; use RuntimeException; +/** Guards all project paths, workspace state, locks, and filesystem operations. */ class Project { private string $root; @@ -83,6 +84,7 @@ public function customisationsPath(): string return $this->rootPath('customisations'); } + /** Acquires the process-wide workspace mutation lock. */ public function lockOperations() { if (!is_dir($this->workspace) && !mkdir($this->workspace, 0775, true) && !is_dir($this->workspace)) @@ -90,6 +92,8 @@ public function lockOperations() throw new RuntimeException("Unable to create QuickInstall workspace: {$this->workspace}"); } + // Keep the file after unlocking. Removing it can let contenders lock + // different filesystem entries and both enter the critical section. $path = $this->workspacePath('operation.lock'); $lock = fopen($path, 'c+b'); if (!is_resource($lock) || !flock($lock, LOCK_EX)) @@ -125,6 +129,7 @@ public function sourcePath(string $version): string return $this->workspacePath('sources/phpbb-' . $version); } + /** Reads validated JSON while holding the registry's shared lock. */ public function readJson(string $file, array $default): array { $path = $this->workspacePath($file); @@ -158,6 +163,7 @@ public function readJson(string $file, array $default): array }); } + /** Atomically replaces JSON while holding the registry's exclusive lock. */ public function writeJson(string $file, array $data): void { $path = $this->workspacePath($file); @@ -177,6 +183,7 @@ public function writeJson(string $file, array $data): void throw new RuntimeException("Unable to create JSON state directory: $directory"); } + // Publish a complete temporary file so readers never see partial JSON. $temp = tempnam($directory, '.' . basename($path) . '.tmp-'); if ($temp === false) { @@ -244,6 +251,8 @@ private function replaceFile(string $temp, string $path): void throw new RuntimeException("Unable to replace JSON state file: $path"); } + // Windows cannot rename over an existing destination. Preserve the old + // state until the replacement has been installed successfully. $backup = $path . '.replace-backup'; @unlink($backup); if (!@rename($path, $backup)) @@ -317,12 +326,14 @@ public function dbPath(string $name): string return $this->workspacePath('db/' . $name); } + /** Recursively deletes only paths contained by the `.qi` workspace. */ public function deleteTree(string $path): void { if (!file_exists($path) && !is_link($path)) { return; } + // Every recursive delete passes through the workspace containment guard. $this->assertWorkspacePath($path); if (is_file($path) || is_link($path)) @@ -397,6 +408,7 @@ public function removeEmptyParents(string $path, string $stop): void } } + /** Resolves user paths and rejects escapes from the trusted drop zone by default. */ public function resolveDropZonePath(string $path, string $basePath, bool $allowExternal, string $error): string { $candidates = [ @@ -405,6 +417,7 @@ public function resolveDropZonePath(string $path, string $basePath, bool $allowE $basePath . '/' . ltrim($path, '/'), ]; + // realpath resolves traversal and symlinks before the containment check. foreach ($candidates as $candidate) { $real = realpath($candidate); diff --git a/src/QuickInstall/Sandbox/SeederWriter.php b/src/QuickInstall/Sandbox/SeederWriter.php index 19873aac..048fe6d9 100644 --- a/src/QuickInstall/Sandbox/SeederWriter.php +++ b/src/QuickInstall/Sandbox/SeederWriter.php @@ -12,6 +12,7 @@ use RuntimeException; +/** Writes the phpBB-aware fixture script executed inside board containers. */ class SeederWriter { private Project $project; diff --git a/src/QuickInstall/Sandbox/SourceProvider.php b/src/QuickInstall/Sandbox/SourceProvider.php index e2fdc097..6b508a07 100644 --- a/src/QuickInstall/Sandbox/SourceProvider.php +++ b/src/QuickInstall/Sandbox/SourceProvider.php @@ -13,6 +13,7 @@ use InvalidArgumentException; use RuntimeException; +/** Resolves, downloads, validates, and registers Composer or Git phpBB sources. */ class SourceProvider { private Project $project; @@ -26,6 +27,7 @@ public function __construct(Project $project, ?Output $output = null, ?ProcessRu $this->processRunner = $processRunner ?: new ProcessRunner($this->output); } + /** Registers source metadata; downloading is deferred unless resolution is floating. */ public function add(string $version, string $type, ?string $url, bool $allowExternal = false): array { if (!in_array($type, ['composer', 'git'], true)) @@ -89,6 +91,7 @@ private function validateGitUrl(string $url): void } } + /** Returns a downloaded exact source, fetching and updating metadata as needed. */ public function ensure(string $version): array { $sources = $this->project->readJson('sources.json', []); @@ -152,6 +155,8 @@ protected function ensureRegisteredSource(array $source): array protected function ensureFloating(string $version, array $selection): array { + // Friendly selectors such as latest and 3.3.* resolve to an immutable + // version record so future resolution cannot silently alter a board. $sources = $this->project->readJson('sources.json', []); $source = $this->withSelectionDefaults($sources[$selection['source_key']] ?? [], $selection); $source['type'] = $source['type'] ?? 'composer'; @@ -297,6 +302,7 @@ protected function withSelectionDefaults(array $source, array $selection): array ]; } + /** Downloads into a temporary sibling and publishes only a complete source tree. */ public function fetch(array $source): void { $path = $source['path']; diff --git a/src/QuickInstall/Sandbox/SourceService.php b/src/QuickInstall/Sandbox/SourceService.php index f68ca255..8cfff479 100644 --- a/src/QuickInstall/Sandbox/SourceService.php +++ b/src/QuickInstall/Sandbox/SourceService.php @@ -13,6 +13,7 @@ use InvalidArgumentException; use RuntimeException; +/** Provides source registry operations and protects sources used by boards. */ class SourceService { private Project $project; diff --git a/src/QuickInstall/Sandbox/StreamOutput.php b/src/QuickInstall/Sandbox/StreamOutput.php index b231ec89..48136286 100644 --- a/src/QuickInstall/Sandbox/StreamOutput.php +++ b/src/QuickInstall/Sandbox/StreamOutput.php @@ -10,6 +10,7 @@ namespace QuickInstall\Sandbox; +/** Writes service and subprocess messages to separate output streams. */ class StreamOutput implements Output { private $stdout; diff --git a/src/QuickInstall/Sandbox/StyleManager.php b/src/QuickInstall/Sandbox/StyleManager.php index 41e7727e..e1409bd1 100644 --- a/src/QuickInstall/Sandbox/StyleManager.php +++ b/src/QuickInstall/Sandbox/StyleManager.php @@ -13,6 +13,7 @@ use InvalidArgumentException; use RuntimeException; +/** Discovers, copies, binds, lists, and removes phpBB styles. */ class StyleManager { private Project $project; @@ -22,6 +23,7 @@ public function __construct(Project $project) $this->project = $project; } + /** Mounts one style using its validated source directory name. */ public function mount(string $board, string $source, bool $copy = false, bool $allowExternal = false): array { $boardConfig = $this->project->board($board); @@ -96,6 +98,7 @@ public function discover(string $source, bool $allowExternal = false): array return $found; } + /** Removes copied files or registry state for a bind mount. */ public function unmount(string $board, string $name): string { $boardConfig = $this->project->board($board); diff --git a/src/QuickInstall/Sandbox/UiServerService.php b/src/QuickInstall/Sandbox/UiServerService.php index 08e52838..93a03850 100644 --- a/src/QuickInstall/Sandbox/UiServerService.php +++ b/src/QuickInstall/Sandbox/UiServerService.php @@ -12,6 +12,7 @@ use RuntimeException; +/** Starts, verifies, tracks, and stops the loopback Dashboard PHP server. */ class UiServerService { private Project $project; @@ -23,6 +24,7 @@ public function __construct(Project $project, ?ProcessRunner $processRunner = nu $this->processRunner = $processRunner ?: new ProcessRunner(new BufferedOutput()); } + /** Starts a detached loopback server and records state only after it responds. */ public function start(string $host, int $port): array { $state = $this->readState(); @@ -66,6 +68,7 @@ public function start(string $host, int $port): array return ['status' => 'started', 'state' => $state]; } + /** Stops only the process whose command line matches the recorded UI server. */ public function stop(): array { $state = $this->readState(); @@ -94,6 +97,7 @@ public function restart(string $host, int $port): array return ['stop' => $stopped, 'start' => $started]; } + /** Distinguishes a live server, stale state, and no tracked server. */ public function status(): array { $state = $this->readState(); @@ -326,6 +330,8 @@ private function processMatchesState(int $pid, array $state): bool return false; } + // A PID may be reused after an unclean exit. Match the router and -S flag + // before status or stop treats that process as ours. $router = (string) ($state['router'] ?? dirname(__DIR__, 3) . '/public/sandbox-ui.php'); if (PHP_OS_FAMILY === 'Windows') { diff --git a/src/QuickInstall/Sandbox/UpdateService.php b/src/QuickInstall/Sandbox/UpdateService.php index 5ba18b9b..d146358f 100644 --- a/src/QuickInstall/Sandbox/UpdateService.php +++ b/src/QuickInstall/Sandbox/UpdateService.php @@ -13,6 +13,7 @@ use RuntimeException; use Throwable; +/** Performs a cached, best-effort check for newer QuickInstall releases. */ class UpdateService { private const ENDPOINT = 'https://www.phpbb.com/customise/db/official_tool/phpbb3_quickinstall/version_check'; diff --git a/src/QuickInstall/Sandbox/VersionMatrix.php b/src/QuickInstall/Sandbox/VersionMatrix.php index db8260d5..e5536106 100644 --- a/src/QuickInstall/Sandbox/VersionMatrix.php +++ b/src/QuickInstall/Sandbox/VersionMatrix.php @@ -12,6 +12,7 @@ use InvalidArgumentException; +/** Maps friendly phpBB selectors to source constraints and PHP runtimes. */ class VersionMatrix { public function list(): array diff --git a/src/QuickInstall/Sandbox/Web/Application.php b/src/QuickInstall/Sandbox/Web/Application.php index 29f08a58..ad7d5a73 100644 --- a/src/QuickInstall/Sandbox/Web/Application.php +++ b/src/QuickInstall/Sandbox/Web/Application.php @@ -24,6 +24,7 @@ use RuntimeException; use Throwable; +/** Loopback-only Dashboard controller and AJAX response boundary. */ class Application { private Project $project; @@ -38,6 +39,7 @@ public function __construct(string $root) $this->output = new BufferedOutput(); } + /** Validates the request, dispatches an action, and renders HTML or JSON. */ public function run(): void { $this->assertLocalRequest(); @@ -62,6 +64,8 @@ public function run(): void private function handleAjaxPost(): void { + // Warnings and accidental output would corrupt JSON. Convert warnings to + // exceptions and capture all output before building the response envelope. $previousDisplayErrors = ini_set('display_errors', '0'); ob_start(); set_error_handler(static function (int $severity, string $message, string $file, int $line): bool { @@ -170,6 +174,7 @@ private function handlePost(): void $operationLock = null; try { + // Uses the same lock as mutating CLI commands. $operationLock = $this->project->lockOperations(); $this->disableExecutionTimeLimit(); $action = (string) ($_POST['action'] ?? ''); diff --git a/src/QuickInstall/Sandbox/bootstrap.php b/src/QuickInstall/Sandbox/bootstrap.php index 08378ff5..70de116c 100644 --- a/src/QuickInstall/Sandbox/bootstrap.php +++ b/src/QuickInstall/Sandbox/bootstrap.php @@ -8,6 +8,7 @@ * */ +// Keep the CLI self-contained before Composer dependencies are available. require_once __DIR__ . '/Output.php'; require_once __DIR__ . '/StreamOutput.php'; require_once __DIR__ . '/BufferedOutput.php'; diff --git a/tests/Integration/ApplicationTest.php b/tests/Integration/ApplicationTest.php index 36adc794..6b2bc97a 100644 --- a/tests/Integration/ApplicationTest.php +++ b/tests/Integration/ApplicationTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use QuickInstall\Sandbox\Application; use QuickInstall\Sandbox\Project; +use QuickInstall\Sandbox\UpdateService; use QuickInstall\Tests\Support\ApplicationRunnerTrait; use QuickInstall\Tests\Support\TempProjectTrait; @@ -60,17 +61,19 @@ public function testCachedUpdatePrintsPassiveCliNotice(): void $root = $this->createTempProjectRoot(); $project = new Project($root); $project->init(); + $currentVersion = (new UpdateService($project))->currentVersion(); + $availableVersion = $this->newerVersionThan($currentVersion); $project->writeJson('cache/update-check.json', [ 'checked_at' => time(), - 'current_version' => '1.7.0', - 'update' => ['current' => '1.8.0', 'download' => 'https://example.com/download'], + 'current_version' => $currentVersion, + 'update' => ['current' => $availableVersion, 'download' => 'https://example.com/download'], 'error' => null, ]); $result = $this->runApplication($root, ['qi', 'source:list']); self::assertSame(0, $result['exit_code']); - self::assertStringContainsString('QuickInstall 1.8.0 available: https://example.com/download', $result['output']); + self::assertStringContainsString("QuickInstall $availableVersion available: https://example.com/download", $result['output']); } public function testPhpbbListPrintsSupportedSelectors(): void diff --git a/tests/Integration/WebApplicationTest.php b/tests/Integration/WebApplicationTest.php index 9f9abd2d..1b3d5726 100644 --- a/tests/Integration/WebApplicationTest.php +++ b/tests/Integration/WebApplicationTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use QuickInstall\Sandbox\Project; +use QuickInstall\Sandbox\UpdateService; use QuickInstall\Sandbox\Web\Application; use QuickInstall\Tests\Support\TempProjectTrait; @@ -237,17 +238,19 @@ public function testRenderShowsCachedUpdateBanner(): void $root = $this->createTempProjectRoot(); $project = new Project($root); $project->init(); + $currentVersion = (new UpdateService($project))->currentVersion(); + $availableVersion = $this->newerVersionThan($currentVersion); $project->writeJson('cache/update-check.json', [ 'checked_at' => time(), - 'current_version' => '1.7.0', - 'update' => ['current' => '1.8.0', 'download' => 'https://example.com/download'], + 'current_version' => $currentVersion, + 'update' => ['current' => $availableVersion, 'download' => 'https://example.com/download'], 'error' => null, ]); $html = $this->runWebApplication($root); self::assertStringContainsString('class="update-banner"', $html); - self::assertStringContainsString('QuickInstall 1.8.0 available', $html); + self::assertStringContainsString("QuickInstall $availableVersion available", $html); self::assertStringContainsString('href="https://example.com/download"', $html); self::assertStringContainsString('data-dismiss-update', $html); } diff --git a/tests/Support/TempProjectTrait.php b/tests/Support/TempProjectTrait.php index f7e6cdd4..86f38b67 100644 --- a/tests/Support/TempProjectTrait.php +++ b/tests/Support/TempProjectTrait.php @@ -18,6 +18,16 @@ protected function createTempProjectRoot(): string return $root; } + protected function newerVersionThan(string $version): string + { + if (!preg_match('/^(\d+)/', $version, $matches)) + { + throw new \RuntimeException("Unable to derive a future version from: $version"); + } + + return ((int) $matches[1] + 1) . '.0.0'; + } + protected function tearDown(): void { foreach (array_reverse($this->temporaryPaths) as $path)