From b72c7e8bbd1a8a19794f2cf43d2cefec48a3b8f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Fri, 7 Aug 2026 06:21:40 +0200 Subject: [PATCH 1/3] Add WebStorage with page storage accessors --- bin/lib/handlers.js | 17 ++ bin/package.json | 2 +- bin/playwright-server.js | 1 + src/Page/Page.php | 17 ++ src/Page/PageInterface.php | 11 ++ src/WebStorage/WebStorage.php | 125 ++++++++++++ src/WebStorage/WebStorageInterface.php | 53 +++++ .../Integration/WebStorage/WebStorageTest.php | 166 ++++++++++++++++ tests/Unit/Page/PageTest.php | 18 ++ tests/Unit/WebStorage/WebStorageTest.php | 183 ++++++++++++++++++ 10 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 src/WebStorage/WebStorage.php create mode 100644 src/WebStorage/WebStorageInterface.php create mode 100644 tests/Integration/WebStorage/WebStorageTest.php create mode 100644 tests/Unit/WebStorage/WebStorageTest.php diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js index c59b8bc..7bc9dc7 100644 --- a/bin/lib/handlers.js +++ b/bin/lib/handlers.js @@ -427,6 +427,23 @@ 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 handleDialog(command) { const dialog = this.dialogs.get(command.dialogId); if (dialog) { diff --git a/bin/package.json b/bin/package.json index c0c1afb..b60c5d0 100644 --- a/bin/package.json +++ b/bin/package.json @@ -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", diff --git a/bin/playwright-server.js b/bin/playwright-server.js index aa43d9a..9f701b1 100644 --- a/bin/playwright-server.js +++ b/bin/playwright-server.js @@ -80,6 +80,7 @@ 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), // 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'), diff --git a/src/Page/Page.php b/src/Page/Page.php index 767840d..a5306fd 100644 --- a/src/Page/Page.php +++ b/src/Page/Page.php @@ -72,6 +72,8 @@ use Playwright\Regex; use Playwright\Screenshot\ScreenshotHelper; use Playwright\Transport\TransportInterface; +use Playwright\WebStorage\WebStorage; +use Playwright\WebStorage\WebStorageInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -84,6 +86,9 @@ final class Page implements PageInterface, EventDispatcherInterface public readonly MouseInterface $mouse; public readonly TouchscreenInterface $touchscreen; + public readonly WebStorageInterface $localStorage; + + public readonly WebStorageInterface $sessionStorage; private PageEventHandlerInterface $eventHandler; @@ -106,6 +111,8 @@ 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->eventHandler = new PageEventHandler(); $this->clock = $this->context->clock(); @@ -198,6 +205,16 @@ public function touchscreen(): TouchscreenInterface return $this->touchscreen; } + public function localStorage(): WebStorageInterface + { + return $this->localStorage; + } + + public function sessionStorage(): WebStorageInterface + { + return $this->sessionStorage; + } + public function events(): PageEventHandlerInterface { return $this->eventHandler; diff --git a/src/Page/PageInterface.php b/src/Page/PageInterface.php index 9e08693..803d0d9 100644 --- a/src/Page/PageInterface.php +++ b/src/Page/PageInterface.php @@ -49,6 +49,7 @@ use Playwright\Page\Options\WaitForSelectorOptions; use Playwright\Page\Options\WaitForUrlOptions; use Playwright\Regex; +use Playwright\WebStorage\WebStorageInterface; interface PageInterface { @@ -306,6 +307,16 @@ public function mouse(): MouseInterface; */ public function touchscreen(): TouchscreenInterface; + /** + * The `localStorage` of the page's current origin. + */ + public function localStorage(): WebStorageInterface; + + /** + * The `sessionStorage` of the page's current origin. + */ + public function sessionStorage(): WebStorageInterface; + public function events(): PageEventHandlerInterface; public function route(string $url, callable $handler): void; diff --git a/src/WebStorage/WebStorage.php b/src/WebStorage/WebStorage.php new file mode 100644 index 0000000..2acf65d --- /dev/null +++ b/src/WebStorage/WebStorage.php @@ -0,0 +1,125 @@ +send([ + 'action' => 'webStorage.clear', + 'pageId' => $this->pageId, + 'storage' => $this->storage, + ]); + } + + public function getItem(string $name): ?string + { + $response = $this->send([ + 'action' => 'webStorage.getItem', + 'pageId' => $this->pageId, + 'storage' => $this->storage, + 'name' => $name, + ]); + + $value = $response['value'] ?? null; + + return is_string($value) ? $value : null; + } + + public function items(): array + { + $response = $this->send([ + 'action' => 'webStorage.items', + 'pageId' => $this->pageId, + 'storage' => $this->storage, + ]); + + $items = $response['items'] ?? null; + if (!is_array($items)) { + throw new ProtocolErrorException('Invalid web storage items response', 0); + } + + $result = []; + foreach ($items as $item) { + if (!is_array($item)) { + throw new ProtocolErrorException('Invalid web storage item response', 0); + } + + $name = $item['name'] ?? null; + $value = $item['value'] ?? null; + if (!is_string($name) || !is_string($value)) { + throw new ProtocolErrorException('Invalid web storage item response', 0); + } + + $result[] = ['name' => $name, 'value' => $value]; + } + + return $result; + } + + public function removeItem(string $name): void + { + $this->send([ + 'action' => 'webStorage.removeItem', + 'pageId' => $this->pageId, + 'storage' => $this->storage, + 'name' => $name, + ]); + } + + public function setItem(string $name, string $value): void + { + $this->send([ + 'action' => 'webStorage.setItem', + 'pageId' => $this->pageId, + 'storage' => $this->storage, + 'name' => $name, + 'value' => $value, + ]); + } + + /** + * @param array $payload + * + * @return array + */ + 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; + } +} diff --git a/src/WebStorage/WebStorageInterface.php b/src/WebStorage/WebStorageInterface.php new file mode 100644 index 0000000..dac0a07 --- /dev/null +++ b/src/WebStorage/WebStorageInterface.php @@ -0,0 +1,53 @@ + + */ + public function items(): array; + + /** + * Does nothing when no item is stored under that name. + */ + public function removeItem(string $name): void; + + /** + * Overwrites any value already stored under that name. + */ + public function setItem(string $name, string $value): void; +} diff --git a/tests/Integration/WebStorage/WebStorageTest.php b/tests/Integration/WebStorage/WebStorageTest.php new file mode 100644 index 0000000..e133bdf --- /dev/null +++ b/tests/Integration/WebStorage/WebStorageTest.php @@ -0,0 +1,166 @@ +setUpPlaywright(); + $this->installRouteServer($this->page, [ + '/index.html' => '

Storage

', + ]); + $this->page->goto($this->routeUrl('/index.html')); + } + + public function tearDown(): void + { + $this->tearDownPlaywright(); + } + + #[Test] + public function itSetsAndReadsAnItem(): void + { + $this->page->localStorage->setItem('token', 'abc'); + + $this->assertSame('abc', $this->page->localStorage->getItem('token')); + } + + #[Test] + public function itReturnsNullForAnAbsentItem(): void + { + $this->assertNull($this->page->localStorage->getItem('missing')); + } + + #[Test] + public function itOverwritesAnExistingItem(): void + { + $this->page->localStorage->setItem('token', 'first'); + $this->page->localStorage->setItem('token', 'second'); + + $this->assertSame('second', $this->page->localStorage->getItem('token')); + } + + #[Test] + public function itListsEveryItem(): void + { + $this->page->localStorage->setItem('a', '1'); + $this->page->localStorage->setItem('b', '2'); + + $items = $this->page->localStorage->items(); + + $this->assertCount(2, $items); + $names = array_column($items, 'value', 'name'); + $this->assertSame(['a' => '1', 'b' => '2'], $names); + } + + #[Test] + public function itReturnsAnEmptyListWhenStorageIsEmpty(): void + { + $this->assertSame([], $this->page->localStorage->items()); + } + + #[Test] + public function itRemovesAnItem(): void + { + $this->page->localStorage->setItem('a', '1'); + $this->page->localStorage->setItem('b', '2'); + + $this->page->localStorage->removeItem('a'); + + $this->assertNull($this->page->localStorage->getItem('a')); + $this->assertSame('2', $this->page->localStorage->getItem('b')); + } + + #[Test] + public function itIgnoresRemovingAnAbsentItem(): void + { + $this->page->localStorage->removeItem('never-set'); + + $this->assertSame([], $this->page->localStorage->items()); + } + + #[Test] + public function itClearsEveryItem(): void + { + $this->page->localStorage->setItem('a', '1'); + $this->page->localStorage->setItem('b', '2'); + + $this->page->localStorage->clear(); + + $this->assertSame([], $this->page->localStorage->items()); + } + + #[Test] + public function itKeepsLocalAndSessionStorageApart(): void + { + $this->page->localStorage->setItem('shared', 'local'); + $this->page->sessionStorage->setItem('shared', 'session'); + + $this->assertSame('local', $this->page->localStorage->getItem('shared')); + $this->assertSame('session', $this->page->sessionStorage->getItem('shared')); + + $this->page->localStorage->clear(); + + $this->assertSame([], $this->page->localStorage->items()); + $this->assertSame([['name' => 'shared', 'value' => 'session']], $this->page->sessionStorage->items()); + } + + #[Test] + public function itExposesTheSameStorageThroughTheInterfaceAccessors(): void + { + $this->assertSame($this->page->localStorage, $this->page->localStorage()); + $this->assertSame($this->page->sessionStorage, $this->page->sessionStorage()); + + $this->page->localStorage()->setItem('via-accessor', 'yes'); + + $this->assertSame('yes', $this->page->localStorage->getItem('via-accessor')); + } + + #[Test] + public function itWritesValuesThePageCanRead(): void + { + $this->page->localStorage->setItem('from-php', 'hello'); + + $this->assertSame('hello', $this->page->evaluate('() => window.localStorage.getItem("from-php")')); + } + + #[Test] + public function itReadsValuesThePageWrote(): void + { + $this->page->evaluate('() => window.sessionStorage.setItem("from-js", "world")'); + + $this->assertSame('world', $this->page->sessionStorage->getItem('from-js')); + } +} diff --git a/tests/Unit/Page/PageTest.php b/tests/Unit/Page/PageTest.php index fd53b4f..8fa2f30 100644 --- a/tests/Unit/Page/PageTest.php +++ b/tests/Unit/Page/PageTest.php @@ -40,6 +40,7 @@ use Playwright\Page\PageEventHandlerInterface; use Playwright\Regex; use Playwright\Transport\TransportInterface; +use Playwright\WebStorage\WebStorageInterface; #[CoversClass(Page::class)] class PageTest extends TestCase @@ -77,6 +78,23 @@ public function testGetTouchscreen(): void $this->assertInstanceOf(TouchscreenInterface::class, $touchscreen); } + public function testGetLocalStorage(): void + { + $this->assertInstanceOf(WebStorageInterface::class, $this->page->localStorage()); + $this->assertSame($this->page->localStorage, $this->page->localStorage()); + } + + public function testGetSessionStorage(): void + { + $this->assertInstanceOf(WebStorageInterface::class, $this->page->sessionStorage()); + $this->assertSame($this->page->sessionStorage, $this->page->sessionStorage()); + } + + public function testLocalAndSessionStorageAreDistinctInstances(): void + { + $this->assertNotSame($this->page->localStorage, $this->page->sessionStorage); + } + public function testGetEvents(): void { $events = $this->page->events(); diff --git a/tests/Unit/WebStorage/WebStorageTest.php b/tests/Unit/WebStorage/WebStorageTest.php new file mode 100644 index 0000000..1ea9db0 --- /dev/null +++ b/tests/Unit/WebStorage/WebStorageTest.php @@ -0,0 +1,183 @@ +transport(); + $transport->queueResponse([]); + + (new WebStorage($transport, 'page_1', 'localStorage'))->clear(); + + $sent = $transport->getSentMessages(); + $this->assertCount(1, $sent); + $this->assertSame('webStorage.clear', $sent[0]['action']); + $this->assertSame('page_1', $sent[0]['pageId']); + $this->assertSame('localStorage', $sent[0]['storage']); + } + + public function testSetItemSendsNameAndValue(): void + { + $transport = $this->transport(); + $transport->queueResponse([]); + + (new WebStorage($transport, 'page_2', 'sessionStorage'))->setItem('token', 'abc'); + + $sent = $transport->getSentMessages(); + $this->assertSame('webStorage.setItem', $sent[0]['action']); + $this->assertSame('sessionStorage', $sent[0]['storage']); + $this->assertSame('token', $sent[0]['name']); + $this->assertSame('abc', $sent[0]['value']); + } + + public function testRemoveItemSendsName(): void + { + $transport = $this->transport(); + $transport->queueResponse([]); + + (new WebStorage($transport, 'page_3', 'localStorage'))->removeItem('token'); + + $sent = $transport->getSentMessages(); + $this->assertSame('webStorage.removeItem', $sent[0]['action']); + $this->assertSame('token', $sent[0]['name']); + } + + public function testGetItemReturnsStringValue(): void + { + $transport = $this->transport(); + $transport->queueResponse(['value' => 'abc']); + + $value = (new WebStorage($transport, 'page_4', 'localStorage'))->getItem('token'); + + $this->assertSame('abc', $value); + $sent = $transport->getSentMessages(); + $this->assertSame('webStorage.getItem', $sent[0]['action']); + $this->assertSame('token', $sent[0]['name']); + } + + public function testGetItemReturnsNullForMissingItem(): void + { + $transport = $this->transport(); + $transport->queueResponse(['value' => null]); + + $this->assertNull((new WebStorage($transport, 'page_5', 'localStorage'))->getItem('nope')); + } + + public function testGetItemReturnsNullWhenValueIsNotAString(): void + { + $transport = $this->transport(); + $transport->queueResponse(['success' => true]); + + $this->assertNull((new WebStorage($transport, 'page_6', 'localStorage'))->getItem('nope')); + } + + public function testItemsReturnsNameValuePairs(): void + { + $transport = $this->transport(); + $transport->queueResponse(['items' => [ + ['name' => 'a', 'value' => '1'], + ['name' => 'b', 'value' => '2'], + ]]); + + $items = (new WebStorage($transport, 'page_7', 'localStorage'))->items(); + + $this->assertSame([ + ['name' => 'a', 'value' => '1'], + ['name' => 'b', 'value' => '2'], + ], $items); + $this->assertSame('webStorage.items', $transport->getSentMessages()[0]['action']); + } + + public function testItemsReturnsEmptyArrayForEmptyStorage(): void + { + $transport = $this->transport(); + $transport->queueResponse(['items' => []]); + + $this->assertSame([], (new WebStorage($transport, 'page_8', 'localStorage'))->items()); + } + + public function testItemsThrowsWhenPayloadIsMissing(): void + { + $transport = $this->transport(); + $transport->queueResponse(['success' => true]); + + $this->expectException(ProtocolErrorException::class); + $this->expectExceptionMessage('Invalid web storage items response'); + + (new WebStorage($transport, 'page_9', 'localStorage'))->items(); + } + + public function testItemsThrowsWhenAnEntryIsNotAnArray(): void + { + $transport = $this->transport(); + $transport->queueResponse(['items' => ['not-an-array']]); + + $this->expectException(ProtocolErrorException::class); + $this->expectExceptionMessage('Invalid web storage item response'); + + (new WebStorage($transport, 'page_10', 'localStorage'))->items(); + } + + public function testItemsThrowsWhenAnEntryHasNoStringValue(): void + { + $transport = $this->transport(); + $transport->queueResponse(['items' => [['name' => 'a', 'value' => 1]]]); + + $this->expectException(ProtocolErrorException::class); + $this->expectExceptionMessage('Invalid web storage item response'); + + (new WebStorage($transport, 'page_11', 'localStorage'))->items(); + } + + public function testItRaisesServerErrors(): void + { + $transport = $this->transport(); + $transport->queueResponse(['error' => 'SecurityError: Storage is disabled']); + + $this->expectException(PlaywrightException::class); + $this->expectExceptionMessage('SecurityError: Storage is disabled'); + + (new WebStorage($transport, 'page_err', 'localStorage'))->setItem('a', '1'); + } + + public function testItRaisesAGenericMessageForANonStringError(): void + { + $transport = $this->transport(); + $transport->queueResponse(['error' => ['message' => 'nope']]); + + $this->expectException(PlaywrightException::class); + $this->expectExceptionMessage('Unknown Playwright server error'); + + (new WebStorage($transport, 'page_err_2', 'localStorage'))->clear(); + } + + private function transport(): MockTransport + { + $transport = new MockTransport(); + $transport->connect(); + + return $transport; + } +} From f21d63d51f029778dea1c2c9d3f0bb3affd83bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Fri, 7 Aug 2026 06:22:42 +0200 Subject: [PATCH 2/3] Add Credentials virtual WebAuthn authenticator --- bin/lib/handlers.js | 14 ++ bin/playwright-server.js | 1 + src/Browser/BrowserContext.php | 9 + src/Browser/BrowserContextInterface.php | 6 + src/Credentials/Credentials.php | 133 ++++++++++++ src/Credentials/CredentialsInterface.php | 60 ++++++ .../Credentials/CredentialsTest.php | 189 ++++++++++++++++++ tests/Unit/Browser/BrowserContextTest.php | 9 + tests/Unit/Credentials/CredentialsTest.php | 187 +++++++++++++++++ 9 files changed, 608 insertions(+) create mode 100644 src/Credentials/Credentials.php create mode 100644 src/Credentials/CredentialsInterface.php create mode 100644 tests/Integration/Credentials/CredentialsTest.php create mode 100644 tests/Unit/Credentials/CredentialsTest.php diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js index 7bc9dc7..d5d6863 100644 --- a/bin/lib/handlers.js +++ b/bin/lib/handlers.js @@ -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; diff --git a/bin/playwright-server.js b/bin/playwright-server.js index 9f701b1..ea7c4a0 100644 --- a/bin/playwright-server.js +++ b/bin/playwright-server.js @@ -81,6 +81,7 @@ class PlaywrightServer extends BaseHandler { selectors: () => this.selectorsHandler.handle(command, actionMethod), clock: () => this.contextHandler.handleClock(command, actionMethod), webStorage: () => this.pageHandler.handleWebStorage(command, actionMethod), + credentials: () => this.contextHandler.handleCredentials(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'), diff --git a/src/Browser/BrowserContext.php b/src/Browser/BrowserContext.php index 5e2bb01..84740df 100644 --- a/src/Browser/BrowserContext.php +++ b/src/Browser/BrowserContext.php @@ -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; @@ -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; @@ -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); diff --git a/src/Browser/BrowserContextInterface.php b/src/Browser/BrowserContextInterface.php index 105c8c3..e8145cb 100644 --- a/src/Browser/BrowserContextInterface.php +++ b/src/Browser/BrowserContextInterface.php @@ -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; @@ -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. */ diff --git a/src/Credentials/Credentials.php b/src/Credentials/Credentials.php new file mode 100644 index 0000000..2001428 --- /dev/null +++ b/src/Credentials/Credentials.php @@ -0,0 +1,133 @@ +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 $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 $payload + * + * @return array + */ + 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; + } +} diff --git a/src/Credentials/CredentialsInterface.php b/src/Credentials/CredentialsInterface.php new file mode 100644 index 0000000..5702a42 --- /dev/null +++ b/src/Credentials/CredentialsInterface.php @@ -0,0 +1,60 @@ + + */ + 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; +} diff --git a/tests/Integration/Credentials/CredentialsTest.php b/tests/Integration/Credentials/CredentialsTest.php new file mode 100644 index 0000000..be50c62 --- /dev/null +++ b/tests/Integration/Credentials/CredentialsTest.php @@ -0,0 +1,189 @@ +setUpPlaywright(); + $this->installRouteServer($this->page, [ + '/index.html' => '

Passkeys

', + ]); + $this->page->goto($this->routeUrl('/index.html')); + } + + public function tearDown(): void + { + $this->tearDownPlaywright(); + } + + #[Test] + public function itSeedsACredentialAndReturnsItsKeys(): void + { + $credential = $this->context->credentials()->create('localhost'); + + $this->assertSame('localhost', $credential['rpId']); + $this->assertNotSame('', $credential['id']); + $this->assertNotSame('', $credential['userHandle']); + $this->assertNotSame('', $credential['privateKey']); + $this->assertNotSame('', $credential['publicKey']); + } + + #[Test] + public function itReturnsTheSameCredentialsInstanceOnEveryCall(): void + { + $this->assertSame($this->context->credentials(), $this->context->credentials()); + } + + #[Test] + public function itListsSeededCredentials(): void + { + $first = $this->context->credentials()->create('localhost'); + $second = $this->context->credentials()->create('example.com'); + + $all = $this->context->credentials()->get(); + + $this->assertCount(2, $all); + $this->assertEqualsCanonicalizing( + [$first['id'], $second['id']], + array_column($all, 'id') + ); + } + + #[Test] + public function itFiltersCredentialsByRelyingParty(): void + { + $wanted = $this->context->credentials()->create('localhost'); + $this->context->credentials()->create('example.com'); + + $filtered = $this->context->credentials()->get(['rpId' => 'localhost']); + + $this->assertCount(1, $filtered); + $this->assertSame($wanted['id'], $filtered[0]['id']); + } + + #[Test] + public function itFiltersCredentialsById(): void + { + $this->context->credentials()->create('localhost'); + $wanted = $this->context->credentials()->create('localhost'); + + $filtered = $this->context->credentials()->get(['id' => $wanted['id']]); + + $this->assertCount(1, $filtered); + $this->assertSame($wanted['id'], $filtered[0]['id']); + } + + #[Test] + public function itDeletesACredential(): void + { + $kept = $this->context->credentials()->create('localhost'); + $removed = $this->context->credentials()->create('localhost'); + + $this->context->credentials()->delete($removed['id']); + + $this->assertSame([$kept['id']], array_column($this->context->credentials()->get(), 'id')); + } + + #[Test] + public function itReimportsACredentialFromItsStoredKeys(): void + { + $original = $this->context->credentials()->create('localhost'); + $this->context->credentials()->delete($original['id']); + + $reimported = $this->context->credentials()->create('localhost', [ + 'id' => $original['id'], + 'userHandle' => $original['userHandle'], + 'privateKey' => $original['privateKey'], + 'publicKey' => $original['publicKey'], + ]); + + $this->assertSame($original['id'], $reimported['id']); + $this->assertSame($original['privateKey'], $reimported['privateKey']); + $this->assertSame($original['publicKey'], $reimported['publicKey']); + } + + #[Test] + public function itAnswersThePageWebAuthnCallWithTheSeededCredential(): void + { + $this->context->credentials()->install(); + $seeded = $this->context->credentials()->create('localhost'); + + $resolved = $this->page->evaluate(<<<'JS' + async () => { + const credential = await navigator.credentials.get({ + publicKey: { + challenge: new Uint8Array([1, 2, 3, 4]), + rpId: 'localhost', + userVerification: 'preferred', + }, + }); + + return credential ? credential.id : null; + } + JS); + + $this->assertSame($seeded['id'], $resolved); + } + + #[Test] + public function itReadsBackACredentialThePageRegistered(): void + { + $this->context->credentials()->install(); + + $registered = $this->page->evaluate(<<<'JS' + async () => { + const credential = await navigator.credentials.create({ + publicKey: { + challenge: new Uint8Array([5, 6, 7, 8]), + rp: { name: 'Test', id: 'localhost' }, + user: { id: new Uint8Array([9, 9]), name: 'u@example.com', displayName: 'U' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + }, + }); + + return credential ? credential.id : null; + } + JS); + + $this->assertIsString($registered); + + $stored = $this->context->credentials()->get(['id' => $registered]); + + $this->assertCount(1, $stored); + $this->assertSame('localhost', $stored[0]['rpId']); + $this->assertNotSame('', $stored[0]['privateKey']); + } +} diff --git a/tests/Unit/Browser/BrowserContextTest.php b/tests/Unit/Browser/BrowserContextTest.php index bd33422..afa462a 100644 --- a/tests/Unit/Browser/BrowserContextTest.php +++ b/tests/Unit/Browser/BrowserContextTest.php @@ -20,6 +20,7 @@ use Playwright\Browser\BrowserContext; use Playwright\Browser\StorageState; use Playwright\Configuration\PlaywrightConfig; +use Playwright\Credentials\CredentialsInterface; use Playwright\Network\NetworkThrottling; use Playwright\Page\PageInterface; use Playwright\Tracing\TracingInterface; @@ -490,6 +491,14 @@ public function testUnrouteAll(): void $this->context->unrouteAll(['behavior' => 'ignoreErrors']); } + public function testCredentialsReturnsTheSameInstance(): void + { + $credentials = $this->context->credentials(); + + $this->assertInstanceOf(CredentialsInterface::class, $credentials); + $this->assertSame($credentials, $this->context->credentials()); + } + public function testTracingReturnsTheSameInstance(): void { $tracing = $this->context->tracing(); diff --git a/tests/Unit/Credentials/CredentialsTest.php b/tests/Unit/Credentials/CredentialsTest.php new file mode 100644 index 0000000..1f5ddcc --- /dev/null +++ b/tests/Unit/Credentials/CredentialsTest.php @@ -0,0 +1,187 @@ +transport(); + $transport->queueResponse([]); + + (new Credentials($transport, 'ctx_1'))->install(); + + $sent = $transport->getSentMessages(); + $this->assertCount(1, $sent); + $this->assertSame('credentials.install', $sent[0]['action']); + $this->assertSame('ctx_1', $sent[0]['contextId']); + } + + public function testDeleteSendsId(): void + { + $transport = $this->transport(); + $transport->queueResponse([]); + + (new Credentials($transport, 'ctx_2'))->delete('cred-id'); + + $sent = $transport->getSentMessages(); + $this->assertSame('credentials.delete', $sent[0]['action']); + $this->assertSame('cred-id', $sent[0]['credentialId']); + $this->assertArrayNotHasKey('id', $sent[0], 'a top-level id would be taken for the JSON-RPC correlation id'); + } + + public function testCreateSendsRpIdAndReturnsCredential(): void + { + $transport = $this->transport(); + $transport->queueResponse(['credential' => $this->credential()]); + + $credential = (new Credentials($transport, 'ctx_3'))->create('example.com'); + + $sent = $transport->getSentMessages(); + $this->assertSame('credentials.create', $sent[0]['action']); + $this->assertSame('example.com', $sent[0]['rpId']); + $this->assertSame([], $sent[0]['options']); + $this->assertSame($this->credential(), $credential); + } + + public function testCreateForwardsKeyMaterial(): void + { + $transport = $this->transport(); + $transport->queueResponse(['credential' => $this->credential()]); + + $options = [ + 'id' => 'i', + 'userHandle' => 'u', + 'privateKey' => 'pk', + 'publicKey' => 'pub', + ]; + (new Credentials($transport, 'ctx_4'))->create('example.com', $options); + + $this->assertSame($options, $transport->getSentMessages()[0]['options']); + } + + public function testCreateThrowsWhenCredentialIsMissing(): void + { + $transport = $this->transport(); + $transport->queueResponse(['success' => true]); + + $this->expectException(ProtocolErrorException::class); + $this->expectExceptionMessage('Invalid credential response'); + + (new Credentials($transport, 'ctx_5'))->create('example.com'); + } + + public function testCreateThrowsWhenAFieldIsMissing(): void + { + $credential = $this->credential(); + unset($credential['privateKey']); + + $transport = $this->transport(); + $transport->queueResponse(['credential' => $credential]); + + $this->expectException(ProtocolErrorException::class); + $this->expectExceptionMessage('Invalid credential response: missing privateKey'); + + (new Credentials($transport, 'ctx_6'))->create('example.com'); + } + + public function testGetReturnsEveryCredential(): void + { + $transport = $this->transport(); + $transport->queueResponse(['credentials' => [$this->credential(), $this->credential('other')]]); + + $credentials = (new Credentials($transport, 'ctx_7'))->get(); + + $this->assertCount(2, $credentials); + $this->assertSame('cred-id', $credentials[0]['id']); + $this->assertSame('other', $credentials[1]['id']); + $this->assertSame('credentials.get', $transport->getSentMessages()[0]['action']); + $this->assertSame([], $transport->getSentMessages()[0]['options']); + } + + public function testGetForwardsFilters(): void + { + $transport = $this->transport(); + $transport->queueResponse(['credentials' => []]); + + $credentials = (new Credentials($transport, 'ctx_8'))->get(['rpId' => 'example.com', 'id' => 'x']); + + $this->assertSame([], $credentials); + $this->assertSame(['rpId' => 'example.com', 'id' => 'x'], $transport->getSentMessages()[0]['options']); + } + + public function testGetThrowsWhenPayloadIsMissing(): void + { + $transport = $this->transport(); + $transport->queueResponse(['success' => true]); + + $this->expectException(ProtocolErrorException::class); + $this->expectExceptionMessage('Invalid credentials response'); + + (new Credentials($transport, 'ctx_9'))->get(); + } + + public function testGetThrowsWhenAnEntryIsNotAnArray(): void + { + $transport = $this->transport(); + $transport->queueResponse(['credentials' => ['not-an-array']]); + + $this->expectException(ProtocolErrorException::class); + $this->expectExceptionMessage('Invalid credential response'); + + (new Credentials($transport, 'ctx_10'))->get(); + } + + public function testItRaisesServerErrors(): void + { + $transport = $this->transport(); + $transport->queueResponse(['error' => 'credentials.create: bad rpId']); + + $this->expectException(PlaywrightException::class); + $this->expectExceptionMessage('credentials.create: bad rpId'); + + (new Credentials($transport, 'ctx_err'))->create('example.com'); + } + + /** + * @return array{id: string, rpId: string, userHandle: string, privateKey: string, publicKey: string} + */ + private function credential(string $id = 'cred-id'): array + { + return [ + 'id' => $id, + 'rpId' => 'example.com', + 'userHandle' => 'user-handle', + 'privateKey' => 'private-key', + 'publicKey' => 'public-key', + ]; + } + + private function transport(): MockTransport + { + $transport = new MockTransport(); + $transport->connect(); + + return $transport; + } +} From 0223f0e3cfc35199d5385142ca1f650e4386a46d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Fri, 7 Aug 2026 06:24:02 +0200 Subject: [PATCH 3/3] Add Screencast recording and annotations --- bin/lib/handlers.js | 21 ++ bin/playwright-server.js | 1 + src/Page/Page.php | 10 + src/Page/PageInterface.php | 6 + src/Screencast/Screencast.php | 115 +++++++++++ src/Screencast/ScreencastInterface.php | 76 +++++++ .../Integration/Screencast/ScreencastTest.php | 185 ++++++++++++++++++ tests/Unit/Page/PageTest.php | 7 + tests/Unit/Screencast/ScreencastTest.php | 184 +++++++++++++++++ 9 files changed, 605 insertions(+) create mode 100644 src/Screencast/Screencast.php create mode 100644 src/Screencast/ScreencastInterface.php create mode 100644 tests/Integration/Screencast/ScreencastTest.php create mode 100644 tests/Unit/Screencast/ScreencastTest.php diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js index d5d6863..c22aae5 100644 --- a/bin/lib/handlers.js +++ b/bin/lib/handlers.js @@ -458,6 +458,27 @@ class PageHandler extends BaseHandler { 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) { diff --git a/bin/playwright-server.js b/bin/playwright-server.js index ea7c4a0..935c2e7 100644 --- a/bin/playwright-server.js +++ b/bin/playwright-server.js @@ -82,6 +82,7 @@ class PlaywrightServer extends BaseHandler { 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'), diff --git a/src/Page/Page.php b/src/Page/Page.php index a5306fd..f9ad62e 100644 --- a/src/Page/Page.php +++ b/src/Page/Page.php @@ -70,6 +70,8 @@ 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; @@ -90,6 +92,8 @@ final class Page implements PageInterface, EventDispatcherInterface public readonly WebStorageInterface $sessionStorage; + public readonly ScreencastInterface $screencast; + private PageEventHandlerInterface $eventHandler; private ?APIRequestContextInterface $apiRequestContext = null; @@ -113,6 +117,7 @@ public function __construct( $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(); @@ -215,6 +220,11 @@ public function sessionStorage(): WebStorageInterface return $this->sessionStorage; } + public function screencast(): ScreencastInterface + { + return $this->screencast; + } + public function events(): PageEventHandlerInterface { return $this->eventHandler; diff --git a/src/Page/PageInterface.php b/src/Page/PageInterface.php index 803d0d9..edb4e3c 100644 --- a/src/Page/PageInterface.php +++ b/src/Page/PageInterface.php @@ -49,6 +49,7 @@ use Playwright\Page\Options\WaitForSelectorOptions; use Playwright\Page\Options\WaitForUrlOptions; use Playwright\Regex; +use Playwright\Screencast\ScreencastInterface; use Playwright\WebStorage\WebStorageInterface; interface PageInterface @@ -317,6 +318,11 @@ public function localStorage(): WebStorageInterface; */ public function sessionStorage(): WebStorageInterface; + /** + * The page's screencast, to record a video and annotate it. + */ + public function screencast(): ScreencastInterface; + public function events(): PageEventHandlerInterface; public function route(string $url, callable $handler): void; diff --git a/src/Screencast/Screencast.php b/src/Screencast/Screencast.php new file mode 100644 index 0000000..f77c261 --- /dev/null +++ b/src/Screencast/Screencast.php @@ -0,0 +1,115 @@ +send([ + 'action' => 'screencast.hideActions', + 'pageId' => $this->pageId, + ]); + } + + public function hideOverlays(): void + { + $this->send([ + 'action' => 'screencast.hideOverlays', + 'pageId' => $this->pageId, + ]); + } + + public function showActions(array $options = []): void + { + $this->send([ + 'action' => 'screencast.showActions', + 'pageId' => $this->pageId, + 'options' => $options, + ]); + } + + public function showChapter(string $title, array $options = []): void + { + $this->send([ + 'action' => 'screencast.showChapter', + 'pageId' => $this->pageId, + 'title' => $title, + 'options' => $options, + ]); + } + + public function showOverlay(string $html, array $options = []): void + { + $this->send([ + 'action' => 'screencast.showOverlay', + 'pageId' => $this->pageId, + 'html' => $html, + 'options' => $options, + ]); + } + + public function showOverlays(): void + { + $this->send([ + 'action' => 'screencast.showOverlays', + 'pageId' => $this->pageId, + ]); + } + + public function start(array $options = []): void + { + $this->send([ + 'action' => 'screencast.start', + 'pageId' => $this->pageId, + 'options' => $options, + ]); + } + + public function stop(): void + { + $this->send([ + 'action' => 'screencast.stop', + 'pageId' => $this->pageId, + ]); + } + + /** + * @param array $payload + * + * @return array + */ + 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; + } +} diff --git a/src/Screencast/ScreencastInterface.php b/src/Screencast/ScreencastInterface.php new file mode 100644 index 0000000..3d380aa --- /dev/null +++ b/src/Screencast/ScreencastInterface.php @@ -0,0 +1,76 @@ + */ + private array $temporaryFiles = []; + + public static function setUpBeforeClass(): void + { + } + + public static function tearDownAfterClass(): void + { + } + + public function setUp(): void + { + $this->setUpPlaywright(); + $this->installRouteServer($this->page, [ + '/index.html' => '

Recording

', + ]); + $this->page->goto($this->routeUrl('/index.html')); + } + + public function tearDown(): void + { + $this->tearDownPlaywright(); + + foreach ($this->temporaryFiles as $file) { + if (is_file($file)) { + unlink($file); + } + } + $this->temporaryFiles = []; + } + + #[Test] + public function itRecordsAVideoToTheGivenPath(): void + { + $path = $this->temporaryPath(); + + $this->page->screencast->start(['path' => $path, 'size' => ['width' => 320, 'height' => 240]]); + $this->page->click('#go'); + usleep(300 * 1000); + $this->page->screencast->stop(); + + $this->assertFileExists($path); + $this->assertGreaterThan(0, (int) filesize($path)); + } + + #[Test] + public function itStartsWithoutAPathAndWritesNothing(): void + { + $this->page->screencast->start(); + usleep(100 * 1000); + $this->page->screencast->stop(); + + $this->assertTrue(true, 'start and stop without a path complete without throwing'); + } + + #[Test] + public function itRejectsASecondStartWhileRecording(): void + { + $this->page->screencast->start(); + + try { + $this->expectException(PlaywrightException::class); + $this->page->screencast->start(); + } finally { + $this->page->screencast->stop(); + } + } + + #[Test] + public function itIgnoresStopWhenNothingIsRecording(): void + { + $this->page->screencast->stop(); + + $this->assertTrue(true, 'stop without a running screencast completes without throwing'); + } + + #[Test] + public function itShowsAndHidesAnOverlay(): void + { + $this->page->screencast->showOverlay('Recording'); + $this->page->screencast->hideOverlays(); + $this->page->screencast->showOverlays(); + + $this->assertTrue(true, 'the overlay lifecycle completes without throwing'); + } + + #[Test] + public function itShowsAnOverlayForALimitedTime(): void + { + $this->page->screencast->showOverlay('Temporary', ['duration' => 100]); + usleep(200 * 1000); + + $this->assertTrue(true, 'a timed overlay is removed without throwing'); + } + + #[Test] + public function itShowsAChapterCard(): void + { + $this->page->screencast->showChapter('Chapter one', ['description' => 'Signing in', 'duration' => 100]); + usleep(200 * 1000); + + $this->assertTrue(true, 'the chapter card is removed without throwing'); + } + + #[Test] + public function itDecoratesActionsUntilHidden(): void + { + $this->page->screencast->showActions(['cursor' => 'pointer', 'duration' => 100, 'position' => 'top-right']); + $this->page->click('#go'); + $this->page->screencast->hideActions(); + + $this->assertTrue(true, 'action decorations are applied and removed without throwing'); + } + + #[Test] + public function itRejectsAnUnknownCursor(): void + { + $this->expectException(PlaywrightException::class); + + $this->page->screencast->showActions(['cursor' => 'wobble']); + } + + #[Test] + public function itAnnotatesARecordingItWrites(): void + { + $path = $this->temporaryPath(); + + $this->page->screencast->start(['path' => $path]); + $this->page->screencast->showChapter('Intro', ['duration' => 100]); + $this->page->screencast->showActions(['duration' => 100]); + $this->page->click('#go'); + $this->page->screencast->hideActions(); + $this->page->screencast->showOverlay('Done', ['duration' => 100]); + usleep(300 * 1000); + $this->page->screencast->stop(); + + $this->assertFileExists($path); + $this->assertGreaterThan(0, (int) filesize($path)); + } + + #[Test] + public function itExposesTheSameScreencastThroughTheInterfaceAccessor(): void + { + $this->assertSame($this->page->screencast, $this->page->screencast()); + } + + private function temporaryPath(): string + { + $path = sprintf('%s/playwright-php-screencast-%s.webm', sys_get_temp_dir(), bin2hex(random_bytes(6))); + $this->temporaryFiles[] = $path; + + return $path; + } +} diff --git a/tests/Unit/Page/PageTest.php b/tests/Unit/Page/PageTest.php index 8fa2f30..18ff9ab 100644 --- a/tests/Unit/Page/PageTest.php +++ b/tests/Unit/Page/PageTest.php @@ -39,6 +39,7 @@ use Playwright\Page\Page; use Playwright\Page\PageEventHandlerInterface; use Playwright\Regex; +use Playwright\Screencast\ScreencastInterface; use Playwright\Transport\TransportInterface; use Playwright\WebStorage\WebStorageInterface; @@ -95,6 +96,12 @@ public function testLocalAndSessionStorageAreDistinctInstances(): void $this->assertNotSame($this->page->localStorage, $this->page->sessionStorage); } + public function testGetScreencast(): void + { + $this->assertInstanceOf(ScreencastInterface::class, $this->page->screencast()); + $this->assertSame($this->page->screencast, $this->page->screencast()); + } + public function testGetEvents(): void { $events = $this->page->events(); diff --git a/tests/Unit/Screencast/ScreencastTest.php b/tests/Unit/Screencast/ScreencastTest.php new file mode 100644 index 0000000..9e3140f --- /dev/null +++ b/tests/Unit/Screencast/ScreencastTest.php @@ -0,0 +1,184 @@ +transport(); + $transport->queueResponse([]); + + $options = [ + 'path' => '/tmp/video.webm', + 'size' => ['width' => 640, 'height' => 480], + 'quality' => 80, + ]; + (new Screencast($transport, 'page_1'))->start($options); + + $sent = $transport->getSentMessages(); + $this->assertCount(1, $sent); + $this->assertSame('screencast.start', $sent[0]['action']); + $this->assertSame('page_1', $sent[0]['pageId']); + $this->assertSame($options, $sent[0]['options']); + } + + public function testStartDefaultsToEmptyOptions(): void + { + $transport = $this->transport(); + $transport->queueResponse([]); + + (new Screencast($transport, 'page_2'))->start(); + + $this->assertSame([], $transport->getSentMessages()[0]['options']); + } + + public function testStopSendsAction(): void + { + $transport = $this->transport(); + $transport->queueResponse([]); + + (new Screencast($transport, 'page_3'))->stop(); + + $sent = $transport->getSentMessages(); + $this->assertSame('screencast.stop', $sent[0]['action']); + $this->assertSame('page_3', $sent[0]['pageId']); + } + + public function testShowOverlaySendsHtmlAndOptions(): void + { + $transport = $this->transport(); + $transport->queueResponse([]); + + (new Screencast($transport, 'page_4'))->showOverlay('hi', ['duration' => 500]); + + $sent = $transport->getSentMessages(); + $this->assertSame('screencast.showOverlay', $sent[0]['action']); + $this->assertSame('hi', $sent[0]['html']); + $this->assertSame(['duration' => 500], $sent[0]['options']); + } + + public function testShowOverlaysSendsAction(): void + { + $transport = $this->transport(); + $transport->queueResponse([]); + + (new Screencast($transport, 'page_5'))->showOverlays(); + + $this->assertSame('screencast.showOverlays', $transport->getSentMessages()[0]['action']); + } + + public function testHideOverlaysSendsAction(): void + { + $transport = $this->transport(); + $transport->queueResponse([]); + + (new Screencast($transport, 'page_6'))->hideOverlays(); + + $this->assertSame('screencast.hideOverlays', $transport->getSentMessages()[0]['action']); + } + + public function testShowActionsSendsOptions(): void + { + $transport = $this->transport(); + $transport->queueResponse([]); + + $options = ['cursor' => 'pointer', 'duration' => 300, 'fontSize' => 20, 'position' => 'top-right']; + (new Screencast($transport, 'page_7'))->showActions($options); + + $sent = $transport->getSentMessages(); + $this->assertSame('screencast.showActions', $sent[0]['action']); + $this->assertSame($options, $sent[0]['options']); + } + + public function testHideActionsSendsAction(): void + { + $transport = $this->transport(); + $transport->queueResponse([]); + + (new Screencast($transport, 'page_8'))->hideActions(); + + $this->assertSame('screencast.hideActions', $transport->getSentMessages()[0]['action']); + } + + public function testShowChapterSendsTitleAndOptions(): void + { + $transport = $this->transport(); + $transport->queueResponse([]); + + (new Screencast($transport, 'page_9'))->showChapter('Step 1', ['description' => 'Sign in', 'duration' => 1500]); + + $sent = $transport->getSentMessages(); + $this->assertSame('screencast.showChapter', $sent[0]['action']); + $this->assertSame('Step 1', $sent[0]['title']); + $this->assertSame(['description' => 'Sign in', 'duration' => 1500], $sent[0]['options']); + } + + public function testShowChapterDefaultsToEmptyOptions(): void + { + $transport = $this->transport(); + $transport->queueResponse([]); + + (new Screencast($transport, 'page_10'))->showChapter('Step 2'); + + $this->assertSame([], $transport->getSentMessages()[0]['options']); + } + + public function testShowOverlayDefaultsToEmptyOptions(): void + { + $transport = $this->transport(); + $transport->queueResponse([]); + + (new Screencast($transport, 'page_11'))->showOverlay('x'); + + $this->assertSame([], $transport->getSentMessages()[0]['options']); + } + + public function testShowActionsDefaultsToEmptyOptions(): void + { + $transport = $this->transport(); + $transport->queueResponse([]); + + (new Screencast($transport, 'page_12'))->showActions(); + + $this->assertSame([], $transport->getSentMessages()[0]['options']); + } + + public function testItRaisesServerErrors(): void + { + $transport = $this->transport(); + $transport->queueResponse(['error' => 'Screencast is already started']); + + $this->expectException(PlaywrightException::class); + $this->expectExceptionMessage('Screencast is already started'); + + (new Screencast($transport, 'page_err'))->start(); + } + + private function transport(): MockTransport + { + $transport = new MockTransport(); + $transport->connect(); + + return $transport; + } +}