# 82. Patrol Web Integration Test — Migration Guide & Implementation Example Date: 2026-04-15 ## Status Proposed ## Related ADRs - [ADR-0053](./0053-patrol-integration-test.md) — Patrol for mobile integration testing (foundation) - [ADR-0080](./0080-patrol-web-integration-test-setup.md) — Web test setup & execution - [ADR-0081](./0081-patrol-web-test-architecture.md) — Cross-platform test architecture (read first) - [ADR-0083](./0083-patrol-web-test-migration-plan.md) — PR-by-PR migration plan ## Context [ADR-0081](./0081-patrol-web-test-architecture.md) defines the target architecture. This ADR covers the impact on existing mobile tests and provides a concrete implementation example. For the PR breakdown and sequencing, see [ADR-0083](./0083-patrol-web-test-migration-plan.md). --- ## Impact on Existing Mobile Tests The migration changes two public APIs in `base/`: | Location | Before | After | |----------|--------|-------| | Test file | `scenarioBuilder: ($) => MyScenario($)` | `scenarioBuilder: ($, robots) => MyScenario($, robots)` | | Scenario constructor | `MyScenario(super.$)` | `MyScenario(super.$, super.robots)` | | Login | `RequiresLoginMixin` | `robots.loginRobot().login()` in `BaseTestScenario` | ### Estimated scope | Category | Action | Count | |----------|--------|-------| | `scenarios/**/*.dart` | Update constructor | ~80 files | | `tests/**/*.dart` | Update `scenarioBuilder` | ~120 files | | `robots/*.dart` | Move to `robots/mobile/`, add interface | ~40 files | | `robots/abstract/*.dart` | New interfaces | ~40 new files | | `robots/web/*.dart` | New web implementations | ~15 new files | | `factories/*.dart` | New factory + provider files | ~6 new files | | `base/` | Update `TestBase` + `BaseTestScenario` | 2 files | ### What is NOT affected - `ScenarioUtilsMixin` — provisioning helpers are unchanged. - `base_scenario.dart`, `core_robot.dart` — unchanged. - Robot interaction logic — moved, not rewritten. - CI pipeline scripts — unchanged. ### Side effects - Mobile CI breaks if the base API change ships without the scenario/test file updates. The final PR must be **atomic** — see [ADR-0083](./0083-patrol-web-test-migration-plan.md). - `RequiresLoginMixin` is deleted once migration is complete. - Robots identical across platforms (e.g. `SearchRobot`) need an abstract interface but no web variant. --- ## Implementation Example: Send Email This example shows the full implementation of a cross-platform test under the new architecture. ### 1. UIKeys — applied to all platform widget variants ```dart // lib/features/base/model/ui_keys.dart class UiKeys { static const String composeEmailButton = 'composeEmailButton'; static const String sendEmailButton = 'sendEmailButton'; } ``` Apply the same key to mobile toolbar, web toolbar, and tablet toolbar widgets. ### 2. Abstract robot interfaces ```dart // robots/abstract/abstract_composer_robot.dart abstract class AbstractComposerRobot { Future addRecipient(PrefixEmailAddress prefix, String email); Future addSubject(String subject); Future addContent(String content); Future send(); } ``` ### 3. Shared base + platform implementations Methods identical across platforms go in a shared base to avoid duplication. The base class can live in its own file (e.g. `robots/mobile/base_composer_robot.dart`) or alongside the mobile implementation: ```dart // robots/mobile/base_composer_robot.dart abstract class BaseComposerRobot extends CoreRobot implements AbstractComposerRobot { BaseComposerRobot(super.$); // Identical on all platforms — defined once here @override Future send() async => $(const ValueKey(UiKeys.sendEmailButton)).tap(); @override Future addRecipient(...) async { ... } @override Future addSubject(...) async { ... } } class MobileComposerRobot extends BaseComposerRobot { MobileComposerRobot(super.$); @override Future addContent(String content) async { // Navigate InAppWebView widget tree ComposerController? controller; await $(ComposerView).which((w) { controller = w.controller; return true; }).$(MobileEditorView).$(HtmlEditor).$(InAppWebView).tap(); await controller!.htmlEditorApi!.insertHtml('$content

'); } } // robots/web/web_composer_robot.dart class WebComposerRobot extends BaseComposerRobot { WebComposerRobot(super.$); @override Future addContent(String content) async { await $(WebEditorWidget).tap(); await $.platformAutomator.web.enterText( WebSelector(cssOrXpath: 'div.note-editable'), iframeSelector: WebSelector(cssOrXpath: 'iframe'), text: content, ); } } ``` `ThreadRobot` has no UI difference — one class reused by both factories: ```dart class MobileThreadRobot extends CoreRobot implements AbstractThreadRobot { @override Future openComposer() async => $(const ValueKey(UiKeys.composeEmailButton)).$(InkWell).tap(); } ``` ### 4. Factory registration ```dart // factories/web_robot_factory.dart class WebRobotFactory implements RobotFactory { final PatrolIntegrationTester $; WebRobotFactory(this.$); @override AbstractComposerRobot composerRobot() => WebComposerRobot($); @override AbstractThreadRobot threadRobot() => MobileThreadRobot($); // reused @override AbstractLoginRobot loginRobot() => WebLoginRobot($); } ``` ### 5. Scenario — written once ```dart // scenarios/composer/send_email_scenario.dart class SendEmailScenario extends BaseTestScenario { const SendEmailScenario(super.$, super.robots); @override Future runTestLogic() async { await robots.threadRobot().openComposer(); await robots.composerRobot().addRecipient(PrefixEmailAddress.to, const String.fromEnvironment('BASIC_AUTH_EMAIL')); await robots.composerRobot().addSubject('Send email test'); await robots.composerRobot().addContent('Hello from Patrol'); await robots.composerRobot().send(); await expectViewVisible( $(find.text(AppLocalizations().message_has_been_sent_successfully))); } } ``` ### 6. Test file — one file, all platforms ```dart // tests/composer/send_email_test.dart void main() { TestBase().runPatrolTest( description: 'Send email', scenarioBuilder: ($, robots) => SendEmailScenario($, robots), ); } ``` ```bash # Mobile patrol test -t integration_test/tests/composer/send_email_test.dart \ --device=android # Web patrol test -t integration_test/tests/composer/send_email_test.dart \ --device=chrome --web-headless=true ``` Same file. Same scenario. Factory resolved automatically per platform via `kIsWeb` — `--device` already determines the target, so `--tags` is not required for routing. --- ## Handling Platform-Only Features Some features exist only on web (e.g., a browser-specific upload flow) or only on mobile (e.g., deep links). Using `@Tags` annotations works but is error-prone — developers can easily forget to add them. Instead, platform constraints are encoded **inside `runPatrolTest()`** so the test skips itself automatically. ### CI commands (simplified) ```bash # Mobile — runs all tests; platform-only tests skip themselves patrol test --device=android # Web — runs all tests; platform-only tests skip themselves patrol test --device=chrome --web-headless=true ``` ### Unsupported robot in the factory As a second safety net, if a web-only robot is accidentally called on mobile (e.g. shared scenario logic), the mobile factory fails fast: ```dart // factories/mobile_robot_factory.dart @override AbstractUploadRobot uploadRobot() => throw UnsupportedError('Web upload is not supported on mobile'); // factories/web_robot_factory.dart @override AbstractUploadRobot uploadRobot() => WebUploadRobot($); ``` ## Consequences - Migration touches ~200 files but changes are mechanical — suitable for scripted bulk update. - Atomic PR (base + scenarios + test files) will be large; review it as a structural change, not a logic change. - After migration, shared base robots keep web implementations minimal — only the genuinely different method is overridden.