8.0 KiB
82. Patrol Web Integration Test — Migration Guide & Implementation Example
Date: 2026-04-15
Status
Proposed
Related ADRs
- ADR-0053 — Patrol for mobile integration testing (foundation)
- ADR-0080 — Web test setup & execution
- ADR-0081 — Cross-platform test architecture (read first)
- ADR-0083 — PR-by-PR migration plan
Context
ADR-0081 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.
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.
RequiresLoginMixinis 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
// 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
// robots/abstract/abstract_composer_robot.dart
abstract class AbstractComposerRobot {
Future<void> addRecipient(PrefixEmailAddress prefix, String email);
Future<void> addSubject(String subject);
Future<void> addContent(String content);
Future<void> 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:
// robots/mobile/base_composer_robot.dart
abstract class BaseComposerRobot extends CoreRobot implements AbstractComposerRobot {
BaseComposerRobot(super.$);
// Identical on all platforms — defined once here
@override Future<void> send() async =>
$(const ValueKey(UiKeys.sendEmailButton)).tap();
@override Future<void> addRecipient(...) async { ... }
@override Future<void> addSubject(...) async { ... }
}
class MobileComposerRobot extends BaseComposerRobot {
MobileComposerRobot(super.$);
@override
Future<void> addContent(String content) async {
// Navigate InAppWebView widget tree
ComposerController? controller;
await $(ComposerView).which<ComposerView>((w) {
controller = w.controller; return true;
}).$(MobileEditorView).$(HtmlEditor).$(InAppWebView).tap();
await controller!.htmlEditorApi!.insertHtml('$content <br><br>');
}
}
// robots/web/web_composer_robot.dart
class WebComposerRobot extends BaseComposerRobot {
WebComposerRobot(super.$);
@override
Future<void> 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:
class MobileThreadRobot extends CoreRobot implements AbstractThreadRobot {
@override Future<void> openComposer() async =>
$(const ValueKey(UiKeys.composeEmailButton)).$(InkWell).tap();
}
4. Factory registration
// 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
// scenarios/composer/send_email_scenario.dart
class SendEmailScenario extends BaseTestScenario {
const SendEmailScenario(super.$, super.robots);
@override
Future<void> 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
// tests/composer/send_email_test.dart
void main() {
TestBase().runPatrolTest(
description: 'Send email',
scenarioBuilder: ($, robots) => SendEmailScenario($, robots),
);
}
# 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)
# 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:
// 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.