From 2f7fcd849d7b919a48ebf3dfd0c21bb2b56195be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Fri, 7 Aug 2026 07:19:38 +0200 Subject: [PATCH 1/2] Add BrowserContext isClosed --- bin/lib/handlers.js | 7 ++++ src/Browser/BrowserContext.php | 15 ++++++++ src/Browser/BrowserContextInterface.php | 5 +++ .../Browser/BrowserContextTest.php | 12 +++++++ tests/Integration/Browser/BrowserTest.php | 11 ++++++ tests/Unit/Browser/BrowserContextTest.php | 36 +++++++++++++++++++ 6 files changed, 86 insertions(+) diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js index b5a6a1e..4523980 100644 --- a/bin/lib/handlers.js +++ b/bin/lib/handlers.js @@ -22,6 +22,13 @@ const evaluateHandleOnTarget = async (target, { expression, arg }) => { class ContextHandler extends BaseHandler { async handle(command, method) { + // Closing a context drops it from the registry, and closing its browser drops it + // too, so an id we no longer know about belongs to a context that is closed. + if (method === 'isClosed') { + const known = this.contexts.get(command.contextId)?.context; + return this.wrapResult({ value: known ? known.isClosed() : true }); + } + const context = this.validateResource(this.contexts, command.contextId, 'Context')?.context; if (!context._initScriptPromise) context._initScriptPromise = Promise.resolve(); diff --git a/src/Browser/BrowserContext.php b/src/Browser/BrowserContext.php index 84740df..ff00a72 100644 --- a/src/Browser/BrowserContext.php +++ b/src/Browser/BrowserContext.php @@ -241,6 +241,21 @@ public function close(): void ]); } + public function isClosed(): bool + { + $response = $this->transport->send([ + 'action' => 'context.isClosed', + 'contextId' => $this->contextId, + ]); + + $value = $response['value'] ?? null; + if (!is_bool($value)) { + throw new ProtocolErrorException('Invalid isClosed response', 0); + } + + return $value; + } + private function saveAutoTrace(): void { $dir = $this->config->traceDir ?? getcwd().'/traces'; diff --git a/src/Browser/BrowserContextInterface.php b/src/Browser/BrowserContextInterface.php index e8145cb..26532db 100644 --- a/src/Browser/BrowserContextInterface.php +++ b/src/Browser/BrowserContextInterface.php @@ -71,6 +71,11 @@ public function clearPermissions(): void; public function close(): void; + /** + * Whether the context is closed, including when its browser was closed instead. + */ + public function isClosed(): bool; + /** * @param array|null $urls * diff --git a/tests/Integration/Browser/BrowserContextTest.php b/tests/Integration/Browser/BrowserContextTest.php index 2ba18fa..164a7da 100644 --- a/tests/Integration/Browser/BrowserContextTest.php +++ b/tests/Integration/Browser/BrowserContextTest.php @@ -60,6 +60,18 @@ public function itCreatesANewPageInContext(): void $page->close(); } + #[Test] + public function itReportsWhetherItIsClosed(): void + { + $context = $this->browser->newContext(); + + $this->assertFalse($context->isClosed()); + + $context->close(); + + $this->assertTrue($context->isClosed()); + } + #[Test] public function itManagesCookies(): void { diff --git a/tests/Integration/Browser/BrowserTest.php b/tests/Integration/Browser/BrowserTest.php index 17b1eb0..9f94078 100644 --- a/tests/Integration/Browser/BrowserTest.php +++ b/tests/Integration/Browser/BrowserTest.php @@ -100,4 +100,15 @@ public function itsContextsPointBackToIt(): void $context->close(); } + + #[Test] + public function itReportsItsContextsAsClosedOnceTheBrowserIsClosed(): void + { + $context = $this->browser->newContext(); + $this->assertFalse($context->isClosed()); + + $this->browser->close(); + + $this->assertTrue($context->isClosed()); + } } diff --git a/tests/Unit/Browser/BrowserContextTest.php b/tests/Unit/Browser/BrowserContextTest.php index afa462a..7a593cf 100644 --- a/tests/Unit/Browser/BrowserContextTest.php +++ b/tests/Unit/Browser/BrowserContextTest.php @@ -21,6 +21,7 @@ use Playwright\Browser\StorageState; use Playwright\Configuration\PlaywrightConfig; use Playwright\Credentials\CredentialsInterface; +use Playwright\Exception\ProtocolErrorException; use Playwright\Network\NetworkThrottling; use Playwright\Page\PageInterface; use Playwright\Tracing\TracingInterface; @@ -111,6 +112,41 @@ public function testClose(): void $this->context->close(); } + public function testIsClosed(): void + { + $this->mockTransport + ->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'context.isClosed', + 'contextId' => 'context_1', + ]) + ->willReturn(['value' => true]); + + $this->assertTrue($this->context->isClosed()); + } + + public function testIsClosedReturnsFalseForAnOpenContext(): void + { + $this->mockTransport + ->method('send') + ->willReturn(['value' => false]); + + $this->assertFalse($this->context->isClosed()); + } + + public function testIsClosedThrowsOnANonBooleanResponse(): void + { + $this->mockTransport + ->method('send') + ->willReturn(['success' => true]); + + $this->expectException(ProtocolErrorException::class); + $this->expectExceptionMessage('Invalid isClosed response'); + + $this->context->isClosed(); + } + public function testAddCookies(): void { $cookies = [ From 409fd17ab28c761cdfd7fd2b40ebb7029688a64c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Fri, 7 Aug 2026 07:21:07 +0200 Subject: [PATCH 2/2] Add Browser bind and unbind --- bin/playwright-server.js | 13 ++++++ src/Browser/Browser.php | 28 +++++++++++++ src/Browser/BrowserInterface.php | 16 ++++++++ tests/Integration/Browser/BrowserTest.php | 39 +++++++++++++++++- tests/Unit/Browser/BrowserTest.php | 50 +++++++++++++++++++++++ 5 files changed, 144 insertions(+), 2 deletions(-) diff --git a/bin/playwright-server.js b/bin/playwright-server.js index 022a8da..463e92b 100644 --- a/bin/playwright-server.js +++ b/bin/playwright-server.js @@ -105,6 +105,8 @@ class PlaywrightServer extends BaseHandler { connect: () => this.connect(command), connectOverCDP: () => this.connectOverCDP(command), newContext: () => this.newContext(command), + bind: () => this.bindBrowser(command), + unbind: () => this.unbindBrowser(command), close: () => this.closeBrowser(command), exit: () => this.exit(), launchServer: () => this.launchServer(command) @@ -277,6 +279,17 @@ class PlaywrightServer extends BaseHandler { return { contextId }; } + async bindBrowser(command) { + const browser = this.validateResource(this.browsers, command.browserId, 'Browser'); + const { endpoint } = await browser.bind(command.title, command.options || {}); + return { endpoint }; + } + + async unbindBrowser(command) { + const browser = this.validateResource(this.browsers, command.browserId, 'Browser'); + await browser.unbind(); + } + async closeBrowser(command) { const browser = this.browsers.get(command.browserId); if (!browser) return; diff --git a/src/Browser/Browser.php b/src/Browser/Browser.php index 55f37a1..41de6ae 100644 --- a/src/Browser/Browser.php +++ b/src/Browser/Browser.php @@ -102,4 +102,32 @@ public function version(): string { return $this->version; } + + /** + * @param array{host?: string, port?: int, workspaceDir?: string, metadata?: array} $options + */ + public function bind(string $title, array $options = []): string + { + $response = $this->transport->send([ + 'action' => 'bind', + 'browserId' => $this->browserId, + 'title' => $title, + 'options' => $options, + ]); + + $endpoint = $response['endpoint'] ?? null; + if (!is_string($endpoint)) { + throw new ProtocolErrorException('Invalid endpoint returned from transport', 0); + } + + return $endpoint; + } + + public function unbind(): void + { + $this->transport->send([ + 'action' => 'unbind', + 'browserId' => $this->browserId, + ]); + } } diff --git a/src/Browser/BrowserInterface.php b/src/Browser/BrowserInterface.php index 31b66d8..915e320 100644 --- a/src/Browser/BrowserInterface.php +++ b/src/Browser/BrowserInterface.php @@ -45,4 +45,20 @@ public function browserType(): BrowserType; public function isConnected(): bool; public function version(): string; + + /** + * Exposes this browser so other Playwright clients can connect to it and drive it. + * + * Binding twice without an intervening unbind() is an error. + * + * @param array{host?: string, port?: int, workspaceDir?: string, metadata?: array} $options + * + * @return string the endpoint to connect to: a local socket path, or a ws:// URL when host or port is given + */ + public function bind(string $title, array $options = []): string; + + /** + * Tears down the server started by bind(); does nothing when the browser is not bound. + */ + public function unbind(): void; } diff --git a/tests/Integration/Browser/BrowserTest.php b/tests/Integration/Browser/BrowserTest.php index 9f94078..020652b 100644 --- a/tests/Integration/Browser/BrowserTest.php +++ b/tests/Integration/Browser/BrowserTest.php @@ -101,13 +101,48 @@ public function itsContextsPointBackToIt(): void $context->close(); } + #[Test] + public function itBindsTheBrowserToALocalSocket(): void + { + $endpoint = $this->browser->bind('playwright-php-socket'); + + try { + $this->assertNotEmpty($endpoint); + } finally { + $this->browser->unbind(); + } + } + + #[Test] + public function itBindsTheBrowserToAWebSocketWhenAPortIsGiven(): void + { + $endpoint = $this->browser->bind('playwright-php-ws', ['host' => '127.0.0.1', 'port' => 0]); + + try { + $this->assertStringStartsWith('ws://127.0.0.1:', $endpoint); + } finally { + $this->browser->unbind(); + } + } + + #[Test] + public function itUnbindsABrowserThatWasNeverBound(): void + { + $this->browser->unbind(); + + $this->assertTrue($this->browser->isConnected()); + } + #[Test] public function itReportsItsContextsAsClosedOnceTheBrowserIsClosed(): void { - $context = $this->browser->newContext(); + // A browser of its own: closing the shared one would force every later test to relaunch it. + $browser = $this->playwright->chromium()->launch(); + $context = $browser->newContext(); + $this->assertFalse($context->isClosed()); - $this->browser->close(); + $browser->close(); $this->assertTrue($context->isClosed()); } diff --git a/tests/Unit/Browser/BrowserTest.php b/tests/Unit/Browser/BrowserTest.php index 7202dce..06f1315 100644 --- a/tests/Unit/Browser/BrowserTest.php +++ b/tests/Unit/Browser/BrowserTest.php @@ -19,6 +19,7 @@ use Playwright\Browser\Browser; use Playwright\Browser\BrowserType; use Playwright\Configuration\PlaywrightConfig; +use Playwright\Exception\ProtocolErrorException; use Playwright\Transport\TransportInterface; #[CoversClass(Browser::class)] @@ -38,6 +39,55 @@ public function testBrowserTypeDefaultsToChromium(): void $this->assertSame(BrowserType::CHROMIUM, $browser->browserType()); } + public function testBindSendsTheTitleAndReturnsTheEndpoint(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport + ->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'bind', + 'browserId' => 'b', + 'title' => 'my-browser', + 'options' => ['port' => 0], + ]) + ->willReturn(['endpoint' => 'ws://127.0.0.1:4242/abc']); + + $browser = new Browser($transport, 'b', 'ctx_default', '1.0', new PlaywrightConfig()); + + $this->assertSame('ws://127.0.0.1:4242/abc', $browser->bind('my-browser', ['port' => 0])); + } + + public function testBindThrowsWhenNoEndpointComesBack(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->method('send')->willReturn(['success' => true]); + + $browser = new Browser($transport, 'b', 'ctx_default', '1.0', new PlaywrightConfig()); + + $this->expectException(ProtocolErrorException::class); + $this->expectExceptionMessage('Invalid endpoint returned from transport'); + + $browser->bind('my-browser'); + } + + public function testUnbindSendsTheBrowserId(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport + ->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'unbind', + 'browserId' => 'b', + ]) + ->willReturn([]); + + $browser = new Browser($transport, 'b', 'ctx_default', '1.0', new PlaywrightConfig()); + + $browser->unbind(); + } + private function browser(BrowserType $type): Browser { return new Browser($this->transport(), 'b', 'ctx_default', '1.0', new PlaywrightConfig(), $type);