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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 01:23:18 +02:00
1 parent 0f68e74e38
commit 011bafb6af
39 files changed
+2073 -16

No files matched your search

+3
View File
@@ -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
+4
View File
@@ -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/
+35 -2
View File
@@ -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`),
+1
View File
@@ -22,6 +22,7 @@ namespace decePubClient;
[JsonSerializable(typeof(PageSettings))]
[JsonSerializable(typeof(AuthData))]
[JsonSerializable(typeof(FeedFilter))]
[JsonSerializable(typeof(PublicActionNotices))]
[JsonSerializable(typeof(ServerInfo))]
[JsonSerializable(typeof(List<ServerInfo>))]
[JsonSerializable(typeof(UploadMedia))]
+160 -11
View File
@@ -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. *@
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
data-post-id="@(Compact ? null : Post.Key)" data-lat="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Latitude))"
data-lng="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Longitude))"
data-src="@(Compact ? null : Post.Location?.Source.ToString().ToLowerInvariant())" data-cdn="@(Compact ? null : Post.Location?.CdnKey)"
data-label="@(Compact ? null : Post.Location?.Label)" data-host="@(Compact ? null : Post.Host)"
data-range="@(Post.Located && !Compact ? Coordinate(Post.RangeKm) : null)" data-post-at="@(Compact ? null : Post.WrittenAt?.ToString("O"))">
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)
{
<div class="flex flex-col py-3 pl-3 md:py-4 md:pl-4 flex-none rounded-l-xl bg-cover bg-no-repeat bg-center"
style="@HeaderBackground(author)">
<a class="block h-12 w-12 md:h-14 md:w-14" href="@author.Url" target="_blank" rel="noopener noreferrer" title="@author.Acct">
<a class="block h-12 w-12 md:h-14 md:w-14" href="@($"account/{Uri.EscapeDataString(author.Id)}")" title="@author.Acct">
@* alt="": the author's name is next to it. *@
<img alt="" loading="lazy" class="h-12 w-12 md:h-14 md:w-14 object-cover rounded-full neo-raised neo-2xs bg-base-100"
src="@(author.Avatar ?? "imgs/icon-192.png")" />
@@ -153,6 +155,17 @@
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" title="@Localizer["Bookmark"]">
<i aria-hidden="true" class="ion-md-bookmark text-lg @(status.Bookmarked ? "text-warning" : null)"></i>
</DButton>
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Top" CloseOnContentClick="true"
TriggerClass="@(isReactionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" ContentClass="mb-2 flex gap-1 p-2 rounded-box"
title="@Localizer["React"]">
<Trigger><i aria-hidden="true" class="ion-md-happy text-lg"></i></Trigger>
<ChildContent>
@foreach (var emoji in QuickReactions)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => React(emoji, true)">@emoji</DButton>
}
</ChildContent>
</DDropdown>
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
</NerdTip>
@@ -211,8 +224,24 @@
else if (Post.Notification?.Account is { } follower)
{
<p class="text-sm">
<b>@follower.Name</b> <span class="opacity-60">@("@" + follower.Acct)</span>
<a class="font-bold" href="@($"account/{Uri.EscapeDataString(follower.Id)}")">@follower.Name</a>
<span class="opacity-60">@("@" + follower.Acct)</span>
</p>
@if (Post.Notification.Type == "follow_request" && requestAnswer is null)
{
<div class="flex gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => AnswerRequest(follower, true)">
<i class="ion-md-checkmark"></i><span>@Localizer["Accept"]</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => AnswerRequest(follower, false)">
<i class="ion-md-close"></i><span>@Localizer["Refuse"]</span>
</DButton>
</div>
}
else if (requestAnswer is { } accepted)
{
<p class="text-xs opacity-70">@(accepted ? Localizer["Accepted."] : Localizer["Refused."])</p>
}
}
</div>
@@ -295,14 +324,43 @@
@if (status.Poll is { } poll)
{
<div class="flex flex-col gap-1 text-xs">
@foreach (var option in poll.Options)
<div class="flex flex-col gap-1 text-xs" data-poll>
@if (CanVote(poll))
{
var share = poll.VotesCount > 0 && option.VotesCount is { } votes ? votes * 100d / poll.VotesCount : 0;
<div class="relative rounded-md overflow-hidden neo-inset neo-2xs px-2 py-1">
<div class="absolute inset-y-0 left-0 bg-primary/20" style="width:@(share.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture))%"></div>
<span class="relative flex justify-between gap-2"><span>@option.Title</span><span>@share.ToString("0")%</span></span>
</div>
@for (var i = 0; i < poll.Options.Count; i++)
{
var index = i;
<label class="flex items-center gap-2 rounded-md neo-inset neo-2xs px-2 py-1 cursor-pointer">
<input type="@(poll.Multiple ? "checkbox" : "radio")" class="@(poll.Multiple ? "checkbox checkbox-xs" : "radio radio-xs")"
name="@($"poll-{poll.Id}")" checked="@pollChoices.Contains(index)" @onchange="() => ToggleChoice(poll, index)" />
<span>@poll.Options[index].Title</span>
</label>
}
<DButton Size="DSize.Xs" Color="DColor.Primary" class="self-start" Disabled="@(IsDefaultDisabled || isActing || pollChoices.Count == 0)"
OnClick="() => Vote(poll)">
@Localizer["Vote"]
</DButton>
}
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;
<div class="relative rounded-md overflow-hidden neo-inset neo-2xs px-2 py-1">
<div class="absolute inset-y-0 left-0 bg-primary/20" style="width:@(share.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture))%"></div>
<span class="relative flex justify-between gap-2">
<span>
@option.Title
@if (poll.OwnVotes.Contains(i))
{
<i class="ion-md-checkmark-circle text-primary" title="@Localizer["Your vote"]" aria-label="@Localizer["Your vote"]"></i>
}
</span>
<span>@share.ToString("0")%</span>
</span>
</div>
}
}
<span class="opacity-60">@Localizer["{0} votes", poll.VotesCount] · @(poll.Expired ? Localizer["closed"] : Localizer["open"])</span>
</div>
@@ -324,6 +382,27 @@
<Content Post="FeedPost.Of(quoted)" Compact="true" IncludeExpand="false" CssContainer="neo-inset! shadow-none" />
}
@if (status.EmojiReactions.Count > 0)
{
<div class="flex flex-wrap gap-1" data-reactions>
@foreach (var reaction in status.EmojiReactions)
{
<DButton Size="DSize.Xs" Active="reaction.Me" Disabled="@(IsDefaultDisabled || isActing || Compact || Post.Located)"
OnClick="() => React(reaction.Name, !reaction.Me)" title="@reaction.Name">
@if (reaction.Url is { Length: > 0 } image)
{
<img alt="@reaction.Name" class="h-4 w-4 object-contain" src="@image" />
}
else
{
<span>@reaction.Name</span>
}
<span class="tabular-nums">@reaction.Count</span>
</DButton>
}
</div>
}
@if (status.Tags.Count > 0)
{
<div class="flex flex-wrap gap-1">
@@ -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<int> 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;
+34
View File
@@ -0,0 +1,34 @@
@inherits LocalizableComponentBase
@implements IDisposable
@* The one-time notice before a persona's first reaction or poll vote (PublicNoticeService), in MainLayout. *@
<DModal IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]">
<Heading>@Localizer["Reactions and votes are public"]</Heading>
<ChildContent>
<p class="text-sm">@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 ?? ""]</p>
</ChildContent>
<Actions>
<DButton Size="DSize.Sm" OnClick="() => Notices.Answer(false)">@Localizer["Cancel"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="() => Notices.Answer(true)">@Localizer["Continue"]</DButton>
</Actions>
</DModal>
@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;
}
+4
View File
@@ -17,6 +17,8 @@ namespace decePubClient;
[JsonSerializable(typeof(Account))]
[JsonSerializable(typeof(List<Account>))]
[JsonSerializable(typeof(Relationship))]
[JsonSerializable(typeof(List<Relationship>))]
[JsonSerializable(typeof(SearchResults))]
[JsonSerializable(typeof(MediaAttachment))]
[JsonSerializable(typeof(Notification))]
[JsonSerializable(typeof(List<Notification>))]
@@ -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
{
}
+8
View File
@@ -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
+11
View File
@@ -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; }
}
/// <summary>/api/v2/search: what matched, a remote handle or URL resolved by PrivaPub.</summary>
public class SearchResults
{
public List<Account> Accounts { get; set; } = [];
public List<Status> Statuses { get; set; } = [];
public List<StatusTag> Hashtags { get; set; } = [];
}
+21
View File
@@ -40,6 +40,8 @@ public class Status
public string Text { get; set; }
public PrivaPubStatus Privapub { get; set; }
public Quote Quote { get; set; }
/// <summary>Emoji reactions (PrivaPub sends Pleroma's and Misskey's forms as one list).</summary>
public List<EmojiReaction> 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; }
/// <summary>The options the current persona chose (indexes).</summary>
public List<int> OwnVotes { get; set; } = [];
public List<PollOption> Options { get; set; } = [];
}
public class EmojiReaction
{
/// <summary>The emoji itself, or a custom emoji's shortcode.</summary>
public string Name { get; set; }
public int Count { get; set; }
/// <summary>Whether the current persona reacted with it.</summary>
public bool Me { get; set; }
/// <summary>A custom emoji's image, through PrivaPub's proxy.</summary>
public string Url { get; set; }
}
/// <summary>POST /api/v1/polls/{id}/votes.</summary>
public class PollVoteForm
{
public List<int> Choices { get; set; } = [];
}
public class PollOption
{
public string Title { get; set; }
+10
View File
@@ -0,0 +1,10 @@
namespace decePubClient.Models;
/// <summary>
/// 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.
/// </summary>
public class PublicActionNotices
{
public List<string> PersonaIds { get; set; } = [];
}
+1 -1
View File
@@ -67,7 +67,7 @@
}
else if (posts.Count == 0)
{
<p class="w-full text-center text-lg">
<p class="w-full text-center text-lg" data-feed-empty>
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
}
+274
View File
@@ -0,0 +1,274 @@
@page "/account/{accountId}"
@attribute [Authorize]
@inherits LocalizableComponentBase
<Title>@(account?.Name ?? Localizer["Profile"])</Title>
@* 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. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
<div class="flex items-center gap-2">
<DButton Size="DSize.Sm" OnClick="Back" title="@Localizer["Back"]">
<i class="ion-md-arrow-back"></i>
</DButton>
<h1 class="font-bold truncate">@Localizer["Profile"]</h1>
</div>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (account is null)
{
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["This account doesn't exist."]
</p>
}
else
{
<article class="flex flex-none flex-col rounded-xl neo-raised neo-xs overflow-hidden" data-account-id="@account.Id">
<div class="h-28 md:h-40 bg-base-200 bg-cover bg-center" style="@HeaderStyle"></div>
<div class="flex flex-wrap items-end gap-3 px-4 -mt-10">
@* alt="": the name is next to it. *@
<img alt="" class="h-20 w-20 md:h-24 md:w-24 flex-none object-cover rounded-full neo-raised neo-2xs bg-base-100"
src="@(account.Avatar ?? "imgs/icon-192.png")" />
<div class="flex flex-col min-w-0 flex-1 pb-1">
<b class="text-lg truncate">@account.Name</b>
<span class="text-sm opacity-70 truncate">@("@" + FullAcct)</span>
</div>
@if (!IsOwn)
{
<div class="flex flex-wrap gap-2 pb-1">
@if (relationship?.FollowedBy == true)
{
<DBadge Size="DSize.Sm" class="self-center">@Localizer["Follows you"]</DBadge>
}
@if (relationship?.Blocking != true)
{
<DButton Size="DSize.Sm" Color="@(relationship?.Following == true || relationship?.Requested == true ? DColor.Neutral : DColor.Primary)"
Disabled="@(IsDefaultDisabled || isActing || relationship is null)" OnClick="ToggleFollow">
@FollowLabel
</DButton>
}
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing || relationship is null)"
OnClick="@(() => Do(relationship?.Muting == true ? AccountAction.Unmute : AccountAction.Mute))">
<i class="@(relationship?.Muting == true ? "ion-md-eye" : "ion-md-eye-off")"></i>
<span>@(relationship?.Muting == true ? Localizer["Unmute"] : Localizer["Mute"])</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing || relationship is null)"
OnClick="@(() => Do(relationship?.Blocking == true ? AccountAction.Unblock : AccountAction.Block))">
<i class="ion-md-remove-circle text-error"></i>
<span>@(relationship?.Blocking == true ? Localizer["Unblock"] : Localizer["Block"])</span>
</DButton>
</div>
}
</div>
<div class="flex flex-wrap gap-2 px-4 pt-2">
@if (account.Locked)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft"><i class="ion-md-lock"></i> @Localizer["Locked account"]</DBadge>
}
@if (account.Bot)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft"><i class="ion-md-cog"></i> @Localizer["Bot"]</DBadge>
}
@if (account.Group)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft"><i class="ion-md-people"></i> @Localizer["Groups"]</DBadge>
}
</div>
@if (!string.IsNullOrWhiteSpace(account.Note))
{
<div class="post-content px-4 pt-3 text-sm md:text-base break-words">@((MarkupString)account.Note)</div>
}
@if (account.Fields.Count > 0)
{
<dl class="grid grid-cols-[minmax(0,10rem)_minmax(0,1fr)] gap-x-3 gap-y-1 px-4 pt-3 text-sm">
@foreach (var field in account.Fields)
{
<dt class="font-semibold truncate">@field.Name</dt>
<dd class="post-content break-words min-w-0">@((MarkupString)field.Value)</dd>
}
</dl>
}
<div class="flex flex-wrap gap-4 px-4 py-3 text-sm tabular-nums">
<span><b>@account.StatusesCount</b> @Localizer["posts"]</span>
<span><b>@account.FollowingCount</b> @Localizer["following"]</span>
<span><b>@account.FollowersCount</b> @Localizer["followers"]</span>
</div>
</article>
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden">
<DTab><Header><span class="capitalize">@Localizer["posts"]</span></Header></DTab>
<DTab><Header>@Localizer["Posts and replies"]</Header></DTab>
<DTab><Header>@Localizer["Media"]</Header></DTab>
</DTabs>
<div class="flex flex-col gap-4">
@foreach (var post in posts)
{
<Content @key="post.Key" Post="post" OnDeleted="Remove" OnTag="OpenTag" />
}
@if (posts.Count == 0 && !isLoadingMore)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]</p>
}
@if (maxId is not null)
{
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="LoadMore">
@if (isLoadingMore)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Load more"]</span>
</DButton>
}
</div>
}
</div>
</section>
@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<FeedPost> 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<FeedFilter>(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");
}
+127
View File
@@ -0,0 +1,127 @@
@page "/search"
@attribute [Authorize]
@inherits LocalizableComponentBase
<Title>@Localizer["Search"]</Title>
@* 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. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
<EditForm Model="form" OnValidSubmit="Run" class="flex flex-wrap gap-2 items-center">
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="form.Query" autocomplete="off" type="search"
placeholder="@Localizer["Search accounts, posts and tags, or paste an address"]">
<Prefix><i class="ion-md-search"></i></Prefix>
</DInput>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSearching)">
@if (isSearching)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Search"]</span>
</DButton>
</EditForm>
@if (searched)
{
@if (results.Accounts.Count == 0 && results.Statuses.Count == 0 && results.Hashtags.Count == 0)
{
<p class="w-full text-center"><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing found."]</p>
}
@if (results.Accounts.Count > 0)
{
<h2 class="font-bold">@Localizer["Accounts"]</h2>
<div class="flex flex-col gap-2">
@foreach (var account in results.Accounts)
{
<a class="flex items-center gap-3 p-3 rounded-xl neo-raised neo-2xs min-w-0" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
@* alt="": the name is next to it. *@
<img alt="" class="h-10 w-10 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@account.Name</b>
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</span>
</a>
}
</div>
}
@if (results.Hashtags.Count > 0)
{
<h2 class="font-bold">@Localizer["Hashtags"]</h2>
<div class="flex flex-wrap gap-2">
@foreach (var tag in results.Hashtags)
{
<DButton Size="DSize.Sm" OnClick="@(() => OpenTag(tag.Name))">#@tag.Name</DButton>
}
</div>
}
@if (results.Statuses.Count > 0)
{
<h2 class="font-bold">@Localizer["Posts and replies"]</h2>
<div class="flex flex-col gap-4">
@foreach (var post in statuses)
{
<Content @key="post.Key" Post="post" OnTag="OpenTag" />
}
</div>
}
}
</div>
</section>
@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<FeedPost> 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<FeedFilter>(nameof(FeedFilter)) ?? new();
filter.Tag = tagName;
filter.TimelineType = TimelineType.Tag;
await LocalStorage.SetItemAsync(nameof(FeedFilter), filter);
Navigation.NavigateTo("");
}
}
+2
View File
@@ -41,6 +41,8 @@ builder.Services.AddOptions()
.AddScoped<AuthService>()
.AddScoped<TimelineService>()
.AddScoped<StatusService>()
.AddScoped<AccountService>()
.AddScoped<PublicNoticeService>()
.AddScoped<MediaService>()
.AddScoped<GroupService>()
.AddScoped<InstanceService>()
+102
View File
@@ -806,4 +806,106 @@
<data name="December solstice" xml:space="preserve">
<value>solstizio di dicembre</value>
</data>
<data name="Follow" xml:space="preserve">
<value>Segui</value>
</data>
<data name="Unfollow" xml:space="preserve">
<value>Smetti di seguire</value>
</data>
<data name="Cancel the request" xml:space="preserve">
<value>Annulla la richiesta</value>
</data>
<data name="Follows you" xml:space="preserve">
<value>Ti segue</value>
</data>
<data name="Unmute" xml:space="preserve">
<value>Riattiva</value>
</data>
<data name="Unblock" xml:space="preserve">
<value>Sblocca</value>
</data>
<data name="Posts and replies" xml:space="preserve">
<value>Post e risposte</value>
</data>
<data name="Locked account" xml:space="preserve">
<value>Account protetto</value>
</data>
<data name="Bot" xml:space="preserve">
<value>Bot</value>
</data>
<data name="This account doesn&#x27;t exist." xml:space="preserve">
<value>Questo account non esiste.</value>
</data>
<data name="Load more" xml:space="preserve">
<value>Carica altro</value>
</data>
<data name="Search" xml:space="preserve">
<value>Cerca</value>
</data>
<data name="Search accounts, posts and tags, or paste an address" xml:space="preserve">
<value>Cerca account, post e tag, o incolla un indirizzo</value>
</data>
<data name="Accounts" xml:space="preserve">
<value>Account</value>
</data>
<data name="Hashtags" xml:space="preserve">
<value>Hashtag</value>
</data>
<data name="Nothing found." xml:space="preserve">
<value>Nessun risultato.</value>
</data>
<data name="My profile" xml:space="preserve">
<value>Il mio profilo</value>
</data>
<data name="Asked {0} to accept you." xml:space="preserve">
<value>Hai chiesto a {0} di accettarti.</value>
</data>
<data name="Following {0}." xml:space="preserve">
<value>Ora segui {0}.</value>
</data>
<data name="{0} unfollowed." xml:space="preserve">
<value>Non segui più {0}.</value>
</data>
<data name="{0} unblocked." xml:space="preserve">
<value>{0} sbloccato.</value>
</data>
<data name="{0} unmuted." xml:space="preserve">
<value>{0} riattivato.</value>
</data>
<data name="Vote" xml:space="preserve">
<value>Vota</value>
</data>
<data name="Your vote" xml:space="preserve">
<value>Il tuo voto</value>
</data>
<data name="Voted." xml:space="preserve">
<value>Votato.</value>
</data>
<data name="Accept" xml:space="preserve">
<value>Accetta</value>
</data>
<data name="Refuse" xml:space="preserve">
<value>Rifiuta</value>
</data>
<data name="Accepted." xml:space="preserve">
<value>Accettata.</value>
</data>
<data name="Refused." xml:space="preserve">
<value>Rifiutata.</value>
</data>
<data name="Reactions and votes are public" xml:space="preserve">
<value>Reazioni e voti sono pubblici</value>
</data>
<data name="Anyone who can see the post can see that {0} reacted to it or voted in it. Your other personas are never named." xml:space="preserve">
<value>Chiunque possa vedere il post può vedere che {0} ha reagito o votato. Le tue altre persone non vengono mai nominate.</value>
</data>
<data name="Cancel" xml:space="preserve">
<value>Annulla</value>
</data>
<data name="Continue" xml:space="preserve">
<value>Continua</value>
</data>
<data name="React" xml:space="preserve">
<value>Reagisci</value>
</data>
</root>
+102
View File
@@ -806,4 +806,106 @@
<data name="December solstice" xml:space="preserve">
<value>December solstice</value>
</data>
<data name="Follow" xml:space="preserve">
<value>Follow</value>
</data>
<data name="Unfollow" xml:space="preserve">
<value>Unfollow</value>
</data>
<data name="Cancel the request" xml:space="preserve">
<value>Cancel the request</value>
</data>
<data name="Follows you" xml:space="preserve">
<value>Follows you</value>
</data>
<data name="Unmute" xml:space="preserve">
<value>Unmute</value>
</data>
<data name="Unblock" xml:space="preserve">
<value>Unblock</value>
</data>
<data name="Posts and replies" xml:space="preserve">
<value>Posts and replies</value>
</data>
<data name="Locked account" xml:space="preserve">
<value>Locked account</value>
</data>
<data name="Bot" xml:space="preserve">
<value>Bot</value>
</data>
<data name="This account doesn&#x27;t exist." xml:space="preserve">
<value>This account doesn't exist.</value>
</data>
<data name="Load more" xml:space="preserve">
<value>Load more</value>
</data>
<data name="Search" xml:space="preserve">
<value>Search</value>
</data>
<data name="Search accounts, posts and tags, or paste an address" xml:space="preserve">
<value>Search accounts, posts and tags, or paste an address</value>
</data>
<data name="Accounts" xml:space="preserve">
<value>Accounts</value>
</data>
<data name="Hashtags" xml:space="preserve">
<value>Hashtags</value>
</data>
<data name="Nothing found." xml:space="preserve">
<value>Nothing found.</value>
</data>
<data name="My profile" xml:space="preserve">
<value>My profile</value>
</data>
<data name="Asked {0} to accept you." xml:space="preserve">
<value>Asked {0} to accept you.</value>
</data>
<data name="Following {0}." xml:space="preserve">
<value>Following {0}.</value>
</data>
<data name="{0} unfollowed." xml:space="preserve">
<value>{0} unfollowed.</value>
</data>
<data name="{0} unblocked." xml:space="preserve">
<value>{0} unblocked.</value>
</data>
<data name="{0} unmuted." xml:space="preserve">
<value>{0} unmuted.</value>
</data>
<data name="Vote" xml:space="preserve">
<value>Vote</value>
</data>
<data name="Your vote" xml:space="preserve">
<value>Your vote</value>
</data>
<data name="Voted." xml:space="preserve">
<value>Voted.</value>
</data>
<data name="Accept" xml:space="preserve">
<value>Accept</value>
</data>
<data name="Refuse" xml:space="preserve">
<value>Refuse</value>
</data>
<data name="Accepted." xml:space="preserve">
<value>Accepted.</value>
</data>
<data name="Refused." xml:space="preserve">
<value>Refused.</value>
</data>
<data name="Reactions and votes are public" xml:space="preserve">
<value>Reactions and votes are public</value>
</data>
<data name="Anyone who can see the post can see that {0} reacted to it or voted in it. Your other personas are never named." xml:space="preserve">
<value>Anyone who can see the post can see that {0} reacted to it or voted in it. Your other personas are never named.</value>
</data>
<data name="Cancel" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="Continue" xml:space="preserve">
<value>Continue</value>
</data>
<data name="React" xml:space="preserve">
<value>React</value>
</data>
</root>
+162
View File
@@ -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
{
/// <summary>What the current persona does to and learns of other accounts.</summary>
public enum AccountAction
{
Follow,
Unfollow,
Block,
Unblock,
Mute,
Unmute
}
/// <summary>Which of an account's posts its profile lists.</summary>
public enum ProfileTab
{
Posts,
PostsAndReplies,
Media
}
/// <summary>
/// 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.
/// </summary>
public class AccountService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
const int PageSize = 20;
public async Task<Account> 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<Account>(cancellationToken);
await LogFailure(response, nameof(Get), cancellationToken);
return null;
}
/// <summary>An account by its handle (name or name@host), resolved by PrivaPub if it has not met it yet.</summary>
public async Task<Account> 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<Account>(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> 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<List<Relationship>>(cancellationToken) ?? []).FirstOrDefault();
await LogFailure(response, nameof(Relationship), cancellationToken);
return null;
}
/// <summary>Follows, unfollows, blocks, unblocks, mutes or unmutes; Data is the new Relationship.</summary>
public async Task<WebResult> 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<Relationship>(cancellationToken) };
}
/// <summary>A page of the account's posts, newest first; Data is a FeedPage.</summary>
public async Task<WebResult> Statuses(string accountId, ProfileTab tab, string maxId, CancellationToken cancellationToken = default)
{
var parameters = new List<string> { $"limit={PageSize}" };
parameters.AddRange(tab switch
{
ProfileTab.Posts => ["exclude_replies=true"],
ProfileTab.Media => ["only_media=true"],
_ => Array.Empty<string>()
});
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<List<Status>>(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
}
};
}
/// <summary>The accounts waiting for the persona to accept their follow.</summary>
public async Task<List<Account>> 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<List<Account>>(cancellationToken) ?? [];
await LogFailure(response, nameof(FollowRequests), cancellationToken);
return [];
}
/// <summary>Accepts (authorize) or refuses (reject) a follow request.</summary>
public async Task<WebResult> 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<Relationship>(cancellationToken) };
}
/// <summary>Accounts, posts and hashtags matching the query; a handle or a URL is resolved when `resolve`.</summary>
public async Task<SearchResults> 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<SearchResults>(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);
}
}
}
+5
View File
@@ -37,6 +37,8 @@ namespace decePubClient.Services
Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
Task<HttpResponseMessage> Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
@@ -78,6 +80,9 @@ namespace decePubClient.Services
public Task<HttpResponseMessage> 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<HttpResponseMessage> 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<HttpResponseMessage> 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);
+43
View File
@@ -0,0 +1,43 @@
using Blazored.LocalStorage;
using decePubClient.Models;
namespace decePubClient.Services
{
/// <summary>
/// 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.
/// </summary>
public class PublicNoticeService(ILocalStorageService localStorage)
{
TaskCompletionSource<bool> pending;
public event Action Changed;
public bool IsAsking => pending is not null;
public async Task<bool> Confirm(string personaId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(personaId))
return false;
var seen = await localStorage.GetItemAsync<PublicActionNotices>(nameof(PublicActionNotices), cancellationToken) ?? new();
if (seen.PersonaIds.Contains(personaId))
return true;
pending = new TaskCompletionSource<bool>();
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);
}
}
+21
View File
@@ -100,6 +100,27 @@ namespace decePubClient.Services
return await StatusResult(response, cancellationToken);
}
/// <summary>Reacts with an emoji (or takes the reaction back); Data is the updated Status.</summary>
public async Task<WebResult> 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);
}
/// <summary>Votes in a poll as the persona (each option by its index); Data is the updated Poll.</summary>
public async Task<WebResult> Vote(string pollId, IReadOnlyCollection<int> 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<Poll>(cancellationToken) };
}
/// <summary>Deletes the persona's own status.</summary>
public async Task<WebResult> Delete(string statusId, CancellationToken cancellationToken = default)
{
+1
View File
@@ -11,3 +11,4 @@
</div>
<ToastHost />
<PublicNoticeHost />
+14
View File
@@ -41,6 +41,20 @@
<i class="ion-md-home"></i>
<span>@Localizer["Home"]</span>
</NavLink>
@if (CascadingState.IsSignedIn)
{
<NavLink ActiveClass="btn-active" class="btn justify-start" href="search">
<i class="ion-md-search"></i>
<span>@Localizer["Search"]</span>
</NavLink>
@if (CascadingState.PersonaId is { } personaId)
{
<NavLink ActiveClass="btn-active" class="btn justify-start" href="@($"account/{Uri.EscapeDataString(personaId)}")">
<i class="ion-md-person"></i>
<span>@Localizer["My profile"]</span>
</NavLink>
}
}
<NavLink ActiveClass="btn-active" class="btn justify-start" href="settings">
<i class="ion-md-settings"></i>
<span>@Localizer["Settings"]</span>
+2 -1
View File
@@ -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;
+6 -1
View File
@@ -6,6 +6,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
<!-- tests/e2e is its own project; without this the app would compile its .cs files too -->
<DefaultItemExcludes>$(DefaultItemExcludes);tests/**</DefaultItemExcludes>
</PropertyGroup>
<ItemGroup>
@@ -38,7 +40,7 @@
</PropertyGroup>
<ItemGroup>
<TailwindSource Include="Styles/**/*.css;**/*.razor;**/*.cs;wwwroot/index.html" Exclude="bin/**;obj/**;.tools/**;tools/**" />
<TailwindSource Include="Styles/**/*.css;**/*.razor;**/*.cs;wwwroot/index.html" Exclude="bin/**;obj/**;.tools/**;tools/**;tests/**" />
</ItemGroup>
<!-- Unpacks the vendored binary when it is missing or the vendored archive changed (an upgrade). -->
@@ -67,6 +69,9 @@
<ItemGroup>
<!-- Points the development build at a local PrivaPub; the published app reads appsettings.json only. -->
<Content Update="wwwroot\appsettings.Development.json" CopyToPublishDirectory="Never" />
<!-- Points the app at the pasture's PrivaPub (tests/e2e/run.sh publishes with WasmApplicationEnvironmentName=Pasture);
any other publish leaves it out, and the deploy checks that it did. -->
<Content Update="wwwroot\appsettings.Pasture.json" CopyToPublishDirectory="Never" Condition="'$(WasmApplicationEnvironmentName)' != 'Pasture'" />
</ItemGroup>
<ItemGroup>
+42
View File
@@ -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
+160
View File
@@ -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<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
public static TheoryData<string, string> Targets()
{
var data = new TheoryData<string, string>();
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<string>() == origin
&& i["visibility"]!.GetValue<string>() is "public" or "community"
&& i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue<string>())));
if (item is not null)
data.Add(origin, item["ref"]!.GetValue<string>());
}
return data;
}
static (JsonNode Item, Pasture.Persona Reader) Target(string reference)
{
var item = Expected.Value.First(i => i!["ref"]!.GetValue<string>() == reference)!;
var personas = Pasture.Personas();
var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue<string>()))
.First(p => p is not null);
return (item, reader);
}
static async Task<int> Count(string origin, string uri, string field)
{
var row = (await Pasture.Stored(origin, uri))[uri]!;
return row[field]?.GetValue<int?>() ?? 0;
}
static async Task<bool> 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<string>()}");
var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue<string>()}']").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<string>();
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<string>();
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<string>();
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<bool>() && row["parent_uri"]?.GetValue<string>() == 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<bool>() || row["deleted"]!.GetValue<bool>();
}, 45), $"the deleted reply is still on {origin}");
await session.AssertGuards();
});
}
}
}
+85
View File
@@ -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<string> 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<string>(), kv.Value!["token"]!.GetValue<string>()))
.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<bool> 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<bool>() == true || (await Pasture.Town("drive", platform, userName, "pending", "{}"))
.AsArray().Any(a => a?.GetValue<string>()?.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);
}
}
}
+115
View File
@@ -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<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
static TheoryData<string, string> Pick(Func<JsonNode, bool> wanted, params string[] origins)
{
var data = new TheoryData<string, string>();
var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet();
foreach (var origin in origins)
{
var item = Expected.Value.FirstOrDefault(i => i!["origin"]!.GetValue<string>() == origin && wanted(i)
&& i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue<string>())));
if (item is not null)
data.Add(origin, item["ref"]!.GetValue<string>());
}
return data;
}
public static TheoryData<string, string> Polls() =>
Pick(i => i["poll"] is JsonArray, "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo");
public static TheoryData<string, string> Reactable() =>
Pick(i => i["visibility"]!.GetValue<string>() 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<string>() == reference)!;
var personas = Pasture.Personas();
var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue<string>()))
.Last(p => p is not null);
var session = await SignIn.As(browser, reader);
await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue<string>()}");
var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue<string>()}']").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<JsonObject> 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<string>();
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<int>() ?? 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<int>() ?? 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<string>();
const string emoji = "🔥";
await Results.Cell("client.action.react", origin, reference, async () =>
{
static int Count(JsonObject row) => row["reactions"]?[emoji]?.GetValue<int>() ?? 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();
});
}
}
}
+68
View File
@@ -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"));
}
}
}
+84
View File
@@ -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<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
public static TheoryData<string, string, string> Samples()
{
var data = new TheoryData<string, string, string>();
var seen = new HashSet<string>();
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<string>())) || !seen.Add(key))
continue;
data.Add(item["origin"]!.GetValue<string>(), $"{item["kind"]}.{item["visibility"]}", item["ref"]!.GetValue<string>());
}
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<string>() == reference)!;
var personas = Pasture.Personas();
var reader = item["readers"]!.AsArray().Select(r => r!.GetValue<string>())
.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<string>()}");
var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue<string>()}']").First;
await card.WaitForAsync(new() { Timeout = 30000 });
var marker = item["marker"]!.GetValue<string>();
if (item["cw"]?.GetValue<string>() 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<string>()))
{
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<bool>("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<string>());
if (item["quote"]?.GetValue<string>() is { } quoted)
{
var quotedRef = Expected.Value.FirstOrDefault(i => i!["uri"]!.GetValue<string>() == quoted)?["marker"]?.GetValue<string>();
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<string>() is { } title)
await Assertions.Expect(card).ToContainTextAsync(title);
await session.AssertGuards();
});
}
}
}
+101
View File
@@ -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<Session> 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<string> Hosts { get; } = [];
public List<string> ConsoleErrors { get; } = [];
public List<string> 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();
}
}
+91
View File
@@ -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<Persona> Personas()
{
var sessions = JsonNode.Parse(File.ReadAllText(Path.Combine(Root, ".state", "town", "sessions.json")))!.AsObject();
var personas = new List<Persona>();
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<string>() is not { } root)
continue;
var account = value["account"]!;
personas.Add(new Persona(account["username"]!.GetValue<string>(), account["name"]?.GetValue<string>(), root,
account["password"]!.GetValue<string>(),
value["token"]!.GetValue<string>(), value["local_id"]!.GetValue<string>(), value["actor_uri"]!.GetValue<string>()));
}
return personas;
}
// tools/pasture/town.sh <args>, its standard output parsed as JSON
public static async Task<JsonNode> 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<JsonObject> Stored(string platform, params string[] uris) =>
(await Town(["stored", platform, .. uris])).AsObject();
public static async Task<bool> Eventually(Func<Task<bool>> 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://<name>.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 }
});
}
}
+48
View File
@@ -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<Task> 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<Task> 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");
}
}
}
}
}
+63
View File
@@ -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<Session> 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 });
}
}
}
+27
View File
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- The decePub end-to-end tests: the published app (environment Pasture) in Chromium against the pasture's PrivaPub
and its peers, run by run.sh. Never in CI: they need podman and the pasture. Playwright 1.61.0 is pinned because
its browsers.json names Chromium 1228, the build cached in ~/.cache/ms-playwright; the tests never install one. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<RootNamespace>decePubClient.E2E</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Playwright" Version="1.61.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<None Update="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+26
View File
@@ -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 "$@"
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false,
"maxParallelThreads": 1
}
+5
View File
@@ -0,0 +1,5 @@
{
"AppConfiguration": {
"ApiBaseAddress": "https://privapub.test"
}
}