kirakira/integration_test/smoke_test.dart

247 lines
10 KiB
Dart

// 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<ProviderContainer> 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<String> 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<void> 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 <unreadable>.
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<void>.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);
});
}