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" + } +}