diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..c374e64 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/flake.lock b/flake.lock index db5f957..be46f01 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,99 @@ { "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": { "inputs": { "nixpkgs-lib": "nixpkgs-lib" @@ -18,6 +112,24 @@ "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": { "inputs": { "systems": "systems" @@ -39,7 +151,7 @@ "nix2flatpak": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs_2" }, "locked": { "lastModified": 1791751882, @@ -57,11 +169,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1773389992, - "narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=", + "lastModified": 1794042290, + "narHash": "sha256-ziZn2PvCJE5JEEpqTdxXwJpPB+L3YqoCpTAMCzyaP3U=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c06b4ae3d6599a672a6210b7021d699c351eebda", + "rev": "f4a03c008967841f2a0b3cb7c436fc6fc31671f6", "type": "github" }, "original": { @@ -86,7 +198,38 @@ "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": { + "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": { "lastModified": 1793540411, "narHash": "sha256-fiPJ88izTVucjeo2EQ3jk4aWmLg03I1D4vxnX2QjEgE=", @@ -104,9 +247,27 @@ }, "root": { "inputs": { - "flake-parts": "flake-parts", + "continuwuity": "continuwuity", + "flake-parts": "flake-parts_2", "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": { @@ -123,6 +284,27 @@ "repo": "default", "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", diff --git a/flake.nix b/flake.nix index e4cae8a..2074aa6 100644 --- a/flake.nix +++ b/flake.nix @@ -6,6 +6,8 @@ nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; nix2flatpak.url = "github:neobrain/nix2flatpak"; + # TEMPORARY: the e2e tests against a local Continuwuity checkout + continuwuity.url = "git+file:///home/quadradical/Documents/Code/continuwuity"; }; outputs = 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/links_test.dart b/integration_test/links_test.dart new file mode 100644 index 0000000..7690cd7 --- /dev/null +++ b/integration_test/links_test.dart @@ -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 bob() => MatrixUser.login("bob", "bobpass"); + + Future 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)); + }); + }); +} 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); + }); + }); +} diff --git a/linux/nix/build_env.nix b/linux/nix/build_env.nix new file mode 100644 index 0000000..cac5fbd --- /dev/null +++ b/linux/nix/build_env.nix @@ -0,0 +1,30 @@ +{ pkgs, lib }: +# What building Nexus needs, shared by the dev shell and the e2e tests +{ + packages = + with pkgs; + [ + stdenv.cc + go + git + + libGL + (flutter.override { + extraPkgConfigPackages = [ + mpv-unwrapped + libass + ]; + }) + ] + ++ lib.optionals pkgs.stdenv.isLinux [ + wayland + # nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately + libxext + libxfixes + (callPackage ./pkg/hook_pkg_config.nix { }) + ]; + + env = lib.optionalAttrs pkgs.stdenv.isLinux { + LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; + }; +} diff --git a/linux/nix/default.nix b/linux/nix/default.nix index 3c0a301..3d3b395 100644 --- a/linux/nix/default.nix +++ b/linux/nix/default.nix @@ -54,6 +54,52 @@ }; devShells.default = pkgs.callPackage ./devshell.nix { }; + + apps = + let + buildEnv = import ./build_env.nix { inherit pkgs lib; }; + e2e = pkgs.writeShellApplication { + name = "nexus-e2e"; + runtimeInputs = + buildEnv.packages + ++ (with pkgs; [ + # TEMPORARY: matrix-continuwuity, once the local fixes are released + inputs.continuwuity.packages.${system}.default + curl + dbus + ]); + runtimeEnv = buildEnv.env; + text = builtins.readFile ./e2e.sh; + }; + in + { + e2e = { + type = "app"; + program = lib.getExe e2e; + }; + + e2e-headless = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "nexus-e2e-headless"; + runtimeInputs = [ pkgs.cage ]; + runtimeEnv = { + WLR_BACKENDS = "headless"; + __EGL_VENDOR_LIBRARY_FILENAMES = "${pkgs.mesa}/share/glvnd/egl_vendor.d/50_mesa.json"; + }; + text = '' + unset DISPLAY WAYLAND_DISPLAY + # Wayland needs one, which CI may not have + XDG_RUNTIME_DIR="''${XDG_RUNTIME_DIR:-$(mktemp -d)}" + export XDG_RUNTIME_DIR + + exec cage -- ${lib.getExe e2e} "$@" + ''; + } + ); + }; + }; }; flake.nixosModules.default = import ./module.nix self; diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index f4bf1c6..cc2223e 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -20,41 +20,20 @@ let includeNDK = true; ndkVersions = [ "28.2.13676358" ]; }; + buildEnv = import ./build_env.nix { inherit pkgs lib; }; in pkgs.mkShell { - packages = - with pkgs; - [ - go - git + packages = buildEnv.packages ++ [ android.platform-tools ]; - libGL - (flutter.override { - extraPkgConfigPackages = [ - mpv-unwrapped - libass - ]; - }) - android.platform-tools - ] - ++ lib.optionals pkgs.stdenv.isLinux [ - wayland - # nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately - libxext - libxfixes - (callPackage ./pkg/hook_pkg_config.nix { }) - ]; + env = + rec { + LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ]; - env = rec { - LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ]; + ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk"; + ANDROID_SDK_ROOT = ANDROID_HOME; + JAVA_HOME = pkgs.jdk21; - ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk"; - ANDROID_SDK_ROOT = ANDROID_HOME; - JAVA_HOME = pkgs.jdk21; - - GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2"; - } - // lib.optionalAttrs pkgs.stdenv.isLinux { - LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; - }; + GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2"; + } + // buildEnv.env; } diff --git a/linux/nix/e2e.sh b/linux/nix/e2e.sh new file mode 100755 index 0000000..6cc9f09 --- /dev/null +++ b/linux/nix/e2e.sh @@ -0,0 +1,77 @@ +# shellcheck shell=bash + +files=() +flutter_args=() +for arg in "$@"; do + if [[ "$arg" == *.dart ]]; then files+=("$arg"); else flutter_args+=("$arg"); fi +done +if [[ ${#files[@]} -eq 0 ]]; then files=(integration_test/*_test.dart); fi + +homeserver="http://localhost:8448" + +if curl -s -o /dev/null "$homeserver"; then + echo "Something is already using port 8448, like another e2e run." >&2 + exit 1 +fi + +work="$(mktemp -d)" +# shellcheck disable=SC2329 # Run by the trap +cleanup() { + [[ -n "${server_pid:-}" ]] && kill "$server_pid" 2>/dev/null && wait "$server_pid" 2>/dev/null + rm -rf "$work" +} +trap cleanup EXIT + +mkdir -p "$work/db" +cat > "$work/continuwuity.toml" < "$work/continuwuity.log" 2>&1 & +server_pid=$! + +all_can_log_in() { + for user in "${users[@]}"; do + curl -sf -XPOST "$homeserver/_matrix/client/v3/login" \ + -d "{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"$user\"},\"password\":\"${user}pass\"}" \ + > /dev/null || return 1 + done +} + +# Wait until login succeeds, otherwise you might try to log in before the account is created +attempts=60 +until all_can_log_in; do + kill -0 "$server_pid" 2>/dev/null || { cat "$work/continuwuity.log"; exit 1; } + (( --attempts )) || { echo "Test users weren't created" >&2; exit 1; } + sleep 0.5 +done + +GOCACHE="$(go env GOCACHE)" +export GOCACHE + +status=0 +for file in "${files[@]}"; do + name="$(basename "$file" .dart)" + mkdir -p "$work/$name/data" "$work/$name/cache" + XDG_DATA_HOME="$work/$name/data" XDG_CACHE_HOME="$work/$name/cache" \ + dbus-run-session -- \ + flutter test "$file" -d linux \ + --dart-define="NEXUS_E2E_HOMESERVER=$homeserver" "${flutter_args[@]}" || + status=$? +done +exit "$status" diff --git a/pubspec.lock b/pubspec.lock index 9ea2664..c4ccd9b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -58,7 +58,7 @@ packages: source: hosted version: "1.0.4" app_links_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: app_links_platform_interface sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" @@ -471,6 +471,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.1" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_file_dialog: dependency: "direct main" description: @@ -581,7 +586,7 @@ packages: source: hosted version: "2.3.2" flutter_test: - dependency: transitive + dependency: "direct dev" description: flutter source: sdk version: "0.0.0" @@ -623,6 +628,11 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" geoclue: dependency: transitive description: @@ -839,6 +849,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -1552,6 +1567,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" synchronized: dependency: transitive description: @@ -1715,7 +1738,7 @@ packages: source: hosted version: "3.2.6" url_launcher_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: url_launcher_platform_interface sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" @@ -1843,6 +1866,14 @@ packages: url: "https://github.com/DavidClaudeAI/webcrypto.dart" source: git version: "0.6.2-wip" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" webpush_encryption: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a1dbdf5..145a55c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -122,6 +122,12 @@ dependencies: latlong2: 0.10.1 geolocator: 14.1.2 dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + app_links_platform_interface: 2.0.4 + url_launcher_platform_interface: 2.3.2 build_runner: 2.17.1 flutter_lints: 6.0.0 freezed: 4.0.3