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
52 changes: 52 additions & 0 deletions bin/lib/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ class ContextHandler extends BaseHandler {
return this.wrapResult(result);
}

async handleCredentials(command, method) {
const context = this.validateResource(this.contexts, command.contextId, 'Context')?.context;

const registry = CommandRegistry.create({
create: async () => ({ credential: await context.credentials.create(command.rpId, command.options || {}) }),
delete: () => context.credentials.delete(command.credentialId),
get: async () => ({ credentials: await context.credentials.get(command.options || {}) }),
install: () => context.credentials.install()
});

const result = await ErrorHandler.safeExecute(() => this.executeWithRegistry(registry, method), { method, contextId: command.contextId });
return this.wrapResult(result);
}

async handleClock(command, method) {
const context = this.validateResource(this.contexts, command.contextId, 'Context')?.context;

Expand Down Expand Up @@ -427,6 +441,44 @@ class PageHandler extends BaseHandler {
}));
}

async handleWebStorage(command, method) {
const page = this.validateResource(this.pages, command.pageId, 'Page');
// Never index the page with the client-supplied name: only these two exist.
const storage = command.storage === 'sessionStorage' ? page.sessionStorage : page.localStorage;

const registry = CommandRegistry.create({
clear: () => storage.clear(),
getItem: () => PromiseUtils.wrapValue(storage.getItem(command.name)),
items: async () => ({ items: await storage.items() }),
removeItem: () => storage.removeItem(command.name),
setItem: () => storage.setItem(command.name, command.value)
});

const result = await ErrorHandler.safeExecute(() => this.executeWithRegistry(registry, method), { method, pageId: command.pageId });
return this.wrapResult(result);
}

async handleScreencast(command, method) {
const page = this.validateResource(this.pages, command.pageId, 'Page');

// start, showOverlay and showActions resolve with a Disposable. It has no
// serialisable form, and stop, hideOverlays and hideActions already undo
// each of them, so it is dropped rather than shipped to PHP.
const registry = CommandRegistry.create({
start: async () => { await page.screencast.start(command.options || {}); },
stop: () => page.screencast.stop(),
showOverlay: async () => { await page.screencast.showOverlay(command.html, command.options || {}); },
showOverlays: () => page.screencast.showOverlays(),
hideOverlays: () => page.screencast.hideOverlays(),
showActions: async () => { await page.screencast.showActions(command.options || {}); },
hideActions: () => page.screencast.hideActions(),
showChapter: () => page.screencast.showChapter(command.title, command.options || {})
});

const result = await ErrorHandler.safeExecute(() => this.executeWithRegistry(registry, method), { method, pageId: command.pageId });
return this.wrapResult(result);
}

async handleDialog(command) {
const dialog = this.dialogs.get(command.dialogId);
if (dialog) {
Expand Down
2 changes: 1 addition & 1 deletion bin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "Playwright server for PHP",
"main": "playwright-server.js",
"dependencies": {
"playwright": "^1.58.2"
"playwright": "^1.62.0"
},
"scripts": {
"install-browsers": "npx playwright install",
Expand Down
3 changes: 3 additions & 0 deletions bin/playwright-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ class PlaywrightServer extends BaseHandler {
browserServer: () => this.handleBrowserServer(command, actionMethod),
selectors: () => this.selectorsHandler.handle(command, actionMethod),
clock: () => this.contextHandler.handleClock(command, actionMethod),
webStorage: () => this.pageHandler.handleWebStorage(command, actionMethod),
credentials: () => this.contextHandler.handleCredentials(command, actionMethod),
screencast: () => this.pageHandler.handleScreencast(command, actionMethod),
// Tracing actions are flat names (no dot), sent by the PHP Tracing class
tracingStart: () => this.contextHandler.handleTracing(command, 'start'),
tracingStartChunk: () => this.contextHandler.handleTracing(command, 'startChunk'),
Expand Down
9 changes: 9 additions & 0 deletions src/Browser/BrowserContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
use Playwright\Clock\Clock;
use Playwright\Clock\ClockInterface;
use Playwright\Configuration\PlaywrightConfig;
use Playwright\Credentials\Credentials;
use Playwright\Credentials\CredentialsInterface;
use Playwright\Event\EventDispatcherInterface;
use Playwright\Exception\ProtocolErrorException;
use Playwright\Exception\TimeoutException;
Expand Down Expand Up @@ -55,6 +57,8 @@ final class BrowserContext implements BrowserContextInterface, EventDispatcherIn

private ClockInterface $clock;

private ?CredentialsInterface $credentials = null;

private bool $autoTracing = false;

private ?TracingInterface $tracing = null;
Expand Down Expand Up @@ -89,6 +93,11 @@ public function clock(): ClockInterface
return $this->clock;
}

public function credentials(): CredentialsInterface
{
return $this->credentials ??= new Credentials($this->transport, $this->contextId);
}

public function tracing(): TracingInterface
{
return $this->tracing ??= new Tracing($this->transport, $this->contextId);
Expand Down
6 changes: 6 additions & 0 deletions src/Browser/BrowserContextInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

use Playwright\API\APIRequestContextInterface;
use Playwright\Clock\ClockInterface;
use Playwright\Credentials\CredentialsInterface;
use Playwright\Network\NetworkThrottling;
use Playwright\Page\PageInterface;
use Playwright\Tracing\TracingInterface;
Expand All @@ -27,6 +28,11 @@ interface BrowserContextInterface
*/
public function clock(): ClockInterface;

/**
* The context's virtual WebAuthn authenticator, to seed and read passkeys.
*/
public function credentials(): CredentialsInterface;

/**
* Sets the context's geolocation; null coordinates clear it.
*/
Expand Down
133 changes: 133 additions & 0 deletions src/Credentials/Credentials.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

declare(strict_types=1);

/*
* This file is part of the community-maintained Playwright PHP project.
* It is not affiliated with or endorsed by Microsoft.
*
* (c) 2025-Present - Playwright PHP - https://github.com/playwright-php
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Playwright\Credentials;

use Playwright\Exception\PlaywrightException;
use Playwright\Exception\ProtocolErrorException;
use Playwright\Transport\TransportInterface;

final class Credentials implements CredentialsInterface
{
public function __construct(
private readonly TransportInterface $transport,
private readonly string $contextId,
) {
}

public function create(string $rpId, array $options = []): array
{
$response = $this->send([
'action' => 'credentials.create',
'contextId' => $this->contextId,
'rpId' => $rpId,
'options' => $options,
]);

$credential = $response['credential'] ?? null;
if (!is_array($credential)) {
throw new ProtocolErrorException('Invalid credential response', 0);
}

return $this->toCredential($credential);
}

public function delete(string $id): void
{
// Not 'id': a top-level id in the payload is taken for the JSON-RPC
// correlation id and the reply would never be matched.
$this->send([
'action' => 'credentials.delete',
'contextId' => $this->contextId,
'credentialId' => $id,
]);
}

public function get(array $options = []): array
{
$response = $this->send([
'action' => 'credentials.get',
'contextId' => $this->contextId,
'options' => $options,
]);

$credentials = $response['credentials'] ?? null;
if (!is_array($credentials)) {
throw new ProtocolErrorException('Invalid credentials response', 0);
}

$result = [];
foreach ($credentials as $credential) {
if (!is_array($credential)) {
throw new ProtocolErrorException('Invalid credential response', 0);
}

$result[] = $this->toCredential($credential);
}

return $result;
}

public function install(): void
{
$this->send([
'action' => 'credentials.install',
'contextId' => $this->contextId,
]);
}

/**
* @param array<mixed> $credential
*
* @return array{id: string, rpId: string, userHandle: string, privateKey: string, publicKey: string}
*/
private function toCredential(array $credential): array
{
$fields = [];
foreach (['id', 'rpId', 'userHandle', 'privateKey', 'publicKey'] as $field) {
$value = $credential[$field] ?? null;
if (!is_string($value)) {
throw new ProtocolErrorException(sprintf('Invalid credential response: missing %s', $field), 0);
}

$fields[$field] = $value;
}

return [
'id' => $fields['id'],
'rpId' => $fields['rpId'],
'userHandle' => $fields['userHandle'],
'privateKey' => $fields['privateKey'],
'publicKey' => $fields['publicKey'],
];
}

/**
* @param array<string, mixed> $payload
*
* @return array<string, mixed>
*/
private function send(array $payload): array
{
$response = $this->transport->send($payload);

if (isset($response['error'])) {
$error = $response['error'];

throw new PlaywrightException(is_string($error) ? $error : 'Unknown Playwright server error');
}

return $response;
}
}
60 changes: 60 additions & 0 deletions src/Credentials/CredentialsInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

/*
* This file is part of the community-maintained Playwright PHP project.
* It is not affiliated with or endorsed by Microsoft.
*
* (c) 2025-Present - Playwright PHP - https://github.com/playwright-php
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Playwright\Credentials;

/**
* Virtual WebAuthn authenticator for a browser context.
*
* Lets a test register passkeys and answer the page's
* `navigator.credentials.create()` and `navigator.credentials.get()` calls
* without a real authenticator. Every id and key is base64url encoded.
*
* @see https://playwright.dev/docs/api/class-credentials
*/
interface CredentialsInterface
{
/**
* Seeds a discoverable credential and returns it, private key included, so it can be stored and seeded again later.
*
* Any key material left out is generated. To import a known credential, pass
* `id`, `userHandle`, `privateKey` and `publicKey` together.
*
* @param array{id?: string, privateKey?: string, publicKey?: string, userHandle?: string} $options
*
* @return array{id: string, rpId: string, userHandle: string, privateKey: string, publicKey: string}
*/
public function create(string $rpId, array $options = []): array;

/**
* Removes the credential with that id, whether it was seeded or registered by the page itself.
*/
public function delete(string $id): void;

/**
* Returns the credentials the authenticator holds, private keys included, narrowed by the given filters.
*
* Covers both seeded credentials and the ones the page registered itself.
*
* @param array{id?: string, rpId?: string} $options
*
* @return list<array{id: string, rpId: string, userHandle: string, privateKey: string, publicKey: string}>
*/
public function get(array $options = []): array;

/**
* Starts intercepting WebAuthn in every page of the context; until this runs the page sees none of the seeded credentials.
*/
public function install(): void;
}
27 changes: 27 additions & 0 deletions src/Page/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,12 @@
use Playwright\Page\Options\WaitForSelectorOptions;
use Playwright\Page\Options\WaitForUrlOptions;
use Playwright\Regex;
use Playwright\Screencast\Screencast;
use Playwright\Screencast\ScreencastInterface;
use Playwright\Screenshot\ScreenshotHelper;
use Playwright\Transport\TransportInterface;
use Playwright\WebStorage\WebStorage;
use Playwright\WebStorage\WebStorageInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

Expand All @@ -84,6 +88,11 @@ final class Page implements PageInterface, EventDispatcherInterface
public readonly MouseInterface $mouse;

public readonly TouchscreenInterface $touchscreen;
public readonly WebStorageInterface $localStorage;

public readonly WebStorageInterface $sessionStorage;

public readonly ScreencastInterface $screencast;

private PageEventHandlerInterface $eventHandler;

Expand All @@ -106,6 +115,9 @@ public function __construct(
$this->keyboard = new Keyboard($this->transport, $this->pageId);
$this->mouse = new Mouse($this->transport, $this->pageId);
$this->touchscreen = new Touchscreen($this->transport, $this->pageId);
$this->localStorage = new WebStorage($this->transport, $this->pageId, 'localStorage');
$this->sessionStorage = new WebStorage($this->transport, $this->pageId, 'sessionStorage');
$this->screencast = new Screencast($this->transport, $this->pageId);
$this->eventHandler = new PageEventHandler();

$this->clock = $this->context->clock();
Expand Down Expand Up @@ -198,6 +210,21 @@ public function touchscreen(): TouchscreenInterface
return $this->touchscreen;
}

public function localStorage(): WebStorageInterface
{
return $this->localStorage;
}

public function sessionStorage(): WebStorageInterface
{
return $this->sessionStorage;
}

public function screencast(): ScreencastInterface
{
return $this->screencast;
}

public function events(): PageEventHandlerInterface
{
return $this->eventHandler;
Expand Down
Loading
Loading