add automated screenshots, fastlane metadata, and appstream metainfo
Fixes #174, fixes #173, fixes #172
|
|
@ -10,9 +10,23 @@ extension PumpUntil on WidgetTester {
|
|||
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");
|
||||
String textsIn(Finder finder) =>
|
||||
widgetList(
|
||||
find.descendant(
|
||||
of: finder,
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) => widget is Text || widget is SelectableText,
|
||||
),
|
||||
),
|
||||
)
|
||||
.map(
|
||||
(widget) => switch (widget) {
|
||||
Text(:final data) || SelectableText(:final data) => data,
|
||||
_ => null,
|
||||
},
|
||||
)
|
||||
.nonNulls
|
||||
.join("\n");
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
while (stopwatch.elapsed < timeout) {
|
||||
|
|
|
|||
22
integration_test/screenshots/avatars/SOURCES.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Screenshot avatars
|
||||
|
||||
Avatars for the people, rooms and spaces in the screenshots, cropped from photos on Wikimedia Commons and Public Domain Pictures that are in the public domain or CC0, so they need no attribution.
|
||||
|
||||
| File | License | Source |
|
||||
| ---------------------- | ------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| pumpkin.webp | Public domain | https://commons.wikimedia.org/wiki/File:Cucurbita_2011_G1.jpg |
|
||||
| orange.webp | Public domain | https://commons.wikimedia.org/wiki/File:Orange-fruit-2.jpg |
|
||||
| log-1.webp | CC0 | https://commons.wikimedia.org/wiki/File:Tabby_cat_with_blue_eyes-3336579.jpg (tinted green) |
|
||||
| strudel.webp | CC0 | https://commons.wikimedia.org/wiki/File:Strudel-de-maca-2.png |
|
||||
| opal.webp | Public domain | https://commons.wikimedia.org/wiki/File:Opal_6.jpg |
|
||||
| cinnamon.webp | CC0 | https://commons.wikimedia.org/wiki/File:Cassia_bark_(2).jpg |
|
||||
| green-tea.webp | CC0 | https://www.publicdomainpictures.net/en/view-image.php?image=165622&picture=hot-green-tea-with-heart-art |
|
||||
| development.webp | CC0 | https://commons.wikimedia.org/wiki/File:Code_on_computer_monitor_(Unsplash).jpg |
|
||||
| fediverse.webp | Public domain | https://commons.wikimedia.org/wiki/File:Blackeyegalaxy.jpg |
|
||||
| flutter-devs.webp | CC0 | https://commons.wikimedia.org/wiki/File:Anna%27s_Hummingbird_Feeding.jpg |
|
||||
| linux-mobile.webp | CC0 | https://commons.wikimedia.org/wiki/File:Gentoo_penguin_with_a_feather_in_its_beak.jpg |
|
||||
| matrix-community.webp | Public domain | https://commons.wikimedia.org/wiki/File:Earth_at_Night,_Asia_(8270740728).jpg |
|
||||
| off-topic.webp | CC0 | https://commons.wikimedia.org/wiki/File:Coffee_cup_surrounded_by_coffee_beans.jpg |
|
||||
| open-source-games.webp | Public domain | https://commons.wikimedia.org/wiki/File:LodgeNet-Nintendo-N64-Controller.jpg |
|
||||
| rust.webp | CC0 | https://commons.wikimedia.org/wiki/File:Carcinus_maenas_146434844.jpg |
|
||||
| self-hosting.webp | CC0 | https://commons.wikimedia.org/wiki/File:Legrand_S.A._server_rack.jpg |
|
||||
BIN
integration_test/screenshots/avatars/cinnamon.webp
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
integration_test/screenshots/avatars/development.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
integration_test/screenshots/avatars/fediverse.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
integration_test/screenshots/avatars/flutter-devs.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
integration_test/screenshots/avatars/green-tea.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
integration_test/screenshots/avatars/linux-mobile.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
integration_test/screenshots/avatars/log-1.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
integration_test/screenshots/avatars/matrix-community.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
integration_test/screenshots/avatars/off-topic.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
integration_test/screenshots/avatars/opal.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
integration_test/screenshots/avatars/open-source-games.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
integration_test/screenshots/avatars/orange.webp
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
integration_test/screenshots/avatars/pumpkin.webp
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
integration_test/screenshots/avatars/quadradical.webp
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
integration_test/screenshots/avatars/rust.webp
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
integration_test/screenshots/avatars/self-hosting.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
integration_test/screenshots/avatars/strudel.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
365
integration_test/screenshots/screenshot_data.dart
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
import "dart:convert";
|
||||
|
||||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:nexus/models/client_state.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
import "package:nexus/models/space_edge.dart";
|
||||
|
||||
/// What the screenshots show: the Nexus space, with its room mid-conversation.
|
||||
/// Rooms are written as gomuks reports them.
|
||||
class ScreenshotData(final DateTime now) {
|
||||
static const me = "@quadradical:federated.nexus";
|
||||
static const bot = "@nexusbot:federated.nexus";
|
||||
static const alice = "@alice:example.org";
|
||||
static const pumpkin = "@pumpkin:example.org";
|
||||
static const strudel = "@strudel:example.org";
|
||||
static const opal = "@opal:example.org";
|
||||
static const greenTea = "@greentea:example.org";
|
||||
|
||||
static const nexusSpace = "!nexus-space:federated.nexus";
|
||||
static const nexusRoom = "!nexus-client:federated.nexus";
|
||||
|
||||
/// Nexus' own avatar, the app icon. Every other `mxc://screenshots/<file>` is
|
||||
/// a file in avatars/, see avatars/SOURCES.md.
|
||||
static const nexusAvatar = "mxc://screenshots/nexus";
|
||||
static const myAvatar = "mxc://screenshots/quadradical.webp";
|
||||
static const issueUrl = "https://git.federated.nexus/Nexus/nexus/issues/174";
|
||||
static const commitUrl =
|
||||
"https://git.federated.nexus/Nexus/nexus/commit/41f5225420";
|
||||
|
||||
static const topic =
|
||||
"A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.";
|
||||
|
||||
/// The users shown, with their power level in the Nexus room. Those without
|
||||
/// an avatar get Nexus' fallback. Everyone but QuadRadical and NexusBot is
|
||||
/// made up, on example.org so as not to be anyone real.
|
||||
static const users = IListConst([
|
||||
(id: me, name: "QuadRadical", powerLevel: 100, avatar: myAvatar),
|
||||
(id: alice, name: "Alice", powerLevel: 100, avatar: null),
|
||||
(id: bot, name: "NexusBot", powerLevel: 50, avatar: nexusAvatar),
|
||||
(
|
||||
id: pumpkin,
|
||||
name: "Pumpkin",
|
||||
powerLevel: 0,
|
||||
avatar: "mxc://screenshots/pumpkin.webp",
|
||||
),
|
||||
(
|
||||
id: strudel,
|
||||
name: "Strudel",
|
||||
powerLevel: 0,
|
||||
avatar: "mxc://screenshots/strudel.webp",
|
||||
),
|
||||
(
|
||||
id: opal,
|
||||
name: "Opal",
|
||||
powerLevel: 0,
|
||||
avatar: "mxc://screenshots/opal.webp",
|
||||
),
|
||||
(
|
||||
id: "@orange:example.org",
|
||||
name: "Orange",
|
||||
powerLevel: 0,
|
||||
avatar: "mxc://screenshots/orange.webp",
|
||||
),
|
||||
(
|
||||
id: "@log1:example.org",
|
||||
name: "log(1)",
|
||||
powerLevel: 0,
|
||||
avatar: "mxc://screenshots/log-1.webp",
|
||||
),
|
||||
(
|
||||
id: greenTea,
|
||||
name: "Green Tea",
|
||||
powerLevel: 0,
|
||||
avatar: "mxc://screenshots/green-tea.webp",
|
||||
),
|
||||
(
|
||||
id: "@cinnamon:example.org",
|
||||
name: "Cinnamon",
|
||||
powerLevel: 0,
|
||||
avatar: "mxc://screenshots/cinnamon.webp",
|
||||
),
|
||||
]);
|
||||
|
||||
/// The Nexus space's other rooms
|
||||
static const otherRooms = IListConst([
|
||||
(name: "Off-topic", unread: 3),
|
||||
(name: "Development", unread: 0),
|
||||
]);
|
||||
|
||||
/// Spaces shown in the rail besides Nexus, so it doesn't look empty
|
||||
static const otherSpaces = IListConst([
|
||||
"Matrix Community",
|
||||
"Flutter Devs",
|
||||
"Linux Mobile",
|
||||
"Rust",
|
||||
"Open Source Games",
|
||||
"Fediverse",
|
||||
"Self-Hosting",
|
||||
]);
|
||||
|
||||
static const clientState = ClientState(
|
||||
isInitialized: true,
|
||||
isLoggedIn: true,
|
||||
isVerified: true,
|
||||
userId: me,
|
||||
deviceId: "SCREENSHOTS",
|
||||
homeserverUrl: "https://federated.nexus",
|
||||
);
|
||||
|
||||
static final IList<String> topLevelSpaces = .new([
|
||||
nexusSpace,
|
||||
...otherSpaces.map(_roomId),
|
||||
]);
|
||||
|
||||
static final IMap<String, IList<SpaceEdge>> spaceEdges = .new({
|
||||
nexusSpace: .new(
|
||||
[
|
||||
nexusRoom,
|
||||
...otherRooms.map((room) => _roomId(room.name)),
|
||||
].map((id) => .new(childId: id)),
|
||||
),
|
||||
});
|
||||
|
||||
/// The rooms, with the Nexus room's state already fetched so it isn't
|
||||
/// requested
|
||||
late final IMap<String, Room> rooms = {
|
||||
nexusSpace: _room(nexusSpace, "Nexus Client", topic: topic),
|
||||
nexusRoom: _room(
|
||||
nexusRoom,
|
||||
"Nexus Client",
|
||||
topic: topic,
|
||||
lastEvent: timeline.last,
|
||||
extra: {
|
||||
"timeline": timeline
|
||||
.map(
|
||||
(event) => {
|
||||
"timeline_rowid": event["rowid"],
|
||||
"event_rowid": event["rowid"],
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
"events": [...nexusState, ...timeline],
|
||||
"state": _stateIndex(nexusState),
|
||||
"receipts": {
|
||||
timeline.last["event_id"]: [
|
||||
_receipt(strudel, timeline.last),
|
||||
_receipt(greenTea, timeline.last),
|
||||
],
|
||||
timeline[timeline.length - 2]["event_id"]: [
|
||||
_receipt(opal, timeline[timeline.length - 2]),
|
||||
],
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
).copyWith(hasFetchedState: true, hasFetchedMembers: true),
|
||||
for (final room in otherRooms)
|
||||
_roomId(room.name): _room(
|
||||
_roomId(room.name),
|
||||
room.name,
|
||||
avatar: _avatarOfRoom(room.name),
|
||||
unread: room.unread,
|
||||
),
|
||||
for (final space in otherSpaces)
|
||||
_roomId(space): _room(
|
||||
_roomId(space),
|
||||
space,
|
||||
avatar: _avatarOfRoom(space),
|
||||
),
|
||||
}.lock;
|
||||
|
||||
/// The Nexus room's state: who's in it, and their power levels
|
||||
late final List<Map<String, dynamic>> nexusState = [
|
||||
_event(me, "m.room.power_levels", {
|
||||
"users": Map.fromEntries(
|
||||
users.map((user) => .new(user.id, user.powerLevel)),
|
||||
),
|
||||
"users_default": 0,
|
||||
"events_default": 0,
|
||||
"state_default": 50,
|
||||
}, key: ""),
|
||||
...users.map(
|
||||
(user) => _event(user.id, "m.room.member", {
|
||||
"membership": "join",
|
||||
"displayname": user.name,
|
||||
"avatar_url": ?user.avatar,
|
||||
}, key: user.id),
|
||||
),
|
||||
];
|
||||
|
||||
late final List<Map<String, dynamic>> timeline = [
|
||||
_message(
|
||||
alice,
|
||||
35,
|
||||
"the screenshots in the readme are pretty out of date btw",
|
||||
),
|
||||
_message(
|
||||
me,
|
||||
32,
|
||||
"Should I:\n"
|
||||
"1. just take screenshots on a few devices, and redo them whenever the "
|
||||
"UI changes\n"
|
||||
"or\n"
|
||||
"2. overengineer a test that makes them automatically, in a fake room\n"
|
||||
"I think the answer is 2 :p",
|
||||
html:
|
||||
"Should I:"
|
||||
"<ol><li>just take screenshots on a few devices, and redo them "
|
||||
"whenever the UI changes</li></ol>"
|
||||
"or"
|
||||
'<ol start="2"><li>overengineer a test that makes them '
|
||||
"automatically, in a fake room</li></ol>"
|
||||
"I think the answer is 2 :p",
|
||||
reactions: {"2️⃣": 3},
|
||||
),
|
||||
_message(pumpkin, 30, "2, obviously"),
|
||||
// Linked only in the HTML, so the body has no link to preview
|
||||
_message(
|
||||
bot,
|
||||
29,
|
||||
"[Nexus/nexus] Issue opened: #174 Automatic scripts to make screenshots "
|
||||
"for different pages and viewports by Henry-Hiles",
|
||||
html:
|
||||
'[Nexus/nexus] Issue opened: <a href="$issueUrl">#174 Automatic '
|
||||
"scripts to make screenshots for different pages and viewports</a> "
|
||||
"by Henry-Hiles",
|
||||
),
|
||||
_message(
|
||||
bot,
|
||||
7,
|
||||
"[Nexus/nexus] Henry-Hiles pushed 1 commit to main: $commitUrl: "
|
||||
"update screenshots - Henry-Hiles",
|
||||
html:
|
||||
"[Nexus/nexus] Henry-Hiles pushed 1 commit to main:<br>"
|
||||
'<a href="$commitUrl">41f5225</a>: update screenshots - Henry-Hiles',
|
||||
reactions: {"🚀": 3},
|
||||
),
|
||||
_message(
|
||||
pumpkin,
|
||||
5,
|
||||
"they're generated now?? let's gooo",
|
||||
reactions: {"🎉": 2, "❤️": 1},
|
||||
),
|
||||
];
|
||||
|
||||
/// The link preview shown under NexusBot's message
|
||||
static const urlPreview = {
|
||||
"og:title": "update screenshots · 41f5225420",
|
||||
"og:description":
|
||||
"nexus - A simple and user-friendly Matrix client made with Flutter "
|
||||
"and a Gomuks backend.",
|
||||
};
|
||||
|
||||
int _rowId = 0;
|
||||
|
||||
/// An event in the Nexus room
|
||||
Map<String, dynamic> _event(
|
||||
String sender,
|
||||
String type,
|
||||
Map<String, dynamic> content, {
|
||||
String? key,
|
||||
int minutesAgo = 60,
|
||||
Map<String, int> reactions = const {},
|
||||
}) {
|
||||
final rowId = ++_rowId;
|
||||
return {
|
||||
"rowid": rowId,
|
||||
"timeline_rowid": rowId,
|
||||
"room_id": nexusRoom,
|
||||
"event_id": "\$screenshots-$rowId",
|
||||
"sender": sender,
|
||||
"type": type,
|
||||
"state_key": ?key,
|
||||
"timestamp": now
|
||||
.subtract(.new(minutes: minutesAgo))
|
||||
.millisecondsSinceEpoch,
|
||||
"content": content,
|
||||
"reactions": reactions,
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _message(
|
||||
String sender,
|
||||
int minutesAgo,
|
||||
String body, {
|
||||
String? html,
|
||||
Map<String, int> reactions = const {},
|
||||
}) => _event(
|
||||
sender,
|
||||
"m.room.message",
|
||||
{
|
||||
"msgtype": sender == bot ? "m.notice" : "m.text",
|
||||
"body": body,
|
||||
if (html != null) ...{
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": html,
|
||||
},
|
||||
},
|
||||
minutesAgo: minutesAgo,
|
||||
reactions: reactions,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _receipt(String userId, Map<String, dynamic> event) => {
|
||||
"user_id": userId,
|
||||
"event_id": event["event_id"],
|
||||
"room_id": nexusRoom,
|
||||
"timestamp": event["timestamp"],
|
||||
};
|
||||
|
||||
/// A room, with [extra] JSON besides its metadata
|
||||
Room _room(
|
||||
String id,
|
||||
String name, {
|
||||
String avatar = nexusAvatar,
|
||||
String? topic,
|
||||
int unread = 0,
|
||||
Map<String, dynamic>? lastEvent,
|
||||
Map<String, dynamic> extra = const {},
|
||||
}) => .fromJson(
|
||||
// Round-tripped, so the JSON has the types gomuks' decoded JSON would
|
||||
jsonDecode(
|
||||
jsonEncode({
|
||||
"meta": {
|
||||
"room_id": id,
|
||||
"name": name,
|
||||
"avatar": avatar,
|
||||
"topic": ?topic,
|
||||
"has_member_list": true,
|
||||
"preview_event_rowid": lastEvent?["rowid"] ?? 0,
|
||||
"sorting_timestamp":
|
||||
lastEvent?["timestamp"] ??
|
||||
now.subtract(const .new(hours: 1)).millisecondsSinceEpoch,
|
||||
"unread_highlights": 0,
|
||||
"unread_notifications": 0,
|
||||
"unread_messages": unread,
|
||||
},
|
||||
...extra,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
static Map<String, Map<String, int>> _stateIndex(
|
||||
List<Map<String, dynamic>> state,
|
||||
) => state
|
||||
.groupListsBy((event) => event["type"] as String)
|
||||
.map(
|
||||
(type, events) => .new(
|
||||
type,
|
||||
Map.fromEntries(
|
||||
events.map(
|
||||
(event) =>
|
||||
.new(event["state_key"] as String, event["rowid"] as int),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
static String _avatarOfRoom(String name) =>
|
||||
"mxc://screenshots/${_slug(name)}.webp";
|
||||
|
||||
static String _slug(String name) =>
|
||||
name.toLowerCase().replaceAll(RegExp("[^a-z]+"), "-");
|
||||
|
||||
static String _roomId(String name) => "!${_slug(name)}:federated.nexus";
|
||||
}
|
||||