Compare commits

...
136 changed files with 2372 additions and 791 deletions

28
.github/workflows/e2e.yml vendored Normal file
View file

@ -0,0 +1,28 @@
name: "End-to-end tests"
on:
push:
branches: ["main"]
pull_request:
workflow_dispatch:
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
submodules: recursive
- name: Lix GHA Installer Action
uses: samueldr/lix-gha-installer-action@v2026-02-22
with:
extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
- name: Generate code
run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build"
- name: Run end-to-end tests
run: nix run .#e2e-headless

194
flake.lock generated
View file

@ -1,5 +1,99 @@
{ {
"nodes": { "nodes": {
"advisory-db": {
"flake": false,
"locked": {
"lastModified": 1793979272,
"narHash": "sha256-oUcBtPbtS7PlEMuSrgTAN4wv49cXYbwKbQqhH2pah5w=",
"owner": "rustsec",
"repo": "advisory-db",
"rev": "309392b18f6b17022f82c701f300433126901068",
"type": "github"
},
"original": {
"owner": "rustsec",
"repo": "advisory-db",
"type": "github"
}
},
"continuwuity": {
"inputs": {
"advisory-db": "advisory-db",
"crane": "crane",
"fenix": "fenix",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1794250216,
"narHash": "sha256-IQcTJbLuxL9BxtTp9tjhC5NM8YvSfODbEP4wKXzc9gM=",
"ref": "refs/heads/quad/nexus",
"rev": "62b78e20b0f28a7a3c847cc4baa6223eeeed53f1",
"revCount": 8090,
"type": "git",
"url": "file:///home/quadradical/Documents/Code/continuwuity"
},
"original": {
"type": "git",
"url": "file:///home/quadradical/Documents/Code/continuwuity"
}
},
"crane": {
"locked": {
"lastModified": 1793836618,
"narHash": "sha256-TREenflwEjxOSq292FYwbc3kEm7TDRtXCZRNWEdjBmg=",
"owner": "ipetkov",
"repo": "crane",
"rev": "79fd63528501446749c09f08cad486a58c7d173e",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"fenix": {
"inputs": {
"nixpkgs": [
"continuwuity",
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1794142885,
"narHash": "sha256-xWFSr7q8IerDAVRk4W7/CgTmi05hMy2qjZ0z3VYpOVk=",
"owner": "nix-community",
"repo": "fenix",
"rev": "f7d416b5073152c7f2e4de1a28d1a4330dd30a26",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "edolstra",
"ref": "master",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": { "flake-parts": {
"inputs": { "inputs": {
"nixpkgs-lib": "nixpkgs-lib" "nixpkgs-lib": "nixpkgs-lib"
@ -18,6 +112,24 @@
"type": "github" "type": "github"
} }
}, },
"flake-parts_2": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib_2"
},
"locked": {
"lastModified": 1793576659,
"narHash": "sha256-V1h85lUaJBQoHFrB2vpXZOE0Ju1MYSnIpsehoVZYlDo=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "ea2630b7470978622c7689a1ff75d2b389175399",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
@ -39,7 +151,7 @@
"nix2flatpak": { "nix2flatpak": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1791751882, "lastModified": 1791751882,
@ -57,11 +169,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1773389992, "lastModified": 1794042290,
"narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=", "narHash": "sha256-ziZn2PvCJE5JEEpqTdxXwJpPB+L3YqoCpTAMCzyaP3U=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "c06b4ae3d6599a672a6210b7021d699c351eebda", "rev": "f4a03c008967841f2a0b3cb7c436fc6fc31671f6",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -86,7 +198,38 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs-lib_2": {
"locked": {
"lastModified": 1793504054,
"narHash": "sha256-ThkfL3D/FhCiZFc9+3BtxwuMmpuK+/bGtXCSo1+yVew=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "7851ba61fd862b277cb6a9cef37394bc18bb94e4",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixpkgs_2": { "nixpkgs_2": {
"locked": {
"lastModified": 1773389992,
"narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c06b4ae3d6599a672a6210b7021d699c351eebda",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": { "locked": {
"lastModified": 1793540411, "lastModified": 1793540411,
"narHash": "sha256-fiPJ88izTVucjeo2EQ3jk4aWmLg03I1D4vxnX2QjEgE=", "narHash": "sha256-fiPJ88izTVucjeo2EQ3jk4aWmLg03I1D4vxnX2QjEgE=",
@ -104,9 +247,27 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"flake-parts": "flake-parts", "continuwuity": "continuwuity",
"flake-parts": "flake-parts_2",
"nix2flatpak": "nix2flatpak", "nix2flatpak": "nix2flatpak",
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_3"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1794096810,
"narHash": "sha256-dWC+YtMX+cwH3e9ndiFB77ijTsKtO5I2OxfcvUY4j3k=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "03ae6be57aa063bb373f69eead3b8e15168a45d2",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
} }
}, },
"systems": { "systems": {
@ -123,6 +284,27 @@
"repo": "default", "repo": "default",
"type": "github" "type": "github"
} }
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"continuwuity",
"nixpkgs"
]
},
"locked": {
"lastModified": 1791885023,
"narHash": "sha256-C3alQ2MEt0zVrUSho5NUDo3jmhbff72++bfwoqT9Omo=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "0d6d739a833ba9a9c93034862e4a7b061a062a1c",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

View file

@ -6,6 +6,8 @@
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.url = "github:hercules-ci/flake-parts";
nix2flatpak.url = "github:neobrain/nix2flatpak"; nix2flatpak.url = "github:neobrain/nix2flatpak";
# TEMPORARY: the e2e tests against a local Continuwuity checkout
continuwuity.url = "git+file:///home/quadradical/Documents/Code/continuwuity";
}; };
outputs = outputs =

View file

@ -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<Uri>.broadcast();
void open(Uri uri) => _links.add(uri);
@override
Future<Uri?> getInitialLink() async => null;
@override
Future<String?> getInitialLinkString() async => null;
@override
Future<Uri?> getLatestLink() async => null;
@override
Future<String?> getLatestLinkString() async => null;
@override
Stream<Uri> get uriLinkStream => _links.stream;
@override
Stream<String> 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<bool> canLaunch(String url) async => true;
@override
Future<bool> launchUrl(String url, LaunchOptions options) async {
onLaunch(.parse(url));
return true;
}
}

View file

@ -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<MatrixUser> 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<Map<String, dynamic>> 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<String> createRoom(String name) async => (await request(
"POST",
"/_matrix/client/v3/createRoom",
accessToken: accessToken,
body: {"name": name, "preset": "private_chat"},
))["room_id"];
Future<void> invite(String roomId, String userId) => request(
"POST",
"/_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/invite",
accessToken: accessToken,
body: {"user_id": userId},
);
Future<void> join(String roomId) => request(
"POST",
"/_matrix/client/v3/join/${Uri.encodeComponent(roomId)}",
accessToken: accessToken,
);
Future<void> logout() =>
request("POST", "/_matrix/client/v3/logout", accessToken: accessToken);
Future<String> sendEvent(
String roomId,
String type,
Map<String, Object> 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<String> 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<String> replyIds})> sendThread(
String roomId,
String rootBody,
IList<String> 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<String> 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<void> 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<IList<String>> 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<IList<String>> 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,
);
}
}

View file

@ -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<void> 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<Uri> authorize(Uri authorizationUrl) async {
final client = http.Client();
String? cookie;
Future<Uri> followRedirect(
String method,
Uri url, [
Map<String, String>? 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();
}
}
}

View file

@ -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<void> pumpUntil(Finder finder) async {
const timeout = Duration(seconds: 30);
String textsIn(Finder finder) => widgetList<Text>(
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<void> tapWhenShown(Finder finder) async {
final tappable = finder.hitTestable();
await pumpUntil(tappable);
await tap(tappable.first);
await pump();
}
}

View file

@ -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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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"));
}
}

View file

@ -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<void> 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<Text>(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");
}
}

View file

@ -0,0 +1,56 @@
import "package:flutter_test/flutter_test.dart";
import "package:integration_test/integration_test.dart";
import "package:nexus/widgets/room_chat/room_chat.dart";
import "package:nexus/widgets/room_summary_sheet.dart";
import "package:nexus/widgets/user_bottom_sheet.dart";
import "helpers/matrix_user.dart";
import "helpers/test_app.dart";
import "helpers/pump_until.dart";
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final app = TestApp("dave", "davepass");
setUpAll(app.setUp);
final run = DateTime.now().millisecondsSinceEpoch;
Future<MatrixUser> bob() => MatrixUser.login("bob", "bobpass");
Future<void> openLink(WidgetTester tester, String link) async {
await app.open(tester);
app.fakes.links.open(.parse(link));
}
group("a link opened by the OS", () {
testWidgets("to a user opens their popover", (tester) async {
await openLink(tester, "matrix:u/bob:localhost");
await tester.pumpUntil(find.byType(UserBottomSheet));
});
testWidgets("to a joined room opens it", (tester) async {
final inviter = await bob();
final roomId = await inviter.createRoom("Linked $run");
// Joined before Nexus starts, so the room is there once it syncs
await app.join(inviter, roomId);
await openLink(tester, "matrix:roomid/${roomId.substring(1)}");
await tester.pumpUntil(
find.byWidgetPredicate(
(widget) => widget is RoomChat && widget.roomId == roomId,
),
);
});
testWidgets("to a room that isn't joined shows its summary", (
tester,
) async {
final roomId = await (await bob()).createRoom("Unjoined $run");
await openLink(tester, "matrix:roomid/${roomId.substring(1)}");
await tester.pumpUntil(find.byType(RoomSummarySheet));
});
});
}

View file

@ -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<void> 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<void> 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);
});
}

View file

@ -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<void> 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<void> 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<int> 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);
});
});
}

View file

@ -0,0 +1,31 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_emoji_picker/material_emoji_picker.dart";
/// Maps each unicode emoji, without variation selectors, to its shortcode.
class EmojiShortcodesController extends Notifier<IMap<String, String>> {
static final _variationSelectors = RegExp("[\uFE0E\uFE0F]");
static String normalize(String emoji) =>
emoji.replaceAll(_variationSelectors, "");
@override
IMap<String, String> build() => .fromEntries(
ref
.watch(EmojiController.provider)
.requireValue
.map((category) => category.emojis)
.flattened
.where((emoji) => emoji.aliases.isNotEmpty)
.map(
(emoji) =>
MapEntry(normalize(emoji.value), ":${emoji.aliases.first}:"),
),
);
static final provider =
NotifierProvider<EmojiShortcodesController, IMap<String, String>>(
EmojiShortcodesController.new,
);
}

View file

@ -1,14 +1,18 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/emoji_shortcodes.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/configs/event.dart"; import "package:nexus/models/configs/event.dart";
import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/reaction_group.dart";
class ReactionsController(final EventConfig config) class ReactionsController(final EventConfig config)
extends AsyncNotifier<IMap<String, IList<String>>> { extends AsyncNotifier<IMap<String, ReactionGroup>> {
@override @override
Future<IMap<String, IList<String>>> build() async { Future<IMap<String, ReactionGroup>> build() async {
final unicodeShortcodes = ref.watch(EmojiShortcodesController.provider);
final eventInfo = ref.watch( final eventInfo = ref.watch(
RoomsController.provider.select((value) { RoomsController.provider.select((value) {
final event = value[config.roomId]?.events[config.eventRowId]; final event = value[config.roomId]?.events[config.eventRowId];
@ -30,12 +34,25 @@ class ReactionsController(final EventConfig config)
return reactionEvents return reactionEvents
?.where((event) => event.redactedBy == null) ?.where((event) => event.redactedBy == null)
.fold<IMap<String, IList<String>>>(.new(), (acc, event) { .fold<IMap<String, ReactionGroup>>(.new(), (acc, event) {
if (event.content case ReactionContent(:final key?)) { if (event.content case ReactionContent(
:final key?,
:final shortcode,
)) {
return acc.update( return acc.update(
key, key,
(list) => list.add(event.sender), (group) => group.copyWith(
ifAbsent: () => .new([event.sender]), senders: group.senders.add(event.sender),
shortcode: group.shortcode ?? shortcode,
),
ifAbsent: () => .new(
senders: .new([event.sender]),
shortcode:
shortcode ??
unicodeShortcodes[EmojiShortcodesController.normalize(
key,
)],
),
); );
} }
@ -47,7 +64,7 @@ class ReactionsController(final EventConfig config)
static final provider = static final provider =
AsyncNotifierProvider.family< AsyncNotifierProvider.family<
ReactionsController, ReactionsController,
IMap<String, IList<String>>, IMap<String, ReactionGroup>,
EventConfig EventConfig
>(ReactionsController.new); >(ReactionsController.new);
} }

View file

@ -36,9 +36,9 @@ import "package:nexus/models/join_rule.dart";
import "package:nexus/models/power_level_permission.dart"; import "package:nexus/models/power_level_permission.dart";
import "package:nexus/models/setting.dart"; import "package:nexus/models/setting.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatars/avatar_or_hash.dart";
import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/common/divider_text.dart";
import "package:nexus/widgets/image_pack_avatar.dart"; import "package:nexus/widgets/avatars/image_pack_avatar.dart";
import "package:nexus/widgets/settings/add_users_power_level_sheet.dart"; import "package:nexus/widgets/settings/add_users_power_level_sheet.dart";
import "package:nexus/widgets/settings/aliases_sheet.dart"; import "package:nexus/widgets/settings/aliases_sheet.dart";
import "package:nexus/widgets/settings/allowed_rooms_sheet.dart"; import "package:nexus/widgets/settings/allowed_rooms_sheet.dart";

View file

@ -35,15 +35,15 @@ import "package:nexus/models/settings.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/models/show_and_send.dart"; import "package:nexus/models/show_and_send.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatars/avatar_or_hash.dart";
import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/common/divider_text.dart";
import "package:nexus/widgets/image_pack_avatar.dart"; import "package:nexus/widgets/avatars/image_pack_avatar.dart";
import "package:nexus/widgets/settings/block_user_field.dart"; import "package:nexus/widgets/settings/block_user_field.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/settings/enable_image_packs_sheet.dart"; import "package:nexus/widgets/settings/enable_image_packs_sheet.dart";
import "package:nexus/widgets/settings/keyword_field.dart"; import "package:nexus/widgets/settings/keyword_field.dart";
import "package:nexus/widgets/user_bottom_sheet.dart"; import "package:nexus/widgets/users/user_bottom_sheet.dart";
import "package:nexus/widgets/reset_encryption_sheet.dart"; import "package:nexus/widgets/encryption/reset_encryption_sheet.dart";
class SettingsSectionsController class SettingsSectionsController
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> { extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {

View file

@ -1,7 +1,7 @@
import "package:flutter/widgets.dart"; import "package:flutter/widgets.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/common/error_dialog.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/common/loading.dart";
extension BetterWhen<T> on AsyncValue<T> { extension BetterWhen<T> on AsyncValue<T> {
Widget betterWhen({ Widget betterWhen({

View file

@ -15,8 +15,8 @@ import "package:nexus/helpers/extensions/event_id_is_fake.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/emoji_picker.dart"; import "package:nexus/widgets/emoji/emoji_picker.dart";
import "package:nexus/widgets/mxc_emoji.dart"; import "package:nexus/widgets/emoji/mxc_emoji.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
extension BuildEventOptions on Event { extension BuildEventOptions on Event {

View file

@ -2,7 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_emoji_picker/material_emoji_picker.dart"; import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/image_pack.dart"; import "package:nexus/models/image_pack.dart";
import "package:nexus/widgets/mxc_emoji.dart"; import "package:nexus/widgets/emoji/mxc_emoji.dart";
extension ImagePackEmoji on ImagePack { extension ImagePackEmoji on ImagePack {
EmojiCategory? get emojiCategory => _category(emoticons); EmojiCategory? get emojiCategory => _category(emoticons);

View file

@ -7,7 +7,7 @@ import "package:nexus/helpers/extensions/focus_room.dart";
import "package:nexus/helpers/extensions/get_link_info.dart"; import "package:nexus/helpers/extensions/get_link_info.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/models/room_summary.dart"; import "package:nexus/models/room_summary.dart";
import "package:nexus/widgets/room_summary_sheet.dart"; import "package:nexus/widgets/rooms/room_summary_sheet.dart";
extension OpenLinkInfo on WidgetRef { extension OpenLinkInfo on WidgetRef {
Future<void> openLinkInfo( Future<void> openLinkInfo(

Some files were not shown because too many files have changed in this diff Show more