forked from Nexus/nexus
parent
f1f0fea114
commit
af99de75da
18 changed files with 1346 additions and 41 deletions
28
.github/workflows/e2e.yml
vendored
Normal file
28
.github/workflows/e2e.yml
vendored
Normal 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
|
||||||
195
flake.lock
generated
195
flake.lock
generated
|
|
@ -1,5 +1,100 @@
|
||||||
{
|
{
|
||||||
"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": 1794353021,
|
||||||
|
"narHash": "sha256-9eu1PH9znI34Sn5c0YCUMFUyU+D4BL+6+VqwgaU4YAI=",
|
||||||
|
"ref": "v26.11.0-alpha.1",
|
||||||
|
"rev": "38fb7897bb53805414f09e5262e297d1e800f01c",
|
||||||
|
"revCount": 8131,
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://forgejo.ellis.link/Continuwuation/Continuwuity"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"ref": "v26.11.0-alpha.1",
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://forgejo.ellis.link/Continuwuation/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 +113,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 +152,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 +170,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 +199,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 +248,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 +285,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",
|
||||||
|
|
|
||||||
|
|
@ -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 Continuwuity from the forge, till the update gets into unstable
|
||||||
|
continuwuity.url = "git+https://forgejo.ellis.link/Continuwuation/Continuwuity?ref=v26.11.0-alpha.1";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
|
|
|
||||||
47
integration_test/helpers/fakes.dart
Normal file
47
integration_test/helpers/fakes.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
174
integration_test/helpers/matrix_user.dart
Normal file
174
integration_test/helpers/matrix_user.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
integration_test/helpers/oauth.dart
Normal file
68
integration_test/helpers/oauth.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
integration_test/helpers/pump_until.dart
Normal file
56
integration_test/helpers/pump_until.dart
Normal 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/common/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();
|
||||||
|
}
|
||||||
|
}
|
||||||
115
integration_test/helpers/test_app.dart
Normal file
115
integration_test/helpers/test_app.dart
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
34
integration_test/helpers/threads.dart
Normal file
34
integration_test/helpers/threads.dart
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
56
integration_test/links_test.dart
Normal file
56
integration_test/links_test.dart
Normal 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/rooms/room_summary_sheet.dart";
|
||||||
|
import "package:nexus/widgets/users/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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
115
integration_test/long_thread_test.dart
Normal file
115
integration_test/long_thread_test.dart
Normal 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/threads/thread_drawer.dart";
|
||||||
|
import "package:nexus/widgets/threads/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);
|
||||||
|
});
|
||||||
|
}
|
||||||
258
integration_test/threads_test.dart
Normal file
258
integration_test/threads_test.dart
Normal 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/threads/thread_drawer.dart";
|
||||||
|
import "package:nexus/widgets/threads/thread_summary.dart";
|
||||||
|
import "package:nexus/widgets/threads/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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
30
linux/nix/build_env.nix
Normal file
30
linux/nix/build_env.nix
Normal file
|
|
@ -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 ]}";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -54,6 +54,52 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.default = pkgs.callPackage ./devshell.nix { };
|
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;
|
flake.nixosModules.default = import ./module.nix self;
|
||||||
|
|
|
||||||
|
|
@ -20,41 +20,20 @@ let
|
||||||
includeNDK = true;
|
includeNDK = true;
|
||||||
ndkVersions = [ "28.2.13676358" ];
|
ndkVersions = [ "28.2.13676358" ];
|
||||||
};
|
};
|
||||||
|
buildEnv = import ./build_env.nix { inherit pkgs lib; };
|
||||||
in
|
in
|
||||||
pkgs.mkShell {
|
pkgs.mkShell {
|
||||||
packages =
|
packages = buildEnv.packages ++ [ android.platform-tools ];
|
||||||
with pkgs;
|
|
||||||
[
|
|
||||||
go
|
|
||||||
git
|
|
||||||
|
|
||||||
libGL
|
env =
|
||||||
(flutter.override {
|
rec {
|
||||||
extraPkgConfigPackages = [
|
LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ];
|
||||||
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 {
|
ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk";
|
||||||
LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ];
|
ANDROID_SDK_ROOT = ANDROID_HOME;
|
||||||
|
JAVA_HOME = pkgs.jdk21;
|
||||||
|
|
||||||
ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk";
|
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2";
|
||||||
ANDROID_SDK_ROOT = ANDROID_HOME;
|
}
|
||||||
JAVA_HOME = pkgs.jdk21;
|
// buildEnv.env;
|
||||||
|
|
||||||
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 ]}";
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
linux/nix/e2e.sh
Executable file
77
linux/nix/e2e.sh
Executable file
|
|
@ -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" <<EOF
|
||||||
|
[global]
|
||||||
|
server_name = "localhost"
|
||||||
|
database_path = "$work/db"
|
||||||
|
address = ["127.0.0.1", "::1"]
|
||||||
|
port = 8448
|
||||||
|
|
||||||
|
[global.well_known]
|
||||||
|
client = "$homeserver"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
users=(alice bob carol dave)
|
||||||
|
create_users=()
|
||||||
|
for user in "${users[@]}"; do
|
||||||
|
create_users+=(--execute "users create-user $user ${user}pass")
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Starting Continuwuity (log: $work/continuwuity.log)..."
|
||||||
|
conduwuit -c "$work/continuwuity.toml" "${create_users[@]}" \
|
||||||
|
> "$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"
|
||||||
37
pubspec.lock
37
pubspec.lock
|
|
@ -58,7 +58,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.4"
|
version: "1.0.4"
|
||||||
app_links_platform_interface:
|
app_links_platform_interface:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: app_links_platform_interface
|
name: app_links_platform_interface
|
||||||
sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809"
|
sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809"
|
||||||
|
|
@ -471,6 +471,11 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.9.1"
|
version: "0.9.1"
|
||||||
|
flutter_driver:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_file_dialog:
|
flutter_file_dialog:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -581,7 +586,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
|
@ -623,6 +628,11 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0"
|
version: "3.1.0"
|
||||||
|
fuchsia_remote_debug_protocol:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
geoclue:
|
geoclue:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -839,6 +849,11 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.2"
|
version: "0.2.2"
|
||||||
|
integration_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1552,6 +1567,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.1"
|
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:
|
synchronized:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1715,7 +1738,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.6"
|
version: "3.2.6"
|
||||||
url_launcher_platform_interface:
|
url_launcher_platform_interface:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: url_launcher_platform_interface
|
name: url_launcher_platform_interface
|
||||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||||
|
|
@ -1843,6 +1866,14 @@ packages:
|
||||||
url: "https://github.com/google/webcrypto.dart"
|
url: "https://github.com/google/webcrypto.dart"
|
||||||
source: git
|
source: git
|
||||||
version: "0.6.2-wip"
|
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:
|
webpush_encryption:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,12 @@ dependencies:
|
||||||
latlong2: 0.10.1
|
latlong2: 0.10.1
|
||||||
geolocator: 14.1.2
|
geolocator: 14.1.2
|
||||||
dev_dependencies:
|
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
|
build_runner: 2.17.1
|
||||||
flutter_lints: 6.0.0
|
flutter_lints: 6.0.0
|
||||||
freezed: 4.0.3
|
freezed: 4.0.3
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue