Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 76 additions & 2 deletions bin/lib/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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; },
Expand Down Expand Up @@ -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 };
14 changes: 11 additions & 3 deletions bin/playwright-server.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 }); }
}
Expand Down
7 changes: 1 addition & 6 deletions src/API/APIRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
}
21 changes: 7 additions & 14 deletions src/API/APIRequestContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ public function fetch(string $urlOrRequest, array $options = []): APIResponseInt
}

/**
* @return array<array<string, mixed>>
* @return array<string, mixed>
*/
public function storageState(?string $path = null): array
{
Expand All @@ -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;
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/API/APIRequestContextInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<array<string, mixed>>
* @return array<string, mixed>
*/
public function storageState(?string $path = null): array;

Expand Down
46 changes: 40 additions & 6 deletions src/API/APIResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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<array-key, mixed>|null $decoded */
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/Browser/BrowserContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions tests/Fixtures/server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading