diff --git a/docs/adr/0080-patrol-web-integration-test-setup.md b/docs/adr/0080-patrol-web-integration-test-setup.md new file mode 100644 index 000000000..8219b4bbf --- /dev/null +++ b/docs/adr/0080-patrol-web-integration-test-setup.md @@ -0,0 +1,123 @@ +# 80. Patrol Web Integration Test — Setup & Execution + +Date: 2026-04-15 + +## Status + +Accepted + +## Related ADRs + +- [ADR-0053](./0053-patrol-integration-test.md) — Patrol for mobile integration testing (foundation) +- [ADR-0081](./0081-patrol-web-test-architecture.md) — Cross-platform test architecture +- [ADR-0082](./0082-patrol-web-test-migration-guide.md) — Migration guide & implementation example +- [ADR-0083](./0083-patrol-web-test-migration-plan.md) — PR-by-PR migration plan + +## Context + +[ADR-0053](./0053-patrol-integration-test.md) established Patrol for mobile (Android/iOS) integration testing. As Twake Mail is also deployed as a web app, the same level of E2E coverage is needed on the browser. Patrol 4.x added Chrome support via `--device=chrome`, making it feasible to extend the existing setup without introducing a second testing framework. + +## Decision + +Use Patrol with Chrome as the target device for web integration testing, sharing the same backend Docker infrastructure used for mobile tests. + +### Prerequisites + +- Flutter 3.38.9 +- Patrol CLI 4.3.1: `dart pub global activate patrol_cli 4.3.1` +- `patrol` package **4.5.0** (and `patrol_finders 3.x`, `patrol_log 0.8.x`) — requires upgrading from the 3.x currently in `pubspec.yaml` +- Docker Desktop (running) +- `openssl` installed (used to generate JWT keys for the backend) + +> Unlike mobile tests, web tests do **not** require `ngrok` or `jq`. The app runs at `localhost` and Chrome connects to it directly — no traffic forwarding needed. + +### Environment Variables + +All tests receive configuration via `--dart-define`. Store values in an env file and source it in the run scripts — this keeps credentials out of command-line history and git history (add the file to `.gitignore`): + +```bash +# integration_test/test_config.env +BASIC_AUTH_URL=http://localhost/ +USERNAME=test_user +PASSWORD=test_password +BASIC_AUTH_EMAIL=test@example.com +ADDITIONAL_MAIL_RECIPIENT=recipient@example.com +``` + +The run scripts source this file and forward each value: + +```bash +source integration_test/test_config.env +patrol test \ + --dart-define=BASIC_AUTH_URL=$BASIC_AUTH_URL \ + --dart-define=USERNAME=$USERNAME \ + --dart-define=PASSWORD=$PASSWORD \ + --dart-define=BASIC_AUTH_EMAIL=$BASIC_AUTH_EMAIL \ + --dart-define=ADDITIONAL_MAIL_RECIPIENT=$ADDITIONAL_MAIL_RECIPIENT \ + ... +``` + +| Variable | Description | +|----------|-------------| +| `BASIC_AUTH_URL` | App server URL (e.g. `http://localhost/`) | +| `USERNAME` | Test account username | +| `PASSWORD` | Test account password | +| `BASIC_AUTH_EMAIL` | Test account email address | +| `ADDITIONAL_MAIL_RECIPIENT` | Secondary email for multi-recipient tests | + +> `--dart-define-from-file` only accepts JSON, not `.env` format, so sourcing in the script is the correct approach. + +### Running Locally (with visible browser) + +```bash +./scripts/patrol-web-local-integration-test-with-docker.sh +``` + +This starts the Docker backend, then runs patrol with Chrome visible. Useful during development. + +To run a single test file, edit the script and replace `patrol test -v` with: + +```bash +patrol test -v -t integration_test/tests/composer/send_email_test.dart +``` + +> After migrating to the architecture defined in [ADR-0081](./0081-patrol-web-test-architecture.md), test files no longer have a `web_` prefix — one file covers all platforms. + +### Running in CI (headless) + +```bash +./scripts/patrol-web-integration-test-with-docker.sh +``` + +Adds `--web-headless=true` — no display required. The GitHub Actions workflow (`.github/workflows/patrol-web-integration-test.yaml`) runs this script on pull requests. + +### CI Orchestration — Running the Full Project + +Mobile and web test suites run as **separate parallel jobs** in CI. Each job targets its own platform: + +```text +PR opened / push +│ +├── job: patrol-mobile-integration-test (ubuntu-latest + Android emulator) +│ patrol test --device=android +│ +└── job: patrol-web-integration-test (ubuntu-latest + Chrome via Patrol) + ./scripts/patrol-web-integration-test-with-docker.sh + # patrol test --device=chrome --web-headless=true +``` + +The two jobs are independent — they share no state and can run concurrently. A PR is gated on **both** jobs passing. + +Platform filtering is handled automatically by `runPatrolTest()` (see [ADR-0082](./0082-patrol-web-test-migration-guide.md)). Tests that do not apply to the current platform skip themselves — no `--exclude-tags` flag needed in either job. + +### Viewing Results + +- **Terminal:** Patrol prints pass/fail per test with full stack traces on failure. +- **GitHub Actions:** Results appear in the workflow run summary under the "Test" step. Each platform job has its own step, making it easy to identify which platform a failure comes from. + +## Consequences + +- Web and mobile tests share the same test files; platform-only tests skip themselves in `runPatrolTest()` — no manual tagging required. +- The same Docker JMAP backend serves both platforms — no additional infrastructure needed. +- Data isolation limitations from [ADR-0053](./0053-patrol-integration-test.md) still apply: backend state is shared across all tests in a single run. +- Patrol automatically installs and manages Chromium — no manual Chrome installation is needed on local machines or CI runners. diff --git a/docs/adr/0081-patrol-web-test-architecture.md b/docs/adr/0081-patrol-web-test-architecture.md new file mode 100644 index 000000000..9fe2ff26e --- /dev/null +++ b/docs/adr/0081-patrol-web-test-architecture.md @@ -0,0 +1,174 @@ +# 81. Patrol Web Integration Test — Architecture & Test Authoring + +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-0082](./0082-patrol-web-test-migration-guide.md) — Migration guide & implementation example +- [ADR-0083](./0083-patrol-web-test-migration-plan.md) — PR-by-PR migration plan + +## Context + +[ADR-0053](./0053-patrol-integration-test.md) established a three-layer architecture — **Test → Scenario → Robot** — for mobile. Extending to web with the POC approach revealed three problems: + +- **Robot classes mix platform logic** — `ComposerRobot` has both `addContent()` (mobile) and `addContentWeb()` (web), violating SRP. +- **Scenarios are duplicated** — same business flow written twice for each platform, violating DRY. +- **Closed to extension** — adding a new platform requires modifying existing robot classes, violating OCP. + +## Decision + +Apply **Abstract Factory** + **Interface Segregation** to separate: +- **WHAT** — Business flow (Scenario): shared across all platforms. +- **HOW** — UI interaction (Robot): implemented per platform. + +Platform selection uses **Dart conditional exports** — no logic class ever contains a platform `if/else` branch. + +### Architecture + +``` +Test File (one file per scenario) + │ + ▼ +TestBase.runPatrolTest() + ├── auto-applies platform tag (web / mobile) + └── calls createRobotFactory($) + ↑ resolved at compile time via conditional export + ├── MobileRobotFactory (default) + └── WebRobotFactory (dart.library.html) + │ injects into + ▼ +BaseTestScenario(PatrolIntegrationTester $, RobotFactory robots) + │ uses + ▼ +Abstract Robot Interfaces ← Scenarios depend only on these + ├── AbstractComposerRobot + └── AbstractThreadRobot + │ implemented by + ▼ +Concrete Robots + ├── mobile/MobileComposerRobot + └── web/WebComposerRobot +``` + +`TestBase` is the only class that calls `createRobotFactory()`. Scenarios and test files never reference any platform-specific class. + +### Folder Structure + +``` +integration_test/ +├── robots/ +│ ├── abstract/ # Interfaces — one per feature area +│ ├── mobile/ # Mobile implementations +│ └── web/ # Web implementations (only where UI differs) +├── factories/ +│ ├── robot_factory.dart +│ ├── robot_factory_provider.dart # Conditional export — never changes +│ ├── mobile_robot_factory.dart +│ ├── mobile_robot_factory_provider.dart +│ ├── web_robot_factory.dart +│ └── web_robot_factory_provider.dart +├── scenarios/ # All shared — no web_/mobile_ prefix +└── tests/ # One file per scenario — no platform subfolders +``` + +### Key Design Decisions + +**1. Conditional export — the only platform-branching point** + +```dart +// factories/robot_factory_provider.dart ← this file never changes +export 'mobile_robot_factory_provider.dart' + if (dart.library.html) 'web_robot_factory_provider.dart'; + +// Each provider exports one function with the same name +RobotFactory createRobotFactory(PatrolIntegrationTester $) => WebRobotFactory($); +``` + +Adding a new platform: create a provider file + factory + robot implementations. **No existing file is modified.** + +**2. TestBase resolves and injects the factory** + +```dart +// base/test_base.dart +import '../factories/robot_factory_provider.dart'; + +void runPatrolTest({ + required String description, + required BaseTestScenario Function(PatrolIntegrationTester $, RobotFactory robots) scenarioBuilder, + List tags = const [], +}) { + final resolvedTags = [ + ...tags.map((t) => t.name), + ]; + patrolTest(description, tags: resolvedTags, ($) async { + await setupTest(); + await scenarioBuilder($, createRobotFactory($)).execute(); + }); +} +``` + +**3. Scenario receives factory via DI — zero platform knowledge** + +```dart +abstract class BaseTestScenario extends BaseScenario { + final RobotFactory robots; + const BaseTestScenario(super.$, this.robots); + + @override + Future execute() async { + await robots.loginRobot().login(...); + await runTestLogic(); + } +} +``` + +**4. Test file — one file for all platforms** + +```dart +void main() { + TestBase().runPatrolTest( + description: 'Send email', + scenarioBuilder: ($, robots) => SendEmailScenario($, robots), + ); +} +``` + +**5. Robot reuse — when behavior is identical, both factories return the same class** + +```dart +// WebRobotFactory — reuse mobile robot when no UI difference +@override AbstractThreadRobot threadRobot() => MobileThreadRobot($); +``` + +### How to Add a Test for a New Feature + +1. Add `UiKey`s to all platform widget variants in `lib/features/base/model/ui_keys.dart`. +2. Define an abstract robot interface in `robots/abstract/` if one does not exist for the feature area. +3. Implement the robot in `robots/mobile/` and `robots/web/`. Use a shared base class for methods identical across platforms. Reuse the same class in both factories if behavior is entirely identical. +4. Register the robot in `MobileRobotFactory` and `WebRobotFactory`. +5. Write one scenario in `scenarios/` using abstract robots only. +6. Write one test file in `tests/`: `scenarioBuilder: ($, robots) => MyScenario($, robots)`. + +### SOLID Alignment + +| Principle | How it applies | +|-----------|---------------| +| **S** | Robot: UI interaction. Scenario: business flow. Factory: object creation. `TestBase`: lifecycle + wiring. | +| **O** | New platform: add provider + factory + robots. No existing class modified. | +| **L** | `WebRobotFactory` and `MobileRobotFactory` are substitutable through `RobotFactory`. | +| **I** | Each robot interface is scoped to its feature area. Split `RobotFactory` if it exceeds ~15 methods. | +| **D** | Scenarios depend on `RobotFactory` (abstraction). `TestBase` imports `createRobotFactory` from provider. | + +## Consequences + +- Scenarios and test files are written exactly once — no platform variants. +- No logic class contains a platform `if/else` — platform branching is a Dart export declaration. +- Adding a new platform touches zero existing files. +- Platform tags are auto-applied by `TestBase`. +- Migration is a one-time breaking change — see [ADR-0082](./0082-patrol-web-test-migration-guide.md) and [ADR-0083](./0083-patrol-web-test-migration-plan.md). diff --git a/docs/adr/0082-patrol-web-test-migration-guide.md b/docs/adr/0082-patrol-web-test-migration-guide.md new file mode 100644 index 000000000..81c0b5248 --- /dev/null +++ b/docs/adr/0082-patrol-web-test-migration-guide.md @@ -0,0 +1,237 @@ +# 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. diff --git a/docs/adr/0083-patrol-web-test-migration-plan.md b/docs/adr/0083-patrol-web-test-migration-plan.md new file mode 100644 index 000000000..741c58099 --- /dev/null +++ b/docs/adr/0083-patrol-web-test-migration-plan.md @@ -0,0 +1,130 @@ +# 83. Patrol Web Integration Test — Migration Plan + +Date: 2026-04-15 + +## Status + +Proposed + +## Related ADRs + +- [ADR-0081](./0081-patrol-web-test-architecture.md) — Cross-platform test architecture (read first) +- [ADR-0082](./0082-patrol-web-test-migration-guide.md) — Impact analysis & implementation example + +## Context + +This ADR translates the migration steps from [ADR-0082](./0082-patrol-web-test-migration-guide.md) into a concrete PR plan. Each PR must leave the **mobile CI pipeline green**. Only the final PR is a breaking change. + +--- + +## PR Plan + +| PR | Type | Files changed | Files added | Mobile CI | Web CI | +|----|------|:---:|:---:|:---:|:---:| +| PR-1 | Addition | 0 | ~40 | ✅ unaffected | — | +| PR-2 | Refactor | ~82 | 0 | ✅ must pass | — | +| PR-3 | Addition | 0 | ~15 | ✅ unaffected | — | +| PR-4 | Addition | 0 | ~6 | ✅ unaffected | — | +| **PR-5** | **Breaking** | **~202** | 0 | ✅ must pass | ✅ first run | + +PRs 1–4 are additive and can be merged in any order. **PR-5 must be a single atomic PR** — splitting it will break mobile CI. + +--- + +## PR-1 — Abstract Robot Interfaces + +Create `robots/abstract/` with one interface per existing robot class. No existing file is touched. + +- **New files:** ~40 abstract interface files +- **Verify:** `flutter analyze` passes +- **Outcome:** Abstract contracts ready for implementations + +--- + +## PR-2 — Move Robots to `robots/mobile/` + +Rename all `robots/*.dart` → `robots/mobile/mobile_*.dart`. Add `implements AbstractXxxRobot` to each class. Extract shared base robots for methods identical across platforms. Update import paths in scenario files. + +- **Moved files:** ~40 robots +- **Changed files:** ~80 scenarios + base files (import path updates only) +- **Verify:** `flutter analyze` passes + **mobile CI green** — confirms no logic was changed +- **Outcome:** Mobile robot layer clean; shared base robots ready for web robots to extend + +--- + +## PR-3 — Web Robot Implementations + +Create `robots/web/` with web-specific implementations for feature areas where the web UI genuinely differs. Robots with identical behavior across platforms are **not** duplicated here — both factories will return the same mobile class. + +- **New files:** ~15 web robot files +- **Verify:** `flutter analyze` passes +- **Outcome:** Web interaction layer ready to be wired via factories + +--- + +## PR-4 — Factories & Provider Files + +Create the factory layer and the conditional export provider. + +``` +factories/ +├── robot_factory.dart # Abstract factory interface +├── robot_factory_provider.dart # Conditional export — never changes +├── mobile_robot_factory.dart +├── mobile_robot_factory_provider.dart +├── web_robot_factory.dart +└── web_robot_factory_provider.dart +``` + +- **New files:** ~6 +- **Verify:** `flutter analyze` passes +- **Outcome:** Full factory graph in place; ready for `TestBase` to consume + +--- + +## PR-5 (Atomic) — Base API + All Scenarios + All Test Files + +> ⚠️ Do not split. Merging base changes without the scenario/test file updates breaks mobile CI immediately. + +**`base/base_test_scenario.dart`** — accept `RobotFactory` via constructor, remove `RequiresLoginMixin`. + +**`base/test_base.dart`** — import `robot_factory_provider.dart`, update `runPatrolTest` signature. + +**All ~80 scenarios** — update constructor: +```dart +// Before // After +MyScenario(super.$); → MyScenario(super.$, super.robots); +``` + +**All ~120 test files** — update `scenarioBuilder`: +```dart +// Before // After +scenarioBuilder: ($) => ... → scenarioBuilder: ($, robots) => ... +``` + +**Delete** `mixin/requires_login_mixin.dart`. + +The scenario and test file changes are mechanical and repetitive — suitable for bulk scripted refactoring followed by careful diff review. + +- **Changed files:** ~202 +- **Deleted files:** 1 +- **Verify:** `flutter analyze` + **mobile CI green** + **web CI green** (first successful web run) +- **Outcome:** Architecture fully in place. Both platforms run from the same test files. + +--- + +## Flow Summary + +``` +PR-1 abstract interfaces → flutter analyze ✅ +PR-2 move robots → mobile CI ✅ +PR-3 web robots → flutter analyze ✅ +PR-4 factories → flutter analyze ✅ +PR-5 base + all call sites → mobile CI ✅ web CI ✅ +``` + +## Consequences + +- PRs 1–4 are low-risk, reviewable asynchronously, and do not affect the team's daily workflow. +- PR-5 is large but entirely mechanical. Prioritize CI verification over line-by-line review. +- After PR-5, the migration is complete and the new architecture is the baseline for all future test work.