*/ private array $pipes = []; private string $logPath; private string $repoRoot; public static function sqliteAvailable(): bool { return class_exists('PDO') && in_array('sqlite', PDO::getAvailableDrivers(), true); } // No constructor promotion: the harness stays PHP 7.4-runnable so the plugin // unit tests can be executed on the oldest version the plugin supports. public function __construct(string $repoRoot) { $this->repoRoot = $repoRoot; $this->tmpDir = sys_get_temp_dir() . '/att-hc-tests-' . getmypid() . '-' . bin2hex(random_bytes(4)); $this->dbPath = $this->tmpDir . '/data/test.sqlite'; $this->logPath = $this->tmpDir . '/php-server.log'; } public function baseUrl(): string { return $this->baseUrl; } public function dbPath(): string { return $this->dbPath; } public function start(): void { $this->copyTree($this->repoRoot . '/server', $this->tmpDir, ['data', 'config.php']); @mkdir($this->tmpDir . '/data', 0777, true); file_put_contents($this->tmpDir . '/config.php', " self::API_KEY, 'db_dsn' => 'sqlite:' . $this->dbPath, 'db_user' => null, 'db_pass' => null, 'version' => 'test', ], true) . ";\n"); $port = $this->freePort(); $this->baseUrl = 'http://127.0.0.1:' . $port; $cmd = sprintf( '%s -S 127.0.0.1:%d -t %s %s', escapeshellarg(PHP_BINARY), $port, escapeshellarg($this->tmpDir . '/public'), escapeshellarg($this->tmpDir . '/dev-router.php') ); $this->process = proc_open( $cmd, [0 => ['file', '/dev/null', 'r'], 1 => ['file', $this->logPath, 'a'], 2 => ['file', $this->logPath, 'a']], $this->pipes ); if (!is_resource($this->process)) { throw new RuntimeException('could not start php -S'); } $this->waitUntilUp(); } public function stop(): void { if (is_resource($this->process)) { proc_terminate($this->process); proc_close($this->process); $this->process = null; } $this->deleteTree($this->tmpDir); } /** Server stdout/stderr — useful when a test fails for a non-obvious reason. */ public function log(): string { return is_file($this->logPath) ? (string) file_get_contents($this->logPath) : ''; } private function waitUntilUp(): void { $deadline = microtime(true) + 15.0; while (microtime(true) < $deadline) { $ctx = stream_context_create(['http' => ['timeout' => 1, 'ignore_errors' => true]]); $body = @file_get_contents($this->baseUrl . '/', false, $ctx); if ($body !== false && strpos($body, '"ok"') !== false) { return; } usleep(100_000); } throw new RuntimeException("test server did not come up at {$this->baseUrl}\n" . $this->log()); } private function freePort(): int { $sock = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); if ($sock === false) { throw new RuntimeException("could not find a free port: {$errstr}"); } $name = (string) stream_socket_get_name($sock, false); fclose($sock); return (int) substr($name, strrpos($name, ':') + 1); } /** @param array $skip basenames to skip at the top level */ private function copyTree(string $src, string $dst, array $skip = []): void { @mkdir($dst, 0777, true); foreach (scandir($src) ?: [] as $entry) { if ($entry === '.' || $entry === '..' || in_array($entry, $skip, true)) continue; $from = $src . '/' . $entry; $to = $dst . '/' . $entry; if (is_dir($from)) { $this->copyTree($from, $to); } else { copy($from, $to); } } } private function deleteTree(string $dir): void { if (!is_dir($dir)) return; foreach (scandir($dir) ?: [] as $entry) { if ($entry === '.' || $entry === '..') continue; $path = $dir . '/' . $entry; is_dir($path) ? $this->deleteTree($path) : @unlink($path); } @rmdir($dir); } } /** * Tiny HTTP client. Returns ['status'=>int, 'body'=>string, 'json'=>array|null, * 'headers'=>array]. * * Streams rather than curl so the suite has no extension requirements beyond * what the server itself needs. follow_location is off so redirects (the * dashboard's key → cookie handoff) are observable. */ function http_call( string $baseUrl, string $method, string $path, ?array $body = null, ?string $apiKey = TestServer::API_KEY, array $extraHeaders = [] ): array { $headers = ['Accept: application/json']; if ($apiKey !== null) $headers[] = 'Authorization: Bearer ' . $apiKey; foreach ($extraHeaders as $h) $headers[] = $h; $http = [ 'method' => $method, 'ignore_errors' => true, 'follow_location' => 0, 'max_redirects' => 1, 'timeout' => 10, ]; if ($body !== null) { $headers[] = 'Content-Type: application/json'; $http['content'] = json_encode($body, JSON_UNESCAPED_SLASHES); } $http['header'] = implode("\r\n", $headers); $raw = @file_get_contents($baseUrl . $path, false, stream_context_create(['http' => $http])); $responseHeaders = $http_response_header ?? []; $status = 0; foreach ($responseHeaders as $h) { if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) $status = (int) $m[1]; } $raw = $raw === false ? '' : $raw; $json = json_decode($raw, true); return [ 'status' => $status, 'body' => $raw, 'json' => is_array($json) ? $json : null, 'headers' => $responseHeaders, ]; } /** http_call() against the running test server (set up by run.php). */ function api( string $method, string $path, ?array $body = null, ?string $apiKey = TestServer::API_KEY, array $extraHeaders = [] ): array { return http_call($GLOBALS['att_hc_base_url'], $method, $path, $body, $apiKey, $extraHeaders); } /** Pull the PHP session cookie out of a response's Set-Cookie headers. */ function session_cookie(array $response): ?string { foreach ($response['headers'] as $h) { if (stripos($h, 'Set-Cookie:') === 0 && preg_match('/(PHPSESSID=[^;]+)/i', $h, $m)) { return $m[1]; } } return null; }