stateDir = sys_get_temp_dir() . '/cepis-test-' . bin2hex(random_bytes(8)); if (!mkdir($this->stateDir, 0o700, true) && !is_dir($this->stateDir)) { throw new \RuntimeException('could not create ' . $this->stateDir); } file_put_contents($this->stateDir . '/routes.json', '{}'); file_put_contents($this->stateDir . '/calls.log', ''); $port = self::freePort(); $this->url = 'http://127.0.0.1:' . $port; $process = proc_open( [PHP_BINARY, '-S', '127.0.0.1:' . $port, __DIR__ . '/server/router.php'], [ 0 => ['pipe', 'r'], 1 => ['file', $this->stateDir . '/server.log', 'a'], 2 => ['file', $this->stateDir . '/server.log', 'a'], ], $pipes, __DIR__ . '/server', ['CEPIS_TEST_STATE' => $this->stateDir, 'PATH' => getenv('PATH') ?: '/usr/bin'], ); if (!is_resource($process)) { throw new \RuntimeException('could not start the built-in server'); } $this->process = $process; foreach ($pipes as $pipe) { if (is_resource($pipe)) { fclose($pipe); } } $this->waitUntilListening($port); } /** * Register the responses for a request URI. * * Each request consumes the next entry, which is how retrying is tested; * the last entry repeats once the list runs out. A route registered under * `"*"` answers any URI that has none of its own. * * @param list> $responses */ public function respond(string $uri, array $responses): void { $routes = json_decode((string) file_get_contents($this->stateDir . '/routes.json'), true); $routes = is_array($routes) ? $routes : []; $routes[$uri] = $responses; file_put_contents( $this->stateDir . '/routes.json', json_encode($routes, JSON_THROW_ON_ERROR), ); } /** * Every request URI the server has seen, in order. * * @return list */ public function calls(): array { $raw = (string) file_get_contents($this->stateDir . '/calls.log'); return array_values(array_filter(explode("\n", $raw), 'strlen')); } /** * How many requests so far began with `$prefix`. */ public function countCalls(string $prefix): int { return count(array_filter( $this->calls(), static fn (string $uri): bool => str_starts_with($uri, $prefix), )); } public function stop(): void { if (is_resource($this->process)) { proc_terminate($this->process); proc_close($this->process); } foreach (['routes.json', 'calls.log', 'server.log'] as $name) { @unlink($this->stateDir . '/' . $name); } @rmdir($this->stateDir); } private function waitUntilListening(int $port): void { $deadline = microtime(true) + 15.0; while (microtime(true) < $deadline) { $socket = @fsockopen('127.0.0.1', $port, $code, $message, 0.2); if (is_resource($socket)) { fclose($socket); return; } usleep(20_000); } throw new \RuntimeException('the built-in server never started listening on ' . $port); } private static function freePort(): int { $socket = stream_socket_server('tcp://127.0.0.1:0', $code, $message); if ($socket === false) { throw new \RuntimeException('could not bind a loopback socket: ' . $message); } $name = (string) stream_socket_get_name($socket, false); fclose($socket); return (int) substr($name, (int) strrpos($name, ':') + 1); } }