diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js index 9a3677d..aa7fa54 100644 --- a/bin/lib/handlers.js +++ b/bin/lib/handlers.js @@ -30,6 +30,76 @@ function harOptions(options) { return { ...rest, urlFilter: new RegExp(urlFilterRegex.slice(1, lastSlash), urlFilterRegex.slice(lastSlash + 1)) }; } +class APIRequestHandler extends BaseHandler { + async handle(command, method) { + const registry = CommandRegistry.create({ + newContext: () => this.newContext(command), + fetch: () => this.fetch(command), + storageState: () => this.storageState(command), + dispose: () => this.dispose(command) + }); + + const result = await ErrorHandler.safeExecute( + () => this.executeWithRegistry(registry, method), + { method, contextId: command.contextId } + ); + + return this.wrapResult(result); + } + + requestContext(contextId) { + const isolated = this.apiContexts.get(contextId); + if (isolated) return isolated; + + const browserContext = this.contexts.get(contextId)?.context; + if (browserContext) return browserContext.request; + + throw new Error(`APIRequestContext not found: ${contextId}`); + } + + async newContext(command) { + const context = await this.apiRequest.newContext(command.options || {}); + const contextId = this.generateId('api'); + this.apiContexts.set(contextId, context); + + return { contextId }; + } + + async fetch(command) { + const context = this.requestContext(command.contextId); + const response = await context.fetch(command.url, command.options || {}); + + try { + const body = await response.body(); + + return { + response: { + url: response.url(), + status: response.status(), + statusText: response.statusText(), + headers: response.headers(), + headersArray: response.headersArray(), + body: body.toString('base64'), + bodyEncoding: 'base64' + } + }; + } finally { + await response.dispose(); + } + } + + async storageState(command) { + const options = command.path ? { path: command.path } : {}; + return { storageState: await this.requestContext(command.contextId).storageState(options) }; + } + + async dispose(command) { + const context = this.requestContext(command.contextId); + await context.dispose(); + this.apiContexts.delete(command.contextId); + } +} + class ContextHandler extends BaseHandler { async handle(command, method) { // Closing a context drops it from the registry, and closing its browser drops it @@ -89,7 +159,11 @@ class ContextHandler extends BaseHandler { } async handleTracing(command, method) { - const context = this.validateResource(this.contexts, command.contextId, 'Context')?.context; + const context = this.contexts.get(command.contextId)?.context ?? this.apiContexts.get(command.contextId); + + if (!context) { + throw new Error(`Tracing context not found: ${command.contextId}`); + } const registry = CommandRegistry.create({ start: async () => { await context.tracing.start(command.options || {}); context.__phpTracingActive = true; }, @@ -1034,4 +1108,4 @@ class VideoHandler extends BaseHandler { } } -module.exports = { ContextHandler, PageHandler, LocatorHandler, InteractionHandler, FrameHandler, JSHandleHandler, SelectorsHandler, VideoHandler }; +module.exports = { APIRequestHandler, ContextHandler, PageHandler, LocatorHandler, InteractionHandler, FrameHandler, JSHandleHandler, SelectorsHandler, VideoHandler }; diff --git a/bin/playwright-server.js b/bin/playwright-server.js index f9fa1b5..c7cb190 100644 --- a/bin/playwright-server.js +++ b/bin/playwright-server.js @@ -1,6 +1,6 @@ -const {chromium, firefox, webkit} = require('playwright'); +const {chromium, firefox, webkit, request} = require('playwright'); const { logger, ErrorHandler, LspFraming, sendFramedResponse, CommandRegistry, BaseHandler } = require('./lib/core'); -const { ContextHandler, PageHandler, LocatorHandler, InteractionHandler, FrameHandler, JSHandleHandler, SelectorsHandler, VideoHandler } = require('./lib/handlers'); +const { APIRequestHandler, ContextHandler, PageHandler, LocatorHandler, InteractionHandler, FrameHandler, JSHandleHandler, SelectorsHandler, VideoHandler } = require('./lib/handlers'); const { globalCoordinator } = require('./lib/coordination'); class PlaywrightServer extends BaseHandler { @@ -23,12 +23,14 @@ class PlaywrightServer extends BaseHandler { this.navigationRedirects = new Map(); this.servers = new Map(); this.videos = new Map(); - this.counters = { browser: 0, context: 0, page: 0, response: 0, route: 0, element: 0, server: 0, video: 0 }; + this.apiContexts = new Map(); + this.counters = { api: 0, browser: 0, context: 0, page: 0, response: 0, route: 0, element: 0, server: 0, video: 0 }; } initHandlers() { const deps = { contexts: this.contexts, contextThrottling: this.contextThrottling, pages: this.pages, + apiContexts: this.apiContexts, apiRequest: request, pageContexts: this.pageContexts, dialogs: this.dialogs, elementHandles: this.elementHandles, responses: this.responses, routes: this.routes, videos: this.videos, generateId: this.generateId.bind(this), navigationRedirects: this.navigationRedirects, @@ -38,6 +40,7 @@ class PlaywrightServer extends BaseHandler { routeCounter: { value: this.counters.route }, setupPageEventListeners: this.setupPageEventListeners.bind(this) }; + this.apiRequestHandler = new APIRequestHandler(deps); this.contextHandler = new ContextHandler(deps); this.pageHandler = new PageHandler(deps); this.locatorHandler = new LocatorHandler(deps); @@ -69,6 +72,7 @@ class PlaywrightServer extends BaseHandler { const [actionPrefix, actionMethod] = command.action.split('.'); const handlerRegistry = CommandRegistry.create({ + api: () => this.apiRequestHandler.handle(command, actionMethod), context: () => this.contextHandler.handle(command, actionMethod), page: () => this.pageHandler.handle(command, actionMethod), locator: () => this.locatorHandler.handle(command, actionMethod), @@ -417,6 +421,10 @@ class PlaywrightServer extends BaseHandler { async exit() { logger.info('Shutting down server'); + for (const context of this.apiContexts.values()) { + try { await context.dispose(); } catch (e) { logger.warn('Error disposing API request context during exit', { error: e.message }); } + } + this.apiContexts.clear(); for (const browser of this.browsers.values()) { try { await browser.close(); } catch (e) { logger.warn('Error closing browser during exit', { error: e.message }); } } diff --git a/src/API/APIRequest.php b/src/API/APIRequest.php index eb3bfe4..f3f9d14 100644 --- a/src/API/APIRequest.php +++ b/src/API/APIRequest.php @@ -50,16 +50,11 @@ public function newContext(array $options = []): APIRequestContextInterface $baseURL = $options['baseURL']; } - $shareCookies = false; - if (isset($options['storageState']) && is_array($options['storageState'])) { - $shareCookies = true; - } - return new APIRequestContext( $this->transport, $contextId, $baseURL, - $shareCookies + false ); } } diff --git a/src/API/APIRequestContext.php b/src/API/APIRequestContext.php index 93eaddc..aea6f53 100644 --- a/src/API/APIRequestContext.php +++ b/src/API/APIRequestContext.php @@ -130,7 +130,7 @@ public function fetch(string $urlOrRequest, array $options = []): APIResponseInt } /** - * @return array> + * @return array */ public function storageState(?string $path = null): array { @@ -140,22 +140,15 @@ public function storageState(?string $path = null): array 'path' => $path, ]); - if (!isset($response['storageState']) || !is_array($response['storageState'])) { - return []; + $storageState = $response['storageState'] ?? null; + if (!is_array($storageState)) { + throw new ProtocolErrorException('Invalid API storageState response', 0); } $result = []; - foreach ($response['storageState'] as $item) { - if (is_array($item)) { - $validated = []; - foreach ($item as $key => $value) { - if (is_string($key)) { - $validated[$key] = $value; - } - } - if (!empty($validated)) { - $result[] = $validated; - } + foreach ($storageState as $key => $value) { + if (is_string($key)) { + $result[$key] = $value; } } diff --git a/src/API/APIRequestContextInterface.php b/src/API/APIRequestContextInterface.php index 6e03eea..48c49d3 100644 --- a/src/API/APIRequestContextInterface.php +++ b/src/API/APIRequestContextInterface.php @@ -102,7 +102,7 @@ public function fetch(string $urlOrRequest, array $options = []): APIResponseInt * The returned cookies and origins can initialize another API or browser context. * Pass a path to save the serialized state for later reuse. * - * @return array> + * @return array */ public function storageState(?string $path = null): array; diff --git a/src/API/APIResponse.php b/src/API/APIResponse.php index b613dd5..35988de 100644 --- a/src/API/APIResponse.php +++ b/src/API/APIResponse.php @@ -90,6 +90,27 @@ public function headers(): array */ public function headersArray(): array { + $headersArray = $this->data['headersArray'] ?? null; + if (is_array($headersArray)) { + $result = []; + foreach ($headersArray as $header) { + if (!is_array($header)) { + continue; + } + + $name = $header['name'] ?? null; + $value = $header['value'] ?? null; + if (!is_string($name) || !is_string($value)) { + continue; + } + + $result[$name] ??= []; + $result[$name][] = $value; + } + + return $result; + } + $headers = $this->data['headers'] ?? []; if (!is_array($headers)) { @@ -156,11 +177,7 @@ public function body(): string */ public function json(): array { - $body = $this->data['body'] ?? ''; - - if (!is_string($body)) { - return []; - } + $body = $this->decodedBody(); try { /** @var array|null $decoded */ @@ -183,10 +200,27 @@ public function json(): array } public function text(): string + { + return $this->decodedBody(); + } + + private function decodedBody(): string { $body = $this->data['body'] ?? ''; + if (!is_string($body)) { + return ''; + } + + if ('base64' !== ($this->data['bodyEncoding'] ?? null)) { + return $body; + } + + $decoded = base64_decode($body, true); + if (false === $decoded) { + throw new PlaywrightException('API response body is not valid base64.'); + } - return is_string($body) ? $body : ''; + return $decoded; } public function dispose(): void diff --git a/src/Browser/BrowserContext.php b/src/Browser/BrowserContext.php index ff00a72..5bf917d 100644 --- a/src/Browser/BrowserContext.php +++ b/src/Browser/BrowserContext.php @@ -63,6 +63,8 @@ final class BrowserContext implements BrowserContextInterface, EventDispatcherIn private ?TracingInterface $tracing = null; + private ?APIRequestContextInterface $apiRequestContext = null; + public function __construct( private readonly TransportInterface $transport, private readonly string $contextId, @@ -687,7 +689,7 @@ private function validateTransportArray(mixed $data, string $context = ''): arra public function request(): APIRequestContextInterface { - return new APIRequestContext($this->transport, $this->contextId, null); + return $this->apiRequestContext ??= new APIRequestContext($this->transport, $this->contextId, null, true); } public function setDefaultTimeout(int $timeout): void diff --git a/tests/Fixtures/server.php b/tests/Fixtures/server.php index facfff0..463933b 100644 --- a/tests/Fixtures/server.php +++ b/tests/Fixtures/server.php @@ -14,6 +14,54 @@ $uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', \PHP_URL_PATH); +if (!is_string($uri)) { + $uri = '/'; +} + +if ('/api/echo' === $uri) { + $body = file_get_contents('php://input'); + if (!is_string($body)) { + $body = ''; + } + + $json = null; + if ('' !== $body) { + $decoded = json_decode($body, true); + if (is_array($decoded)) { + $json = $decoded; + } + } + + header('Content-Type: application/json'); + echo json_encode([ + 'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', + 'body' => $body, + 'json' => $json, + 'query' => $_GET, + 'cookies' => $_COOKIE, + 'requestHeader' => $_SERVER['HTTP_X_PLAYWRIGHT_PHP'] ?? null, + ], \JSON_THROW_ON_ERROR); + + return true; +} + +if ('/api/set-cookie' === $uri) { + header('Content-Type: application/json'); + header('Set-Cookie: api-session=from-api; Path=/; SameSite=Lax'); + echo '{"cookie":"set"}'; + + return true; +} + +if (1 === preg_match('#^/api/status/(\d{3})$#', $uri, $matches)) { + $status = (int) $matches[1]; + http_response_code($status); + header('Content-Type: application/json'); + echo json_encode(['status' => $status], \JSON_THROW_ON_ERROR); + + return true; +} + // Default to index.html if root if ('/' === $uri) { $uri = '/index.html'; diff --git a/tests/Functional/API/APIRequestContextTest.php b/tests/Functional/API/APIRequestContextTest.php new file mode 100644 index 0000000..a24c431 --- /dev/null +++ b/tests/Functional/API/APIRequestContextTest.php @@ -0,0 +1,121 @@ +context->request()->get($this->getBaseUrl().'/api/echo', [ + 'headers' => ['X-Playwright-PHP' => 'api-test'], + 'params' => ['page' => '2'], + ]); + + Expect::response($response)->toBeOK()->toHaveStatus(200); + $this->assertStringContainsString('application/json', (string) $response->headerValue('content-type')); + $this->assertSame('GET', $response->json()['method']); + $this->assertSame(['page' => '2'], $response->json()['query']); + $this->assertSame('api-test', $response->json()['requestHeader']); + } + + public function testPostSerializesJsonData(): void + { + $response = $this->context->request()->post($this->getBaseUrl().'/api/echo', [ + 'data' => ['name' => 'Ada', 'active' => true], + ]); + + $this->assertSame('POST', $response->json()['method']); + $this->assertSame(['name' => 'Ada', 'active' => true], $response->json()['json']); + } + + public function testRequestSharesCookiesWithTheBrowserContext(): void + { + $this->context->addCookies([[ + 'name' => 'browser-session', + 'value' => 'from-browser', + 'url' => $this->getBaseUrl(), + ]]); + + $response = $this->context->request()->get($this->getBaseUrl().'/api/echo'); + + $this->assertSame('from-browser', $response->json()['cookies']['browser-session']); + } + + public function testResponseCookiesUpdateTheBrowserContext(): void + { + $this->context->request()->get($this->getBaseUrl().'/api/set-cookie'); + + $cookies = $this->context->cookies([$this->getBaseUrl()]); + $cookie = array_values(array_filter( + $cookies, + static fn (array $candidate): bool => 'api-session' === ($candidate['name'] ?? null) + )); + + $this->assertCount(1, $cookie); + $this->assertSame('from-api', $cookie[0]['value']); + } + + public function testStorageStateContainsCookiesFromApiResponses(): void + { + $request = $this->context->request(); + $request->get($this->getBaseUrl().'/api/set-cookie'); + + $state = $request->storageState(); + $this->assertArrayHasKey('cookies', $state); + $this->assertArrayHasKey('origins', $state); + $this->assertTrue((bool) array_filter( + $state['cookies'], + static fn (array $cookie): bool => 'api-session' === ($cookie['name'] ?? null) + )); + } + + public function testNonSuccessfulStatusReturnsAResponseByDefault(): void + { + $response = $this->context->request()->get($this->getBaseUrl().'/api/status/418'); + + $this->assertFalse($response->ok()); + $this->assertSame(418, $response->status()); + $this->assertSame(['status' => 418], $response->json()); + } + + public function testFailOnStatusCodeReportsTheRequestError(): void + { + $this->expectException(PlaywrightException::class); + $this->expectExceptionMessage('418'); + + $this->context->request()->get($this->getBaseUrl().'/api/status/418', [ + 'failOnStatusCode' => true, + ]); + } + + public function testDisposedContextCannotSendAnotherRequest(): void + { + $request = $this->context->request(); + $request->dispose(); + + $this->expectException(PlaywrightException::class); + + $request->get($this->getBaseUrl().'/api/echo'); + } +} diff --git a/tests/Functional/Tracing/TracingApiTest.php b/tests/Functional/Tracing/TracingApiTest.php index 502c288..f7ae6c3 100644 --- a/tests/Functional/Tracing/TracingApiTest.php +++ b/tests/Functional/Tracing/TracingApiTest.php @@ -144,12 +144,15 @@ public function testHarRecordingIsAvailableOnTheApiRequestContext(): void $tracing = $this->context->request()->tracing(); $tracing->startHar($harPath); - $this->goto('/index.html'); + $response = $this->context->request()->get($this->getBaseUrl().'/index.html'); $tracing->stopHar(); + $this->assertSame(200, $response->status()); $this->assertFileExists($harPath); - $this->assertNotSame([], $this->readHarEntries($harPath)); + $entries = $this->readHarEntries($harPath); + $this->assertNotSame([], $entries); + $this->assertStringContainsString('/index.html', json_encode($entries, \JSON_THROW_ON_ERROR)); } /** diff --git a/tests/Unit/API/APIRequestContextTest.php b/tests/Unit/API/APIRequestContextTest.php index 51da474..c3bbba4 100644 --- a/tests/Unit/API/APIRequestContextTest.php +++ b/tests/Unit/API/APIRequestContextTest.php @@ -17,6 +17,8 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Playwright\API\APIRequestContext; +use Playwright\API\APIResponseInterface; +use Playwright\Exception\ProtocolErrorException; use Playwright\Tracing\TracingInterface; use Playwright\Transport\TransportInterface; @@ -32,6 +34,192 @@ protected function setUp(): void $this->context = new APIRequestContext($this->transport, 'context_1'); } + public function testStandaloneContextDoesNotShareCookies(): void + { + $this->assertFalse($this->context->getShareCookies()); + } + + public function testGetSendsARequestAndReturnsTheResponse(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'api.fetch', + 'contextId' => 'context_1', + 'url' => 'https://example.test/users', + 'options' => ['headers' => ['Accept' => 'application/json'], 'method' => 'GET'], + ]) + ->willReturn([ + 'response' => [ + 'url' => 'https://example.test/users', + 'status' => 200, + 'statusText' => 'OK', + 'headers' => ['content-type' => 'application/json'], + 'body' => base64_encode('{"users":[]}'), + 'bodyEncoding' => 'base64', + ], + ]); + + $response = $this->context->get('https://example.test/users', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + $this->assertInstanceOf(APIResponseInterface::class, $response); + $this->assertSame(200, $response->status()); + $this->assertSame(['users' => []], $response->json()); + } + + public function testRelativeUrlsResolveAgainstTheBaseUrl(): void + { + $context = new APIRequestContext($this->transport, 'context_1', 'https://example.test/api/'); + + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'api.fetch', + 'contextId' => 'context_1', + 'url' => 'https://example.test/api/users', + 'options' => ['method' => 'GET'], + ]) + ->willReturn(['response' => ['status' => 204]]); + + $context->get('/users'); + } + + public function testAbsoluteUrlsIgnoreTheBaseUrl(): void + { + $context = new APIRequestContext($this->transport, 'context_1', 'https://example.test/api'); + + $this->transport->expects($this->once()) + ->method('send') + ->with($this->callback(static fn (array $message): bool => 'https://other.test/users' === $message['url'])) + ->willReturn(['response' => ['status' => 200]]); + + $context->get('https://other.test/users'); + } + + public function testConvenienceMethodsSetTheirHttpMethod(): void + { + $messages = []; + $this->transport->expects($this->exactly(4)) + ->method('send') + ->willReturnCallback(static function (array $message) use (&$messages): array { + $messages[] = $message; + + return ['response' => ['status' => 200]]; + }); + + $this->context->put('https://example.test/resource'); + $this->context->patch('https://example.test/resource'); + $this->context->delete('https://example.test/resource'); + $this->context->head('https://example.test/resource'); + + $this->assertSame( + ['PUT', 'PATCH', 'DELETE', 'HEAD'], + array_column(array_column($messages, 'options'), 'method') + ); + } + + public function testArrayDataIsSentAsJson(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'api.fetch', + 'contextId' => 'context_1', + 'url' => 'https://example.test/users', + 'options' => [ + 'data' => '{"name":"Ada"}', + 'method' => 'POST', + 'headers' => ['Content-Type' => 'application/json'], + ], + ]) + ->willReturn(['response' => ['status' => 201]]); + + $this->context->post('https://example.test/users', ['data' => ['name' => 'Ada']]); + } + + public function testFormDataIsUrlEncoded(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'api.fetch', + 'contextId' => 'context_1', + 'url' => 'https://example.test/login', + 'options' => [ + 'method' => 'POST', + 'data' => 'email=ada%40example.test', + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], + ], + ]) + ->willReturn(['response' => ['status' => 200]]); + + $this->context->post('https://example.test/login', ['form' => ['email' => 'ada@example.test']]); + } + + public function testStorageStateReturnsCookiesAndOrigins(): void + { + $state = [ + 'cookies' => [['name' => 'session', 'value' => 'abc']], + 'origins' => [], + ]; + + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'api.storageState', + 'contextId' => 'context_1', + 'path' => '/tmp/state.json', + ]) + ->willReturn(['storageState' => $state]); + + $this->assertSame($state, $this->context->storageState('/tmp/state.json')); + } + + public function testStorageStateRejectsAnInvalidResponse(): void + { + $this->transport->method('send')->willReturn(['storageState' => 'invalid']); + + $this->expectException(ProtocolErrorException::class); + $this->expectExceptionMessage('Invalid API storageState response'); + + $this->context->storageState(); + } + + public function testFetchReportsTransportErrors(): void + { + $this->transport->method('send')->willReturn(['error' => ['message' => 'Connection refused']]); + + $this->expectException(ProtocolErrorException::class); + $this->expectExceptionMessage('Connection refused'); + + $this->context->get('https://example.test'); + } + + public function testFetchRejectsAnInvalidResponse(): void + { + $this->transport->method('send')->willReturn(['success' => true]); + + $this->expectException(ProtocolErrorException::class); + $this->expectExceptionMessage('Invalid API response from transport'); + + $this->context->get('https://example.test'); + } + + public function testDisposeReleasesTheRequestContext(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'api.dispose', + 'contextId' => 'context_1', + ]) + ->willReturn([]); + + $this->context->dispose(); + } + public function testTracingReturnsATracingInstance(): void { $this->assertInstanceOf(TracingInterface::class, $this->context->tracing()); diff --git a/tests/Unit/API/APIRequestTest.php b/tests/Unit/API/APIRequestTest.php new file mode 100644 index 0000000..51ffc1a --- /dev/null +++ b/tests/Unit/API/APIRequestTest.php @@ -0,0 +1,59 @@ +createMock(TransportInterface::class); + $options = [ + 'baseURL' => 'https://example.test/api', + 'extraHTTPHeaders' => ['Accept' => 'application/json'], + 'storageState' => ['cookies' => [], 'origins' => []], + ]; + + $transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'api.newContext', + 'options' => $options, + ]) + ->willReturn(['contextId' => 'api_1']); + + $context = (new APIRequest($transport))->newContext($options); + + $this->assertInstanceOf(APIRequestContext::class, $context); + $this->assertFalse($context->getShareCookies()); + } + + public function testNewContextRejectsAnInvalidResponse(): void + { + $transport = $this->createStub(TransportInterface::class); + $transport->method('send')->willReturn(['success' => true]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to create API request context'); + + (new APIRequest($transport))->newContext(); + } +} diff --git a/tests/Unit/API/APIResponseTest.php b/tests/Unit/API/APIResponseTest.php new file mode 100644 index 0000000..0475852 --- /dev/null +++ b/tests/Unit/API/APIResponseTest.php @@ -0,0 +1,122 @@ + 201, + 'statusText' => 'Created', + 'url' => 'https://example.test/users/1', + 'headers' => ['content-type' => 'application/json'], + 'body' => base64_encode('{"id":1}'), + 'bodyEncoding' => 'base64', + ]); + + $this->assertTrue($response->ok()); + $this->assertSame(201, $response->status()); + $this->assertSame('Created', $response->statusText()); + $this->assertSame('https://example.test/users/1', $response->url()); + $this->assertSame('{"id":1}', $response->body()); + $this->assertSame('{"id":1}', $response->text()); + $this->assertSame(['id' => 1], $response->json()); + $this->assertSame('application/json', $response->headerValue('Content-Type')); + } + + public function testNonSuccessfulStatusIsNotOk(): void + { + $this->assertFalse((new APIResponse(['status' => 404]))->ok()); + } + + public function testHeadersArrayPreservesRepeatedValues(): void + { + $response = new APIResponse([ + 'headers' => ['set-cookie' => 'first=1\nsecond=2'], + 'headersArray' => [ + ['name' => 'set-cookie', 'value' => 'first=1'], + ['name' => 'set-cookie', 'value' => 'second=2'], + ['name' => 'x-request-id', 'value' => 'request-1'], + ], + ]); + + $this->assertSame([ + 'set-cookie' => ['first=1', 'second=2'], + 'x-request-id' => ['request-1'], + ], $response->headersArray()); + $this->assertSame( + ['set-cookie' => ['first=1', 'second=2']], + $response->headerValues('Set-Cookie') + ); + } + + public function testHeadersArrayFallsBackToNormalizedHeaders(): void + { + $response = new APIResponse([ + 'headers' => [ + 'x-one' => 'one', + 'x-many' => ['first', 'second'], + 0 => 'ignored', + ], + ]); + + $this->assertSame([ + 'x-one' => ['one'], + 'x-many' => ['first', 'second'], + ], $response->headersArray()); + } + + public function testInvalidJsonRaisesAPlaywrightException(): void + { + $response = new APIResponse(['body' => base64_encode('not json'), 'bodyEncoding' => 'base64']); + + $this->expectException(PlaywrightException::class); + $this->expectExceptionMessage('Response body is not valid JSON'); + + $response->json(); + } + + public function testInvalidBase64RaisesAPlaywrightException(): void + { + $response = new APIResponse(['body' => '*invalid*', 'bodyEncoding' => 'base64']); + + $this->expectException(PlaywrightException::class); + $this->expectExceptionMessage('API response body is not valid base64'); + + $response->text(); + } + + public function testMissingValuesUseSafeDefaults(): void + { + $response = new APIResponse([]); + + $this->assertSame(0, $response->status()); + $this->assertSame('', $response->statusText()); + $this->assertSame('', $response->url()); + $this->assertSame([], $response->headers()); + $this->assertNull($response->headerValue('missing')); + $this->assertSame('', $response->text()); + + $response->dispose(); + $this->addToAssertionCount(1); + } +} diff --git a/tests/Unit/Browser/BrowserContextTest.php b/tests/Unit/Browser/BrowserContextTest.php index 7a593cf..bfa1b15 100644 --- a/tests/Unit/Browser/BrowserContextTest.php +++ b/tests/Unit/Browser/BrowserContextTest.php @@ -16,6 +16,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Playwright\API\APIRequestContext; use Playwright\Browser\Browser; use Playwright\Browser\BrowserContext; use Playwright\Browser\StorageState; @@ -898,4 +899,13 @@ public function testWaitForPopupThrowsOnInvalidResponse(): void $context->waitForPopup(static function (): void {}); } + + public function testRequestSharesTheBrowserContextCookieJar(): void + { + $request = $this->context->request(); + + $this->assertInstanceOf(APIRequestContext::class, $request); + $this->assertTrue($request->getShareCookies()); + $this->assertSame($request, $this->context->request()); + } }