From c0328df9e60af72d877775527bfcad062706d49d Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Mon, 31 Aug 2026 15:23:55 +0800 Subject: [PATCH] test(ci): add EXT/LIB integration tests for PHP ZTS builds - Add new integration workflow for PHP 8.4 and 8.5 ZTS testing - Create comprehensive integration test runner script for EXT/LIB boundaries - Add lifecycle tests covering CLI, php-S, and PHP-FPM execution environments - Implement provider library integration tests with stub generation - Add FastCGI protocol support for PHP-FPM communication testing - Include detailed README documentation for integration test suite - Set up automatic artifact collection for integration test failures --- .github/workflows/linux-x64.yml | 99 ++++ bin/run-integration-tests.php | 648 +++++++++++++++++++++ tests/integration/README.md | 29 + tests/integration/lib/provider/project.yml | 5 + 4 files changed, 781 insertions(+) create mode 100755 bin/run-integration-tests.php create mode 100644 tests/integration/README.md create mode 100644 tests/integration/lib/provider/project.yml diff --git a/.github/workflows/linux-x64.yml b/.github/workflows/linux-x64.yml index 8d8e4865..493220ad 100644 --- a/.github/workflows/linux-x64.yml +++ b/.github/workflows/linux-x64.yml @@ -215,6 +215,105 @@ jobs: - name: Run PHPUnit run: vendor/bin/phpunit + integration: + name: EXT/LIB - PHP ${{ matrix.php }} ZTS + if: ${{ startsWith(github.ref, 'refs/tags/') || !contains(github.event.head_commit.message || '', '--skip-tests') }} + runs-on: ubuntu-22.04 + timeout-minutes: 45 + needs: build + strategy: + fail-fast: false + matrix: + php: ["8.4", "8.5"] + env: + PHPX_HOME: ${{ github.workspace }}/vendor/swoole/phpx + + steps: + - name: Checkout TypePHP + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + ini-values: precision=17, memory_limit=4G, error_reporting=E_ERROR|E_WARNING, display_errors=1, display_startup_errors=1, log_errors=0 + tools: composer:v2 + env: + fail-fast: true + phpts: ts + update: true + + - name: Verify ZTS PHP and FPM + shell: bash + run: | + php -r 'if (!PHP_ZTS) { fwrite(STDERR, "Expected a ZTS PHP build\n"); exit(1); }' + case "$(php -r 'echo PHP_VERSION;')" in + "${{ matrix.php }}"*) ;; + *) echo "setup-php installed an unexpected PHP version" >&2; exit 1 ;; + esac + php_home="$(php-config --prefix)" + php_fpm="" + for candidate in \ + "${php_home}/sbin/php-fpm" \ + "${php_home}/bin/php-fpm" \ + "/usr/sbin/php-fpm${{ matrix.php }}" \ + "/usr/bin/php-fpm${{ matrix.php }}"; do + if test -x "${candidate}"; then + php_fpm="${candidate}" + break + fi + done + test -n "${php_fpm}" + "${php_fpm}" -v + "${php_fpm}" -i > /tmp/typephp-fpm-info.txt + grep -q 'Thread Safety => enabled' /tmp/typephp-fpm-info.txt + echo "PHP_HOME=${php_home}" >> "${GITHUB_ENV}" + echo "PHP_FPM=${php_fpm}" >> "${GITHUB_ENV}" + + - name: Patch php_hash.h C++ compatibility + uses: ./.github/actions/patch-php-headers + + - name: Install native build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes build-essential cmake libgmp-dev libmpfr-dev pkg-config + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress + + - name: Download Linux x64 build outputs + uses: actions/download-artifact@v4 + with: + name: tpc-linux-x64-php-${{ matrix.php }}-zts + path: . + + - name: Verify integration build inputs + shell: bash + run: | + test -f ./tpc + test -f "${PHPX_HOME}/lib/libphpx.so" + test -f "${PHP_HOME}/lib/libphp.so" + chmod +x ./tpc + echo "LD_LIBRARY_PATH=${PHPX_HOME}/lib:${PHP_HOME}/lib" >> "${GITHUB_ENV}" + + - name: Run EXT/LIB integration tests + shell: bash + run: | + php -n bin/run-integration-tests.php \ + --compiler=./tpc \ + --php="$(command -v php)" \ + --php-fpm="${PHP_FPM}" + + - name: Upload integration failure artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: integration-failures-linux-x64-php-${{ matrix.php }}-zts + if-no-files-found: ignore + retention-days: 7 + path: build/integration-* + phpt: name: PHPT - PHP ${{ matrix.php }} ZTS if: ${{ startsWith(github.ref, 'refs/tags/') || !contains(github.event.head_commit.message || '', '--skip-tests') }} diff --git a/bin/run-integration-tests.php b/bin/run-integration-tests.php new file mode 100755 index 00000000..ee9f3d29 --- /dev/null +++ b/bin/run-integration-tests.php @@ -0,0 +1,648 @@ +#!/usr/bin/env php + TYPEPHP_INTEGRATION_ROOT . '/tpc', + 'php' => PHP_BINARY, + 'php_fpm' => '', + 'keep' => false, + 'suite' => 'all', + ]; + + foreach (array_slice($argv, 1) as $argument) { + if ($argument === '--keep') { + $options['keep'] = true; + continue; + } + if (str_starts_with($argument, '--suite=')) { + $options['suite'] = substr($argument, strlen('--suite=')); + continue; + } + foreach (['compiler', 'php', 'php-fpm'] as $name) { + $prefix = '--' . $name . '='; + if (str_starts_with($argument, $prefix)) { + $key = str_replace('-', '_', $name); + $options[$key] = substr($argument, strlen($prefix)); + continue 2; + } + } + throw new IntegrationFailure('Unknown option: ' . $argument); + } + + if (!in_array($options['suite'], ['all', 'ext', 'lib'], true)) { + throw new IntegrationFailure('Invalid --suite value; expected all, ext, or lib'); + } + + foreach (['compiler', 'php'] as $name) { + $path = realpath($options[$name]); + if ($path === false || !is_executable($path)) { + throw new IntegrationFailure("{$name} is not executable: {$options[$name]}"); + } + $options[$name] = $path; + } + + if ($options['suite'] !== 'lib') { + if ($options['php_fpm'] === '') { + $prefix = integrationPhpPrefix($options['php']); + $candidates = [ + $prefix . '/sbin/php-fpm', + dirname($options['php']) . '/php-fpm', + dirname($options['php']) . '/php-fpm' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, + ]; + foreach ($candidates as $candidate) { + if (is_executable($candidate)) { + $options['php_fpm'] = $candidate; + break; + } + } + } + + $fpm = realpath($options['php_fpm']); + if ($fpm === false || !is_executable($fpm)) { + throw new IntegrationFailure( + 'A PHP-FPM binary from the same PHP installation is required; pass --php-fpm=/path/to/php-fpm', + ); + } + $options['php_fpm'] = $fpm; + } + + return $options; +} + +function integrationPhpPrefix(string $php): string +{ + $phpConfig = dirname($php) . '/php-config'; + if (!is_executable($phpConfig)) { + return dirname(dirname($php)); + } + $result = runIntegrationCommand([$phpConfig, '--prefix'], null, [], 15, false); + if ($result['exit_code'] !== 0) { + return dirname(dirname($php)); + } + return trim($result['stdout']); +} + +/** + * @param list $command + * @param array $environment + * @return array{exit_code: int, stdout: string, stderr: string} + */ +function runIntegrationCommand( + array $command, + ?string $workingDirectory = null, + array $environment = [], + int $timeout = 180, + bool $throwOnFailure = true, +): array { + fwrite(STDOUT, '$ ' . implode(' ', array_map('escapeshellarg', $command)) . PHP_EOL); + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open( + $command, + $descriptors, + $pipes, + $workingDirectory, + array_replace(integrationEnvironment(), $environment), + ['bypass_shell' => true], + ); + if (!is_resource($process)) { + throw new IntegrationFailure('Failed to start command'); + } + + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $stdout = ''; + $stderr = ''; + $startedAt = microtime(true); + $exitCode = -1; + + while (true) { + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + $status = proc_get_status($process); + if (!$status['running']) { + $exitCode = $status['exitcode']; + break; + } + if (microtime(true) - $startedAt > $timeout) { + proc_terminate($process); + usleep(200_000); + if (proc_get_status($process)['running']) { + proc_terminate($process, 9); + } + $stderr .= "\nCommand timed out after {$timeout} seconds\n"; + break; + } + usleep(20_000); + } + + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + + if ($stdout !== '') { + fwrite(STDOUT, $stdout); + } + if ($stderr !== '') { + fwrite(STDERR, $stderr); + } + if ($throwOnFailure && $exitCode !== 0) { + throw new IntegrationFailure('Command failed with exit code ' . $exitCode); + } + + return ['exit_code' => $exitCode, 'stdout' => $stdout, 'stderr' => $stderr]; +} + +/** @return array */ +function integrationEnvironment(): array +{ + return getenv(); +} + +function assertIntegrationSame(string $expected, string $actual, string $message): void +{ + if ($expected !== $actual) { + throw new IntegrationFailure( + $message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true), + ); + } +} + +function assertIntegrationTrue(bool $condition, string $message): void +{ + if (!$condition) { + throw new IntegrationFailure($message); + } +} + +/** + * @param int|null $expectedPid + * @param-out int $expectedPid + */ +function assertLifecycleBody(string $body, int $request, ?int &$expectedPid, string $host): void +{ + $kind = $request % 2 === 0 ? 'even' : 'odd'; + try { + $actual = json_decode($body, true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + throw new IntegrationFailure("{$host} returned invalid JSON: {$body}", previous: $error); + } + $pid = $actual['pid'] ?? 0; + unset($actual['pid']); + $expected = [ + 'request' => $request, + 'results' => [ + "1@{$request}|{$kind}-handler[{$kind}:{$request}]", + "2@{$request}|{$kind}-handler[{$kind}:{$request}]", + ], + 'main_registered' => false, + ]; + if ($actual !== $expected) { + throw new IntegrationFailure( + "{$host} request {$request} failed\nExpected: " . var_export($expected, true) + . "\nActual: " . var_export($actual, true), + ); + } + assertIntegrationTrue(is_int($pid) && $pid > 0, "{$host} did not return a valid worker PID"); + if ($expectedPid !== null && $expectedPid !== $pid) { + throw new IntegrationFailure("{$host} worker restarted between requests: {$expectedPid} -> {$pid}"); + } + $expectedPid = $pid; +} + +/** @return array{process: resource, pipes: array} */ +function startIntegrationProcess(array $command, ?string $workingDirectory = null): array +{ + fwrite(STDOUT, '$ ' . implode(' ', array_map('escapeshellarg', $command)) . PHP_EOL); + $process = proc_open( + $command, + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $workingDirectory, + integrationEnvironment(), + ['bypass_shell' => true], + ); + if (!is_resource($process)) { + throw new IntegrationFailure('Failed to start server process'); + } + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + return ['process' => $process, 'pipes' => $pipes]; +} + +/** @param array{process: resource, pipes: array} $server */ +function stopIntegrationProcess(array $server): string +{ + $process = $server['process']; + $status = proc_get_status($process); + if ($status['running']) { + proc_terminate($process); + $deadline = microtime(true) + 3; + do { + usleep(50_000); + $status = proc_get_status($process); + } while ($status['running'] && microtime(true) < $deadline); + if ($status['running']) { + proc_terminate($process, 9); + } + } + $output = stream_get_contents($server['pipes'][1]) . stream_get_contents($server['pipes'][2]); + fclose($server['pipes'][1]); + fclose($server['pipes'][2]); + proc_close($process); + return $output; +} + +function reserveIntegrationPort(): int +{ + $socket = stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage); + if ($socket === false) { + throw new IntegrationFailure("Cannot reserve TCP port: {$errorMessage} ({$errorCode})"); + } + $address = stream_socket_get_name($socket, false); + fclose($socket); + if ($address === false || !preg_match('/:(\d+)$/', $address, $matches)) { + throw new IntegrationFailure('Cannot determine reserved TCP port'); + } + return (int) $matches[1]; +} + +function requestHttp(int $port, int $request, float $timeout = 2.0): string +{ + $socket = @stream_socket_client( + "tcp://127.0.0.1:{$port}", + $errorCode, + $errorMessage, + $timeout, + ); + if ($socket === false) { + throw new IntegrationFailure("HTTP connection failed: {$errorMessage} ({$errorCode})"); + } + stream_set_timeout($socket, (int) ceil($timeout)); + fwrite( + $socket, + "GET /request.php?request={$request} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", + ); + $response = stream_get_contents($socket); + fclose($socket); + if (!preg_match('/^HTTP\/1\.[01] 200\b/', $response)) { + throw new IntegrationFailure('Unexpected HTTP response: ' . $response); + } + $parts = preg_split("/\r?\n\r?\n/", $response, 2); + return trim($parts[1] ?? ''); +} + +/** @param callable(): string $request */ +function waitForIntegrationServer(array $server, callable $request): string +{ + $deadline = microtime(true) + 10; + $lastError = null; + do { + $status = proc_get_status($server['process']); + if (!$status['running']) { + $logs = stream_get_contents($server['pipes'][1]) . stream_get_contents($server['pipes'][2]); + throw new IntegrationFailure('Server exited during startup: ' . $logs); + } + try { + return $request(); + } catch (IntegrationFailure $error) { + $lastError = $error; + usleep(100_000); + } + } while (microtime(true) < $deadline); + + throw new IntegrationFailure('Server did not become ready: ' . $lastError->getMessage()); +} + +function encodeFastCgiLength(int $length): string +{ + return $length < 128 ? chr($length) : pack('N', $length | 0x80000000); +} + +/** @param array $parameters */ +function encodeFastCgiParameters(array $parameters): string +{ + $result = ''; + foreach ($parameters as $name => $value) { + $result .= encodeFastCgiLength(strlen($name)); + $result .= encodeFastCgiLength(strlen($value)); + $result .= $name . $value; + } + return $result; +} + +function fastCgiRecord(int $type, int $requestId, string $content): string +{ + $padding = (8 - strlen($content) % 8) % 8; + return pack('CCnnCC', 1, $type, $requestId, strlen($content), $padding, 0) + . $content . str_repeat("\0", $padding); +} + +function requestFastCgi(int $port, string $script, int $request, float $timeout = 3.0): string +{ + $socket = @stream_socket_client( + "tcp://127.0.0.1:{$port}", + $errorCode, + $errorMessage, + $timeout, + ); + if ($socket === false) { + throw new IntegrationFailure("FastCGI connection failed: {$errorMessage} ({$errorCode})"); + } + stream_set_timeout($socket, (int) ceil($timeout)); + $parameters = encodeFastCgiParameters([ + 'GATEWAY_INTERFACE' => 'CGI/1.1', + 'SERVER_SOFTWARE' => 'typephp-integration', + 'SERVER_PROTOCOL' => 'HTTP/1.1', + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => "/request.php?request={$request}", + 'SCRIPT_NAME' => '/request.php', + 'SCRIPT_FILENAME' => $script, + 'DOCUMENT_ROOT' => dirname($script), + 'QUERY_STRING' => "request={$request}", + 'REMOTE_ADDR' => '127.0.0.1', + 'REMOTE_PORT' => '12345', + 'SERVER_ADDR' => '127.0.0.1', + 'SERVER_PORT' => (string) $port, + 'SERVER_NAME' => 'localhost', + 'CONTENT_LENGTH' => '0', + ]); + $beginRequest = pack('nCxxxxx', 1, 0); + fwrite($socket, fastCgiRecord(1, 1, $beginRequest)); + fwrite($socket, fastCgiRecord(4, 1, $parameters)); + fwrite($socket, fastCgiRecord(4, 1, '')); + fwrite($socket, fastCgiRecord(5, 1, '')); + + $stdout = ''; + $stderr = ''; + while (!feof($socket)) { + $header = fread($socket, 8); + if ($header === '' || strlen($header) < 8) { + break; + } + $record = unpack('Cversion/Ctype/nrequest/nlength/Cpadding/Creserved', $header); + $content = ''; + while (strlen($content) < $record['length']) { + $chunk = fread($socket, $record['length'] - strlen($content)); + if ($chunk === false || $chunk === '') { + break 2; + } + $content .= $chunk; + } + if ($record['padding'] > 0) { + fread($socket, $record['padding']); + } + if ($record['type'] === 6) { + $stdout .= $content; + } elseif ($record['type'] === 7) { + $stderr .= $content; + } elseif ($record['type'] === 3) { + break; + } + } + fclose($socket); + + if ($stderr !== '') { + throw new IntegrationFailure('FastCGI stderr: ' . $stderr); + } + $parts = preg_split("/\r?\n\r?\n/", $stdout, 2); + if (!str_contains($parts[0] ?? '', 'Status: 200') && !str_contains($parts[0] ?? '', 'Content-Type:')) { + throw new IntegrationFailure('Unexpected FastCGI response: ' . $stdout); + } + return trim($parts[1] ?? ''); +} + +function runExtIntegration(array $options, string $temporaryRoot): void +{ + fwrite(STDOUT, "\n[EXT] build and Zend host lifecycle\n"); + $extension = $temporaryRoot . '/typephp_integration_ext.' . PHP_SHLIB_SUFFIX; + $buildDirectory = $temporaryRoot . '/ext-build'; + runIntegrationCommand([ + $options['compiler'], + TYPEPHP_INTEGRATION_TEST_ROOT . '/ext/lifecycle/src/extension.php', + '--mode', 'ext', + '--output', $extension, + '--build-dir', $buildDirectory, + '--job', '1', + '--no-progress', + ]); + assertIntegrationTrue(is_file($extension), 'Extension artifact was not generated: ' . $extension); + + $hostScript = realpath(TYPEPHP_INTEGRATION_TEST_ROOT . '/ext/lifecycle/host/request.php'); + if ($hostScript === false) { + throw new IntegrationFailure('Extension host script is missing'); + } + for ($request = 1; $request <= 2; ++$request) { + $result = runIntegrationCommand([ + $options['php'], '-n', '-d', 'extension=' . $extension, $hostScript, + ], null, ['TYPEPHP_INTEGRATION_REQUEST' => (string) $request]); + $cliPid = null; + assertLifecycleBody(trim($result['stdout']), $request, $cliPid, 'CLI extension'); + } + + $serverPort = reserveIntegrationPort(); + $server = startIntegrationProcess([ + $options['php'], '-n', '-d', 'extension=' . $extension, + '-d', 'display_errors=1', '-S', "127.0.0.1:{$serverPort}", + '-t', dirname($hostScript), + ]); + $serverLogs = ''; + try { + $first = waitForIntegrationServer($server, fn(): string => requestHttp($serverPort, 1)); + $serverPid = null; + assertLifecycleBody($first, 1, $serverPid, 'php -S'); + for ($request = 2; $request <= 8; ++$request) { + $body = requestHttp($serverPort, $request); + assertLifecycleBody($body, $request, $serverPid, 'php -S'); + } + } finally { + $serverLogs = stopIntegrationProcess($server); + if ($serverLogs !== '') { + fwrite(STDOUT, $serverLogs); + } + } + + $fpmPort = reserveIntegrationPort(); + $fpmConfig = $temporaryRoot . '/php-fpm.conf'; + $fpmLog = $temporaryRoot . '/php-fpm.log'; + file_put_contents($fpmConfig, << requestFastCgi($fpmPort, $hostScript, 1), + ); + $fpmPid = null; + assertLifecycleBody($first, 1, $fpmPid, 'PHP-FPM'); + for ($request = 2; $request <= 8; ++$request) { + $body = requestFastCgi($fpmPort, $hostScript, $request); + assertLifecycleBody($body, $request, $fpmPid, 'PHP-FPM'); + } + } finally { + $fpmLogs = stopIntegrationProcess($fpm); + if ($fpmLogs !== '') { + fwrite(STDOUT, $fpmLogs); + } + } +} + +function copyIntegrationTree(string $source, string $destination): void +{ + if (!is_dir($destination) && !mkdir($destination, 0777, true) && !is_dir($destination)) { + throw new IntegrationFailure('Cannot create directory: ' . $destination); + } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + foreach ($iterator as $item) { + $target = $destination . '/' . $iterator->getSubPathName(); + if ($item->isDir()) { + if (!is_dir($target)) { + mkdir($target, 0777, true); + } + } elseif (!copy($item->getPathname(), $target)) { + throw new IntegrationFailure('Cannot copy fixture: ' . $item->getPathname()); + } + } +} + +function runLibIntegration(array $options, string $temporaryRoot): void +{ + fwrite(STDOUT, "\n[LIB] provider/import stub/consumer boundary\n"); + $providerRoot = $temporaryRoot . '/provider'; + copyIntegrationTree(TYPEPHP_INTEGRATION_TEST_ROOT . '/lib/provider', $providerRoot); + runIntegrationCommand([ + $options['compiler'], $providerRoot . '/project.yml', + '--output', $providerRoot . '/integration_provider.' . PHP_SHLIB_SUFFIX, + '--build-dir', $providerRoot . '/build', '--job', '1', '--no-progress', + ]); + + $library = $providerRoot . '/integration_provider.' . PHP_SHLIB_SUFFIX; + $stub = $providerRoot . '/integration_provider.stub.php'; + assertIntegrationTrue(is_file($library), 'Library artifact was not generated: ' . $library); + assertIntegrationTrue(is_file($stub), 'Library import stub was not generated: ' . $stub); + $stubCode = file_get_contents($stub); + assertIntegrationTrue(is_string($stubCode) && str_contains($stubCode, '@import-library'), 'Invalid library stub'); + assertIntegrationTrue(!str_contains($stubCode, 'function main('), 'Library stub must not export bin main()'); + + // The published stub automatically adds -lintegration_provider to its + // consumer. Keep the provider target name (and therefore its stub ABI name) + // independent from the Unix lib prefix used by the linker. + $linkLibrary = $providerRoot . '/libintegration_provider.' . PHP_SHLIB_SUFFIX; + if (!copy($library, $linkLibrary)) { + throw new IntegrationFailure('Cannot prepare linker-visible provider library'); + } + + $consumerRoot = $temporaryRoot . '/consumer'; + copyIntegrationTree(TYPEPHP_INTEGRATION_TEST_ROOT . '/lib/consumer', $consumerRoot); + copy($stub, $consumerRoot . '/integration_provider.stub.php'); + $consumer = $temporaryRoot . '/integration_consumer'; + runIntegrationCommand([ + $options['compiler'], $consumerRoot, + '--mode', 'bin', '--output', $consumer, + '--build-dir', $consumerRoot . '/build', + '--link-path', $providerRoot, + '--job', '1', '--no-progress', + ]); + $libraryPath = $providerRoot; + $environment = PHP_OS_FAMILY === 'Darwin' + ? ['DYLD_LIBRARY_PATH' => $libraryPath . ':' . (getenv('DYLD_LIBRARY_PATH') ?: '')] + : ['LD_LIBRARY_PATH' => $libraryPath . ':' . (getenv('LD_LIBRARY_PATH') ?: '')]; + $result = runIntegrationCommand([$consumer], null, $environment); + assertIntegrationSame("42\ncounter=7\n", $result['stdout'], 'TypePHP library consumer returned unexpected output'); +} + +function removeIntegrationTree(string $path): void +{ + if (!is_dir($path)) { + return; + } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($path); +} + +function main(array $argv): int +{ + $temporaryRoot = TYPEPHP_INTEGRATION_ROOT . '/build/integration-' + . getmypid() . '-' . bin2hex(random_bytes(3)); + $succeeded = false; + try { + $options = parseIntegrationOptions($argv); + if (!mkdir($temporaryRoot, 0777, true) && !is_dir($temporaryRoot)) { + throw new IntegrationFailure('Cannot create integration build directory: ' . $temporaryRoot); + } + fwrite(STDOUT, 'Integration artifacts: ' . $temporaryRoot . PHP_EOL); + if ($options['suite'] === 'all' || $options['suite'] === 'ext') { + runExtIntegration($options, $temporaryRoot); + } + if ($options['suite'] === 'all' || $options['suite'] === 'lib') { + runLibIntegration($options, $temporaryRoot); + } + $succeeded = true; + fwrite(STDOUT, "\nEXT/LIB integration tests passed\n"); + return 0; + } catch (Throwable $error) { + fwrite(STDERR, "\nFAIL: {$error->getMessage()}\n"); + fwrite(STDERR, "Artifacts retained at: {$temporaryRoot}\n"); + return 1; + } finally { + if ($succeeded && !in_array('--keep', $argv, true)) { + removeIntegrationTree($temporaryRoot); + } + } +} + +exit(main($argv)); diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 00000000..986dd684 --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,29 @@ +# EXT/LIB integration tests + +This suite protects build-mode boundaries rather than duplicating the PHP +syntax coverage in `tests/compiler`. + +- `ext/lifecycle` builds a real Zend extension and loads it through CLI, + `php -S`, and PHP-FPM. The long-running hosts alternate implementations of + the same request-local class and function, while the extension also calls an + internal class and method. This protects request cache cleanup and persistent + cache reuse across repeated RINIT/RSHUTDOWN cycles. +- `lib` builds a provider library, consumes its generated `@import-library` + stub from a second TypePHP binary, links the two artifacts, and runs the + consumer. Both modes include a throwing `main()` declaration to verify that + only bin mode executes the entrypoint. + +Run from the repository root: + +```sh +PHPX_HOME=../phpx php bin/run-integration-tests.php \ + --compiler=./tpc \ + --php="$(command -v php)" \ + --php-fpm="$(php-config --prefix)/sbin/php-fpm" +``` + +Successful runs remove their temporary build tree. On failure the generated +C++ sources, shared libraries, server configuration, and logs remain under +`build/integration-*` for CI artifact collection. Pass `--keep` to retain a +successful build as well. `--suite=ext` and `--suite=lib` can isolate one mode +while debugging; the default is `--suite=all`. diff --git a/tests/integration/lib/provider/project.yml b/tests/integration/lib/provider/project.yml new file mode 100644 index 00000000..02b336f9 --- /dev/null +++ b/tests/integration/lib/provider/project.yml @@ -0,0 +1,5 @@ +name: integration_provider +mode: lib +cxx-std: c++17 +sources: + - src