// Device integration tests over the real UI, driven by Patrol against the // system bunshin on the test host. The integration flavor installs beside // the real app and the instrumentation runner clears its data, so every run // starts at onboarding with a fresh identity — the app's own flows are the // subject under test, including the network path. // // Run: devbox run test-integration // (the script resolves the host's LAN IP into SMOL_TEST_HOST, because the // device reaches the server over Wi-Fi, not loopback.) import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_test/flutter_test.dart"; import "package:hive_flutter/hive_flutter.dart"; import "package:path_provider/path_provider.dart"; import "package:patrol/patrol.dart"; import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/presentation/app_widget.dart"; import "package:smol_mail/smol/config.dart"; import "package:smol_mail/smol/store.dart"; const testHost = String.fromEnvironment("SMOL_TEST_HOST", defaultValue: "10.0.2.2"); // The system bunshin's static key: a documented, operator-supplied value on // this machine — the trusted channel SPEC.md §4 asks a pin to come from. const serverKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; // main()'s boot, minus runApp: the test pumps the same tree. The container is // the one the UI reads from, so the tests drive the same SmolClient the // screens do — the fetch cancellation test needs exactly that. Future boot(PatrolIntegrationTester $) async { final dataDir = await getApplicationSupportDirectory(); Hive.init(dataDir.path); final store = await SmolStore.open(dbPath: "${dataDir.path}/smol-mail.db"); applyPresetServer(store); final container = ProviderContainer(overrides: [storeProvider.overrideWithValue(store)]); await $.pumpWidgetAndSettle(UncontrolledProviderScope( container: container, child: const AppWidget(), )); return container; } // Onboarding into a registered account: a fresh identity, backed up, then // registered under a name no server has bound yet. Returns the username. Future onboard(PatrolIntegrationTester $, ProviderContainer container) async { await $("Create Identity").tap(); await $.pumpAndSettle(); await $("I have backed it up").waitUntilVisible(); await $("I have backed it up").tap(); await $.pumpAndSettle(); final user = "it${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}"; // Enter text by TextField, not by its labelText: the decoration label is // a RichText that is not hit-testable, so a text finder times out. await $.enterText($(TextField).at(0), "$user@$testHost:1961"); await $.enterText($(TextField).at(1), serverKey); await $("Pin and Register").tap(); // The register round trip is real network: the inbox tabs only exist // behind HomeGuard, so reaching them proves the account bound. await $("Inbox").waitUntilVisible(timeout: const Duration(seconds: 30)); return user; } // Imports the account's own smol:// address as a contact — the §8 verified // path — so later steps have an entry to re-resolve. Leaves the app on the // Contacts screen. Future importSelfContact(PatrolIntegrationTester $, ProviderContainer container) async { final client = container.read(clientProvider); final uri = client.accountAddress()!.uri(client.identity!.publicKey); await $("Contacts").tap(); await $.pumpAndSettle(); await $(Icons.person_add).tap(); await $.pumpAndSettle(); await $.enterText($(TextField).at(0), uri); await $("Import").tap(); await $("contact imported (verified key)").waitUntilVisible(); // The snackbar floats over the bottom navigation bar; let it expire // before the next navigation tap. await $.pump(const Duration(seconds: 5)); } void main() { patrolTest("smoke: onboarding, register, self-send, fetch, read in requests", ($) async { final container = await boot($); final user = await onboard($, container); // Compose the first self-addressed mail. await $(Icons.edit).tap(); await $.pumpAndSettle(); await $.enterText($(TextField).at(0), "$user@$testHost:1961"); await $.enterText($(TextField).at(1), "smoke subject"); await $.enterText($(TextField).at(2), "smoke body from patrol"); await $("Send").tap(); // Compose pops back to the inbox once the send round trip completes. await $("Inbox").waitUntilVisible(timeout: const Duration(seconds: 30)); await $.pumpAndSettle(); // Fetch over the air, then read what arrived. await $(Icons.cloud_download).tap(); await $.pumpAndSettle(duration: const Duration(seconds: 5)); await $("Requests (1)").tap(); await $.pumpAndSettle(); // First contact is unsolicited, so it landed in requests (sec 5.8) — // and the sec 5.6 sent copy must read back as plain text, not . await $("smoke subject").tap(); await $.pumpAndSettle(); await $("smoke body from patrol").waitUntilVisible(); await $.native.pressBack(); // ignore: deprecated_member_use await $.pumpAndSettle(); await $("Sent").tap(); await $.pumpAndSettle(); await $("smoke subject").tap(); await $.pumpAndSettle(); await $("smoke body from patrol").waitUntilVisible(); }); // Checklist item 11: a fetch interrupted mid-run keeps everything that // already arrived, and fetching again completes the set without re-storing // what the first pass took. There is no cancel control in the UI yet, so // the test raises the facade's cancellation flag — the same call a cancel // button would make; when one exists, drive it from the UI instead. patrolTest( "smoke: cancelled fetch keeps what arrived and resume completes it", ($) async { final container = await boot($); final user = await onboard($, container); final client = container.read(clientProvider); final address = "$user@$testHost:1961"; // 20 letters with 40 KiB bodies: the fetch has real pages to chew // through, so a cancel 600 ms in lands mid-run, not after it. for (var i = 0; i < 20; i++) { await client.send(address, "cancel $i", "cancel body $i\n${"x" * 40960}"); } final fetch = client.fetch(); await Future.delayed(const Duration(milliseconds: 600)); client.cancelFetch(); final summary = await fetch; container.read(revisionProvider.notifier).bump(); await $.pumpAndSettle(); // If the cancel landed mid-run, part of the page arrived and the // warning banner says so; if the fetch had already finished, nothing // was lost either way and the resume below is a no-op. if (summary.stored < 20) { await $("fetch cancelled; partial results kept").waitUntilVisible(); } // The UI's fetch resumes and completes the set. The requests badge // counts every unread message, so a re-stored duplicate would push // the count past 20. await $(Icons.cloud_download).tap(); await $.pumpAndSettle(duration: const Duration(seconds: 5)); await $("Requests (20)") .waitUntilVisible(timeout: const Duration(seconds: 30)); }); // Checklist item 13: after rotating, a correspondent re-resolving the // address sees the signed-chain rotation banner — a warning, not the // terminal mismatch — and mail sealed to the superseded key still reads. patrolTest( "smoke: rotation re-resolves through the chain; old mail stays readable", ($) async { final container = await boot($); final user = await onboard($, container); final client = container.read(clientProvider); final address = "$user@$testHost:1961"; // A letter sealed to the pre-rotation key, waiting in requests. await client.send(address, "rotation subject", "rotation body before the key change"); await client.fetch(); container.read(revisionProvider.notifier).bump(); await $.pumpAndSettle(); // The correspondent entry, bound to the current key, must exist before // the rotation so re-resolving has a known key to move away from. await importSelfContact($, container); // Rotate: the dialog pushes the next index's key with a certificate. await $("Settings").tap(); await $.pumpAndSettle(); // The rotate row sits below the settings list's fold, and a SliverList // builds lazily — off-screen rows do not exist as widgets until // scrolled to, so bring it into the tree before tapping. await $.scrollUntilVisible(finder: $("Rotate identity key")); await $("Rotate identity key").tap(); await $.pumpAndSettle(); await $("Rotate").tap(); await $(RegExp("rotated; new key")).waitUntilVisible(); await $.pump(const Duration(seconds: 5)); // Re-resolve: the chain validates, so the outcome is the rotation // banner, and the superseded key moves to the §8 history section. await $("Contacts").tap(); await $.pumpAndSettle(); await $(client.accountAddress()!.short).tap(); await $.pumpAndSettle(); await $("Re-resolve").tap(); await $(RegExp("rotated its key; a signed chain confirms it")) .waitUntilVisible(timeout: const Duration(seconds: 30)); await $("previous keys (1)").waitUntilVisible(); await $("Dismiss").tap(); // Mail sealed to the old key: the master still derives it, so the // letter reads exactly as before the rotation. The AppBar's back arrow // pops the detail route; the native back press is avoided here because // it killed the patrol connection at this point in an earlier run. await $(Icons.arrow_back).tap(); await $.pumpAndSettle(); await $("Mail").tap(); await $.pumpAndSettle(); await $("Requests (1)").tap(); await $.pumpAndSettle(); await $("rotation subject").tap(); await $.pumpAndSettle(); await $("rotation body before the key change").waitUntilVisible(); }); // Checklist item 14: re-resolving a contact whose key did not change is a // quiet confirmation — no banner, no history section. patrolTest("smoke: re-resolving an unchanged contact is quiet", ($) async { final container = await boot($); await onboard($, container); final client = container.read(clientProvider); final short = client.accountAddress()!.short; await importSelfContact($, container); await $(short).tap(); await $.pumpAndSettle(); await $("Re-resolve").tap(); await $("$short: key unchanged") .waitUntilVisible(timeout: const Duration(seconds: 30)); // A banner would have carried the rotation warning instead; history // records a rotation, and there was none. expect($("previous keys (1)"), findsNothing); }); }