From b2326ce6e97a4bc8681c1e8b7ba8ff34687b96e6 Mon Sep 17 00:00:00 2001 From: Mir-Zairan Date: Thu, 1 Oct 2026 16:00:53 +0530 Subject: [PATCH 1/5] Handle route fallback in the server Route::fallback() sent route.fallback, but the server never registered it, so the request was left unanswered until it timed out. Pass it on to Playwright's own fallback so the next matching route or the network answers. --- bin/playwright-server.js | 3 +- tests/Functional/Network/RouteTest.php | 39 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/bin/playwright-server.js b/bin/playwright-server.js index 05fde22..4b58833 100644 --- a/bin/playwright-server.js +++ b/bin/playwright-server.js @@ -128,7 +128,8 @@ class PlaywrightServer extends BaseHandler { fulfill: () => this.fulfillRoute(route, command.options), abort: () => route.abort(command.errorCode), redirectNavigationRequest: () => this.redirectNavigationRequest(route, info, command), - continue: () => this.continueRoute(route, info, command) + continue: () => this.continueRoute(route, info, command), + fallback: () => route.fallback(command.options || undefined) }); logger.info(`ROUTE ${method.toUpperCase()}`, { routeId: command.routeId }); await ErrorHandler.safeExecute(() => this.executeWithRegistry(registry, method), { method, routeId: command.routeId }); diff --git a/tests/Functional/Network/RouteTest.php b/tests/Functional/Network/RouteTest.php index 382850e..a2aec7d 100644 --- a/tests/Functional/Network/RouteTest.php +++ b/tests/Functional/Network/RouteTest.php @@ -98,6 +98,45 @@ public function testCanModifyRequestHeaders(): void $this->assertTrue(true); } + public function testFallbackPassesRequestToNetwork(): void + { + $this->goto('/index.html'); + $this->page->route('**/api/echo', static function (Route $route): void { + $route->fallback(); + }); + + $result = $this->page->evaluate('async () => (await fetch("/api/echo")).json()'); + + $this->assertSame('GET', $result['method'] ?? null); + } + + public function testFallbackPassesRequestToContextRoute(): void + { + $this->goto('/index.html'); + $this->context->route('**/api/echo', static function (Route $route): void { + $route->fulfill(['status' => 200, 'body' => 'from context']); + }); + $this->page->route('**/api/echo', static function (Route $route): void { + $route->fallback(); + }); + + $result = $this->page->evaluate('async () => (await fetch("/api/echo")).text()'); + + $this->assertSame('from context', $result); + } + + public function testFallbackOverridesRequestHeaders(): void + { + $this->goto('/index.html'); + $this->page->route('**/api/echo', static function (Route $route): void { + $route->fallback(['headers' => ['x-playwright-php' => 'from fallback']]); + }); + + $result = $this->page->evaluate('async () => (await fetch("/api/echo")).json()'); + + $this->assertSame('from fallback', $result['requestHeader'] ?? null); + } + public function testCanMatchRoutePattern(): void { $this->goto('/network.html'); From 6cb01a190f8b0732e550399410e8091defc9b380 Mon Sep 17 00:00:00 2001 From: Mir-Zairan Date: Thu, 1 Oct 2026 16:27:08 +0530 Subject: [PATCH 2/5] Dispatch page route events to the handler that registered them Every page route event ran all handlers added with route(), so the oldest handler answered, unrelated patterns ran and fallback() never reached an older route. Each registration now carries its own id through the server. --- bin/lib/core.js | 27 ++++++++-- bin/lib/handlers.js | 5 +- src/Page/Page.php | 39 ++++++++++++-- tests/Functional/Network/RouteTest.php | 74 ++++++++++++++++++++++++++ tests/Unit/Page/PageTest.php | 72 +++++++++++++++++++++++++ 5 files changed, 208 insertions(+), 9 deletions(-) diff --git a/bin/lib/core.js b/bin/lib/core.js index ae77ed7..a29c54a 100644 --- a/bin/lib/core.js +++ b/bin/lib/core.js @@ -304,25 +304,44 @@ class FrameUtils { } } +// Playwright removes a single registration only when unroute() gets the very +// callback route() was given, so each one is kept under the PHP handlerId. +const routeHandlers = new Map(); + class RouteUtils { - static async setupRoute(owner, ownerId, url, generateId, routes, extractRequestData, sendFramedResponse, routeIdFactory = null) { - await owner.route(url, async (route) => { + static async setupRoute(owner, ownerId, url, generateId, routes, extractRequestData, sendFramedResponse, routeIdFactory = null, handlerId = null) { + const callback = async (route) => { const routeId = typeof routeIdFactory === 'function' ? routeIdFactory() : generateId('route'); routes.set(routeId, { route, contextId: ownerId }); const req = route.request(); const requestData = extractRequestData(req); logger.info('ROUTE SETUP', { url: requestData.url || req.url(), method: requestData.method || req.method(), ownerId }); - sendFramedResponse({ objectId: ownerId, event: 'route', params: { routeId, request: requestData } }); - }); + const params = handlerId ? { routeId, handlerId, request: requestData } : { routeId, request: requestData }; + sendFramedResponse({ objectId: ownerId, event: 'route', params }); + }; + await owner.route(url, callback); + if (handlerId) routeHandlers.set(handlerId, { ownerId, url, callback }); return { success: true }; } + static async unroute(owner, handlerIds) { + for (const handlerId of handlerIds) { + const handler = routeHandlers.get(handlerId); + if (!handler) continue; + routeHandlers.delete(handlerId); + await owner.unroute(handler.url, handler.callback); + } + } + // unrouteAll drops the handlers inside Playwright, but the pending Route // objects we handed out stay in the map and can never be answered again. static forgetRoutes(routes, ownerId) { for (const [routeId, info] of routes.entries()) { if (info?.contextId === ownerId) routes.delete(routeId); } + for (const [handlerId, handler] of routeHandlers.entries()) { + if (handler.ownerId === ownerId) routeHandlers.delete(handlerId); + } } } diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js index 6210335..7739915 100644 --- a/bin/lib/handlers.js +++ b/bin/lib/handlers.js @@ -402,8 +402,8 @@ class PageHandler extends BaseHandler { addScriptTag: () => page.addScriptTag(command.options), addStyleTag: () => page.addStyleTag(command.options).then(() => ({ success: true })), handleDialog: () => this.handleDialog(command), - route: () => RouteUtils.setupRoute(page, command.pageId, command.url, this.generateId, this.routes, this.extractRequestData, this.sendFramedResponse, () => `route_${++this.routeCounter.value}`), - unroute: () => page.unroute(command.url), + route: () => RouteUtils.setupRoute(page, command.pageId, command.url, this.generateId, this.routes, this.extractRequestData, this.sendFramedResponse, () => `route_${++this.routeCounter.value}`, command.handlerId), + unroute: () => RouteUtils.unroute(page, command.handlerIds || []), unrouteAll: () => this.unrouteAll(page, command.pageId, command.options), goBack: () => this.followNavigationRedirects(command.pageId, page, () => page.goBack(command.options)), goForward: () => this.followNavigationRedirects(command.pageId, page, () => page.goForward(command.options)), @@ -424,6 +424,7 @@ class PageHandler extends BaseHandler { async closePage(pageId) { const page = this.pages.get(pageId); if (page) { await page.close(); this.pages.delete(pageId); } + RouteUtils.forgetRoutes(this.routes, pageId); } async getVideo(page) { diff --git a/src/Page/Page.php b/src/Page/Page.php index 3a35201..cc957c4 100644 --- a/src/Page/Page.php +++ b/src/Page/Page.php @@ -108,6 +108,11 @@ final class Page implements PageInterface, EventDispatcherInterface */ private array $handledDialogs = []; + /** + * @var array + */ + private array $routeHandlers = []; + public function __construct( private readonly TransportInterface $transport, private readonly BrowserContextInterface $context, @@ -191,6 +196,7 @@ public function dispatchEvent(string $eventName, array $params): void } else { break; } + $this->dispatchRoute($route, $params['handlerId'] ?? null); $this->eventHandler->publicEmit('route', [$route]); break; default: @@ -1189,13 +1195,23 @@ public function frame(array|FrameQueryOptions $options): ?FrameInterface public function route(string $url, callable $handler): void { - $this->eventHandler->onRoute($handler); - $this->sendCommand('route', ['url' => $url]); + // Registered before the command is sent: a request already in flight + // can be routed while the server is still answering it. + $handlerId = uniqid('handler_', true); + $this->routeHandlers[$handlerId] = ['url' => $url, 'handler' => $handler]; + $this->sendCommand('route', ['url' => $url, 'handlerId' => $handlerId]); } public function unroute(string $url, ?callable $handler = null): void { - $this->sendCommand('unroute', ['url' => $url]); + $handlerIds = []; + foreach ($this->routeHandlers as $handlerId => $registration) { + if ($registration['url'] === $url && (null === $handler || $registration['handler'] === $handler)) { + $handlerIds[] = $handlerId; + unset($this->routeHandlers[$handlerId]); + } + } + $this->sendCommand('unroute', ['url' => $url, 'handlerIds' => $handlerIds]); } /** @@ -1203,6 +1219,7 @@ public function unroute(string $url, ?callable $handler = null): void */ public function unrouteAll(array $options = []): void { + $this->routeHandlers = []; $this->sendCommand('unrouteAll', ['options' => $options]); } @@ -1316,6 +1333,22 @@ private function createRequest(mixed $data): Request return new Request($this->validateRequestData($data), $this->transport, null, $this->pageId); } + /** + * A route event carries the registration whose native route matched, so + * Playwright's own order (newest first, then fallback) decides who answers. + */ + private function dispatchRoute(Route $route, mixed $handlerId): void + { + $registration = \is_string($handlerId) ? ($this->routeHandlers[$handlerId] ?? null) : null; + if (null === $registration) { + $route->fallback(); + + return; + } + + $registration['handler']($route); + } + /** * Create a Route object from transport data. */ diff --git a/tests/Functional/Network/RouteTest.php b/tests/Functional/Network/RouteTest.php index a2aec7d..296269b 100644 --- a/tests/Functional/Network/RouteTest.php +++ b/tests/Functional/Network/RouteTest.php @@ -137,6 +137,80 @@ public function testFallbackOverridesRequestHeaders(): void $this->assertSame('from fallback', $result['requestHeader'] ?? null); } + public function testNewestRouteAnswersAndOlderRouteNeverRuns(): void + { + $this->goto('/index.html'); + $calls = []; + $this->page->route('**/api/data.json', static function (Route $route) use (&$calls): void { + $calls[] = 'older'; + $route->fulfill(['status' => 200, 'body' => 'older']); + }); + $this->page->route('**/api/data.json', static function (Route $route) use (&$calls): void { + $calls[] = 'newer'; + $route->fulfill(['status' => 200, 'body' => 'newer']); + }); + + $result = $this->page->evaluate('async () => (await fetch("/api/data.json")).text()'); + + $this->assertSame('newer', $result); + $this->assertSame(['newer'], $calls); + } + + public function testFallbackPassesRequestToOlderPageRoute(): void + { + $this->goto('/index.html'); + $calls = []; + $this->page->route('**/api/data.json', static function (Route $route) use (&$calls): void { + $calls[] = 'older'; + $route->fulfill(['status' => 200, 'body' => 'older']); + }); + $this->page->route('**/api/data.json', static function (Route $route) use (&$calls): void { + $calls[] = 'newer'; + $route->fallback(); + }); + + $result = $this->page->evaluate('async () => (await fetch("/api/data.json")).text()'); + + $this->assertSame('older', $result); + $this->assertSame(['newer', 'older'], $calls); + } + + public function testRouteNeverRunsForAUrlItDoesNotMatch(): void + { + $this->goto('/index.html'); + $calls = []; + $this->page->route('**/api/data.json', static function (Route $route) use (&$calls): void { + $calls[] = 'data'; + $route->fulfill(['status' => 200, 'body' => 'data']); + }); + $this->page->route('**/api/other', static function (Route $route) use (&$calls): void { + $calls[] = 'other'; + $route->fulfill(['status' => 200, 'body' => 'other']); + }); + + $result = $this->page->evaluate('async () => (await fetch("/api/other")).text()'); + + $this->assertSame('other', $result); + $this->assertSame(['other'], $calls); + } + + public function testUnrouteWithHandlerKeepsOtherHandlersOnTheSameUrl(): void + { + $this->goto('/index.html'); + $this->page->route('**/api/data.json', static function (Route $route): void { + $route->fulfill(['status' => 200, 'body' => 'kept']); + }); + $removed = static function (Route $route): void { + $route->fulfill(['status' => 200, 'body' => 'removed']); + }; + $this->page->route('**/api/data.json', $removed); + + $this->page->unroute('**/api/data.json', $removed); + $result = $this->page->evaluate('async () => (await fetch("/api/data.json")).text()'); + + $this->assertSame('kept', $result); + } + public function testCanMatchRoutePattern(): void { $this->goto('/network.html'); diff --git a/tests/Unit/Page/PageTest.php b/tests/Unit/Page/PageTest.php index 4747e77..5b64ddc 100644 --- a/tests/Unit/Page/PageTest.php +++ b/tests/Unit/Page/PageTest.php @@ -649,6 +649,7 @@ public function testUnrouteSendsPageCommand(): void ->method('send') ->with([ 'url' => '**/api/**', + 'handlerIds' => [], 'action' => 'page.unroute', 'pageId' => 'page-id', ]) @@ -657,6 +658,62 @@ public function testUnrouteSendsPageCommand(): void $page->unroute('**/api/**'); } + public function testRouteEventRunsOnlyTheHandlerThatRegisteredIt(): void + { + $sent = $this->recordSentCommands(); + $calls = []; + $this->page->route('**/api/**', static function () use (&$calls): void { + $calls[] = 'first'; + }); + $this->page->route('**/api/**', static function () use (&$calls): void { + $calls[] = 'second'; + }); + + $this->page->dispatchEvent('route', [ + 'routeId' => 'route_1', + 'handlerId' => $sent[1]['handlerId'], + 'request' => ['url' => 'https://example.com/api/data'], + ]); + + $this->assertSame(['second'], $calls); + } + + public function testRouteEventForAnUnroutedHandlerFallsBack(): void + { + $sent = $this->recordSentCommands(); + $called = false; + $handler = static function () use (&$called): void { + $called = true; + }; + $this->page->route('**/api/**', $handler); + $this->page->unroute('**/api/**', $handler); + + $this->transport->expects($this->once()) + ->method('sendAsync') + ->with(['action' => 'route.fallback', 'routeId' => 'route_1', 'options' => null]); + + $this->page->dispatchEvent('route', [ + 'routeId' => 'route_1', + 'handlerId' => $sent[0]['handlerId'], + 'request' => ['url' => 'https://example.com/api/data'], + ]); + + $this->assertFalse($called); + } + + public function testUnrouteWithHandlerSendsOnlyItsRegistrations(): void + { + $sent = $this->recordSentCommands(); + $removed = static function (): void {}; + $this->page->route('**/api/**', static function (): void {}); + $this->page->route('**/api/**', $removed); + $this->page->route('**/other/**', $removed); + + $this->page->unroute('**/api/**', $removed); + + $this->assertSame([$sent[1]['handlerId']], $sent[3]['handlerIds']); + } + public function testMainFrame(): void { $page = $this->createPage(); @@ -1235,6 +1292,21 @@ public function testQueriedFramesRetainTheirOriginatingPage(): void $this->assertSame($this->page, $frame->page()); } + /** + * @return \ArrayObject> + */ + private function recordSentCommands(): \ArrayObject + { + $sent = new \ArrayObject(); + $this->transport->method('send')->willReturnCallback(static function (array $payload) use ($sent): array { + $sent[] = $payload; + + return []; + }); + + return $sent; + } + private function createPage(string $pageId = 'page-1'): Page { return new Page($this->transport, $this->context, $pageId, new PlaywrightConfig()); From 15f1cec950d6ee11634b218a875635fd5f287093 Mon Sep 17 00:00:00 2001 From: Mir-Zairan Date: Thu, 1 Oct 2026 16:48:29 +0530 Subject: [PATCH 3/5] Share route ids between pages and contexts and clear page route registrations Page routes counted ids on a copy of the route counter, so a page route and a context route could both be route_1 and answer each other's requests. Route registrations now live with the other server maps, are dropped when their page or context closes, and a failed route() no longer leaves its handler behind. --- bin/lib/core.js | 16 +++-- bin/lib/handlers.js | 17 +++-- bin/playwright-server.js | 7 ++- src/Page/Page.php | 17 +++-- src/Page/PageEventHandlerInterface.php | 5 ++ tests/Functional/Network/RouteTest.php | 87 ++++++++++++++++++++++++++ tests/Unit/Page/PageTest.php | 62 ++++++++++++++++++ 7 files changed, 184 insertions(+), 27 deletions(-) diff --git a/bin/lib/core.js b/bin/lib/core.js index a29c54a..f797faa 100644 --- a/bin/lib/core.js +++ b/bin/lib/core.js @@ -304,14 +304,12 @@ class FrameUtils { } } -// Playwright removes a single registration only when unroute() gets the very -// callback route() was given, so each one is kept under the PHP handlerId. -const routeHandlers = new Map(); - class RouteUtils { - static async setupRoute(owner, ownerId, url, generateId, routes, extractRequestData, sendFramedResponse, routeIdFactory = null, handlerId = null) { + // Playwright removes a single registration only when unroute() gets the very + // callback route() was given, so each one is kept under the PHP handlerId. + static async setupRoute(owner, ownerId, url, generateId, routes, extractRequestData, sendFramedResponse, routeHandlers = null, handlerId = null) { const callback = async (route) => { - const routeId = typeof routeIdFactory === 'function' ? routeIdFactory() : generateId('route'); + const routeId = generateId('route'); routes.set(routeId, { route, contextId: ownerId }); const req = route.request(); const requestData = extractRequestData(req); @@ -324,10 +322,10 @@ class RouteUtils { return { success: true }; } - static async unroute(owner, handlerIds) { + static async unroute(owner, ownerId, routeHandlers, handlerIds) { for (const handlerId of handlerIds) { const handler = routeHandlers.get(handlerId); - if (!handler) continue; + if (!handler || handler.ownerId !== ownerId) continue; routeHandlers.delete(handlerId); await owner.unroute(handler.url, handler.callback); } @@ -335,7 +333,7 @@ class RouteUtils { // unrouteAll drops the handlers inside Playwright, but the pending Route // objects we handed out stay in the map and can never be answered again. - static forgetRoutes(routes, ownerId) { + static forgetRoutes(routes, routeHandlers, ownerId) { for (const [routeId, info] of routes.entries()) { if (info?.contextId === ownerId) routes.delete(routeId); } diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js index 7739915..69c05d6 100644 --- a/bin/lib/handlers.js +++ b/bin/lib/handlers.js @@ -243,7 +243,7 @@ class ContextHandler extends BaseHandler { async unrouteAll(context, contextId, options) { await context.unrouteAll(options || {}); - RouteUtils.forgetRoutes(this.routes, contextId); + RouteUtils.forgetRoutes(this.routes, this.routeHandlers, contextId); } setThrottling(command) { @@ -295,14 +295,11 @@ class ContextHandler extends BaseHandler { if (mappedContextId === contextId) { this.pageContexts.delete(pageId); this.pages.delete(pageId); + RouteUtils.forgetRoutes(this.routes, this.routeHandlers, pageId); } } - for (const [routeId, info] of this.routes.entries()) { - if (info?.contextId === contextId) { - this.routes.delete(routeId); - } - } + RouteUtils.forgetRoutes(this.routes, this.routeHandlers, contextId); } async waitForPopup(context, command) { @@ -402,8 +399,8 @@ class PageHandler extends BaseHandler { addScriptTag: () => page.addScriptTag(command.options), addStyleTag: () => page.addStyleTag(command.options).then(() => ({ success: true })), handleDialog: () => this.handleDialog(command), - route: () => RouteUtils.setupRoute(page, command.pageId, command.url, this.generateId, this.routes, this.extractRequestData, this.sendFramedResponse, () => `route_${++this.routeCounter.value}`, command.handlerId), - unroute: () => RouteUtils.unroute(page, command.handlerIds || []), + route: () => RouteUtils.setupRoute(page, command.pageId, command.url, this.generateId, this.routes, this.extractRequestData, this.sendFramedResponse, this.routeHandlers, command.handlerId), + unroute: () => RouteUtils.unroute(page, command.pageId, this.routeHandlers, command.handlerIds || []), unrouteAll: () => this.unrouteAll(page, command.pageId, command.options), goBack: () => this.followNavigationRedirects(command.pageId, page, () => page.goBack(command.options)), goForward: () => this.followNavigationRedirects(command.pageId, page, () => page.goForward(command.options)), @@ -424,7 +421,7 @@ class PageHandler extends BaseHandler { async closePage(pageId) { const page = this.pages.get(pageId); if (page) { await page.close(); this.pages.delete(pageId); } - RouteUtils.forgetRoutes(this.routes, pageId); + RouteUtils.forgetRoutes(this.routes, this.routeHandlers, pageId); } async getVideo(page) { @@ -535,7 +532,7 @@ class PageHandler extends BaseHandler { async unrouteAll(page, pageId, options) { await page.unrouteAll(options || {}); - RouteUtils.forgetRoutes(this.routes, pageId); + RouteUtils.forgetRoutes(this.routes, this.routeHandlers, pageId); } async querySelector(page, command) { diff --git a/bin/playwright-server.js b/bin/playwright-server.js index 4b58833..bae30fb 100644 --- a/bin/playwright-server.js +++ b/bin/playwright-server.js @@ -1,5 +1,5 @@ const {chromium, firefox, webkit, request} = require('playwright'); -const { logger, ErrorHandler, LspFraming, sendFramedResponse, CommandRegistry, BaseHandler } = require('./lib/core'); +const { logger, ErrorHandler, LspFraming, sendFramedResponse, CommandRegistry, BaseHandler, RouteUtils } = require('./lib/core'); const { APIRequestHandler, ContextHandler, PageHandler, LocatorHandler, InteractionHandler, FrameHandler, JSHandleHandler, SelectorsHandler, VideoHandler } = require('./lib/handlers'); const { globalCoordinator } = require('./lib/coordination'); @@ -17,6 +17,7 @@ class PlaywrightServer extends BaseHandler { this.pageContexts = new Map(); this.responses = new Map(); this.routes = new Map(); + this.routeHandlers = new Map(); this.dialogs = new Map(); this.elementHandles = new Map(); this.contextThrottling = new Map(); @@ -32,12 +33,11 @@ class PlaywrightServer extends BaseHandler { 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), + responses: this.responses, routes: this.routes, routeHandlers: this.routeHandlers, videos: this.videos, generateId: this.generateId.bind(this), navigationRedirects: this.navigationRedirects, extractRequestData: this.extractRequestData.bind(this), serializeResponse: this.serializeResponse.bind(this), serializeConsoleMessage: this.serializeConsoleMessage.bind(this), sendFramedResponse, - routeCounter: { value: this.counters.route }, setupPageEventListeners: this.setupPageEventListeners.bind(this) }; this.apiRequestHandler = new APIRequestHandler(deps); @@ -174,6 +174,7 @@ class PlaywrightServer extends BaseHandler { sendFramedResponse({ objectId: pageId, event: eventName, params }); })); }); + page.once('close', () => RouteUtils.forgetRoutes(this.routes, this.routeHandlers, pageId)); } // Pages opened by the browser itself (window.open(), target="_blank", ...) never go through diff --git a/src/Page/Page.php b/src/Page/Page.php index cc957c4..04a22f4 100644 --- a/src/Page/Page.php +++ b/src/Page/Page.php @@ -187,7 +187,8 @@ public function dispatchEvent(string $eventName, array $params): void $this->eventHandler->publicEmit('close', []); break; case 'route': - if (is_string($params['routeId']) && is_array($params['request'])) { + $handlerId = $params['handlerId'] ?? null; + if (is_string($params['routeId']) && is_array($params['request']) && (null === $handlerId || is_string($handlerId))) { $route = $this->createRoute( $this->pageId, $params['routeId'], @@ -196,7 +197,7 @@ public function dispatchEvent(string $eventName, array $params): void } else { break; } - $this->dispatchRoute($route, $params['handlerId'] ?? null); + $this->dispatchRoute($route, $handlerId); $this->eventHandler->publicEmit('route', [$route]); break; default: @@ -1199,7 +1200,13 @@ public function route(string $url, callable $handler): void // can be routed while the server is still answering it. $handlerId = uniqid('handler_', true); $this->routeHandlers[$handlerId] = ['url' => $url, 'handler' => $handler]; - $this->sendCommand('route', ['url' => $url, 'handlerId' => $handlerId]); + try { + $this->sendCommand('route', ['url' => $url, 'handlerId' => $handlerId]); + } catch (\Throwable $e) { + unset($this->routeHandlers[$handlerId]); + + throw $e; + } } public function unroute(string $url, ?callable $handler = null): void @@ -1337,9 +1344,9 @@ private function createRequest(mixed $data): Request * A route event carries the registration whose native route matched, so * Playwright's own order (newest first, then fallback) decides who answers. */ - private function dispatchRoute(Route $route, mixed $handlerId): void + private function dispatchRoute(Route $route, ?string $handlerId): void { - $registration = \is_string($handlerId) ? ($this->routeHandlers[$handlerId] ?? null) : null; + $registration = null === $handlerId ? null : ($this->routeHandlers[$handlerId] ?? null); if (null === $registration) { $route->fallback(); diff --git a/src/Page/PageEventHandlerInterface.php b/src/Page/PageEventHandlerInterface.php index 543ab54..a01f8a0 100644 --- a/src/Page/PageEventHandlerInterface.php +++ b/src/Page/PageEventHandlerInterface.php @@ -31,5 +31,10 @@ public function onResponse(callable $handler): void; public function onRequestFailed(callable $handler): void; + /** + * Observes routed requests only: the handler registered with route() + * answers each one before these listeners run, so they must not + * fulfill, abort, continue or fall back the route themselves. + */ public function onRoute(callable $handler): void; } diff --git a/tests/Functional/Network/RouteTest.php b/tests/Functional/Network/RouteTest.php index 296269b..304ebf4 100644 --- a/tests/Functional/Network/RouteTest.php +++ b/tests/Functional/Network/RouteTest.php @@ -211,6 +211,93 @@ public function testUnrouteWithHandlerKeepsOtherHandlersOnTheSameUrl(): void $this->assertSame('kept', $result); } + public function testUnrouteWithoutHandlerRemovesEveryRouteOnThatUrl(): void + { + $this->goto('/index.html'); + $this->page->route('**/api/data.json', static function (Route $route): void { + $route->fulfill(['status' => 200, 'body' => 'first']); + }); + $this->page->route('**/api/data.json', static function (Route $route): void { + $route->fulfill(['status' => 200, 'body' => 'second']); + }); + $this->page->route('**/api/other', static function (Route $route): void { + $route->fulfill(['status' => 200, 'body' => 'other']); + }); + + $this->page->unroute('**/api/data.json'); + $status = $this->page->evaluate('async () => (await fetch("/api/data.json")).status'); + $other = $this->page->evaluate('async () => (await fetch("/api/other")).text()'); + + $this->assertSame(404, $status); + $this->assertSame('other', $other); + } + + public function testRouteAddedAfterUnrouteAnswersAlone(): void + { + $this->goto('/index.html'); + $calls = []; + $first = static function (Route $route) use (&$calls): void { + $calls[] = 'A'; + $route->fulfill(['status' => 200, 'body' => 'A']); + }; + $this->page->route('**/api/data.json', $first); + $this->page->unroute('**/api/data.json', $first); + $this->page->route('**/api/data.json', static function (Route $route) use (&$calls): void { + $calls[] = 'B'; + $route->fulfill(['status' => 200, 'body' => 'B']); + }); + + $result = $this->page->evaluate('async () => (await fetch("/api/data.json")).text()'); + + $this->assertSame('B', $result); + $this->assertSame(['B'], $calls); + } + + public function testRouteAddedAfterUnrouteAllAnswersAlone(): void + { + $this->goto('/index.html'); + $calls = []; + $this->page->route('**/api/data.json', static function (Route $route) use (&$calls): void { + $calls[] = 'A'; + $route->fulfill(['status' => 200, 'body' => 'A']); + }); + $this->page->unrouteAll(); + $this->page->route('**/api/data.json', static function (Route $route) use (&$calls): void { + $calls[] = 'B'; + $route->fulfill(['status' => 200, 'body' => 'B']); + }); + + $result = $this->page->evaluate('async () => (await fetch("/api/data.json")).text()'); + + $this->assertSame('B', $result); + $this->assertSame(['B'], $calls); + } + + public function testPageAndContextRoutesInFlightTogetherAnswerTheirOwnRequests(): void + { + $this->goto('/index.html'); + // Neither request is answered until both are waiting on the server. + $held = []; + $answerBoth = static function () use (&$held): void { + if (2 === \count($held)) { + $held['page']->fulfill(['status' => 200, 'body' => 'from page']); + $held['context']->fulfill(['status' => 200, 'body' => 'from context']); + } + }; + $this->page->route('**/api/data.json', static function (Route $route) use (&$held, $answerBoth): void { + $held['page'] = $route; + $answerBoth(); + }); + $this->context->route('**/api/other', static function (Route $route) use (&$held, $answerBoth): void { + $held['context'] = $route; + $answerBoth(); + }); + + $result = $this->page->evaluate('async () => Promise.all(["/api/data.json", "/api/other"].map(async url => (await fetch(url)).text()))'); + + $this->assertSame(['from page', 'from context'], $result); + } + public function testCanMatchRoutePattern(): void { $this->goto('/network.html'); diff --git a/tests/Unit/Page/PageTest.php b/tests/Unit/Page/PageTest.php index 5b64ddc..1a43010 100644 --- a/tests/Unit/Page/PageTest.php +++ b/tests/Unit/Page/PageTest.php @@ -23,6 +23,7 @@ use Playwright\Clock\ClockInterface; use Playwright\Configuration\PlaywrightConfig; use Playwright\Console\ConsoleMessage; +use Playwright\Exception\PlaywrightException; use Playwright\Exception\ProtocolErrorException; use Playwright\Exception\RuntimeException; use Playwright\Exception\TimeoutException; @@ -701,6 +702,67 @@ public function testRouteEventForAnUnroutedHandlerFallsBack(): void $this->assertFalse($called); } + public function testRouteEventAfterUnrouteAllFallsBack(): void + { + $sent = $this->recordSentCommands(); + $called = false; + $this->page->route('**/api/**', static function () use (&$called): void { + $called = true; + }); + $this->page->unrouteAll(); + + $this->transport->expects($this->once()) + ->method('sendAsync') + ->with(['action' => 'route.fallback', 'routeId' => 'route_1', 'options' => null]); + + $this->page->dispatchEvent('route', [ + 'routeId' => 'route_1', + 'handlerId' => $sent[0]['handlerId'], + 'request' => ['url' => 'https://example.com/api/data'], + ]); + + $this->assertFalse($called); + } + + public function testRouteEventWithoutHandlerIdFallsBack(): void + { + $this->recordSentCommands(); + $called = false; + $this->page->route('**/api/**', static function () use (&$called): void { + $called = true; + }); + + $this->transport->expects($this->once()) + ->method('sendAsync') + ->with(['action' => 'route.fallback', 'routeId' => 'route_1', 'options' => null]); + + $this->page->dispatchEvent('route', [ + 'routeId' => 'route_1', + 'request' => ['url' => 'https://example.com/api/data'], + ]); + + $this->assertFalse($called); + } + + public function testRouteThatTheServerRejectsIsNotKept(): void + { + $sent = []; + $this->transport->method('send')->willReturnCallback(static function (array $payload) use (&$sent): array { + $sent[] = $payload; + + return 'page.route' === $payload['action'] ? ['error' => 'Invalid url'] : []; + }); + + try { + $this->page->route('**/api/**', static function (): void {}); + $this->fail('route() should rethrow the server error'); + } catch (PlaywrightException) { + } + $this->page->unroute('**/api/**'); + + $this->assertSame([], $sent[1]['handlerIds']); + } + public function testUnrouteWithHandlerSendsOnlyItsRegistrations(): void { $sent = $this->recordSentCommands(); From a2ffb16770c7748e0906c2373162dc3675477cda Mon Sep 17 00:00:00 2001 From: Mir-Zairan Date: Thu, 1 Oct 2026 16:56:00 +0530 Subject: [PATCH 4/5] Dispatch context route events to the handler that registered them Context routes ran the oldest handler whose fnmatch() pattern matched, so the newest route never answered, fallback() could not reach an older route and unroute() with a handler dropped every route on the URL. They now use the same per-registration ids as page routes. --- bin/lib/handlers.js | 4 +- src/Browser/BrowserContext.php | 62 +++++++++---- tests/Functional/Network/RouteTest.php | 93 +++++++++++++++++++ tests/Unit/Browser/BrowserContextTest.php | 104 +++++++++++++++++++--- 4 files changed, 232 insertions(+), 31 deletions(-) diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js index 69c05d6..b70b674 100644 --- a/bin/lib/handlers.js +++ b/bin/lib/handlers.js @@ -143,8 +143,8 @@ class ContextHandler extends BaseHandler { setNetworkThrottling: () => this.setThrottling(command), setDefaultTimeout: () => context.setDefaultTimeout(command.timeout), setDefaultNavigationTimeout: () => context.setDefaultNavigationTimeout(command.timeout), - route: () => RouteUtils.setupRoute(context, command.contextId, command.url, this.generateId, this.routes, this.extractRequestData, this.sendFramedResponse), - unroute: () => context.unroute(command.url), + route: () => RouteUtils.setupRoute(context, command.contextId, command.url, this.generateId, this.routes, this.extractRequestData, this.sendFramedResponse, this.routeHandlers, command.handlerId), + unroute: () => RouteUtils.unroute(context, command.contextId, this.routeHandlers, command.handlerIds || []), unrouteAll: () => this.unrouteAll(context, command.contextId, command.options), cookies: async () => ({ cookies: await context.cookies(command.urls) }), storageState: async () => ({ storageState: await context.storageState(command.options) }), diff --git a/src/Browser/BrowserContext.php b/src/Browser/BrowserContext.php index 5bf917d..9ad4397 100644 --- a/src/Browser/BrowserContext.php +++ b/src/Browser/BrowserContext.php @@ -41,7 +41,7 @@ final class BrowserContext implements BrowserContextInterface, EventDispatcherIn private array $pages = []; /** - * @var array + * @var array */ private array $routeHandlers = []; @@ -117,19 +117,16 @@ public function dispatchEvent(string $eventName, array $params): void if (!is_array($params['request'])) { throw new ProtocolErrorException('Invalid request data in route event', 0); } + $handlerId = $params['handlerId'] ?? null; + if (null !== $handlerId && !is_string($handlerId)) { + throw new ProtocolErrorException('Invalid handlerId in route event', 0); + } $route = new Route( $this->transport, $params['routeId'], $this->validateTransportArray($params['request'], 'request') ); - foreach ($this->routeHandlers as $handler) { - if (fnmatch($handler['url'], $route->request()->url())) { - $handler['handler']($route); - - return; - } - } - $route->continue(); + $this->dispatchRoute($route, $handlerId); } if (in_array($eventName, ['page', 'popup', 'pageCreated'], true)) { @@ -518,21 +515,38 @@ public function setOffline(bool $offline): void public function route(string $url, callable $handler): void { - $this->transport->send([ - 'action' => 'context.route', - 'contextId' => $this->contextId, - 'url' => $url, - ]); - $this->routeHandlers[] = ['url' => $url, 'handler' => $handler]; + // Registered before the command is sent: a request already in flight + // can be routed while the server is still answering it. + $handlerId = uniqid('handler_', true); + $this->routeHandlers[$handlerId] = ['url' => $url, 'handler' => $handler]; + try { + $this->transport->send([ + 'action' => 'context.route', + 'contextId' => $this->contextId, + 'url' => $url, + 'handlerId' => $handlerId, + ]); + } catch (\Throwable $e) { + unset($this->routeHandlers[$handlerId]); + + throw $e; + } } public function unroute(string $url, ?callable $handler = null): void { - $this->routeHandlers = array_filter($this->routeHandlers, fn ($h) => $h['url'] !== $url); + $handlerIds = []; + foreach ($this->routeHandlers as $handlerId => $registration) { + if ($registration['url'] === $url && (null === $handler || $registration['handler'] === $handler)) { + $handlerIds[] = $handlerId; + unset($this->routeHandlers[$handlerId]); + } + } $this->transport->send([ 'action' => 'context.unroute', 'contextId' => $this->contextId, 'url' => $url, + 'handlerIds' => $handlerIds, ]); } @@ -665,6 +679,22 @@ private static function validateExtraHttpHeaders(array $headers): void } } + /** + * A route event carries the registration whose native route matched, so + * Playwright's own order (newest first, then fallback) decides who answers. + */ + private function dispatchRoute(Route $route, ?string $handlerId): void + { + $registration = null === $handlerId ? null : ($this->routeHandlers[$handlerId] ?? null); + if (null === $registration) { + $route->fallback(); + + return; + } + + $registration['handler']($route); + } + /** * Helper method to validate and cast transport data to proper array type. * diff --git a/tests/Functional/Network/RouteTest.php b/tests/Functional/Network/RouteTest.php index 304ebf4..3882b2c 100644 --- a/tests/Functional/Network/RouteTest.php +++ b/tests/Functional/Network/RouteTest.php @@ -298,6 +298,99 @@ public function testPageAndContextRoutesInFlightTogetherAnswerTheirOwnRequests() $this->assertSame(['from page', 'from context'], $result); } + public function testNewestContextRouteAnswersAndOlderContextRouteNeverRuns(): void + { + $this->goto('/index.html'); + $calls = []; + $this->context->route('**/api/data.json', static function (Route $route) use (&$calls): void { + $calls[] = 'older'; + $route->fulfill(['status' => 200, 'body' => 'older']); + }); + $this->context->route('**/api/data.json', static function (Route $route) use (&$calls): void { + $calls[] = 'newer'; + $route->fulfill(['status' => 200, 'body' => 'newer']); + }); + + $result = $this->page->evaluate('async () => (await fetch("/api/data.json")).text()'); + + $this->assertSame('newer', $result); + $this->assertSame(['newer'], $calls); + } + + public function testFallbackPassesRequestToOlderContextRoute(): void + { + $this->goto('/index.html'); + $calls = []; + $this->context->route('**/api/data.json', static function (Route $route) use (&$calls): void { + $calls[] = 'older'; + $route->fulfill(['status' => 200, 'body' => 'older']); + }); + $this->context->route('**/api/data.json', static function (Route $route) use (&$calls): void { + $calls[] = 'newer'; + $route->fallback(); + }); + + $result = $this->page->evaluate('async () => (await fetch("/api/data.json")).text()'); + + $this->assertSame('older', $result); + $this->assertSame(['newer', 'older'], $calls); + } + + public function testContextRouteFallbackPassesRequestToNetwork(): void + { + $this->goto('/index.html'); + $calls = []; + $this->context->route('**/api/echo', static function (Route $route) use (&$calls): void { + $calls[] = 'older'; + $route->fallback(); + }); + $this->context->route('**/api/echo', static function (Route $route) use (&$calls): void { + $calls[] = 'newer'; + $route->fallback(['headers' => ['x-playwright-php' => 'from context']]); + }); + + $result = $this->page->evaluate('async () => (await fetch("/api/echo")).json()'); + + $this->assertSame('from context', $result['requestHeader'] ?? null); + $this->assertSame(['newer', 'older'], $calls); + } + + public function testContextRouteSeesHeadersSetByPageRouteFallback(): void + { + $this->goto('/index.html'); + $seen = null; + // Braces are Playwright glob syntax that fnmatch() does not understand. + $this->context->route('**/api/{echo,other}', static function (Route $route) use (&$seen): void { + $seen = $route->request()->headers()['x-playwright-php'] ?? null; + $route->fulfill(['status' => 200, 'body' => 'from context']); + }); + $this->page->route('**/api/echo', static function (Route $route): void { + $route->fallback(['headers' => ['x-playwright-php' => 'from page']]); + }); + + $result = $this->page->evaluate('async () => (await fetch("/api/echo")).text()'); + + $this->assertSame('from context', $result); + $this->assertSame('from page', $seen); + } + + public function testContextUnrouteWithHandlerKeepsOtherHandlersOnTheSameUrl(): void + { + $this->goto('/index.html'); + $this->context->route('**/api/data.json', static function (Route $route): void { + $route->fulfill(['status' => 200, 'body' => 'kept']); + }); + $removed = static function (Route $route): void { + $route->fulfill(['status' => 200, 'body' => 'removed']); + }; + $this->context->route('**/api/data.json', $removed); + + $this->context->unroute('**/api/data.json', $removed); + $result = $this->page->evaluate('async () => (await fetch("/api/data.json")).text()'); + + $this->assertSame('kept', $result); + } + public function testCanMatchRoutePattern(): void { $this->goto('/network.html'); diff --git a/tests/Unit/Browser/BrowserContextTest.php b/tests/Unit/Browser/BrowserContextTest.php index bfa1b15..0558272 100644 --- a/tests/Unit/Browser/BrowserContextTest.php +++ b/tests/Unit/Browser/BrowserContextTest.php @@ -482,22 +482,17 @@ public function testSetOffline(): void $this->context->setOffline(true); } - public function testRoute(): void + public function testRouteSendsItsHandlerIdWithTheCommand(): void { - $handler = function ($route) { - $route->fulfill(['body' => 'mocked']); - }; + $sent = []; + $context = new BrowserContext($this->createRecordingTransport($sent), 'context_1', $this->config); - $this->mockTransport - ->expects($this->once()) - ->method('send') - ->with([ - 'action' => 'context.route', - 'contextId' => 'context_1', - 'url' => '**/*.api', - ]); + $context->route('**/*.api', static function (): void {}); - $this->context->route('**/*.api', $handler); + $this->assertSame('context.route', $sent[0]['action']); + $this->assertSame('context_1', $sent[0]['contextId']); + $this->assertSame('**/*.api', $sent[0]['url']); + $this->assertIsString($sent[0]['handlerId']); } public function testUnroute(): void @@ -509,11 +504,94 @@ public function testUnroute(): void 'action' => 'context.unroute', 'contextId' => 'context_1', 'url' => '**/*.api', + 'handlerIds' => [], ]); $this->context->unroute('**/*.api'); } + public function testRouteEventRunsOnlyTheHandlerThatRegisteredIt(): void + { + $sent = []; + $context = new BrowserContext($this->createRecordingTransport($sent), 'context_1', $this->config); + $calls = []; + $context->route('**/api/**', static function () use (&$calls): void { + $calls[] = 'first'; + }); + $context->route('**/api/**', static function () use (&$calls): void { + $calls[] = 'second'; + }); + + $context->dispatchEvent('route', [ + 'routeId' => 'route_1', + 'handlerId' => $sent[1]['handlerId'], + 'request' => ['url' => 'https://example.com/api/data'], + ]); + + $this->assertSame(['second'], $calls); + } + + public function testRouteEventForAnUnroutedHandlerFallsBack(): void + { + $sent = []; + $transport = $this->createRecordingTransport($sent); + $context = new BrowserContext($transport, 'context_1', $this->config); + $called = false; + $handler = static function () use (&$called): void { + $called = true; + }; + $context->route('**/api/**', $handler); + $context->unroute('**/api/**', $handler); + + $transport->expects($this->once()) + ->method('sendAsync') + ->with(['action' => 'route.fallback', 'routeId' => 'route_1', 'options' => null]); + + $context->dispatchEvent('route', [ + 'routeId' => 'route_1', + 'handlerId' => $sent[0]['handlerId'], + 'request' => ['url' => 'https://example.com/api/data'], + ]); + + $this->assertFalse($called); + } + + public function testRouteEventWithoutHandlerIdFallsBack(): void + { + $sent = []; + $transport = $this->createRecordingTransport($sent); + $context = new BrowserContext($transport, 'context_1', $this->config); + $called = false; + $context->route('**/api/**', static function () use (&$called): void { + $called = true; + }); + + $transport->expects($this->once()) + ->method('sendAsync') + ->with(['action' => 'route.fallback', 'routeId' => 'route_1', 'options' => null]); + + $context->dispatchEvent('route', [ + 'routeId' => 'route_1', + 'request' => ['url' => 'https://example.com/api/data'], + ]); + + $this->assertFalse($called); + } + + public function testUnrouteWithHandlerSendsOnlyItsRegistrations(): void + { + $sent = []; + $context = new BrowserContext($this->createRecordingTransport($sent), 'context_1', $this->config); + $removed = static function (): void {}; + $context->route('**/api/**', static function (): void {}); + $context->route('**/api/**', $removed); + $context->route('**/other/**', $removed); + + $context->unroute('**/api/**', $removed); + + $this->assertSame([$sent[1]['handlerId']], $sent[3]['handlerIds']); + } + public function testUnrouteAll(): void { $this->mockTransport From 177a4f086e037b0ff855b830f2b23d19b04334b7 Mon Sep 17 00:00:00 2001 From: Mir-Zairan Date: Thu, 1 Oct 2026 17:24:16 +0530 Subject: [PATCH 5/5] Clear context route registrations on every close and tighten route tests A rejected context route stayed registered, and a context closed with its browser kept its route registrations on the server. Route events with a bad handlerId now fall back on pages and contexts alike instead of hanging or throwing. --- bin/playwright-server.js | 2 + src/Browser/BrowserContext.php | 12 ++-- src/Page/Page.php | 6 +- tests/Functional/Network/RouteIdTest.php | 56 ++++++++++++++++ tests/Functional/Network/RouteTest.php | 60 ++++++++++------- tests/Unit/Browser/BrowserContextTest.php | 79 ++++++++++++++++++++++- tests/Unit/Page/PageTest.php | 65 +++++++++++++++++++ 7 files changed, 248 insertions(+), 32 deletions(-) create mode 100644 tests/Functional/Network/RouteIdTest.php diff --git a/bin/playwright-server.js b/bin/playwright-server.js index bae30fb..9902d63 100644 --- a/bin/playwright-server.js +++ b/bin/playwright-server.js @@ -193,6 +193,8 @@ class PlaywrightServer extends BaseHandler { sendFramedResponse({ objectId: contextId, event: 'page', params: { pageId } }); }); }); + // A context also closes with its browser, which never goes through closeContext(). + context.once('close', () => RouteUtils.forgetRoutes(this.routes, this.routeHandlers, contextId)); } formatEventParams(eventName, eventData) { diff --git a/src/Browser/BrowserContext.php b/src/Browser/BrowserContext.php index 9ad4397..5070e08 100644 --- a/src/Browser/BrowserContext.php +++ b/src/Browser/BrowserContext.php @@ -117,10 +117,7 @@ public function dispatchEvent(string $eventName, array $params): void if (!is_array($params['request'])) { throw new ProtocolErrorException('Invalid request data in route event', 0); } - $handlerId = $params['handlerId'] ?? null; - if (null !== $handlerId && !is_string($handlerId)) { - throw new ProtocolErrorException('Invalid handlerId in route event', 0); - } + $handlerId = is_string($params['handlerId'] ?? null) ? $params['handlerId'] : null; $route = new Route( $this->transport, $params['routeId'], @@ -238,6 +235,7 @@ public function close(): void 'action' => 'context.close', 'contextId' => $this->contextId, ]); + $this->routeHandlers = []; } public function isClosed(): bool @@ -520,12 +518,16 @@ public function route(string $url, callable $handler): void $handlerId = uniqid('handler_', true); $this->routeHandlers[$handlerId] = ['url' => $url, 'handler' => $handler]; try { - $this->transport->send([ + $response = $this->transport->send([ 'action' => 'context.route', 'contextId' => $this->contextId, 'url' => $url, 'handlerId' => $handlerId, ]); + if (isset($response['error'])) { + $errorMsg = is_string($response['error']) ? $response['error'] : 'Unknown transport error'; + throw new TransportException('Transport error in route: '.$errorMsg); + } } catch (\Throwable $e) { unset($this->routeHandlers[$handlerId]); diff --git a/src/Page/Page.php b/src/Page/Page.php index 04a22f4..d94af46 100644 --- a/src/Page/Page.php +++ b/src/Page/Page.php @@ -184,11 +184,12 @@ public function dispatchEvent(string $eventName, array $params): void break; case 'close': $this->isClosed = true; + $this->routeHandlers = []; $this->eventHandler->publicEmit('close', []); break; case 'route': - $handlerId = $params['handlerId'] ?? null; - if (is_string($params['routeId']) && is_array($params['request']) && (null === $handlerId || is_string($handlerId))) { + $handlerId = is_string($params['handlerId'] ?? null) ? $params['handlerId'] : null; + if (is_string($params['routeId']) && is_array($params['request'])) { $route = $this->createRoute( $this->pageId, $params['routeId'], @@ -646,6 +647,7 @@ public function close(): void $this->sendCommand('close'); $this->isClosed = true; + $this->routeHandlers = []; } public function isClosed(): bool diff --git a/tests/Functional/Network/RouteIdTest.php b/tests/Functional/Network/RouteIdTest.php new file mode 100644 index 0000000..3cde907 --- /dev/null +++ b/tests/Functional/Network/RouteIdTest.php @@ -0,0 +1,56 @@ +goto('/index.html'); + // Neither request is answered until both are waiting on the server. + $held = []; + $answerBoth = static function () use (&$held): void { + if (2 === \count($held)) { + $held['page']->fulfill(['status' => 200, 'body' => 'from page']); + $held['context']->fulfill(['status' => 200, 'body' => 'from context']); + } + }; + $this->page->route('**/api/data.json', static function (Route $route) use (&$held, $answerBoth): void { + $held['page'] = $route; + $answerBoth(); + }); + $this->context->route('**/api/other', static function (Route $route) use (&$held, $answerBoth): void { + $held['context'] = $route; + $answerBoth(); + }); + + $result = $this->page->evaluate('async () => Promise.all(["/api/data.json", "/api/other"].map(async url => (await fetch(url, { signal: AbortSignal.timeout(5000) })).text()))'); + + $this->assertSame(['from page', 'from context'], $result); + } +} diff --git a/tests/Functional/Network/RouteTest.php b/tests/Functional/Network/RouteTest.php index 3882b2c..7849046 100644 --- a/tests/Functional/Network/RouteTest.php +++ b/tests/Functional/Network/RouteTest.php @@ -244,12 +244,12 @@ public function testRouteAddedAfterUnrouteAnswersAlone(): void $this->page->unroute('**/api/data.json', $first); $this->page->route('**/api/data.json', static function (Route $route) use (&$calls): void { $calls[] = 'B'; - $route->fulfill(['status' => 200, 'body' => 'B']); + $route->fallback(); }); - $result = $this->page->evaluate('async () => (await fetch("/api/data.json")).text()'); + $status = $this->page->evaluate('async () => (await fetch("/api/data.json")).status'); - $this->assertSame('B', $result); + $this->assertSame(404, $status); $this->assertSame(['B'], $calls); } @@ -264,38 +264,54 @@ public function testRouteAddedAfterUnrouteAllAnswersAlone(): void $this->page->unrouteAll(); $this->page->route('**/api/data.json', static function (Route $route) use (&$calls): void { $calls[] = 'B'; - $route->fulfill(['status' => 200, 'body' => 'B']); + $route->fallback(); }); - $result = $this->page->evaluate('async () => (await fetch("/api/data.json")).text()'); + $status = $this->page->evaluate('async () => (await fetch("/api/data.json")).status'); - $this->assertSame('B', $result); + $this->assertSame(404, $status); $this->assertSame(['B'], $calls); } - public function testPageAndContextRoutesInFlightTogetherAnswerTheirOwnRequests(): void + public function testContextRouteAddedAfterUnrouteAnswersAlone(): void { $this->goto('/index.html'); - // Neither request is answered until both are waiting on the server. - $held = []; - $answerBoth = static function () use (&$held): void { - if (2 === \count($held)) { - $held['page']->fulfill(['status' => 200, 'body' => 'from page']); - $held['context']->fulfill(['status' => 200, 'body' => 'from context']); - } + $calls = []; + $first = static function (Route $route) use (&$calls): void { + $calls[] = 'A'; + $route->fulfill(['status' => 200, 'body' => 'A']); }; - $this->page->route('**/api/data.json', static function (Route $route) use (&$held, $answerBoth): void { - $held['page'] = $route; - $answerBoth(); + $this->context->route('**/api/data.json', $first); + $this->context->unroute('**/api/data.json', $first); + $this->context->route('**/api/data.json', static function (Route $route) use (&$calls): void { + $calls[] = 'B'; + $route->fallback(); + }); + + $status = $this->page->evaluate('async () => (await fetch("/api/data.json")).status'); + + $this->assertSame(404, $status); + $this->assertSame(['B'], $calls); + } + + public function testContextRouteAddedAfterUnrouteAllAnswersAlone(): void + { + $this->goto('/index.html'); + $calls = []; + $this->context->route('**/api/data.json', static function (Route $route) use (&$calls): void { + $calls[] = 'A'; + $route->fulfill(['status' => 200, 'body' => 'A']); }); - $this->context->route('**/api/other', static function (Route $route) use (&$held, $answerBoth): void { - $held['context'] = $route; - $answerBoth(); + $this->context->unrouteAll(); + $this->context->route('**/api/data.json', static function (Route $route) use (&$calls): void { + $calls[] = 'B'; + $route->fallback(); }); - $result = $this->page->evaluate('async () => Promise.all(["/api/data.json", "/api/other"].map(async url => (await fetch(url)).text()))'); + $status = $this->page->evaluate('async () => (await fetch("/api/data.json")).status'); - $this->assertSame(['from page', 'from context'], $result); + $this->assertSame(404, $status); + $this->assertSame(['B'], $calls); } public function testNewestContextRouteAnswersAndOlderContextRouteNeverRuns(): void diff --git a/tests/Unit/Browser/BrowserContextTest.php b/tests/Unit/Browser/BrowserContextTest.php index 0558272..ade5d00 100644 --- a/tests/Unit/Browser/BrowserContextTest.php +++ b/tests/Unit/Browser/BrowserContextTest.php @@ -23,6 +23,7 @@ use Playwright\Configuration\PlaywrightConfig; use Playwright\Credentials\CredentialsInterface; use Playwright\Exception\ProtocolErrorException; +use Playwright\Exception\TransportException; use Playwright\Network\NetworkThrottling; use Playwright\Page\PageInterface; use Playwright\Tracing\TracingInterface; @@ -489,10 +490,35 @@ public function testRouteSendsItsHandlerIdWithTheCommand(): void $context->route('**/*.api', static function (): void {}); - $this->assertSame('context.route', $sent[0]['action']); - $this->assertSame('context_1', $sent[0]['contextId']); - $this->assertSame('**/*.api', $sent[0]['url']); + $this->assertCount(1, $sent); $this->assertIsString($sent[0]['handlerId']); + $this->assertSame([ + 'action' => 'context.route', + 'contextId' => 'context_1', + 'url' => '**/*.api', + 'handlerId' => $sent[0]['handlerId'], + ], $sent[0]); + } + + public function testRouteThatTheServerRejectsIsNotKept(): void + { + $sent = []; + $transport = $this->createMock(TransportInterface::class); + $transport->method('send')->willReturnCallback(static function (array $payload) use (&$sent): array { + $sent[] = $payload; + + return 'context.route' === $payload['action'] ? ['error' => 'Invalid url'] : []; + }); + $context = new BrowserContext($transport, 'context_1', $this->config); + + try { + $context->route('**/api/**', static function (): void {}); + $this->fail('route() should throw the server error'); + } catch (TransportException) { + } + $context->unroute('**/api/**'); + + $this->assertSame([], $sent[1]['handlerIds']); } public function testUnroute(): void @@ -592,6 +618,53 @@ public function testUnrouteWithHandlerSendsOnlyItsRegistrations(): void $this->assertSame([$sent[1]['handlerId']], $sent[3]['handlerIds']); } + public function testRouteEventWithAnInvalidHandlerIdFallsBack(): void + { + $sent = []; + $transport = $this->createRecordingTransport($sent); + $context = new BrowserContext($transport, 'context_1', $this->config); + $called = false; + $context->route('**/api/**', static function () use (&$called): void { + $called = true; + }); + + $transport->expects($this->once()) + ->method('sendAsync') + ->with(['action' => 'route.fallback', 'routeId' => 'route_1', 'options' => null]); + + $context->dispatchEvent('route', [ + 'routeId' => 'route_1', + 'handlerId' => 42, + 'request' => ['url' => 'https://example.com/api/data'], + ]); + + $this->assertFalse($called); + } + + public function testRouteEventAfterCloseFallsBack(): void + { + $sent = []; + $transport = $this->createRecordingTransport($sent); + $context = new BrowserContext($transport, 'context_1', $this->config); + $called = false; + $context->route('**/api/**', static function () use (&$called): void { + $called = true; + }); + $context->close(); + + $transport->expects($this->once()) + ->method('sendAsync') + ->with(['action' => 'route.fallback', 'routeId' => 'route_1', 'options' => null]); + + $context->dispatchEvent('route', [ + 'routeId' => 'route_1', + 'handlerId' => $sent[0]['handlerId'], + 'request' => ['url' => 'https://example.com/api/data'], + ]); + + $this->assertFalse($called); + } + public function testUnrouteAll(): void { $this->mockTransport diff --git a/tests/Unit/Page/PageTest.php b/tests/Unit/Page/PageTest.php index 1a43010..d7acdc0 100644 --- a/tests/Unit/Page/PageTest.php +++ b/tests/Unit/Page/PageTest.php @@ -776,6 +776,71 @@ public function testUnrouteWithHandlerSendsOnlyItsRegistrations(): void $this->assertSame([$sent[1]['handlerId']], $sent[3]['handlerIds']); } + public function testRouteEventWithAnInvalidHandlerIdFallsBack(): void + { + $this->recordSentCommands(); + $called = false; + $this->page->route('**/api/**', static function () use (&$called): void { + $called = true; + }); + + $this->transport->expects($this->once()) + ->method('sendAsync') + ->with(['action' => 'route.fallback', 'routeId' => 'route_1', 'options' => null]); + + $this->page->dispatchEvent('route', [ + 'routeId' => 'route_1', + 'handlerId' => 42, + 'request' => ['url' => 'https://example.com/api/data'], + ]); + + $this->assertFalse($called); + } + + public function testRouteEventAfterCloseFallsBack(): void + { + $sent = $this->recordSentCommands(); + $called = false; + $this->page->route('**/api/**', static function () use (&$called): void { + $called = true; + }); + $this->page->close(); + + $this->transport->expects($this->once()) + ->method('sendAsync') + ->with(['action' => 'route.fallback', 'routeId' => 'route_1', 'options' => null]); + + $this->page->dispatchEvent('route', [ + 'routeId' => 'route_1', + 'handlerId' => $sent[0]['handlerId'], + 'request' => ['url' => 'https://example.com/api/data'], + ]); + + $this->assertFalse($called); + } + + public function testRouteEventAfterCloseEventFallsBack(): void + { + $sent = $this->recordSentCommands(); + $called = false; + $this->page->route('**/api/**', static function () use (&$called): void { + $called = true; + }); + $this->page->dispatchEvent('close', []); + + $this->transport->expects($this->once()) + ->method('sendAsync') + ->with(['action' => 'route.fallback', 'routeId' => 'route_1', 'options' => null]); + + $this->page->dispatchEvent('route', [ + 'routeId' => 'route_1', + 'handlerId' => $sent[0]['handlerId'], + 'request' => ['url' => 'https://example.com/api/data'], + ]); + + $this->assertFalse($called); + } + public function testMainFrame(): void { $page = $this->createPage();