diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js index aa7fa54..6210335 100644 --- a/bin/lib/handlers.js +++ b/bin/lib/handlers.js @@ -159,7 +159,11 @@ class ContextHandler extends BaseHandler { } async handleTracing(command, method) { - const context = this.contexts.get(command.contextId)?.context ?? this.apiContexts.get(command.contextId); + // API requests share the browser context ID, but must target request.tracing. + const browserContext = this.contexts.get(command.contextId)?.context; + const context = browserContext + ? (command.apiRequest ? browserContext.request : browserContext) + : this.apiContexts.get(command.contextId); if (!context) { throw new Error(`Tracing context not found: ${command.contextId}`); diff --git a/src/API/APIRequestContext.php b/src/API/APIRequestContext.php index aea6f53..e1bba0f 100644 --- a/src/API/APIRequestContext.php +++ b/src/API/APIRequestContext.php @@ -157,7 +157,7 @@ public function storageState(?string $path = null): array public function tracing(): TracingInterface { - return new Tracing($this->transport, $this->contextId); + return new Tracing($this->transport, $this->contextId, apiRequest: true); } public function dispose(): void diff --git a/src/Tracing/Tracing.php b/src/Tracing/Tracing.php index 10dd07e..774fa53 100644 --- a/src/Tracing/Tracing.php +++ b/src/Tracing/Tracing.php @@ -26,13 +26,14 @@ final class Tracing implements TracingInterface public function __construct( private readonly TransportInterface $transport, private readonly string $contextId, + private readonly bool $apiRequest = false, ) { } public function start(array|StartOptions $options = []): void { $options = StartOptions::from($options); - $this->transport->send([ + $this->send([ 'action' => 'tracingStart', 'contextId' => $this->contextId, 'options' => $options->toArray(), @@ -42,7 +43,7 @@ public function start(array|StartOptions $options = []): void public function startChunk(array|StartChunkOptions $options = []): void { $options = StartChunkOptions::from($options); - $this->transport->send([ + $this->send([ 'action' => 'tracingStartChunk', 'contextId' => $this->contextId, 'options' => $options->toArray(), @@ -52,7 +53,7 @@ public function startChunk(array|StartChunkOptions $options = []): void public function stop(array|StopOptions $options = []): void { $options = StopOptions::from($options); - $this->transport->send([ + $this->send([ 'action' => 'tracingStop', 'contextId' => $this->contextId, 'options' => $options->toArray(), @@ -62,7 +63,7 @@ public function stop(array|StopOptions $options = []): void public function stopChunk(array|StopChunkOptions $options = []): void { $options = StopChunkOptions::from($options); - $this->transport->send([ + $this->send([ 'action' => 'tracingStopChunk', 'contextId' => $this->contextId, 'options' => $options->toArray(), @@ -72,7 +73,7 @@ public function stopChunk(array|StopChunkOptions $options = []): void public function startHar(string $path, array|StartHarOptions $options = []): void { $options = StartHarOptions::from($options); - $this->transport->send([ + $this->send([ 'action' => 'tracingStartHar', 'contextId' => $this->contextId, 'path' => $path, @@ -82,7 +83,7 @@ public function startHar(string $path, array|StartHarOptions $options = []): voi public function stopHar(): void { - $this->transport->send([ + $this->send([ 'action' => 'tracingStopHar', 'contextId' => $this->contextId, ]); @@ -99,14 +100,26 @@ public function group(string $name, ?string $location = null): void $payload['location'] = $location; } - $this->transport->send($payload); + $this->send($payload); } public function groupEnd(): void { - $this->transport->send([ + $this->send([ 'action' => 'tracingGroupEnd', 'contextId' => $this->contextId, ]); } + + /** + * @param array $message + */ + private function send(array $message): void + { + if ($this->apiRequest) { + $message['apiRequest'] = true; + } + + $this->transport->send($message); + } } diff --git a/tests/Functional/Tracing/TracingApiTest.php b/tests/Functional/Tracing/TracingApiTest.php index f7ae6c3..ae9e3a1 100644 --- a/tests/Functional/Tracing/TracingApiTest.php +++ b/tests/Functional/Tracing/TracingApiTest.php @@ -15,12 +15,16 @@ namespace Playwright\Tests\Functional\Tracing; use PHPUnit\Framework\Attributes\CoversClass; +use Playwright\API\APIRequest; use Playwright\API\APIRequestContext; use Playwright\Browser\BrowserContext; +use Playwright\Configuration\PlaywrightConfig; use Playwright\Regex; use Playwright\Tests\Functional\FunctionalTestCase; use Playwright\Tracing\Options\StartHarOptions; use Playwright\Tracing\Tracing; +use Playwright\Transport\TransportFactory; +use Psr\Log\NullLogger; #[CoversClass(Tracing::class)] #[CoversClass(StartHarOptions::class)] @@ -155,11 +159,87 @@ public function testHarRecordingIsAvailableOnTheApiRequestContext(): void $this->assertStringContainsString('/index.html', json_encode($entries, \JSON_THROW_ON_ERROR)); } + public function testStandaloneApiHarRecordingCapturesRequests(): void + { + $transport = (new TransportFactory())->create(new PlaywrightConfig(), new NullLogger()); + $transport->connect(); + + try { + $request = (new APIRequest($transport))->newContext(); + try { + $harPath = $this->tempDir.'/standalone.har'; + $tracing = $request->tracing(); + $tracing->startHar($harPath); + $response = $request->get($this->getBaseUrl().'/index.html'); + $tracing->stopHar(); + + $this->assertSame(200, $response->status()); + $this->assertStringContainsString('/index.html', json_encode($this->readHarEntries($harPath), \JSON_THROW_ON_ERROR)); + } finally { + $request->dispose(); + } + } finally { + $transport->disconnect(); + } + } + + public function testBrowserAndApiHarRecordingsKeepTheirRequests(): void + { + $browserHar = $this->tempDir.'/browser.har'; + $apiHar = $this->tempDir.'/request.har'; + $browserTracing = $this->context->tracing(); + $request = $this->context->request(); + $apiTracing = $request->tracing(); + + $browserTracing->startHar($browserHar); + $this->goto('/index.html'); + $browserTracing->stopHar(); + + $apiTracing->startHar($apiHar); + $request->get($this->getBaseUrl().'/api/echo'); + $apiTracing->stopHar(); + + $this->assertContains($this->getBaseUrl().'/api/echo', array_column(array_column($this->readHarEntries($apiHar), 'request'), 'url')); + $this->assertContains($this->getBaseUrl().'/index.html', array_column(array_column($this->readHarEntries($browserHar), 'request'), 'url')); + } + + public function testApiTraceGroupsAndChunksKeepTheirEvents(): void + { + $browserPath = $this->tempDir.'/browser-trace.zip'; + $apiPath = $this->tempDir.'/api-chunk.zip'; + $browserTracing = $this->context->tracing(); + $request = $this->context->request(); + $apiTracing = $request->tracing(); + + $apiTracing->start(); + $apiTracing->startChunk(['title' => 'API chunk']); + $apiTracing->group('api-only-step'); + $request->get($this->getBaseUrl().'/api/echo'); + $apiTracing->groupEnd(); + $apiTracing->stopChunk(['path' => $apiPath]); + $apiTracing->stop(); + + $browserTracing->start(); + $browserTracing->group('browser-only-step'); + $this->goto('/index.html'); + $browserTracing->groupEnd(); + $browserTracing->stop(['path' => $browserPath]); + + $apiEvents = $this->readTraceEvents($apiPath); + $browserEvents = $this->readTraceEvents($browserPath); + $this->assertStringContainsString('api-only-step', $apiEvents); + $this->assertStringContainsString('/api/echo', $apiEvents); + $this->assertStringNotContainsString('browser-only-step', $apiEvents); + $this->assertStringContainsString('browser-only-step', $browserEvents); + $this->assertStringNotContainsString('api-only-step', $browserEvents); + } + /** * @return array */ private function readHarEntries(string $harPath): array { + $this->assertFileExists($harPath); $raw = file_get_contents($harPath); $this->assertIsString($raw); @@ -175,6 +255,7 @@ private function readHarEntries(string $harPath): array private function readTraceEvents(string $zipPath): string { + $this->assertFileExists($zipPath); $zip = new \ZipArchive(); $this->assertTrue($zip->open($zipPath)); diff --git a/tests/Unit/API/APIRequestContextTest.php b/tests/Unit/API/APIRequestContextTest.php index c3bbba4..13a93d6 100644 --- a/tests/Unit/API/APIRequestContextTest.php +++ b/tests/Unit/API/APIRequestContextTest.php @@ -225,7 +225,7 @@ public function testTracingReturnsATracingInstance(): void $this->assertInstanceOf(TracingInterface::class, $this->context->tracing()); } - public function testTracingTargetsTheSameContext(): void + public function testTracingTargetsTheApiRequestContext(): void { $this->transport->expects($this->once()) ->method('send') @@ -234,6 +234,7 @@ public function testTracingTargetsTheSameContext(): void 'contextId' => 'context_1', 'path' => '/tmp/api.har', 'options' => [], + 'apiRequest' => true, ]) ->willReturn([]); diff --git a/tests/Unit/Tracing/TracingTest.php b/tests/Unit/Tracing/TracingTest.php index 41cb937..19d71d2 100644 --- a/tests/Unit/Tracing/TracingTest.php +++ b/tests/Unit/Tracing/TracingTest.php @@ -15,6 +15,7 @@ namespace Playwright\Tests\Unit\Tracing; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Playwright\Tracing\Tracing; use Playwright\Transport\TransportInterface; @@ -143,4 +144,37 @@ public function testGroupEndSendsAction(): void $this->tracing->groupEnd(); } + + /** + * @param list $arguments + */ + #[DataProvider('apiTracingOperations')] + public function testEveryApiTracingOperationKeepsItsTarget(string $method, array $arguments, string $action): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with($this->callback(static fn (array $message): bool => $action === $message['action'] + && 'context_1' === $message['contextId'] + && true === ($message['apiRequest'] ?? false) + )) + ->willReturn([]); + + $tracing = new Tracing($this->transport, 'context_1', apiRequest: true); + $tracing->$method(...$arguments); + } + + /** + * @return iterable, string}> + */ + public static function apiTracingOperations(): iterable + { + yield 'start' => ['start', [], 'tracingStart']; + yield 'start chunk' => ['startChunk', [], 'tracingStartChunk']; + yield 'stop' => ['stop', [], 'tracingStop']; + yield 'stop chunk' => ['stopChunk', [], 'tracingStopChunk']; + yield 'start HAR' => ['startHar', ['/tmp/api.har'], 'tracingStartHar']; + yield 'stop HAR' => ['stopHar', [], 'tracingStopHar']; + yield 'group' => ['group', ['API call'], 'tracingGroup']; + yield 'group end' => ['groupEnd', [], 'tracingGroupEnd']; + } }