diff --git a/bin/cgi-aot.php b/bin/cgi-aot.php new file mode 100755 index 00000000..a920b627 --- /dev/null +++ b/bin/cgi-aot.php @@ -0,0 +1,39 @@ +#!/usr/bin/env php +" >&2 + echo " or: PHPC_DEPLOY_ROOT=/path/to/dist $(basename "$0")" >&2 + exit 1 +} + +resolve_binary() { + if [[ $# -ge 1 && -n "${1:-}" ]]; then + if [[ ! -f "$1" ]]; then + echo "cgi-aot: binary not found: $1" >&2 + exit 1 + fi + printf '%s\n' "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" + return 0 + fi + + local root="${PHPC_DEPLOY_ROOT:-}" + if [[ -z "$root" ]]; then + usage + fi + root="$(cd "$root" && pwd)" + + if [[ -f "${root}/bin/app" ]]; then + printf '%s\n' "${root}/bin/app" + return 0 + fi + + echo "cgi-aot: no bin/app under PHPC_DEPLOY_ROOT=${root}" >&2 + exit 1 +} + +ingest_stdin_body() { + local len="${CONTENT_LENGTH:-}" + if [[ -z "$len" || "$len" == "0" ]]; then + return 0 + fi + if ! [[ "$len" =~ ^[0-9]+$ ]]; then + echo "cgi-aot: invalid CONTENT_LENGTH" >&2 + exit 1 + fi + if [[ "$len" -gt "$MAX_BODY" ]]; then + echo "cgi-aot: CONTENT_LENGTH exceeds limit" >&2 + exit 1 + fi + + BODY_FILE="$(mktemp)" + export REQUEST_BODY_FILE="$BODY_FILE" + dd if=/dev/stdin of="$BODY_FILE" bs=1 count="$len" status=none 2>/dev/null || { + echo "cgi-aot: could not read request body" >&2 + exit 1 + } + if [[ "${REQUEST_METHOD:-}" != "POST" ]]; then + export REQUEST_METHOD=POST + fi +} + +BINARY="$(resolve_binary "${1:-}")" +if [[ ! -x "$BINARY" ]]; then + chmod +x "$BINARY" 2>/dev/null || true +fi +if [[ ! -x "$BINARY" ]]; then + echo "cgi-aot: binary is not executable: $BINARY" >&2 + exit 1 +fi + +if [[ -z "${PHPC_DEPLOY_ROOT:-}" ]]; then + bin_dir="$(dirname "$BINARY")" + if [[ "$(basename "$bin_dir")" == "bin" ]]; then + export PHPC_DEPLOY_ROOT="$(cd "$bin_dir/.." && pwd)" + fi +fi + +ingest_stdin_body + +OUTFILE="$(mktemp)" +cleanup() { + if [[ -n "${OUTFILE}" && -f "${OUTFILE}" ]]; then + rm -f "${OUTFILE}" + fi + if [[ -n "${BODY_FILE}" && -f "${BODY_FILE}" ]]; then + rm -f "${BODY_FILE}" + fi +} +trap cleanup EXIT + +"$BINARY" >"$OUTFILE" 2>/dev/null || true +if [[ ! -s "$OUTFILE" ]]; then + echo "cgi-aot: binary produced no output" >&2 + exit 1 +fi + +if grep -qi '^Status:' "$OUTFILE"; then + cat "$OUTFILE" + exit 0 +fi + +printf 'Status: 200 OK\r\n' +# AOT binaries emit a single CRLF between headers and body; CGI expects CRLF CRLF. +raw="$(<"$OUTFILE")" +printf '%s' "${raw/$'\r\n<'/$'\r\n\r\n<'}" +exit 0 diff --git a/bin/phpc.php b/bin/phpc.php index 97986df6..4ab999f1 100755 --- a/bin/phpc.php +++ b/bin/phpc.php @@ -13,6 +13,7 @@ * phpc build [-o outfile] entry.php * phpc build --project [dir] [--dry-run] AOT compile from phpc.json entry/binary * phpc deploy [dir] -o [--from-build] Bundle binary, public/, assets/, phpc.json + * phpc cgi [binary] CGI wrapper for AOT binary (issue #665) * phpc lint [-r 'code'] [--json] entry.php * phpc lint --project [--json] * phpc lint --all [--json] @@ -44,6 +45,8 @@ --verbose On failure, keep full LLVM stderr (default adds #568 trailer) phpc deploy [dir] -o Package AOT binary + manifest trees into dist/ --from-build Require existing binary (skip phpc build --project) + phpc cgi [binary] Run AOT binary under CGI env (stdin → REQUEST_BODY) + PHPC_DEPLOY_ROOT= Resolve bin/app from deploy bundle when binary omitted phpc lint [-r 'code'] [--json] Report unsupported syntax (line-accurate) phpc lint --project [--json] Entry + literal include/require chain phpc lint --all [--json] All .php under a tree (aggregated) @@ -90,6 +93,17 @@ require $repoRoot.'/vendor/autoload.php'; exit(deployFromProject($repoRoot, phpCommand(), $args)); + case 'cgi': + if (!is_file($repoRoot.'/vendor/autoload.php')) { + fwrite(STDERR, "phpc cgi: run composer install first\n"); + exit(1); + } + $cgiArgs = []; + while ([] !== $args) { + $cgiArgs[] = array_shift($args); + } + exit(runProcess(array_merge($php, array_merge([$repoRoot.'/bin/cgi-aot.php'], $cgiArgs)), $repoRoot)); + case 'build': if ([] !== $args && '--project' === $args[0]) { array_shift($args); diff --git a/docs/bootstrap-inventory.md b/docs/bootstrap-inventory.md index 7958a0ec..5b5708e5 100644 --- a/docs/bootstrap-inventory.md +++ b/docs/bootstrap-inventory.md @@ -8,9 +8,9 @@ Regenerate: `php script/bootstrap-inventory.php` | Metric | Count | |--------|------:| -| PHP files on vm.php path | 336 | +| PHP files on vm.php path | 337 | | Source constructs flagged (blockers) | 10 | -| Source constructs flagged (warnings) | 907 | +| Source constructs flagged (warnings) | 908 | ## Compiler CFG gaps (`lib/Compiler.php`) @@ -345,6 +345,7 @@ These `LogicException` messages indicate CFG ops or expressions not yet lowered: | `lib/VM/Refcount.php` | 0 | 1 | | `lib/VM/TypeCheck.php` | 0 | 1 | | `lib/VM/Variable.php` | 0 | 4 | +| `lib/Web/CgiAotDriver.php` | 0 | 1 | | `lib/Web/CgiDriver.php` | 0 | 2 | | `lib/Web/ConstStringFolder.php` | 0 | 1 | | `lib/Web/DeployRoot.php` | 0 | 1 | @@ -1863,14 +1864,14 @@ These `LogicException` messages indicate CFG ops or expressions not yet lowered: - new OpCode (line 731) - new Variable (line 920) - new Variable (line 929) -- new Variable (line 1118) -- new Variable (line 1376) -- new Operand\Literal (line 1474) -- new Operand\Literal (line 1478) -- new Operand\Literal (line 1482) -- new Variable (line 1486) -- new Variable (line 1506) -- 20 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler +- new Variable (line 1140) +- new Variable (line 1391) +- new Operand\Literal (line 1489) +- new Operand\Literal (line 1493) +- new Operand\Literal (line 1497) +- new Variable (line 1501) +- new Variable (line 1521) +- 21 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler ### `lib/JIT/Analyzer.php` @@ -2206,9 +2207,9 @@ These `LogicException` messages indicate CFG ops or expressions not yet lowered: **Warnings** (review for bootstrap subset): - new Variable (line 22) -- new Variable (line 33) -- new Variable (line 40) -- new Variable (line 62) +- new Variable (line 36) +- new Variable (line 43) +- new Variable (line 65) - 2 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler ### `lib/JIT/JitStringCompare.php` @@ -2219,7 +2220,7 @@ These `LogicException` messages indicate CFG ops or expressions not yet lowered: ### `lib/JIT/JitValueBox.php` **Warnings** (review for bootstrap subset): -- 5 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler +- 6 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler ### `lib/JIT/JitValueCompare.php` @@ -2510,6 +2511,11 @@ These `LogicException` messages indicate CFG ops or expressions not yet lowered: - new HashTable (line 106) - 39 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler +### `lib/Web/CgiAotDriver.php` + +**Warnings** (review for bootstrap subset): +- 3 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler + ### `lib/Web/CgiDriver.php` **Warnings** (review for bootstrap subset): @@ -2556,8 +2562,8 @@ These `LogicException` messages indicate CFG ops or expressions not yet lowered: ### `lib/Web/ProjectDeploy.php` **Warnings** (review for bootstrap subset): -- new RecursiveIteratorIterator (line 205) -- new RecursiveDirectoryIterator (line 206) +- new RecursiveIteratorIterator (line 220) +- new RecursiveDirectoryIterator (line 221) - 6 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler ### `lib/Web/ProjectManifest.php` diff --git a/docs/bootstrap-profile.json b/docs/bootstrap-profile.json index fc29541b..84119b90 100644 --- a/docs/bootstrap-profile.json +++ b/docs/bootstrap-profile.json @@ -346,6 +346,7 @@ "lib/VM/ScriptExit.php", "lib/VM/TypeCheck.php", "lib/VM/Variable.php", + "lib/Web/CgiAotDriver.php", "lib/Web/CgiDriver.php", "lib/Web/ConstStringFolder.php", "lib/Web/DeployRoot.php", @@ -401,9 +402,9 @@ "test/bootstrap-aot/lib_opcode/main.php" ], "totals": { - "inventory_files": 336, + "inventory_files": 337, "excluded": 2, - "eligible": 334, + "eligible": 335, "aot_lint_targets": 15, "aot_link_targets": 13, "aot_link_lib_targets": 1 diff --git a/lib/Web/CgiAotDriver.php b/lib/Web/CgiAotDriver.php new file mode 100644 index 00000000..1cb7fa73 --- /dev/null +++ b/lib/Web/CgiAotDriver.php @@ -0,0 +1,125 @@ + or set '.DeployRoot::ENV.' to a deploy dist directory' + ); + } + + $resolved = ProjectManifest::resolveBinaryPath($root, null); + if (null === $resolved) { + $fallback = realpath($root.'/bin/app'); + if (false !== $fallback && is_file($fallback)) { + return $fallback; + } + + throw new \InvalidArgumentException( + 'No AOT binary under '.DeployRoot::ENV.'='.$root.' (phpc.json "binary" or bin/app)' + ); + } + + return $resolved; + } + + /** + * Run an AOT binary under the current CGI environment; stdout is CGI (Status + headers + body). + */ + public static function run(string $binary, ?string $deployRoot = null): void + { + if (!is_executable($binary)) { + throw new \InvalidArgumentException('AOT binary is not executable: '.$binary); + } + + if (null === $deployRoot || '' === $deployRoot) { + $deployRoot = getenv(DeployRoot::ENV); + } + if (false === $deployRoot || '' === $deployRoot) { + $parent = dirname($binary); + if (basename($parent) === 'bin') { + $guess = realpath(dirname($parent)); + if (false !== $guess) { + putenv(DeployRoot::ENV.'='.$guess); + $_ENV[DeployRoot::ENV] = $guess; + $_SERVER[DeployRoot::ENV] = $guess; + } + } + } + + CgiDriver::ingestStdinRequestBody(); + + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $env = self::cgiEnvironment(); + $proc = proc_open([$binary], $descriptorSpec, $pipes, null, $env); + if (!is_resource($proc)) { + throw new \RuntimeException('Failed to start AOT binary: '.$binary); + } + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $code = proc_close($proc); + if (0 !== $code) { + fwrite(STDERR, trim($stderr !== false ? $stderr : '')."\n"); + exit($code > 0 ? $code : 1); + } + + $raw = $stdout !== false ? $stdout : ''; + if (preg_match('/^Status:\s*\d+/im', $raw)) { + fwrite(STDOUT, $raw); + exit(0); + } + + [$status, $contentType, $body, $extraHeaders] = DevServer::parseCgiOutput($raw); + fwrite(STDOUT, CgiDriver::formatResponse($status, $contentType, $body, $extraHeaders)); + exit(0); + } + + /** + * @return array + */ + private static function cgiEnvironment(): array + { + $env = []; + foreach (array_merge($_ENV, $_SERVER) as $key => $value) { + if (is_string($key) && is_string($value)) { + $env[$key] = $value; + } + } + + return $env; + } +} diff --git a/lib/Web/ProjectDeploy.php b/lib/Web/ProjectDeploy.php index d28bbeff..3449d3ef 100644 --- a/lib/Web/ProjectDeploy.php +++ b/lib/Web/ProjectDeploy.php @@ -97,6 +97,16 @@ public static function deploy(string $projectDir, string $outputDir, bool $build } } + $wrapperSrc = dirname(__DIR__, 2).'/bin/cgi-aot.sh'; + if (!is_file($wrapperSrc)) { + return ['cgi-aot.sh missing in repository (issue #665)']; + } + $wrapperDest = $outReal.'/'.CgiAotDriver::WRAPPER_NAME; + if (!copy($wrapperSrc, $wrapperDest)) { + return ['failed to copy '.CgiAotDriver::WRAPPER_NAME]; + } + @chmod($wrapperDest, 0755); + $readme = self::readmeDeployContent(); if (false === file_put_contents($outReal.'/'.self::README_DEPLOY, $readme)) { return ['failed to write '.self::README_DEPLOY]; @@ -119,11 +129,16 @@ public static function readmeDeployContent(): string QUERY_STRING CGI query string (e.g. name=value&page=1) PHP_COMPILER_DEBUG Set to 1 for serve/build diagnostics -Example: +Example (direct binary): export PHPC_DEPLOY_ROOT=/var/www/myapp ./bin/app +Production CGI (nginx ScriptAlias → cgi-wrapper): + + export PHPC_DEPLOY_ROOT=/var/www/myapp + ./cgi-wrapper + See docs/deploy-web-aot.md (quickstart), docs/local-ci-matrix.md, and the production deployment guide (issue #445). TXT; diff --git a/test/real/CgiDriverTest.php b/test/real/CgiDriverTest.php index 3526a102..2dd8db66 100644 --- a/test/real/CgiDriverTest.php +++ b/test/real/CgiDriverTest.php @@ -8,6 +8,7 @@ /** * VM smoke for bin/cgi.php with CGI env only (no TCP, issues #50, #656, #666). + * AOT wrapper smoke for bin/cgi-aot.php (issue #665). * * @group cgi */ @@ -20,6 +21,8 @@ final class CgiDriverTest extends TestCase private string $cgiBin; + private static ?bool $llvmReady = null; + protected function setUp(): void { $this->repoRoot = dirname(__DIR__, 2); @@ -119,6 +122,87 @@ public function testMiniWebAppApiStatusViaCgiDriver(): void $this->assertStringContainsString('"ok":true', $this->cgiBody($out)); } + /** + * 001-SimpleWeb AOT binary via cgi-aot wrapper (issue #665). + * + * @group llvm + * @group aot-link + */ + public function testSimpleWebGetViaAotCgiWrapper(): void + { + if (!self::isLlvmReady()) { + $this->markTestSkipped('LLVM 9 toolchain not available'); + } + $cgiAot = realpath($this->repoRoot.'/bin/cgi-aot.php'); + if (false === $cgiAot) { + $this->markTestSkipped('bin/cgi-aot.php missing (#665)'); + } + + $source = $this->repoRoot.'/examples/001-SimpleWeb/example.php'; + $this->assertFileExists($source); + $binaryDir = sys_get_temp_dir().'/phpc_cgi_aot_'.bin2hex(random_bytes(4)); + $this->assertTrue(mkdir($binaryDir)); + $binary = $binaryDir.'/app'; + try { + $this->compileAotBinary($source, $binary); + + $env = $this->baseEnv(); + $env['REQUEST_METHOD'] = 'GET'; + $env['QUERY_STRING'] = 'name=AotCgi'; + $env['SCRIPT_NAME'] = '/example.php'; + $env['SCRIPT_FILENAME'] = $source; + $env['REQUEST_URI'] = '/example.php?name=AotCgi'; + + $out = $this->runCgiAot($cgiAot, $binary, $env); + $this->assertStringContainsString('Status: 200', $out); + $this->assertStringContainsString('Content-Type: text/html', $out); + $this->assertStringContainsString('AotCgi', $this->cgiBody($out)); + } finally { + @unlink($binary); + @rmdir($binaryDir); + } + } + + /** + * Deploy dist cgi-wrapper shell script (issue #665). + * + * @group llvm + * @group aot-link + */ + public function testDeployCgiWrapperRunsSimpleWebBinary(): void + { + if (!self::isLlvmReady()) { + $this->markTestSkipped('LLVM 9 toolchain not available'); + } + $wrapper = realpath($this->repoRoot.'/bin/cgi-aot.sh'); + if (false === $wrapper) { + $this->markTestSkipped('bin/cgi-aot.sh missing (#665)'); + } + + $source = $this->repoRoot.'/examples/001-SimpleWeb/example.php'; + $dist = sys_get_temp_dir().'/phpc_cgi_deploy_'.bin2hex(random_bytes(4)); + $this->assertTrue(mkdir($dist.'/bin', 0777, true)); + $binary = $dist.'/bin/app'; + try { + $this->compileAotBinary($source, $binary); + copy($wrapper, $dist.'/cgi-wrapper'); + chmod($dist.'/cgi-wrapper', 0755); + + $env = $this->baseEnv(); + $env['PHPC_DEPLOY_ROOT'] = $dist; + $env['REQUEST_METHOD'] = 'GET'; + $env['QUERY_STRING'] = 'name=DeployCgi'; + $env['SCRIPT_NAME'] = '/example.php'; + $env['REQUEST_URI'] = '/example.php?name=DeployCgi'; + + $out = $this->runCgiShell($dist.'/cgi-wrapper', $env); + $this->assertStringContainsString('Status: 200', $out); + $this->assertStringContainsString('DeployCgi', $this->cgiBody($out)); + } finally { + $this->removeTree($dist); + } + } + private function miniWebAppIndexScript(): string { $script = $this->repoRoot.'/examples/003-MiniWebApp/public/index.php'; @@ -149,12 +233,61 @@ private function miniWebAppBaseEnv(string $script): array return $env; } + private function compileAotBinary(string $source, string $outfile): void + { + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $env = $this->baseEnv(); + LlvmToolchain::applyProcessEnv($env, $this->repoRoot); + $compile = proc_open( + array_merge( + LlvmToolchain::envPrefix($this->repoRoot), + $this->phpCmd, + [$this->repoRoot.'/bin/compile.php', '-o', $outfile, $source] + ), + $descriptorSpec, + $pipes, + $this->repoRoot, + $env + ); + $this->assertIsResource($compile); + fclose($pipes[0]); + $err = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($compile); + $this->assertSame(0, $exitCode, 'compile.php failed: '.trim($err !== false ? $err : '')); + $this->assertFileExists($outfile); + $this->assertTrue(is_executable($outfile)); + } + /** * @param array $env */ - private function runCgi(string $script, array $env, string $stdin = ''): string + private function runCgiAot(string $cgiAot, string $binary, array $env, string $stdin = ''): string + { + $cmd = array_merge($this->phpCmd, [$cgiAot, $binary]); + + return $this->runCgiProcess($cmd, $env, $stdin); + } + + /** + * @param array $env + */ + private function runCgiShell(string $wrapper, array $env, string $stdin = ''): string + { + return $this->runCgiProcess([$wrapper], $env, $stdin); + } + + /** + * @param list $cmd + * @param array $env + */ + private function runCgiProcess(array $cmd, array $env, string $stdin = ''): string { - $cmd = array_merge($this->phpCmd, [$this->cgiBin, $script]); $descriptor = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], @@ -174,6 +307,16 @@ private function runCgi(string $script, array $env, string $stdin = ''): string return false !== $stdout ? $stdout : ''; } + /** + * @param array $env + */ + private function runCgi(string $script, array $env, string $stdin = ''): string + { + $cmd = array_merge($this->phpCmd, [$this->cgiBin, $script]); + + return $this->runCgiProcess($cmd, $env, $stdin); + } + private function cgiBody(string $output): string { $parts = preg_split("/\r\n\r\n|\n\n/", $output, 2); @@ -196,6 +339,36 @@ private function baseEnv(): array return $env; } + private static function isLlvmReady(): bool + { + if (null === self::$llvmReady) { + self::$llvmReady = LlvmToolchain::isReady(dirname(__DIR__, 2)); + } + + return self::$llvmReady; + } + + private function removeTree(string $path): void + { + if (!is_dir($path)) { + @unlink($path); + + return; + } + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $item) { + if ($item->isDir()) { + @rmdir($item->getPathname()); + } else { + @unlink($item->getPathname()); + } + } + @rmdir($path); + } + /** * @return list */ diff --git a/test/unit/PhpcCliTest.php b/test/unit/PhpcCliTest.php index bc83cd57..88451643 100644 --- a/test/unit/PhpcCliTest.php +++ b/test/unit/PhpcCliTest.php @@ -22,6 +22,7 @@ public function testHelpListsSubcommands(): void $this->assertStringContainsString('phpc build --project', $result['stdout']); $this->assertStringContainsString('--dry-run', $result['stdout']); $this->assertStringContainsString('phpc deploy', $result['stdout']); + $this->assertStringContainsString('phpc cgi', $result['stdout']); $this->assertStringContainsString('--from-build', $result['stdout']); $this->assertStringContainsString('phpc test', $result['stdout']); $this->assertStringContainsString('phpc lint', $result['stdout']); diff --git a/test/unit/PhpcDeployTest.php b/test/unit/PhpcDeployTest.php index eb4b5ef0..658e9ed2 100644 --- a/test/unit/PhpcDeployTest.php +++ b/test/unit/PhpcDeployTest.php @@ -5,6 +5,7 @@ namespace PHPCompiler; use PHPUnit\Framework\TestCase; +use PHPCompiler\Web\CgiAotDriver; use PHPCompiler\Web\ProjectDeploy; /** @@ -48,7 +49,11 @@ public function testDeployCopiesBinaryManifestAndOptionalTrees(): void $this->assertFileExists($out.'/assets/app.css'); $this->assertFileExists($out.'/templates/layout.php'); $this->assertFileExists($out.'/'.ProjectDeploy::README_DEPLOY); - $this->assertStringContainsString('PHPC_DEPLOY_ROOT', (string) file_get_contents($out.'/'.ProjectDeploy::README_DEPLOY)); + $this->assertFileExists($out.'/'.CgiAotDriver::WRAPPER_NAME); + $this->assertTrue(is_executable($out.'/'.CgiAotDriver::WRAPPER_NAME)); + $readme = (string) file_get_contents($out.'/'.ProjectDeploy::README_DEPLOY); + $this->assertStringContainsString('PHPC_DEPLOY_ROOT', $readme); + $this->assertStringContainsString('cgi-wrapper', $readme); } finally { $this->removeTree($project); $this->removeTree($out); diff --git a/test/unit/Web/CgiAotDriverTest.php b/test/unit/Web/CgiAotDriverTest.php new file mode 100644 index 00000000..d064ea13 --- /dev/null +++ b/test/unit/Web/CgiAotDriverTest.php @@ -0,0 +1,55 @@ +assertFileExists($compile); + + $resolved = CgiAotDriver::resolveBinary($compile, null); + $this->assertSame(realpath($compile), $resolved); + } + + public function testResolveFromDeployRootBinApp(): void + { + $dist = sys_get_temp_dir().'/phpc_cgi_resolve_'.bin2hex(random_bytes(4)); + $this->assertTrue(mkdir($dist.'/bin', 0777, true)); + $binary = $dist.'/bin/app'; + file_put_contents($binary, "#!/bin/sh\necho ok\n"); + chmod($binary, 0755); + + try { + $resolved = CgiAotDriver::resolveBinary(null, $dist); + $this->assertSame(realpath($binary), $resolved); + } finally { + @unlink($binary); + @rmdir($dist.'/bin'); + @rmdir($dist); + } + } + + public function testResolveFailsWithoutBinaryOrDeployRoot(): void + { + $this->expectException(\InvalidArgumentException::class); + CgiAotDriver::resolveBinary(null, null); + } + + public function testWrapperNameConstant(): void + { + $this->assertSame('cgi-wrapper', CgiAotDriver::WRAPPER_NAME); + $this->assertSame('PHPC_DEPLOY_ROOT', DeployRoot::ENV); + } +}