diff --git a/bin/lib/core.js b/bin/lib/core.js index ae77ed7..f797faa 100644 --- a/bin/lib/core.js +++ b/bin/lib/core.js @@ -305,24 +305,41 @@ class FrameUtils { } class RouteUtils { - static async setupRoute(owner, ownerId, url, generateId, routes, extractRequestData, sendFramedResponse, routeIdFactory = null) { - await owner.route(url, async (route) => { - const routeId = typeof routeIdFactory === 'function' ? routeIdFactory() : generateId('route'); + // 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 = 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, ownerId, routeHandlers, handlerIds) { + for (const handlerId of handlerIds) { + const handler = routeHandlers.get(handlerId); + if (!handler || handler.ownerId !== ownerId) 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) { + static forgetRoutes(routes, routeHandlers, 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..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) }), @@ -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}`), - unroute: () => page.unroute(command.url), + 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,6 +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, this.routeHandlers, pageId); } async getVideo(page) { @@ -534,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 05fde22..9902d63 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); @@ -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 }); @@ -173,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 @@ -191,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 5bf917d..5070e08 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,13 @@ public function dispatchEvent(string $eventName, array $params): void if (!is_array($params['request'])) { throw new ProtocolErrorException('Invalid request data in route event', 0); } + $handlerId = is_string($params['handlerId'] ?? null) ? $params['handlerId'] : null; $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)) { @@ -241,6 +235,7 @@ public function close(): void 'action' => 'context.close', 'contextId' => $this->contextId, ]); + $this->routeHandlers = []; } public function isClosed(): bool @@ -518,21 +513,42 @@ 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 { + $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]); + + 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 +681,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/src/Page/Page.php b/src/Page/Page.php index 3a35201..d94af46 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, @@ -179,9 +184,11 @@ public function dispatchEvent(string $eventName, array $params): void break; case 'close': $this->isClosed = true; + $this->routeHandlers = []; $this->eventHandler->publicEmit('close', []); break; case 'route': + $handlerId = is_string($params['handlerId'] ?? null) ? $params['handlerId'] : null; if (is_string($params['routeId']) && is_array($params['request'])) { $route = $this->createRoute( $this->pageId, @@ -191,6 +198,7 @@ public function dispatchEvent(string $eventName, array $params): void } else { break; } + $this->dispatchRoute($route, $handlerId); $this->eventHandler->publicEmit('route', [$route]); break; default: @@ -639,6 +647,7 @@ public function close(): void $this->sendCommand('close'); $this->isClosed = true; + $this->routeHandlers = []; } public function isClosed(): bool @@ -1189,13 +1198,29 @@ 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]; + 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 { - $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 +1228,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 +1342,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, ?string $handlerId): void + { + $registration = null === $handlerId ? null : ($this->routeHandlers[$handlerId] ?? null); + if (null === $registration) { + $route->fallback(); + + return; + } + + $registration['handler']($route); + } + /** * Create a Route object from transport data. */ 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/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 382850e..7849046 100644 --- a/tests/Functional/Network/RouteTest.php +++ b/tests/Functional/Network/RouteTest.php @@ -98,6 +98,315 @@ 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 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 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->fallback(); + }); + + $status = $this->page->evaluate('async () => (await fetch("/api/data.json")).status'); + + $this->assertSame(404, $status); + $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->fallback(); + }); + + $status = $this->page->evaluate('async () => (await fetch("/api/data.json")).status'); + + $this->assertSame(404, $status); + $this->assertSame(['B'], $calls); + } + + public function testContextRouteAddedAfterUnrouteAnswersAlone(): void + { + $this->goto('/index.html'); + $calls = []; + $first = static function (Route $route) use (&$calls): void { + $calls[] = 'A'; + $route->fulfill(['status' => 200, 'body' => 'A']); + }; + $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->unrouteAll(); + $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 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..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; @@ -482,22 +483,42 @@ 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->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); - $this->context->route('**/*.api', $handler); + 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 @@ -509,11 +530,141 @@ 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 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 4747e77..d7acdc0 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; @@ -649,6 +650,7 @@ public function testUnrouteSendsPageCommand(): void ->method('send') ->with([ 'url' => '**/api/**', + 'handlerIds' => [], 'action' => 'page.unroute', 'pageId' => 'page-id', ]) @@ -657,6 +659,188 @@ 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 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(); + $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 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(); @@ -1235,6 +1419,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());