diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2667238..8039e8a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - php: [ "7.4", "8.0", "8.1", "8.2", "8.3", "8.4" ] + php: [ "7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5" ] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 528bf8e..15d14c5 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ The command writes one JSON event per line, each prefixed with `TWEAKPHP_STREAM:`: ```text +TWEAKPHP_STREAM:{"type":"started","pid":1234} TWEAKPHP_STREAM:{"type":"statement.started","index":0,"line":2,"code":"echo \"First\";"} TWEAKPHP_STREAM:{"type":"output","index":0,"data":"First"} TWEAKPHP_STREAM:{"type":"statement.completed","index":0,"queries":[]} @@ -131,6 +132,10 @@ TWEAKPHP_STREAM:{"type":"completed"} Event types: +- `started`: the client started. It is emitted as the first line, before the + project's framework bootstraps, so callers can stop the process even when + bootstrapping hangs. It includes the client process id as `pid`; `pid` is + omitted when unavailable. - `statement.started`: a statement started - `output`: the statement produced output. Presenter output also includes an `html` field containing the rendered HTML fragment; plain `echo` output only diff --git a/index.php b/index.php index 9648a80..1305824 100644 --- a/index.php +++ b/index.php @@ -63,6 +63,15 @@ echo 'TWEAKPHP_ERROR:'.json_encode($error, JSON_INVALID_UTF8_SUBSTITUTE).PHP_EOL; }; +if ($command === 'execute-stream') { + $started = ['type' => 'started']; + $pid = getmypid(); + if ($pid !== false && $pid > 0) { + $started['pid'] = (int) $pid; + } + $writeStreamEvent($started); +} + $customLoader = Cli::getArgument('loader'); try { QueryCollector::enable(filter_var(Cli::getArgument('query-log'), FILTER_VALIDATE_BOOL)); diff --git a/tests/ExecuteStreamTest.php b/tests/ExecuteStreamTest.php new file mode 100644 index 0000000..8d9fc9c --- /dev/null +++ b/tests/ExecuteStreamTest.php @@ -0,0 +1,79 @@ +runClient('execute-stream', 'echo 1;'); + + $this->assertStringStartsWith('TWEAKPHP_STREAM:', $lines[0]); + + $event = json_decode(substr($lines[0], strlen('TWEAKPHP_STREAM:')), true); + $this->assertSame(JSON_ERROR_NONE, json_last_error()); + $this->assertSame('started', $event['type']); + $this->assertArrayHasKey('pid', $event); + $this->assertIsInt($event['pid']); + $this->assertGreaterThan(0, $event['pid']); + } + + public function test_execute_stream_emits_the_full_event_sequence() + { + $lines = $this->runClient('execute-stream', 'echo 1;'); + + $types = []; + foreach ($lines as $line) { + $this->assertStringStartsWith('TWEAKPHP_STREAM:', $line); + + $event = json_decode(substr($line, strlen('TWEAKPHP_STREAM:')), true); + $this->assertSame(JSON_ERROR_NONE, json_last_error()); + $types[] = $event['type']; + } + + $this->assertSame( + ['started', 'statement.started', 'output', 'statement.completed', 'completed'], + $types + ); + } + + public function test_execute_does_not_emit_stream_events() + { + $lines = $this->runClient('execute', 'echo 1;'); + + $this->assertNotEmpty($lines); + $this->assertStringStartsWith('TWEAKPHP_RESULT:', $lines[0]); + + foreach ($lines as $line) { + $this->assertStringNotContainsString('TWEAKPHP_STREAM:', $line); + } + } + + private function runClient(string $command, string $code): array + { + $projectPath = sys_get_temp_dir().'/tweakphp-client-test-'.uniqid(); + mkdir($projectPath); + + try { + $executable = sprintf( + '%s %s %s %s %s 2>/dev/null', + escapeshellarg(PHP_BINARY), + escapeshellarg(__DIR__.'/../index.php'), + escapeshellarg($projectPath), + escapeshellarg($command), + escapeshellarg(base64_encode($code)) + ); + + exec($executable, $lines, $exitCode); + + $this->assertSame(0, $exitCode); + $this->assertNotEmpty($lines); + + return $lines; + } finally { + @rmdir($projectPath); + } + } +}