From 15287621e1d59e05505eb86189ec050c8723450e Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 6 Nov 2026 21:17:24 -0500 Subject: [PATCH] add thread e2e tests --- integration_test/helpers/fakes.dart | 47 ++++ integration_test/helpers/matrix_user.dart | 174 +++++++++++++++ integration_test/helpers/oauth.dart | 68 ++++++ integration_test/helpers/pump_until.dart | 56 +++++ integration_test/helpers/test_app.dart | 115 ++++++++++ integration_test/helpers/threads.dart | 34 +++ integration_test/long_thread_test.dart | 115 ++++++++++ integration_test/threads_test.dart | 258 ++++++++++++++++++++++ 8 files changed, 867 insertions(+) create mode 100644 integration_test/helpers/fakes.dart create mode 100644 integration_test/helpers/matrix_user.dart create mode 100644 integration_test/helpers/oauth.dart create mode 100644 integration_test/helpers/pump_until.dart create mode 100644 integration_test/helpers/test_app.dart create mode 100644 integration_test/helpers/threads.dart create mode 100644 integration_test/long_thread_test.dart create mode 100644 integration_test/threads_test.dart diff --git a/integration_test/helpers/fakes.dart b/integration_test/helpers/fakes.dart new file mode 100644 index 0000000..050eb99 --- /dev/null +++ b/integration_test/helpers/fakes.dart @@ -0,0 +1,47 @@ +import "dart:async"; + +import "package:app_links_platform_interface/app_links_platform_interface.dart"; +import "package:url_launcher_platform_interface/link.dart"; +import "package:url_launcher_platform_interface/url_launcher_platform_interface.dart"; + +/// Delivers links to the app as if the OS had opened them. +class FakeAppLinks extends AppLinksPlatform { + final _links = StreamController.broadcast(); + + void open(Uri uri) => _links.add(uri); + + @override + Future getInitialLink() async => null; + + @override + Future getInitialLinkString() async => null; + + @override + Future getLatestLink() async => null; + + @override + Future getLatestLinkString() async => null; + + @override + Stream get uriLinkStream => _links.stream; + + @override + Stream get stringLinkStream => + _links.stream.map((uri) => uri.toString()); +} + +/// Hands launched URLs to [onLaunch] instead of opening a browser. +class FakeUrlLauncher(final void Function(Uri url) onLaunch) + extends UrlLauncherPlatform { + @override + LinkDelegate? get linkDelegate => null; + + @override + Future canLaunch(String url) async => true; + + @override + Future launchUrl(String url, LaunchOptions options) async { + onLaunch(.parse(url)); + return true; + } +} diff --git a/integration_test/helpers/matrix_user.dart b/integration_test/helpers/matrix_user.dart new file mode 100644 index 0000000..bd2692c --- /dev/null +++ b/integration_test/helpers/matrix_user.dart @@ -0,0 +1,174 @@ +import "dart:convert"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:http/http.dart" as http; + +const homeserver = String.fromEnvironment("NEXUS_E2E_HOMESERVER"); + +/// A user acting over the client-server API directly, as a control for the +/// tests: it doesn't go through gomuks or any other Nexus code, so it sets +/// things up and checks the server's side independently of what's being +/// tested. That includes Nexus' models, so the JSON it sends is written out +/// here rather than built the way Nexus builds it. +class const MatrixUser(final String userId, final String accessToken) { + static Future login(String username, String password) async { + final json = await request( + "POST", + "/_matrix/client/v3/login", + body: { + "type": "m.login.password", + "identifier": {"type": "m.id.user", "user": username}, + "password": password, + }, + ); + return MatrixUser(json["user_id"], json["access_token"]); + } + + static Future> request( + String method, + String path, { + String? accessToken, + Object? body, + }) async { + final httpRequest = + http.Request(method, Uri.parse(homeserver).resolve(path)) + ..headers.addAll({ + "Content-Type": "application/json", + if (accessToken != null) "Authorization": "Bearer $accessToken", + }); + if (method != "GET") httpRequest.body = jsonEncode(body ?? {}); + final response = await http.Response.fromStream(await httpRequest.send()); + + if (response.statusCode == 429) { + final retryAfter = jsonDecode(response.body)["retry_after_ms"] ?? 1000; + await Future.delayed(.new(milliseconds: retryAfter)); + return request(method, path, accessToken: accessToken, body: body); + } + if (response.statusCode >= 300) { + throw StateError( + "$method $path failed with ${response.statusCode}: ${response.body}", + ); + } + return jsonDecode(response.body); + } + + Future createRoom(String name) async => (await request( + "POST", + "/_matrix/client/v3/createRoom", + accessToken: accessToken, + body: {"name": name, "preset": "private_chat"}, + ))["room_id"]; + + Future invite(String roomId, String userId) => request( + "POST", + "/_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/invite", + accessToken: accessToken, + body: {"user_id": userId}, + ); + + Future join(String roomId) => request( + "POST", + "/_matrix/client/v3/join/${Uri.encodeComponent(roomId)}", + accessToken: accessToken, + ); + + Future logout() => + request("POST", "/_matrix/client/v3/logout", accessToken: accessToken); + + Future sendEvent( + String roomId, + String type, + Map content, + ) async => (await request( + "PUT", + "/_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/send/$type/" + "${DateTime.now().microsecondsSinceEpoch}", + accessToken: accessToken, + body: content, + ))["event_id"]; + + /// Sends a text message. With [threadRoot] it's a reply in that thread, and + /// otherwise with [replyTo] it's a reply to that event. + Future send( + String roomId, + String body, { + String? threadRoot, + String? replyTo, + }) => sendEvent(roomId, "m.room.message", { + "msgtype": "m.text", + "body": body, + "m.relates_to": ?switch ((threadRoot, replyTo)) { + (final threadRoot?, _) => { + "rel_type": "m.thread", + "event_id": threadRoot, + "is_falling_back": true, + "m.in_reply_to": {"event_id": threadRoot}, + }, + (null, final replyTo?) => { + "m.in_reply_to": {"event_id": replyTo}, + }, + (null, null) => null, + }, + }); + + /// Sends [rootBody], then [replies] in a thread from it, oldest first. + Future<({String rootId, IList replyIds})> sendThread( + String roomId, + String rootBody, + IList replies, + ) async { + final rootId = await send(roomId, rootBody); + // One at a time, so they're in order (oops earlier tests weren't reproducable) + final replyIds = await Stream.fromIterable(replies) + .asyncMap((body) => send(roomId, body, threadRoot: rootId)) + .toList(); + return (rootId: rootId, replyIds: replyIds.toIList()); + } + + /// Edits the text message [eventId] to say [body]. + Future edit(String roomId, String eventId, String body) => + sendEvent(roomId, "m.room.message", { + "msgtype": "m.text", + "body": "* $body", + "m.new_content": {"msgtype": "m.text", "body": body}, + "m.relates_to": {"rel_type": "m.replace", "event_id": eventId}, + }); + + Future redact(String roomId, String eventId) => request( + "PUT", + "/_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/redact/" + "${Uri.encodeComponent(eventId)}/${DateTime.now().microsecondsSinceEpoch}", + accessToken: accessToken, + ); + + /// The bodies of the replies in the thread from [threadRoot], oldest first. + Future> threadReplies(String roomId, String threadRoot) async { + final json = await request( + "GET", + "/_matrix/client/v1/rooms/${Uri.encodeComponent(roomId)}/relations/" + "${Uri.encodeComponent(threadRoot)}/m.thread?dir=f&limit=100", + accessToken: accessToken, + ); + return (json["chunk"] as List) + .map((event) => event["content"]["body"] as String) + .toIList(); + } + + Future> threadRepliesIncluding( + String roomId, + String threadRoot, + String body, { + int attempts = 20, + }) async { + final replies = await threadReplies(roomId, threadRoot); + if (replies.contains(body) || attempts <= 1) return replies; + + await Future.delayed(const .new(milliseconds: 500)); + return threadRepliesIncluding( + roomId, + threadRoot, + body, + attempts: attempts - 1, + ); + } +} diff --git a/integration_test/helpers/oauth.dart b/integration_test/helpers/oauth.dart new file mode 100644 index 0000000..be652d7 --- /dev/null +++ b/integration_test/helpers/oauth.dart @@ -0,0 +1,68 @@ +import "package:http/http.dart" as http; + +import "fakes.dart"; + +/// Fake platforms that do the browser's part of OAuth on Continuwuity, logging +/// in as [username], then hand the redirect back to Nexus. +class OAuthFakes(final String username, final String password) { + final links = FakeAppLinks(); + late final launcher = FakeUrlLauncher(onLaunch); + + Future onLaunch(Uri url) async { + if (!url.queryParameters.containsKey("code_challenge")) return; + + final redirect = await authorize(url); + // Nexus listens for the redirect after the launch returns + await Future.delayed(const .new(milliseconds: 500)); + links.open(redirect); + } + + /// Logs in on [authorizationUrl], then confirms the grant, returning the URI + /// Continuwuity redirects back to the app with. + Future authorize(Uri authorizationUrl) async { + final client = http.Client(); + String? cookie; + + Future followRedirect( + String method, + Uri url, [ + Map? form, + ]) async { + final request = http.Request(method, url) + ..followRedirects = false + ..headers.addAll({ + // Continuwuity only accepts its forms from top-level navigations + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + "Cookie": ?cookie, + }); + if (form != null) request.bodyFields = form; + + final response = await http.Response.fromStream( + await client.send(request), + ); + if (response.headers["set-cookie"] case final setCookie?) { + cookie = setCookie.split(";").first; + } + + return switch ((response.statusCode, response.headers["location"])) { + (302 || 303 || 307, final location?) => url.resolve(location), + (final status, _) => throw StateError( + "Expected $method $url to redirect, got $status: ${response.body}", + ), + }; + } + + try { + final loginUrl = await followRedirect("GET", authorizationUrl); + final grantUrl = await followRedirect("POST", loginUrl, { + "identifier": username, + "password": password, + }); + return await followRedirect("POST", grantUrl, {"stage": "confirm"}); + } finally { + client.close(); + } + } +} diff --git a/integration_test/helpers/pump_until.dart b/integration_test/helpers/pump_until.dart new file mode 100644 index 0000000..5f278f4 --- /dev/null +++ b/integration_test/helpers/pump_until.dart @@ -0,0 +1,56 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/error_dialog.dart"; + +// Custom because `patrol_finder` gives me jQuery vibes, and that's Not GoodTM +extension PumpUntil on WidgetTester { + /// Pumps until [finder] finds something, failing if it doesn't within 30 + /// seconds, or if the app shows an error. + Future pumpUntil(Finder finder) async { + const timeout = Duration(seconds: 30); + + String textsIn(Finder finder) => widgetList( + find.descendant(of: finder, matching: find.byType(Text)), + ).map((text) => text.data).nonNulls.join("\n"); + + final stopwatch = Stopwatch()..start(); + while (stopwatch.elapsed < timeout) { + await pump(const .new(milliseconds: 100)); + + if (finder.evaluate().isNotEmpty) return; + + final errorDialog = find.byType(ErrorDialog); + if (errorDialog.evaluate().isNotEmpty) { + fail( + "The app showed an error while waiting for $finder:\n" + "${textsIn(errorDialog)}", + ); + } + } + + final snackBar = find.byType(SnackBar); + final shown = find + .byType(RichText) + .hitTestable() + .evaluate() + .map((element) => (element.widget as RichText).text.toPlainText()) + .where((text) => text.trim().isNotEmpty) + .toIList() + .removeDuplicates() + .join(" | "); + fail( + "Timed out after $timeout waiting for $finder" + "${snackBar.evaluate().isEmpty ? "" : "\nSnack bar: ${textsIn(snackBar)}"}" + "\nShown: $shown", + ); + } + + /// Taps [finder] once it can be tapped, like after animating in. + Future tapWhenShown(Finder finder) async { + final tappable = finder.hitTestable(); + await pumpUntil(tappable); + await tap(tappable.first); + await pump(); + } +} diff --git a/integration_test/helpers/test_app.dart b/integration_test/helpers/test_app.dart new file mode 100644 index 0000000..d6724c0 --- /dev/null +++ b/integration_test/helpers/test_app.dart @@ -0,0 +1,115 @@ +import "package:app_links_platform_interface/app_links_platform_interface.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:media_kit/media_kit.dart"; +import "package:nexus/controllers/active_account.dart"; +import "package:nexus/main.dart" as nexus; +import "package:nexus/widgets/account_scope.dart"; +import "package:nexus/widgets/pages/chat.dart"; +import "package:nexus/widgets/pages/select_server.dart"; +import "package:nexus/widgets/pages/verify.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:url_launcher_platform_interface/url_launcher_platform_interface.dart"; + +import "matrix_user.dart"; +import "oauth.dart"; +import "pump_until.dart"; + +/// Nexus, run as the account [username], which is set up through the UI the +/// first time it's opened. +class TestApp(final String username, final String password) { + late final fakes = OAuthFakes(username, password); + + void setUp() { + nexus.isInBackground = false; + MediaKit.ensureInitialized(); + AppLinksPlatform.instance = fakes.links; + UrlLauncherPlatform.instance = fakes.launcher; + + // A tap that misses would otherwise only warn + WidgetController.hitTestWarningShouldBeFatal = true; + } + + Future join(MatrixUser inviter, String roomId) async { + final user = await MatrixUser.login(username, password); + await inviter.invite(roomId, user.userId); + await user.join(roomId); + await user.logout(); + } + + /// Starts Nexus like [open], then opens [roomName]. + Future openRoom(WidgetTester tester, String roomName) async { + await open(tester); + await selectRoom(tester, roomName); + } + + /// Taps [roomName] in the room list, opening the drawer it's in first when + /// the window is narrow enough for the mobile layout. + Future selectRoom(WidgetTester tester, String roomName) async { + // Shown once the first sync is done, as is the drawer button on mobile + await tester.pumpUntil(find.byType(RoomChat)); + + final drawerButton = find + .byTooltip(const DefaultMaterialLocalizations().openAppDrawerTooltip) + .hitTestable(); + if (drawerButton.evaluate().isNotEmpty) await tester.tap(drawerButton); + await tester.tapWhenShown(find.text(roomName)); + } + + /// Starts Nexus, logged in and verified, logging in and setting up + /// encryption through the UI first if it isn't. + Future open(WidgetTester tester) async { + await tester.pumpWidget( + AccountScope( + initialAccountId: await ActiveAccountController.stored(), + retry: (_, _) => null, + child: nexus.App(), + ), + ); + + await tester.pumpUntil( + find.byWidgetPredicate( + (widget) => + widget is SelectServerPage || + widget is VerifyPage || + widget is ChatPage, + ), + ); + if (find.byType(SelectServerPage).evaluate().isNotEmpty) { + await logIn(tester); + } + if (find.byType(VerifyPage).evaluate().isNotEmpty) { + await setUpEncryption(tester); + } + await tester.pumpUntil(find.byType(ChatPage)); + } + + Future logIn(WidgetTester tester) async { + final homeserverField = find.widgetWithText(TextField, "Homeserver URL"); + await tester.pumpUntil(homeserverField); + await tester.enterText(homeserverField, homeserver); + await tester.testTextInput.receiveAction(.done); + + // Discovery only uses HTTPS, which the test homeserver doesn't serve, so + // Nexus offers to log in with the URL as typed + await tester.tapWhenShown(find.text("Attempt log in anyways")); + + // Nexus opens the authorization page, which fakes authorizes + await tester.pumpUntil( + find.byWidgetPredicate( + (widget) => widget is VerifyPage || widget is ChatPage, + ), + ); + } + + /// Sets up encryption from the verify page, for an account without it. + Future setUpEncryption(WidgetTester tester) async { + await tester.pumpUntil(find.text("Set Up Encryption")); + await tester.tapWhenShown(find.widgetWithText(TextButton, "Set Up")); + + await tester.tapWhenShown(find.text("I've saved my recovery key")); + await tester.tapWhenShown(find.widgetWithText(TextButton, "Set Up")); + + await tester.tapWhenShown(find.widgetWithText(TextButton, "Done")); + } +} diff --git a/integration_test/helpers/threads.dart b/integration_test/helpers/threads.dart new file mode 100644 index 0000000..d2eee0d --- /dev/null +++ b/integration_test/helpers/threads.dart @@ -0,0 +1,34 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:material_ui/material_ui.dart"; + +import "pump_until.dart"; + +extension MessageFinders on CommonFinders { + Finder message(String body) => textContaining(body, findRichText: true); +} + +extension RelativeFinders on Finder { + Finder inside(Type type) => + find.descendant(of: find.byType(type), matching: this); + + /// The [InkWell] ancestor around what this finds + Finder get inkWell => find.ancestor(of: this, matching: find.byType(InkWell)); +} + +extension ReplyCounts on WidgetTester { + /// Waits for a reply count in widgets of [type], then checks it, so a wrong + /// count fails straight away with what was shown. + Future expectReplyCount(Type type, int expected) async { + final repliesLabel = RegExp(r"^(\d+) repl(?:y|ies)$"); + final labels = find.textContaining(repliesLabel).inside(type); + await pumpUntil(labels); + + final shown = widgetList(labels) + .map((label) => label.data) + .nonNulls + .map(repliesLabel.firstMatch) + .nonNulls + .map((match) => int.parse(match[1]!)); + expect(shown, everyElement(expected), reason: "Reply count in $type"); + } +} diff --git a/integration_test/long_thread_test.dart b/integration_test/long_thread_test.dart new file mode 100644 index 0000000..b292c12 --- /dev/null +++ b/integration_test/long_thread_test.dart @@ -0,0 +1,115 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:nexus/widgets/thread_drawer.dart"; +import "package:nexus/widgets/threads_drawer.dart"; + +import "helpers/matrix_user.dart"; +import "helpers/test_app.dart"; +import "helpers/pump_until.dart"; +import "helpers/threads.dart"; + +/// A thread longer than gomuks' initial sync, which gets the latest 100 +/// events, so its start only reaches Nexus through `/relations`. That needs +/// an account whose first sync is after the thread exists, so it's not the one +/// other tests use. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final app = TestApp("carol", "carolpass"); + setUpAll(app.setUp); + final run = DateTime.now().millisecondsSinceEpoch; + final roomName = "Long thread $run"; + final rootBody = "Long thread root $run"; + const replyCount = 120; + // Among the first replies, which aren't synced + const jumpedTo = 10; + // Padded, so no reply's text contains another's (oops those failures were confusing when I didn't do that) + String reply(int number) => "Long reply ${number.toString().padLeft(3, "0")}"; + + setUpAll(() async { + final bob = await MatrixUser.login("bob", "bobpass"); + final roomId = await bob.createRoom(roomName); + await app.join(bob, roomId); + + final thread = await bob.sendThread( + roomId, + rootBody, + Iterable.generate(replyCount, (index) => reply(index + 1)).toIList(), + ); + // In the room's timeline, so there's something to jump from, to a reply + // with others on both sides that aren't synced either + await bob.send( + roomId, + "Quoting an old reply", + replyTo: thread.replyIds[jumpedTo - 1], + ); + }); + + /// Drags the open thread by [offset] until [finder] is shown in it, loading + /// more replies as it goes, for up to 30 seconds. + Future scrollThread( + WidgetTester tester, + Finder finder, + Offset offset, + ) => tester.dragUntilVisible( + finder.inside(ThreadDrawer), + find.byType(CustomScrollView).inside(ThreadDrawer).first, + offset, + maxIteration: 60, + duration: const .new(milliseconds: 500), + ); + + Future openThreadsDrawer(WidgetTester tester) async { + await app.openRoom(tester, roomName); + await tester.tapWhenShown(find.byTooltip("Open threads")); + await tester.pumpUntil(find.message(rootBody).inside(ThreadsDrawer)); + } + + testWidgets("lists the thread with all of its replies", (tester) async { + await openThreadsDrawer(tester); + + await tester.expectReplyCount(ThreadsDrawer, replyCount); + }); + + // Before the whole thread is loaded by scrolling through it below + testWidgets("jumps to a reply in the thread that isn't loaded", ( + tester, + ) async { + await app.openRoom(tester, roomName); + + // The quote of the reply is fetched on its own, then tapped + await tester.tapWhenShown( + find.message(reply(jumpedTo)).inside(RoomChat).inkWell, + ); + + // The thread opens around the reply, with replies on both sides + await tester.pumpUntil( + find.message(reply(jumpedTo)).inside(ThreadDrawer).hitTestable(), + ); + await tester.pumpUntil( + find.message(reply(jumpedTo - 1)).inside(ThreadDrawer), + ); + await tester.pumpUntil( + find.message(reply(jumpedTo + 1)).inside(ThreadDrawer), + ); + + await scrollThread(tester, find.message(reply(60)), const .new(0, -400)); + }); + + testWidgets("loads older replies back to the root", (tester) async { + await openThreadsDrawer(tester); + await tester.tapWhenShown( + find.message(rootBody).inside(ThreadsDrawer).inkWell, + ); + await tester.pumpUntil( + find.message(reply(replyCount)).inside(ThreadDrawer), + ); + + // Dragging down scrolls up the reversed timeline, to older replies + await scrollThread(tester, find.message(rootBody), const .new(0, 400)); + expect(find.message(reply(1)).inside(ThreadDrawer), findsWidgets); + }); +} diff --git a/integration_test/threads_test.dart b/integration_test/threads_test.dart new file mode 100644 index 0000000..6eba136 --- /dev/null +++ b/integration_test/threads_test.dart @@ -0,0 +1,258 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/composer/composer.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:nexus/widgets/thread_drawer.dart"; +import "package:nexus/widgets/thread_summary.dart"; +import "package:nexus/widgets/threads_drawer.dart"; + +import "helpers/matrix_user.dart"; +import "helpers/test_app.dart"; +import "helpers/pump_until.dart"; +import "helpers/threads.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final app = TestApp("alice", "alicepass"); + setUpAll(app.setUp); + final run = DateTime.now().millisecondsSinceEpoch; + late MatrixUser bob; + + setUpAll(() async => bob = await MatrixUser.login("bob", "bobpass")); + + /// Opens [roomName], waiting for the thread from [rootBody] in its timeline. + Future openRoom( + WidgetTester tester, + String roomName, + String rootBody, + ) async { + await app.openRoom(tester, roomName); + await tester.pumpUntil( + find.message(rootBody).inside(RoomChat).hitTestable(), + ); + } + + /// Opens the thread from the room's summary, waiting for [reply] in it. + Future openThread(WidgetTester tester, String reply) async { + await tester.tapWhenShown(find.byType(ThreadSummary)); + await tester.pumpUntil(find.message(reply).inside(ThreadDrawer)); + } + + group("a thread", () { + final roomName = "Threads $run"; + final rootBody = "Thread root $run"; + const firstReply = "First reply"; + const secondReply = "Second reply"; + + late String roomId; + late String rootId; + + setUpAll(() async { + roomId = await bob.createRoom(roomName); + // Joined before Nexus logs in, so the room is there once it does + await app.join(bob, roomId); + + rootId = (await bob.sendThread( + roomId, + rootBody, + .new([firstReply, secondReply]), + )).rootId; + }); + + Future replyCount() async => + (await bob.threadReplies(roomId, rootId)).length; + + testWidgets("logs in with OAuth and sets up encryption", (tester) async { + await app.open(tester); + + await app.selectRoom(tester, roomName); + }); + + testWidgets("summarises the thread under its root", (tester) async { + await openRoom(tester, roomName, rootBody); + + await tester.expectReplyCount(ThreadSummary, 2); + expect(find.message(secondReply).inside(ThreadSummary), findsWidgets); + // Replies are in the thread, not the room's timeline + expect(find.message(firstReply), findsNothing); + }); + + testWidgets("lists the thread in the threads drawer", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.tapWhenShown(find.byTooltip("Open threads")); + + await tester.pumpUntil(find.message(rootBody).inside(ThreadsDrawer)); + await tester.expectReplyCount(ThreadsDrawer, 2); + expect(find.text("Last active:").inside(ThreadsDrawer), findsOneWidget); + }); + + testWidgets("opens the thread from the threads drawer", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.tapWhenShown(find.byTooltip("Open threads")); + await tester.tapWhenShown( + find.message(rootBody).inside(ThreadsDrawer).inkWell, + ); + + await tester.pumpUntil(find.message(secondReply).inside(ThreadDrawer)); + }); + + testWidgets("shows the root and its replies, oldest first", (tester) async { + await openRoom(tester, roomName, rootBody); + await openThread(tester, secondReply); + + final root = find.message(rootBody).inside(ThreadDrawer).first; + final first = find.message(firstReply).inside(ThreadDrawer).first; + final second = find.message(secondReply).inside(ThreadDrawer).first; + expect(root, findsOneWidget); + expect(first, findsOneWidget); + expect( + tester.getTopLeft(root).dy, + lessThan(tester.getTopLeft(first).dy), + reason: "The root should be above the replies", + ); + expect( + tester.getTopLeft(first).dy, + lessThan(tester.getTopLeft(second).dy), + reason: "Older replies should be above newer ones", + ); + }); + + testWidgets("sends replies to the thread", (tester) async { + final body = "Reply from Nexus $run"; + final before = await replyCount(); + await openRoom(tester, roomName, rootBody); + await openThread(tester, secondReply); + + final composer = find + .widgetWithText(TextField, "Your message here...") + .inside(ThreadDrawer); + await tester.pumpUntil(composer); + await tester.enterText(composer, body); + await tester.tapWhenShown( + find.byTooltip("Send message").inside(ThreadDrawer), + ); + await tester.pumpUntil(find.message(body).inside(ThreadDrawer)); + + // It's in the thread for other clients too + final replies = await bob.threadRepliesIncluding(roomId, rootId, body); + expect(replies.last, body); + + // Shown as the latest reply once the summary has caught up + await tester.pumpUntil(find.message(body).inside(ThreadSummary)); + await tester.expectReplyCount(ThreadSummary, before + 1); + expect( + find.text(body).inside(Composer), + findsNothing, + reason: "The composer should be cleared once sent", + ); + }); + + testWidgets("shows replies from others as they arrive", (tester) async { + final body = "Live reply $run"; + await openRoom(tester, roomName, rootBody); + await openThread(tester, secondReply); + + final before = await replyCount(); + await bob.send(roomId, body, threadRoot: rootId); + + await tester.pumpUntil(find.message(body).inside(ThreadDrawer)); + await tester.pumpUntil(find.message(body).inside(ThreadSummary)); + await tester.expectReplyCount(ThreadSummary, before + 1); + }); + }); + + group("redactions", () { + final roomName = "Redactions $run"; + final rootBody = "Redaction root $run"; + const keptReply = "Kept reply"; + const redactedReply = "Redacted reply"; + + late String roomId; + late String rootId; + late String redactedId; + + setUpAll(() async { + roomId = await bob.createRoom(roomName); + await app.join(bob, roomId); + + final thread = await bob.sendThread( + roomId, + rootBody, + .new([keptReply, redactedReply]), + ); + rootId = thread.rootId; + redactedId = thread.replyIds.last; + }); + + testWidgets("drops a redacted reply from its thread", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.expectReplyCount(ThreadSummary, 2); + + await bob.redact(roomId, redactedId); + + // The reply before it becomes the latest + await tester.pumpUntil(find.message(keptReply).inside(ThreadSummary)); + await tester.expectReplyCount(ThreadSummary, 1); + + await openThread(tester, keptReply); + expect(find.message(redactedReply).inside(ThreadDrawer), findsNothing); + }); + + testWidgets("still opens the thread of a redacted root", (tester) async { + await bob.redact(roomId, rootId); + await app.openRoom(tester, roomName); + await tester.tapWhenShown(find.byTooltip("Open threads")); + + // The root's preview is redacted, so the card is found by its count + await tester.tapWhenShown( + find.text("1 reply").inside(ThreadsDrawer).inkWell, + ); + + final root = find.text("Redacted event").inside(ThreadDrawer); + final reply = find.message(keptReply).inside(ThreadDrawer); + await tester.pumpUntil(root); + await tester.pumpUntil(reply); + expect( + tester.getTopLeft(root.first).dy, + lessThan(tester.getTopLeft(reply.first).dy), + reason: "The redacted root should still be above the replies", + ); + }); + }); + + group("edits", () { + final roomName = "Edits $run"; + final rootBody = "Edit root $run"; + const originalReply = "Reply before editing"; + const editedReply = "Reply after editing"; + + late String roomId; + late String replyId; + + setUpAll(() async { + roomId = await bob.createRoom(roomName); + await app.join(bob, roomId); + + final thread = await bob.sendThread( + roomId, + rootBody, + .new([originalReply]), + ); + replyId = thread.replyIds.single; + }); + + testWidgets("shows an edited reply's new text", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.pumpUntil(find.message(originalReply).inside(ThreadSummary)); + + await bob.edit(roomId, replyId, editedReply); + + await tester.pumpUntil(find.message(editedReply).inside(ThreadSummary)); + await openThread(tester, editedReply); + expect(find.message(originalReply).inside(ThreadDrawer), findsNothing); + expect(find.text("(edited)").inside(ThreadDrawer), findsOneWidget); + }); + }); +}