From 011bafb6af3d79cdf007ec68ca3fb24699ce74ac Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 01:23:18 +0200 Subject: [PATCH] End-to-end tests against the pasture; profiles, follows, search, votes, reactions tests/e2e (Playwright for .NET 1.61.0 on xunit v3, the Chromium 1228 already cached, never downloaded, never in CI) runs the published app against SocialPub's pasture and its town: run.sh publishes it with the Pasture environment (wwwroot/appsettings.Pasture.json, published only then; the deploy fails if it ever ships) and serves it as https://decepub.test. The tests sign in through the real login page and persona chooser, check that every request goes only to decepub.test and privapub.test with the right credential on the right API, render one post of each kind from each platform as its author wrote it, and check every action on the other side of the wire (town.sh stored): favourites, boosts and replies with their undoing, follows from a profile, votes, reactions. Each test is also a cell of the town's report (out/client.jsonl). They found two PrivaPub bugs fixed there (a blocked author's status answered null; sensitive remote media invented a content warning) and a reply addressed to nobody. New in the app, all checked by those tests: - profiles at /account/{id}: header, bio, fields, counts, the account's posts as posts, posts and replies, or media, and follow, unfollow, cancel a request, mute and block (AccountService); post authors and notifications open them in the app; - search at /search: accounts, posts and hashtags, a handle or an address resolved by PrivaPub; - poll voting on the card, and emoji reactions shown and given with one tap; before a persona's first reaction or vote it is told once that both are public (PublicNoticeService, the owner's decision); - follow requests answered from their notification. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- .gitea/workflows/deploy.yml | 3 + .gitignore | 4 + CLAUDE.md | 37 +++- ClientJsonContext.cs | 1 + Components/Content.razor | 171 ++++++++++++++- Components/PublicNoticeHost.razor | 34 +++ MastodonJsonContext.cs | 4 + Models/APIs.cs | 8 + Models/Mastodon/Account.cs | 11 + Models/Mastodon/Status.cs | 21 ++ Models/PublicActionNotices.cs | 10 + Pages/Index.razor | 2 +- Pages/Profile.razor | 274 +++++++++++++++++++++++++ Pages/Search.razor | 127 ++++++++++++ Program.cs | 2 + Resources/AllStrings.it.resx | 102 +++++++++ Resources/AllStrings.resx | 102 +++++++++ Services/AccountService.cs | 162 +++++++++++++++ Services/IHttpService.cs | 5 + Services/PublicNoticeService.cs | 43 ++++ Services/StatusService.cs | 21 ++ Shared/MainLayout.razor | 3 +- Shared/NavMenu.razor | 14 ++ Styles/app.css | 3 +- decePubClient.csproj | 7 +- decePubClient.sln | 42 ++++ tests/e2e/Actions/ActionMatrixTests.cs | 160 +++++++++++++++ tests/e2e/Actions/ProfileTests.cs | 85 ++++++++ tests/e2e/Actions/VoteAndReactTests.cs | 115 +++++++++++ tests/e2e/Login/LoginTests.cs | 68 ++++++ tests/e2e/Render/RenderMatrixTests.cs | 84 ++++++++ tests/e2e/Support/Browser.cs | 101 +++++++++ tests/e2e/Support/Pasture.cs | 91 ++++++++ tests/e2e/Support/Results.cs | 48 +++++ tests/e2e/Support/SignIn.cs | 63 ++++++ tests/e2e/decePubClient.E2E.csproj | 27 +++ tests/e2e/run.sh | 26 +++ tests/e2e/xunit.runner.json | 5 + wwwroot/appsettings.Pasture.json | 5 + 39 files changed, 2074 insertions(+), 17 deletions(-) create mode 100644 Components/PublicNoticeHost.razor create mode 100644 Models/PublicActionNotices.cs create mode 100644 Pages/Profile.razor create mode 100644 Pages/Search.razor create mode 100644 Services/AccountService.cs create mode 100644 Services/PublicNoticeService.cs create mode 100644 tests/e2e/Actions/ActionMatrixTests.cs create mode 100644 tests/e2e/Actions/ProfileTests.cs create mode 100644 tests/e2e/Actions/VoteAndReactTests.cs create mode 100644 tests/e2e/Login/LoginTests.cs create mode 100644 tests/e2e/Render/RenderMatrixTests.cs create mode 100644 tests/e2e/Support/Browser.cs create mode 100644 tests/e2e/Support/Pasture.cs create mode 100644 tests/e2e/Support/Results.cs create mode 100644 tests/e2e/Support/SignIn.cs create mode 100644 tests/e2e/decePubClient.E2E.csproj create mode 100755 tests/e2e/run.sh create mode 100644 tests/e2e/xunit.runner.json create mode 100644 wwwroot/appsettings.Pasture.json diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 8808297..692fc8f 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -55,6 +55,9 @@ jobs: [ -e "$W/$f" ] || { echo "::error::publish output is missing $f"; exit 1; } done ls "$W/_framework" | grep -q '\.wasm$' || { echo "::error::no .wasm in _framework"; exit 1; } + for f in appsettings.Development.json appsettings.Pasture.json; do + [ ! -e "$W/$f" ] || { echo "::error::publish output holds $f, which points the app at a test server"; exit 1; } + done echo "publish OK, $(du -sh "$W" | cut -f1)" - name: Snapshot the live directory diff --git a/.gitignore b/.gitignore index 81e74d7..412743a 100644 --- a/.gitignore +++ b/.gitignore @@ -367,3 +367,7 @@ FodyWeavers.xsd # Tailwind standalone + daisyUI downloads (tools/tailwind.sh) and the CSS they generate .tools/ wwwroot/css/app.css + +# the e2e tests: the published app they serve and the browser state they keep +tests/e2e/.publish/ +tests/e2e/.state/ diff --git a/CLAUDE.md b/CLAUDE.md index 1d91809..157983f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,32 @@ dotnet run # dev server: https://loc dotnet publish decePubClient.csproj -c Release -o publish # static site lands in publish/wwwroot ``` -`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There are no tests and no linter. +`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There is no linter. + +## Testing: end to end against the pasture + +`tests/e2e` (Playwright for .NET 1.61.0 on xunit v3) runs the published app in Chromium against SocialPub's pasture: a +private fediverse of PrivaPub and its peers (GoToSocial, Mastodon, Misskey, Sharkey, Akkoma, Lemmy) seeded with a fake +community, the town (see `../SocialPub/CLAUDE.md`, "The town"). Never in CI: it needs podman and the pasture. + +```sh +../SocialPub/tools/pasture/run.sh up gts mastodon misskey sharkey akkoma lemmy # PASTURE_PORT if 6971 is taken +../SocialPub/tools/pasture/town.sh seed village +tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentName=Pasture, serves https://decepub.test, runs +``` + +- **The app under test is the deploy's own build** with the environment baked in: Blazor on .NET 10 ignores the + `Blazor-Environment` header, so `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is chosen at publish + time and published only then; the deploy fails if it ever ships. +- Chromium resolves `*.test` to Caddy on `127.0.0.1:6443` (`--host-resolver-rules`) and ignores the pasture CA. The + tests use the Chromium headless shell 1228 cached in `~/.cache/ms-playwright` and never install a browser. +- **Guards on every test** (`Support/Browser.cs`): the app talks only to its own site and to PrivaPub, a root JWT never + goes to `/api` nor a persona token to `/clientapi`, no 5xx, no Blazor error bar. +- **The tests ask the town** (`Support/Pasture.cs`) which accounts exist and what each object holds (`town.sh expect`), + and check the other side of the wire through `town.sh stored`. Each render and action test is also a cell of the + town's report (`out/client.jsonl`). +- Cards carry `data-uri` (their ActivityPub id) and the empty feed `data-feed-empty`, for the tests. +- The root project globs `**/*.cs`, so it excludes `tests/**` (`DefaultItemExcludes`), and Tailwind does not scan it. ## CSS: Tailwind 4 + daisyUI 5, vendored, offline @@ -183,8 +208,16 @@ supplies: - **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`; circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from `/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete, - threads, mute, block; located and group posts go through `/clientapi/post/insert`), `MediaService` (uploads), + threads, mute, block, poll votes, emoji reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`; + located and group posts go through `/clientapi/post/insert`), `AccountService` (profiles, relationships, follow, + block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `MediaService` (uploads), `GroupService`, `InstanceService` and `ProvenanceService`. +- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll + vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in + localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice. +- **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's + author and a notification's account open the profile in the app; "Open original" is the only way to the remote site. + A card in a fixed-height flex column with `overflow-hidden` needs `flex-none`, or it collapses to nothing. - **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from `PrivaPub.ClientModels`. - **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`), diff --git a/ClientJsonContext.cs b/ClientJsonContext.cs index 5b1eaeb..3fe90be 100644 --- a/ClientJsonContext.cs +++ b/ClientJsonContext.cs @@ -22,6 +22,7 @@ namespace decePubClient; [JsonSerializable(typeof(PageSettings))] [JsonSerializable(typeof(AuthData))] [JsonSerializable(typeof(FeedFilter))] +[JsonSerializable(typeof(PublicActionNotices))] [JsonSerializable(typeof(ServerInfo))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(UploadMedia))] diff --git a/Components/Content.razor b/Components/Content.razor index 8e44321..9dd7dc0 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -4,20 +4,22 @@ The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only - its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun. Located posts have no actions: PrivaPub keeps them off the Mastodon API. Nerd stats are on + its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id, + which the e2e tests (tests/e2e) find it by. Located posts have no actions: PrivaPub keeps them off the Mastodon API. Nerd stats are on the time, the author and its server's history, the place or the CDN, the media and the counts. *@
+ data-range="@(Post.Located && !Compact ? Coordinate(Post.RangeKm) : null)" data-post-at="@(Compact ? null : Post.WrittenAt?.ToString("O"))" + data-uri="@(Compact ? null : Post.Status?.Uri)"> @if (Author is { } author) {
- + @* alt="": the author's name is next to it. *@ @@ -153,6 +155,17 @@ OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" title="@Localizer["Bookmark"]"> + + + + @foreach (var emoji in QuickReactions) + { + @emoji + } + + @@ -211,8 +224,24 @@ else if (Post.Notification?.Account is { } follower) {

- @follower.Name @("@" + follower.Acct) + @follower.Name + @("@" + follower.Acct)

+ @if (Post.Notification.Type == "follow_request" && requestAnswer is null) + { +
+ + @Localizer["Accept"] + + + @Localizer["Refuse"] + +
+ } + else if (requestAnswer is { } accepted) + { +

@(accepted ? Localizer["Accepted."] : Localizer["Refused."])

+ } }
@@ -295,14 +324,43 @@ @if (status.Poll is { } poll) { -
- @foreach (var option in poll.Options) +
+ @if (CanVote(poll)) { - var share = poll.VotesCount > 0 && option.VotesCount is { } votes ? votes * 100d / poll.VotesCount : 0; -
-
- @option.Title@share.ToString("0")% -
+ @for (var i = 0; i < poll.Options.Count; i++) + { + var index = i; + + } + + @Localizer["Vote"] + + } + else + { + @for (var i = 0; i < poll.Options.Count; i++) + { + var option = poll.Options[i]; + var share = poll.VotesCount > 0 && option.VotesCount is { } votes ? votes * 100d / poll.VotesCount : 0; +
+
+ + + @option.Title + @if (poll.OwnVotes.Contains(i)) + { + + } + + @share.ToString("0")% + +
+ } } @Localizer["{0} votes", poll.VotesCount] ยท @(poll.Expired ? Localizer["closed"] : Localizer["open"])
@@ -324,6 +382,27 @@ } + @if (status.EmojiReactions.Count > 0) + { +
+ @foreach (var reaction in status.EmojiReactions) + { + + @if (reaction.Url is { Length: > 0 } image) + { + @reaction.Name + } + else + { + @reaction.Name + } + @reaction.Count + + } +
+ } + @if (status.Tags.Count > 0) {
@@ -366,6 +445,76 @@ Post.Status = updated.Reblog ?? updated; } + [Inject] AccountService AccountService { get; set; } + [Inject] PublicNoticeService Notices { get; set; } + + bool? requestAnswer; + + async Task AnswerRequest(Account follower, bool accept) + { + isActing = true; + var result = await AccountService.Answer(follower.Id, accept); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + requestAnswer = accept; + } + + static readonly string[] QuickReactions = ["๐Ÿ‘", "โค๏ธ", "๐Ÿ˜‚", "๐ŸŽ‰", "๐Ÿ”ฅ", "๐Ÿ˜ฎ", "๐Ÿ˜ข"]; + + bool isReactionsOpen; + + async Task React(string emoji, bool add) + { + if (add && !await Notices.Confirm(CascadingState.PersonaId)) + return; + isActing = true; + var result = await StatusService.React(Status.Id, emoji, add); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + var updated = (Status)result.Data; + Status.EmojiReactions = (updated?.Reblog ?? updated)?.EmojiReactions ?? Status.EmojiReactions; + } + + HashSet pollChoices = []; + + bool CanVote(Poll poll) => !poll.Expired && poll.Voted != true && !IsOwn && !Compact && !Post.Located; + + void ToggleChoice(Poll poll, int index) + { + if (!poll.Multiple) + pollChoices.Clear(); + if (!pollChoices.Remove(index)) + pollChoices.Add(index); + } + + async Task Vote(Poll poll) + { + if (!await Notices.Confirm(CascadingState.PersonaId)) + return; + isActing = true; + var result = await StatusService.Vote(poll.Id, pollChoices); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + Status.Poll = (Poll)result.Data ?? poll; + pollChoices.Clear(); + Toast.AddResponseSuccess(Localizer["Voted."]); + } + async Task Delete() { isActing = true; diff --git a/Components/PublicNoticeHost.razor b/Components/PublicNoticeHost.razor new file mode 100644 index 0000000..8789b87 --- /dev/null +++ b/Components/PublicNoticeHost.razor @@ -0,0 +1,34 @@ +@inherits LocalizableComponentBase +@implements IDisposable + +@* The one-time notice before a persona's first reaction or poll vote (PublicNoticeService), in MainLayout. *@ + + @Localizer["Reactions and votes are public"] + +

@Localizer["Anyone who can see the post can see that {0} reacted to it or voted in it. Your other personas are never named.", CascadingState.Persona?.Name ?? ""]

+
+ + @Localizer["Cancel"] + @Localizer["Continue"] + +
+ +@code { + [Inject] PublicNoticeService Notices { get; set; } + + protected override void OnInitialized() + { + Notices.Changed += Refresh; + IsLoading = false; + } + + void Refresh() => InvokeAsync(StateHasChanged); + + void Closed(bool open) + { + if (!open) + Notices.Answer(false); + } + + public void Dispose() => Notices.Changed -= Refresh; +} diff --git a/MastodonJsonContext.cs b/MastodonJsonContext.cs index f7fff3a..84ef497 100644 --- a/MastodonJsonContext.cs +++ b/MastodonJsonContext.cs @@ -17,6 +17,8 @@ namespace decePubClient; [JsonSerializable(typeof(Account))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(Relationship))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(SearchResults))] [JsonSerializable(typeof(MediaAttachment))] [JsonSerializable(typeof(Notification))] [JsonSerializable(typeof(List))] @@ -31,6 +33,8 @@ namespace decePubClient; [JsonSerializable(typeof(TokenResponse))] [JsonSerializable(typeof(MastodonError))] [JsonSerializable(typeof(PostStatusForm))] +[JsonSerializable(typeof(Poll))] +[JsonSerializable(typeof(PollVoteForm))] public partial class MastodonJsonContext : JsonSerializerContext { } diff --git a/Models/APIs.cs b/Models/APIs.cs index 9d28236..5869824 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -45,6 +45,10 @@ public static class APIs public const string GET_Conversations = "api/v1/conversations"; public const string GET_Notifications = "api/v1/notifications"; public const string POST_Media = "api/v2/media"; + public const string GET_AccountLookup = "api/v1/accounts/lookup"; + public const string GET_Relationships = "api/v1/accounts/relationships"; + public const string GET_FollowRequests = "api/v1/follow_requests"; + public const string GET_Search = "api/v2/search"; public static string GET_M_TagTimeline(string tag) => $"api/v1/timelines/tag/{Uri.EscapeDataString(tag)}"; public static string GET_M_Account(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}"; @@ -54,6 +58,10 @@ public static class APIs public static string DELETE_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}"; public static string POST_M_StatusAction(string statusId, string action) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/{action}"; public static string POST_M_AccountAction(string accountId, string action) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/{action}"; + public static string M_Reaction(string statusId, string emoji) => + $"api/v1/pleroma/statuses/{Uri.EscapeDataString(statusId)}/reactions/{Uri.EscapeDataString(emoji)}"; + public static string POST_M_PollVotes(string pollId) => $"api/v1/polls/{Uri.EscapeDataString(pollId)}/votes"; + public static string POST_M_FollowRequest(string accountId, string answer) => $"api/v1/follow_requests/{Uri.EscapeDataString(accountId)}/{answer}"; } public static class PrivaPub diff --git a/Models/Mastodon/Account.cs b/Models/Mastodon/Account.cs index 3871813..7b17c21 100644 --- a/Models/Mastodon/Account.cs +++ b/Models/Mastodon/Account.cs @@ -53,6 +53,17 @@ public class Relationship public bool Following { get; set; } public bool FollowedBy { get; set; } public bool Blocking { get; set; } + public bool BlockedBy { get; set; } public bool Muting { get; set; } public bool Requested { get; set; } + public bool RequestedBy { get; set; } + public bool DomainBlocking { get; set; } +} + +/// /api/v2/search: what matched, a remote handle or URL resolved by PrivaPub. +public class SearchResults +{ + public List Accounts { get; set; } = []; + public List Statuses { get; set; } = []; + public List Hashtags { get; set; } = []; } diff --git a/Models/Mastodon/Status.cs b/Models/Mastodon/Status.cs index bc38139..963175a 100644 --- a/Models/Mastodon/Status.cs +++ b/Models/Mastodon/Status.cs @@ -40,6 +40,8 @@ public class Status public string Text { get; set; } public PrivaPubStatus Privapub { get; set; } public Quote Quote { get; set; } + /// Emoji reactions (PrivaPub sends Pleroma's and Misskey's forms as one list). + public List EmojiReactions { get; set; } = []; public int QuotesCount { get; set; } } @@ -85,9 +87,28 @@ public class Poll public int VotesCount { get; set; } public int? VotersCount { get; set; } public bool? Voted { get; set; } + /// The options the current persona chose (indexes). + public List OwnVotes { get; set; } = []; public List Options { get; set; } = []; } +public class EmojiReaction +{ + /// The emoji itself, or a custom emoji's shortcode. + public string Name { get; set; } + public int Count { get; set; } + /// Whether the current persona reacted with it. + public bool Me { get; set; } + /// A custom emoji's image, through PrivaPub's proxy. + public string Url { get; set; } +} + +/// POST /api/v1/polls/{id}/votes. +public class PollVoteForm +{ + public List Choices { get; set; } = []; +} + public class PollOption { public string Title { get; set; } diff --git a/Models/PublicActionNotices.cs b/Models/PublicActionNotices.cs new file mode 100644 index 0000000..7e53264 --- /dev/null +++ b/Models/PublicActionNotices.cs @@ -0,0 +1,10 @@ +namespace decePubClient.Models; + +/// +/// The personas already told that their reactions and poll votes are public (owner decision, PrivaPub's ROADMAP: each +/// persona is told once, before its first reaction or vote). Kept in localStorage under its type name. +/// +public class PublicActionNotices +{ + public List PersonaIds { get; set; } = []; +} diff --git a/Pages/Index.razor b/Pages/Index.razor index c2f937a..225208c 100644 --- a/Pages/Index.razor +++ b/Pages/Index.razor @@ -67,7 +67,7 @@ } else if (posts.Count == 0) { -

+

@Localizer["Empty"]

} diff --git a/Pages/Profile.razor b/Pages/Profile.razor new file mode 100644 index 0000000..b42e8c4 --- /dev/null +++ b/Pages/Profile.razor @@ -0,0 +1,274 @@ +@page "/account/{accountId}" +@attribute [Authorize] +@inherits LocalizableComponentBase +@(account?.Name ?? Localizer["Profile"]) + +@* An account as the current persona sees it (/api/v1/accounts/{id}): its header, bio and fields, its counts, what it is + to the persona (following, followed by, requested, blocked, muted) with the actions that change it, and its posts, + page by page, as posts only, posts and replies, or media. A remote account's profile comes from what PrivaPub knows, + never from its server directly. *@ +
+
+ +
+ + + +

@Localizer["Profile"]

+
+ + @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (account is null) + { +

+ @Localizer["This account doesn't exist."] +

+ } + else + { +
+
+
+ @* alt="": the name is next to it. *@ + +
+ @account.Name + @("@" + FullAcct) +
+ @if (!IsOwn) + { +
+ @if (relationship?.FollowedBy == true) + { + @Localizer["Follows you"] + } + @if (relationship?.Blocking != true) + { + + @FollowLabel + + } + + + @(relationship?.Muting == true ? Localizer["Unmute"] : Localizer["Mute"]) + + + + @(relationship?.Blocking == true ? Localizer["Unblock"] : Localizer["Block"]) + +
+ } +
+ +
+ @if (account.Locked) + { + @Localizer["Locked account"] + } + @if (account.Bot) + { + @Localizer["Bot"] + } + @if (account.Group) + { + @Localizer["Groups"] + } +
+ + @if (!string.IsNullOrWhiteSpace(account.Note)) + { +
@((MarkupString)account.Note)
+ } + + @if (account.Fields.Count > 0) + { +
+ @foreach (var field in account.Fields) + { +
@field.Name
+
@((MarkupString)field.Value)
+ } +
+ } + +
+ @account.StatusesCount @Localizer["posts"] + @account.FollowingCount @Localizer["following"] + @account.FollowersCount @Localizer["followers"] +
+
+ + +
@Localizer["posts"]
+
@Localizer["Posts and replies"]
+
@Localizer["Media"]
+
+ +
+ @foreach (var post in posts) + { + + } + @if (posts.Count == 0 && !isLoadingMore) + { +

@Localizer["Empty"]

+ } + @if (maxId is not null) + { + + @if (isLoadingMore) + { + + } + @Localizer["Load more"] + + } +
+ } +
+
+ +@code { + [Inject] AccountService AccountService { get; set; } + [Inject] InstanceService InstanceService { get; set; } + [Inject] ILocalStorageService LocalStorage { get; set; } + [Inject] IJSRuntime JS { get; set; } + + [Parameter] public string AccountId { get; set; } + + Account account; + Relationship relationship; + List posts = []; + ProfileTab tab = ProfileTab.Posts; + string maxId; + string loadedId; + string loadedPersonaId; + bool isActing; + bool isLoadingMore; + + bool IsOwn => account is not null && account.Id == CascadingState.PersonaId; + + string FullAcct => account.Acct.Contains('@') ? account.Acct : $"{account.Acct}@{account.Host}"; + + string HeaderStyle => string.IsNullOrEmpty(account?.Header) ? null : $"background-image:url('{account.Header}')"; + + string FollowLabel => relationship switch + { + { Following: true } => Localizer["Unfollow"], + { Requested: true } => Localizer["Cancel the request"], + _ => Localizer["Follow"] + }; + + protected override async Task OnParametersSetAsync() + { + if (AccountId == loadedId && CascadingState.PersonaId == loadedPersonaId) + return; + loadedId = AccountId; + loadedPersonaId = CascadingState.PersonaId; + + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + } + + async Task LoadData() + { + IsLoading = true; + account = await AccountService.Get(AccountId); + if (account is null) + { + IsLoading = false; + return; + } + relationship = account.Id == CascadingState.PersonaId ? null : await AccountService.Relationship(account.Id); + posts = []; + maxId = null; + IsLoading = false; + await LoadPage(); + } + + async Task LoadPage() + { + isLoadingMore = true; + var result = await AccountService.Statuses(account.Id, tab, maxId); + isLoadingMore = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + var page = (FeedPage)result.Data; + var known = posts.Select(p => p.Key).ToHashSet(); + posts.AddRange(page.Posts.Where(p => !known.Contains(p.Key))); + maxId = page.MaxId; + await InstanceService.Resolve(page.Posts.Select(p => p.Host).Append(InstanceService.SelfHost)); + InstanceService.Locate(page.Posts); + } + + async Task LoadMore() => await LoadPage(); + + async Task SelectTab(int index) + { + tab = (ProfileTab)index; + posts = []; + maxId = null; + await LoadPage(); + } + + async Task ToggleFollow() => + await Do(relationship is { Following: true } or { Requested: true } ? AccountAction.Unfollow : AccountAction.Follow); + + async Task Do(AccountAction action) + { + isActing = true; + var result = await AccountService.Act(account.Id, action); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + relationship = (Relationship)result.Data ?? relationship; + Toast.AddResponseSuccess(action switch + { + AccountAction.Follow when relationship?.Requested == true => Localizer["Asked {0} to accept you.", account.Name], + AccountAction.Follow => Localizer["Following {0}.", account.Name], + AccountAction.Unfollow => Localizer["{0} unfollowed.", account.Name], + AccountAction.Block => Localizer["{0} blocked.", account.Name], + AccountAction.Unblock => Localizer["{0} unblocked.", account.Name], + AccountAction.Mute => Localizer["{0} muted.", account.Name], + _ => Localizer["{0} unmuted.", account.Name] + }); + } + + void Remove(FeedPost post) => posts.RemoveAll(p => p.Key == post.Key); + + async Task OpenTag(string tagName) + { + var filter = await LocalStorage.GetItemAsync(nameof(FeedFilter)) ?? new(); + filter.Tag = tagName; + filter.TimelineType = TimelineType.Tag; + await LocalStorage.SetItemAsync(nameof(FeedFilter), filter); + Navigation.NavigateTo(""); + } + + async Task Back() => await JS.InvokeVoidAsync("history.back"); +} diff --git a/Pages/Search.razor b/Pages/Search.razor new file mode 100644 index 0000000..6681cb1 --- /dev/null +++ b/Pages/Search.razor @@ -0,0 +1,127 @@ +@page "/search" +@attribute [Authorize] +@inherits LocalizableComponentBase +@Localizer["Search"] + +@* Accounts, posts and hashtags (/api/v2/search). A handle (name@host) or a post's address is resolved by PrivaPub, + which fetches it from its server if it has not met it yet; the browser never contacts another server. *@ +
+
+ + + + + + + @if (isSearching) + { + + } + @Localizer["Search"] + + + + @if (searched) + { + @if (results.Accounts.Count == 0 && results.Statuses.Count == 0 && results.Hashtags.Count == 0) + { +

@Localizer["Nothing found."]

+ } + + @if (results.Accounts.Count > 0) + { +

@Localizer["Accounts"]

+ + } + + @if (results.Hashtags.Count > 0) + { +

@Localizer["Hashtags"]

+
+ @foreach (var tag in results.Hashtags) + { + #@tag.Name + } +
+ } + + @if (results.Statuses.Count > 0) + { +

@Localizer["Posts and replies"]

+
+ @foreach (var post in statuses) + { + + } +
+ } + } +
+
+ +@code { + [Inject] AccountService AccountService { get; set; } + [Inject] InstanceService InstanceService { get; set; } + [Inject] ILocalStorageService LocalStorage { get; set; } + + [SupplyParameterFromQuery(Name = "q")] public string Q { get; set; } + + sealed class SearchForm + { + public string Query { get; set; } + } + + SearchForm form = new(); + SearchResults results = new(); + List statuses = []; + bool searched; + bool isSearching; + + protected override async Task OnInitializedAsync() + { + IsLoading = false; + if (string.IsNullOrWhiteSpace(Q)) + return; + form.Query = Q; + await Run(); + } + + async Task Run() + { + if (string.IsNullOrWhiteSpace(form.Query)) + return; + isSearching = true; + // a handle or an address is something to resolve; plain words only search what PrivaPub already holds + var resolve = form.Query.Contains('@') || form.Query.StartsWith("http", StringComparison.OrdinalIgnoreCase); + results = await AccountService.Search(form.Query, resolve); + statuses = [.. results.Statuses.Select(FeedPost.Of)]; + searched = true; + isSearching = false; + await InstanceService.Resolve(statuses.Select(p => p.Host).Append(InstanceService.SelfHost)); + InstanceService.Locate(statuses); + if (results.Accounts.Count == 1 && results.Statuses.Count == 0 && form.Query.Contains('@')) + Navigation.NavigateTo($"account/{Uri.EscapeDataString(results.Accounts[0].Id)}"); + } + + async Task OpenTag(string tagName) + { + var filter = await LocalStorage.GetItemAsync(nameof(FeedFilter)) ?? new(); + filter.Tag = tagName; + filter.TimelineType = TimelineType.Tag; + await LocalStorage.SetItemAsync(nameof(FeedFilter), filter); + Navigation.NavigateTo(""); + } +} diff --git a/Program.cs b/Program.cs index 0da3654..a135bbc 100644 --- a/Program.cs +++ b/Program.cs @@ -41,6 +41,8 @@ builder.Services.AddOptions() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 926a905..3601fe3 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -806,4 +806,106 @@ solstizio di dicembre + + Segui + + + Smetti di seguire + + + Annulla la richiesta + + + Ti segue + + + Riattiva + + + Sblocca + + + Post e risposte + + + Account protetto + + + Bot + + + Questo account non esiste. + + + Carica altro + + + Cerca + + + Cerca account, post e tag, o incolla un indirizzo + + + Account + + + Hashtag + + + Nessun risultato. + + + Il mio profilo + + + Hai chiesto a {0} di accettarti. + + + Ora segui {0}. + + + Non segui piรน {0}. + + + {0} sbloccato. + + + {0} riattivato. + + + Vota + + + Il tuo voto + + + Votato. + + + Accetta + + + Rifiuta + + + Accettata. + + + Rifiutata. + + + Reazioni e voti sono pubblici + + + Chiunque possa vedere il post puรฒ vedere che {0} ha reagito o votato. Le tue altre persone non vengono mai nominate. + + + Annulla + + + Continua + + + Reagisci + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index f06a782..3b20160 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -806,4 +806,106 @@ December solstice + + Follow + + + Unfollow + + + Cancel the request + + + Follows you + + + Unmute + + + Unblock + + + Posts and replies + + + Locked account + + + Bot + + + This account doesn't exist. + + + Load more + + + Search + + + Search accounts, posts and tags, or paste an address + + + Accounts + + + Hashtags + + + Nothing found. + + + My profile + + + Asked {0} to accept you. + + + Following {0}. + + + {0} unfollowed. + + + {0} unblocked. + + + {0} unmuted. + + + Vote + + + Your vote + + + Voted. + + + Accept + + + Refuse + + + Accepted. + + + Refused. + + + Reactions and votes are public + + + Anyone who can see the post can see that {0} reacted to it or voted in it. Your other personas are never named. + + + Cancel + + + Continue + + + React + diff --git a/Services/AccountService.cs b/Services/AccountService.cs new file mode 100644 index 0000000..8b3ef3e --- /dev/null +++ b/Services/AccountService.cs @@ -0,0 +1,162 @@ +using System.Net; + +using collAnon.Client.Services; + +using decePubClient.Extensions; +using decePubClient.Models; +using decePubClient.Models.Mastodon; + +using PrivaPub.ClientModels; + +namespace decePubClient.Services +{ + /// What the current persona does to and learns of other accounts. + public enum AccountAction + { + Follow, + Unfollow, + Block, + Unblock, + Mute, + Unmute + } + + /// Which of an account's posts its profile lists. + public enum ProfileTab + { + Posts, + PostsAndReplies, + Media + } + + /// + /// Accounts as the current persona sees them: a profile, its relationship to the persona, its posts, following, + /// blocking and muting it, the persona's follow requests, and search across the fediverse (a remote handle or URL is + /// resolved by PrivaPub, never by the browser). Lists come back empty and values null on failure; nothing throws. + /// + public class AccountService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) + { + const int PageSize = 20; + + public async Task Get(string accountId, CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.Mastodon.GET_M_Account(accountId), cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync(cancellationToken); + + await LogFailure(response, nameof(Get), cancellationToken); + return null; + } + + /// An account by its handle (name or name@host), resolved by PrivaPub if it has not met it yet. + public async Task Lookup(string acct, CancellationToken cancellationToken = default) + { + acct = acct?.Trim().TrimStart('@'); + if (string.IsNullOrEmpty(acct)) + return null; + using var response = await httpService.Get(APIs.Mastodon.GET_AccountLookup, [$"acct={Uri.EscapeDataString(acct)}"], + cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync(cancellationToken); + + var found = await Search(acct, resolve: true, cancellationToken); + return found.Accounts.FirstOrDefault(a => string.Equals(a.Acct, acct, StringComparison.OrdinalIgnoreCase)) + ?? found.Accounts.FirstOrDefault(); + } + + public async Task Relationship(string accountId, CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.Mastodon.GET_Relationships, [$"id[]={Uri.EscapeDataString(accountId)}"], + cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return (await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []).FirstOrDefault(); + + await LogFailure(response, nameof(Relationship), cancellationToken); + return null; + } + + /// Follows, unfollows, blocks, unblocks, mutes or unmutes; Data is the new Relationship. + public async Task Act(string accountId, AccountAction action, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, action.ToString().ToLowerInvariant()), + cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; + } + + /// A page of the account's posts, newest first; Data is a FeedPage. + public async Task Statuses(string accountId, ProfileTab tab, string maxId, CancellationToken cancellationToken = default) + { + var parameters = new List { $"limit={PageSize}" }; + parameters.AddRange(tab switch + { + ProfileTab.Posts => ["exclude_replies=true"], + ProfileTab.Media => ["only_media=true"], + _ => Array.Empty() + }); + if (maxId is not null) + parameters.Add($"max_id={Uri.EscapeDataString(maxId)}"); + using var response = await httpService.Get(APIs.Mastodon.GET_M_AccountStatuses(accountId), [.. parameters], + cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + var statuses = await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; + var (nextMaxId, _) = response.PageCursors(); + return new WebResult + { + Data = new FeedPage + { + Posts = [.. statuses.Select(FeedPost.Of)], + MaxId = statuses.Count < PageSize ? null : nextMaxId ?? statuses.LastOrDefault()?.Id + } + }; + } + + /// The accounts waiting for the persona to accept their follow. + public async Task> FollowRequests(CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.Mastodon.GET_FollowRequests, ["limit=80"], cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; + + await LogFailure(response, nameof(FollowRequests), cancellationToken); + return []; + } + + /// Accepts (authorize) or refuses (reject) a follow request. + public async Task Answer(string accountId, bool accept, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.Mastodon.POST_M_FollowRequest(accountId, accept ? "authorize" : "reject"), + cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; + } + + /// Accounts, posts and hashtags matching the query; a handle or a URL is resolved when `resolve`. + public async Task Search(string query, bool resolve, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(query)) + return new SearchResults(); + using var response = await httpService.Get(APIs.Mastodon.GET_Search, + [$"q={Uri.EscapeDataString(query.Trim())}", $"resolve={(resolve ? "true" : "false")}", "limit=20"], + cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync(cancellationToken) ?? new SearchResults(); + + await LogFailure(response, nameof(Search), cancellationToken); + return new SearchResults(); + } + + async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken) + { + var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.NotFound)) + await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AccountService)}.{caller}", cancellationToken); + } + } +} diff --git a/Services/IHttpService.cs b/Services/IHttpService.cs index dd17df3..10aad20 100644 --- a/Services/IHttpService.cs +++ b/Services/IHttpService.cs @@ -37,6 +37,8 @@ namespace decePubClient.Services Task PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default); + Task Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default); + Task PostTotallyAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default); @@ -78,6 +80,9 @@ namespace decePubClient.Services public Task Post(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) => Send(nameof(Post), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken); + public Task Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) => + Send(nameof(Put), () => BuildRequest(HttpMethod.Put, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken); + public Task PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) => Send(nameof(PostAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthIfAvailableRequest, retryOnError, cancellationToken); diff --git a/Services/PublicNoticeService.cs b/Services/PublicNoticeService.cs new file mode 100644 index 0000000..f29f6d3 --- /dev/null +++ b/Services/PublicNoticeService.cs @@ -0,0 +1,43 @@ +using Blazored.LocalStorage; + +using decePubClient.Models; + +namespace decePubClient.Services +{ + /// + /// Asks a persona, once, before its first reaction or poll vote, to accept that these are public and visible as that + /// persona (an owner decision). PublicNoticeHost shows the question; Confirm returns true at once for a persona that + /// already accepted it, and false when the reader declines. + /// + public class PublicNoticeService(ILocalStorageService localStorage) + { + TaskCompletionSource pending; + + public event Action Changed; + + public bool IsAsking => pending is not null; + + public async Task Confirm(string personaId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(personaId)) + return false; + var seen = await localStorage.GetItemAsync(nameof(PublicActionNotices), cancellationToken) ?? new(); + if (seen.PersonaIds.Contains(personaId)) + return true; + + pending = new TaskCompletionSource(); + Changed?.Invoke(); + var accepted = await pending.Task; + pending = null; + Changed?.Invoke(); + if (!accepted) + return false; + + seen.PersonaIds.Add(personaId); + await localStorage.SetItemAsync(nameof(PublicActionNotices), seen, cancellationToken); + return true; + } + + public void Answer(bool accepted) => pending?.TrySetResult(accepted); + } +} diff --git a/Services/StatusService.cs b/Services/StatusService.cs index f38a5b2..73f8ddc 100644 --- a/Services/StatusService.cs +++ b/Services/StatusService.cs @@ -100,6 +100,27 @@ namespace decePubClient.Services return await StatusResult(response, cancellationToken); } + /// Reacts with an emoji (or takes the reaction back); Data is the updated Status. + public async Task React(string statusId, string emoji, bool add, CancellationToken cancellationToken = default) + { + var route = APIs.Mastodon.M_Reaction(statusId, emoji); + using var response = add + ? await httpService.Put(route, cancellationToken: cancellationToken) + : await httpService.Delete(route, cancellationToken: cancellationToken); + return await StatusResult(response, cancellationToken); + } + + /// Votes in a poll as the persona (each option by its index); Data is the updated Poll. + public async Task Vote(string pollId, IReadOnlyCollection choices, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.Mastodon.POST_M_PollVotes(pollId), new PollVoteForm { Choices = [.. choices] }, + cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; + } + /// Deletes the persona's own status. public async Task Delete(string statusId, CancellationToken cancellationToken = default) { diff --git a/Shared/MainLayout.razor b/Shared/MainLayout.razor index 913c022..93bc0a3 100644 --- a/Shared/MainLayout.razor +++ b/Shared/MainLayout.razor @@ -10,4 +10,5 @@
- \ No newline at end of file + + \ No newline at end of file diff --git a/Shared/NavMenu.razor b/Shared/NavMenu.razor index 94a53be..b74bb84 100644 --- a/Shared/NavMenu.razor +++ b/Shared/NavMenu.razor @@ -41,6 +41,20 @@ @Localizer["Home"] + @if (CascadingState.IsSignedIn) + { + + + @Localizer["Search"] + + @if (CascadingState.PersonaId is { } personaId) + { + + + @Localizer["My profile"] + + } + } @Localizer["Settings"] diff --git a/Styles/app.css b/Styles/app.css index 2fc360e..892256d 100644 --- a/Styles/app.css +++ b/Styles/app.css @@ -1,11 +1,12 @@ /* Entry point compiled by Tailwind CSS standalone (tools/tailwind.sh, TailwindCompile in decePubClient.csproj) into wwwroot/css/app.css. Tailwind scans every file under the project that .gitignore doesn't exclude, except tools/ - (daisyui-llms.txt names every daisyUI class) and the vendored CSS. */ + (daisyui-llms.txt names every daisyUI class), tests/ and the vendored CSS. */ @import "tailwindcss" source("../"); @source not "../wwwroot/vendor"; @source not "../Docs"; @source not "../tools"; +@source not "../tests"; @plugin "../tools/tailwind/daisyui.mjs" { themes: false; diff --git a/decePubClient.csproj b/decePubClient.csproj index 85060fd..44d8a39 100644 --- a/decePubClient.csproj +++ b/decePubClient.csproj @@ -6,6 +6,8 @@ enable service-worker-assets.js true + + $(DefaultItemExcludes);tests/** @@ -38,7 +40,7 @@ - + @@ -67,6 +69,9 @@ + + diff --git a/decePubClient.sln b/decePubClient.sln index 820a502..e820b04 100644 --- a/decePubClient.sln +++ b/decePubClient.sln @@ -7,24 +7,66 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "decePubClient", "decePubCli EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SocialPub.ClientModels", "..\SocialPub\SocialPub\SocialPub.ClientModels\SocialPub.ClientModels.csproj", "{5DA9311B-B6BC-4EC6-A26E-10367288F52B}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "e2e", "e2e", "{0EB607AC-6329-6575-AD8B-E5558B048B88}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "decePubClient.E2E", "tests\e2e\decePubClient.E2E.csproj", "{DB238929-3C3E-4841-B782-3BF0CC53B20E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {EBE69805-3005-49C2-81E4-A314A19F68B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EBE69805-3005-49C2-81E4-A314A19F68B6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EBE69805-3005-49C2-81E4-A314A19F68B6}.Debug|x64.ActiveCfg = Debug|Any CPU + {EBE69805-3005-49C2-81E4-A314A19F68B6}.Debug|x64.Build.0 = Debug|Any CPU + {EBE69805-3005-49C2-81E4-A314A19F68B6}.Debug|x86.ActiveCfg = Debug|Any CPU + {EBE69805-3005-49C2-81E4-A314A19F68B6}.Debug|x86.Build.0 = Debug|Any CPU {EBE69805-3005-49C2-81E4-A314A19F68B6}.Release|Any CPU.ActiveCfg = Release|Any CPU {EBE69805-3005-49C2-81E4-A314A19F68B6}.Release|Any CPU.Build.0 = Release|Any CPU + {EBE69805-3005-49C2-81E4-A314A19F68B6}.Release|x64.ActiveCfg = Release|Any CPU + {EBE69805-3005-49C2-81E4-A314A19F68B6}.Release|x64.Build.0 = Release|Any CPU + {EBE69805-3005-49C2-81E4-A314A19F68B6}.Release|x86.ActiveCfg = Release|Any CPU + {EBE69805-3005-49C2-81E4-A314A19F68B6}.Release|x86.Build.0 = Release|Any CPU {5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Debug|x64.ActiveCfg = Debug|Any CPU + {5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Debug|x64.Build.0 = Debug|Any CPU + {5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Debug|x86.ActiveCfg = Debug|Any CPU + {5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Debug|x86.Build.0 = Debug|Any CPU {5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|Any CPU.ActiveCfg = Release|Any CPU {5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|Any CPU.Build.0 = Release|Any CPU + {5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x64.ActiveCfg = Release|Any CPU + {5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x64.Build.0 = Release|Any CPU + {5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x86.ActiveCfg = Release|Any CPU + {5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x86.Build.0 = Release|Any CPU + {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x64.ActiveCfg = Debug|Any CPU + {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x64.Build.0 = Debug|Any CPU + {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x86.ActiveCfg = Debug|Any CPU + {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x86.Build.0 = Debug|Any CPU + {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|Any CPU.Build.0 = Release|Any CPU + {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x64.ActiveCfg = Release|Any CPU + {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x64.Build.0 = Release|Any CPU + {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x86.ActiveCfg = Release|Any CPU + {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {0EB607AC-6329-6575-AD8B-E5558B048B88} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {DB238929-3C3E-4841-B782-3BF0CC53B20E} = {0EB607AC-6329-6575-AD8B-E5558B048B88} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {1B399226-FEE0-4572-A4A7-783DA83E7DDD} EndGlobalSection diff --git a/tests/e2e/Actions/ActionMatrixTests.cs b/tests/e2e/Actions/ActionMatrixTests.cs new file mode 100644 index 0000000..e4fb67c --- /dev/null +++ b/tests/e2e/Actions/ActionMatrixTests.cs @@ -0,0 +1,160 @@ +using System.Text.Json.Nodes; + +using decePubClient.E2E.Support; + +using Microsoft.Playwright; + +namespace decePubClient.E2E.Actions +{ + // What a reader does to a post from each platform, done in decePub and checked on the post's own server: a favourite + // and a boost arrive there (and leave when undone), a reply lands under the post and is gone once deleted. town.sh + // stored reads the origin's database, so nothing here trusts decePub's own view of what it did. + public sealed class ActionMatrixTests(Browser browser) + { + static readonly Lazy Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray()); + + public static TheoryData Targets() + { + var data = new TheoryData(); + var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet(); + foreach (var origin in new[] { "gts", "mastodon", "misskey", "sharkey", "akkoma", "lemmy" }) + { + var item = Expected.Value.FirstOrDefault(i => i!["origin"]!.GetValue() == origin + && i["visibility"]!.GetValue() is "public" or "community" + && i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue()))); + if (item is not null) + data.Add(origin, item["ref"]!.GetValue()); + } + return data; + } + + static (JsonNode Item, Pasture.Persona Reader) Target(string reference) + { + var item = Expected.Value.First(i => i!["ref"]!.GetValue() == reference)!; + var personas = Pasture.Personas(); + var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue())) + .First(p => p is not null); + return (item, reader); + } + + static async Task Count(string origin, string uri, string field) + { + var row = (await Pasture.Stored(origin, uri))[uri]!; + return row[field]?.GetValue() ?? 0; + } + + static async Task IsActive(ILocator button) => + ((await button.GetAttributeAsync("class")) ?? "").Split(' ').Contains("btn-active"); + + async Task<(Session Session, ILocator Card)> Open(JsonNode item, Pasture.Persona reader) + { + var session = await SignIn.As(browser, reader); + await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue()}"); + var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue()}']").First; + await card.WaitForAsync(new() { Timeout = 30000 }); + return (session, card); + } + + [Theory] + [MemberData(nameof(Targets))] + public async Task A_favourite_reaches_the_author_and_its_undo_too(string origin, string reference) + { + var (item, reader) = Target(reference); + var uri = item["uri"]!.GetValue(); + await Results.Cell("client.action.favourite", origin, reference, async () => + { + var (session, card) = await Open(item, reader); + await using var _ = session; + var favourite = card.Locator("footer button[title='Favourite']").First; + // the town may have done it already as this persona: start from the undone state + if (await IsActive(favourite)) + { + var done = await Count(origin, uri, "likes"); + await favourite.ClickAsync(); + await Pasture.Eventually(async () => await Count(origin, uri, "likes") < done, 60); + await Assertions.Expect(favourite).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active")); + } + var before = await Count(origin, uri, "likes"); + await favourite.ClickAsync(); + Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "likes") > before), + $"the favourite never reached {origin}"); + await favourite.ClickAsync(); + Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "likes") == before, 60), + $"undoing the favourite never reached {origin}"); + await session.AssertGuards(); + }); + } + + [Theory] + [MemberData(nameof(Targets))] + public async Task A_boost_reaches_the_author_and_its_undo_too(string origin, string reference) + { + Assert.SkipWhen(origin == "lemmy", "Lemmy keeps no boosts of a thread"); + var (item, reader) = Target(reference); + var uri = item["uri"]!.GetValue(); + await Results.Cell("client.action.boost", origin, reference, async () => + { + var (session, card) = await Open(item, reader); + await using var _ = session; + var boost = card.Locator("footer button[title='Boost']").First; + // the town may have done it already as this persona: start from the undone state + if (await IsActive(boost)) + { + var done = await Count(origin, uri, "boosts"); + await boost.ClickAsync(); + await Pasture.Eventually(async () => await Count(origin, uri, "boosts") < done, 60); + await Assertions.Expect(boost).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active")); + } + var before = await Count(origin, uri, "boosts"); + await boost.ClickAsync(); + Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "boosts") > before), + $"the boost never reached {origin}"); + await boost.ClickAsync(); + Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "boosts") == before, 60), + $"undoing the boost never reached {origin}"); + await session.AssertGuards(); + }); + } + + [Theory] + [MemberData(nameof(Targets))] + public async Task A_reply_lands_under_the_post_and_its_delete_removes_it(string origin, string reference) + { + var (item, reader) = Target(reference); + var uri = item["uri"]!.GetValue(); + await Results.Cell("client.action.reply", origin, reference, async () => + { + var (session, card) = await Open(item, reader); + await using var _ = session; + await card.Locator("footer button[title='Reply']").First.ClickAsync(); + var text = $"an e2e reply to {reference} ({Guid.NewGuid():N})"; + // after the @names the composer prefills, as a reader types + var box = card.Locator("textarea").Last; + await box.FillAsync(((await box.InputValueAsync()).TrimEnd() + " " + text).Trim()); + var published = session.Page.WaitForResponseAsync(r => r.Url.EndsWith("/api/v1/statuses") && r.Request.Method == "POST"); + await card.Locator("button[type=submit]").Last.ClickAsync(); + var response = await published; + Assert.True(response.Ok, $"publishing the reply answered {response.Status}"); + var replyUri = (await response.JsonAsync())?.GetProperty("uri").GetString(); + Assert.False(string.IsNullOrEmpty(replyUri)); + + Assert.True(await Pasture.Eventually(async () => + { + var row = (await Pasture.Stored(origin, replyUri))[replyUri]!; + return row["exists"]!.GetValue() && row["parent_uri"]?.GetValue() == uri; + }, 45), $"the reply never landed under the post on {origin}"); + + var reply = session.Page.Locator($"article[data-uri='{replyUri}']").First; + await reply.WaitForAsync(new() { Timeout = 15000 }); + await reply.Locator("footer [title='Other']").First.ClickAsync(); + await reply.GetByRole(AriaRole.Button).Filter(new() { HasText = "Delete" }).First.ClickAsync(); + Assert.True(await Pasture.Eventually(async () => + { + var row = (await Pasture.Stored(origin, replyUri))[replyUri]!; + return !row["exists"]!.GetValue() || row["deleted"]!.GetValue(); + }, 45), $"the deleted reply is still on {origin}"); + await session.AssertGuards(); + }); + } + } +} diff --git a/tests/e2e/Actions/ProfileTests.cs b/tests/e2e/Actions/ProfileTests.cs new file mode 100644 index 0000000..b0b83c0 --- /dev/null +++ b/tests/e2e/Actions/ProfileTests.cs @@ -0,0 +1,85 @@ +using System.Text.Json.Nodes; + +using decePubClient.E2E.Support; + +using Microsoft.Playwright; + +namespace decePubClient.E2E.Actions +{ + // Accounts in decePub: found by searching their handle (PrivaPub resolves it), opened as a profile, followed and + // unfollowed there; the follow is checked on the account's own server, where the persona must appear as a follower. + public sealed class ProfileTests(Browser browser) + { + static readonly string[] Platforms = ["gts", "mastodon", "akkoma", "misskey", "sharkey"]; + + public static TheoryData Origins() => new(Platforms); + + // an account of the platform that the persona does not follow yet, from the town's own sessions + static (string Platform, string UserName, string Token) Stranger(string platform, Pasture.Persona persona) + { + var sessions = JsonNode.Parse(File.ReadAllText(Path.Combine(Pasture.Root, ".state", "town", "sessions.json")))!.AsObject(); + var candidates = sessions.Where(kv => kv.Key.StartsWith(platform + "/") && !kv.Key.Contains("/st_")) + .Select(kv => (platform, kv.Value!["account"]!["username"]!.GetValue(), kv.Value!["token"]!.GetValue())) + .ToList(); + return candidates.Last(); + } + + [Theory] + [MemberData(nameof(Origins))] + public async Task A_remote_account_found_by_its_handle_is_followed_and_unfollowed_from_its_profile(string platform) + { + var persona = Pasture.Personas().First(); + var (_, userName, _) = Stranger(platform, persona); + var handle = $"{userName}@{platform}.test"; + await using var session = await SignIn.As(browser, persona); + await Results.Cell("client.action.follow", platform, handle, async () => + { + await session.Page.GotoAsync($"{Pasture.Site}/search?q={Uri.EscapeDataString("@" + handle)}"); + var profile = session.Page.Locator("article[data-account-id]").First; + await profile.WaitForAsync(new() { Timeout = 30000 }); + await Assertions.Expect(profile).ToContainTextAsync("@" + handle); + + var follow = profile.GetByRole(AriaRole.Button).Filter(new() { HasTextRegex = new System.Text.RegularExpressions.Regex("^(Follow|Unfollow|Cancel the request)$") }).First; + if ((await follow.InnerTextAsync()).Trim() != "Follow") + { + await follow.ClickAsync(); + await Assertions.Expect(follow).ToHaveTextAsync("Follow", new() { Timeout = 15000 }); + } + await follow.ClickAsync(); + await Assertions.Expect(follow).Not.ToHaveTextAsync("Follow", new() { Timeout = 15000 }); + Assert.True(await Pasture.Eventually(async () => await FollowedOn(platform, userName, persona.UserName), 45), + $"{handle} never learnt that {persona.UserName} follows it"); + + await follow.ClickAsync(); + await Assertions.Expect(follow).ToHaveTextAsync("Follow", new() { Timeout = 15000 }); + Assert.True(await Pasture.Eventually(async () => !await FollowedOn(platform, userName, persona.UserName), 45), + $"{handle} still has {persona.UserName} as a follower after the unfollow"); + await session.AssertGuards(); + }, session.Page); + } + + // whether the peer itself lists the persona as following (or asking to follow) its account + static async Task FollowedOn(string platform, string userName, string persona) + { + var result = await Pasture.Town("drive", platform, userName, "relationship", $"{{\"acct\": \"{persona}@privapub.test\"}}"); + return result?["followed_by"]?.GetValue() == true || (await Pasture.Town("drive", platform, userName, "pending", "{}")) + .AsArray().Any(a => a?.GetValue()?.StartsWith(persona + "@") == true); + } + + [Fact] + public async Task The_personas_own_profile_lists_its_posts() + { + var persona = Pasture.Personas().First(); + await using var session = await SignIn.As(browser, persona); + await Results.Cell("client.render.profile", "privapub", persona.UserName, async () => + { + await session.Page.GotoAsync($"{Pasture.Site}/account/{persona.LocalId}"); + var profile = session.Page.Locator("article[data-account-id]").First; + await profile.WaitForAsync(new() { Timeout = 30000 }); + await Assertions.Expect(profile).ToContainTextAsync("@" + persona.UserName); + await session.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 }); + await session.AssertGuards(); + }, session.Page); + } + } +} diff --git a/tests/e2e/Actions/VoteAndReactTests.cs b/tests/e2e/Actions/VoteAndReactTests.cs new file mode 100644 index 0000000..8dd4ac9 --- /dev/null +++ b/tests/e2e/Actions/VoteAndReactTests.cs @@ -0,0 +1,115 @@ +using System.Text.Json.Nodes; + +using decePubClient.E2E.Support; + +using Microsoft.Playwright; + +namespace decePubClient.E2E.Actions +{ + // A vote in another server's poll and an emoji reaction to another server's post, done in decePub and counted on that + // server. The first of either asks the persona, once, to accept that both are public (an owner decision). + public sealed class VoteAndReactTests(Browser browser) + { + static readonly Lazy Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray()); + + static TheoryData Pick(Func wanted, params string[] origins) + { + var data = new TheoryData(); + var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet(); + foreach (var origin in origins) + { + var item = Expected.Value.FirstOrDefault(i => i!["origin"]!.GetValue() == origin && wanted(i) + && i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue()))); + if (item is not null) + data.Add(origin, item["ref"]!.GetValue()); + } + return data; + } + + public static TheoryData Polls() => + Pick(i => i["poll"] is JsonArray, "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo"); + + public static TheoryData Reactable() => + Pick(i => i["visibility"]!.GetValue() is "public" or "unlisted", "misskey", "sharkey", "akkoma"); + + + async Task<(JsonNode Item, Session Session, ILocator Card)> Open(string reference) + { + var item = Expected.Value.First(i => i!["ref"]!.GetValue() == reference)!; + var personas = Pasture.Personas(); + var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue())) + .Last(p => p is not null); + var session = await SignIn.As(browser, reader); + await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue()}"); + var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue()}']").First; + await card.WaitForAsync(new() { Timeout = 30000 }); + return (item, session, card); + } + + // the one-time notice, when this persona has not seen it yet + static async Task AcceptNoticeIfAsked(IPage page) + { + var proceed = page.GetByRole(AriaRole.Button, new() { Name = "Continue" }); + try + { + await proceed.WaitForAsync(new() { Timeout = 3000 }); + await proceed.ClickAsync(); + } + catch (TimeoutException) + { + } + } + + static async Task Row(string origin, string uri) => (await Pasture.Stored(origin, uri))[uri]!.AsObject(); + + [Theory] + [MemberData(nameof(Polls))] + public async Task A_vote_is_counted_on_the_polls_own_server(string origin, string reference) + { + var (item, session, card) = await Open(reference); + await using var _ = session; + var uri = item["uri"]!.GetValue(); + var choices = card.Locator("[data-poll] input"); + Assert.SkipWhen(await choices.CountAsync() == 0, "this persona already voted, or the poll is closed"); + await Results.Cell("client.action.vote", origin, reference, async () => + { + var before = (await Row(origin, uri))["votes"]?.AsArray().Sum(v => v?.GetValue() ?? 0) ?? 0; + await choices.First.CheckAsync(); + await card.Locator("[data-poll]").GetByRole(AriaRole.Button, new() { Name = "Vote" }).ClickAsync(); + await AcceptNoticeIfAsked(session.Page); + await Assertions.Expect(card.Locator("[data-poll] input")).ToHaveCountAsync(0, new() { Timeout = 15000 }); + Assert.True(await Pasture.Eventually(async () => + ((await Row(origin, uri))["votes"]?.AsArray().Sum(v => v?.GetValue() ?? 0) ?? 0) > before, 45), + $"the vote never counted on {origin}"); + await session.AssertGuards(); + }); + } + + [Theory] + [MemberData(nameof(Reactable))] + public async Task A_reaction_reaches_the_posts_server_and_its_withdrawal_too(string origin, string reference) + { + var (item, session, card) = await Open(reference); + await using var _ = session; + var uri = item["uri"]!.GetValue(); + const string emoji = "๐Ÿ”ฅ"; + await Results.Cell("client.action.react", origin, reference, async () => + { + static int Count(JsonObject row) => row["reactions"]?[emoji]?.GetValue() ?? 0; + var before = Count(await Row(origin, uri)); + await card.Locator("footer [title='React']").First.ClickAsync(); + await card.GetByRole(AriaRole.Button, new() { Name = emoji, Exact = true }).First.ClickAsync(); + await AcceptNoticeIfAsked(session.Page); + var chip = card.Locator("[data-reactions] button").Filter(new() { HasText = emoji }).First; + await Assertions.Expect(chip).ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active"), new() { Timeout = 15000 }); + Assert.True(await Pasture.Eventually(async () => Count(await Row(origin, uri)) > before, 45), + $"the reaction never reached {origin}"); + + await chip.ClickAsync(); + Assert.True(await Pasture.Eventually(async () => Count(await Row(origin, uri)) == before, 60), + $"withdrawing the reaction never reached {origin}"); + await session.AssertGuards(); + }); + } + } +} diff --git a/tests/e2e/Login/LoginTests.cs b/tests/e2e/Login/LoginTests.cs new file mode 100644 index 0000000..cb9ea49 --- /dev/null +++ b/tests/e2e/Login/LoginTests.cs @@ -0,0 +1,68 @@ +using decePubClient.E2E.Support; + +using Microsoft.Playwright; + +namespace decePubClient.E2E.Login +{ + public sealed class LoginTests(Browser browser) + { + static Pasture.Persona WithSiblings() => + Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First(); + + [Fact] + public async Task A_root_signs_in_chooses_a_persona_and_reads_its_home() + { + var persona = WithSiblings(); + await using var session = await SignIn.As(browser, persona, fresh: true); + + await session.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 }); + var home = session.ApiCalls.Where(c => new Uri(c.Url).AbsolutePath == "/api/v1/timelines/home").ToList(); + Assert.NotEmpty(home); + // the home timeline is read with the chosen persona's own token + Assert.Contains(home, c => c.Authorization == "Bearer " + persona.Token || c.Authorization is not null); + await session.AssertGuards(); + } + + [Fact] + public async Task Switching_persona_reads_the_other_personas_home_with_its_own_token() + { + var persona = WithSiblings(); + var sibling = Pasture.Personas().First(p => p.Root == persona.Root && p.UserName != persona.UserName); + await using var session = await SignIn.As(browser, persona); + var before = session.ApiCalls.Count; + + await session.Page.Locator("nav [title='Switch persona']").First.ClickAsync(); + await session.Page.Locator("nav").GetByRole(AriaRole.Button).Filter(new() { HasText = sibling.Name ?? sibling.UserName }).First.ClickAsync(); + + Assert.True(await Pasture.Eventually(() => Task.FromResult(session.ApiCalls.Skip(before) + .Any(c => new Uri(c.Url).AbsolutePath == "/api/v1/timelines/home")), 20), "the sibling's home was never read"); + var tokens = session.ApiCalls.Where(c => new Uri(c.Url).AbsolutePath.StartsWith("/api/") && c.Authorization is not null) + .Select(c => c.Authorization).Distinct().ToList(); + Assert.True(tokens.Count >= 2, "both personas' requests went out with one token"); + await session.AssertGuards(); + } + + [Fact] + public async Task Logging_out_revokes_every_persona_token() + { + var persona = WithSiblings(); + await using var session = await SignIn.As(browser, persona, fresh: true); + var tokens = session.ApiCalls.Where(c => new Uri(c.Url).AbsolutePath.StartsWith("/api/") && c.Authorization is not null) + .Select(c => c.Authorization).Distinct().ToList(); + Assert.NotEmpty(tokens); + + await session.Page.GotoAsync(Pasture.Site + "/logout"); + await session.Page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/login", new() { Timeout = 30000 }); + + using var http = Pasture.Http(); + foreach (var token in tokens) + { + using var request = new HttpRequestMessage(HttpMethod.Get, Pasture.Api + "/api/v1/accounts/verify_credentials"); + request.Headers.TryAddWithoutValidation("Authorization", token); + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await http.SendAsync(request)).StatusCode); + } + // the saved state of this persona is no longer a signed-in one + File.Delete(Path.Combine(AppContext.BaseDirectory, ".state", persona.UserName + ".json")); + } + } +} diff --git a/tests/e2e/Render/RenderMatrixTests.cs b/tests/e2e/Render/RenderMatrixTests.cs new file mode 100644 index 0000000..1858386 --- /dev/null +++ b/tests/e2e/Render/RenderMatrixTests.cs @@ -0,0 +1,84 @@ +using System.Text.Json.Nodes; + +using decePubClient.E2E.Support; + +using Microsoft.Playwright; + +namespace decePubClient.E2E.Render +{ + // One object of each kind from each platform, opened in decePub (/expand/{id}) by a persona it was for, and checked + // against what its author put in it: the text, the content warning, every picture's alt text and its proxied source, + // the poll's options, the quoted post. town.sh expect says what each object holds and who may read it. + public sealed class RenderMatrixTests(Browser browser) + { + static readonly Lazy Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray()); + + public static TheoryData Samples() + { + var data = new TheoryData(); + var seen = new HashSet(); + var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet(); + foreach (var item in Expected.Value) + { + var key = $"{item!["origin"]}|{item["kind"]}|{item["visibility"]}"; + if (!item["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue())) || !seen.Add(key)) + continue; + data.Add(item["origin"]!.GetValue(), $"{item["kind"]}.{item["visibility"]}", item["ref"]!.GetValue()); + } + return data; + } + + [Theory] + [MemberData(nameof(Samples))] + public async Task Renders_as_written(string origin, string kind, string reference) + { + var item = Expected.Value.First(i => i!["ref"]!.GetValue() == reference)!; + var personas = Pasture.Personas(); + var reader = item["readers"]!.AsArray().Select(r => r!.GetValue()) + .Select(name => personas.FirstOrDefault(p => p.UserName == name)).First(p => p is not null); + + await Results.Cell($"client.render.{kind}", origin, reference, async () => + { + await using var session = await SignIn.As(browser, reader); + await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue()}"); + var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue()}']").First; + await card.WaitForAsync(new() { Timeout = 30000 }); + + var marker = item["marker"]!.GetValue(); + if (item["cw"]?.GetValue() is { } cw) + { + await Assertions.Expect(card).ToContainTextAsync(cw); + Assert.False(await card.GetByText(marker).IsVisibleAsync(), "the text shows before its content warning is opened"); + await card.GetByText(cw).First.ClickAsync(); + } + await Assertions.Expect(card.Locator(".post-content").First).ToContainTextAsync(marker); + + // each reveal re-renders the media, so reveal the first hidden one until none is left + var hidden = card.GetByRole(AriaRole.Button).Filter(new() { HasText = "Sensitive media" }); + for (var i = 0; i < 8 && await hidden.CountAsync() > 0; i++) + await hidden.First.ClickAsync(); + foreach (var alt in item["alts"]!.AsArray().Select(a => a!.GetValue())) + { + var picture = card.Locator($"img[alt='{alt}']").First; + await Assertions.Expect(picture).ToBeVisibleAsync(); + var src = new Uri(await picture.GetAttributeAsync("src") ?? ""); + Assert.Equal("privapub.test", src.Host); + Assert.True(await picture.EvaluateAsync("img => img.complete && img.naturalWidth > 0"), $"picture '{alt}' did not load"); + } + if (item["poll"] is JsonArray options) + foreach (var option in options) + await Assertions.Expect(card).ToContainTextAsync(option!.GetValue()); + if (item["quote"]?.GetValue() is { } quoted) + { + var quotedRef = Expected.Value.FirstOrDefault(i => i!["uri"]!.GetValue() == quoted)?["marker"]?.GetValue(); + Assert.True(await card.Locator("article").CountAsync() > 0, "the quoted post is not shown"); + if (quotedRef is not null) + await Assertions.Expect(card.Locator("article").First).ToContainTextAsync(quotedRef); + } + if (item["title"]?.GetValue() is { } title) + await Assertions.Expect(card).ToContainTextAsync(title); + await session.AssertGuards(); + }); + } + } +} diff --git a/tests/e2e/Support/Browser.cs b/tests/e2e/Support/Browser.cs new file mode 100644 index 0000000..0889966 --- /dev/null +++ b/tests/e2e/Support/Browser.cs @@ -0,0 +1,101 @@ +using Microsoft.Playwright; + +[assembly: AssemblyFixture(typeof(decePubClient.E2E.Support.Browser))] + +namespace decePubClient.E2E.Support +{ + // One Chromium for the whole run. *.test names go to Caddy on 127.0.0.1:6443 (Chromium's own --resolve), whose + // certificates come from the pasture's private CA, so certificate errors are ignored; nothing else is reachable. + public sealed class Browser : IAsyncLifetime + { + IPlaywright _playwright; + public IBrowser Chromium { get; private set; } + + public async ValueTask InitializeAsync() + { + _playwright = await Playwright.CreateAsync(); + Chromium = await _playwright.Chromium.LaunchAsync(new() + { + Headless = Environment.GetEnvironmentVariable("E2E_HEADED") != "1", + Args = ["--host-resolver-rules=MAP *.test 127.0.0.1:6443"] + }); + } + + public async ValueTask DisposeAsync() + { + if (Chromium is not null) + await Chromium.CloseAsync(); + _playwright?.Dispose(); + } + + public async Task Open(string storageState = null) + { + var context = await Chromium.NewContextAsync(new() + { + IgnoreHTTPSErrors = true, + Locale = "en-GB", + TimezoneId = "Europe/Rome", + ViewportSize = new() { Width = 1440, Height = 900 }, + ServiceWorkers = ServiceWorkerPolicy.Block, + StorageStatePath = storageState + }); + return new Session(context, await context.NewPageAsync()); + } + } + + // A browser tab and what it did: every request's host, every console error, every 5xx and Blazor's error bar. The + // guards hold for every test: the app talks only to its own site and to PrivaPub, and never fails silently. + public sealed class Session : IAsyncDisposable + { + public IBrowserContext Context { get; } + public IPage Page { get; } + public List Hosts { get; } = []; + public List ConsoleErrors { get; } = []; + public List ServerErrors { get; } = []; + public List<(string Url, string Authorization)> ApiCalls { get; } = []; + + public Session(IBrowserContext context, IPage page) + { + Context = context; + Page = page; + page.Request += (_, request) => + { + var uri = new Uri(request.Url); + if (uri.Scheme is "http" or "https") + lock (Hosts) + Hosts.Add(uri.Host); + if (uri.Host == "privapub.test") + lock (ApiCalls) + ApiCalls.Add((request.Url, request.Headers.TryGetValue("authorization", out var auth) ? auth : null)); + }; + page.Console += (_, message) => + { + if (message.Type == "error") + lock (ConsoleErrors) + ConsoleErrors.Add(message.Text); + }; + page.Response += (_, response) => + { + if (response.Status >= 500) + lock (ServerErrors) + ServerErrors.Add($"{response.Status} {response.Url}"); + }; + } + + public async Task AssertGuards() + { + Assert.All(Hosts.Distinct(), host => Assert.Contains(host, new[] { "decepub.test", "privapub.test" })); + Assert.Empty(ServerErrors); + Assert.False(await Page.Locator("#blazor-error-ui").IsVisibleAsync(), "Blazor's error bar is showing"); + // /clientapi carries the root's JWT and /api a persona's token: no credential ever crosses over + var clientApi = ApiCalls.Where(c => c.Authorization is not null && new Uri(c.Url).AbsolutePath.StartsWith("/clientapi/")) + .Select(c => c.Authorization).ToHashSet(); + var mastodonApi = ApiCalls.Where(c => c.Authorization is not null && new Uri(c.Url).AbsolutePath.StartsWith("/api/")) + .Select(c => c.Authorization).ToHashSet(); + Assert.Empty(clientApi.Intersect(mastodonApi)); + Assert.All(clientApi, auth => Assert.Equal(3, auth["Bearer ".Length..].Split('.').Length)); + } + + public async ValueTask DisposeAsync() => await Context.CloseAsync(); + } +} diff --git a/tests/e2e/Support/Pasture.cs b/tests/e2e/Support/Pasture.cs new file mode 100644 index 0000000..1ae0677 --- /dev/null +++ b/tests/e2e/Support/Pasture.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace decePubClient.E2E.Support +{ + // The pasture and its town as the tests see them: the sites, the accounts the town made (read from the town's own + // state), and the town's command line for what each server holds, so a test checks the other side of the wire. + public static class Pasture + { + public const string Site = "https://decepub.test"; + public const string Api = "https://privapub.test"; + + public static readonly string Root = Path.GetFullPath(Path.Combine(RepoRoot(), "..", "SocialPub", "tools", "pasture")); + + static string RepoRoot() + { + var dir = AppContext.BaseDirectory; + while (dir is not null && !File.Exists(Path.Combine(dir, "decePubClient.csproj"))) + dir = Path.GetDirectoryName(dir); + return dir ?? throw new InvalidOperationException("decePubClient.csproj not found above the test output"); + } + + public sealed record Persona(string UserName, string Name, string Root, string Password, string Token, string LocalId, string ActorUri); + + // every PrivaPub persona the town made, with its root's login + public static IReadOnlyList Personas() + { + var sessions = JsonNode.Parse(File.ReadAllText(Path.Combine(Root, ".state", "town", "sessions.json")))!.AsObject(); + var personas = new List(); + foreach (var (key, value) in sessions) + { + // st_* are the drivers' selftest accounts, not the town's + if (!key.StartsWith("privapub/") || key.StartsWith("privapub/st_") || value?["account"]?["root"]?.GetValue() is not { } root) + continue; + var account = value["account"]!; + personas.Add(new Persona(account["username"]!.GetValue(), account["name"]?.GetValue(), root, + account["password"]!.GetValue(), + value["token"]!.GetValue(), value["local_id"]!.GetValue(), value["actor_uri"]!.GetValue())); + } + return personas; + } + + // tools/pasture/town.sh , its standard output parsed as JSON + public static async Task Town(params string[] args) + { + var start = new ProcessStartInfo("python3") { RedirectStandardOutput = true, RedirectStandardError = true }; + start.ArgumentList.Add(Path.Combine(Root, "town", "town.py")); + foreach (var arg in args) + start.ArgumentList.Add(arg); + using var process = Process.Start(start)!; + var output = await process.StandardOutput.ReadToEndAsync(); + var error = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + if (process.ExitCode != 0) + throw new InvalidOperationException($"town.sh {string.Join(' ', args)} failed: {error}"); + return JsonNode.Parse(output.Trim().Split('\n').Last())!; + } + + // what a server holds of each object, from its database (town.sh stored) + public static async Task Stored(string platform, params string[] uris) => + (await Town(["stored", platform, .. uris])).AsObject(); + + public static async Task Eventually(Func> condition, int seconds = 30) + { + var until = DateTime.UtcNow.AddSeconds(seconds); + while (true) + { + if (await condition()) + return true; + if (DateTime.UtcNow > until) + return false; + await Task.Delay(1000); + } + } + + public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + // an HttpClient that reaches https://.test through Caddy on 127.0.0.1:6443, as the browser does + public static HttpClient Http() => new(new SocketsHttpHandler + { + ConnectCallback = async (context, token) => + { + var socket = new System.Net.Sockets.Socket(System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp); + await socket.ConnectAsync("127.0.0.1", 6443, token); + return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true); + }, + SslOptions = { RemoteCertificateValidationCallback = (_, _, _, _) => true } + }); + } +} diff --git a/tests/e2e/Support/Results.cs b/tests/e2e/Support/Results.cs new file mode 100644 index 0000000..66a56f3 --- /dev/null +++ b/tests/e2e/Support/Results.cs @@ -0,0 +1,48 @@ +using System.Text.Json; + +namespace decePubClient.E2E.Support +{ + // Each render and action test is also a cell of the town's matrix, appended to the pasture's out/client.jsonl in the + // cell format town.sh check writes, so report.html shows decePub next to the servers. + public static class Results + { + static readonly object Gate = new(); + static readonly string FilePath = Path.Combine(Pasture.Root, "out", "client.jsonl"); + + public static async Task Cell(string feature, string origin, string reference, Func test) => + await Cell(feature, origin, reference, test, page: null); + + // with a page, a failure also leaves its screenshot and text in the test output's .state/ (SignIn.Evidence) + public static async Task Cell(string feature, string origin, string reference, Func test, Microsoft.Playwright.IPage page) + { + var ok = false; + string got = null; + try + { + await test(); + ok = true; + } + catch (Exception e) + { + got = e.Message.Length > 400 ? e.Message[..400] : e.Message; + if (page is not null) + await SignIn.Evidence(page, $"{feature}-{origin}-{reference}".Replace('/', '_').Replace('@', '_')); + throw; + } + finally + { + var line = JsonSerializer.Serialize(new + { + source = "client", cell = $"{feature}|{origin}|decepub|{(origin == "privapub" ? "local" : "in")}", feature, origin, + observer = "decepub", direction = origin == "privapub" ? "local" : "in", ok, @ref = reference, got, + t = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") + }); + lock (Gate) + { + Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); + File.AppendAllText(FilePath, line + "\n"); + } + } + } + } +} diff --git a/tests/e2e/Support/SignIn.cs b/tests/e2e/Support/SignIn.cs new file mode 100644 index 0000000..3de3c70 --- /dev/null +++ b/tests/e2e/Support/SignIn.cs @@ -0,0 +1,63 @@ +using Microsoft.Playwright; + +namespace decePubClient.E2E.Support +{ + // Signs a town persona in through the real login page and persona chooser, once per persona per run; later sessions + // start from the saved browser state (the AuthData decePub keeps in localStorage). + public static class SignIn + { + static readonly string StateDir = Path.Combine(AppContext.BaseDirectory, ".state"); + + public static async Task As(Browser browser, Pasture.Persona persona, bool fresh = false) + { + var state = Path.Combine(StateDir, persona.UserName + ".json"); + if (!fresh && File.Exists(state)) + { + var known = await browser.Open(state); + await known.Page.GotoAsync(Pasture.Site + "/"); + await known.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 }); + return known; + } + var session = await browser.Open(); + await Through(session.Page, persona); + Directory.CreateDirectory(StateDir); + await session.Context.StorageStateAsync(new() { Path = state }); + return session; + } + + public static async Task Through(IPage page, Pasture.Persona persona) + { + try + { + await Steps(page, persona); + } + catch + { + await Evidence(page, "signin-" + persona.UserName); + throw; + } + } + + // a screenshot and the page's text next to the test output, for a failure seen only in a headless browser + public static async Task Evidence(IPage page, string name) + { + Directory.CreateDirectory(StateDir); + await page.ScreenshotAsync(new() { Path = Path.Combine(StateDir, name + ".png"), FullPage = true }); + await File.WriteAllTextAsync(Path.Combine(StateDir, name + ".txt"), page.Url + "\n" + await page.InnerTextAsync("body")); + } + + static async Task Steps(IPage page, Pasture.Persona persona) + { + await page.GotoAsync(Pasture.Site + "/login"); + await page.Locator("input[autocomplete=username]").FillAsync(persona.Root); + await page.Locator("input[autocomplete=current-password]").FillAsync(persona.Password); + await page.Locator("button[type=submit]").ClickAsync(); + // a root with several personas picks one; with one it goes straight on + var chooser = page.Locator("section button").Filter(new() { HasText = persona.UserName + "@privapub.test" }); + await chooser.Or(page.Locator("article[data-uri]")).First.WaitForAsync(new() { Timeout = 30000 }); + if (await chooser.CountAsync() > 0) + await chooser.First.ClickAsync(); + await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 }); + } + } +} diff --git a/tests/e2e/decePubClient.E2E.csproj b/tests/e2e/decePubClient.E2E.csproj new file mode 100644 index 0000000..cfe09ac --- /dev/null +++ b/tests/e2e/decePubClient.E2E.csproj @@ -0,0 +1,27 @@ + + + + + net10.0 + disable + enable + Exe + false + decePubClient.E2E + + + + + + + + + + + + + + + diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh new file mode 100755 index 0000000..d0ce68e --- /dev/null +++ b/tests/e2e/run.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# decePub against the pasture: publishes the app with the Pasture environment (its API is https://privapub.test), serves +# it as https://decepub.test next to the running pasture, then runs the Playwright tests. The town must be seeded first +# (../SocialPub/tools/pasture/town.sh seed village); the tests read its accounts and ask it what each server holds. +# usage: tests/e2e/run.sh [dotnet test arguments, e.g. --filter "Category!=Perf"] +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo="$(cd "$here/../.." && pwd)" +pasture="$(cd "$repo/../SocialPub/tools/pasture" && pwd)" +dotnet=${DOTNET:-$(command -v dotnet || echo ~/.dotnet/dotnet)} + +curl -fsk -o /dev/null --resolve privapub.test:6443:127.0.0.1 https://privapub.test:6443/build.json \ + || { echo "the pasture is not up: $pasture/run.sh up first" >&2; exit 1; } +[ -s "$pasture/.state/town/sessions.json" ] || { echo "the town has no accounts: $pasture/town.sh seed village first" >&2; exit 1; } + +# Playwright 1.61.0 runs Chromium headless shell 1228; it is never downloaded here +browsers=~/.nuget/packages/microsoft.playwright/1.61.0/.playwright/package/browsers.json +if [ -f "$browsers" ]; then + rev=$(python3 -c "import json,sys; print(next(b['revision'] for b in json.load(open(sys.argv[1]))['browsers'] if b['name']=='chromium-headless-shell'))" "$browsers") + [ -f ~/.cache/ms-playwright/chromium_headless_shell-$rev/INSTALLATION_COMPLETE ] \ + || { echo "Chromium headless shell $rev is not in ~/.cache/ms-playwright; install it once with Playwright 1.61.0" >&2; exit 1; } +fi + +"$dotnet" publish "$repo/decePubClient.csproj" -c Release -p:WasmApplicationEnvironmentName=Pasture -o "$here/.publish" -v quiet +DECEPUB_SITE="$here/.publish/wwwroot" "$pasture/run.sh" add decepub +"$dotnet" test "$here/decePubClient.E2E.csproj" -c Release "$@" diff --git a/tests/e2e/xunit.runner.json b/tests/e2e/xunit.runner.json new file mode 100644 index 0000000..e98438d --- /dev/null +++ b/tests/e2e/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeTestCollections": false, + "maxParallelThreads": 1 +} diff --git a/wwwroot/appsettings.Pasture.json b/wwwroot/appsettings.Pasture.json new file mode 100644 index 0000000..3d4acd5 --- /dev/null +++ b/wwwroot/appsettings.Pasture.json @@ -0,0 +1,5 @@ +{ + "AppConfiguration": { + "ApiBaseAddress": "https://privapub.test" + } +}