diff --git a/.env.slic b/.env.slic index 521e106..73c5cc7 100644 --- a/.env.slic +++ b/.env.slic @@ -58,6 +58,12 @@ SLIC_INTERACTIVE=1 # The PHP version to run in the slic container. Only use single dot notation, e.g. 7.4, not 7.4.35 SLIC_PHP_VERSION=7.4 +# Playwright tests and PHP hooks run in slic; browser fixtures use a separate server. +# The browser image is selected from the installed Playwright CLI, not package.json's range. +# Override the browser image. It must include browsers matching the installed CLI and Node.js. +# SLIC_PLAYWRIGHT_IMAGE=mcr.microsoft.com/playwright:v1.60.0-noble +# SLIC_PLAYWRIGHT_CONTAINER_SHM_SIZE=1g + # XDebug configuration parameters, will apply to the `cli`, `wordpress` and `codeception` services. # =============================== # The IDE key used to identify connection requests coming from the services. diff --git a/README.md b/README.md index d289131..2a501b8 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ The slic (**S**tellarWP **L**ocal **I**nteractive **C**ontainers) CLI command pr * [Preparing your project](#preparing-your-project) * [Adding tests](#adding-tests) * [Running tests](#running-tests) + * [Running Playwright tests](#running-playwright-tests) * [Advanced topics](#advanced-topics) * [Defaults for your project with `slic.json`](/docs/slicjson.md) * [Managing PHP Versions](#managing-php-versions) @@ -252,6 +253,17 @@ slic shell > cr wpunit ``` +### Running Playwright tests + +For projects with a Playwright suite, prepare your WordPress test site and run: + +```bash +slic playwright test +``` + +See the [Playwright guide](/docs/playwright.md) for dependency installation, test +examples, and how to adapt existing browser setup hooks to work with Slic. + ## Advanced topics ### Managing PHP Versions diff --git a/changelog.md b/changelog.md index 2a7ccf8..215c321 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +# [2.5.0] - TBD + +- Breaking Change - Playwright suites that launch browsers directly must use built-in fixtures or explicitly connect to the remote browser. Browser-facing `localhost` URLs and `download.path()` calls may also need changes. See the [migration guidance](docs/playwright.md#update-setup-code-that-launches-a-browser-directly) before upgrading. + +- Added - Internal process helpers in [src/process-argv.php](src/process-argv.php) for executing commands as arrays of literal, unquoted arguments. Use `process_argv()` to capture stdout and receive a `status`/`stdout` result array, or `process_argv_realtime()` to stream output and receive an exit status. These helpers preserve argument boundaries without shell expansion and support interruptible child-process execution. +- Added - Internal Compose helpers in [src/docker-argv.php](src/docker-argv.php): `docker_compose_argv()` and `docker_compose_argv_realtime()` accept command arguments followed by optional Compose options, applying Slic's environment and terminal settings. Use these helpers for new commands built from raw arguments, and `slic_stack_argv()` for stack file options. Pass values without shell quoting or escaping; existing helpers retain their current calling conventions. +- Changed - `slic playwright test` keeps tests and PHP hooks in the slic container and connects browser fixtures to a temporary server in the Microsoft Playwright image. The image follows the installed CLI version, so dependency ranges are supported. Each invocation owns and cleans up its browser container. +- Changed - `slic playwright install`, including `install chromium --with-deps`, no longer installs browsers or system packages. See [the Playwright guide](docs/playwright.md) for usage examples and help adapting existing suites. +- Fixed - Playwright browser readiness checks, logs, and cleanup preserve the configured Docker executable and context when using a Docker Compose command prefix. + # [2.4.2] - 2026-09-08 - Fixed - The PHP 7.3, 7.4 and 8.0 images install system packages from the Debian archive, so they can be built and `slic playwright install` works again now that Debian 11 has left LTS. diff --git a/docs/playwright.md b/docs/playwright.md new file mode 100644 index 0000000..28c56db --- /dev/null +++ b/docs/playwright.md @@ -0,0 +1,174 @@ +# Running Playwright tests + +Slic runs your tests with the Playwright version installed in your project. Browsers +run in a separate container with their dependencies already installed, so you can +skip `playwright install`. Your tests and setup hooks still have access to PHP, +WP-CLI, and Composer inside Slic. + +## Get started + +Prepare your WordPress test site as usual, then install your project's dependencies +and run the tests: + +```bash +slic npm ci +slic playwright test +``` + +You can pass the usual test filters: + +```bash +slic playwright test tests/e2e/login.spec.ts +slic playwright test --grep 'can log in' +``` + +Keep your dependency lockfile committed and use `npm ci` in CI. Slic chooses the +browser image to match your installed Playwright version; you don't need to pin a +second version in Slic. The first run may take longer while Docker pulls the image. + +Existing preparation scripts can keep `slic playwright install chromium --with-deps`. +Slic skips that installation because the browsers are already available. + +## Use Playwright's built-in fixtures + +Use the `page`, `context`, or `browser` fixtures in your tests. They connect to the +browser automatically, and Playwright manages their cleanup. + +> [!WARNING] +> Avoid calling `chromium.launch()` directly in tests or setup hooks. It launches a +> local browser and does not use Slic's remote browser connection. Use Playwright's +> built-in fixtures, or [update your existing setup code](#update-setup-code-that-launches-a-browser-directly) +> to connect explicitly. The same applies to `firefox.launch()` and `webkit.launch()`. + +Set the WordPress URL in `playwright.config.ts`: + +```ts +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + use: { + baseURL: 'http://wordpress.test', + }, +}); +``` + +Then use relative URLs and Playwright's locators and assertions: + +```ts +import { expect, test } from '@playwright/test'; + +test('shows the WordPress login form', async ({ page }) => { + await page.goto('/wp-login.php'); + + await expect(page.getByLabel('Username or Email Address')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Log In', exact: true })).toBeVisible(); +}); +``` + +Use `http://wordpress.test` for WordPress inside Slic. From the browser, +`localhost` refers to the browser's own container. If your tests start another +web server inside Slic, bind it to `0.0.0.0` and use `http://slic:` as its +browser-facing URL. + +## Keep PHP and WP-CLI setup in your hooks + +Your hooks can continue calling PHP, WP-CLI, and Composer. For example, this hook +checks that a plugin is active before running the tests: + +```ts +import { execFileSync } from 'node:child_process'; +import { test } from '@playwright/test'; + +test.beforeAll(() => { + execFileSync('wp', ['plugin', 'is-active', 'my-plugin'], { + stdio: 'inherit', + }); +}); +``` + +Replace `my-plugin` with your plugin's slug. Pass arguments as an array with +`execFileSync` so values containing spaces or shell characters remain intact. +A failed command fails the hook, and `stdio: 'inherit'` shows its output in the test log. + +If hooks reset or import a shared database, avoid running those tests in parallel. +Separate browser sessions still use the same WordPress database. + +## Update setup code that launches a browser directly + +For new browser-based setup, prefer a +[Playwright setup project](https://playwright.dev/docs/test-global-setup-teardown#option-1-project-dependencies). +It can use the same built-in fixtures as your tests, with automatic browser cleanup +and setup results in the test report. + +If you already use `chromium.launch()` in a global setup file, change that call to +connect when Slic supplies an endpoint: + +```ts +import { chromium } from '@playwright/test'; + +export default async function globalSetup() { + const endpoint = process.env.PW_TEST_CONNECT_WS_ENDPOINT; + const browser = endpoint + ? await chromium.connect(endpoint) + : await chromium.launch(); + + try { + const context = await browser.newContext({ + baseURL: process.env.WP_BASE_URL ?? 'http://wordpress.test', + }); + const page = await context.newPage(); + + await page.goto('/wp-login.php'); + // Add your project's login or other browser setup here. + } finally { + await browser.close(); + } +} +``` + +`PW_TEST_CONNECT_WS_ENDPOINT` is Playwright's own environment variable. Slic sets +it automatically to the browser server's WebSocket address. You don't need to add +it to your `.env` file. The fallback lets the same setup run outside Slic when local +browsers are installed. + +Apply the same pattern to direct `firefox.launch()` or `webkit.launch()` calls. +`launchPersistentContext()` has no equivalent in this connection pattern; code +that depends on a persistent browser profile needs a separate migration. + +## Save downloads to the test output directory + +Use `download.saveAs()` to copy a download to your test output. Register the download +listener before clicking, so you don't miss the event: + +```ts +import { test } from '@playwright/test'; + +test('exports a report', async ({ page }, testInfo) => { + // Replace this route and button name with your project's export screen. + await page.goto('/reports'); + + const downloadPromise = page.waitForEvent('download'); + + await page.getByRole('button', { name: 'Export report' }).click(); + + const download = await downloadPromise; + + await download.saveAs(testInfo.outputPath('report.csv')); +}); +``` + +`testInfo.outputPath()` keeps files separate for each test. Avoid `download.path()`, +which is unavailable with remote browsers. See Playwright's +[download guide](https://playwright.dev/docs/downloads) for more examples. + +## Before switching an existing suite + +Check for direct browser launches, browser-facing `localhost` URLs, and +`download.path()` calls, then run the suite against your prepared WordPress site. +If you rely on custom browser executables, Chrome/Edge channels, or browser launch +arguments, verify those separately; the remote browser may not support your settings. + +This prototype supports `slic playwright test`. Headed and UI debugging have not +been validated, and commands such as `codegen`, `open`, and `screenshot` don't use +the remote browser connection. Your Playwright package must also support the Node.js +version provided by Slic. diff --git a/skills/slic/references/slic-commands.md b/skills/slic/references/slic-commands.md index 25c09f0..86999a5 100644 --- a/skills/slic/references/slic-commands.md +++ b/skills/slic/references/slic-commands.md @@ -123,10 +123,9 @@ Inside the slic shell, shorthand commands are available: Runs Playwright commands in the stack for browser-based testing. -> **Note:** Available since slic 2.x. Requires Playwright to be installed in the target project (`slic playwright install`). +Tests and PHP hooks run in the `slic` service. Browser fixtures connect to a temporary server in `mcr.microsoft.com/playwright`, selected from the installed Playwright CLI version. Dependency ranges in `package.json` are supported. `SLIC_PLAYWRIGHT_IMAGE` can override the browser image; its browsers must match the installed CLI. `slic playwright install` (including `install chromium --with-deps`) is unnecessary and exits successfully. Explicit `browserType.launch()` calls still launch locally. See [the remote browser guide](../../../docs/playwright.md) for compatibility details. ```bash -slic playwright install # install Playwright + Chromium slic playwright test # run all Playwright tests slic playwright test tests/e2e/my-test.spec.ts # run a specific test file ``` diff --git a/slic-stack.yml b/slic-stack.yml index df00d17..40b98c8 100644 --- a/slic-stack.yml +++ b/slic-stack.yml @@ -21,7 +21,6 @@ services: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root} healthcheck: test: ${SLIC_DB_HEALTHCHECK:-healthcheck.sh --connect --innodb_initialized} - start_period: 5s interval: 1s timeout: 3s retries: 30 @@ -36,7 +35,6 @@ services: - "${SLIC_REDIS_LOCALHOST_PORT:-8379}:6379" healthcheck: # It should reply PONG to PING test: redis-cli ping | grep PONG - start_period: 2s interval: 1s timeout: 3s retries: 30 @@ -117,7 +115,6 @@ services: - ./containers/wordpress/php.ini:/usr/local/etc/php/conf.d/zz-docker.ini healthcheck: # Apache service should be running correctly. test: service apache2 status - start_period: 5s interval: 1s timeout: 3s retries: 30 @@ -134,12 +131,36 @@ services: condition: service_healthy healthcheck: # It should reply with a 200 status code to a request to the status endpoint. test: curl -f http://localhost:4444/wd/hub/status - start_period: 5s interval: 1s timeout: 3s retries: 30 shm_size: "${SLIC_CHROME_CONTAINER_SHM_SIZE:-512m}" + # Browser server only. Test code and PHP hooks still execute in slic. + # The command selects the image from the installed CLI and starts a uniquely named + # one-off container. No ports are published on the host. + playwright: + image: ${SLIC_PLAYWRIGHT_IMAGE:-mcr.microsoft.com/playwright} + profiles: + - playwright + init: true + networks: + - slic + user: "${SLIC_UID:-}:${SLIC_GID:-}" + environment: + HOME: /tmp + volumes: + # Reuse the installed CLI to run the server, with no npm install at startup. + - ${SLIC_WP_DIR}:/var/www/html + - ${SLIC_PLUGINS_DIR}:${SLIC_WP_CONTENT_CONTAINER_DIR}/plugins + - ${SLIC_THEMES_DIR}:${SLIC_WP_CONTENT_CONTAINER_DIR}/themes + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', r => {r.resume(); process.exit(r.statusCode === 200 ? 0 : 1)}).on('error', () => process.exit(1))"] + interval: 1s + timeout: 2s + retries: 20 + shm_size: "${SLIC_PLAYWRIGHT_CONTAINER_SHM_SIZE:-1g}" + slic: image: ghcr.io/stellarwp/slic-php${SLIC_PHP_VERSION}:${SLIC_VERSION} networks: diff --git a/slic.php b/slic.php index 098a5aa..fc05577 100644 --- a/slic.php +++ b/slic.php @@ -27,6 +27,9 @@ require_once __DIR__ . '/src/scaffold.php'; require_once __DIR__ . '/src/slic.php'; require_once __DIR__ . '/src/docker.php'; +require_once __DIR__ . '/src/process-argv.php'; +require_once __DIR__ . '/src/docker-argv.php'; +require_once __DIR__ . '/src/playwright.php'; require_once __DIR__ . '/src/notify.php'; require_once __DIR__ . '/src/plugins.php'; require_once __DIR__ . '/src/themes.php'; @@ -54,7 +57,7 @@ ] ); $cli_name = 'slic'; -const CLI_VERSION = '2.4.2'; +const CLI_VERSION = '2.5.0'; // If the run-time option `-q`, for "quiet", is specified, then do not print the header. if ( in_array( '-q', $argv, true ) || ( in_array( 'exec', $argv, true ) && ! in_array( 'help', $argv, true ) ) ) { diff --git a/src/commands/playwright.php b/src/commands/playwright.php index 3fc9734..a624937 100644 --- a/src/commands/playwright.php +++ b/src/commands/playwright.php @@ -13,7 +13,12 @@ $help = <<< HELP SUMMARY: - This command requires a use target set using the use command. + Runs Playwright commands in the stack. This command requires a use target set using the use command. + + Tests and PHP hooks run in slic; browser fixtures connect to a temporary Playwright server. + The browser image matches the installed Playwright CLI version. Dependency ranges in package.json are supported. + Set SLIC_PLAYWRIGHT_IMAGE to override the browser image; its browsers must match the installed CLI. + Browser installation is unnecessary. Explicit browser.launch() calls still launch locally; see docs/playwright.md. USAGE: @@ -21,9 +26,6 @@ EXAMPLES: - {$cli_name} playwright install - Install Playwright dependencies in the current use target. - {$cli_name} playwright test Run all Playwright tests following the Playwright configuration in the current use target. @@ -40,48 +42,4 @@ $using = slic_target_or_fail(); echo light_cyan( "Using {$using}" . PHP_EOL ); -ensure_service_running( 'slic' ); - -setup_id(); -$playwright_args = $args( '...' ); -$is_install_command = $playwright_args[0] === 'install'; - -if ( $is_install_command ) { - // Install commands will need to run as root. - $user = '0:0'; -} else { - // Other commands will run as the current user. - $user = sprintf( '"%s:%s"', getenv( 'SLIC_UID' ), getenv( 'SLIC_GID' ) ); -} - -if ( $playwright_args === ['install'] ) { - // It's exactly the `playwright install` command and nothing more. - $command = [ - 'exec', - '--user', - '0:0', - '--workdir', - escapeshellarg( get_project_container_path() ), - 'slic', - 'node_modules/.bin/playwright install chromium --with-deps', - ]; -} else { - $command = array_merge( [ - 'exec', - '--user', - $user, - '--workdir', - escapeshellarg( get_project_container_path() ), - 'slic', - 'node_modules/.bin/playwright', - ], $playwright_args ); -} - -$status = slic_realtime()( $command ); - -// If there is a status other than 0, we have an error. Bail. -if ( $status ) { - exit( $status ); -} - -exit( $status ); +exit( run_playwright( $args( '...' ) ) ); diff --git a/src/docker-argv.php b/src/docker-argv.php new file mode 100644 index 0000000..a903697 --- /dev/null +++ b/src/docker-argv.php @@ -0,0 +1,238 @@ + [ '-T' ], 'run' => [ '-T' ], 'logs' => [ '--no-color' ] ]; + array_splice( $arguments, 1, 0, $flags[ $subcommand ] ?? [] ); + } + + $command = array_merge( $prefix, $options, $arguments ); + + if ( ! $realtime ) { + return process_argv( $command, $environment ); + } + + // Preserve the existing Compose exec terminal workaround through stdin's descriptor. + $stdin_null = $subcommand === 'exec' && is_tty_supported(); + + return process_argv_realtime( $command, $environment, $stdin_null ); +} + +/** + * Read the configured Compose executable and optional prefix arguments. + * + * The legacy setting is a string. Split quoted words once at this boundary, without + * shell expansion. Executable paths, docker-compose and "docker --context ... compose" + * are supported. Shell operators require an executable wrapper script instead. + * + * @return string[] The executable and its prefix arguments. + */ +function docker_compose_binary_argv(): array { + $command = trim( docker_compose_bin() ); + + // An existing executable path may contain spaces without being shell-quoted. + // Only parse words when the setting is not itself a file path. + if ( is_file( $command ) ) { + return [ $command ]; + } + + return docker_compose_prefix_argv( $command, DIRECTORY_SEPARATOR === '\\' ); +} + +/** + * Use the same Docker executable and global options for container management. + * + * @return string[] The Docker executable and its prefix arguments. + */ +function docker_binary_argv(): array { + $command = docker_compose_binary_argv(); + + // For "docker --context remote compose", remove only the Compose subcommand. + // Inspect, logs and removal must address the daemon that created the container. + if ( count( $command ) > 1 && end( $command ) === 'compose' ) { + array_pop( $command ); + + return $command; + } + + // Standalone docker-compose and Compose-only wrappers cannot run Docker commands. + // Use Docker with the inherited environment, including DOCKER_HOST/DOCKER_CONTEXT. + // Wrappers must share those settings; options hidden inside a script cannot be inferred. + return [ 'docker' ]; +} + +/** + * Split a configured command prefix, preserving platform-specific path separators. + * + * @param string $command The executable and optional quoted prefix arguments. + * @param bool $windows Whether backslashes are Windows path separators. + * + * @return string[] Literal prefix arguments, with quoting removed. + */ +function docker_compose_prefix_argv( string $command, bool $windows ): array { + // Track whether a word has started separately from its contents: quoted empty + // arguments must survive, while whitespace between words adds no arguments. + $arguments = []; + $word = ''; + $quote = null; + $started = false; + $length = strlen( $command ); + + for ( $i = 0; $i < $length; $i++ ) { + $character = $command[ $i ]; + $next = $command[ $i + 1 ] ?? ''; + $escapable = $quote === '"' ? '\\"$`' : "\\\"' \t"; + + // Preserve Windows path separators, including UNC prefixes. On POSIX, only + // consume the supported escapes; single-quoted contents stay literal below. + if ( $windows ) { + $escapable = '"'; + } + + if ( $character === '\\' && $quote !== "'" && $next !== '' && strpos( $escapable, $next ) !== false ) { + $word .= $next; + $started = true; + $i++; + + continue; + } + + if ( $quote !== null ) { + if ( $character === $quote ) { + $quote = null; + } else { + $word .= $character; + } + + continue; + } + + if ( $character === '"' || $character === "'" ) { + $quote = $character; + $started = true; + + continue; + } + + if ( strpos( " \t\r\n", $character ) !== false ) { + if ( $started ) { + $arguments[] = $word; + $word = ''; + $started = false; + } + + continue; + } + + if ( strpos( '|&;<>()`', $character ) !== false ) { + // No shell evaluates this prefix. Reject unquoted operators instead of + // silently passing a pipeline or redirection to Docker as literal arguments. + throw new \InvalidArgumentException( 'SLIC_DOCKER_COMPOSE_BIN cannot contain shell operators with the argv runner. Use an executable wrapper script.' ); + } + + $word .= $character; + $started = true; + } + + if ( $quote !== null ) { + throw new \InvalidArgumentException( 'SLIC_DOCKER_COMPOSE_BIN contains an unmatched quote.' ); + } + + if ( $started ) { + $arguments[] = $word; + } + + if ( ! $arguments || $arguments[0] === '' ) { + throw new \InvalidArgumentException( 'SLIC_DOCKER_COMPOSE_BIN requires an executable.' ); + } + + return $arguments; +} diff --git a/src/playwright.php b/src/playwright.php new file mode 100644 index 0000000..f617ccb --- /dev/null +++ b/src/playwright.php @@ -0,0 +1,178 @@ +' . getenv( 'SLIC_PLAYWRIGHT_IMAGE' ) . ' (tests run in slic)' . PHP_EOL ); + + // Each run owns only its own server, including concurrent runs using different versions. + $name = 'slic-playwright-' . bin2hex( random_bytes( 8 ) ); + $cleaned = false; + // Both finally and shutdown can reach this callback. Remove the container once, + // including when a signal exits PHP before the finally block can run. + $cleanup = static function () use ( $name, &$cleaned ) { + if ( ! $cleaned ) { + $cleaned = true; + process_argv( array_merge( docker_binary_argv(), [ 'rm', '--force', $name ] ) ); + } + }; + register_shutdown_function( $cleanup ); + $signals = []; + $async_signals = null; + + // When PCNTL is available, handle Ctrl+C and termination signals while waiting on + // child processes. Exiting runs the registered browser cleanup and returns the + // conventional 128 + signal status. Save the existing handlers and async mode so + // the finally block can restore them when this command returns normally. + if ( function_exists( 'pcntl_async_signals' ) && function_exists( 'pcntl_signal_get_handler' ) ) { + $async_signals = pcntl_async_signals( true ); + + foreach ( [ SIGINT, SIGTERM ] as $signal ) { + $signals[ $signal ] = pcntl_signal_get_handler( $signal ); + pcntl_signal( $signal, static function ( int $received ) { + exit( 128 + $received ); + } ); + } + } + + try { + // Use the project's mounted CLI for the server as well as the client. + // Keep the container until cleanup so startup failures still have readable logs. + $status = docker_compose_argv_realtime( [ + 'run', '--detach', '--no-deps', '--name', $name, + '--workdir', get_project_container_path(), + 'playwright', 'node_modules/.bin/playwright', 'run-server', '--host', '0.0.0.0', '--port', '3000', + ], slic_stack_argv() ); + + if ( $status !== 0 ) { + return $status; + } + + if ( ! wait_for_playwright_server( $name ) ) { + echo magenta( "Playwright browser server exited or did not become ready within 30 seconds. Server logs follow:\n" ); + process_argv_realtime( array_merge( docker_binary_argv(), [ 'logs', $name ] ) ); + + return 1; + } + + return docker_compose_argv_realtime( playwright_exec_command( $arguments, 'ws://' . $name . ':3000/' ), slic_stack_argv() ); + } finally { + $cleanup(); + + foreach ( $signals as $signal => $handler ) { + pcntl_signal( $signal, $handler ); + } + + if ( $async_signals !== null ) { + pcntl_async_signals( $async_signals ); + } + } +} diff --git a/src/process-argv.php b/src/process-argv.php new file mode 100644 index 0000000..aaa4a6d --- /dev/null +++ b/src/process-argv.php @@ -0,0 +1,134 @@ + $environment Overrides for the inherited child environment. + * + * @return array{status: int, stdout: string} The exit status and unmodified stdout. + */ +function process_argv( array $arguments, array $environment = [] ): array { + // A file cannot fill a pipe buffer while the child is running, including on Windows. + $output = tmpfile(); + + if ( $output === false ) { + throw new \RuntimeException( 'Could not create a process output buffer.' ); + } + + try { + // Captured commands get EOF on stdin and leave errors visible on stderr. + // Rewind after the child exits to read stdout from the beginning of the file. + $status = run_process_argv( $arguments, [ [ 'file', process_null_device(), 'r' ], $output, STDERR ], $environment ); + rewind( $output ); + $text = stream_get_contents( $output ); + } finally { + fclose( $output ); + } + + return [ 'status' => $status, 'stdout' => $text ]; +} + +/** + * Run raw arguments with live output and interruptible child supervision. + * + * @param string[] $arguments Executable followed by literal, unquoted arguments. + * @param array $environment Overrides for the inherited child environment. + * @param bool $stdin_null Whether to close input instead of inheriting the terminal. + * + * @return int The child exit status. + */ +function process_argv_realtime( array $arguments, array $environment = [], bool $stdin_null = false ): int { + setup_terminal(); + echo PHP_EOL; + $input = $stdin_null ? [ 'file', process_null_device(), 'r' ] : STDIN; + + return run_process_argv( $arguments, [ $input, STDOUT, STDERR ], $environment ); +} + +/** + * Return the platform's null device for descriptor-based input redirection. + * + * @return string The null device path. + */ +function process_null_device(): string { + return DIRECTORY_SEPARATOR === '\\' ? 'NUL' : '/dev/null'; +} + +/** + * Execute an argument vector directly, without parsing or escaping a shell command. + * + * Polling allows PHP signal handlers to run while the child is active. Shutdown + * terminates the local child; remote processes started by Docker have their own lifecycle. + * + * @param string[] $arguments Executable followed by literal, unquoted arguments. + * @param array $descriptors The proc_open descriptors for stdin, stdout and stderr. + * @param array $environment Overrides for the inherited child environment. + * + * @return int The exit status, or 128 plus the terminating signal. + */ +function run_process_argv( array $arguments, array $descriptors, array $environment = [] ): int { + if ( ! $arguments || ! isset( $arguments[0] ) || $arguments[0] === '' ) { + throw new \InvalidArgumentException( 'A process requires an executable.' ); + } + + foreach ( $arguments as $argument ) { + if ( ! is_string( $argument ) || strpos( $argument, "\0" ) !== false ) { + throw new \InvalidArgumentException( 'Process arguments must be strings without null bytes.' ); + } + } + + debug( 'Executing arguments: ' . json_encode( $arguments ) . PHP_EOL ); + // An explicit environment replaces inheritance in proc_open, so merge overrides + // with the current environment to retain PATH and other caller settings. + // Passing an argument array preserves boundaries without a shell command string. + $env = $environment ? array_replace( getenv(), $environment ) : null; + $child = proc_open( array_values( $arguments ), $descriptors, $pipes, null, $env ); + + if ( ! is_resource( $child ) ) { + return 1; + } + + // exit() in a signal handler bypasses finally. Keep a shutdown fallback, with a + // reference so normal completion can clear the handle and make this a no-op. + register_shutdown_function( static function () use ( &$child ) { + if ( is_resource( $child ) ) { + proc_terminate( $child ); + } + } ); + + try { + // Poll instead of blocking in proc_close so PHP can dispatch signal handlers + // promptly. The short sleep avoids busy-waiting while the child is running. + do { + $state = proc_get_status( $child ); + + if ( $state === false ) { + throw new \RuntimeException( 'Could not read the child process status.' ); + } + + if ( $state['running'] ) { + usleep( 20000 ); + } + } while ( $state['running'] ); + + $closed = proc_close( $child ); + $child = null; + + // Older PHP versions may return -1 from proc_close after proc_get_status has + // collected the exit code. Prefer that saved code, then fall back to the close + // result or the conventional status for a process terminated by a signal. + return $state['exitcode'] >= 0 ? $state['exitcode'] : ( $closed >= 0 ? $closed : 128 + $state['termsig'] ); + } finally { + if ( is_resource( $child ) ) { + proc_terminate( $child ); + proc_close( $child ); + $child = null; + } + } +} diff --git a/src/services.php b/src/services.php index 8439e8e..59ccdb5 100644 --- a/src/services.php +++ b/src/services.php @@ -61,7 +61,17 @@ function services_schema() { * @return array The services in the stack. */ function get_services() { - $services = services_schema(); + /* + * Services in a profile, like `playwright`, are only started by the commands that use them. + * Docker compose enables a service's profile when the service is named on the command line, and + * the callers of this function start each service by name, so they have to be left out here. + */ + $services = array_filter( + services_schema(), + static function ( $service ) { + return empty( $service['profiles'] ); + } + ); $services = array_keys( $services ); sort( $services );