diff --git a/dev/e2e_app/macos/Flutter/GeneratedPluginRegistrant.swift b/dev/e2e_app/macos/Flutter/GeneratedPluginRegistrant.swift index 8a4e5852dc..d289de3c9e 100644 --- a/dev/e2e_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/dev/e2e_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -4,6 +4,7 @@ import FlutterMacOS import Foundation + import app_links import file_picker import file_saver @@ -21,8 +22,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) - FlutterLocalNotificationsPlugin.register( - with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/dev/e2e_app/patrol_test/web/web_example_app.dart b/dev/e2e_app/patrol_test/web/web_example_app.dart index 48b1a55db6..0955424a76 100644 --- a/dev/e2e_app/patrol_test/web/web_example_app.dart +++ b/dev/e2e_app/patrol_test/web/web_example_app.dart @@ -102,6 +102,12 @@ class _HomePage extends StatelessWidget { icon: const Icon(Icons.arrow_forward), label: const Text('Go to Page 1'), ), + ElevatedButton.icon( + key: const Key('open_popup_button'), + onPressed: () => web.window.open('about:blank', '_blank'), + icon: const Icon(Icons.open_in_new), + label: const Text('Open Popup Window'), + ), ElevatedButton.icon( onPressed: () => context.go('/iframe'), icon: const Icon(Icons.web), diff --git a/dev/e2e_app/patrol_test/web/web_example_multi_tab_navigation_test.dart b/dev/e2e_app/patrol_test/web/web_example_multi_tab_navigation_test.dart new file mode 100644 index 0000000000..91af2a8cfa --- /dev/null +++ b/dev/e2e_app/patrol_test/web/web_example_multi_tab_navigation_test.dart @@ -0,0 +1,52 @@ +import '../common.dart'; + +import 'web_example_app.dart'; + +void main() { + patrol('interact with form in new page while navigating Flutter app', ( + $, + ) async { + await $.pumpWidgetAndSettle(const WebExampleApp()); + + final formUrl = '${Uri.base.origin}/assets/assets/iframe_content.html'; + final newPageId = await $.platform.web.openNewPage(url: formUrl); + await $.pumpAndSettle(); + await Future.delayed(const Duration(seconds: 2)); + + final pages = await $.platform.web.getPages(); + expect(pages.length, 2); + + // Fill form in the new page + await $.platform.web.switchToPage(pageId: newPageId); + await $.pumpAndSettle(); + await Future.delayed(const Duration(seconds: 1)); + + await $.platform.web.enterText( + WebSelector(cssOrXpath: '#test-input'), + text: 'Patrol multi-page test', + ); + await $.platform.web.tap(WebSelector(cssOrXpath: '#submit-button')); + await Future.delayed(const Duration(seconds: 1)); + + // Switch back and navigate the Flutter app + await $.platform.web.switchToMainPage(); + await $.pumpAndSettle(); + + await $('Go to Page 1').scrollTo().tap(); + await $.pumpAndSettle(); + await Future.delayed(const Duration(seconds: 2)); + + expect($('This is Page 1'), findsOneWidget); + + await $.platform.web.goBack(); + await $.pumpAndSettle(); + await Future.delayed(const Duration(seconds: 2)); + + expect($('This is the home page'), findsOneWidget); + + await $.platform.web.closePage(pageId: newPageId); + + final remaining = await $.platform.web.getPages(); + expect(remaining.length, 1); + }); +} diff --git a/dev/e2e_app/patrol_test/web/web_example_multi_tab_test.dart b/dev/e2e_app/patrol_test/web/web_example_multi_tab_test.dart new file mode 100644 index 0000000000..0e8cc3743f --- /dev/null +++ b/dev/e2e_app/patrol_test/web/web_example_multi_tab_test.dart @@ -0,0 +1,104 @@ +import 'dart:collection'; + +import '../common.dart'; + +import 'web_example_app.dart'; + +void main() { + patrol('open external page, fill form, and return to Flutter app', ($) async { + await $.pumpWidgetAndSettle(const WebExampleApp()); + + final formUrl = '${Uri.base.origin}/assets/assets/iframe_content.html'; + final newPageId = await $.platform.web.openNewPage(url: formUrl); + await $.pumpAndSettle(); + await Future.delayed(const Duration(seconds: 2)); + + await $.platform.web.switchToPage(pageId: newPageId); + await $.pumpAndSettle(); + await Future.delayed(const Duration(seconds: 1)); + + final currentTab = await $.platform.web.getCurrentPage(); + expect(currentTab, newPageId); + + final currentUrl = await $.platform.web.getCurrentPageUrl(); + expect(currentUrl, formUrl); + + await $.platform.web.enterText( + WebSelector(cssOrXpath: '#test-input'), + text: 'Hello from new tab', + ); + await $.platform.web.tap(WebSelector(cssOrXpath: '#submit-button')); + await Future.delayed(const Duration(seconds: 1)); + + await $.platform.web.switchToMainPage(); + await $.pumpAndSettle(); + + expect(await $.platform.web.getCurrentPage(), 'page_0'); + expect($('This is the home page'), findsOneWidget); + + await $.platform.web.closePage(pageId: newPageId); + }); + + patrol('open leancode.co, accept cookies, and close tab', ($) async { + await $.pumpWidgetAndSettle(const WebExampleApp()); + + final newPageId = await $.platform.web.openNewPage( + url: 'https://leancode.co/', + ); + await $.pumpAndSettle(); + await Future.delayed(const Duration(seconds: 3)); + + await $.platform.web.switchToPage(pageId: newPageId); + await $.pumpAndSettle(); + await Future.delayed(const Duration(seconds: 2)); + + await $.platform.web.tap(WebSelector(text: 'Accept All')); + await Future.delayed(const Duration(seconds: 1)); + + await $.platform.web.switchToMainPage(); + await $.pumpAndSettle(); + + expect($('This is the home page'), findsOneWidget); + + await $.platform.web.closePage(pageId: newPageId); + }); + + patrol('cross-tab cookies persist across browser context', ($) async { + await $.pumpWidgetAndSettle(const WebExampleApp()); + + await $.platform.web.addCookie( + name: 'session', + value: 'abc123', + url: Uri.base.origin, + ); + + var cookies = await $.platform.web.getCookies(); + var sessionCookie = cookies.firstWhere( + (c) => c['name'] == 'session', + orElse: LinkedHashMap.new, + ); + expect(sessionCookie['value'], 'abc123'); + + final formUrl = '${Uri.base.origin}/assets/assets/iframe_content.html'; + final newPageId = await $.platform.web.openNewPage(url: formUrl); + await $.pumpAndSettle(); + await Future.delayed(const Duration(seconds: 2)); + + await $.platform.web.switchToPage(pageId: newPageId); + await $.pumpAndSettle(); + await Future.delayed(const Duration(seconds: 1)); + + cookies = await $.platform.web.getCookies(); + sessionCookie = cookies.firstWhere( + (c) => c['name'] == 'session', + orElse: LinkedHashMap.new, + ); + expect(sessionCookie['value'], 'abc123'); + + await $.platform.web.switchToMainPage(); + await $.pumpAndSettle(); + + await $.platform.web.clearCookies(); + await $.platform.web.closePage(pageId: newPageId); + }); +} diff --git a/docs/documentation/web.mdx b/docs/documentation/web.mdx index 7a99f6fd4d..e564ed83a4 100644 --- a/docs/documentation/web.mdx +++ b/docs/documentation/web.mdx @@ -30,8 +30,9 @@ patrol test --device chrome --target patrol_test/login_test.dart ``` - When you first run tests on web, Patrol will automatically install Node.js dependencies - including Playwright and its browser binaries. This may take a moment. + When you first run tests on web, Patrol will automatically install Node.js + dependencies including Playwright and its browser binaries. This may take a + moment. ### Running Tests in your CI pipeline @@ -94,4 +95,14 @@ await $.platform.web.enterTextWeb(selector: inputSelector, text: 'Hello from Pat await $.platform.web.tapWeb(selector: buttonSelector, iframeSelector: iframeSelector); // Window operations await $.platform.web.resizeWindow(size: Size(800, 600)); +// Multi-tab management +final pageId = await $.platform.web.openNewPage(url: 'https://example.com'); +await $.platform.web.switchToPage(pageId: pageId); +final currentPage = await $.platform.web.getCurrentPage(); +final pages = await $.platform.web.getPages(); +await $.platform.web.closePage(pageId: pageId); +final popupPageIdFuture = $.platform.web.waitForPopup(); +// ... trigger the popup ... +final popupPageId = await popupPageIdFuture; +final url = await $.platform.web.getCurrentPageUrl(); ``` diff --git a/packages/patrol/CHANGELOG.md b/packages/patrol/CHANGELOG.md index fea6424db8..ccf9ac6088 100644 --- a/packages/patrol/CHANGELOG.md +++ b/packages/patrol/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- Add multi-tab browser support for web tests: `openNewPage`, `closePage`, `switchToPage`, `switchToMainPage`, `getPages`, `getCurrentPage`, `getCurrentPageUrl`, `waitForPopup`. (#2871) + ## 4.5.0 - Fix `appId` not being passed down on `$.platform.mobile.enterText` and `$.platform.mobile.enterTextByIndex` (#2992) @@ -13,7 +17,6 @@ - Add `stopMockLocation` method to `PlatformAutomator` and make mockLocation method less flaky (#2937) - Fix matching test entries to produce test summary. (#2998) - ## 4.3.0 - Fix WASM compatibility by migrating conditional imports from `dart.library.html` to `dart.library.js_interop`. (#2960) @@ -46,8 +49,8 @@ Patrol 4.0 is here! Read the article announcing Patrol 4.0 [here](https://leancode.co/blog/patrol-4-0-release). -- New API for native/platform interactions: - - Introduce new way of communicating with platform (`PlatformAutomator`). (#2789) +- New API for native/platform interactions: + - Introduce new way of communicating with platform (`PlatformAutomator`). (#2789) - Deprecate `NativeAutomator` and `NativeAutomator2`. (#2789) - Add support for running Patrol tests on Web: @@ -74,7 +77,7 @@ Read the article announcing Patrol 4.0 [here](https://leancode.co/blog/patrol-4- - `--web-user-agent`, `--web-viewport`. - Add support for configurable test directory via `test_directory` option in `pubspec.yaml`. (#2728) - Introduces *experimental* `--full-isolation` flag that uninstall the app between each run on iOS Simulator. (#2803) -- Bump `patrol_log` to `0.6.0`. +- Bump `patrol_log` to `0.6.0`. - **BREAKING CHANGE** - Change default test directory from `integration_test` to `patrol_test`. (#2728) @@ -91,7 +94,7 @@ This version requires version 3.11.0 of `patrol_cli` package. ## 3.19.0 -- Fix logging for `$.native.pullToRefresh()` and `$.native.swipeBack()`. (#2707) +- Fix logging for `$.native.pullToRefresh()` and `$.native.swipeBack()`. (#2707) - Fix `$.native.enableDarkMode()` and `$.native.disableDarkMode()` on iOS 18 simulators. (#2705) - Add support for de, fr and pl languages for native methods that operates on strings. (#2659) - Add support for gallery permission dialog on iOS 17. (#2659) @@ -320,7 +323,6 @@ Other changes: - Bump minimum supported Flutter version to 3.16 - **BREAKING:** - - Remove `bindingType` parameter from `patrolTest()` function. Now only `PatrolBinding` is used and it's automatically initialized (#1882) - Remove `nativeAutomation` parameter from `patrolTest()` function. Now it's @@ -762,7 +764,6 @@ flakiness. ## 0.5.0 - Revamp scrolling and dragging (#217) - - New `MaestroTester.dragUntilExists()` - Fixed `MaestroTester.dragUntilVisible()`'s behavior - New `MaestroTester.scrollUntilExists` method @@ -846,7 +847,6 @@ Native: ## 0.3.2 - Improve selector engine: - - Make it possible to pass a `Key` as `matching` to `MaestroTester.call(dynamic matching)` and `MaestroFinder.$(dynamic matching)` @@ -859,7 +859,6 @@ matching)` ## 0.3.1 - Improve selector engine: - - Make it possible to pass a `MaestroFinder` as `matching` to `MaestroTester.call(dynamic matching)` and `MaestroFinder.$(dynamic matching)` @@ -876,7 +875,6 @@ matching)` - Introduce `Selector` class, which can be passed into `Maestro.tap(selector)`. - Add more platform functionality: - - `Maestro.enableWifi()` and `Maestro.disableWifi()` - `Maestro.enableCellular()` and `Maestro.disableCellular()` - `Maestro.enableDarkMode()` and `Maestro.disableDarkMode()` diff --git a/packages/patrol/lib/src/platform/web/web_automator.dart b/packages/patrol/lib/src/platform/web/web_automator.dart index 248c2fa96f..ea6a82a06d 100644 --- a/packages/patrol/lib/src/platform/web/web_automator.dart +++ b/packages/patrol/lib/src/platform/web/web_automator.dart @@ -88,4 +88,32 @@ abstract interface class WebAutomator { /// Returns a list of all files downloaded during the single test. Future> verifyFileDownloads(); + + /// Opens a new browser page navigating to [url]. + /// Returns the stable page ID of the new page. + Future openNewPage({required String url}); + + /// Closes the page with the given [pageId]. + Future closePage({required String pageId}); + + /// Switches the active page to [pageId]. + /// All subsequent actions will target this page until switched again. + Future switchToPage({required String pageId}); + + /// Switches to the main page. + /// All subsequent actions will target the main page until switched again. + Future switchToMainPage(); + + /// Returns identifiers of all open pages. + Future> getPages(); + + /// Returns the ID of the currently active page. + Future getCurrentPage(); + + /// Returns the URL of the currently active page. + Future getCurrentPageUrl(); + + /// Waits for a popup/new page to open. + /// Returns the page ID of the newly opened page. + Future waitForPopup(); } diff --git a/packages/patrol/lib/src/platform/web/web_automator_native.dart b/packages/patrol/lib/src/platform/web/web_automator_native.dart index 4e2572dee2..419882b6e8 100644 --- a/packages/patrol/lib/src/platform/web/web_automator_native.dart +++ b/packages/patrol/lib/src/platform/web/web_automator_native.dart @@ -283,4 +283,89 @@ class WebAutomator implements web_automator.WebAutomator { ); return (result as List).cast(); } + + @override + Future openNewPage({required String url}) async { + final result = await callPlaywright( + 'openNewPage', + {'url': url}, + logger: _config.logger, + patrolLog: _patrolLog, + ); + return result as String; + } + + @override + Future closePage({required String pageId}) async { + await callPlaywright( + 'closePage', + {'pageId': pageId}, + logger: _config.logger, + patrolLog: _patrolLog, + ); + } + + @override + Future switchToMainPage() async { + await callPlaywright( + 'switchToMainPage', + {}, + logger: _config.logger, + patrolLog: _patrolLog, + ); + } + + @override + Future switchToPage({required String pageId}) async { + await callPlaywright( + 'switchToPage', + {'pageId': pageId}, + logger: _config.logger, + patrolLog: _patrolLog, + ); + } + + @override + Future> getPages() async { + final result = await callPlaywright( + 'getPages', + {}, + logger: _config.logger, + patrolLog: _patrolLog, + ); + return (result as List).cast(); + } + + @override + Future getCurrentPage() async { + final result = await callPlaywright( + 'getCurrentPage', + {}, + logger: _config.logger, + patrolLog: _patrolLog, + ); + return result as String; + } + + @override + Future getCurrentPageUrl() async { + final result = await callPlaywright( + 'getCurrentPageUrl', + {}, + logger: _config.logger, + patrolLog: _patrolLog, + ); + return result as String; + } + + @override + Future waitForPopup() async { + final result = await callPlaywright( + 'waitForPopup', + {}, + logger: _config.logger, + patrolLog: _patrolLog, + ); + return result as String; + } } diff --git a/packages/patrol/web_runner/playwright.integration.config.ts b/packages/patrol/web_runner/playwright.integration.config.ts new file mode 100644 index 0000000000..53268c3e37 --- /dev/null +++ b/packages/patrol/web_runner/playwright.integration.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + testDir: "./tests/__tests__", + testMatch: "**/*.integration.test.ts", + reporter: "list", + timeout: 30_000, + workers: 1, + use: { + headless: true, + }, +}) diff --git a/packages/patrol/web_runner/playwright.unit.config.ts b/packages/patrol/web_runner/playwright.unit.config.ts new file mode 100644 index 0000000000..721ef9e15a --- /dev/null +++ b/packages/patrol/web_runner/playwright.unit.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + testDir: "./tests/__tests__", + testMatch: /^(?!.*\.integration\.).*\.test\.ts$/, + reporter: "list", + timeout: 10_000, + workers: 1, +}) diff --git a/packages/patrol/web_runner/tests/__tests__/assumptions.integration.test.ts b/packages/patrol/web_runner/tests/__tests__/assumptions.integration.test.ts new file mode 100644 index 0000000000..010a6f5a48 --- /dev/null +++ b/packages/patrol/web_runner/tests/__tests__/assumptions.integration.test.ts @@ -0,0 +1,210 @@ +import { test, expect } from "@playwright/test" +import { PageManager } from "../pageManager" +import { startTest, downloadedFiles } from "../actions/startTest" + +// --------------------------------------------------------------------------- +// Teardown tests +// --------------------------------------------------------------------------- + +test("teardown closes secondary pages without errors", async ({ browser }) => { + const context = await browser.newContext() + const page = await context.newPage() + new PageManager(context, page) + + // Open 3 additional pages + const second = await context.newPage() + const third = await context.newPage() + const fourth = await context.newPage() + + expect(context.pages()).toHaveLength(4) + + // Close one manually before teardown to simulate a page that was closed + // during the test itself + await third.close() + expect(context.pages()).toHaveLength(3) + + // Run the exact teardown logic from test.spec.ts + for (const p of context.pages()) { + if (p !== page && !p.isClosed()) { + await p.close().catch(() => {}) + } + } + + // Only the initial page should remain + expect(context.pages()).toHaveLength(1) + expect(context.pages()[0]).toBe(page) + + // The pages we closed should all report as closed + expect(second.isClosed()).toBe(true) + expect(third.isClosed()).toBe(true) + expect(fourth.isClosed()).toBe(true) + + await context.close() +}) + +test("teardown handles page that was already closed", async ({ browser }) => { + const context = await browser.newContext() + const page = await context.newPage() + + // Open a secondary page and immediately close it + const secondary = await context.newPage() + await secondary.close() + expect(secondary.isClosed()).toBe(true) + + // Run teardown — should NOT throw even though secondary is already closed. + // The isClosed() guard should skip it entirely. + await expect( + (async () => { + for (const p of context.pages()) { + if (p !== page && !p.isClosed()) { + await p.close().catch(() => {}) + } + } + })(), + ).resolves.toBeUndefined() + + // Only the initial page remains + expect(context.pages()).toHaveLength(1) + expect(context.pages()[0]).toBe(page) + + await context.close() +}) + +// --------------------------------------------------------------------------- +// Download tracking tests +// --------------------------------------------------------------------------- + +test("download in a secondary page is captured by verifyFileDownloads", async ({ browser }) => { + const context = await browser.newContext({ acceptDownloads: true }) + const page = await context.newPage() + const pageManager = new PageManager(context, page) + + // Set up download tracking via startTest + await startTest({ pageManager, params: {} }) + + // Open a secondary page — startTest's context.on('page') listener should + // register a download listener on it automatically + const secondPage = await context.newPage() + + // Trigger a download on the secondary page + await secondPage.setContent(` + Download + `) + + const [download] = await Promise.all([secondPage.waitForEvent("download"), secondPage.click("a")]) + + // Wait for the download to finish so the event handler has fired + await download.path() + + // Verify that downloadedFiles captured the file from the secondary page + expect(downloadedFiles).toContain("test-file.txt") + + await context.close() +}) + +test("download tracking is cleared between tests", async ({ browser }) => { + const context = await browser.newContext({ acceptDownloads: true }) + const page = await context.newPage() + const pageManager = new PageManager(context, page) + + // First call to startTest should clear downloadedFiles + await startTest({ pageManager, params: {} }) + expect(downloadedFiles).toHaveLength(0) + + // Trigger a download + await page.setContent(` + Download + `) + + const [download1] = await Promise.all([page.waitForEvent("download"), page.click("a")]) + await download1.path() + + expect(downloadedFiles).toHaveLength(1) + expect(downloadedFiles[0]).toBe("first-file.txt") + + // Calling startTest again should clear the list (simulates a new test run) + await startTest({ pageManager, params: {} }) + expect(downloadedFiles).toHaveLength(0) + + await context.close() +}) + +// --------------------------------------------------------------------------- +// Binding timing tests +// --------------------------------------------------------------------------- + +test("context.exposeBinding works on a page that loaded content BEFORE the binding was set up", async ({ browser }) => { + const context = await browser.newContext() + const page = await context.newPage() + + // Navigate to content FIRST — before any binding is registered + await page.setContent(` + + `) + + // THEN set up the binding on the context + let bindingCalled = false + let receivedArg: unknown = null + await context.exposeBinding("testBinding", (_source, arg) => { + bindingCalled = true + receivedArg = arg + return "binding-response" + }) + + // Call the binding from the already-loaded page + const result = await page.evaluate(async () => { + return await (window as any).testBinding("hello-from-page") + }) + + expect(bindingCalled).toBe(true) + expect(receivedArg).toBe("hello-from-page") + expect(result).toBe("binding-response") + + await context.close() +}) + +test("context.exposeBinding works on a NEW page created AFTER the binding was set up", async ({ browser }) => { + const context = await browser.newContext() + const page = await context.newPage() + + // Set up binding on the context first + let bindingCalled = false + let receivedArg: unknown = null + await context.exposeBinding("testBinding", (_source, arg) => { + bindingCalled = true + receivedArg = arg + return "binding-response-new-page" + }) + + // Create a NEW page after the binding is in place + const newPage = await context.newPage() + + // Set content on the new page + await newPage.setContent(` + + `) + + // Call the binding from the new page + const result = await newPage.evaluate(async () => { + return await (window as any).testBinding("hello-from-new-page") + }) + + expect(bindingCalled).toBe(true) + expect(receivedArg).toBe("hello-from-new-page") + expect(result).toBe("binding-response-new-page") + + // Also verify the binding still works on the original page + bindingCalled = false + receivedArg = null + + await page.setContent(`
original
`) + const resultOriginal = await page.evaluate(async () => { + return await (window as any).testBinding("hello-from-original") + }) + + expect(bindingCalled).toBe(true) + expect(receivedArg).toBe("hello-from-original") + expect(resultOriginal).toBe("binding-response-new-page") + + await context.close() +}) diff --git a/packages/patrol/web_runner/tests/__tests__/contracts.test.ts b/packages/patrol/web_runner/tests/__tests__/contracts.test.ts new file mode 100644 index 0000000000..746bf44233 --- /dev/null +++ b/packages/patrol/web_runner/tests/__tests__/contracts.test.ts @@ -0,0 +1,164 @@ +import { test, expect } from "@playwright/test" +import type { + TapRequest, + OpenNewPageRequest, + ClosePageRequest, + SwitchToPageRequest, + GetPagesRequest, + GetCurrentPageRequest, + WaitForPopupRequest, + PatrolNativeRequest, + SwitchToMainPageRequest, + GetCurrentPageUrlRequest, +} from "../contracts" + +// --------------------------------------------------------------------------- +// Type-level helpers -- these are compile-time-only checks. +// If a type does not satisfy the constraint the file will not compile. +// --------------------------------------------------------------------------- + +// Asserts that type A is assignable to type B. +type AssertAssignable = B + +// --------------------------------------------------------------------------- +// 1. Existing requests still work +// --------------------------------------------------------------------------- + +type _TapWithoutPageId = AssertAssignable< + TapRequest, + { + action: "tap" + params: { + selector: { + role: null + label: null + placeholder: null + text: null + altText: null + title: null + testId: null + cssOrXpath: null + } + iframeSelector: null + } + } +> + +// --------------------------------------------------------------------------- +// 2. New request types exist and have the correct shape +// --------------------------------------------------------------------------- + +type _OpenNewPage = AssertAssignable + +type _ClosePage = AssertAssignable + +type _SwitchToPage = AssertAssignable + +type _GetPages = AssertAssignable }> + +type _GetCurrentPage = AssertAssignable< + GetCurrentPageRequest, + { action: "getCurrentPage"; params: Record } +> + +type _WaitForPopup = AssertAssignable< + WaitForPopupRequest, + { + action: "waitForPopup" + params: Record + } +> + +// --------------------------------------------------------------------------- +// 3. New types are included in the PatrolNativeRequest union +// --------------------------------------------------------------------------- + +type _UnionIncludesOpenNewPage = AssertAssignable< + OpenNewPageRequest, + Extract +> +type _UnionIncludesClosePage = AssertAssignable> +type _UnionIncludesSwitchToPage = AssertAssignable< + SwitchToPageRequest, + Extract +> +type _UnionIncludesGetPages = AssertAssignable> +type _UnionIncludesGetCurrentPage = AssertAssignable< + GetCurrentPageRequest, + Extract +> +type _UnionIncludesWaitForPopup = AssertAssignable< + WaitForPopupRequest, + Extract +> + +// --------------------------------------------------------------------------- +// Runtime structure tests +// --------------------------------------------------------------------------- + +test.describe("contract types - new multi-tab requests", () => { + test("OpenNewPageRequest has correct structure", () => { + const req: OpenNewPageRequest = { action: "openNewPage", params: { url: "https://example.com" } } + expect(req.action).toBe("openNewPage") + expect(req.params).toEqual({ url: "https://example.com" }) + }) + + test("ClosePageRequest has correct structure", () => { + const req: ClosePageRequest = { action: "closePage", params: { pageId: "tab_1" } } + expect(req.action).toBe("closePage") + expect(req.params).toEqual({ pageId: "tab_1" }) + }) + + test("SwitchToPageRequest has correct structure", () => { + const req: SwitchToPageRequest = { action: "switchToPage", params: { pageId: "tab_2" } } + expect(req.action).toBe("switchToPage") + expect(req.params).toEqual({ pageId: "tab_2" }) + }) + + test("SwitchToMainPageRequest has correct structure", () => { + const req: SwitchToMainPageRequest = { action: "switchToMainPage", params: {} } + expect(req.action).toBe("switchToMainPage") + expect(req.params).toEqual({}) + }) + + test("GetPagesRequest has correct structure", () => { + const req: GetPagesRequest = { action: "getPages", params: {} } + expect(req.action).toBe("getPages") + expect(req.params).toEqual({}) + }) + + test("GetCurrentPageRequest has correct structure", () => { + const req: GetCurrentPageRequest = { action: "getCurrentPage", params: {} } + expect(req.action).toBe("getCurrentPage") + expect(req.params).toEqual({}) + }) + + test("GetCurrentPageUrlRequest has correct structure", () => { + const req: GetCurrentPageUrlRequest = { action: "getCurrentPageUrl", params: {} } + expect(req.action).toBe("getCurrentPageUrl") + expect(req.params).toEqual({}) + }) + + test("WaitForPopupRequest has correct structure", () => { + const req: WaitForPopupRequest = { + action: "waitForPopup", + params: {}, + } + expect(req.action).toBe("waitForPopup") + expect(req.params).toEqual({}) + }) +}) + +test.describe("contract types - union includes new request types", () => { + test("PatrolNativeRequest union accepts all new tab types", () => { + const requests: PatrolNativeRequest[] = [ + { action: "openNewPage", params: { url: "https://example.com" } }, + { action: "closePage", params: { pageId: "page_1" } }, + { action: "switchToPage", params: { pageId: "page_2" } }, + { action: "getPages", params: {} }, + { action: "getCurrentPage", params: {} }, + { action: "waitForPopup", params: {} }, + ] + expect(requests).toHaveLength(6) + }) +}) diff --git a/packages/patrol/web_runner/tests/__tests__/existingActions.integration.test.ts b/packages/patrol/web_runner/tests/__tests__/existingActions.integration.test.ts new file mode 100644 index 0000000000..a5e868329b --- /dev/null +++ b/packages/patrol/web_runner/tests/__tests__/existingActions.integration.test.ts @@ -0,0 +1,644 @@ +import { test, expect, BrowserContext, Page, Cookie } from "@playwright/test" +import { PageManager } from "../pageManager" +import { handlePatrolPlatformAction } from "../patrolPlatformHandler" +import { WebSelector } from "../contracts" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a full WebSelector with only the specified fields set; all others null. */ +function selector(overrides: Partial): WebSelector { + return { + role: null, + label: null, + placeholder: null, + text: null, + altText: null, + title: null, + testId: null, + cssOrXpath: null, + ...overrides, + } +} + +/** Shorthand: create isolated context + page + PageManager and tear down afterwards. */ +async function setup(browser: import("@playwright/test").Browser) { + const context = await browser.newContext() + const page = await context.newPage() + const pageManager = new PageManager(context, page) + return { context, page, pageManager } +} + +// --------------------------------------------------------------------------- +// 1. tap +// --------------------------------------------------------------------------- +test("tap - clicks a button through the dispatch layer", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + await page.setContent(` + + + + `) + + await handlePatrolPlatformAction(pageManager, { + action: "tap", + params: { + selector: selector({ testId: "clicker" }), + iframeSelector: null, + }, + }) + + const resultText = await page.locator("#result").textContent() + expect(resultText).toBe("clicked") + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 2. enterText +// --------------------------------------------------------------------------- +test("enterText - fills an input through the dispatch layer", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + await page.setContent(``) + + await handlePatrolPlatformAction(pageManager, { + action: "enterText", + params: { + selector: selector({ testId: "name-input" }), + text: "Hello Patrol", + iframeSelector: null, + }, + }) + + const value = await page.locator('[data-testid="name-input"]').inputValue() + expect(value).toBe("Hello Patrol") + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 3. enableDarkMode +// --------------------------------------------------------------------------- +test("enableDarkMode - emulates dark color scheme without crashing", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + await page.setContent(` + + + `) + + await handlePatrolPlatformAction(pageManager, { + action: "enableDarkMode", + params: {}, + }) + + const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor) + // "rgb(0, 0, 0)" is black + expect(bg).toBe("rgb(0, 0, 0)") + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 4. disableDarkMode +// --------------------------------------------------------------------------- +test("disableDarkMode - emulates light color scheme without crashing", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + // First enable dark mode, then disable it + await page.setContent(` + + + `) + + await handlePatrolPlatformAction(pageManager, { + action: "enableDarkMode", + params: {}, + }) + + await handlePatrolPlatformAction(pageManager, { + action: "disableDarkMode", + params: {}, + }) + + const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor) + // "rgb(255, 255, 255)" is white + expect(bg).toBe("rgb(255, 255, 255)") + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 5. goBack / goForward +// --------------------------------------------------------------------------- +test("goBack and goForward - navigate browser history", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + // Use route interception to serve two distinct pages without network calls + await context.route("**/page-one", route => + route.fulfill({ + status: 200, + contentType: "text/html", + body: "

Page One

", + }), + ) + await context.route("**/page-two", route => + route.fulfill({ + status: 200, + contentType: "text/html", + body: "

Page Two

", + }), + ) + + await page.goto("http://localhost/page-one") + await page.goto("http://localhost/page-two") + expect(page.url()).toContain("page-two") + + // Go back + await handlePatrolPlatformAction(pageManager, { + action: "goBack", + params: {}, + }) + expect(page.url()).toContain("page-one") + + // Go forward + await handlePatrolPlatformAction(pageManager, { + action: "goForward", + params: {}, + }) + expect(page.url()).toContain("page-two") + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 6. pressKey +// --------------------------------------------------------------------------- +test("pressKey - types a key into a focused input", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + await page.setContent(``) + await page.locator('[data-testid="key-input"]').focus() + + await handlePatrolPlatformAction(pageManager, { + action: "pressKey", + params: { key: "a" }, + }) + + const value = await page.locator('[data-testid="key-input"]').inputValue() + expect(value).toBe("a") + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 7. pressKeyCombo +// --------------------------------------------------------------------------- +test("pressKeyCombo - sends a key combination", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + await page.setContent(` + + + `) + + // Send Shift+A key combo through the dispatch layer + await handlePatrolPlatformAction(pageManager, { + action: "pressKeyCombo", + params: { keys: ["Shift", "A"] }, + }) + + const resultText = await page.locator("#combo-result").textContent() + expect(resultText).toBe("Shift+A pressed") + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 8. acceptNextDialog +// --------------------------------------------------------------------------- +test("acceptNextDialog - accepts an alert and returns its message", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + await page.setContent(``) + + // Register the dialog handler BEFORE triggering the dialog + const dialogPromise = handlePatrolPlatformAction(pageManager, { + action: "acceptNextDialog", + params: {}, + }) + + // Trigger the alert + await page.evaluate(() => alert("Hello from alert")) + + const message = await dialogPromise + expect(message).toBe("Hello from alert") + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 9. dismissNextDialog +// --------------------------------------------------------------------------- +test("dismissNextDialog - dismisses a confirm and returns its message", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + await page.setContent(`
`) + + // Register the dialog handler BEFORE triggering the dialog + const dialogPromise = handlePatrolPlatformAction(pageManager, { + action: "dismissNextDialog", + params: {}, + }) + + // Trigger a confirm dialog (dismiss returns false for confirm) + await page.evaluate(() => { + const result = confirm("Shall we proceed?") + document.getElementById("result")!.textContent = result ? "yes" : "no" + }) + + const message = await dialogPromise + expect(message).toBe("Shall we proceed?") + + // Confirm was dismissed, so result should be "no" + const resultText = await page.locator("#result").textContent() + expect(resultText).toBe("no") + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 10. addCookie / getCookies / clearCookies +// --------------------------------------------------------------------------- +test("addCookie, getCookies, clearCookies - full cookie lifecycle", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + // Cookies need a real origin; use a routed page + await context.route("**/cookie-page", route => + route.fulfill({ + status: 200, + contentType: "text/html", + body: "Cookie Page", + }), + ) + await page.goto("http://localhost/cookie-page") + + // Add a cookie + await handlePatrolPlatformAction(pageManager, { + action: "addCookie", + params: { + name: "patrol_test", + value: "abc123", + domain: "localhost", + path: "/", + url: null, + expires: null, + httpOnly: null, + secure: null, + sameSite: null, + }, + }) + + // Get cookies and verify + const cookies = (await handlePatrolPlatformAction(pageManager, { + action: "getCookies", + params: {}, + })) as any[] + + expect(cookies).toEqual(expect.arrayContaining([expect.objectContaining({ name: "patrol_test", value: "abc123" })])) + + // Clear cookies + await handlePatrolPlatformAction(pageManager, { + action: "clearCookies", + params: {}, + }) + + // Verify cookies are gone + const cookiesAfterClear = (await handlePatrolPlatformAction(pageManager, { + action: "getCookies", + params: {}, + })) as any[] + + expect(cookiesAfterClear).toHaveLength(0) + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 11. grantPermissions / clearPermissions +// --------------------------------------------------------------------------- +test("grantPermissions and clearPermissions - execute without error", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + // Navigate to a routed page so we have a valid origin + await context.route("**/perm-page", route => + route.fulfill({ + status: 200, + contentType: "text/html", + body: "Permissions Page", + }), + ) + await page.goto("http://localhost/perm-page") + + // Grant permissions (geolocation is commonly supported) + await handlePatrolPlatformAction(pageManager, { + action: "grantPermissions", + params: { + permissions: ["geolocation"], + origin: "http://localhost", + }, + }) + + // Clear permissions + await handlePatrolPlatformAction(pageManager, { + action: "clearPermissions", + params: {}, + }) + + // If we got here without throwing, the actions work through the dispatch layer + await context.close() +}) + +// --------------------------------------------------------------------------- +// 12. resizeWindow +// --------------------------------------------------------------------------- +test("resizeWindow - changes the viewport size", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + await page.setContent("Resize test") + + await handlePatrolPlatformAction(pageManager, { + action: "resizeWindow", + params: { width: 800, height: 600 }, + }) + + const viewportSize = page.viewportSize() + expect(viewportSize).toEqual({ width: 800, height: 600 }) + + // Resize again to a different size to prove it actually changes + await handlePatrolPlatformAction(pageManager, { + action: "resizeWindow", + params: { width: 1024, height: 768 }, + }) + + const newViewportSize = page.viewportSize() + expect(newViewportSize).toEqual({ width: 1024, height: 768 }) + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 13. setClipboard / getClipboard +// --------------------------------------------------------------------------- +test("setClipboard and getClipboard - round-trip clipboard text", async ({ browser }) => { + // Grant clipboard permissions via context options + const context = await browser.newContext({ + permissions: ["clipboard-read", "clipboard-write"], + }) + const page = await context.newPage() + const pageManager = new PageManager(context, page) + + // Need a page with an origin for clipboard API to work + await context.route("**/clipboard-page", route => + route.fulfill({ + status: 200, + contentType: "text/html", + body: "Clipboard test", + }), + ) + await page.goto("http://localhost/clipboard-page") + + // Set clipboard + await handlePatrolPlatformAction(pageManager, { + action: "setClipboard", + params: { text: "patrol clipboard test" }, + }) + + // Get clipboard + const clipboardText = await handlePatrolPlatformAction(pageManager, { + action: "getClipboard", + params: {}, + }) + + expect(clipboardText).toBe("patrol clipboard test") + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 14. scrollTo +// --------------------------------------------------------------------------- +test("scrollTo - scrolls an element into view", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + await page.setContent(` +
+
Spacer
+
Target element
+
+ `) + + // Verify the element is NOT in the viewport initially + const initiallyVisible = await page.locator('[data-testid="scroll-target"]').isVisible() + // The element exists in the DOM but is far below the fold + expect(initiallyVisible).toBe(true) // visible in DOM, but not in viewport + + const scrollYBefore = await page.evaluate(() => window.scrollY) + expect(scrollYBefore).toBe(0) + + await handlePatrolPlatformAction(pageManager, { + action: "scrollTo", + params: { + selector: selector({ testId: "scroll-target" }), + iframeSelector: null, + }, + }) + + // After scrollTo, the page should have scrolled down + const scrollYAfter = await page.evaluate(() => window.scrollY) + expect(scrollYAfter).toBeGreaterThan(0) + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 15. tap with cssOrXpath selector +// --------------------------------------------------------------------------- +test("tap - works with cssOrXpath selector variant", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + await page.setContent(` + + + + `) + + await handlePatrolPlatformAction(pageManager, { + action: "tap", + params: { + selector: selector({ cssOrXpath: ".my-btn" }), + iframeSelector: null, + }, + }) + + const resultText = await page.locator("#result").textContent() + expect(resultText).toBe("css-clicked") + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 16. tap with text selector +// --------------------------------------------------------------------------- +test("tap - works with text selector variant", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + await page.setContent(` + + + + `) + + await handlePatrolPlatformAction(pageManager, { + action: "tap", + params: { + selector: selector({ text: "Unique Text Button" }), + iframeSelector: null, + }, + }) + + const resultText = await page.locator("#result").textContent() + expect(resultText).toBe("text-clicked") + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 17. enterText with placeholder selector +// --------------------------------------------------------------------------- +test("enterText - works with placeholder selector", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + await page.setContent(``) + + await handlePatrolPlatformAction(pageManager, { + action: "enterText", + params: { + selector: selector({ placeholder: "Enter your email" }), + text: "test@example.com", + iframeSelector: null, + }, + }) + + const value = await page.locator('[placeholder="Enter your email"]').inputValue() + expect(value).toBe("test@example.com") + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 18. startTest - initializes download listener +// --------------------------------------------------------------------------- +test("startTest - executes without error through dispatch layer", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + await page.setContent("Start test page") + + // startTest should not throw + await handlePatrolPlatformAction(pageManager, { + action: "startTest", + params: {}, + }) + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 19. dispatch of unknown action throws +// --------------------------------------------------------------------------- +test("dispatch of unknown action throws an error", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + await expect( + handlePatrolPlatformAction(pageManager, { + action: "unknown-placeholder-nonexistent" as any, + params: {}, + }), + ).rejects.toThrow(/not found/) + + await context.close() +}) + +// --------------------------------------------------------------------------- +// 20. addCookie with extra options (httpOnly, secure, sameSite) +// --------------------------------------------------------------------------- +test("addCookie - supports additional cookie properties", async ({ browser }) => { + const { context, page, pageManager } = await setup(browser) + + await context.route("**/cookie-page", route => + route.fulfill({ + status: 200, + contentType: "text/html", + body: "Cookie Page", + }), + ) + await page.goto("http://localhost/cookie-page") + + await handlePatrolPlatformAction(pageManager, { + action: "addCookie", + params: { + name: "session_id", + value: "xyz789", + domain: "localhost", + path: "/", + url: null, + expires: null, + httpOnly: true, + secure: false, + sameSite: "Lax", + }, + }) + + const cookies = (await handlePatrolPlatformAction(pageManager, { + action: "getCookies", + params: {}, + })) as Cookie[] + + const sessionCookie = cookies.find(c => c.name === "session_id") + expect(sessionCookie).toBeDefined() + expect(sessionCookie!.value).toBe("xyz789") + expect(sessionCookie!.httpOnly).toBe(true) + expect(sessionCookie!.sameSite).toBe("Lax") + + await context.close() +}) diff --git a/packages/patrol/web_runner/tests/__tests__/multiTab.integration.test.ts b/packages/patrol/web_runner/tests/__tests__/multiTab.integration.test.ts new file mode 100644 index 0000000000..c19f0b8033 --- /dev/null +++ b/packages/patrol/web_runner/tests/__tests__/multiTab.integration.test.ts @@ -0,0 +1,190 @@ +import { test, expect, BrowserContext } from "@playwright/test" +import { PageManager } from "../pageManager" +import { openNewPage } from "../actions/openNewPage" +import { closePage } from "../actions/closePage" +import { switchToPage } from "../actions/switchToPage" +import { getPages } from "../actions/getPages" +import { getCurrentPage } from "../actions/getCurrentPage" +import { handlePatrolPlatformAction } from "../patrolPlatformHandler" + +// Local HTML content served via route interception — no network access needed. +const TEST_PAGE_HTML = ` +Test Page +

Test Page

Local content for integration tests.

` + +const TEST_PAGE_2_HTML = ` +Second Page +

Second Page

Another local page.

` + +const POPUP_TARGET_HTML = ` +Popup Target +

Popup Target

Opened via window.open().

` + +/** + * Intercept all requests to https://test.local/** and serve local HTML. + * This lets us exercise the full openNewPage code path (newPage + goto) + * without any real network access. + */ +async function interceptTestRoutes(context: BrowserContext) { + await context.route("https://test.local/page1", route => { + route.fulfill({ contentType: "text/html", body: TEST_PAGE_HTML }) + }) + await context.route("https://test.local/page2", route => { + route.fulfill({ contentType: "text/html", body: TEST_PAGE_2_HTML }) + }) + await context.route("https://test.local/popup-target", route => { + route.fulfill({ contentType: "text/html", body: POPUP_TARGET_HTML }) + }) +} + +test("open a new page, switch to it, interact, and switch back", async ({ browser }) => { + const context = await browser.newContext() + await interceptTestRoutes(context) + const page = await context.newPage() + const pageManager = new PageManager(context, page) + + // Open a new page to locally-served content + const pageId = await openNewPage({ pageManager, params: { url: "https://test.local/page1" } }) + expect(pageId).toBe("page_1") + + // Verify 2 pages exist + const pages = await getPages({ pageManager, params: {} }) + expect(pages).toHaveLength(2) + expect(pages).toContain("page_0") + expect(pages).toContain("page_1") + + // Switch to the new page + await switchToPage({ pageManager, params: { pageId: "page_1" } }) + + // Verify active page is page_1 + const currentPage = await getCurrentPage({ pageManager, params: {} }) + expect(currentPage).toBe("page_1") + + // Verify the new page loaded the local page + const newPage = pageManager.resolve("page_1") + await newPage.waitForLoadState("domcontentloaded") + const heading = await newPage.locator("h1").textContent() + expect(heading).toContain("Test Page") + + // Switch back to page_0 and verify + await switchToPage({ pageManager, params: { pageId: "page_0" } }) + const backToPage = await getCurrentPage({ pageManager, params: {} }) + expect(backToPage).toBe("page_0") + + await context.close() +}) + +test("close a page and verify cleanup", async ({ browser }) => { + const context = await browser.newContext() + await interceptTestRoutes(context) + const page = await context.newPage() + const pageManager = new PageManager(context, page) + + // Open 2 new pages + await openNewPage({ pageManager, params: { url: "https://test.local/page1" } }) + await openNewPage({ pageManager, params: { url: "https://test.local/page2" } }) + + // Verify 3 pages total + const before = await getPages({ pageManager, params: {} }) + expect(before).toHaveLength(3) + expect(before).toEqual(expect.arrayContaining(["page_0", "page_1", "page_2"])) + + // Close page_1 + await closePage({ pageManager, params: { pageId: "page_1" } }) + + // Verify 2 remain with stable IDs (page_0 and page_2) + const after = await getPages({ pageManager, params: {} }) + expect(after).toHaveLength(2) + expect(after).toContain("page_0") + expect(after).toContain("page_2") + expect(after).not.toContain("page_1") + + // Verify resolving page_1 throws + expect(() => pageManager.resolve("page_1")).toThrow(/No page found for page ID "page_1"/) + + await context.close() +}) + +test("dispatch via handlePatrolPlatformAction routes correctly", async ({ browser }) => { + const context = await browser.newContext() + await interceptTestRoutes(context) + const page = await context.newPage() + const pageManager = new PageManager(context, page) + + // Dispatch openNewPage through the handler + const pageId = await handlePatrolPlatformAction(pageManager, { + action: "openNewPage", + params: { url: "https://test.local/page1" }, + }) + + // Verify it returned a page ID and the page was registered + expect(pageId).toBe("page_1") + expect(pageManager.count).toBe(2) + expect(pageManager.ids).toContain("page_1") + + // Verify the new page actually loaded the page + const newPage = pageManager.resolve("page_1") + await newPage.waitForLoadState("domcontentloaded") + const heading = await newPage.locator("h1").textContent() + expect(heading).toContain("Test Page") + + await context.close() +}) + +test("pages persist correct content after switching", async ({ browser }) => { + const context = await browser.newContext() + await interceptTestRoutes(context) + const page = await context.newPage() + const pageManager = new PageManager(context, page) + + // Open page_1 to locally-served page + await openNewPage({ pageManager, params: { url: "https://test.local/page1" } }) + + // Switch to page_1 and read the page title + await switchToPage({ pageManager, params: { pageId: "page_1" } }) + const page1 = pageManager.resolve("page_1") + await page1.waitForLoadState("domcontentloaded") + const title = await page1.title() + expect(title).toContain("Test Page") + + // Switch back to page_0 and verify its URL is still about:blank + await switchToPage({ pageManager, params: { pageId: "page_0" } }) + const page0 = pageManager.resolve("page_0") + expect(page0.url()).toBe("about:blank") + + // Switch to page_1 again and verify content is still there + await switchToPage({ pageManager, params: { pageId: "page_1" } }) + const page1Again = pageManager.resolve("page_1") + const headingText = await page1Again.locator("h1").textContent() + expect(headingText).toContain("Test Page") + + await context.close() +}) + +test("PageManager auto-registers popup from window.open", async ({ browser }) => { + const context = await browser.newContext() + await interceptTestRoutes(context) + const page = await context.newPage() + const pageManager = new PageManager(context, page) + + // Set initial page content with a button that opens a popup to a routed URL + await page.setContent("") + + // Click the button and wait for the popup to appear + const [popup] = await Promise.all([context.waitForEvent("page"), page.locator("button").click()]) + + // Wait for the popup to finish loading + await popup.waitForLoadState("domcontentloaded") + + // Verify PageManager has 2 pages + expect(pageManager.count).toBe(2) + + // Verify the popup page loaded the local content + const popupId = pageManager.idOf(popup) + expect(popupId).toBeDefined() + const popupPage = pageManager.resolve(popupId!) + const heading = await popupPage.locator("h1").textContent() + expect(heading).toContain("Popup Target") + + await context.close() +}) diff --git a/packages/patrol/web_runner/tests/__tests__/pageActions.test.ts b/packages/patrol/web_runner/tests/__tests__/pageActions.test.ts new file mode 100644 index 0000000000..559886a0e9 --- /dev/null +++ b/packages/patrol/web_runner/tests/__tests__/pageActions.test.ts @@ -0,0 +1,454 @@ +import { test, expect } from "@playwright/test" +import { EventEmitter } from "events" +import { PageManager } from "../pageManager" + +// --- Imports from action files that DO NOT EXIST yet (RED phase) ----------- +import { openNewPage } from "../actions/openNewPage" +import { closePage } from "../actions/closePage" +import { switchToPage } from "../actions/switchToPage" +import { getPages } from "../actions/getPages" +import { getCurrentPage } from "../actions/getCurrentPage" +import { waitForPopup } from "../actions/waitForPopup" +import { tap } from "../actions/tap" +import { WebSelector } from "../contracts" + +// --------------------------------------------------------------------------- +// Lightweight mocks for Playwright's Page and BrowserContext. +// Extended from the pattern in pageManager.test.ts with goto, close, and +// newPage capabilities needed by the page management actions. +// --------------------------------------------------------------------------- + +type MockPage = EventEmitter & { + on: EventEmitter["on"] + off: EventEmitter["off"] + once: EventEmitter["once"] + removeListener: EventEmitter["removeListener"] + isClosed: () => boolean + goto: (url: string) => Promise + close: () => Promise + bringToFront: () => Promise + url: () => string +} + +type MockContext = EventEmitter & { + on: EventEmitter["on"] + off: EventEmitter["off"] + once: EventEmitter["once"] + removeListener: EventEmitter["removeListener"] + newPage: () => Promise + waitForEvent: (event: string) => Promise +} + +function createMockPage(): MockPage { + const emitter = new EventEmitter() + let currentUrl = "about:blank" + let closed = false + + let pageRef: any = null + function mockLocator() { + const locator = { + click: async () => {}, + and: (other: any) => locator, + contentFrame: () => pageRef, + } + return locator + } + + const page = Object.assign(emitter, { + on: emitter.on.bind(emitter), + off: emitter.off.bind(emitter), + once: emitter.once.bind(emitter), + removeListener: emitter.removeListener.bind(emitter), + isClosed: () => closed, + goto: async (url: string) => { + currentUrl = url + }, + close: async () => { + closed = true + emitter.emit("close") + }, + bringToFront: async () => {}, + url: () => currentUrl, + // Minimal Playwright-like selector helpers used by parseWebSelector + getByTestId: (_: string) => mockLocator(), + getByRole: (_: string) => mockLocator(), + getByLabel: (_: string) => mockLocator(), + getByPlaceholder: (_: string) => mockLocator(), + getByText: (_: string) => mockLocator(), + getByAltText: (_: string) => mockLocator(), + getByTitle: (_: string) => mockLocator(), + locator: (_: string) => mockLocator(), + }) as MockPage + + pageRef = page + + return page +} + +function createMockContext(): MockContext { + const emitter = new EventEmitter() + + const context = Object.assign(emitter, { + on: emitter.on.bind(emitter), + off: emitter.off.bind(emitter), + once: emitter.once.bind(emitter), + removeListener: emitter.removeListener.bind(emitter), + newPage: async (): Promise => { + const page = createMockPage() + // Simulate Playwright behavior: context emits 'page' when a new page is created + emitter.emit("page", page) + return page + }, + waitForEvent: (event: string): Promise => { + return new Promise(resolve => { + emitter.once(event, (page: MockPage) => { + resolve(page) + }) + }) + }, + }) as MockContext + + return context +} + +/** Build a full WebSelector with only the specified fields set; all others null. */ +function selector(overrides: Partial): WebSelector { + return { + role: null, + label: null, + placeholder: null, + text: null, + altText: null, + title: null, + testId: null, + cssOrXpath: null, + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe("openNewPage", () => { + test("creates a new page via context.newPage()", async () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const pageId = await openNewPage({ + pageManager: manager, + params: { url: "https://example.com" }, + }) + + // A new page should have been registered in the manager + expect(manager.count).toBe(2) + expect(pageId).toBeDefined() + expect(typeof pageId).toBe("string") + }) + + test("navigates the new page to the given URL", async () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const pageId = await openNewPage({ + pageManager: manager, + params: { url: "https://example.com/test" }, + }) + + // The newly created page should have navigated to the URL + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newPage = manager.resolve(pageId) as any as MockPage + expect(newPage.url()).toBe("https://example.com/test") + }) + + test("returns the page ID assigned by PageManager", async () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const pageId = await openNewPage({ + pageManager: manager, + params: { url: "https://example.com" }, + }) + + // page_0 is the initial page, so new page should be page_1 + expect(pageId).toBe("page_1") + expect(manager.ids).toContain(pageId) + }) +}) + +test.describe("closePage", () => { + test("resolves the page from pageManager and closes it", async () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + // Add a second page to close + const secondPage = createMockPage() + context.emit("page", secondPage) + expect(manager.count).toBe(2) + + await closePage({ + pageManager: manager, + params: { pageId: "page_1" }, + }) + + // The page should be removed from the manager (via the close event) + expect(manager.count).toBe(1) + expect(manager.ids).not.toContain("page_1") + }) + + test("calls page.close() on the resolved page", async () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const secondPage = createMockPage() + context.emit("page", secondPage) + + await closePage({ + pageManager: manager, + params: { pageId: "page_1" }, + }) + + expect(secondPage.isClosed()).toBe(true) + }) + + test("throws if pageId does not exist", async () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + await expect( + closePage({ + pageManager: manager, + params: { pageId: "nonexistent" }, + }), + ).rejects.toThrow() + }) + + test("throws when trying to close page_0", async () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + await expect( + closePage({ + pageManager: manager, + params: { pageId: "page_0" }, + }), + ).rejects.toThrow("Cannot close the main Flutter page") + }) +}) + +test.describe("switchToPage", () => { + test("sets pageManager.activeId to the given pageId", async () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const secondPage = createMockPage() + context.emit("page", secondPage) + + await switchToPage({ + pageManager: manager, + params: { pageId: "page_1" }, + }) + + expect(manager.activeId).toBe("page_1") + }) + + test("throws if pageId does not exist", async () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + await expect( + switchToPage({ + pageManager: manager, + params: { pageId: "nonexistent" }, + }), + ).rejects.toThrow() + }) +}) + +test.describe("getPages", () => { + test("returns all page IDs from pageManager.ids", async () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const secondPage = createMockPage() + context.emit("page", secondPage) + + const thirdPage = createMockPage() + context.emit("page", thirdPage) + + const result = await getPages({ + pageManager: manager, + params: {}, + }) + + expect(result).toEqual(expect.arrayContaining(["page_0", "page_1", "page_2"])) + expect(result).toHaveLength(3) + }) +}) + +test.describe("getCurrentPage", () => { + test("returns pageManager.activeId (default is page_0)", async () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const result = await getCurrentPage({ + pageManager: manager, + params: {}, + }) + + expect(result).toBe("page_0") + }) + + test("returns the updated activeId after switching pages", async () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const secondPage = createMockPage() + context.emit("page", secondPage) + manager.activeId = "page_1" + + const result = await getCurrentPage({ + pageManager: manager, + params: {}, + }) + + expect(result).toBe("page_1") + }) +}) + +test.describe("waitForPopup", () => { + test("sets up context.waitForEvent('page') before executing the trigger action", async () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + // Simulate a popup appearing during the trigger action. + // We schedule the popup emission so that waitForPopup can catch it. + const popupPage = createMockPage() + setTimeout(() => { + context.emit("page", popupPage) + }, 10) + + const pageIdPromise = waitForPopup({ + pageManager: manager, + params: {}, + }) + + await tap({ + pageManager: manager, + params: { + selector: selector({ text: "Open popup" }), + iframeSelector: null, + }, + }) + + const pageId = await pageIdPromise + + expect(pageId).toBeDefined() + expect(typeof pageId).toBe("string") + }) + + test("returns the page ID of the newly appeared popup page", async () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const popupPage = createMockPage() + setTimeout(() => { + context.emit("page", popupPage) + }, 10) + + const pageIdPromise = waitForPopup({ + pageManager: manager, + params: {}, + }) + + await tap({ + pageManager: manager, + params: { + selector: selector({ text: "Open popup" }), + iframeSelector: null, + }, + }) + + const pageId = await pageIdPromise + + // The popup should be registered and the returned ID should resolve to it + expect(manager.ids).toContain(pageId) + expect(manager.resolve(pageId)).toBe(popupPage) + }) + + test("the popup page is registered in the PageManager", async () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const popupPage = createMockPage() + setTimeout(() => { + context.emit("page", popupPage) + }, 10) + + const countBefore = manager.count + + const pageIdPromise = waitForPopup({ + pageManager: manager, + params: {}, + }) + + await tap({ + pageManager: manager, + params: { + selector: selector({ text: "Open popup" }), + iframeSelector: null, + }, + }) + + const pageId = await pageIdPromise + + expect(manager.count).toBe(countBefore + 1) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(manager.idOf(popupPage as any)).toBe(pageId) + }) +}) diff --git a/packages/patrol/web_runner/tests/__tests__/pageManager.test.ts b/packages/patrol/web_runner/tests/__tests__/pageManager.test.ts new file mode 100644 index 0000000000..1ac6e420e4 --- /dev/null +++ b/packages/patrol/web_runner/tests/__tests__/pageManager.test.ts @@ -0,0 +1,267 @@ +import { test, expect } from "@playwright/test" +import { EventEmitter } from "events" +import { PageManager } from "../pageManager" + +// --------------------------------------------------------------------------- +// Lightweight mocks for Playwright's Page and BrowserContext. +// These use Node's EventEmitter so we can simulate 'close', 'crash', and +// 'page' events without spinning up a real browser. +// --------------------------------------------------------------------------- + +function createMockPage(): MockPage { + const emitter = new EventEmitter() + return Object.assign(emitter, { + on: emitter.on.bind(emitter), + off: emitter.off.bind(emitter), + once: emitter.once.bind(emitter), + removeListener: emitter.removeListener.bind(emitter), + isClosed: () => false, + }) as MockPage +} + +type MockPage = EventEmitter & { + on: EventEmitter["on"] + off: EventEmitter["off"] + once: EventEmitter["once"] + removeListener: EventEmitter["removeListener"] + isClosed: () => boolean +} + +function createMockContext(): MockContext { + const emitter = new EventEmitter() + return Object.assign(emitter, { + on: emitter.on.bind(emitter), + off: emitter.off.bind(emitter), + once: emitter.once.bind(emitter), + removeListener: emitter.removeListener.bind(emitter), + }) as MockContext +} + +type MockContext = EventEmitter & { + on: EventEmitter["on"] + off: EventEmitter["off"] + once: EventEmitter["once"] + removeListener: EventEmitter["removeListener"] +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe("PageManager", () => { + test("initial state: constructor registers the initial page as page_0, sets it as active, count = 1", () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + expect(manager.activeId).toBe("page_0") + expect(manager.count).toBe(1) + expect(manager.ids).toEqual(["page_0"]) + }) + + test("resolve() with no args returns the active page", () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const resolved = manager.activePage + expect(resolved).toBe(initialPage) + }) + + test("resolve() with a valid pageId returns the correct page", () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const resolved = manager.resolve("page_0") + expect(resolved).toBe(initialPage) + }) + + test("resolve() with an invalid pageId throws an error", () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + expect(() => manager.resolve("nonexistent")).toThrow() + }) + + test("activeId setter: switching to a valid page updates activeId", () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + // Simulate a new page opening + const secondPage = createMockPage() + context.emit("page", secondPage) + + manager.activeId = "page_1" + expect(manager.activeId).toBe("page_1") + }) + + test("activeId setter with invalid ID throws an error", () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + expect(() => { + manager.activeId = "nonexistent" + }).toThrow() + }) + + test("auto-registration: new page from context event is registered as page_1 with incremented count", () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const secondPage = createMockPage() + context.emit("page", secondPage) + + expect(manager.count).toBe(2) + expect(manager.ids).toContain("page_1") + expect(manager.resolve("page_1")).toBe(secondPage) + }) + + test("page close cleanup: when a page closes, it is removed from the registry", () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const secondPage = createMockPage() + context.emit("page", secondPage) + expect(manager.count).toBe(2) + + // Simulate the second page closing + secondPage.emit("close") + + expect(manager.count).toBe(1) + expect(manager.ids).not.toContain("page_1") + }) + + test("page crash cleanup: when a page crashes, it is removed from the registry", () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const secondPage = createMockPage() + context.emit("page", secondPage) + expect(manager.count).toBe(2) + + // Simulate the second page crashing + secondPage.emit("crash") + + expect(manager.count).toBe(1) + expect(manager.ids).not.toContain("page_1") + }) + + test("closing active page: if the active page closes, active switches to page_0 (initial page)", () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const secondPage = createMockPage() + context.emit("page", secondPage) + + // Switch to the second page, then close it + manager.activeId = "page_1" + expect(manager.activeId).toBe("page_1") + + secondPage.emit("close") + + expect(manager.activeId).toBe("page_0") + }) + + test("stable IDs: closing page_1 when page_2 exists does not change page_2 ID", () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const secondPage = createMockPage() + context.emit("page", secondPage) + + const thirdPage = createMockPage() + context.emit("page", thirdPage) + + expect(manager.count).toBe(3) + expect(manager.ids).toEqual(expect.arrayContaining(["page_0", "page_1", "page_2"])) + + // Close page_1 — page_2 must keep its ID + secondPage.emit("close") + + expect(manager.count).toBe(2) + expect(manager.ids).toContain("page_0") + expect(manager.ids).toContain("page_2") + expect(manager.ids).not.toContain("page_1") + expect(manager.resolve("page_2")).toBe(thirdPage) + }) + + test("ids: returns all currently tracked page IDs", () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + expect(manager.ids).toEqual(["page_0"]) + + const secondPage = createMockPage() + context.emit("page", secondPage) + + const thirdPage = createMockPage() + context.emit("page", thirdPage) + + expect(manager.ids).toEqual(expect.arrayContaining(["page_0", "page_1", "page_2"])) + expect(manager.ids).toHaveLength(3) + }) + + test("idOf: returns the ID for a given Page instance", () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(manager.idOf(initialPage as any)).toBe("page_0") + + const secondPage = createMockPage() + context.emit("page", secondPage) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(manager.idOf(secondPage as any)).toBe("page_1") + }) + + test("idOf: returns undefined for an untracked Page instance", () => { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + const unknownPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(manager.idOf(unknownPage as any)).toBeUndefined() + }) +}) diff --git a/packages/patrol/web_runner/tests/__tests__/patrolPlatformHandler.test.ts b/packages/patrol/web_runner/tests/__tests__/patrolPlatformHandler.test.ts new file mode 100644 index 0000000000..c578a2a0a8 --- /dev/null +++ b/packages/patrol/web_runner/tests/__tests__/patrolPlatformHandler.test.ts @@ -0,0 +1,147 @@ +import { test, expect } from "@playwright/test" +import { EventEmitter } from "events" +import { exposePatrolPlatformHandler, handlePatrolPlatformAction } from "../patrolPlatformHandler" +import { PageManager } from "../pageManager" +import type { ActionParams, PatrolNativeRequest } from "../contracts" + +// --------------------------------------------------------------------------- +// Lightweight mocks — same EventEmitter pattern as pageManager.test.ts +// --------------------------------------------------------------------------- + +function createMockPage(): MockPage { + const emitter = new EventEmitter() + return Object.assign(emitter, { + on: emitter.on.bind(emitter), + off: emitter.off.bind(emitter), + once: emitter.once.bind(emitter), + removeListener: emitter.removeListener.bind(emitter), + isClosed: () => false, + }) as MockPage +} + +type MockPage = EventEmitter & { + on: EventEmitter["on"] + off: EventEmitter["off"] + once: EventEmitter["once"] + removeListener: EventEmitter["removeListener"] + isClosed: () => boolean +} + +function createMockContext(): MockContext { + const emitter = new EventEmitter() + return Object.assign(emitter, { + on: emitter.on.bind(emitter), + off: emitter.off.bind(emitter), + once: emitter.once.bind(emitter), + removeListener: emitter.removeListener.bind(emitter), + exposeBinding: (async () => {}) as MockContext["exposeBinding"], + }) as MockContext +} + +type MockContext = EventEmitter & { + on: EventEmitter["on"] + off: EventEmitter["off"] + once: EventEmitter["once"] + removeListener: EventEmitter["removeListener"] + exposeBinding: (name: string, callback: (...args: unknown[]) => unknown) => Promise +} + +// --------------------------------------------------------------------------- +// Helper: build a PageManager with mock objects +// --------------------------------------------------------------------------- + +function buildPageManager() { + const context = createMockContext() + const initialPage = createMockPage() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const manager = new PageManager(context as any, initialPage as any) + + return { context, initialPage, manager } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe("patrolPlatformHandler", () => { + // ------------------------------------------------------------------------- + // 1. exposePatrolPlatformHandler calls context.exposeBinding + // ------------------------------------------------------------------------- + + test("exposePatrolPlatformHandler calls context.exposeBinding with '__patrol__platformHandler'", async () => { + const { context, manager } = buildPageManager() + + let boundName: string | undefined + let boundCallback: ((...args: unknown[]) => unknown) | undefined + + context.exposeBinding = async (name: string, callback: (...args: unknown[]) => unknown) => { + boundName = name + boundCallback = callback + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await exposePatrolPlatformHandler(context as any, manager) + + expect(boundName).toBe("__patrol__platformHandler") + expect(typeof boundCallback).toBe("function") + }) + + // ------------------------------------------------------------------------- + // 2. handlePatrolPlatformAction dispatches to the correct action + // ------------------------------------------------------------------------- + + test("handlePatrolPlatformAction dispatches to the correct action with the active page", async () => { + const { initialPage, manager } = buildPageManager() + + // Replace enableDarkMode with a spy so we can verify the page and params + const actionsModule = await import("../actions") + const originalAction = actionsModule.actions.enableDarkMode + + let receivedActivePage: unknown + let receivedParams: unknown + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(actionsModule.actions as any).enableDarkMode = async ({ + pageManager, + params, + }: ActionParams) => { + receivedActivePage = pageManager.activePage + receivedParams = params + } + + const request: PatrolNativeRequest = { + action: "enableDarkMode", + params: {}, + } + + try { + // For the RED phase: this will fail because handlePatrolPlatformAction + // currently takes (page: Page, request) instead of (pageManager, request). + await handlePatrolPlatformAction(manager, request) + } finally { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(actionsModule.actions as any).enableDarkMode = originalAction + } + + // The handler should resolve the active page from PageManager and pass it + // to the action function + expect(receivedActivePage).toBe(initialPage) + expect(receivedParams).toEqual({}) + }) + + // ------------------------------------------------------------------------- + // 3. handlePatrolPlatformAction throws for unknown action + // ------------------------------------------------------------------------- + + test("handlePatrolPlatformAction throws for an unknown action", async () => { + const { manager } = buildPageManager() + + const request = { + action: "unknown-placeholder-nonexistent", + params: {}, + } as PatrolNativeRequest + + await expect(handlePatrolPlatformAction(manager, request)).rejects.toThrow(/not found/i) + }) +}) diff --git a/packages/patrol/web_runner/tests/actions.ts b/packages/patrol/web_runner/tests/actions.ts index 28b83f0b89..0928502033 100644 --- a/packages/patrol/web_runner/tests/actions.ts +++ b/packages/patrol/web_runner/tests/actions.ts @@ -2,46 +2,63 @@ import { acceptNextDialog } from "./actions/acceptNextDialog" import { addCookie } from "./actions/addCookie" import { clearCookies } from "./actions/clearCookies" import { clearPermissions } from "./actions/clearPermissions" +import { closePage } from "./actions/closePage" import { disableDarkMode } from "./actions/disableDarkMode" import { dismissNextDialog } from "./actions/dismissNextDialog" import { enableDarkMode } from "./actions/enableDarkMode" import { enterText } from "./actions/enterText" import { getClipboard } from "./actions/getClipboard" import { getCookies } from "./actions/getCookies" +import { getCurrentPage } from "./actions/getCurrentPage" +import { getCurrentPageUrl } from "./actions/getCurrentPageUrl" +import { getPages } from "./actions/getPages" import { goBack } from "./actions/goBack" import { goForward } from "./actions/goForward" import { grantPermissions } from "./actions/grantPermissions" +import { openNewPage } from "./actions/openNewPage" import { pressKey } from "./actions/pressKey" import { pressKeyCombo } from "./actions/pressKeyCombo" import { resizeWindow } from "./actions/resizeWindow" import { scrollTo } from "./actions/scrollTo" import { setClipboard } from "./actions/setClipboard" import { startTest } from "./actions/startTest" +import { switchToMainPage } from "./actions/switchToMainPage" +import { switchToPage } from "./actions/switchToPage" import { tap } from "./actions/tap" import { uploadFile } from "./actions/uploadFile" import { verifyFileDownloads } from "./actions/verifyFileDownloads" +import { waitForPopup } from "./actions/waitForPopup" +import { ActionParams } from "./contracts" export const actions = { - startTest, - grantPermissions, - enableDarkMode, - disableDarkMode, - tap, - enterText, - scrollTo, - clearPermissions, + acceptNextDialog, addCookie, - getCookies, clearCookies, - uploadFile, - acceptNextDialog, + clearPermissions, + closePage, + disableDarkMode, dismissNextDialog, - pressKey, - pressKeyCombo, - verifyFileDownloads, + enableDarkMode, + enterText, + getClipboard, + getCookies, + getCurrentPage, + getCurrentPageUrl, + getPages, goBack, goForward, - getClipboard, - setClipboard, + grantPermissions, + openNewPage, + pressKey, + pressKeyCombo, resizeWindow, -} as const + scrollTo, + setClipboard, + startTest, + switchToMainPage, + switchToPage, + tap, + uploadFile, + verifyFileDownloads, + waitForPopup, +} as const satisfies Record) => Promise> diff --git a/packages/patrol/web_runner/tests/actions/acceptNextDialog.ts b/packages/patrol/web_runner/tests/actions/acceptNextDialog.ts index 78d1ead5bc..c9bb765282 100644 --- a/packages/patrol/web_runner/tests/actions/acceptNextDialog.ts +++ b/packages/patrol/web_runner/tests/actions/acceptNextDialog.ts @@ -1,8 +1,8 @@ -import { Page } from "playwright" +import type { AcceptNextDialogRequest, ActionParams } from "../contracts" -export async function acceptNextDialog(page: Page) { +export async function acceptNextDialog({ pageManager }: ActionParams) { return new Promise(resolve => { - page.once("dialog", async dialog => { + pageManager.activePage.once("dialog", async dialog => { const message = dialog.message() await dialog.accept() resolve(message) diff --git a/packages/patrol/web_runner/tests/actions/addCookie.ts b/packages/patrol/web_runner/tests/actions/addCookie.ts index 261ceaaf55..f91296a888 100644 --- a/packages/patrol/web_runner/tests/actions/addCookie.ts +++ b/packages/patrol/web_runner/tests/actions/addCookie.ts @@ -1,11 +1,10 @@ -import { Page } from "playwright" -import { AddCookieRequest } from "../contracts" +import type { ActionParams, AddCookieRequest } from "../contracts" import { logger } from "../logger" -export async function addCookie(page: Page, params: AddCookieRequest["params"]) { +export async function addCookie({ pageManager, params }: ActionParams) { logger.info(`Adding cookie: ${params.name} = ${params.value}`) logger.info(`Domain: ${params.domain}`) - await page.context().addCookies([ + await pageManager.context.addCookies([ { name: params.name, value: params.value, diff --git a/packages/patrol/web_runner/tests/actions/clearCookies.ts b/packages/patrol/web_runner/tests/actions/clearCookies.ts index c07165408d..8834409e41 100644 --- a/packages/patrol/web_runner/tests/actions/clearCookies.ts +++ b/packages/patrol/web_runner/tests/actions/clearCookies.ts @@ -1,5 +1,5 @@ -import { Page } from "playwright" +import type { ActionParams, ClearCookiesRequest } from "../contracts" -export async function clearCookies(page: Page) { - await page.context().clearCookies() +export async function clearCookies({ pageManager }: ActionParams) { + await pageManager.context.clearCookies() } diff --git a/packages/patrol/web_runner/tests/actions/clearPermissions.ts b/packages/patrol/web_runner/tests/actions/clearPermissions.ts index 93e415aada..98c5998dcf 100644 --- a/packages/patrol/web_runner/tests/actions/clearPermissions.ts +++ b/packages/patrol/web_runner/tests/actions/clearPermissions.ts @@ -1,5 +1,5 @@ -import { Page } from "playwright" +import type { ActionParams, ClearPermissionsRequest } from "../contracts" -export async function clearPermissions(page: Page) { - await page.context().clearPermissions() +export async function clearPermissions({ pageManager }: ActionParams) { + await pageManager.context.clearPermissions() } diff --git a/packages/patrol/web_runner/tests/actions/closePage.ts b/packages/patrol/web_runner/tests/actions/closePage.ts new file mode 100644 index 0000000000..dd9dd8e019 --- /dev/null +++ b/packages/patrol/web_runner/tests/actions/closePage.ts @@ -0,0 +1,5 @@ +import type { ActionParams, ClosePageRequest } from "../contracts" + +export async function closePage({ pageManager, params }: ActionParams) { + await pageManager.close(params.pageId) +} diff --git a/packages/patrol/web_runner/tests/actions/disableDarkMode.ts b/packages/patrol/web_runner/tests/actions/disableDarkMode.ts index 9d82cbdc87..8ae0d0230a 100644 --- a/packages/patrol/web_runner/tests/actions/disableDarkMode.ts +++ b/packages/patrol/web_runner/tests/actions/disableDarkMode.ts @@ -1,7 +1,7 @@ -import { Page } from "playwright" import { logger } from "../logger" +import type { ActionParams, DisableDarkModeRequest } from "../contracts" -export async function disableDarkMode(page: Page) { - await page.emulateMedia({ colorScheme: "light" }) +export async function disableDarkMode({ pageManager }: ActionParams) { + await pageManager.activePage.emulateMedia({ colorScheme: "light" }) logger.info("Dark mode disabled") } diff --git a/packages/patrol/web_runner/tests/actions/dismissNextDialog.ts b/packages/patrol/web_runner/tests/actions/dismissNextDialog.ts index 7b0000bf79..751368684e 100644 --- a/packages/patrol/web_runner/tests/actions/dismissNextDialog.ts +++ b/packages/patrol/web_runner/tests/actions/dismissNextDialog.ts @@ -1,8 +1,8 @@ -import { Page } from "playwright" +import type { ActionParams, DismissNextDialogRequest } from "../contracts" -export async function dismissNextDialog(page: Page) { +export async function dismissNextDialog({ pageManager }: ActionParams) { return new Promise(resolve => { - page.once("dialog", async dialog => { + pageManager.activePage.once("dialog", async dialog => { const message = dialog.message() await dialog.dismiss() resolve(message) diff --git a/packages/patrol/web_runner/tests/actions/enableDarkMode.ts b/packages/patrol/web_runner/tests/actions/enableDarkMode.ts index fce131490c..92120b1efc 100644 --- a/packages/patrol/web_runner/tests/actions/enableDarkMode.ts +++ b/packages/patrol/web_runner/tests/actions/enableDarkMode.ts @@ -1,7 +1,7 @@ -import { Page } from "playwright" import { logger } from "../logger" +import type { ActionParams, EnableDarkModeRequest } from "../contracts" -export async function enableDarkMode(page: Page) { - await page.emulateMedia({ colorScheme: "dark" }) +export async function enableDarkMode({ pageManager }: ActionParams) { + await pageManager.activePage.emulateMedia({ colorScheme: "dark" }) logger.info("Dark mode enabled") } diff --git a/packages/patrol/web_runner/tests/actions/enterText.ts b/packages/patrol/web_runner/tests/actions/enterText.ts index 4051434fc8..351f42c386 100644 --- a/packages/patrol/web_runner/tests/actions/enterText.ts +++ b/packages/patrol/web_runner/tests/actions/enterText.ts @@ -1,12 +1,12 @@ -import { FrameLocator, Page } from "playwright" -import { EnterTextRequest } from "../contracts" +import type { FrameLocator, Page } from "playwright" +import type { ActionParams, EnterTextRequest } from "../contracts" import { parseWebSelector } from "../parseWebSelector" -export async function enterText(page: Page, params: EnterTextRequest["params"]) { - let context: FrameLocator | Page = page +export async function enterText({ pageManager, params }: ActionParams) { + let context: FrameLocator | Page = pageManager.activePage if (params.iframeSelector) { - const iframeLocator = parseWebSelector(page, params.iframeSelector) + const iframeLocator = parseWebSelector(context, params.iframeSelector) context = iframeLocator.contentFrame() if (!context) throw new Error("Iframe not found") } diff --git a/packages/patrol/web_runner/tests/actions/getClipboard.ts b/packages/patrol/web_runner/tests/actions/getClipboard.ts index ad9261fc50..51bdf43f37 100644 --- a/packages/patrol/web_runner/tests/actions/getClipboard.ts +++ b/packages/patrol/web_runner/tests/actions/getClipboard.ts @@ -1,11 +1,11 @@ -import { Page } from "playwright" import { logger } from "../logger" import { sleep } from "../utils" +import type { ActionParams, GetClipboardRequest } from "../contracts" -export async function getClipboard(page: Page): Promise { +export async function getClipboard({ pageManager }: ActionParams): Promise { try { // we need timeout, because when browser has no permissions to clipboard, it will be waiting for the user to choose an option on dialog - const result = await Promise.race([page.evaluate(() => navigator.clipboard.readText()), sleep(1)]) + const result = await Promise.race([pageManager.activePage.evaluate(() => navigator.clipboard.readText()), sleep(1)]) if (!result) { throw new Error("Timeout") diff --git a/packages/patrol/web_runner/tests/actions/getCookies.ts b/packages/patrol/web_runner/tests/actions/getCookies.ts index 822e34e673..adf6bf0a93 100644 --- a/packages/patrol/web_runner/tests/actions/getCookies.ts +++ b/packages/patrol/web_runner/tests/actions/getCookies.ts @@ -1,5 +1,5 @@ -import { Page } from "playwright" +import type { ActionParams, GetCookiesRequest } from "../contracts" -export async function getCookies(page: Page) { - return await page.context().cookies() +export async function getCookies({ pageManager }: ActionParams) { + return await pageManager.context.cookies() } diff --git a/packages/patrol/web_runner/tests/actions/getCurrentPage.ts b/packages/patrol/web_runner/tests/actions/getCurrentPage.ts new file mode 100644 index 0000000000..b0d52bf245 --- /dev/null +++ b/packages/patrol/web_runner/tests/actions/getCurrentPage.ts @@ -0,0 +1,5 @@ +import type { ActionParams, GetCurrentPageRequest } from "../contracts" + +export async function getCurrentPage({ pageManager }: ActionParams) { + return pageManager.activeId +} diff --git a/packages/patrol/web_runner/tests/actions/getCurrentPageUrl.ts b/packages/patrol/web_runner/tests/actions/getCurrentPageUrl.ts new file mode 100644 index 0000000000..3ecd7f28dd --- /dev/null +++ b/packages/patrol/web_runner/tests/actions/getCurrentPageUrl.ts @@ -0,0 +1,5 @@ +import type { ActionParams, GetCurrentPageUrlRequest } from "../contracts" + +export async function getCurrentPageUrl({ pageManager }: ActionParams) { + return pageManager.activePage.url() +} diff --git a/packages/patrol/web_runner/tests/actions/getPages.ts b/packages/patrol/web_runner/tests/actions/getPages.ts new file mode 100644 index 0000000000..03bfe9f29f --- /dev/null +++ b/packages/patrol/web_runner/tests/actions/getPages.ts @@ -0,0 +1,5 @@ +import type { ActionParams, GetPagesRequest } from "../contracts" + +export async function getPages({ pageManager }: ActionParams) { + return pageManager.ids +} diff --git a/packages/patrol/web_runner/tests/actions/goBack.ts b/packages/patrol/web_runner/tests/actions/goBack.ts index 9e2ab9e299..cf120a3308 100644 --- a/packages/patrol/web_runner/tests/actions/goBack.ts +++ b/packages/patrol/web_runner/tests/actions/goBack.ts @@ -1,5 +1,5 @@ -import { Page } from "playwright" +import type { ActionParams, GoBackRequest } from "../contracts" -export async function goBack(page: Page) { - await page.goBack() +export async function goBack({ pageManager }: ActionParams) { + await pageManager.activePage.goBack() } diff --git a/packages/patrol/web_runner/tests/actions/goForward.ts b/packages/patrol/web_runner/tests/actions/goForward.ts index dd7884dfe3..789b4d6017 100644 --- a/packages/patrol/web_runner/tests/actions/goForward.ts +++ b/packages/patrol/web_runner/tests/actions/goForward.ts @@ -1,5 +1,5 @@ -import { Page } from "playwright" +import type { ActionParams, GoForwardRequest } from "../contracts" -export async function goForward(page: Page) { - await page.goForward() +export async function goForward({ pageManager }: ActionParams) { + await pageManager.activePage.goForward() } diff --git a/packages/patrol/web_runner/tests/actions/grantPermissions.ts b/packages/patrol/web_runner/tests/actions/grantPermissions.ts index 97f2896977..4a6aee533b 100644 --- a/packages/patrol/web_runner/tests/actions/grantPermissions.ts +++ b/packages/patrol/web_runner/tests/actions/grantPermissions.ts @@ -1,9 +1,8 @@ -import { Page } from "playwright" -import { GrantPermissionsRequest } from "../contracts" +import type { ActionParams, GrantPermissionsRequest } from "../contracts" import { logger } from "../logger" -export async function grantPermissions(page: Page, params: GrantPermissionsRequest["params"]) { - const origin = params.origin ?? new URL(page.url()).origin - await page.context().grantPermissions(params.permissions ?? [], { origin }) +export async function grantPermissions({ pageManager, params }: ActionParams) { + const origin = params.origin ?? new URL(pageManager.activePage.url()).origin + await pageManager.context.grantPermissions(params.permissions ?? [], { origin }) logger.info(`Granted permissions: ${params.permissions?.join(", ")} for ${origin}`) } diff --git a/packages/patrol/web_runner/tests/actions/openNewPage.ts b/packages/patrol/web_runner/tests/actions/openNewPage.ts new file mode 100644 index 0000000000..c0c6e2647c --- /dev/null +++ b/packages/patrol/web_runner/tests/actions/openNewPage.ts @@ -0,0 +1,7 @@ +import type { ActionParams, OpenNewPageRequest } from "../contracts" + +export async function openNewPage({ pageManager, params }: ActionParams): Promise { + const newPage = await pageManager.context.newPage() + await newPage.goto(params.url) + return pageManager.idOf(newPage)! +} diff --git a/packages/patrol/web_runner/tests/actions/pressKey.ts b/packages/patrol/web_runner/tests/actions/pressKey.ts index 82195d194d..e24af8ed52 100644 --- a/packages/patrol/web_runner/tests/actions/pressKey.ts +++ b/packages/patrol/web_runner/tests/actions/pressKey.ts @@ -1,6 +1,5 @@ -import { Page } from "playwright" -import { PressKeyRequest } from "../contracts" +import { ActionParams, PressKeyRequest } from "../contracts" -export async function pressKey(page: Page, params: PressKeyRequest["params"]) { - await page.keyboard.press(params.key) +export async function pressKey({ pageManager, params }: ActionParams) { + await pageManager.activePage.keyboard.press(params.key) } diff --git a/packages/patrol/web_runner/tests/actions/pressKeyCombo.ts b/packages/patrol/web_runner/tests/actions/pressKeyCombo.ts index 7c91b15bc4..a13a751a41 100644 --- a/packages/patrol/web_runner/tests/actions/pressKeyCombo.ts +++ b/packages/patrol/web_runner/tests/actions/pressKeyCombo.ts @@ -1,7 +1,6 @@ -import { Page } from "playwright" -import { PressKeyComboRequest } from "../contracts" +import type { ActionParams, PressKeyComboRequest } from "../contracts" -export async function pressKeyCombo(page: Page, params: PressKeyComboRequest["params"]) { +export async function pressKeyCombo({ pageManager, params }: ActionParams) { const combo = params.keys.join("+") - await page.keyboard.press(combo) + await pageManager.activePage.keyboard.press(combo) } diff --git a/packages/patrol/web_runner/tests/actions/resizeWindow.ts b/packages/patrol/web_runner/tests/actions/resizeWindow.ts index af482abc0c..c963e7462b 100644 --- a/packages/patrol/web_runner/tests/actions/resizeWindow.ts +++ b/packages/patrol/web_runner/tests/actions/resizeWindow.ts @@ -1,8 +1,7 @@ -import { Page } from "playwright" -import { ResizeWindowRequest } from "../contracts" +import type { ActionParams, ResizeWindowRequest } from "../contracts" -export async function resizeWindow(page: Page, params: ResizeWindowRequest["params"]) { - await page.setViewportSize({ +export async function resizeWindow({ pageManager, params }: ActionParams) { + await pageManager.activePage.setViewportSize({ width: params.width, height: params.height, }) diff --git a/packages/patrol/web_runner/tests/actions/scrollTo.ts b/packages/patrol/web_runner/tests/actions/scrollTo.ts index 7c2f32f1f9..89ea4f122b 100644 --- a/packages/patrol/web_runner/tests/actions/scrollTo.ts +++ b/packages/patrol/web_runner/tests/actions/scrollTo.ts @@ -1,12 +1,12 @@ -import { FrameLocator, Page } from "playwright" -import { ScrollToRequest } from "../contracts" +import type { FrameLocator, Page } from "playwright" +import type { ActionParams, ScrollToRequest } from "../contracts" import { parseWebSelector } from "../parseWebSelector" -export async function scrollTo(page: Page, params: ScrollToRequest["params"]) { - let context: FrameLocator | Page = page +export async function scrollTo({ pageManager, params }: ActionParams) { + let context: FrameLocator | Page = pageManager.activePage if (params.iframeSelector) { - const iframeLocator = parseWebSelector(page, params.iframeSelector) + const iframeLocator = parseWebSelector(context, params.iframeSelector) context = iframeLocator.contentFrame() if (!context) throw new Error("Iframe not found") } diff --git a/packages/patrol/web_runner/tests/actions/setClipboard.ts b/packages/patrol/web_runner/tests/actions/setClipboard.ts index f2993acfeb..c7107b6179 100644 --- a/packages/patrol/web_runner/tests/actions/setClipboard.ts +++ b/packages/patrol/web_runner/tests/actions/setClipboard.ts @@ -1,12 +1,11 @@ -import { Page } from "playwright" -import { SetClipboardRequest } from "../contracts" +import { ActionParams, SetClipboardRequest } from "../contracts" import { logger } from "../logger" import { sleep } from "../utils" -export async function setClipboard(page: Page, params: SetClipboardRequest["params"]) { +export async function setClipboard({ pageManager, params }: ActionParams) { try { const write = async () => { - await page.evaluate(text => navigator.clipboard.writeText(text), params.text) + await pageManager.activePage.evaluate(text => navigator.clipboard.writeText(text), params.text) return true } diff --git a/packages/patrol/web_runner/tests/actions/startTest.ts b/packages/patrol/web_runner/tests/actions/startTest.ts index fef66cff9c..c070321657 100644 --- a/packages/patrol/web_runner/tests/actions/startTest.ts +++ b/packages/patrol/web_runner/tests/actions/startTest.ts @@ -1,12 +1,11 @@ -import { Page } from "playwright" +import type { Page } from "playwright" import { logger } from "../logger" +import type { ActionParams, StartTestRequest } from "../contracts" export const downloadedFiles: string[] = [] const initializedPages = new WeakSet() -export async function startTest(page: Page) { - downloadedFiles.splice(0, downloadedFiles.length) - +function registerDownloadListener(page: Page) { if (!initializedPages.has(page)) { initializedPages.add(page) page.on("download", async download => { @@ -16,3 +15,19 @@ export async function startTest(page: Page) { }) } } + +export async function startTest({ pageManager }: ActionParams) { + downloadedFiles.splice(0, downloadedFiles.length) + registerDownloadListener(pageManager.activePage) + + // Register on all currently tracked pages from the context + const context = pageManager.context + for (const p of context.pages()) { + registerDownloadListener(p) + } + + // Listen for future pages in this context + context.on("page", newPage => { + registerDownloadListener(newPage) + }) +} diff --git a/packages/patrol/web_runner/tests/actions/switchToMainPage.ts b/packages/patrol/web_runner/tests/actions/switchToMainPage.ts new file mode 100644 index 0000000000..1fdf9eba74 --- /dev/null +++ b/packages/patrol/web_runner/tests/actions/switchToMainPage.ts @@ -0,0 +1,6 @@ +import type { ActionParams, SwitchToMainPageRequest } from "../contracts" +import { switchToPage } from "./switchToPage" + +export async function switchToMainPage({ pageManager }: ActionParams) { + await switchToPage({ pageManager, params: { pageId: pageManager.mainPageId } }) +} diff --git a/packages/patrol/web_runner/tests/actions/switchToPage.ts b/packages/patrol/web_runner/tests/actions/switchToPage.ts new file mode 100644 index 0000000000..708938a23b --- /dev/null +++ b/packages/patrol/web_runner/tests/actions/switchToPage.ts @@ -0,0 +1,6 @@ +import type { ActionParams, SwitchToPageRequest } from "../contracts" + +export async function switchToPage({ pageManager, params }: ActionParams) { + pageManager.activeId = params.pageId + await pageManager.activePage.bringToFront() +} diff --git a/packages/patrol/web_runner/tests/actions/tap.ts b/packages/patrol/web_runner/tests/actions/tap.ts index df9018e20f..2e7f9ef666 100644 --- a/packages/patrol/web_runner/tests/actions/tap.ts +++ b/packages/patrol/web_runner/tests/actions/tap.ts @@ -1,12 +1,12 @@ -import { FrameLocator, Page } from "playwright" -import { TapRequest } from "../contracts" +import type { FrameLocator, Page } from "playwright" +import type { ActionParams, TapRequest } from "../contracts" import { parseWebSelector } from "../parseWebSelector" -export async function tap(page: Page, params: TapRequest["params"]) { - let context: FrameLocator | Page = page +export async function tap({ pageManager, params }: ActionParams) { + let context: FrameLocator | Page = pageManager.activePage if (params.iframeSelector) { - const iframeLocator = parseWebSelector(page, params.iframeSelector) + const iframeLocator = parseWebSelector(context, params.iframeSelector) context = iframeLocator.contentFrame() if (!context) throw new Error("Iframe not found") } diff --git a/packages/patrol/web_runner/tests/actions/uploadFile.ts b/packages/patrol/web_runner/tests/actions/uploadFile.ts index 033d8897f3..8113ff7ea8 100644 --- a/packages/patrol/web_runner/tests/actions/uploadFile.ts +++ b/packages/patrol/web_runner/tests/actions/uploadFile.ts @@ -1,14 +1,13 @@ -import { Page } from "playwright" -import { UploadFileRequest } from "../contracts" +import { ActionParams, UploadFileRequest } from "../contracts" -export async function uploadFile(page: Page, params: UploadFileRequest["params"]) { +export async function uploadFile({ pageManager, params }: ActionParams) { const files = params.files.map(file => ({ name: file.name, mimeType: file.mimeType, buffer: Buffer.from(file.base64Data, "base64"), })) - const fileChooser = await page.waitForEvent("filechooser") + const fileChooser = await pageManager.activePage.waitForEvent("filechooser") await fileChooser.setFiles(files) } diff --git a/packages/patrol/web_runner/tests/actions/waitForPopup.ts b/packages/patrol/web_runner/tests/actions/waitForPopup.ts new file mode 100644 index 0000000000..c20528e524 --- /dev/null +++ b/packages/patrol/web_runner/tests/actions/waitForPopup.ts @@ -0,0 +1,16 @@ +import type { ActionParams, WaitForPopupRequest } from "../contracts" + +export async function waitForPopup({ pageManager }: ActionParams): Promise { + return new Promise((resolve, reject) => { + pageManager.context.once("page", async page => { + const tabId = pageManager.idOf(page) + + if (!tabId) { + reject(new Error("Popup page was not registered by PageManager")) + return + } + + resolve(tabId) + }) + }) +} diff --git a/packages/patrol/web_runner/tests/contracts.ts b/packages/patrol/web_runner/tests/contracts.ts index 579444298c..4dd13d0404 100644 --- a/packages/patrol/web_runner/tests/contracts.ts +++ b/packages/patrol/web_runner/tests/contracts.ts @@ -1,3 +1,5 @@ +import { PageManager } from "./pageManager" + type PatrolNativeRequestBase = { action: TAction params: TParams @@ -105,6 +107,29 @@ export type ResizeWindowRequest = PatrolNativeRequestBase< height: number } > +export type OpenNewPageRequest = PatrolNativeRequestBase< + "openNewPage", + { + url: string + } +> +export type ClosePageRequest = PatrolNativeRequestBase< + "closePage", + { + pageId: string + } +> +export type SwitchToPageRequest = PatrolNativeRequestBase< + "switchToPage", + { + pageId: string + } +> +export type SwitchToMainPageRequest = PatrolNativeRequestBase<"switchToMainPage", {}> +export type GetPagesRequest = PatrolNativeRequestBase<"getPages", {}> +export type GetCurrentPageRequest = PatrolNativeRequestBase<"getCurrentPage", {}> +export type GetCurrentPageUrlRequest = PatrolNativeRequestBase<"getCurrentPageUrl", {}> +export type WaitForPopupRequest = PatrolNativeRequestBase<"waitForPopup", {}> type UnknownRequest = PatrolNativeRequestBase<`unknown-placeholder-${string}`, unknown> export type PatrolNativeRequest = @@ -112,22 +137,35 @@ export type PatrolNativeRequest = | AddCookieRequest | ClearCookiesRequest | ClearPermissionsRequest + | ClosePageRequest | DisableDarkModeRequest | DismissNextDialogRequest | EnableDarkModeRequest | EnterTextRequest | GetClipboardRequest | GetCookiesRequest + | GetCurrentPageRequest + | GetCurrentPageUrlRequest + | GetPagesRequest | GoBackRequest | GoForwardRequest | GrantPermissionsRequest + | OpenNewPageRequest | PressKeyComboRequest | PressKeyRequest | ResizeWindowRequest | ScrollToRequest | SetClipboardRequest | StartTestRequest + | SwitchToPageRequest + | SwitchToMainPageRequest | TapRequest | UnknownRequest | UploadFileRequest | VerifyFileDownloadsRequest + | WaitForPopupRequest + +export type ActionParams = { + pageManager: PageManager + params: T["params"] +} diff --git a/packages/patrol/web_runner/tests/develop.ts b/packages/patrol/web_runner/tests/develop.ts index c681e5fb3a..f25bee6954 100644 --- a/packages/patrol/web_runner/tests/develop.ts +++ b/packages/patrol/web_runner/tests/develop.ts @@ -1,6 +1,7 @@ import { chromium } from "playwright" import { initialise } from "./initialise" import { logger } from "./logger" +import { PageManager } from "./pageManager" import { exposePatrolPlatformHandler } from "./patrolPlatformHandler" import "./types" @@ -17,7 +18,8 @@ async function develop() { const page = context.pages().at(0) ?? (await context.newPage()) - await exposePatrolPlatformHandler(page) + const pageManager = new PageManager(context, page) + await exposePatrolPlatformHandler(context, pageManager) await initialise(page) diff --git a/packages/patrol/web_runner/tests/pageManager.ts b/packages/patrol/web_runner/tests/pageManager.ts new file mode 100644 index 0000000000..486c99d759 --- /dev/null +++ b/packages/patrol/web_runner/tests/pageManager.ts @@ -0,0 +1,93 @@ +import type { Page, BrowserContext } from "playwright" + +export class PageManager { + private registry = new Map() + private reverse = new Map() + private _activeId: string + readonly mainPageId: string + private nextIndex = 0 + + constructor( + readonly context: BrowserContext, + mainPage: Page, + ) { + this.context = context + this.mainPageId = this.register(mainPage) + this._activeId = this.mainPageId + + this.context.on("page", (page: Page) => { + this.register(page) + }) + } + + private register(page: Page): string { + const id = `page_${this.nextIndex++}` + + this.registry.set(id, page) + this.reverse.set(page, id) + + const cleanup = () => { + this.registry.delete(id) + this.reverse.delete(page) + if (this._activeId === id) { + this._activeId = this.mainPageId + } + } + + page.on("close", cleanup) + page.on("crash", cleanup) + + return id + } + + resolve(pageId: string): Page { + const page = this.registry.get(pageId) + + if (!page) { + throw new Error(`No page found for page ID "${pageId}"`) + } + + return page + } + + async close(pageId: string): Promise { + if (pageId === this.mainPageId) { + throw new Error("Cannot close the main Flutter page") + } + + const page = this.resolve(pageId) + await page.close() + } + + get activeId(): string { + return this._activeId + } + + set activeId(pageId: string) { + if (!this.registry.has(pageId)) { + throw new Error(`No page found for page ID "${pageId}"`) + } + + this._activeId = pageId + } + + get activePage(): Page { + return this.resolve(this.activeId) + } + + get count(): number { + return this.registry.size + } + + get ids(): string[] { + return Array.from(this.registry.keys()) + } + + idOf(page: Page): string | undefined { + return this.reverse.get(page) + } + + isMainPage(page: Page): boolean { + return this.idOf(page) === this.mainPageId + } +} diff --git a/packages/patrol/web_runner/tests/patrolPlatformHandler.ts b/packages/patrol/web_runner/tests/patrolPlatformHandler.ts index a178295abb..e6bba7950b 100644 --- a/packages/patrol/web_runner/tests/patrolPlatformHandler.ts +++ b/packages/patrol/web_runner/tests/patrolPlatformHandler.ts @@ -1,15 +1,20 @@ -import { Page } from "playwright" +import { BrowserContext } from "playwright" import { actions } from "./actions" import { PatrolNativeRequest } from "./contracts" import { logger } from "./logger" +import { PageManager } from "./pageManager" -export async function exposePatrolPlatformHandler(page: Page) { - await page.exposeBinding("__patrol__platformHandler", async ({ page }, request) => - handlePatrolPlatformAction(page, request), - ) +export async function exposePatrolPlatformHandler(context: BrowserContext, pageManager: PageManager) { + await context.exposeBinding("__patrol__platformHandler", async ({ page }, request) => { + if (!pageManager.isMainPage(page)) { + throw new Error(`Unauthorized: only the main test page can call the platform handler`) + } + + return handlePatrolPlatformAction(pageManager, request) + }) } -async function handlePatrolPlatformAction(page: Page, { action, params }: PatrolNativeRequest) { +export async function handlePatrolPlatformAction(pageManager: PageManager, { action, params }: PatrolNativeRequest) { logger.info(params, `Received action: ${action}`) const actionFn = actions[action as keyof typeof actions] @@ -19,7 +24,7 @@ async function handlePatrolPlatformAction(page: Page, { action, params }: Patrol } try { - return await actionFn(page, params as any) + return await actionFn({ pageManager, params: params as any }) } catch (e) { logger.error(e, "Failed to handle patrol platform request") throw e diff --git a/packages/patrol/web_runner/tests/test.spec.ts b/packages/patrol/web_runner/tests/test.spec.ts index c5857c4e9f..61758cd118 100644 --- a/packages/patrol/web_runner/tests/test.spec.ts +++ b/packages/patrol/web_runner/tests/test.spec.ts @@ -1,6 +1,7 @@ import { test as base } from "@playwright/test" import { initialise } from "./initialise" import { logger } from "./logger" +import { PageManager } from "./pageManager" import { exposePatrolPlatformHandler } from "./patrolPlatformHandler" import { PatrolTestEntry } from "./types" @@ -10,7 +11,7 @@ if (tests.length === 0) { } export const patrolTest = base.extend({ - page: async ({ page }, use) => { + page: async ({ page, context }, use) => { page.on("console", message => { const text = message.text() if (text.startsWith("PATROL_LOG")) { @@ -25,11 +26,19 @@ export const patrolTest = base.extend({ await page.goto("/", { waitUntil: "load" }) - await exposePatrolPlatformHandler(page) + const pageManager = new PageManager(context, page) + await exposePatrolPlatformHandler(context, pageManager) await initialise(page) await use(page) + + // Teardown: close all secondary pages (not the initial one) + for (const p of context.pages()) { + if (p !== page && !p.isClosed()) { + await p.close().catch(() => {}) + } + } }, })