From e4414f1ac92f4bd344aa85e48cf7931eaa03afe0 Mon Sep 17 00:00:00 2001
From: thepra
Date: Sat, 17 Oct 2026 00:28:34 +0200
Subject: [PATCH] No test project: tests/e2e goes
Owner decision 2026-10-17, with SocialPub's PrivaPub.Tests. The e2e project leaves the solution, the csproj's
exclusions, Tailwind's sources and .gitignore; comments no longer point at it, and CLAUDE.md's e2e section becomes
Verifying: a publish with the Pasture environment, which SocialPub's tools/pasture/peers/decepub.sh serves as
https://decepub.test. appsettings.Pasture.json stays for that, and the deploy still fails if it ever ships. The cards'
data-* hooks stay in the markup.
Co-Authored-By: Claude Opus 5.5
---
.gitignore | 3 -
CLAUDE.md | 67 +---
Components/Content.razor | 8 +-
Pages/Index.razor | 3 +-
Styles/app.css | 3 +-
Styles/touch.css | 2 +-
decePubClient.csproj | 7 +-
decePubClient.sln | 22 --
tests/e2e/Actions/AccountTests.cs | 67 ----
tests/e2e/Actions/ActionMatrixTests.cs | 161 --------
tests/e2e/Actions/AdministrationTests.cs | 73 ----
tests/e2e/Actions/ArticleTests.cs | 63 ----
tests/e2e/Actions/BackupsTests.cs | 138 -------
tests/e2e/Actions/ComposerTests.cs | 141 -------
tests/e2e/Actions/ConversationTests.cs | 58 ---
tests/e2e/Actions/DiscoveryTests.cs | 111 ------
tests/e2e/Actions/EventTests.cs | 70 ----
tests/e2e/Actions/FeedMemoryTests.cs | 57 ---
tests/e2e/Actions/FilterTests.cs | 72 ----
tests/e2e/Actions/GroupTests.cs | 58 ---
tests/e2e/Actions/ListTests.cs | 115 ------
tests/e2e/Actions/MediaTests.cs | 184 ----------
tests/e2e/Actions/NotificationPolicyTests.cs | 76 ----
tests/e2e/Actions/NotificationTests.cs | 52 ---
tests/e2e/Actions/PersonaArchiveTests.cs | 82 -----
tests/e2e/Actions/PinTests.cs | 55 ---
tests/e2e/Actions/ProfileTests.cs | 118 ------
tests/e2e/Actions/ReportTests.cs | 84 -----
tests/e2e/Actions/SettingsTests.cs | 208 -----------
tests/e2e/Actions/StreamingTests.cs | 66 ----
tests/e2e/Actions/ThreadTests.cs | 61 ----
tests/e2e/Actions/VoteAndReactTests.cs | 123 -------
tests/e2e/Actions/WallTests.cs | 101 ------
tests/e2e/Login/LoginTests.cs | 69 ----
tests/e2e/Login/SignUpTests.cs | 131 -------
tests/e2e/Mobile/ControlsTests.cs | 56 ---
tests/e2e/Mobile/DesktopUnchangedTests.cs | 40 --
tests/e2e/Mobile/Fit.cs | 182 ----------
tests/e2e/Mobile/FlowTests.cs | 363 -------------------
tests/e2e/Mobile/RouteSweepTests.cs | 62 ----
tests/e2e/Mobile/Routes.cs | 136 -------
tests/e2e/Mobile/fit.js | 313 ----------------
tests/e2e/Perf/FeedPerfTests.cs | 83 -----
tests/e2e/Render/GlobeTests.cs | 159 --------
tests/e2e/Render/RenderMatrixTests.cs | 84 -----
tests/e2e/Support/Browser.cs | 136 -------
tests/e2e/Support/Pasture.cs | 119 ------
tests/e2e/Support/PastureAdmin.cs | 26 --
tests/e2e/Support/PersonaApi.cs | 25 --
tests/e2e/Support/Results.cs | 64 ----
tests/e2e/Support/SignIn.cs | 126 -------
tests/e2e/decePubClient.E2E.csproj | 28 --
tests/e2e/run.sh | 26 --
tests/e2e/xunit.runner.json | 5 -
54 files changed, 22 insertions(+), 4720 deletions(-)
delete mode 100644 tests/e2e/Actions/AccountTests.cs
delete mode 100644 tests/e2e/Actions/ActionMatrixTests.cs
delete mode 100644 tests/e2e/Actions/AdministrationTests.cs
delete mode 100644 tests/e2e/Actions/ArticleTests.cs
delete mode 100644 tests/e2e/Actions/BackupsTests.cs
delete mode 100644 tests/e2e/Actions/ComposerTests.cs
delete mode 100644 tests/e2e/Actions/ConversationTests.cs
delete mode 100644 tests/e2e/Actions/DiscoveryTests.cs
delete mode 100644 tests/e2e/Actions/EventTests.cs
delete mode 100644 tests/e2e/Actions/FeedMemoryTests.cs
delete mode 100644 tests/e2e/Actions/FilterTests.cs
delete mode 100644 tests/e2e/Actions/GroupTests.cs
delete mode 100644 tests/e2e/Actions/ListTests.cs
delete mode 100644 tests/e2e/Actions/MediaTests.cs
delete mode 100644 tests/e2e/Actions/NotificationPolicyTests.cs
delete mode 100644 tests/e2e/Actions/NotificationTests.cs
delete mode 100644 tests/e2e/Actions/PersonaArchiveTests.cs
delete mode 100644 tests/e2e/Actions/PinTests.cs
delete mode 100644 tests/e2e/Actions/ProfileTests.cs
delete mode 100644 tests/e2e/Actions/ReportTests.cs
delete mode 100644 tests/e2e/Actions/SettingsTests.cs
delete mode 100644 tests/e2e/Actions/StreamingTests.cs
delete mode 100644 tests/e2e/Actions/ThreadTests.cs
delete mode 100644 tests/e2e/Actions/VoteAndReactTests.cs
delete mode 100644 tests/e2e/Actions/WallTests.cs
delete mode 100644 tests/e2e/Login/LoginTests.cs
delete mode 100644 tests/e2e/Login/SignUpTests.cs
delete mode 100644 tests/e2e/Mobile/ControlsTests.cs
delete mode 100644 tests/e2e/Mobile/DesktopUnchangedTests.cs
delete mode 100644 tests/e2e/Mobile/Fit.cs
delete mode 100644 tests/e2e/Mobile/FlowTests.cs
delete mode 100644 tests/e2e/Mobile/RouteSweepTests.cs
delete mode 100644 tests/e2e/Mobile/Routes.cs
delete mode 100644 tests/e2e/Mobile/fit.js
delete mode 100644 tests/e2e/Perf/FeedPerfTests.cs
delete mode 100644 tests/e2e/Render/GlobeTests.cs
delete mode 100644 tests/e2e/Render/RenderMatrixTests.cs
delete mode 100644 tests/e2e/Support/Browser.cs
delete mode 100644 tests/e2e/Support/Pasture.cs
delete mode 100644 tests/e2e/Support/PastureAdmin.cs
delete mode 100644 tests/e2e/Support/PersonaApi.cs
delete mode 100644 tests/e2e/Support/Results.cs
delete mode 100644 tests/e2e/Support/SignIn.cs
delete mode 100644 tests/e2e/decePubClient.E2E.csproj
delete mode 100755 tests/e2e/run.sh
delete mode 100644 tests/e2e/xunit.runner.json
diff --git a/.gitignore b/.gitignore
index 412743a..ece3221 100644
--- a/.gitignore
+++ b/.gitignore
@@ -368,6 +368,3 @@ FodyWeavers.xsd
.tools/
wwwroot/css/app.css
-# the e2e tests: the published app they serve and the browser state they keep
-tests/e2e/.publish/
-tests/e2e/.state/
diff --git a/CLAUDE.md b/CLAUDE.md
index 40f566c..361ed4c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -45,63 +45,21 @@ dotnet publish decePubClient.csproj -c Release -o publish # static site lands
`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There is no linter.
-## Testing: end to end against the pasture
+## Verifying
-`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.
+There is no test project (owner decision 2026-10-17: `tests/e2e` was removed, as was SocialPub's `PrivaPub.Tests`). Don't
+add one back unless asked. Verify by building, running against a local PrivaPub, and in SocialPub's 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
+dotnet publish decePubClient.csproj -c Release -p:WasmApplicationEnvironmentName=Pasture -o publish
+../SocialPub/tools/pasture/run.sh up gts decepub # serves publish/wwwroot as https://decepub.test (peers/decepub.sh)
```
-- **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 feed renders
- only the cards near the view, so a test looks for recent cards, and counts what is loaded by `data-feed-count`.
-- **Phones** (`Mobile/`, `Category=Mobile`): `Browser.Open(state, Phone)` opens Playwright's own device presets
- (shipped in its package): 360×800, iPhone SE (3rd gen), Pixel 7, and the Pixel 7 held sideways, with touch, a coarse
- pointer and reduced motion; a phone session fails at once if the page doesn't see a touch screen.
- `SignIn.As(browser, persona, phone)` reuses the desktop sign-in, and any saved sign-in that has expired is renewed.
- - `RouteSweepTests` measures every page, one screen at a time down its main scroller (`Mobile/fit.js`). It checks:
- - sideways scroll, unless the strip is marked `data-scroll-x`;
- - boxes past the screen's sides;
- - open menus, sheets and dialogs that are cut or outside the screen;
- - targets without a 44px hit area: hit-tested at their centre and 21px around it, so a crowding neighbour fails
- them too. Inline links in post text, native media controls and disabled controls are exempt, a fixed floating
- control covering a target doesn't count, and `data-touch-exempt` exempts one explicitly;
- - control text under 12px.
-
- Screenshots, findings and a contact sheet go to `tests/e2e/out/mobile//`. Cells are `client.mobile.fit.*` and
- `client.mobile.touch.*`, with the phone as the ref.
- - `FlowTests` drives the narrowest phone through the menu, a dialog, a new post, a reply, a reaction, a deletion, the
- full-screen globe, a phone held sideways, a tap into a thread, a picture and the Back button (`client.mobile.flow.*`).
- - `DesktopUnchangedTests` holds desktop's control sizes.
-- **The globe** (`Render/GlobeTests.cs`): the instruments are built, with the Sun drawn over its subsolar point.
- - On desktop, a located post written from Milano is pinned, and the globe is photographed in both themes and again
- turned to the subsolar point.
- - On a phone, the banner draws little, full screen draws everything with the camera moved back, and closing returns
- to the banner.
- - An idle globe draws no frame.
-
- Photos and stats go to `tests/e2e/out/globe/`.
-- **Restore** (`Category=Restore`, in `Actions/BackupsTests.cs`): a real restore of the pasture from Administration. It
- ends every session, the town's too, so it runs only when asked (`--filter "Category=Restore"`, never next to other
- tests) and signs the town in again (`town.sh renew privapub`). Normal runs use `--filter "Category!=Perf&Category!=Restore"`.
-- **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests`
- scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time,
- DOM nodes, JS and .NET heaps and long tasks to `perf.json`.
-- The root project globs `**/*.cs`, so it excludes `tests/**` (`DefaultItemExcludes`), and Tailwind does not scan it.
+- Blazor on .NET 10 ignores the `Blazor-Environment` header, so the environment is chosen at publish time:
+ `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is published only then, and the deploy fails if it
+ ever ships.
+- A browser reaches `*.test` through the pasture's Caddy on `127.0.0.1:6443` (Chromium: `--host-resolver-rules="MAP *.test
+ 127.0.0.1:6443"`), and must accept the pasture's CA.
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
@@ -167,7 +125,7 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
- Layout differences between a phone and a desktop use the width variants at the call site (`max-md:`, `md:`).
- How big a thing is to a finger uses the pointer: `Styles/touch.css` under `@media (pointer: coarse)`, and
`pointer-coarse:` at a call site.
- - A mouse matches none of it, so desktop keeps daisyUI's sizes (`Mobile/DesktopUnchangedTests` holds them).
+ - A mouse matches none of it, so desktop keeps daisyUI's sizes.
- A window at most 30rem tall (a phone held sideways) is the `short:` variant.
- **`touch.css`** is plain CSS in Tailwind's utilities layer, so like `neo.css` it beats daisyUI's sublayers. It sets
daisyUI's own size variables:
@@ -180,8 +138,7 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
Its `touch-target` utility gives a small link or button a 44px hit area. It can't be used on something that clips
its own overflow (a `truncate`d name takes `pointer-coarse:py-3.5` instead), nor on a daisyUI tooltip, which draws
its arrow with `::after` (NerdTip takes minimum sizes).
-- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`). The
- phone sweep fails anything less.
+- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`).
- **Overlays on a phone rise from the bottom.**
- `DModal Placement="ResponsiveBottom"` is a sheet below md and a centred dialog from md. Every DModal has a visible
close button (`ShowClose`, named by `CloseText`).
diff --git a/Components/Content.razor b/Components/Content.razor
index 571f9cd..0af0e0c 100644
--- a/Components/Content.razor
+++ b/Components/Content.razor
@@ -5,8 +5,8 @@
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; data-uri is its ActivityPub id,
- which the e2e tests (tests/e2e) find it by. The persona's filters apply where the page says the card is (FilterIn): a
+ 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.
+ The persona's filters apply where the page says the card is (FilterIn): a
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their author deletes them through /clientapi. Nerd stats are on
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
@if (!IsFilteredOut)
@@ -572,8 +572,8 @@
// A card re-renders when something it shows may have changed: its own actions and toggles (its events), a new copy of
// its post (an edit, a grouped notification, a place found), where it is shown, or the persona and connection. Without
- // this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll to
- // (tests/e2e/Perf/FeedPerfTests).
+ // this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll
+ // through.
object renderedWith;
int handlingEvents;
diff --git a/Pages/Index.razor b/Pages/Index.razor
index 234b8a3..e227f3d 100644
--- a/Pages/Index.razor
+++ b/Pages/Index.razor
@@ -91,8 +91,7 @@
}
- @* only the cards near the view are rendered: a feed of thousands stays as light as one of forty
- (tests/e2e/Perf/FeedPerfTests) *@
+ @* only the cards near the view are rendered: a feed of thousands stays as light as one of forty *@
diff --git a/Styles/app.css b/Styles/app.css
index ba94b70..4a22721 100644
--- a/Styles/app.css
+++ b/Styles/app.css
@@ -1,12 +1,11 @@
/* 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), tests/ and the vendored CSS. */
+ (daisyui-llms.txt names every daisyUI class) and the vendored CSS. */
@import "tailwindcss" source("../");
@source not "../wwwroot/vendor";
@source not "../Docs";
@source not "../tools";
-@source not "../tests";
@plugin "../tools/tailwind/daisyui.mjs" {
themes: false;
diff --git a/Styles/touch.css b/Styles/touch.css
index 4b170c3..0b151dd 100644
--- a/Styles/touch.css
+++ b/Styles/touch.css
@@ -5,7 +5,7 @@
daisyUI's sizes. The rules are plain CSS in Tailwind's utilities layer: like neo.css's @utility overrides they beat
daisyUI's rules, which sit in sublayers of it, and they set daisyUI's own size variables where it has one.
- A target gets a 44px hit area (Apple's guideline, WCAG 2.5.5), which tests/e2e/Mobile measures. Buttons, tabs and
+ A target gets a 44px hit area (Apple's guideline, WCAG 2.5.5). Buttons, tabs and
fields are 44px tall; the extra small buttons and the badges that act stay 36px to the eye and reach 44px through a
transparent ::after, so the rows that hold them leave 8px between them on touch (pointer-coarse:gap-2), or two hit
areas would overlap. */
diff --git a/decePubClient.csproj b/decePubClient.csproj
index 44d8a39..8be25e1 100644
--- a/decePubClient.csproj
+++ b/decePubClient.csproj
@@ -6,8 +6,6 @@
enable
service-worker-assets.js
true
-
- $(DefaultItemExcludes);tests/**
@@ -40,7 +38,7 @@
-
+
@@ -69,7 +67,8 @@
-
diff --git a/decePubClient.sln b/decePubClient.sln
index e820b04..b60cb64 100644
--- a/decePubClient.sln
+++ b/decePubClient.sln
@@ -7,12 +7,6 @@ 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
@@ -47,26 +41,10 @@ Global
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x64.Build.0 = Release|Any CPU
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x86.ActiveCfg = Release|Any CPU
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x86.Build.0 = Release|Any CPU
- {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x64.ActiveCfg = Debug|Any CPU
- {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x64.Build.0 = Debug|Any CPU
- {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x86.ActiveCfg = Debug|Any CPU
- {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x86.Build.0 = Debug|Any CPU
- {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|Any CPU.Build.0 = Release|Any CPU
- {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x64.ActiveCfg = Release|Any CPU
- {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x64.Build.0 = Release|Any CPU
- {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x86.ActiveCfg = Release|Any CPU
- {DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
- GlobalSection(NestedProjects) = preSolution
- {0EB607AC-6329-6575-AD8B-E5558B048B88} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
- {DB238929-3C3E-4841-B782-3BF0CC53B20E} = {0EB607AC-6329-6575-AD8B-E5558B048B88}
- EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {1B399226-FEE0-4572-A4A7-783DA83E7DDD}
EndGlobalSection
diff --git a/tests/e2e/Actions/AccountTests.cs b/tests/e2e/Actions/AccountTests.cs
deleted file mode 100644
index e77ba1a..0000000
--- a/tests/e2e/Actions/AccountTests.cs
+++ /dev/null
@@ -1,67 +0,0 @@
-using System.Net;
-using System.Net.Http.Headers;
-using System.Net.Http.Json;
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // A login of its own, made for the test: its password changed in Settings, then the whole of it deleted there, each
- // checked against PrivaPub's own login
- public sealed class AccountTests(Browser browser)
- {
- const string FirstPassword = "Pasture-Pass-1!";
- const string SecondPassword = "Pasture-Pass-2!";
-
- static async Task Login(string root, string password)
- {
- using var http = Pasture.Http();
- using var response = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/login", new { userName = root, password });
- return response.StatusCode;
- }
-
- [Fact]
- public async Task A_logins_password_is_changed_and_the_login_deleted_from_settings()
- {
- var stamp = DateTime.UtcNow.ToString("MMddHHmmss");
- var root = $"e2eroot{stamp}";
- using (var http = Pasture.Http())
- {
- using var signup = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/signup", new { userName = root, password = FirstPassword });
- Assert.SkipWhen(!signup.IsSuccessStatusCode, $"PrivaPub refuses sign-ups ({(int)signup.StatusCode})");
- var jwt = JsonNode.Parse(await signup.Content.ReadAsStringAsync())!["token"]!.GetValue();
- using var insert = new HttpRequestMessage(HttpMethod.Post, Pasture.Api + "/clientapi/avatar/private/insert")
- {
- Content = JsonContent.Create(new { userName = $"e2eacct{stamp}", name = "an e2e persona", biography = "for the account test" })
- };
- insert.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
- using var inserted = await http.SendAsync(insert);
- Assert.True(inserted.IsSuccessStatusCode, $"the persona could not be made ({(int)inserted.StatusCode})");
- }
- var persona = new Pasture.Persona($"e2eacct{stamp}", "an e2e persona", root, FirstPassword, null, null, null);
- await using var session = await SignIn.As(browser, persona, fresh: true);
- var page = session.Page;
- await Results.Cell("client.action.account", "privapub", root, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/settings");
- await page.Locator("[data-settings-account]").ClickAsync();
- await page.Locator("input[data-account-current]").FillAsync(FirstPassword);
- await page.Locator("input[data-account-new]").FillAsync(SecondPassword);
- await page.Locator("input[data-account-repeat]").FillAsync(SecondPassword);
- await page.Locator("[data-account-password-save]").ClickAsync();
- Assert.True(await Pasture.Eventually(async () => await Login(root, SecondPassword) == HttpStatusCode.OK, 15), "the new password never worked");
- Assert.NotEqual(HttpStatusCode.OK, await Login(root, FirstPassword));
-
- await page.Locator("input[data-account-delete-password]").FillAsync(SecondPassword);
- await page.Locator("input[data-account-delete-understood]").CheckAsync();
- await page.Locator("[data-account-delete-confirm]").ClickAsync();
- await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/login", new() { Timeout = 30000 });
- Assert.True(await Pasture.Eventually(async () => await Login(root, SecondPassword) != HttpStatusCode.OK, 15), "the deleted login still signs in");
- await session.AssertGuards();
- }, page);
- }
- }
-}
diff --git a/tests/e2e/Actions/ActionMatrixTests.cs b/tests/e2e/Actions/ActionMatrixTests.cs
deleted file mode 100644
index 316405e..0000000
--- a/tests/e2e/Actions/ActionMatrixTests.cs
+++ /dev/null
@@ -1,161 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // What a reader does to a post from each platform, done in decePub and checked on the post's own server: a favourite
- // and a boost arrive there (and leave when undone), a reply lands under the post and is gone once deleted. town.sh
- // stored reads the origin's database, so nothing here trusts decePub's own view of what it did.
- public sealed class ActionMatrixTests(Browser browser)
- {
- static readonly Lazy Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
-
- public static TheoryData Targets()
- {
- var data = new TheoryData();
- var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet();
- foreach (var origin in new[] { "gts", "mastodon", "misskey", "sharkey", "akkoma", "lemmy" })
- {
- var item = Expected.Value.FirstOrDefault(i => i!["origin"]!.GetValue() == origin
- && i["visibility"]!.GetValue() is "public" or "community"
- && i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue())));
- if (item is not null)
- data.Add(origin, item["ref"]!.GetValue());
- }
- return data;
- }
-
- static (JsonNode Item, Pasture.Persona Reader) Target(string reference)
- {
- var item = Expected.Value.First(i => i!["ref"]!.GetValue() == reference)!;
- var personas = Pasture.Personas();
- var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue()))
- .First(p => p is not null);
- return (item, reader);
- }
-
- static async Task Count(string origin, string uri, string field)
- {
- var row = (await Pasture.Stored(origin, uri))[uri]!;
- return row[field]?.GetValue() ?? 0;
- }
-
- static async Task IsActive(ILocator button) =>
- ((await button.GetAttributeAsync("class")) ?? "").Split(' ').Contains("btn-active");
-
- async Task<(Session Session, ILocator Card)> Open(JsonNode item, Pasture.Persona reader)
- {
- var session = await SignIn.As(browser, reader);
- await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue()}");
- var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue()}']").First;
- await card.WaitForAsync(new() { Timeout = 30000 });
- return (session, card);
- }
-
- [Theory]
- [MemberData(nameof(Targets))]
- public async Task A_favourite_reaches_the_author_and_its_undo_too(string origin, string reference)
- {
- var (item, reader) = Target(reference);
- var uri = item["uri"]!.GetValue();
- await Results.Cell("client.action.favourite", origin, reference, async () =>
- {
- var (session, card) = await Open(item, reader);
- await using var _ = session;
- var favourite = card.Locator("footer button[title='Favourite']").First;
- // the town may have done it already as this persona: start from the undone state
- if (await IsActive(favourite))
- {
- var done = await Count(origin, uri, "likes");
- await favourite.ClickAsync();
- await Pasture.Eventually(async () => await Count(origin, uri, "likes") < done, 60);
- await Assertions.Expect(favourite).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active"));
- }
- var before = await Count(origin, uri, "likes");
- await favourite.ClickAsync();
- Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "likes") > before),
- $"the favourite never reached {origin}");
- await favourite.ClickAsync();
- Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "likes") == before, 60),
- $"undoing the favourite never reached {origin}");
- await session.AssertGuards();
- });
- }
-
- [Theory]
- [MemberData(nameof(Targets))]
- public async Task A_boost_reaches_the_author_and_its_undo_too(string origin, string reference)
- {
- Assert.SkipWhen(origin == "lemmy", "Lemmy keeps no boosts of a thread");
- var (item, reader) = Target(reference);
- var uri = item["uri"]!.GetValue();
- await Results.Cell("client.action.boost", origin, reference, async () =>
- {
- var (session, card) = await Open(item, reader);
- await using var _ = session;
- var boost = card.Locator("footer button[title='Boost']").First;
- // the town may have done it already as this persona: start from the undone state
- if (await IsActive(boost))
- {
- var done = await Count(origin, uri, "boosts");
- await boost.ClickAsync();
- await Pasture.Eventually(async () => await Count(origin, uri, "boosts") < done, 60);
- await Assertions.Expect(boost).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active"));
- }
- var before = await Count(origin, uri, "boosts");
- await boost.ClickAsync();
- Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "boosts") > before),
- $"the boost never reached {origin}");
- await boost.ClickAsync();
- Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "boosts") == before, 60),
- $"undoing the boost never reached {origin}");
- await session.AssertGuards();
- });
- }
-
- [Theory]
- [MemberData(nameof(Targets))]
- public async Task A_reply_lands_under_the_post_and_its_delete_removes_it(string origin, string reference)
- {
- var (item, reader) = Target(reference);
- var uri = item["uri"]!.GetValue();
- await Results.Cell("client.action.reply", origin, reference, async () =>
- {
- var (session, card) = await Open(item, reader);
- await using var _ = session;
- await card.Locator("footer button[title='Reply']").First.ClickAsync();
- var text = $"an e2e reply to {reference} ({Guid.NewGuid():N})";
- // after the @names the composer prefills, as a reader types
- var box = card.Locator("textarea").Last;
- await box.FillAsync(((await box.InputValueAsync()).TrimEnd() + " " + text).Trim());
- var published = session.Page.WaitForResponseAsync(r => r.Url.EndsWith("/api/v1/statuses") && r.Request.Method == "POST");
- await card.Locator("button[type=submit]").Last.ClickAsync();
- var response = await published;
- Assert.True(response.Ok, $"publishing the reply answered {response.Status}");
- var replyUri = (await response.JsonAsync())?.GetProperty("uri").GetString();
- Assert.False(string.IsNullOrEmpty(replyUri));
-
- Assert.True(await Pasture.Eventually(async () =>
- {
- var row = (await Pasture.Stored(origin, replyUri))[replyUri]!;
- return row["exists"]!.GetValue() && row["parent_uri"]?.GetValue() == uri;
- }, 45), $"the reply never landed under the post on {origin}");
-
- var reply = session.Page.Locator($"article[data-uri='{replyUri}']").First;
- await reply.WaitForAsync(new() { Timeout = 15000 });
- await reply.Locator("footer [title='Other']").First.ClickAsync();
- await reply.GetByRole(AriaRole.Button).Filter(new() { HasText = "Delete" }).First.ClickAsync();
- await session.Page.Locator("[data-confirm-yes]").ClickAsync();
- Assert.True(await Pasture.Eventually(async () =>
- {
- var row = (await Pasture.Stored(origin, replyUri))[replyUri]!;
- return !row["exists"]!.GetValue() || row["deleted"]!.GetValue();
- }, 45), $"the deleted reply is still on {origin}");
- await session.AssertGuards();
- });
- }
- }
-}
diff --git a/tests/e2e/Actions/AdministrationTests.cs b/tests/e2e/Actions/AdministrationTests.cs
deleted file mode 100644
index 91711e4..0000000
--- a/tests/e2e/Actions/AdministrationTests.cs
+++ /dev/null
@@ -1,73 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // Administration as the pasture's administrator (pastureroot, whom the scenarios make): a server blocked for everyone
- // and unblocked, the federation statistics, and the reports moderators handle; each change checked on /clientapi
- public sealed class AdministrationTests(Browser browser)
- {
- const string Root = PastureAdmin.Root;
- static readonly Pasture.Persona Admin = new("alice", "alice", Root, PastureAdmin.Password, null, null, null);
-
- static Task AsAdmin(string path) => PastureAdmin.Get(path);
-
- static async Task Open(IPage page, string section)
- {
- await page.GotoAsync($"{Pasture.Site}/administration");
- var header = page.Locator(".collapse-title", new() { HasText = section }).First;
- await header.ClickAsync();
- return header.Locator("xpath=..");
- }
-
- [Fact]
- public async Task A_server_is_blocked_for_everyone_and_unblocked()
- {
- const string server = "instance-block-by-e2e.example";
- await using var session = await SignIn.As(browser, Admin);
- var page = session.Page;
- await Results.Cell("client.admin.server-block", "privapub", Root, async () =>
- {
- var panel = await Open(page, "Blocked servers");
- var blocks = panel.Locator("[data-server-blocks]");
- await blocks.WaitForAsync(new() { Timeout = 30000 });
- await blocks.Locator("[data-server-block-domain]").FillAsync(server);
- await blocks.GetByRole(AriaRole.Button, new() { Name = "Block server" }).ClickAsync();
- var row = blocks.Locator($"[data-server-block='{server}']");
- await Assertions.Expect(row).ToContainTextAsync("Suspended", new() { Timeout = 15000 });
- Assert.Contains((await AsAdmin("/clientapi/admin/domainblocks/list"))!.AsArray(), b => b!["domain"]!.GetValue() == server);
-
- await row.GetByRole(AriaRole.Button, new() { Name = "Unblock" }).ClickAsync();
- await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 });
- Assert.DoesNotContain((await AsAdmin("/clientapi/admin/domainblocks/list"))!.AsArray(), b => b!["domain"]!.GetValue() == server);
- await session.AssertGuards();
- }, page);
- }
-
- [Fact]
- public async Task The_federation_statistics_and_the_reports_are_shown()
- {
- await using var session = await SignIn.As(browser, Admin);
- var page = session.Page;
- await Results.Cell("client.admin.statistics", "privapub", Root, async () =>
- {
- var panel = await Open(page, "Statistics");
- await Assertions.Expect(panel.Locator("[data-statistics-overview]")).ToBeVisibleAsync(new() { Timeout = 30000 });
- var expected = (await AsAdmin("/clientapi/admin/statistics/hosts?days=30&sort=traffic&page=1&limit=50"))!["hosts"]!.AsArray()
- .Select(h => h!["host"]!.GetValue()).Take(5).ToList();
- Assert.NotEmpty(expected);
- foreach (var host in expected)
- await Assertions.Expect(panel.Locator($"[data-statistics-host='{host}']")).ToHaveCountAsync(1, new() { Timeout = 15000 });
-
- var reports = await Open(page, "Reports");
- await Assertions.Expect(reports.Locator("[data-reports]")).ToBeVisibleAsync(new() { Timeout = 30000 });
- var open = (await AsAdmin("/clientapi/moderator/reports?resolved=false"))!.AsArray().Count;
- await Assertions.Expect(reports.Locator("[data-report-id]")).ToHaveCountAsync(open, new() { Timeout = 15000 });
- await session.AssertGuards();
- }, page);
- }
- }
-}
diff --git a/tests/e2e/Actions/ArticleTests.cs b/tests/e2e/Actions/ArticleTests.cs
deleted file mode 100644
index a777dd9..0000000
--- a/tests/e2e/Actions/ArticleTests.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using System.Net.Http.Headers;
-using System.Text;
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // An article in a feed shows its title, its excerpt and a Read button, and its whole text once asked: a WriteFreely
- // blog the persona follows publishes one (SocialPub's peers/writefreely.sh keeps the blog's token).
- public sealed class ArticleTests(Browser browser)
- {
- [Fact]
- public async Task An_article_is_folded_in_the_feed_and_read_on_demand()
- {
- var tokenFile = Path.Combine(Pasture.Root, ".state", "writefreely", "wfuser.token");
- Assert.SkipUnless(File.Exists(tokenFile), "no WriteFreely blog: run tools/pasture/run.sh add writefreely");
- var persona = Pasture.Personas().First();
- var blog = (await PersonaApi.Call(persona, "/api/v2/search?q=wfuser%40writefreely.test&resolve=true&type=accounts"))?["accounts"]?.AsArray().FirstOrDefault();
- Assert.SkipWhen(blog is null, "PrivaPub cannot resolve the WriteFreely blog");
- var blogId = blog!["id"]!.GetValue();
- await PersonaApi.Call(persona, $"/api/v1/accounts/{blogId}/follow", HttpMethod.Post);
- Assert.True(await Pasture.Eventually(async () =>
- (await PersonaApi.Call(persona, $"/api/v1/accounts/relationships?id[]={blogId}"))![0]!["following"]!.GetValue(), 45), "the blog never accepted");
-
- var title = $"An article to fold {Guid.NewGuid():N}"[..30];
- var ending = $"the last words {Guid.NewGuid():N}"[..24];
- var body = string.Join(" ", Enumerable.Repeat("A long paragraph that a feed should not show whole.", 12)) + " " + ending;
- using var http = Pasture.Http();
- using var publish = new HttpRequestMessage(HttpMethod.Post, "https://writefreely.test/api/collections/wfuser/posts")
- {
- Content = new StringContent(new JsonObject { ["title"] = title, ["body"] = body }.ToJsonString(), Encoding.UTF8, "application/json")
- };
- publish.Headers.Authorization = new AuthenticationHeaderValue("Token", File.ReadAllText(tokenFile).Trim());
- (await http.SendAsync(publish)).EnsureSuccessStatusCode();
- JsonNode article = null;
- Assert.True(await Pasture.Eventually(async () =>
- (article = (await PersonaApi.Call(persona, "/api/v1/timelines/home?limit=40"))!.AsArray()
- .FirstOrDefault(s => s!["privapub"]?["title"]?.GetValue() == title)) is not null, 60), "the article never reached the persona");
-
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- await Results.Cell("client.render.article", "writefreely", persona.UserName, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/");
- await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
- await page.GotoAsync($"{Pasture.Site}/");
- var card = page.Locator($"article[data-uri='{article!["uri"]!.GetValue()}']").First;
- await card.WaitForAsync(new() { Timeout = 30000 });
-
- await Assertions.Expect(card).ToContainTextAsync(title);
- await Assertions.Expect(card.Locator("[data-article]")).ToBeVisibleAsync();
- await Assertions.Expect(card).Not.ToContainTextAsync(ending);
- await card.Locator("[data-article-open]").ClickAsync();
- await Assertions.Expect(card).ToContainTextAsync(ending, new() { Timeout = 5000 });
- await session.AssertGuards();
- }, page);
- await PersonaApi.Call(persona, $"/api/v1/accounts/{blogId}/unfollow", HttpMethod.Post);
- }
- }
-}
diff --git a/tests/e2e/Actions/BackupsTests.cs b/tests/e2e/Actions/BackupsTests.cs
deleted file mode 100644
index 98e91cd..0000000
--- a/tests/e2e/Actions/BackupsTests.cs
+++ /dev/null
@@ -1,138 +0,0 @@
-using System.Formats.Tar;
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // The server's backups from Administration (owner decision 2026-10-07), as the pasture's administrator: one made now,
- // downloaded through its link (a tar whose files are the backup's), deleted and uploaded again from the browser in
- // pieces; each step checked on /clientapi
- public sealed class BackupsTests(Browser browser)
- {
- const string Root = PastureAdmin.Root;
- static readonly Pasture.Persona Admin = new("alice", "alice", Root, PastureAdmin.Password, null, null, null);
-
- internal static async Task Open(IPage page)
- {
- await page.GotoAsync($"{Pasture.Site}/administration");
- var header = page.Locator(".collapse-title", new() { HasText = "Backups" }).First;
- await header.ClickAsync();
- var panel = header.Locator("xpath=..").Locator("[data-backups]");
- await panel.Locator("[data-backup-now]").WaitForAsync(new() { Timeout = 30000 });
- return panel;
- }
-
- internal static async Task BackUp(ILocator panel)
- {
- var before = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Select(b => b!["id"]!.GetValue()).ToHashSet();
- await panel.Locator("[data-backup-now]").ClickAsync();
- var made = default(string);
- Assert.True(await Pasture.Eventually(async () =>
- {
- var list = (await PastureAdmin.Get("/clientapi/admin/backups"))!;
- made = list["backups"]!.AsArray().Select(b => b!["id"]!.GetValue()).FirstOrDefault(id => !before.Contains(id));
- return made is not null && list["running"] is null;
- }, 300), "the backup was never made");
- await Assertions.Expect(panel.Locator($"[data-backup='{made}']")).ToBeVisibleAsync(new() { Timeout = 30000 });
- return made;
- }
-
- [Fact]
- public async Task A_backup_is_made_downloaded_and_uploaded_again()
- {
- await using var session = await SignIn.As(browser, Admin);
- var page = session.Page;
- var file = Path.Combine(Path.GetTempPath(), $"decepub-backup-{Guid.NewGuid():N}.tar");
- try
- {
- await Results.Cell("client.admin.backup", "privapub", Root, async () =>
- {
- var panel = await Open(page);
- var id = await BackUp(panel);
- var row = panel.Locator($"[data-backup='{id}']");
- await Assertions.Expect(row).ToContainTextAsync("Made by hand");
-
- // the link, for the password
- await row.Locator("[data-backup-download]").ClickAsync();
- var dialog = page.Locator("[data-backup-download-dialog]");
- await dialog.Locator("[data-backup-password]").FillAsync(PastureAdmin.Password);
- await dialog.Locator("[data-backup-confirm]").ClickAsync();
- var link = dialog.Locator("[data-backup-link]");
- await Assertions.Expect(link).ToBeVisibleAsync(new() { Timeout = 15000 });
- var download = await page.RunAndWaitForDownloadAsync(() => link.ClickAsync(), new() { Timeout = 120000 });
- await download.SaveAsAsync(file);
- var names = new List();
- await using (var reader = new TarReader(File.OpenRead(file)))
- while (await reader.GetNextEntryAsync() is { } entry)
- names.Add(entry.Name);
- Assert.Contains($"{id}/manifest.json", names);
- Assert.Contains(names, n => n.StartsWith($"{id}/db/", StringComparison.Ordinal));
- await dialog.Locator("[data-backup-dialog-close]").ClickAsync();
-
- // deleted (asked first), then uploaded again from the file
- await row.Locator("[data-backup-delete]").ClickAsync();
- await page.Locator("[data-confirm-yes]").ClickAsync();
- await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 });
- Assert.DoesNotContain((await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray(), b => b!["id"]!.GetValue() == id);
-
- await panel.Locator("input[data-backup-upload]").SetInputFilesAsync(file);
- await panel.Locator("[data-backup-upload-start]").ClickAsync();
- await Assertions.Expect(row).ToContainTextAsync("Uploaded", new() { Timeout = 120000 });
- var uploaded = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Single(b => b!["id"]!.GetValue() == id)!;
- Assert.Equal("uploaded", uploaded["kind"]!.GetValue());
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- File.Delete(file);
- }
- }
- }
-
- // A real restore of the pasture from Administration: PrivaPub stops and restores as it starts again, every session
- // ends, and the page says so; signed in again, the last restore's report is there. It restores the whole server and
- // ends the town's sessions, so it runs only when asked (--filter "Category=Restore"), alone, and signs the town in
- // again (town.sh renew privapub).
- [Trait("Category", "Restore")]
- public sealed class RestoreTests(Browser browser)
- {
- static readonly Pasture.Persona Admin = new("alice", "alice", PastureAdmin.Root, PastureAdmin.Password, null, null, null);
-
- [Fact]
- public async Task A_backup_is_restored_and_everyone_signs_in_again()
- {
- await using var session = await SignIn.As(browser, Admin);
- var page = session.Page;
- await Results.Cell("client.admin.restore", "privapub", PastureAdmin.Root, async () =>
- {
- var panel = await BackupsTests.Open(page);
- var id = await BackupsTests.BackUp(panel);
- var host = (await PastureAdmin.Get("/clientapi/admin/backups"))!["host"]!.GetValue();
-
- await panel.Locator($"[data-backup='{id}'] [data-backup-restore]").ClickAsync();
- var dialog = page.Locator("[data-backup-restore-dialog]");
- await dialog.Locator("[data-backup-password]").FillAsync(PastureAdmin.Password);
- var confirm = dialog.Locator("[data-backup-confirm]");
- await dialog.Locator("[data-backup-host]").FillAsync("elsewhere.test");
- await Assertions.Expect(confirm).ToBeDisabledAsync();
- await dialog.Locator("[data-backup-host]").FillAsync(host);
- await confirm.ClickAsync();
- await Assertions.Expect(page.Locator("[data-backup-restarting]")).ToBeVisibleAsync(new() { Timeout = 15000 });
-
- // once PrivaPub is back, the session it ended sends the page to sign in again
- await page.WaitForURLAsync(url => url.Contains("/login"), new() { Timeout = 300000 });
- await Pasture.RenewTown();
- }, page);
-
- await using var again = await SignIn.As(browser, Admin);
- var panelAgain = await BackupsTests.Open(again.Page);
- await Assertions.Expect(panelAgain.Locator("[data-last-restore]")).ToBeVisibleAsync(new() { Timeout = 30000 });
- Assert.NotNull((await PastureAdmin.Get("/clientapi/admin/backups"))!["lastRestore"]);
- await again.AssertGuards();
- }
- }
-}
diff --git a/tests/e2e/Actions/ComposerTests.cs b/tests/e2e/Actions/ComposerTests.cs
deleted file mode 100644
index 8d3c58e..0000000
--- a/tests/e2e/Actions/ComposerTests.cs
+++ /dev/null
@@ -1,141 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // The composer beyond words: a poll in a language picked, the persona's own post edited and its history shown, and a
- // quote, each checked on PrivaPub
- public sealed class ComposerTests(Browser browser)
- {
- static async Task OpenHome(IPage page)
- {
- await page.GotoAsync($"{Pasture.Site}/");
- await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
- await page.GotoAsync($"{Pasture.Site}/");
- await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
- }
-
- static async Task Latest(Pasture.Persona persona) =>
- (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault();
-
- [Fact]
- public async Task A_poll_is_published_from_the_composer()
- {
- var persona = Pasture.Personas().First();
- var text = $"which one {Guid.NewGuid():N}"[..18];
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- string made = null;
- try
- {
- await Results.Cell("client.action.poll", "privapub", persona.UserName, async () =>
- {
- await OpenHome(page);
- await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
- await page.Locator("textarea[data-compose]").First.FillAsync(text);
- await page.Locator("button[data-poll-toggle]").ClickAsync();
- var choices = page.Locator("[data-poll-editor] input[type=text]");
- await choices.Nth(0).FillAsync("tea");
- await choices.Nth(1).FillAsync("coffee");
- // and in the language picked
- await page.Locator("[data-compose-language]").First.SelectOptionAsync("it");
- // (the composer's own submit: a card's time is a "Post" button too, its nerd stats)
- await page.Locator("form:has(textarea[data-compose]) button[type=submit]").First.ClickAsync();
- JsonNode latest = null;
- Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue().Contains(text) == true, 15),
- "the poll was never published");
- made = latest!["id"]!.GetValue();
- Assert.Equal(new[] { "tea", "coffee" }, latest["poll"]!["options"]!.AsArray().Select(o => o!["title"]!.GetValue()));
- Assert.Equal("it", latest["language"]?.GetValue());
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- if (made is not null)
- await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
- }
- }
-
- // a mention being written is offered the accounts PrivaPub knows, and a click writes the whole handle out
- [Fact]
- public async Task A_mention_being_written_is_completed_from_the_accounts_the_server_knows()
- {
- var personas = Pasture.Personas();
- Assert.SkipWhen(personas.Count < 2, "the town has one persona");
- var (author, other) = (personas[0], personas[1]);
- var acct = (await PersonaApi.Call(other, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue();
- await using var session = await SignIn.As(browser, author);
- var page = session.Page;
- await Results.Cell("client.action.mention-suggestion", "privapub", author.UserName, async () =>
- {
- await OpenHome(page);
- await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
- var box = page.Locator("textarea[data-compose]").First;
- await box.FillAsync("hello @" + acct[..Math.Min(4, acct.Length)]);
- await page.Locator($"[data-suggestions] [data-suggestion='@{acct}']").ClickAsync(new() { Timeout = 15000 });
- await Assertions.Expect(box).ToHaveValueAsync($"hello @{acct} ");
- await Assertions.Expect(page.Locator("[data-suggestions]")).ToHaveCountAsync(0);
- await session.AssertGuards();
- }, page);
- }
-
- [Fact]
- public async Task The_personas_own_post_is_edited_and_quoted_from_its_card()
- {
- var persona = Pasture.Personas().First();
- var text = $"first words {Guid.NewGuid():N}"[..20];
- var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "public" }))!;
- var id = post["id"]!.GetValue();
- string quote = null;
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- try
- {
- await Results.Cell("client.action.edit-quote", "privapub", persona.UserName, async () =>
- {
- await OpenHome(page);
- var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue()}']").First;
- await card.WaitForAsync(new() { Timeout = 30000 });
-
- await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
- await card.Locator("button[data-edit]").ClickAsync();
- var box = card.Locator("textarea[data-compose]");
- await Assertions.Expect(box).ToHaveValueAsync(text, new() { Timeout = 10000 });
- await box.FillAsync(text + " (edited)");
- await card.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
- await Assertions.Expect(card).ToContainTextAsync(text + " (edited)", new() { Timeout = 10000 });
- Assert.Contains("(edited)", (await PersonaApi.Call(persona, $"/api/v1/statuses/{id}"))!["content"]!.GetValue());
- await card.Locator("button[data-history]").ClickAsync();
- var versions = card.Locator("[data-history-list] [data-version]");
- await Assertions.Expect(versions).ToHaveCountAsync(2, new() { Timeout = 10000 });
- await Assertions.Expect(versions.First).ToContainTextAsync(text + " (edited)");
- await Assertions.Expect(versions.Last).ToContainTextAsync(text);
- await Assertions.Expect(versions.Last).Not.ToContainTextAsync("(edited)");
- await page.Keyboard.PressAsync("Escape");
- await Assertions.Expect(card.Locator("[data-history-list]")).ToHaveCountAsync(0);
-
- await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
- await card.Locator("button[data-quote]").ClickAsync();
- await card.Locator("textarea[data-compose]").FillAsync("quoting myself");
- await card.GetByRole(AriaRole.Button, new() { Name = "Quote" }).Last.ClickAsync();
- JsonNode latest = null;
- Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue().Contains("quoting myself") == true, 15),
- "the quote was never published");
- quote = latest!["id"]!.GetValue();
- Assert.Equal(id, (latest["quote"]?["quoted_status"]?["id"] ?? latest["quote"]?["quoted_status_id"])?.GetValue());
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- if (quote is not null)
- await PersonaApi.Call(persona, $"/api/v1/statuses/{quote}", HttpMethod.Delete);
- await PersonaApi.Call(persona, $"/api/v1/statuses/{id}", HttpMethod.Delete);
- }
- }
- }
-}
diff --git a/tests/e2e/Actions/ConversationTests.cs b/tests/e2e/Actions/ConversationTests.cs
deleted file mode 100644
index 86cd6c4..0000000
--- a/tests/e2e/Actions/ConversationTests.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // Direct conversations: a message from another persona badges the Direct tab and its card says unread; marking it
- // read and taking it off the list are checked on PrivaPub
- public sealed class ConversationTests(Browser browser)
- {
- [Fact]
- public async Task A_new_message_is_unread_until_marked_and_leaves_the_list_when_removed()
- {
- var personas = Pasture.Personas();
- Assert.SkipWhen(personas.Count < 2, "the town has fewer than two personas");
- var (reader, writer) = (personas[0], personas[1]);
- var message = (await PersonaApi.Call(writer, "/api/v1/statuses", HttpMethod.Post, new JsonObject
- {
- ["status"] = $"@{reader.UserName} just between us {Guid.NewGuid():N}"[..40], ["visibility"] = "direct"
- }))!;
- var uri = message["uri"]!.GetValue();
- async Task Conversation() => (await PersonaApi.Call(reader, "/api/v1/conversations?limit=40"))!.AsArray()
- .FirstOrDefault(c => c!["last_status"]?["uri"]?.GetValue() == uri);
- await using var session = await SignIn.As(browser, reader);
- var page = session.Page;
- try
- {
- await Results.Cell("client.action.conversation", "privapub", reader.UserName, async () =>
- {
- Assert.True(await Pasture.Eventually(async () => (await Conversation())?["unread"]?.GetValue() == true, 15),
- "the message never made an unread conversation");
- await page.GotoAsync($"{Pasture.Site}/");
- await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
- await page.GotoAsync($"{Pasture.Site}/");
- await page.Locator("[data-unread-conversations]").WaitForAsync(new() { Timeout = 30000 });
-
- await page.GetByRole(AriaRole.Tab).Filter(new() { Has = page.Locator(".ion-md-mail") }).ClickAsync();
- var card = page.Locator($"article[data-uri='{uri}']");
- await card.Locator("[data-conversation-unread]").WaitForAsync(new() { Timeout = 30000 });
- await card.Locator("[data-conversation-read]").ClickAsync();
- await Assertions.Expect(card.Locator("[data-conversation-unread]")).ToHaveCountAsync(0, new() { Timeout = 10000 });
- Assert.False((await Conversation())!["unread"]!.GetValue());
-
- await card.Locator("[data-conversation-remove]").ClickAsync();
- await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 10000 });
- Assert.Null(await Conversation());
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- await PersonaApi.Call(writer, $"/api/v1/statuses/{message["id"]!.GetValue()}", HttpMethod.Delete);
- }
- }
- }
-}
diff --git a/tests/e2e/Actions/DiscoveryTests.cs b/tests/e2e/Actions/DiscoveryTests.cs
deleted file mode 100644
index ff99317..0000000
--- a/tests/e2e/Actions/DiscoveryTests.cs
+++ /dev/null
@@ -1,111 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // Posts written for later, hashtags followed and what Explore shows, each checked against PrivaPub's own answer
- public sealed class DiscoveryTests(Browser browser)
- {
- [Fact]
- public async Task A_post_scheduled_in_the_composer_waits_is_moved_and_is_dropped()
- {
- var persona = Pasture.Personas().First();
- var text = $"later {Guid.NewGuid():N}"[..20];
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- await Results.Cell("client.action.schedule", "privapub", persona.UserName, async () =>
- {
- await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
- await page.GotoAsync($"{Pasture.Site}/");
- await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
- await page.Locator("textarea").First.FillAsync(text);
- await page.Locator("button[data-schedule]").ClickAsync();
- await page.Locator("input[data-schedule-at]").FillAsync(DateTime.Now.AddHours(2).ToString("yyyy-MM-ddTHH:mm"));
- await page.GetByRole(AriaRole.Button, new() { Name = "Schedule" }).ClickAsync();
- JsonNode waiting = null;
- Assert.True(await Pasture.Eventually(async () => (waiting = (await PersonaApi.Call(persona, "/api/v1/scheduled_statuses"))!.AsArray()
- .FirstOrDefault(s => s!["params"]!["text"]?.GetValue() == text)) is not null, 15), "PrivaPub never kept the scheduled post");
- var id = waiting!["id"]!.GetValue();
- Assert.DoesNotContain((await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=10"))!.AsArray(),
- s => s!["content"]!.GetValue().Contains(text));
-
- await page.GotoAsync($"{Pasture.Site}/scheduled");
- var item = page.Locator($"article[data-scheduled-id='{id}']");
- await Assertions.Expect(item).ToContainTextAsync(text, new() { Timeout = 15000 });
- var later = DateTime.Now.AddHours(5);
- await item.Locator("input[type=datetime-local]").FillAsync(later.ToString("yyyy-MM-ddTHH:mm"));
- await item.GetByRole(AriaRole.Button, new() { Name = "Move" }).ClickAsync();
- Assert.True(await Pasture.Eventually(async () =>
- (await PersonaApi.Call(persona, $"/api/v1/scheduled_statuses/{id}"))?["scheduled_at"]?.GetValue().ToUniversalTime()
- is { } at && Math.Abs((at - later.ToUniversalTime()).TotalMinutes) < 2, 15), "PrivaPub never moved the post");
- await item.GetByRole(AriaRole.Button, new() { Name = "Never publish it" }).ClickAsync();
- await page.Locator("[data-confirm-yes]").ClickAsync();
- await Assertions.Expect(item).ToHaveCountAsync(0, new() { Timeout = 15000 });
- Assert.Null(await PersonaApi.Call(persona, $"/api/v1/scheduled_statuses/{id}"));
- await session.AssertGuards();
- }, page);
- }
-
- [Fact]
- public async Task A_hashtag_is_followed_from_its_feed_tab_and_listed_in_explore()
- {
- var persona = Pasture.Personas().First();
- var tag = $"e2etag{Guid.NewGuid():N}"[..14];
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- try
- {
- await Results.Cell("client.action.follow-tag", "privapub", persona.UserName, async () =>
- {
- // a reader finds the hashtag through search, which opens it as a tab of the feed
- await page.GotoAsync($"{Pasture.Site}/search?q={Uri.EscapeDataString("#" + tag)}");
- await page.GetByRole(AriaRole.Button, new() { Name = "#" + tag }).ClickAsync(new() { Timeout = 30000 });
- var follow = page.Locator("button[data-tag-follow]");
- await Assertions.Expect(follow).ToContainTextAsync($"Follow #{tag}", new() { Timeout = 30000 });
- await follow.ClickAsync();
- await Assertions.Expect(follow).ToContainTextAsync($"Stop following #{tag}", new() { Timeout = 15000 });
- Assert.True((await PersonaApi.Call(persona, $"/api/v1/tags/{tag}"))!["following"]!.GetValue());
-
- await page.GotoAsync($"{Pasture.Site}/explore");
- await page.GetByRole(AriaRole.Tab, new() { Name = "Followed hashtags" }).ClickAsync();
- var row = page.Locator($"[data-tag='{tag}']");
- await row.WaitForAsync(new() { Timeout = 15000 });
- await row.GetByRole(AriaRole.Button, new() { Name = "Unfollow" }).ClickAsync();
- await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 });
- Assert.False((await PersonaApi.Call(persona, $"/api/v1/tags/{tag}"))!["following"]!.GetValue());
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- await PersonaApi.Call(persona, $"/api/v1/tags/{tag}/unfollow", HttpMethod.Post);
- }
- }
-
- [Fact]
- public async Task Explore_shows_what_privapub_says_is_trending_and_its_directory()
- {
- var persona = Pasture.Personas().First();
- var trending = (await PersonaApi.Call(persona, "/api/v1/trends/tags?limit=20"))!.AsArray().Select(t => t!["name"]!.GetValue()).ToList();
- var directory = (await PersonaApi.Call(persona, "/api/v1/directory?order=active&local=false&limit=40"))!.AsArray().Select(a => a!["id"]!.GetValue()).ToList();
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- await Results.Cell("client.render.explore", "privapub", persona.UserName, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/explore");
- await page.Locator("[data-tag], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
- var shownTags = await page.Locator("[data-tag]").EvaluateAllAsync("rows => rows.map(r => r.dataset.tag)");
- Assert.Equal(trending, shownTags);
-
- await page.GetByRole(AriaRole.Tab, new() { Name = "People" }).ClickAsync();
- await page.Locator("[data-account], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
- var shownPeople = await page.Locator("[data-account]").EvaluateAllAsync("rows => rows.map(r => r.dataset.account)");
- Assert.Equal(directory, shownPeople);
- await session.AssertGuards();
- }, page);
- }
- }
-}
diff --git a/tests/e2e/Actions/EventTests.cs b/tests/e2e/Actions/EventTests.cs
deleted file mode 100644
index c8fdc48..0000000
--- a/tests/e2e/Actions/EventTests.cs
+++ /dev/null
@@ -1,70 +0,0 @@
-using System.Diagnostics;
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // Joining another server's event from its card (an owner decision): the persona is asked once that taking part is
- // public, Mobilizon makes it a participant and accepts, and leaving takes it off there. The event is the one
- // SocialPub's tools/pasture/scenarios/mobilizon.sh keeps for these tests.
- public sealed class EventTests(Browser browser)
- {
- [Fact]
- public async Task A_persona_joins_a_mobilizon_event_from_its_card_and_leaves_it()
- {
- var kept = Path.Combine(Pasture.Root, ".state", "mobilizon-kept-event.json");
- Assert.SkipUnless(File.Exists(kept), "no Mobilizon event kept for the client: run tools/pasture/interop.sh mobilizon");
- var url = JsonNode.Parse(File.ReadAllText(kept))!["url"]!.GetValue();
- var personas = Pasture.Personas();
- Assert.SkipWhen(personas.Count == 0, "the town has no personas");
- var reader = personas[0];
- var status = (await PersonaApi.Call(reader, $"/api/v2/search?q={Uri.EscapeDataString(url)}&resolve=true&type=statuses"))?["statuses"]?.AsArray().FirstOrDefault();
- Assert.SkipWhen(status is null, "PrivaPub cannot resolve the kept Mobilizon event");
- var id = status!["id"]!.GetValue();
- async Task Participation() =>
- (await PersonaApi.Call(reader, $"/api/v1/statuses/{id}"))?["privapub"]?["event"]?["participation"]?.GetValue();
- // a run cut short may have left the persona in: start from outside
- if (await Participation() is not null)
- await PersonaApi.Call(reader, $"/api/privapub/v1/statuses/{id}/leave", HttpMethod.Post);
- await using var session = await SignIn.As(browser, reader);
- var page = session.Page;
- await Results.Cell("client.action.join", "mobilizon", "kept-event", async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/expand/{id}");
- var card = page.Locator($"article[data-uri='{status["uri"]!.GetValue()}']").First;
- await card.Locator("[data-event-join]").ClickAsync(new() { Timeout = 30000 });
- await VoteAndReactTests.AcceptNoticeIfAsked(page);
- await card.Locator("[data-event-leave]").WaitForAsync(new() { Timeout = 15000 });
- Assert.True(await Pasture.Eventually(async () => await Participation() == "accepted", 45), "Mobilizon never accepted the Join");
- Assert.Equal("participant", await MobilizonRole(reader.ActorUri, url));
-
- await page.ReloadAsync();
- await Assertions.Expect(card.Locator("[data-participation]")).ToHaveAttributeAsync("data-participation", "accepted", new() { Timeout = 30000 });
- await card.Locator("[data-event-leave]").ClickAsync();
- await Assertions.Expect(card.Locator("[data-participation]")).ToHaveAttributeAsync("data-participation", "none", new() { Timeout = 15000 });
- Assert.Null(await Participation());
- Assert.True(await Pasture.Eventually(async () => await MobilizonRole(reader.ActorUri, url) == "", 45),
- "the persona is still a participant on Mobilizon");
- await session.AssertGuards();
- }, page);
- }
-
- // the persona's role in the event, from Mobilizon's database; empty when it takes no part
- static async Task MobilizonRole(string actorUri, string eventUrl)
- {
- static string Quoted(string value) => "'" + value.Replace("'", "''") + "'";
- var start = new ProcessStartInfo("podman") { RedirectStandardOutput = true, RedirectStandardError = true };
- foreach (var arg in new[] { "exec", "pasture-postgis", "psql", "-U", "pasture", "-d", "mobilizon", "-tAc",
- "select p.role from participants p join actors a on a.id = p.actor_id join events e on e.id = p.event_id " +
- $"where a.url = {Quoted(actorUri)} and e.url = {Quoted(eventUrl)}" })
- start.ArgumentList.Add(arg);
- using var process = Process.Start(start)!;
- var output = await process.StandardOutput.ReadToEndAsync();
- await process.WaitForExitAsync();
- return output.Trim();
- }
- }
-}
diff --git a/tests/e2e/Actions/FeedMemoryTests.cs b/tests/e2e/Actions/FeedMemoryTests.cs
deleted file mode 100644
index 540ab56..0000000
--- a/tests/e2e/Actions/FeedMemoryTests.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // Coming back to the feed from a thread shows it as it was left, with its older pages, without reading it again
- // (Services/FeedMemory.cs).
- public sealed class FeedMemoryTests(Browser browser)
- {
- [Fact]
- public async Task The_feed_comes_back_from_a_thread_as_it_was_left()
- {
- var persona = Pasture.Personas().First();
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- // a deletion the stream tells (late federated Deletes from the tests before) takes its post out at once
- var deletions = 0;
- page.WebSocket += (_, socket) => socket.FrameReceived += (_, frame) =>
- {
- if (frame.Text?.Contains("\"event\":\"delete\"") == true)
- Interlocked.Increment(ref deletions);
- };
- await Results.Cell("client.action.feed-memory", "privapub", persona.UserName, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/");
- await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
- await page.GotoAsync($"{Pasture.Site}/");
- var feed = page.Locator("[data-feed-count]");
- await page.Locator("article[data-uri]").First.WaitForAsync(new() { Timeout = 30000 });
- // an older page too when there is one, so there is more to keep than the first
- await page.EvalOnSelectorAsync("#feed-scroll", "root => root.scrollTop = root.scrollHeight");
- await Pasture.Eventually(async () => int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0") > 20, 10);
- var count = int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0");
- var deletedBefore = Volatile.Read(ref deletions);
-
- var homeReads = 0;
- page.Request += (_, request) =>
- {
- if (request.Url.Contains("/api/v1/timelines/home") && !request.Url.Contains("min_id"))
- homeReads++;
- };
- await page.EvalOnSelectorAsync("#feed-scroll", "root => root.scrollTop = 0");
- await page.Locator("article[data-uri] footer a[title='Expand']").First.ClickAsync();
- await page.WaitForURLAsync("**/expand/**");
- await page.GoBackAsync();
- await page.Locator("article[data-uri]").First.WaitForAsync(new() { Timeout = 10000 });
-
- var back = int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0");
- var deleted = Volatile.Read(ref deletions) - deletedBefore;
- Assert.True(back >= count - deleted, $"the feed came back with {back} posts, left with {count} ({deleted} deleted meanwhile)");
- Assert.Equal(0, homeReads);
- await session.AssertGuards();
- }, page);
- }
- }
-}
diff --git a/tests/e2e/Actions/FilterTests.cs b/tests/e2e/Actions/FilterTests.cs
deleted file mode 100644
index e89863d..0000000
--- a/tests/e2e/Actions/FilterTests.cs
+++ /dev/null
@@ -1,72 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // A filter in decePub: made on /filters with a word only one post has, which the home feed then folds behind the
- // filter's title (warn) and leaves out once the filter hides; PrivaPub keeps what the page sent.
- public sealed class FilterTests(Browser browser)
- {
- [Fact]
- public async Task A_filters_word_folds_then_hides_a_post_in_the_home_feed()
- {
- var persona = Pasture.Personas().First();
- var word = $"zq{Guid.NewGuid():N}"[..10];
- var title = $"e2e {word}";
- foreach (var left in (await PersonaApi.Call(persona, "/api/v2/filters"))!.AsArray().Where(f => f!["title"]!.GetValue().StartsWith("e2e ")))
- await PersonaApi.Call(persona, $"/api/v2/filters/{left!["id"]!.GetValue()}", HttpMethod.Delete);
- var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = $"a post about {word}", ["visibility"] = "unlisted" }))!;
- var uri = post["uri"]!.GetValue();
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- try
- {
- await Results.Cell("client.action.filter", "privapub", persona.UserName, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/filters");
- await page.Locator("input[data-filter-new]").FillAsync(title);
- await page.GetByRole(AriaRole.Button, new() { Name = "Make a filter" }).ClickAsync();
- var article = page.Locator("article[data-filter-id]").Filter(new() { HasText = title });
- await article.Locator("input[data-filter-keyword-new]").FillAsync(word);
- await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
- var filterId = await article.GetAttributeAsync("data-filter-id");
- Assert.True(await Pasture.Eventually(async () => (await PersonaApi.Call(persona, $"/api/v2/filters/{filterId}"))?["keywords"]?.AsArray()
- .Any(k => k!["keyword"]!.GetValue() == word) == true, 15), "PrivaPub never got the filter's word");
-
- // warn: the card is folded behind the filter's title, and opens on request
- // the feed opens on its default tab, Home, when no tab is remembered
- await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
- await page.GotoAsync($"{Pasture.Site}/");
- var card = page.Locator($"article[data-uri='{uri}']");
- await card.WaitForAsync(new() { Timeout = 30000 });
- await Assertions.Expect(card.Locator("[data-filtered]")).ToContainTextAsync(title);
- await Assertions.Expect(card).Not.ToContainTextAsync($"a post about {word}");
- await card.GetByRole(AriaRole.Button, new() { Name = "Show anyway" }).ClickAsync();
- await Assertions.Expect(card).ToContainTextAsync($"a post about {word}");
-
- // hide: the card is left out
- await page.GotoAsync($"{Pasture.Site}/filters");
- article = page.Locator($"article[data-filter-id='{filterId}']");
- await article.GetByRole(AriaRole.Button, new() { Name = "Edit" }).ClickAsync();
- await article.Locator("select").First.SelectOptionAsync("Hide");
- await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
- Assert.True(await Pasture.Eventually(async () => (await PersonaApi.Call(persona, $"/api/v2/filters/{filterId}"))?["filter_action"]?
- .GetValue() == "hide", 15), "PrivaPub never made the filter hide");
- await page.GotoAsync($"{Pasture.Site}/");
- await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
- await Assertions.Expect(page.Locator($"article[data-uri='{uri}']")).ToHaveCountAsync(0);
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- await PersonaApi.Call(persona, $"/api/v1/statuses/{post["id"]!.GetValue()}", HttpMethod.Delete);
- foreach (var made in (await PersonaApi.Call(persona, "/api/v2/filters"))!.AsArray().Where(f => f!["title"]!.GetValue() == title))
- await PersonaApi.Call(persona, $"/api/v2/filters/{made!["id"]!.GetValue()}", HttpMethod.Delete);
- }
- }
- }
-}
diff --git a/tests/e2e/Actions/GroupTests.cs b/tests/e2e/Actions/GroupTests.cs
deleted file mode 100644
index b1af3c9..0000000
--- a/tests/e2e/Actions/GroupTests.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // A circle made in decePub, joined by another persona with its invitation code, and that member taken out by the
- // owner, who alone sees who is in it
- public sealed class GroupTests(Browser browser)
- {
- [Fact]
- public async Task A_circle_is_made_joined_by_invitation_and_its_member_taken_out()
- {
- var personas = Pasture.Personas();
- Assert.SkipWhen(personas.Count < 2, "the town has fewer than two personas");
- var (owner, joiner) = (personas[0], personas[1]);
- var userName = $"e2ecircle{DateTime.UtcNow:MMddHHmmss}";
- await using var ownerSession = await SignIn.As(browser, owner);
- await using var joinerSession = await SignIn.As(browser, joiner);
- var page = ownerSession.Page;
- await Results.Cell("client.action.group", "privapub", owner.UserName, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/groups");
- var form = page.Locator("[data-group-new]");
- await form.Locator("input[data-group-username]").FillAsync(userName);
- await form.Locator("input[data-group-name]").FillAsync(userName);
- await form.Locator("[data-group-create]").ClickAsync();
- var invitation = page.Locator("[data-group-invitation]");
- await invitation.WaitForAsync(new() { Timeout = 30000 });
- var code = await invitation.InputValueAsync();
- Assert.Equal(64, code.Length);
-
- var other = joinerSession.Page;
- await other.GotoAsync($"{Pasture.Site}/groups");
- var join = other.Locator("[data-group-join]");
- await join.Locator("input[data-group-code]").FillAsync(code);
- await join.Locator("[data-group-join-submit]").ClickAsync();
- await other.Locator("article[data-group-id] [data-group-leave]").First.WaitForAsync(new() { Timeout = 30000 });
-
- // closed and opened again, so its members are read after the join
- var card = page.Locator("article[data-group-id]").Filter(new() { HasText = "@" + userName });
- await card.Locator("[data-group-manage]").ClickAsync();
- await card.Locator("[data-group-manage]").ClickAsync();
- var member = card.Locator($"[data-member='{joiner.ActorUri}']");
- await member.WaitForAsync(new() { Timeout = 30000 });
- Assert.Equal("false", await member.GetAttributeAsync("data-pending"));
- await member.Locator("[data-member-remove]").ClickAsync();
- await Assertions.Expect(member).ToHaveCountAsync(0, new() { Timeout = 15000 });
-
- await other.ReloadAsync();
- await other.Locator("[data-group-new]").WaitForAsync(new() { Timeout = 30000 });
- await Assertions.Expect(other.Locator("article[data-group-id]").Filter(new() { HasText = "@" + userName })).ToHaveCountAsync(0, new() { Timeout = 15000 });
- await ownerSession.AssertGuards();
- await joinerSession.AssertGuards();
- }, page);
- }
- }
-}
diff --git a/tests/e2e/Actions/ListTests.cs b/tests/e2e/Actions/ListTests.cs
deleted file mode 100644
index 88950fd..0000000
--- a/tests/e2e/Actions/ListTests.cs
+++ /dev/null
@@ -1,115 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // A list in decePub: made on /lists, filled with an account the persona follows (found by its name among the accounts
- // it follows), read as the feed's Lists tab, made exclusive and deleted. Each step is checked against PrivaPub's own
- // answer to the persona's token, so the page and the server agree on the list, its members and its posts.
- public sealed class ListTests(Browser browser)
- {
- static Task Api(Pasture.Persona persona, string path, HttpMethod method = null, JsonObject body = null) =>
- PersonaApi.Call(persona, path, method, body);
-
- // an account the persona follows that has written something
- static async Task Followed(Pasture.Persona persona) =>
- (await Api(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=80"))!.AsArray()
- .FirstOrDefault(a => a!["statuses_count"]!.GetValue() > 0 && !a["acct"]!.GetValue().StartsWith("st_"));
-
- [Fact]
- public async Task An_account_joins_and_leaves_a_list_from_its_profile()
- {
- var persona = Pasture.Personas().First();
- var member = await Followed(persona);
- Assert.SkipWhen(member is null, $"{persona.UserName} follows nobody who has posted");
- var memberId = member!["id"]!.GetValue();
- var listId = (await Api(persona, "/api/v1/lists", HttpMethod.Post, new JsonObject { ["title"] = $"e2e {Guid.NewGuid():N}"[..12] }))!["id"]!.GetValue();
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- try
- {
- await Results.Cell("client.action.list-member", "privapub", persona.UserName, async () =>
- {
- async Task InList() =>
- (await Api(persona, $"/api/v1/lists/{listId}/accounts"))!.AsArray().Any(a => a!["id"]!.GetValue() == memberId);
-
- await page.GotoAsync($"{Pasture.Site}/account/{memberId}");
- var profile = page.Locator("article[data-account-id]").First;
- await profile.WaitForAsync(new() { Timeout = 30000 });
- await profile.GetByRole(AriaRole.Button, new() { Name = "Lists" }).ClickAsync();
- var box = profile.Locator($"[data-list-id='{listId}'] input");
- await box.CheckAsync();
- Assert.True(await Pasture.Eventually(InList, 15), "the profile's checkbox never put the account in the list");
- await box.UncheckAsync();
- Assert.True(await Pasture.Eventually(async () => !await InList(), 15), "the profile's checkbox never took the account out");
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- await Api(persona, $"/api/v1/lists/{listId}", HttpMethod.Delete);
- }
- }
-
- [Fact]
- public async Task A_list_is_made_filled_read_as_a_feed_and_deleted()
- {
- var persona = Pasture.Personas().First();
- var member = await Followed(persona);
- Assert.SkipWhen(member is null, $"{persona.UserName} follows nobody who has posted");
- var memberId = member!["id"]!.GetValue();
- var title = $"e2e {Guid.NewGuid():N}"[..12];
- // lists an earlier failed run left behind
- foreach (var left in (await Api(persona, "/api/v1/lists"))!.AsArray().Where(l => l!["title"]!.GetValue().StartsWith("e2e ")))
- await Api(persona, $"/api/v1/lists/{left!["id"]!.GetValue()}", HttpMethod.Delete);
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- await Results.Cell("client.action.list", "privapub", persona.UserName, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/lists");
- await page.Locator("[data-list-new] input, input[data-list-new]").First.FillAsync(title);
- await page.GetByRole(AriaRole.Button, new() { Name = "Make a list" }).ClickAsync();
- var article = page.Locator("article[data-list-id]").Filter(new() { HasText = title });
- await article.WaitForAsync(new() { Timeout = 15000 });
- var listId = await article.GetAttributeAsync("data-list-id");
- Assert.Contains((await Api(persona, "/api/v1/lists"))!.AsArray(), l => l!["id"]!.GetValue() == listId);
-
- // the list opens once made: find the member among the accounts followed, and add it
- await article.Locator("[data-list-find] input, input[data-list-find]").First.FillAsync(member["username"]!.GetValue());
- await article.Locator("[data-list-find] input, input[data-list-find]").First.PressAsync("Enter");
- var candidate = article.Locator($"[data-candidate-id='{memberId}']");
- await candidate.WaitForAsync(new() { Timeout = 15000 });
- await candidate.GetByRole(AriaRole.Button, new() { Name = "Add" }).ClickAsync();
- await article.Locator($"[data-member-id='{memberId}']").WaitForAsync(new() { Timeout = 15000 });
- Assert.Equal([memberId], (await Api(persona, $"/api/v1/lists/{listId}/accounts"))!.AsArray().Select(a => a!["id"]!.GetValue()));
-
- // read as a feed: the cards are the list timeline's first page, as PrivaPub answers it
- await article.GetByRole(AriaRole.Button, new() { Name = "Read the list" }).ClickAsync();
- await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
- var expected = (await Api(persona, $"/api/v1/timelines/list/{listId}?limit=20"))!.AsArray()
- .Select(s => (s!["reblog"] ?? s)["uri"]!.GetValue()).ToHashSet();
- var shown = await page.Locator("article[data-uri]").EvaluateAllAsync("cards => cards.map(c => c.dataset.uri)");
- Assert.NotEmpty(expected);
- Assert.Subset(expected, shown.ToHashSet());
-
- // exclusive, then deleted
- await page.GotoAsync($"{Pasture.Site}/lists");
- article = page.Locator($"article[data-list-id='{listId}']");
- await article.GetByRole(AriaRole.Button, new() { Name = "Edit" }).ClickAsync();
- await article.GetByLabel("Keep its members out of the home feed").CheckAsync();
- await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
- Assert.True(await Pasture.Eventually(async () => (await Api(persona, $"/api/v1/lists/{listId}"))?["exclusive"]?.GetValue() == true, 15),
- "PrivaPub never made the list exclusive");
- // the icon's glyph (CSS content) is part of a button's name, so names are matched as substrings
- await article.GetByRole(AriaRole.Button, new() { Name = "Delete" }).First.ClickAsync();
- await article.GetByRole(AriaRole.Button, new() { Name = "Delete it for good" }).ClickAsync();
- await Assertions.Expect(article).ToHaveCountAsync(0, new() { Timeout = 15000 });
- Assert.Null(await Api(persona, $"/api/v1/lists/{listId}"));
- await session.AssertGuards();
- }, page);
- }
- }
-}
diff --git a/tests/e2e/Actions/MediaTests.cs b/tests/e2e/Actions/MediaTests.cs
deleted file mode 100644
index 73bf850..0000000
--- a/tests/e2e/Actions/MediaTests.cs
+++ /dev/null
@@ -1,184 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // Media from the composer and the profile, checked on PrivaPub: a picture uploaded as soon as it is picked and posted
- // with its description, the reminder before a picture without one, a failed upload retried without the rest being sent
- // again, and an avatar set and removed
- public sealed class MediaTests(Browser browser)
- {
- // a 1×1 PNG
- static readonly byte[] Png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==");
-
- static async Task OpenComposer(IPage page)
- {
- await page.GotoAsync($"{Pasture.Site}/");
- await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
- await page.GotoAsync($"{Pasture.Site}/");
- await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
- await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
- }
-
- static Task Attach(IPage page, string name) =>
- page.Locator("input[data-attach]").SetInputFilesAsync(new FilePayload { Name = name, MimeType = "image/png", Buffer = Png });
-
- static ILocator Submit(IPage page) => page.Locator("form:has(textarea[data-compose]) button[type=submit]").First;
-
- static async Task Published(Pasture.Persona persona, string text)
- {
- JsonNode latest = null;
- Assert.True(await Pasture.Eventually(async () =>
- (latest = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault())?["content"]
- ?.GetValue().Contains(text) == true, 15), "the post was never published");
- return latest;
- }
-
- [Fact]
- public async Task A_picture_is_uploaded_when_picked_and_posted_with_its_description()
- {
- var persona = Pasture.Personas().First();
- var text = $"a picture {Guid.NewGuid():N}"[..20];
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- string made = null;
- try
- {
- await Results.Cell("client.action.media", "privapub", persona.UserName, async () =>
- {
- await OpenComposer(page);
- await page.Locator("textarea[data-compose]").First.FillAsync(text);
- await Attach(page, "dot.png");
- // uploaded on its own, before anything is published
- await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
- await page.Locator("[data-alt-text]").First.FillAsync("a single dot");
- await Submit(page).ClickAsync();
-
- var post = await Published(persona, text);
- made = post["id"]!.GetValue();
- Assert.Equal("a single dot", post["media_attachments"]![0]!["description"]!.GetValue());
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- if (made is not null)
- await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
- }
- }
-
- [Fact]
- public async Task A_picture_without_a_description_is_asked_about_once()
- {
- var persona = Pasture.Personas().First();
- var text = $"undescribed {Guid.NewGuid():N}"[..22];
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- string made = null;
- try
- {
- await Results.Cell("client.action.media-reminder", "privapub", persona.UserName, async () =>
- {
- await OpenComposer(page);
- await page.Locator("textarea[data-compose]").First.FillAsync(text);
- await Attach(page, "dot.png");
- await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
- await Submit(page).ClickAsync();
-
- await Assertions.Expect(page.Locator("[data-alt-reminder]")).ToBeVisibleAsync();
- await page.Locator("[data-alt-publish-anyway]").ClickAsync();
-
- var post = await Published(persona, text);
- made = post["id"]!.GetValue();
- Assert.Null(post["media_attachments"]![0]!["description"]?.GetValue());
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- if (made is not null)
- await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
- }
- }
-
- [Fact]
- public async Task A_failed_upload_is_retried_alone()
- {
- var persona = Pasture.Personas().First();
- var text = $"retried {Guid.NewGuid():N}"[..18];
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- string made = null;
- try
- {
- await Results.Cell("client.action.media-retry", "privapub", persona.UserName, async () =>
- {
- // the second upload is refused once, as a server would refuse it
- var uploads = 0;
- await page.RouteAsync("**/api/v2/media", async route =>
- {
- if (Interlocked.Increment(ref uploads) == 2)
- await route.FulfillAsync(new() { Status = 422, ContentType = "application/json", Body = "{\"error\":\"Validation failed: try again\"}" });
- else
- await route.ContinueAsync();
- });
- await OpenComposer(page);
- await page.Locator("textarea[data-compose]").First.FillAsync(text);
- await Attach(page, "first.png");
- await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
- await Attach(page, "second.png");
- await Assertions.Expect(page.Locator("[data-upload-state=failed]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
- await Assertions.Expect(page.Locator("[data-upload-state=failed]")).ToContainTextAsync("try again");
-
- await page.Locator("[data-upload-retry]").ClickAsync();
- await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(2, new() { Timeout = 20000 });
- foreach (var alt in await page.Locator("[data-alt-text]").AllAsync())
- await alt.FillAsync("a dot");
- await Submit(page).ClickAsync();
-
- var post = await Published(persona, text);
- made = post["id"]!.GetValue();
- Assert.Equal(2, post["media_attachments"]!.AsArray().Count);
- // the first file was sent once: one upload for it, two for the second (refused, then retried)
- Assert.Equal(3, uploads);
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- if (made is not null)
- await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
- }
- }
-
- [Fact]
- public async Task An_avatar_is_set_and_removed_from_settings()
- {
- var persona = Pasture.Personas().Last();
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- await Results.Cell("client.action.avatar", "privapub", persona.UserName, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/settings");
- var header = page.Locator(".collapse-title", new() { HasText = "Profile" }).First;
- await header.ClickAsync();
- var editor = header.Locator("xpath=..").Locator("[data-profile-editor]");
- await editor.WaitForAsync(new() { Timeout = 30000 });
-
- await editor.Locator("input[data-profile-avatar]").SetInputFilesAsync(new FilePayload { Name = "me.png", MimeType = "image/png", Buffer = Png });
- await editor.Locator("[data-profile-save]").ClickAsync();
- await Assertions.Expect(page.GetByText("Profile saved.")).ToBeVisibleAsync(new() { Timeout = 15000 });
- var set = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["avatar"]!.GetValue();
- Assert.DoesNotContain("missing", set);
-
- await editor.Locator("[data-profile-avatar-remove]").ClickAsync();
- await Assertions.Expect(page.GetByText("Avatar removed.")).ToBeVisibleAsync(new() { Timeout = 15000 });
- Assert.Contains("missing", (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["avatar"]!.GetValue());
- await session.AssertGuards();
- }, page);
- }
- }
-}
diff --git a/tests/e2e/Actions/NotificationPolicyTests.cs b/tests/e2e/Actions/NotificationPolicyTests.cs
deleted file mode 100644
index c63aa05..0000000
--- a/tests/e2e/Actions/NotificationPolicyTests.cs
+++ /dev/null
@@ -1,76 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // The persona's notification policy from Settings: people it does not follow are filtered, a like from one waits in a
- // request the Notifications tab leads to, and letting the request in brings the notification back; the policy is put back to accepting everything, and
- // the stranger's permission is taken away again
- public sealed class NotificationPolicyTests(Browser browser)
- {
- [Fact]
- public async Task A_strangers_like_waits_in_a_request_the_persona_lets_in()
- {
- var personas = Pasture.Personas();
- var author = personas[0];
- // (a persona of the town the author does not follow)
- var stranger = default(Pasture.Persona);
- foreach (var candidate in personas.Skip(1))
- if ((await PersonaApi.Call(author, $"/api/v1/accounts/relationships?id[]={candidate.LocalId}"))![0]!["following"]!.GetValue() == false)
- {
- stranger = candidate;
- break;
- }
- Assert.SkipWhen(stranger is null, "the town's first persona follows every other");
- var strangerAcct = (await PersonaApi.Call(stranger!, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue();
- var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
- new JsonObject { ["status"] = $"like me, stranger {Guid.NewGuid():N}"[..30], ["visibility"] = "public" }))!;
- var id = post["id"]!.GetValue();
- await using var session = await SignIn.As(browser, author);
- var page = session.Page;
- try
- {
- await Results.Cell("client.action.notification-policy", "privapub", author.UserName, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/settings");
- var header = page.Locator(".collapse-title", new() { HasText = "Notifications" }).First;
- await header.ClickAsync();
- var panel = page.Locator("[data-notification-policy]");
- await panel.WaitForAsync(new() { Timeout = 30000 });
- await panel.Locator("[data-policy='for_not_following']").SelectOptionAsync("filter");
- await panel.Locator("[data-policy-save]").ClickAsync();
- Assert.True(await Pasture.Eventually(async () =>
- (await PersonaApi.Call(author, "/api/v2/notifications/policy"))!["for_not_following"]!.GetValue() == "filter", 15), "the policy was never saved");
-
- await PersonaApi.Call(stranger!, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
- Assert.DoesNotContain((await PersonaApi.Call(author, "/api/v1/notifications?types[]=favourite"))!.AsArray(),
- n => n!["account"]!["acct"]!.GetValue() == strangerAcct && n["status"]?["id"]?.GetValue() == id);
-
- // the Notifications tab says requests are waiting, and leads to them
- await page.GotoAsync($"{Pasture.Site}/");
- await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
- await page.GotoAsync($"{Pasture.Site}/");
- await page.GetByRole(AriaRole.Tab).Filter(new() { Has = page.Locator(".ion-md-notifications") }).ClickAsync();
- await page.Locator("[data-filtered-requests]").ClickAsync(new() { Timeout = 30000 });
- var request = page.Locator($"[data-notification-requests] [data-request='{strangerAcct}']");
- await request.WaitForAsync(new() { Timeout = 30000 });
- await request.Locator("[data-request-accept]").ClickAsync();
- await Assertions.Expect(request).ToHaveCountAsync(0, new() { Timeout = 15000 });
- Assert.Contains((await PersonaApi.Call(author, "/api/v1/notifications?types[]=favourite"))!.AsArray(),
- n => n!["account"]!["acct"]!.GetValue() == strangerAcct && n["status"]?["id"]?.GetValue() == id);
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- await PersonaApi.Call(author, "/api/v2/notifications/policy", HttpMethod.Patch, new JsonObject { ["for_not_following"] = "accept" });
- // (letting a request in lets its account in for good, which no API takes back)
- await Pasture.Mongo($"db.NotificationPermission.deleteMany({{AvatarId: '{author.LocalId}', FromActorURI: '{stranger!.ActorUri}'}})");
- await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
- }
- }
- }
-}
diff --git a/tests/e2e/Actions/NotificationTests.cs b/tests/e2e/Actions/NotificationTests.cs
deleted file mode 100644
index 770b78a..0000000
--- a/tests/e2e/Actions/NotificationTests.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // Notifications as groups: two likes of one post are one card naming both, the tab's badge counts what is unread, and
- // reading the tab moves the marker so PrivaPub counts nothing unread
- public sealed class NotificationTests(Browser browser)
- {
- [Fact]
- public async Task Two_likes_of_one_post_are_one_card_and_reading_them_clears_the_badge()
- {
- var personas = Pasture.Personas();
- Assert.SkipWhen(personas.Count < 3, "the town has fewer than three personas");
- var (author, first, second) = (personas[0], personas[1], personas[2]);
- var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
- new JsonObject { ["status"] = $"like me {Guid.NewGuid():N}"[..16], ["visibility"] = "public" }))!;
- var id = post["id"]!.GetValue();
- await using var session = await SignIn.As(browser, author);
- var page = session.Page;
- try
- {
- await PersonaApi.Call(first, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
- await PersonaApi.Call(second, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
- await Results.Cell("client.render.notification-group", "privapub", author.UserName, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/");
- await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
- await page.GotoAsync($"{Pasture.Site}/");
- await page.Locator("[data-unread]").WaitForAsync(new() { Timeout = 30000 });
-
- await page.GetByRole(AriaRole.Tab).Filter(new() { Has = page.Locator(".ion-md-notifications") }).ClickAsync();
- var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue()}']").Filter(new() { Has = page.Locator("[data-notification='favourite']") });
- await card.WaitForAsync(new() { Timeout = 30000 });
- await Assertions.Expect(card.Locator("[data-notification='favourite']")).ToHaveAttributeAsync("data-count", "2");
- await Assertions.Expect(card).ToContainTextAsync($"{second.Name} and {first.Name} favourited your post");
- Assert.True(await Pasture.Eventually(async () =>
- (await PersonaApi.Call(author, "/api/v2/notifications/unread_count"))!["count"]!.GetValue() == 0, 15), "reading the tab left notifications unread");
- await Assertions.Expect(page.Locator("[data-unread]")).ToHaveCountAsync(0);
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
- }
- }
- }
-}
diff --git a/tests/e2e/Actions/PersonaArchiveTests.cs b/tests/e2e/Actions/PersonaArchiveTests.cs
deleted file mode 100644
index 6792b64..0000000
--- a/tests/e2e/Actions/PersonaArchiveTests.cs
+++ /dev/null
@@ -1,82 +0,0 @@
-using System.IO.Compression;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // A persona's archive from Settings (owner decision 2026-10-07): one town persona's archive made and downloaded through
- // its link, then imported into another, whose profile shows the posts while nobody was told; the copies are taken away
- // again after, so the town stays as seeded
- public sealed class PersonaArchiveTests(Browser browser)
- {
- static async Task Open(IPage page)
- {
- await page.GotoAsync($"{Pasture.Site}/settings");
- var header = page.Locator(".collapse-title", new() { HasText = "Data import/export" }).First;
- await header.ClickAsync();
- var panel = header.Locator("xpath=..").Locator("[data-archive]");
- await panel.WaitForAsync(new() { Timeout = 30000 });
- return panel;
- }
-
- [Fact]
- public async Task An_archive_is_downloaded_and_imported_into_another_persona()
- {
- var personas = Pasture.Personas();
- var (source, target) = (personas[0], personas[1]);
- var file = Path.Combine(Path.GetTempPath(), $"decepub-archive-{Guid.NewGuid():N}.zip");
- try
- {
- await using (var session = await SignIn.As(browser, source))
- {
- var page = session.Page;
- await Results.Cell("client.action.archive-export", "privapub", source.UserName, async () =>
- {
- var panel = await Open(page);
- await panel.Locator("[data-archive-make]").ClickAsync();
- var get = panel.Locator("[data-archive-get-link]");
- await get.WaitForAsync(new() { Timeout = 120000 });
- await get.ClickAsync();
- var link = panel.Locator("[data-archive-link]");
- var download = await page.RunAndWaitForDownloadAsync(() => link.ClickAsync(), new() { Timeout = 120000 });
- await download.SaveAsAsync(file);
- using var archive = ZipFile.OpenRead(file);
- Assert.Contains(archive.Entries, e => e.FullName == "outbox.json");
- Assert.Contains(archive.Entries, e => e.FullName == "actor.json");
- Assert.DoesNotContain(archive.Entries, e => e.FullName.Contains(source.Root, StringComparison.Ordinal));
- await session.AssertGuards();
- }, page);
- }
-
- await using (var session = await SignIn.As(browser, target))
- {
- var page = session.Page;
- await Results.Cell("client.action.archive-import", "privapub", target.UserName, async () =>
- {
- var panel = await Open(page);
- await panel.Locator("input[data-archive-file]").SetInputFilesAsync(file);
- foreach (var part in new[] { "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags", "pins" })
- await panel.Locator($"[data-archive-part='{part}'] input, input[data-archive-part='{part}']").First.UncheckAsync();
- await panel.Locator("[data-archive-import-start]").ClickAsync();
- var report = panel.Locator("[data-archive-report][data-archive-state='done']");
- await report.WaitForAsync(new() { Timeout = 300000 });
- await Assertions.Expect(report.Locator("[data-archive-count='posts']")).ToBeVisibleAsync();
-
- var copies = (await PersonaApi.Call(target, $"/api/v1/accounts/{target.LocalId}/statuses?limit=40"))!.AsArray();
- Assert.NotEmpty(copies);
- await session.AssertGuards();
- }, page);
- }
- }
- finally
- {
- File.Delete(file);
- // the copies go, as if never imported: the town stays as seeded
- await Pasture.Mongo($"const copies = db.Post.find({{GroupUserId:'{target.LocalId}', ImportedFromURI:{{$exists:true, $ne:null}}}}, {{_id:1}}).toArray().map(p => p._id); "
- + "db.MediaAttachment.deleteMany({PostId:{$in:copies.map(id => id.toString())}}); db.Post.deleteMany({_id:{$in:copies}})");
- }
- }
- }
-}
diff --git a/tests/e2e/Actions/PinTests.cs b/tests/e2e/Actions/PinTests.cs
deleted file mode 100644
index d999f7f..0000000
--- a/tests/e2e/Actions/PinTests.cs
+++ /dev/null
@@ -1,55 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // A persona pins its own post from the card, finds it above its posts on its profile, and unpins it. PrivaPub tells
- // the persona's followers with Add and Remove; SocialPub's tools/pasture/scenarios/pins.sh checks that side.
- public sealed class PinTests(Browser browser)
- {
- [Fact]
- public async Task A_persona_pins_its_post_from_the_card_and_its_profile_shows_it_first()
- {
- var persona = Pasture.Personas().First();
- var text = $"pin me {Guid.NewGuid():N}"[..20];
- var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "public" }))!;
- var id = post["id"]!.GetValue();
- var me = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["id"]!.GetValue();
- async Task Pinned() =>
- (await PersonaApi.Call(persona, $"/api/v1/accounts/{me}/statuses?pinned=true"))!.AsArray().Any(s => s!["id"]!.GetValue() == id);
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- try
- {
- await Results.Cell("client.action.pin", "privapub", persona.UserName, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/account/{me}");
- var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue()}']").First;
- await card.WaitForAsync(new() { Timeout = 30000 });
-
- await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
- await card.Locator("button[data-pin]").ClickAsync();
- Assert.True(await Pasture.Eventually(Pinned, 15), "the pin never reached PrivaPub");
-
- await page.ReloadAsync();
- var pinnedCard = page.Locator($"[data-pinned] article[data-uri='{post["uri"]!.GetValue()}']");
- await pinnedCard.WaitForAsync(new() { Timeout = 30000 });
- Assert.Equal(1, await page.Locator($"article[data-uri='{post["uri"]!.GetValue()}']").CountAsync());
-
- await pinnedCard.Locator("footer [title='Other'] > [role=button]").ClickAsync();
- await Assertions.Expect(pinnedCard.Locator("button[data-pin]")).ToContainTextAsync("Unpin", new() { Timeout = 5000 });
- await pinnedCard.Locator("button[data-pin]").ClickAsync();
- Assert.True(await Pasture.Eventually(async () => !await Pinned(), 15), "the unpin never reached PrivaPub");
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- await PersonaApi.Call(persona, $"/api/v1/statuses/{id}", HttpMethod.Delete);
- }
- }
- }
-}
diff --git a/tests/e2e/Actions/ProfileTests.cs b/tests/e2e/Actions/ProfileTests.cs
deleted file mode 100644
index 4788bf5..0000000
--- a/tests/e2e/Actions/ProfileTests.cs
+++ /dev/null
@@ -1,118 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // Accounts in decePub: found by searching their handle (PrivaPub resolves it), opened as a profile, followed and
- // unfollowed there; the follow is checked on the account's own server, where the persona must appear as a follower.
- public sealed class ProfileTests(Browser browser)
- {
- static readonly string[] Platforms = ["gts", "mastodon", "akkoma", "misskey", "sharkey"];
-
- public static TheoryData Origins() => new(Platforms);
-
- // an account of the platform that the persona does not follow yet, from the town's own sessions
- static (string Platform, string UserName, string Token) Stranger(string platform, Pasture.Persona persona)
- {
- var sessions = JsonNode.Parse(File.ReadAllText(Path.Combine(Pasture.Root, ".state", "town", "sessions.json")))!.AsObject();
- var candidates = sessions.Where(kv => kv.Key.StartsWith(platform + "/") && !kv.Key.Contains("/st_"))
- .Select(kv => (platform, kv.Value!["account"]!["username"]!.GetValue(), kv.Value!["token"]!.GetValue()))
- .ToList();
- return candidates.Last();
- }
-
- // the newest GoToSocial account that SocialPub's moves scenario moved (scenarios/moves.sh), as PrivaPub knows it
- static async Task<(string Old, string New)> Moved()
- {
- var start = new System.Diagnostics.ProcessStartInfo("podman") { RedirectStandardOutput = true, RedirectStandardError = true };
- foreach (var arg in new[] { "exec", "pasture-mongo", "mongosh", "--quiet", "PrivaPub", "--eval",
- "const a = db.ForeignAvatar.find({ActorURI: {$regex: 'gts.test.users.mvold'}, MovedToURL: {$ne: null}}).sort({_id: -1}).limit(1).toArray()[0];" +
- "print(a ? a.ActorURI + ' ' + a.MovedToURL : '')" })
- start.ArgumentList.Add(arg);
- using var process = System.Diagnostics.Process.Start(start)!;
- var output = (await process.StandardOutput.ReadToEndAsync()).Trim();
- await process.WaitForExitAsync();
- var parts = output.Split(' ', StringSplitOptions.RemoveEmptyEntries);
- return parts.Length == 2 ? (parts[0], parts[1]) : default;
- }
-
- [Fact]
- public async Task A_moved_accounts_profile_says_where_it_went()
- {
- var (old, moved) = await Moved();
- Assert.SkipWhen(old is null, "no moved account: run SocialPub's tools/pasture/interop.sh moves");
- var persona = Pasture.Personas().First();
- var handle = old.Split("/users/")[1] + "@gts.test";
- await using var session = await SignIn.As(browser, persona);
- await Results.Cell("client.render.moved", "gts", handle, async () =>
- {
- await session.Page.GotoAsync($"{Pasture.Site}/search?q={Uri.EscapeDataString("@" + handle)}");
- var banner = session.Page.Locator("[data-moved]");
- await banner.WaitForAsync(new() { Timeout = 30000 });
- await Assertions.Expect(banner).ToContainTextAsync("@" + moved.Split("/users/")[1] + "@gts.test");
- await session.AssertGuards();
- }, session.Page);
- }
-
- [Theory]
- [MemberData(nameof(Origins))]
- public async Task A_remote_account_found_by_its_handle_is_followed_and_unfollowed_from_its_profile(string platform)
- {
- var persona = Pasture.Personas().First();
- var (_, userName, _) = Stranger(platform, persona);
- var handle = $"{userName}@{platform}.test";
- await using var session = await SignIn.As(browser, persona);
- await Results.Cell("client.action.follow", platform, handle, async () =>
- {
- await session.Page.GotoAsync($"{Pasture.Site}/search?q={Uri.EscapeDataString("@" + handle)}");
- var profile = session.Page.Locator("article[data-account-id]").First;
- await profile.WaitForAsync(new() { Timeout = 30000 });
- await Assertions.Expect(profile).ToContainTextAsync("@" + handle);
-
- var follow = profile.GetByRole(AriaRole.Button).Filter(new() { HasTextRegex = new System.Text.RegularExpressions.Regex("^(Follow|Unfollow|Cancel the request)$") }).First;
- if ((await follow.InnerTextAsync()).Trim() != "Follow")
- {
- await follow.ClickAsync();
- await Assertions.Expect(follow).ToHaveTextAsync("Follow", new() { Timeout = 15000 });
- }
- await follow.ClickAsync();
- await Assertions.Expect(follow).Not.ToHaveTextAsync("Follow", new() { Timeout = 15000 });
- Assert.True(await Pasture.Eventually(async () => await FollowedOn(platform, userName, persona.UserName), 45),
- $"{handle} never learnt that {persona.UserName} follows it");
-
- await follow.ClickAsync();
- await Assertions.Expect(follow).ToHaveTextAsync("Follow", new() { Timeout = 15000 });
- Assert.True(await Pasture.Eventually(async () => !await FollowedOn(platform, userName, persona.UserName), 45),
- $"{handle} still has {persona.UserName} as a follower after the unfollow");
- await session.AssertGuards();
- }, session.Page);
- }
-
- // whether the peer itself lists the persona as following (or asking to follow) its account
- static async Task FollowedOn(string platform, string userName, string persona)
- {
- var result = await Pasture.Town("drive", platform, userName, "relationship", $"{{\"acct\": \"{persona}@privapub.test\"}}");
- return result?["followed_by"]?.GetValue() == true || (await Pasture.Town("drive", platform, userName, "pending", "{}"))
- .AsArray().Any(a => a?.GetValue()?.StartsWith(persona + "@") == true);
- }
-
- [Fact]
- public async Task The_personas_own_profile_lists_its_posts()
- {
- var persona = Pasture.Personas().First();
- await using var session = await SignIn.As(browser, persona);
- await Results.Cell("client.render.profile", "privapub", persona.UserName, async () =>
- {
- await session.Page.GotoAsync($"{Pasture.Site}/account/{persona.LocalId}");
- var profile = session.Page.Locator("article[data-account-id]").First;
- await profile.WaitForAsync(new() { Timeout = 30000 });
- await Assertions.Expect(profile).ToContainTextAsync("@" + persona.UserName);
- await session.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
- await session.AssertGuards();
- }, session.Page);
- }
- }
-}
diff --git a/tests/e2e/Actions/ReportTests.cs b/tests/e2e/Actions/ReportTests.cs
deleted file mode 100644
index 960c445..0000000
--- a/tests/e2e/Actions/ReportTests.cs
+++ /dev/null
@@ -1,84 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // A persona reports a remote account from its profile, with a category, its words and a copy to the account's server:
- // PrivaPub's moderators see it, forwarded (/clientapi/moderator/reports). And who liked or boosted a post is listed
- // from its card
- public sealed class ReportTests(Browser browser)
- {
- [Fact]
- public async Task A_remote_account_is_reported_from_its_profile()
- {
- var persona = Pasture.Personas().First();
- var target = (await PersonaApi.Call(persona, "/api/v2/search?q=mastouser%40mastodon.test&resolve=true&type=accounts"))?["accounts"]?.AsArray().FirstOrDefault();
- Assert.SkipWhen(target is null, "no Mastodon: run tools/pasture/run.sh add mastodon");
- var targetId = target!["id"]!.GetValue();
- var words = $"reported by the e2e tests {Guid.NewGuid():N}"[..40];
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- await Results.Cell("client.action.report", "mastodon", persona.UserName, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/account/{Uri.EscapeDataString(targetId)}");
- // (the profile's own button: each of its posts' cards has one too)
- await page.Locator($"article[data-account-id='{targetId}'] button[data-report]").ClickAsync(new() { Timeout = 30000 });
- var dialog = page.Locator("[data-report-dialog]");
- await dialog.Locator("[data-report-category]").SelectOptionAsync("Spam");
- await dialog.Locator("[data-report-comment]").FillAsync(words);
- await Assertions.Expect(dialog.Locator("[data-report-forward]")).ToBeCheckedAsync();
- await dialog.Locator("[data-report-send]").ClickAsync();
- await Assertions.Expect(dialog).ToHaveCountAsync(0, new() { Timeout = 15000 });
-
- var report = (await PastureAdmin.Get("/clientapi/moderator/reports?resolved=false"))!.AsArray()
- .FirstOrDefault(r => r!["comment"]?.GetValue() == words);
- Assert.NotNull(report);
- Assert.Equal((targetId, "spam", true), (report!["targetAccountId"]!.GetValue(), report["category"]!.GetValue(),
- report["forwarded"]!.GetValue()));
- await session.AssertGuards();
- }, page);
- }
-
- [Fact]
- public async Task Who_liked_and_boosted_a_post_is_listed_from_its_card()
- {
- var personas = Pasture.Personas();
- Assert.SkipWhen(personas.Count < 2, "the town has one persona");
- var (author, fan) = (personas[0], personas[1]);
- var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
- new JsonObject { ["status"] = $"like and boost me {Guid.NewGuid():N}"[..28], ["visibility"] = "public" }))!;
- var id = post["id"]!.GetValue();
- var fanAcct = (await PersonaApi.Call(fan, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue();
- await PersonaApi.Call(fan, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
- await PersonaApi.Call(fan, $"/api/v1/statuses/{id}/reblog", HttpMethod.Post);
- await using var session = await SignIn.As(browser, author);
- var page = session.Page;
- try
- {
- await Results.Cell("client.render.interacted-by", "privapub", author.UserName, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/expand/{Uri.EscapeDataString(id)}");
- var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue()}']").First;
- await card.WaitForAsync(new() { Timeout = 30000 });
- foreach (var (button, list) in new[] { ("data-liked-by", "likes"), ("data-boosted-by", "boosts") })
- {
- await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
- await card.Locator($"button[{button}]").ClickAsync();
- var dialog = card.Locator($"[data-interacted-by='{list}']");
- await Assertions.Expect(dialog.Locator($"[data-account='{fanAcct}']")).ToHaveCountAsync(1, new() { Timeout = 15000 });
- await page.Keyboard.PressAsync("Escape");
- await Assertions.Expect(dialog).ToHaveCountAsync(0);
- }
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
- }
- }
- }
-}
diff --git a/tests/e2e/Actions/SettingsTests.cs b/tests/e2e/Actions/SettingsTests.cs
deleted file mode 100644
index af2bec5..0000000
--- a/tests/e2e/Actions/SettingsTests.cs
+++ /dev/null
@@ -1,208 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // Settings as a persona: its profile saved (update_credentials) and read back from PrivaPub, a server blocked and
- // unblocked, the follows exported and servers imported in Mastodon's files, and a new persona made; what a test changes
- // is put back through the API afterwards, but for the persona (PrivaPub keeps every persona, so the test makes it under
- // the scenarios' own root, never a town one)
- public sealed class SettingsTests(Browser browser)
- {
- static async Task Open(IPage page, string section)
- {
- await page.GotoAsync($"{Pasture.Site}/settings");
- var header = page.Locator(".collapse-title", new() { HasText = section }).First;
- await header.ClickAsync();
- return header.Locator("xpath=..");
- }
-
- static JsonObject Restore(JsonNode account)
- {
- var fields = new JsonObject();
- var source = account["source"]!;
- var kept = source["fields"]!.AsArray();
- for (var i = 0; i < 4; i++)
- fields[i.ToString()] = new JsonObject
- {
- ["name"] = i < kept.Count ? kept[i]!["name"]!.GetValue() : "",
- ["value"] = i < kept.Count ? kept[i]!["value"]!.GetValue() : ""
- };
- return new JsonObject
- {
- ["display_name"] = account["display_name"]!.GetValue(),
- ["note"] = source["note"]!.GetValue(),
- ["locked"] = account["locked"]!.GetValue(),
- ["source"] = new JsonObject { ["privacy"] = source["privacy"]!.GetValue() },
- ["fields_attributes"] = fields
- };
- }
-
- [Fact]
- public async Task The_personas_profile_is_saved_from_settings()
- {
- var persona = Pasture.Personas().Last();
- var original = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!;
- var mark = Guid.NewGuid().ToString("N")[..6];
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- try
- {
- await Results.Cell("client.action.profile-edit", "privapub", persona.UserName, async () =>
- {
- var panel = await Open(page, "Profile");
- var editor = panel.Locator("[data-profile-editor]");
- await editor.WaitForAsync(new() { Timeout = 30000 });
- await Assertions.Expect(editor.Locator("[data-profile-name]")).ToHaveValueAsync(original["display_name"]!.GetValue(),
- new() { Timeout = 10000 });
-
- await editor.Locator("[data-profile-name]").FillAsync($"Edited {mark}");
- await editor.Locator("[data-profile-note]").FillAsync($"A biography written in the e2e tests {mark}");
- var row = editor.Locator("[data-profile-field='3'] input");
- await row.Nth(0).FillAsync("e2e");
- await row.Nth(1).FillAsync(mark);
- await editor.Locator("[data-profile-privacy]").SelectOptionAsync("Unlisted");
- await editor.Locator("[data-profile-save]").ClickAsync();
- await Assertions.Expect(page.GetByText("Profile saved.")).ToBeVisibleAsync(new() { Timeout = 15000 });
-
- var saved = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!;
- Assert.Equal($"Edited {mark}", saved["display_name"]!.GetValue());
- Assert.Equal($"A biography written in the e2e tests {mark}", saved["source"]!["note"]!.GetValue());
- Assert.Equal("unlisted", saved["source"]!["privacy"]!.GetValue());
- Assert.Contains(saved["source"]!["fields"]!.AsArray(), f => f!["name"]!.GetValue() == "e2e" && f["value"]!.GetValue() == mark);
- // the persona chooser shows the new name at once
- await Assertions.Expect(page.GetByText($"Edited {mark}", new() { Exact = true }).First).ToBeVisibleAsync(new() { Timeout = 5000 });
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- await PersonaApi.Call(persona, "/api/v1/accounts/update_credentials", HttpMethod.Patch, Restore(original));
- }
- }
-
- [Fact]
- public async Task A_server_is_blocked_and_unblocked_from_settings()
- {
- var persona = Pasture.Personas().Last();
- const string server = "blocked-by-e2e.example";
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- try
- {
- await Results.Cell("client.action.domain-block", "privapub", persona.UserName, async () =>
- {
- var panel = await Open(page, "Mutes/Blocks");
- var servers = panel.Locator("[data-domain-blocks]");
- await servers.WaitForAsync(new() { Timeout = 30000 });
-
- await servers.Locator("[data-domain-new]").FillAsync($"https://{server.ToUpperInvariant()}/about");
- await servers.GetByRole(AriaRole.Button, new() { Name = "Block server" }).ClickAsync();
- await Assertions.Expect(servers.Locator($"[data-domain='{server}']")).ToBeVisibleAsync(new() { Timeout = 15000 });
- Assert.Contains(server, (await PersonaApi.Call(persona, "/api/v1/domain_blocks"))!.AsArray().Select(d => d!.GetValue()));
-
- await servers.Locator($"[data-domain='{server}']").GetByRole(AriaRole.Button, new() { Name = "Unblock" }).ClickAsync();
- await Assertions.Expect(servers.Locator($"[data-domain='{server}']")).ToHaveCountAsync(0, new() { Timeout = 15000 });
- Assert.DoesNotContain(server, (await PersonaApi.Call(persona, "/api/v1/domain_blocks"))!.AsArray().Select(d => d!.GetValue()));
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- await PersonaApi.Call(persona, $"/api/v1/domain_blocks?domain={server}", HttpMethod.Delete);
- }
- }
-
- // the follows export is Mastodon's following_accounts.csv, naming every account in full
- [Fact]
- public async Task The_follows_are_exported_in_mastodons_format()
- {
- var persona = Pasture.Personas().First();
- var following = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=80"))!.AsArray();
- Assert.SkipWhen(following.Count == 0, $"{persona.UserName} follows nobody in this town");
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- await Results.Cell("client.action.export", "privapub", persona.UserName, async () =>
- {
- var panel = await Open(page, "Data import/export");
- var button = panel.Locator("[data-export-kind='Follows']");
- await button.WaitForAsync(new() { Timeout = 30000 });
- var download = await page.RunAndWaitForDownloadAsync(() => button.ClickAsync(), new() { Timeout = 60000 });
- Assert.Equal("following_accounts.csv", download.SuggestedFilename);
- var lines = (await File.ReadAllLinesAsync((await download.PathAsync())!)).ToList();
- Assert.Equal("Account address,Show boosts,Notify on new posts,Languages", lines[0]);
- foreach (var account in following)
- {
- var acct = account!["acct"]!.GetValue();
- var full = acct.Contains('@') ? acct : $"{acct}@privapub.test";
- Assert.Contains(lines, l => l.StartsWith(full + ",", StringComparison.Ordinal));
- }
- await session.AssertGuards();
- }, page);
- }
-
- [Fact]
- public async Task Blocked_servers_are_imported_from_a_mastodon_file()
- {
- var persona = Pasture.Personas().Last();
- string[] servers = ["imported-one.example", "imported-two.example"];
- var csv = Path.Combine(Path.GetTempPath(), $"blocked_domains-{Guid.NewGuid():N}.csv");
- await File.WriteAllTextAsync(csv, string.Join("\n", servers) + "\n");
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- try
- {
- await Results.Cell("client.action.import", "privapub", persona.UserName, async () =>
- {
- var panel = await Open(page, "Data import/export");
- var import = panel.Locator("[data-import]");
- await import.WaitForAsync(new() { Timeout = 30000 });
- await import.Locator("[data-import-kind]").SelectOptionAsync("ServerBlocks");
- await import.Locator("[data-import-file]").SetInputFilesAsync(csv);
- await import.Locator("[data-import-start]").ClickAsync();
- await Assertions.Expect(import.Locator("[data-import-report]")).ToContainTextAsync("2 of 2 done, 0 could not be.", new() { Timeout = 30000 });
- var blocked = (await PersonaApi.Call(persona, "/api/v1/domain_blocks"))!.AsArray().Select(d => d!.GetValue()).ToList();
- Assert.All(servers, s => Assert.Contains(s, blocked));
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- foreach (var server in servers)
- await PersonaApi.Call(persona, $"/api/v1/domain_blocks?domain={server}", HttpMethod.Delete);
- File.Delete(csv);
- }
- }
-
- [Fact]
- public async Task A_new_persona_is_made_and_used()
- {
- var name = $"e2e_{Guid.NewGuid():N}"[..12];
- await using var session = await SignIn.As(browser, new Pasture.Persona("alice", "alice", "pastureroot", "Pasture-Pass-1!", null, null, null));
- var page = session.Page;
- await Results.Cell("client.action.persona-create", "privapub", "pastureroot", async () =>
- {
- var panel = await Open(page, "Personas");
- var form = panel.Locator("[data-persona-new]");
- await form.WaitForAsync(new() { Timeout = 30000 });
- await form.Locator("[data-persona-username]").FillAsync(name);
- await form.Locator("[data-persona-name]").FillAsync($"Made by e2e {name}");
- await form.Locator("[data-persona-biography]").FillAsync("A persona the end-to-end tests made.");
- await form.Locator("[data-persona-create]").ClickAsync();
- var made = panel.Locator($"[data-persona='{name}']");
- await made.WaitForAsync(new() { Timeout = 15000 });
-
- await made.GetByRole(AriaRole.Button, new() { Name = "Use" }).ClickAsync();
- await Assertions.Expect(made).ToContainTextAsync("in use", new() { Timeout = 15000 });
- await Assertions.Expect(page.GetByText($"Made by e2e {name}", new() { Exact = true }).First).ToBeVisibleAsync();
- using var http = Pasture.Http();
- var webfinger = await http.GetAsync($"{Pasture.Api}/.well-known/webfinger?resource=acct:{name}@privapub.test");
- Assert.True(webfinger.IsSuccessStatusCode, $"{name} is not found by WebFinger");
- await session.AssertGuards();
- }, page);
- }
- }
-}
diff --git a/tests/e2e/Actions/StreamingTests.cs b/tests/e2e/Actions/StreamingTests.cs
deleted file mode 100644
index 67a22a5..0000000
--- a/tests/e2e/Actions/StreamingTests.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // The home feed hears of a new post and of its deletion through PrivaPub's streaming API, in seconds, where polling
- // would take up to 45
- public sealed class StreamingTests(Browser browser)
- {
- [Fact]
- public async Task A_post_made_elsewhere_waits_behind_the_new_button_and_its_deletion_takes_it_away()
- {
- var persona = Pasture.Personas().First();
- var text = $"streamed {Guid.NewGuid():N}"[..21];
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- // the stream counts once the app has subscribed to the home feed on it (it opens after the first page is read,
- // later on a busy page)
- var subscribed = new TaskCompletionSource();
- page.WebSocket += (_, socket) =>
- {
- if (socket.Url.Contains("/api/v1/streaming"))
- socket.FrameSent += (_, frame) =>
- {
- if (frame.Text?.Contains("\"subscribe\"") == true && frame.Text.Contains("\"user\""))
- subscribed.TrySetResult();
- };
- };
- JsonNode post = null;
- try
- {
- await Results.Cell("client.render.stream", "privapub", persona.UserName, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/");
- await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
- subscribed = new();
- await page.GotoAsync($"{Pasture.Site}/");
- await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
- await subscribed.Task.WaitAsync(TimeSpan.FromSeconds(30));
- // (and the server has taken the subscription in)
- await Task.Delay(500);
-
- post = await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "unlisted" });
- var fresh = page.GetByRole(AriaRole.Button, new() { Name = "1 new" });
- await fresh.WaitForAsync(new() { Timeout = 10000 });
- await fresh.ClickAsync();
- var card = page.Locator($"article[data-uri='{post!["uri"]!.GetValue()}']");
- await Assertions.Expect(card).ToContainTextAsync(text, new() { Timeout = 5000 });
-
- await PersonaApi.Call(persona, $"/api/v1/statuses/{post["id"]!.GetValue()}", HttpMethod.Delete);
- post = null;
- await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 10000 });
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- if (post is not null)
- await PersonaApi.Call(persona, $"/api/v1/statuses/{post["id"]!.GetValue()}", HttpMethod.Delete);
- }
- }
- }
-}
diff --git a/tests/e2e/Actions/ThreadTests.cs b/tests/e2e/Actions/ThreadTests.cs
deleted file mode 100644
index 5a6e0aa..0000000
--- a/tests/e2e/Actions/ThreadTests.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // A remote thread opened in decePub fills with the replies PrivaPub never received: opening it has PrivaPub read the
- // thread from its server (Mastodon's FEP-7888 context), and the page reads the thread again a few seconds later
- public sealed class ThreadTests(Browser browser)
- {
- const string Platform = "mastodon";
- const string Author = "elif_ms06";
- // followed by no persona, so Mastodon never delivers her reply to PrivaPub
- const string Outsider = "gigi_ms28";
-
- static async Task Drive(string user, string verb, JsonObject parameters) =>
- await Pasture.Town("drive", Platform, user, verb, parameters.ToJsonString());
-
- [Fact]
- public async Task A_remote_thread_brings_the_replies_that_never_reached_privapub()
- {
- var persona = Pasture.Personas().First();
- var mark = Guid.NewGuid().ToString("N")[..8];
- var root = await Drive(Author, "post", new JsonObject { ["text"] = $"a thread to open {mark}", ["visibility"] = "public" });
- var rootUri = root["uri"]!.GetValue();
- string replyUri = null;
- try
- {
- var reply = await Drive(Outsider, "post", new JsonObject
- {
- ["text"] = $"said where nobody listens {mark}", ["visibility"] = "public", ["reply_to_uri"] = rootUri
- });
- replyUri = reply["uri"]!.GetValue();
- var found = await PersonaApi.Call(persona, $"/api/v2/search?q={Uri.EscapeDataString(rootUri)}&resolve=true&type=statuses");
- var rootId = found!["statuses"]!.AsArray().Single()!["id"]!.GetValue();
- await Task.Delay(3000);
- Assert.SkipWhen((await Pasture.Stored("privapub", replyUri))[replyUri]?["exists"]?.GetValue() == true,
- $"{Outsider}'s reply reached PrivaPub by delivery, so the thread proves nothing");
-
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- await Results.Cell("client.render.thread-backfill", Platform, persona.UserName, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/expand/{rootId}");
- await page.Locator($"article[data-uri='{rootUri}']").First.WaitForAsync(new() { Timeout = 30000 });
- var answer = page.Locator($"article[data-uri='{replyUri}']");
- await Assertions.Expect(answer).ToContainTextAsync($"said where nobody listens {mark}", new() { Timeout = 25000 });
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- if (replyUri is not null)
- await Drive(Outsider, "delete", new JsonObject { ["uri"] = replyUri });
- await Drive(Author, "delete", new JsonObject { ["uri"] = rootUri });
- }
- }
- }
-}
diff --git a/tests/e2e/Actions/VoteAndReactTests.cs b/tests/e2e/Actions/VoteAndReactTests.cs
deleted file mode 100644
index ab14ef5..0000000
--- a/tests/e2e/Actions/VoteAndReactTests.cs
+++ /dev/null
@@ -1,123 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // A vote in another server's poll and an emoji reaction to another server's post, done in decePub and counted on that
- // server. The first of either asks the persona, once, to accept that both are public (an owner decision).
- public sealed class VoteAndReactTests(Browser browser)
- {
- static readonly Lazy Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
-
- static TheoryData Pick(Func wanted, params string[] origins)
- {
- var data = new TheoryData();
- var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet();
- foreach (var origin in origins)
- {
- var item = Expected.Value.FirstOrDefault(i => i!["origin"]!.GetValue() == origin && wanted(i)
- && i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue())));
- if (item is not null)
- data.Add(origin, item["ref"]!.GetValue());
- }
- return data;
- }
-
- public static TheoryData Polls() =>
- Pick(i => i["poll"] is JsonArray, "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo");
-
- public static TheoryData Reactable() =>
- Pick(i => i["visibility"]!.GetValue() is "public" or "unlisted", "misskey", "sharkey", "akkoma");
-
-
- async Task<(JsonNode Item, Session Session, ILocator Card)> Open(string reference)
- {
- var item = Expected.Value.First(i => i!["ref"]!.GetValue() == reference)!;
- var personas = Pasture.Personas();
- var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue()))
- .Last(p => p is not null);
- var session = await SignIn.As(browser, reader);
- await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue()}");
- var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue()}']").First;
- await card.WaitForAsync(new() { Timeout = 30000 });
- return (item, session, card);
- }
-
- // the one-time notice, when this persona has not seen it yet
- internal static async Task AcceptNoticeIfAsked(IPage page)
- {
- var proceed = page.GetByRole(AriaRole.Button, new() { Name = "Continue" });
- try
- {
- await proceed.WaitForAsync(new() { Timeout = 3000 });
- await proceed.ClickAsync();
- }
- catch (TimeoutException)
- {
- }
- }
-
- static async Task Row(string origin, string uri) => (await Pasture.Stored(origin, uri))[uri]!.AsObject();
-
- [Theory]
- [MemberData(nameof(Polls))]
- public async Task A_vote_is_counted_on_the_polls_own_server(string origin, string reference)
- {
- var (item, session, card) = await Open(reference);
- await using var _ = session;
- var uri = item["uri"]!.GetValue();
- var choices = card.Locator("[data-poll] input");
- Assert.SkipWhen(await choices.CountAsync() == 0, "this persona already voted, or the poll is closed");
- await Results.Cell("client.action.vote", origin, reference, async () =>
- {
- var before = (await Row(origin, uri))["votes"]?.AsArray().Sum(v => v?.GetValue() ?? 0) ?? 0;
- await choices.First.CheckAsync();
- await card.Locator("[data-poll]").GetByRole(AriaRole.Button, new() { Name = "Vote" }).ClickAsync();
- await AcceptNoticeIfAsked(session.Page);
- await Assertions.Expect(card.Locator("[data-poll] input")).ToHaveCountAsync(0, new() { Timeout = 15000 });
- Assert.True(await Pasture.Eventually(async () =>
- ((await Row(origin, uri))["votes"]?.AsArray().Sum(v => v?.GetValue() ?? 0) ?? 0) > before, 45),
- $"the vote never counted on {origin}");
- await session.AssertGuards();
- });
- }
-
- [Theory]
- [MemberData(nameof(Reactable))]
- public async Task A_reaction_reaches_the_posts_server_and_its_withdrawal_too(string origin, string reference)
- {
- var (item, session, card) = await Open(reference);
- await using var _ = session;
- var uri = item["uri"]!.GetValue();
- const string emoji = "🔥";
- await Results.Cell("client.action.react", origin, reference, async () =>
- {
- static int Count(JsonObject row) => row["reactions"]?[emoji]?.GetValue() ?? 0;
- var chip = card.Locator("[data-reactions] button").Filter(new() { HasText = emoji }).First;
- // a run cut short may have left the persona's reaction: start from the withdrawn state
- if (await chip.CountAsync() > 0 && ((await chip.GetAttributeAsync("class")) ?? "").Split(' ').Contains("btn-active"))
- {
- var left = Count(await Row(origin, uri));
- await chip.ClickAsync();
- await Pasture.Eventually(async () => Count(await Row(origin, uri)) < left, 60);
- }
- 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);
- 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.ScrollIntoViewIfNeededAsync();
- await chip.ClickAsync();
- Assert.True(await Pasture.Eventually(async () => Count(await Row(origin, uri)) == before, 60),
- $"withdrawing the reaction never reached {origin}");
- await session.AssertGuards();
- });
- }
- }
-}
diff --git a/tests/e2e/Actions/WallTests.cs b/tests/e2e/Actions/WallTests.cs
deleted file mode 100644
index 9407fa4..0000000
--- a/tests/e2e/Actions/WallTests.cs
+++ /dev/null
@@ -1,101 +0,0 @@
-using System.Net.Http.Headers;
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Actions
-{
- // A Smithereen account that follows the persona writes on its wall (FEP-400e): the card says so, and the persona takes
- // it off from the card's menu, which deletes it on Smithereen too. SocialPub's tools/pasture/scenarios/smithereen.sh
- // checks the rest of the wire.
- public sealed class WallTests(Browser browser)
- {
- // Smithereen's VKontakte-like API as smuser (the token SocialPub's peers/smithereen.sh keeps)
- static async Task Smithereen(string token, string method, params (string Name, string Value)[] args)
- {
- using var http = Pasture.Http();
- using var request = new HttpRequestMessage(HttpMethod.Post, $"https://smithereen.test/api/method/{method}")
- {
- Content = new FormUrlEncodedContent(args.Select(a => KeyValuePair.Create(a.Name, a.Value)).Append(KeyValuePair.Create("v", "1.0")))
- };
- request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
- using var response = await http.SendAsync(request);
- return JsonNode.Parse(await response.Content.ReadAsStringAsync());
- }
-
- [Fact]
- public async Task A_post_on_the_personas_wall_says_so_and_the_persona_takes_it_off()
- {
- var tokenFile = Path.Combine(Pasture.Root, ".state", "smithereen", "smuser.token");
- Assert.SkipUnless(File.Exists(tokenFile), "no Smithereen: run tools/pasture/run.sh add smithereen");
- var token = File.ReadAllText(tokenFile).Trim();
- // (one whose follows need no approval)
- var persona = default(Pasture.Persona);
- var me = default(JsonNode);
- foreach (var candidate in Pasture.Personas())
- {
- me = await PersonaApi.Call(candidate, "/api/v1/accounts/verify_credentials");
- if (me?["locked"]?.GetValue() == false)
- {
- persona = candidate;
- break;
- }
- }
- Assert.SkipWhen(persona is null, "every persona of the town is locked");
- var actorUri = me["url"]!.GetValue().Replace("/@", "/peasants/");
- // (its profile told again, so Smithereen reads its wall; smuser follows it, as its followers may write there)
- await PersonaApi.Call(persona, "/api/v1/accounts/update_credentials", HttpMethod.Patch, new JsonObject());
- var personaOnSm = (await Smithereen(token, "utils.loadRemoteObject", ("q", actorUri)))?["response"]?["id"]?.ToString();
- Assert.SkipWhen(string.IsNullOrEmpty(personaOnSm), "Smithereen cannot resolve the persona");
- await Smithereen(token, "friends.add", ("user_id", personaOnSm));
- try
- {
- var smuser = (await PersonaApi.Call(persona, "/api/v2/search?q=smuser%40smithereen.test&resolve=true&type=accounts"))!["accounts"]![0]!["id"]!.GetValue();
- Assert.True(await Pasture.Eventually(async () =>
- (await PersonaApi.Call(persona, $"/api/v1/accounts/relationships?id[]={smuser}"))![0]!["followed_by"]!.GetValue(), 45), "smuser never followed the persona");
-
- var text = $"a word on the wall {Guid.NewGuid():N}"[..32];
- // (Smithereen lets its followers write there once the persona's Accept has reached it, which may be after
- // PrivaPub counts the follow)
- var written = default(string);
- Assert.True(await Pasture.Eventually(async () =>
- !string.IsNullOrEmpty(written = (await Smithereen(token, "wall.post", ("owner_id", personaOnSm), ("message", text)))?["response"]?.ToString()), 30),
- "Smithereen refused smuser's post on the persona's wall");
- JsonNode status = null;
- Assert.True(await Pasture.Eventually(async () =>
- (status = (await PersonaApi.Call(persona, "/api/v1/timelines/home?limit=40"))!.AsArray()
- .FirstOrDefault(s => s!["content"]!.GetValue().Contains(text))) is not null, 60), "the post on the wall never reached the persona");
- Assert.Equal(me["id"]!.GetValue(), status!["privapub"]?["wall"]?["id"]?.GetValue());
-
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- await Results.Cell("client.action.wall", "smithereen", persona.UserName, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/");
- await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
- await page.GotoAsync($"{Pasture.Site}/");
- var card = page.Locator($"article[data-uri='{status["uri"]!.GetValue()}']").First;
- await card.WaitForAsync(new() { Timeout = 30000 });
-
- await Assertions.Expect(card.Locator("[data-wall]")).ToBeVisibleAsync();
- await Assertions.Expect(card.Locator("[data-wall]")).ToContainTextAsync(me["display_name"]!.GetValue() is { Length: > 0 } name ? name : me["username"]!.GetValue());
- await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
- await card.Locator("button[data-take-off-wall]").ClickAsync();
- await session.Page.Locator("[data-confirm-yes]").ClickAsync();
- Assert.True(await Pasture.Eventually(async () => await PersonaApi.Call(persona, $"/api/v1/statuses/{status["id"]!.GetValue()}") is null, 15),
- "the post is still on PrivaPub");
- Assert.True(await Pasture.Eventually(async () =>
- (await Smithereen(token, "wall.getById", ("posts", written)))?["response"] is not JsonArray { Count: > 0 }, 30), "the post is still on Smithereen");
- await session.AssertGuards();
- }, page);
- }
- finally
- {
- // (the town as it was: smuser no longer follows the persona)
- await Smithereen(token, "friends.delete", ("user_id", personaOnSm));
- }
- }
- }
-}
diff --git a/tests/e2e/Login/LoginTests.cs b/tests/e2e/Login/LoginTests.cs
deleted file mode 100644
index f053e2f..0000000
--- a/tests/e2e/Login/LoginTests.cs
+++ /dev/null
@@ -1,69 +0,0 @@
-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();
-
- // the sibling's home, read with another token than the first persona's (whose own home may still be loading when the
- // switch is asked for)
- string FirstToken() => session.ApiCalls.FirstOrDefault(c => new Uri(c.Url).AbsolutePath.StartsWith("/api/") && c.Authorization is not null).Authorization;
- Assert.True(await Pasture.Eventually(() => Task.FromResult(session.ApiCalls.Skip(before)
- .Any(c => new Uri(c.Url).AbsolutePath == "/api/v1/timelines/home" && c.Authorization is not null && c.Authorization != FirstToken())), 20),
- "the sibling's home was never read with its own token");
- await session.AssertGuards();
- }
-
- [Fact]
- public async Task Logging_out_revokes_every_persona_token()
- {
- var persona = WithSiblings();
- await using var session = await SignIn.As(browser, persona, fresh: true);
- var tokens = session.ApiCalls.Where(c => new Uri(c.Url).AbsolutePath.StartsWith("/api/") && c.Authorization is not null)
- .Select(c => c.Authorization).Distinct().ToList();
- Assert.NotEmpty(tokens);
-
- await session.Page.GotoAsync(Pasture.Site + "/logout");
- await session.Page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/login", new() { Timeout = 30000 });
-
- using var http = Pasture.Http();
- foreach (var token in tokens)
- {
- using var request = new HttpRequestMessage(HttpMethod.Get, Pasture.Api + "/api/v1/accounts/verify_credentials");
- request.Headers.TryAddWithoutValidation("Authorization", token);
- Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await http.SendAsync(request)).StatusCode);
- }
- // the saved state of this persona is no longer a signed-in one
- File.Delete(Path.Combine(AppContext.BaseDirectory, ".state", persona.UserName + ".json"));
- }
- }
-}
diff --git a/tests/e2e/Login/SignUpTests.cs b/tests/e2e/Login/SignUpTests.cs
deleted file mode 100644
index 842cfc3..0000000
--- a/tests/e2e/Login/SignUpTests.cs
+++ /dev/null
@@ -1,131 +0,0 @@
-using System.Net.Http.Headers;
-using System.Net.Http.Json;
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Login
-{
- // The two ways in now that sign-up is closed: the server's first login, made from the client while it has no
- // administrator, and a login made through a group's invitation
- public sealed class SignUpTests(Browser browser)
- {
- const string Password = "Pasture-Pass-1!";
-
- static async Task SetupPending()
- {
- using var http = Pasture.Http();
- return JsonNode.Parse(await http.GetStringAsync(Pasture.Api + "/clientapi/user/setup"))!["isPending"]!.GetValue();
- }
-
- static async Task Jwt(string root, string password)
- {
- using var http = Pasture.Http();
- using var login = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/login", new { userName = root, password });
- login.EnsureSuccessStatusCode();
- return (await login.Content.ReadFromJsonAsync())!["token"]!.GetValue();
- }
-
- static async Task Send(HttpMethod method, string path, string jwt, object body = null)
- {
- using var http = Pasture.Http();
- using var request = new HttpRequestMessage(method, Pasture.Api + path) { Content = body is null ? null : JsonContent.Create(body) };
- request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
- using var response = await http.SendAsync(request);
- response.EnsureSuccessStatusCode();
- return JsonNode.Parse(await response.Content.ReadAsStringAsync());
- }
-
- [Fact]
- public async Task The_first_login_sets_the_server_up_and_becomes_its_administrator()
- {
- var stamp = DateTime.UtcNow.ToString("MMddHHmmss");
- var root = $"e2eadmin{stamp}";
- var persona = $"e2efounder{stamp}";
- // the pasture's administrator closed setup: it is opened for this test, and closed again whatever happens
- await Pasture.Mongo("db.ServerSetup.deleteOne({_id: 'setup'})");
- try
- {
- Assert.True(await SetupPending());
- await using var session = await browser.Open();
- var page = session.Page;
- await Results.Cell("client.action.setup", "privapub", root, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/login");
- await page.Locator("[data-setup-offer]").ClickAsync();
- await page.Locator("input[data-setup-username]").FillAsync(root);
- await page.Locator("input[data-setup-password]").FillAsync(Password);
- await page.Locator("input[data-setup-repeat]").FillAsync(Password);
- await page.Locator("input[data-setup-persona-username]").FillAsync(persona);
- await page.Locator("input[data-setup-persona-name]").FillAsync("The e2e founder");
- await page.Locator("[data-setup-submit]").ClickAsync();
-
- await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 });
- await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
- await page.Locator("nav a[href='administration']").First.WaitForAsync(new() { Timeout = 15000 });
- Assert.False(await SetupPending());
- var jwt = await Jwt(root, Password);
- var personas = (await Send(HttpMethod.Get, "/clientapi/avatar/private/list", jwt))!.AsArray();
- Assert.Equal(persona, Assert.Single(personas)!["userName"]!.GetValue());
- await session.AssertGuards();
-
- // set up: a reader without a login is offered sign-in and invitations only
- await using var stranger = await browser.Open();
- await stranger.Page.GotoAsync($"{Pasture.Site}/setup");
- await stranger.Page.Locator("[data-setup-done]").WaitForAsync(new() { Timeout = 15000 });
- await stranger.Page.GotoAsync($"{Pasture.Site}/login");
- await stranger.Page.Locator("[data-join-offer]").WaitForAsync(new() { Timeout = 15000 });
- Assert.Equal(0, await stranger.Page.Locator("[data-setup-offer]").CountAsync());
- }, page);
- }
- finally
- {
- // the pasture's administrator stays pastureroot: the test's is demoted, and setup stays closed
- await Pasture.Mongo($"db.RootUser.updateOne({{UserName: '{root}'}}, {{$set: {{Policies: ['isUser']}}}}); " +
- "if (!db.ServerSetup.findOne({_id: 'setup'})) db.ServerSetup.insertOne({_id: 'setup', AdminId: 'e2e', SealedAt: new Date()})");
- }
- }
-
- [Fact]
- public async Task An_invitation_link_makes_a_login_whose_persona_joins_the_group()
- {
- var stamp = DateTime.UtcNow.ToString("MMddHHmmss");
- // the circle's owner is a login of the test's own (the pasture's sign-up is open), so the town's sessions don't matter
- var ownerJwt = default(string);
- using (var http = Pasture.Http())
- {
- using var signup = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/signup", new { userName = $"e2eowner{stamp}", password = Password });
- Assert.SkipWhen(!signup.IsSuccessStatusCode, $"PrivaPub refuses sign-ups ({(int)signup.StatusCode})");
- ownerJwt = (await signup.Content.ReadFromJsonAsync())!["token"]!.GetValue();
- }
- var ownerId = (await Send(HttpMethod.Post, "/clientapi/avatar/private/insert", ownerJwt,
- new { userName = $"e2ehost{stamp}", name = "An e2e host", biography = "for the invitation test" }))!["id"]!.GetValue();
- var group = await Send(HttpMethod.Post, "/clientapi/group/insert", ownerJwt,
- new { avatarId = ownerId, userName = $"e2ecircle{stamp}", name = "An e2e circle", isCommunity = false });
- var code = group!["invitationCode"]!.GetValue();
- var root = $"e2ejoin{stamp}";
- var persona = $"e2eguest{stamp}";
- await using var session = await browser.Open();
- var page = session.Page;
- await Results.Cell("client.action.invitation", "privapub", root, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/join?code={Uri.EscapeDataString(code)}");
- Assert.Equal(code, await page.Locator("input[data-join-code]").InputValueAsync());
- await page.Locator("input[data-join-username]").FillAsync(root);
- await page.Locator("input[data-join-password]").FillAsync(Password);
- await page.Locator("input[data-join-repeat]").FillAsync(Password);
- await page.Locator("input[data-join-persona-username]").FillAsync(persona);
- await page.Locator("[data-join-submit]").ClickAsync();
-
- await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 });
- await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
- var members = (await Send(HttpMethod.Get,
- $"/clientapi/group/members?avatarId={Uri.EscapeDataString(ownerId)}&groupId={Uri.EscapeDataString(group["id"]!.GetValue())}", ownerJwt))!.AsArray();
- Assert.Contains(members, m => (m!["handle"]?.GetValue() ?? "").Contains(persona) || m["name"]?.GetValue() == persona);
- await session.AssertGuards();
- }, page);
- }
- }
-}
diff --git a/tests/e2e/Mobile/ControlsTests.cs b/tests/e2e/Mobile/ControlsTests.cs
deleted file mode 100644
index 964f8a9..0000000
--- a/tests/e2e/Mobile/ControlsTests.cs
+++ /dev/null
@@ -1,56 +0,0 @@
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Mobile
-{
- // Controls the sweep doesn't reach, since it measures pages with their sections closed: Settings' Data import/export
- // opened on the narrowest phone, its file inputs and checkboxes thumb-sized and drawn like the rest of the app (a
- // ticked box filled, not a tick on nothing), in both themes. Photos go to tests/e2e/out/mobile-controls/.
- [Trait("Category", "Mobile")]
- public sealed class ControlsTests(Browser browser)
- {
- static readonly Phone Phone = Phone.Narrow360;
- static readonly string OutDir = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "out", "mobile-controls");
-
- [Fact]
- public async Task Data_transfer_controls_are_thumb_sized_and_drawn_like_the_rest()
- {
- await using var session = await SignIn.As(browser, Pasture.Personas().First(), Phone);
- var page = session.Page;
- await Results.Cell("client.mobile.controls.data-transfer", "privapub", Phone.Key, async () =>
- {
- await page.GotoAsync($"{Pasture.Site}/settings");
- var header = page.Locator(".collapse-title", new() { HasText = "Data import/export" }).First;
- await header.TapAsync();
- var archive = page.Locator("[data-archive]");
- await archive.WaitForAsync(new() { Timeout = 30000 });
-
- Fit.AssertTouchable(await Fit.Now(page, "[data-archive]"));
- Fit.AssertTouchable(await Fit.Now(page, "[data-import]"));
-
- // a ticked box is filled: daisyUI leaves it transparent unless a colour asks otherwise
- var ticked = archive.Locator("input[type=checkbox]:checked").First;
- var fill = await ticked.EvaluateAsync("e => getComputedStyle(e).backgroundColor");
- Assert.False(fill is "transparent" or "rgba(0, 0, 0, 0)", $"a ticked checkbox is drawn on nothing ({fill})");
- // a file input is as tall as the other fields on a touch screen
- foreach (var input in await page.Locator("[data-archive] input[type=file], [data-import] input[type=file]").AllAsync())
- Assert.True((await input.BoundingBoxAsync())!.Height >= 44, $"a file input is {(await input.BoundingBoxAsync())!.Height}px tall: "
- + await input.EvaluateAsync("e => { const s = getComputedStyle(e); return `${e.className} --size=${s.getPropertyValue('--size')} height=${s.height} coarse=${matchMedia('(pointer: coarse)').matches}`; }"));
- // photos the size of the screen: one of an element taller than the screen makes Playwright resize the page,
- // which drops the touch emulation for what comes after
- Directory.CreateDirectory(OutDir);
- foreach (var theme in new[] { "neo-dark", "neo-light" })
- {
- await page.EvaluateAsync("t => document.documentElement.setAttribute('data-theme', t)", theme);
- foreach (var (part, what) in new[] { ("archive", "[data-archive-make]"), ("restore", "input[data-archive-file]"), ("csv", "[data-import]") })
- {
- await page.Locator(what).First.EvaluateAsync("e => e.scrollIntoView({ block: 'start' })");
- await page.ScreenshotAsync(new() { Path = Path.Combine(OutDir, $"{part}-{theme}.png"), Animations = ScreenshotAnimations.Disabled });
- }
- }
- await session.AssertGuards();
- }, page);
- }
- }
-}
diff --git a/tests/e2e/Mobile/DesktopUnchangedTests.cs b/tests/e2e/Mobile/DesktopUnchangedTests.cs
deleted file mode 100644
index 5699b73..0000000
--- a/tests/e2e/Mobile/DesktopUnchangedTests.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Mobile
-{
- // What phones get stays on phones: at 1440x900 with a mouse the controls keep daisyUI's sizes, measured on probes made
- // with the app's own classes, and the nav menu stays open beside the feed.
- [Trait("Category", "Mobile")]
- public sealed class DesktopUnchangedTests(Browser browser)
- {
- const string Probe = """
- () => ["btn", "btn btn-sm", "btn btn-xs", "input", "input input-sm"].map(classes => {
- const el = document.createElement(classes.startsWith("input") ? "input" : "button")
- el.className = classes
- document.body.append(el)
- const height = el.getBoundingClientRect().height
- el.remove()
- return height
- })
- """;
-
- [Fact]
- public async Task Desktop_keeps_its_sizes()
- {
- var persona = Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
- await using var session = await SignIn.As(browser, persona);
- var page = session.Page;
- await Results.Cell("client.mobile.desktop", "privapub", "desktop", async () =>
- {
- Assert.False(await page.EvaluateAsync("() => matchMedia('(pointer: coarse)').matches"), "the desktop session has a coarse pointer");
- // btn, btn btn-sm, btn btn-xs, input, input input-sm
- var heights = await page.EvaluateAsync(Probe);
- Assert.Equal([40, 32, 24, 40, 32], heights.Select(h => Math.Round(h)).ToArray());
- await Assertions.Expect(page.Locator("nav a.btn").First).ToBeVisibleAsync();
- await session.AssertGuards();
- }, page);
- }
- }
-}
diff --git a/tests/e2e/Mobile/Fit.cs b/tests/e2e/Mobile/Fit.cs
deleted file mode 100644
index 1cede72..0000000
--- a/tests/e2e/Mobile/Fit.cs
+++ /dev/null
@@ -1,182 +0,0 @@
-using System.Net;
-using System.Text;
-using System.Text.Json;
-using System.Text.Json.Nodes;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Mobile
-{
- // The phone sweep: a page measured and photographed one screen at a time down its main scroller (fit.js measures),
- // what doesn't fit kept as violations.json next to the screenshots, and a contact sheet of every route at every phone
- // in tests/e2e/out/mobile//index.html, for the eye.
- public static class Fit
- {
- public static readonly string Script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Mobile", "fit.js"));
-
- static readonly string RunDir = Path.Combine(TestsRoot(), "out", "mobile", DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"));
- static readonly object SheetGate = new();
-
- static string TestsRoot()
- {
- var dir = AppContext.BaseDirectory;
- while (dir is not null && !File.Exists(Path.Combine(dir, "decePubClient.E2E.csproj")))
- dir = Path.GetDirectoryName(dir);
- return dir ?? throw new InvalidOperationException("decePubClient.E2E.csproj not found above the test output");
- }
-
- // what a sweep found, each finding once however many screens showed it
- public sealed class Report
- {
- readonly HashSet seen = [];
- public List Overflow { get; } = [];
- public List Beyond { get; } = [];
- public List Popovers { get; } = [];
- public List Small { get; } = [];
- public List Tiny { get; } = [];
- public int Screens { get; set; }
-
- public void Take(JsonNode measured, int screen)
- {
- Add(Overflow, measured["overflow"], "overflow", screen);
- Add(Beyond, measured["beyond"], "beyond", screen);
- Add(Popovers, measured["popovers"], "popover", screen);
- Add(Small, measured["small"], "small", screen);
- Add(Tiny, measured["tiny"], "tiny", screen);
- }
-
- void Add(List into, JsonNode found, string kind, int screen)
- {
- foreach (var item in found!.AsArray().Select(i => i!.AsObject()))
- {
- var key = $"{kind}|{item["element"]}|{item["text"]}|{item["card"]}";
- if (!seen.Add(key))
- continue;
- var copy = Clean(item.DeepClone()).AsObject();
- copy["screen"] = screen;
- into.Add(copy);
- }
- }
-
- // Playwright marks the objects it hands back with $id; the findings don't need them
- static JsonNode Clean(JsonNode node)
- {
- if (node is JsonObject obj)
- {
- obj.Remove("$id");
- foreach (var (_, value) in obj)
- Clean(value);
- }
- else if (node is JsonArray array)
- foreach (var value in array)
- Clean(value);
- return node;
- }
-
- public string Json() => new JsonObject
- {
- ["screens"] = Screens,
- ["overflow"] = new JsonArray(Overflow.Select(o => (JsonNode)o.DeepClone()).ToArray()),
- ["beyond"] = new JsonArray(Beyond.Select(o => (JsonNode)o.DeepClone()).ToArray()),
- ["popovers"] = new JsonArray(Popovers.Select(o => (JsonNode)o.DeepClone()).ToArray()),
- ["small"] = new JsonArray(Small.Select(o => (JsonNode)o.DeepClone()).ToArray()),
- ["tiny"] = new JsonArray(Tiny.Select(o => (JsonNode)o.DeepClone()).ToArray())
- }.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
- }
-
- public static Task Settle(IPage page) => page.EvaluateAsync("() => window.__fit.settle()");
-
- public static async Task Sweep(IPage page, Support.Phone phone, string slug, int screens)
- {
- var dir = Path.Combine(RunDir, phone.Key, slug);
- Directory.CreateDirectory(dir);
- var report = new Report();
- await Settle(page);
- await page.EvaluateAsync("() => window.__fit.primary()");
- for (var screen = 0; screen < screens; screen++)
- {
- var measured = JsonNode.Parse((await page.EvaluateAsync("() => window.__fit.measure()")).GetRawText())!;
- report.Take(measured, screen);
- await page.ScreenshotAsync(new()
- {
- Path = Path.Combine(dir, $"{screen:00}.png"), Animations = ScreenshotAnimations.Disabled, Caret = ScreenshotCaret.Hide
- });
- report.Screens = screen + 1;
- if (!await page.EvaluateAsync("() => window.__fit.step(0.8)"))
- break;
- await Settle(page);
- }
- await File.WriteAllTextAsync(Path.Combine(dir, "violations.json"), report.Json());
- ContactSheet();
- return report;
- }
-
- // one measurement of the page as it is now; with a scope (an open menu, sheet or dialog), its targets only
- public static async Task Now(IPage page, string scope = null)
- {
- await Settle(page);
- var report = new Report();
- var measured = JsonNode.Parse((await page.EvaluateAsync("scope => window.__fit.measure(scope)", scope)).GetRawText())!;
- report.Take(measured, 0);
- report.Screens = 1;
- return report;
- }
-
- // nothing scrolls sideways, nothing lies past the screen's sides, and every open menu, dialog or tip is whole
- public static void AssertFits(Report report)
- {
- var problems = report.Overflow.Select(o => $"sideways scroll in {o["element"]} (+{o["extra"]}px), furthest: {o["culprit"]?.ToJsonString()}")
- .Concat(report.Beyond.Select(b => $"past the screen: {b.ToJsonString()}"))
- .Concat(report.Popovers.Select(p => $"cut or outside: {p.ToJsonString()}"))
- .ToList();
- Assert.True(problems.Count == 0, $"{problems.Count} fit problems:\n" + string.Join("\n", problems.Take(12)));
- }
-
- // every target a thumb can reach has a 44px hit area, and no control's text is under 12px
- public static void AssertTouchable(Report report)
- {
- var problems = report.Small.Select(s => $"small target: {s.ToJsonString()}")
- .Concat(report.Tiny.Select(t => $"small text: {t.ToJsonString()}"))
- .ToList();
- Assert.True(problems.Count == 0, $"{problems.Count} touch problems:\n" + string.Join("\n", problems.Take(12)));
- }
-
- // every route as a row, every phone as a column: the first screen and how many findings
- static void ContactSheet()
- {
- lock (SheetGate)
- {
- var phones = Directory.GetDirectories(RunDir).Select(Path.GetFileName).Order().ToList();
- var routes = phones.SelectMany(p => Directory.GetDirectories(Path.Combine(RunDir, p)).Select(Path.GetFileName)).Distinct().Order().ToList();
- var html = new StringBuilder("decePub on phones")
- .Append(" | ");
- foreach (var phone in phones)
- html.Append("").Append(WebUtility.HtmlEncode(phone)).Append(" | ");
- html.Append("
");
- foreach (var route in routes)
- {
- html.Append("| ").Append(WebUtility.HtmlEncode(route)).Append(" | ");
- foreach (var phone in phones)
- {
- var dir = Path.Combine(RunDir, phone, route);
- html.Append("");
- if (File.Exists(Path.Combine(dir, "violations.json")))
- {
- var found = JsonNode.Parse(File.ReadAllText(Path.Combine(dir, "violations.json")))!;
- var fit = found["overflow"]!.AsArray().Count + found["beyond"]!.AsArray().Count + found["popovers"]!.AsArray().Count;
- var touch = found["small"]!.AsArray().Count + found["tiny"]!.AsArray().Count;
- var link = $"{phone}/{route}";
- html.Append($" ")
- .Append($" 0 ? "bad" : "")}\">fit {fit} · touch {touch} · {found["screens"]} screens ")
- .Append($"findings");
- }
- html.Append(" | ");
- }
- html.Append("
");
- }
- File.WriteAllText(Path.Combine(RunDir, "index.html"), html.Append("
").ToString());
- }
- }
- }
-}
diff --git a/tests/e2e/Mobile/FlowTests.cs b/tests/e2e/Mobile/FlowTests.cs
deleted file mode 100644
index f28884c..0000000
--- a/tests/e2e/Mobile/FlowTests.cs
+++ /dev/null
@@ -1,363 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Mobile
-{
- // What a thumb does on the narrowest phone, step by step: every menu, sheet and dialog that opens is whole on the
- // screen and its targets have 44px hit areas, and each one closes the way a phone user expects. One cell each,
- // client.mobile.flow.*.
- [Trait("Category", "Mobile")]
- public sealed class FlowTests(Browser browser)
- {
- static readonly Phone Phone = Phone.Narrow360;
-
- static Pasture.Persona Persona() => Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
-
- static async Task Flow(Session session, string name, Func steps)
- {
- var page = session.Page;
- await Results.Cell($"client.mobile.flow.{name}", "privapub", Phone.Key, async () =>
- {
- await steps(page);
- await session.AssertGuards();
- }, page);
- }
-
- // what opened is whole (not cut, inside the screen) and its targets are thumb-sized; the rest of the page is the
- // sweep's
- static async Task AssertWhole(IPage page, string scope)
- {
- var report = await Fit.Now(page, scope);
- Assert.True(report.Popovers.Count == 0, "cut or outside: " + string.Join("\n", report.Popovers.Select(p => p.ToJsonString())));
- Fit.AssertTouchable(report);
- }
-
- static ILocator Card(IPage page, string has = null) =>
- (has is null ? page.Locator("#feed-scroll article[data-uri]") : page.Locator($"#feed-scroll article[data-uri]:has({has})")).First;
-
- [Fact]
- public async Task The_menu_closes_after_a_choice_an_outside_tap_and_escape()
- {
- await using var session = await SignIn.As(browser, Persona(), Phone);
- await Flow(session, "nav", async page =>
- {
- var toggle = page.Locator("[data-nav-toggle]");
- var panel = page.Locator("[data-nav-panel]");
- await toggle.TapAsync();
- await Assertions.Expect(panel).ToBeVisibleAsync();
- await AssertWhole(page, "[data-nav-panel]");
- await panel.Locator("a[href='explore']").TapAsync();
- await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore");
- await Assertions.Expect(panel).ToBeHiddenAsync();
-
- await toggle.TapAsync();
- await Assertions.Expect(panel).ToBeVisibleAsync();
- var size = page.ViewportSize!;
- await page.Touchscreen.TapAsync(size.Width - 12, size.Height - 40);
- await Assertions.Expect(panel).ToBeHiddenAsync();
-
- await toggle.TapAsync();
- await Assertions.Expect(panel).ToBeVisibleAsync();
- await page.Keyboard.PressAsync("Escape");
- await Assertions.Expect(panel).ToBeHiddenAsync();
- });
- }
-
- [Fact]
- public async Task A_dialog_rises_from_the_bottom_and_closes_from_its_button()
- {
- await using var session = await SignIn.As(browser, Persona(), Phone);
- await Flow(session, "dialog", async page =>
- {
- var card = Card(page, "[data-liked-by]");
- if (await card.CountAsync() == 0)
- Assert.Skip("no post in the home feed has been liked");
- await card.ScrollIntoViewIfNeededAsync();
- await card.Locator("footer [title='Other'] > [role=button]").TapAsync();
- await AssertWhole(page, "[data-sheet]");
- await card.Locator("[data-liked-by]").TapAsync();
- var dialog = page.Locator(".modal-open .modal-box").First;
- await Assertions.Expect(dialog).ToBeVisibleAsync();
- await AssertWhole(page, ".modal-open .modal-box");
- var box = (await dialog.BoundingBoxAsync())!;
- Assert.True(box.Y + box.Height >= page.ViewportSize!.Height - 2, "the dialog doesn't rise from the bottom of the screen");
- await page.Locator(".modal-open [data-modal-close]").TapAsync();
- await Assertions.Expect(page.Locator(".modal-open")).ToHaveCountAsync(0);
- });
- }
-
- [Fact]
- public async Task A_new_post_is_written_in_a_sheet_from_a_scrolled_feed()
- {
- var persona = Persona();
- var text = $"from a phone {Guid.NewGuid():N}"[..24];
- await using var session = await SignIn.As(browser, persona, Phone);
- string made = null;
- try
- {
- await Flow(session, "compose", async page =>
- {
- await page.EvaluateAsync("() => document.getElementById('feed-scroll').scrollTo({ top: 1500, behavior: 'instant' })");
- await page.Locator("[data-compose-open]").TapAsync();
- var sheet = page.Locator("[data-sheet]:has(textarea[data-compose])").First;
- await Assertions.Expect(sheet).ToBeVisibleAsync();
- await AssertWhole(page, "[data-sheet]");
- Assert.True(await page.EvaluateAsync("() => document.activeElement?.matches('textarea[data-compose]') === true"),
- "the composer's words don't have the focus");
- await page.Keyboard.TypeAsync(text);
- await sheet.Locator("button[type=submit]").TapAsync();
- await Assertions.Expect(sheet).ToBeHiddenAsync(new() { Timeout = 15000 });
- JsonNode latest = null;
- Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue().Contains(text) == true, 15),
- "the post was never published");
- made = latest!["id"]!.GetValue();
- });
- }
- finally
- {
- if (made is not null)
- await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
- }
- }
-
- [Fact]
- public async Task A_reply_is_written_in_a_sheet()
- {
- var persona = Persona();
- var text = $"a phone reply {Guid.NewGuid():N}"[..24];
- await using var session = await SignIn.As(browser, persona, Phone);
- string made = null;
- try
- {
- await Flow(session, "reply", async page =>
- {
- var card = Card(page, "footer button[title='Reply']");
- await card.Locator("footer button[title='Reply']").TapAsync();
- var sheet = page.Locator("[data-sheet]:has(textarea[data-compose])").First;
- await Assertions.Expect(sheet).ToBeVisibleAsync();
- await AssertWhole(page, "[data-sheet]");
- await sheet.Locator("textarea[data-compose]").FillAsync(text);
- await sheet.Locator("button[type=submit]").TapAsync();
- await Assertions.Expect(sheet).ToBeHiddenAsync(new() { Timeout = 15000 });
- JsonNode latest = null;
- Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue().Contains(text) == true, 15),
- "the reply was never published");
- made = latest!["id"]!.GetValue();
- Assert.NotNull(latest["in_reply_to_id"]?.GetValue());
- });
- }
- finally
- {
- if (made is not null)
- await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
- }
- }
-
- [Fact]
- public async Task A_reaction_is_picked_from_a_sheet()
- {
- await using var session = await SignIn.As(browser, Persona(), Phone);
- await Flow(session, "react", async page =>
- {
- var card = Card(page, "footer [title='React']");
- var uri = await card.GetAttributeAsync("data-uri");
- card = page.Locator($"#feed-scroll article[data-uri='{uri}']").First;
- await card.Locator("footer [title='React'] > [role=button]").TapAsync();
- await AssertWhole(page, "[data-sheet]");
- await card.Locator("[data-sheet] button", new() { HasText = "😮" }).First.TapAsync();
- var notice = page.Locator("[data-public-notice].modal-open");
- if (await notice.IsVisibleAsync() || await Pasture.Eventually(() => notice.IsVisibleAsync(), 2))
- {
- await AssertWhole(page, "[data-public-notice] .modal-box");
- await page.Locator("[data-public-notice-yes]").TapAsync();
- }
- var mine = card.Locator("[data-reactions] button.btn-active", new() { HasText = "😮" });
- await Assertions.Expect(mine).ToBeVisibleAsync(new() { Timeout = 15000 });
- // and taken back
- await mine.TapAsync();
- await Assertions.Expect(mine).ToHaveCountAsync(0, new() { Timeout = 15000 });
- });
- }
-
- [Fact]
- public async Task The_globe_opens_full_screen_from_its_banner_and_closes()
- {
- await using var session = await SignIn.As(browser, Persona(), Phone);
- await Flow(session, "globe", async page =>
- {
- await page.Locator("button[title='Globe']").TapAsync();
- var expand = page.Locator("[data-globe-expand]");
- await Assertions.Expect(expand).ToBeVisibleAsync();
- await expand.TapAsync();
- var full = page.Locator("aside[role=dialog]");
- await Assertions.Expect(full).ToBeVisibleAsync();
- var box = (await full.BoundingBoxAsync())!;
- var size = page.ViewportSize!;
- Assert.True(Math.Abs(box.Width - size.Width) < 2 && Math.Abs(box.Height - size.Height) < 2,
- $"the globe is {box.Width}x{box.Height}, not the screen's {size.Width}x{size.Height}");
- await AssertWhole(page, "aside[role=dialog]");
- await page.Locator("[data-globe-close]").TapAsync();
- await Assertions.Expect(full).ToHaveCountAsync(0);
- await Assertions.Expect(expand).ToBeVisibleAsync();
- });
- }
-
- // a phone held sideways keeps its feed: no banner squeezing it, the globe full screen from the tabs instead
- [Fact]
- public async Task A_phone_held_sideways_keeps_its_feed()
- {
- await using var session = await SignIn.As(browser, Persona(), Phone.Pixel7Landscape);
- var page = session.Page;
- await Results.Cell("client.mobile.flow.landscape", "privapub", Phone.Pixel7Landscape.Key, async () =>
- {
- Assert.False(await page.Locator("[data-globe-expand]").IsVisibleAsync(), "the globe's banner squeezes the feed");
- var feedHeight = await page.EvaluateAsync("() => document.getElementById('feed-scroll').clientHeight");
- Assert.True(feedHeight >= 180, $"the feed is {feedHeight}px tall");
- Assert.True(await page.EvaluateAsync("() => document.scrollingElement.scrollWidth <= innerWidth + 1"), "the page scrolls sideways");
- await page.Locator("[data-globe-full]").TapAsync();
- await Assertions.Expect(page.Locator("aside[role=dialog]")).ToBeVisibleAsync();
- await page.Locator("[data-globe-close]").TapAsync();
- await Assertions.Expect(page.Locator("aside[role=dialog]")).ToHaveCountAsync(0);
- await session.AssertGuards();
- }, page);
- }
-
- [Fact]
- public async Task Deleting_a_post_asks_first()
- {
- var persona = Persona();
- var text = $"to be deleted {Guid.NewGuid():N}"[..26];
- var made = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "public" }))!;
- var id = made["id"]!.GetValue();
- await using var session = await SignIn.As(browser, persona, Phone);
- try
- {
- await Flow(session, "delete", async page =>
- {
- var card = page.Locator($"#feed-scroll article[data-uri='{made["uri"]}']").First;
- await card.WaitForAsync(new() { Timeout = 30000 });
- var more = card.Locator("footer [title='Other'] > [role=button]");
- await more.TapAsync();
- await card.Locator("[data-sheet] button", new() { HasText = "Delete" }).TapAsync();
- var confirm = page.Locator("[data-confirm].modal-open");
- await Assertions.Expect(confirm).ToBeVisibleAsync();
- await AssertWhole(page, "[data-confirm] .modal-box");
- await page.Locator("[data-confirm-no]").TapAsync();
- await Assertions.Expect(confirm).ToHaveCountAsync(0);
- Assert.NotNull(await PersonaApi.Call(persona, $"/api/v1/statuses/{id}"));
-
- await more.TapAsync();
- await card.Locator("[data-sheet] button", new() { HasText = "Delete" }).TapAsync();
- await page.Locator("[data-confirm-yes]").TapAsync();
- await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 15000 });
- Assert.True(await Pasture.Eventually(async () => await PersonaApi.Call(persona, $"/api/v1/statuses/{id}") is null, 15), "the post is still there");
- });
- }
- finally
- {
- await PersonaApi.Call(persona, $"/api/v1/statuses/{id}", HttpMethod.Delete);
- }
- }
-
- // a tap on a post (in its margin, not on a link or a control) opens its thread
- [Fact]
- public async Task A_tap_on_a_post_opens_its_thread()
- {
- await using var session = await SignIn.As(browser, Persona(), Phone);
- await Flow(session, "thread", async page =>
- {
- var card = Card(page, "a[data-thread-link]");
- var box = (await card.BoundingBoxAsync())!;
- await card.TapAsync(new() { Position = new() { X = box.Width - 5, Y = box.Height * 0.35f } });
- await page.WaitForURLAsync(url => new Uri(url).AbsolutePath.StartsWith("/expand/"), new() { Timeout = 15000 });
- Assert.Empty(await page.Locator("[data-globe-pinned]").AllAsync());
- });
- }
-
- // a picture opens in the app, the whole screen, and closes
- [Fact]
- public async Task A_picture_opens_in_the_app()
- {
- var expected = (await Pasture.Town("expect")).AsArray();
- var personas = Pasture.Personas();
- var item = expected.FirstOrDefault(i => i!["alts"]!.AsArray().Count > 0
- && i["readers"]!.AsArray().Any(r => personas.Any(p => p.UserName == (string)r)));
- Assert.SkipWhen(item is null, "the town holds no post with pictures");
- var reader = personas.First(p => item!["readers"]!.AsArray().Any(r => (string)r == p.UserName));
- await using var session = await SignIn.As(browser, reader, Phone);
- await Flow(session, "picture", async page =>
- {
- await page.GotoAsync($"{Pasture.Site}/expand/{item!["id"]}");
- var card = page.Locator($"article[data-uri='{item["uri"]}']").First;
- await card.WaitForAsync(new() { Timeout = 30000 });
- 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.TapAsync();
- await card.Locator("button[data-picture]").First.TapAsync();
- var viewer = page.Locator("[data-media-viewer].modal-open .modal-box");
- await Assertions.Expect(viewer).ToBeVisibleAsync();
- var box = (await viewer.BoundingBoxAsync())!;
- var size = page.ViewportSize!;
- Assert.True(box.Width >= size.Width - 2 && box.Height >= size.Height - 2, $"the picture viewer is {box.Width}x{box.Height}");
- await AssertWhole(page, "[data-media-viewer] .modal-box");
- if (await page.Locator("[data-picture-next]:enabled").CountAsync() > 0)
- await page.Locator("[data-picture-next]").TapAsync();
- await page.Locator("[data-media-viewer] [data-modal-close]").TapAsync();
- await Assertions.Expect(page.Locator("[data-media-viewer].modal-open")).ToHaveCountAsync(0);
- });
- }
-
- // the Back button closes what is open, one at a time, and stays on the page
- [Fact]
- public async Task Back_closes_what_is_open()
- {
- await using var session = await SignIn.As(browser, Persona(), Phone);
- await Flow(session, "back", async page =>
- {
- var home = page.Url;
- // a page to go back to, so a Back that isn't held would leave the feed
- await page.Locator("[data-nav-toggle]").TapAsync();
- await page.Locator("[data-nav-panel] a[href='explore']").TapAsync();
- await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore");
- await page.Locator("[data-nav-toggle]").TapAsync();
- await page.Locator("[data-nav-panel] a[href='/']").First.TapAsync();
- await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/");
- await page.Locator("#feed-scroll article[data-uri]").First.WaitForAsync();
-
- // (history.back(), as the Back button does: a Back held to close an overlay loads nothing, which GoBackAsync would
- // wait for)
- async Task BackCloses(ILocator open, string what)
- {
- await Assertions.Expect(open).ToBeVisibleAsync();
- await page.EvaluateAsync("() => history.back()");
- await Assertions.Expect(open).ToBeHiddenAsync(new() { Timeout = 5000 });
- Assert.True(new Uri(page.Url).AbsolutePath == "/", $"Back after the {what} left the feed for {page.Url}");
- }
-
- await page.Locator("[data-nav-toggle]").TapAsync();
- await BackCloses(page.Locator("[data-nav-panel]"), "menu");
-
- var card = Card(page, "footer [title='Other']");
- await card.Locator("footer [title='Other'] > [role=button]").TapAsync();
- await BackCloses(card.Locator(".dropdown-open > [data-sheet]"), "More sheet");
-
- await page.Locator("[data-compose-open]").TapAsync();
- await BackCloses(page.Locator("[data-sheet]:has(textarea[data-compose])"), "composer");
-
- await page.Locator("button[title='Globe']").TapAsync();
- await page.Locator("[data-globe-expand]").TapAsync();
- await BackCloses(page.Locator("aside[role=dialog]"), "full-screen globe");
-
- // with nothing open, Back leaves the feed as before
- await page.EvaluateAsync("() => history.back()");
- await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore", new() { Timeout = 10000 });
- });
- }
-
- static async Task Latest(Pasture.Persona persona) =>
- (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault();
- }
-}
diff --git a/tests/e2e/Mobile/RouteSweepTests.cs b/tests/e2e/Mobile/RouteSweepTests.cs
deleted file mode 100644
index 5b3ff05..0000000
--- a/tests/e2e/Mobile/RouteSweepTests.cs
+++ /dev/null
@@ -1,62 +0,0 @@
-using decePubClient.E2E.Support;
-
-namespace decePubClient.E2E.Mobile
-{
- // Every page at every portrait phone: nothing scrolls sideways or lies past the screen, every open menu and dialog is
- // whole, every target has a 44px hit area and no control's text is under 12px. Two cells per page and phone,
- // client.mobile.fit.* and client.mobile.touch.*; the screenshots and findings go to tests/e2e/out/mobile/.
- [Trait("Category", "Mobile")]
- public sealed class RouteSweepTests(Browser browser)
- {
- public static TheoryData Cases()
- {
- var data = new TheoryData();
- foreach (var phone in Phone.Portrait)
- foreach (var route in Route.All)
- data.Add(phone.Key, route.Slug);
- return data;
- }
-
- [Theory]
- [MemberData(nameof(Cases))]
- public async Task Fits_a_phone(string phoneKey, string slug)
- {
- var phone = Phone.ByKey(phoneKey);
- var route = Route.Get(slug);
- var persona = route.Persona();
- await using var session = route.SignedOut ? await browser.Open(phone: phone) : await SignIn.As(browser, persona, phone);
- try
- {
- await route.Open(session.Page, persona);
- }
- catch (Exception e) when (e is not Xunit.Sdk.SkipException)
- {
- await SignIn.Evidence(session.Page, $"open-{phone.Key}-{slug}");
- throw;
- }
- var report = await Fit.Sweep(session.Page, phone, slug, route.Screens);
- if (!route.Gating)
- return;
-
- var problems = new List();
- foreach (var (feature, check) in new (string, Action)[]
- {
- ($"client.mobile.fit.{slug}", Fit.AssertFits), ($"client.mobile.touch.{slug}", Fit.AssertTouchable)
- })
- try
- {
- await Results.Cell(feature, "privapub", phone.Key, () =>
- {
- check(report);
- return Task.CompletedTask;
- });
- }
- catch (Exception e)
- {
- problems.Add(e.Message);
- }
- await session.AssertGuards();
- Assert.True(problems.Count == 0, string.Join("\n\n", problems));
- }
- }
-}
diff --git a/tests/e2e/Mobile/Routes.cs b/tests/e2e/Mobile/Routes.cs
deleted file mode 100644
index caba068..0000000
--- a/tests/e2e/Mobile/Routes.cs
+++ /dev/null
@@ -1,136 +0,0 @@
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Mobile
-{
- // The pages the phone sweep visits, each opened as a reader would get there: signed in as a town persona (the one with
- // sibling personas, unless the page shows another's post), or signed out for the login page. Screens is how far down
- // the page's main scroller the sweep measures.
- public sealed record Route(string Slug, Func Open, Func Reader = null,
- int Screens = 8, bool SignedOut = false, bool Gating = true)
- {
- static readonly Lazy Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
-
- static Pasture.Persona Sweeper() => Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
-
- static readonly Pasture.Persona Admin = new("alice", "alice", PastureAdmin.Root, PastureAdmin.Password, null, null, null);
-
- public Pasture.Persona Persona() => (Reader ?? Sweeper)();
-
- public static readonly Route[] All =
- [
- new("feed-home", (_, _) => Task.CompletedTask),
- new("feed-local", Tab(1), Screens: 6),
- new("feed-federated", Tab(2), Screens: 6),
- new("feed-nearby", Tab(3), Screens: 4),
- new("feed-groups", Tab(4), Screens: 6),
- new("feed-direct", Tab(5), Screens: 6),
- new("feed-notifications", Tab(6), Screens: 6),
- new("feed-favourites", Tab(7), Screens: 4),
- new("feed-bookmarks", Tab(8), Screens: 4),
- new("feed-lists", Tab(9), Screens: 4),
- Thread("thread-poll", i => i["poll"] is JsonArray),
- Thread("thread-pictures", i => i["alts"]!.AsArray().Count > 0),
- Thread("thread-titled", i => i["title"] is not null),
- Thread("thread-quote", i => i["quote"] is not null),
- Thread("thread-warning", i => i["cw"] is not null),
- Thread("thread-replies", i => (string)i["kind"] == "thread"),
- new("profile-own", async (page, persona) => await Visit(page, $"/account/{persona.LocalId}"), Screens: 10),
- new("profile-followed", async (page, persona) =>
- {
- var followed = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=1"))?.AsArray().FirstOrDefault();
- if (followed is null)
- Assert.Skip("the persona follows nobody");
- await Visit(page, $"/account/{followed["id"]}");
- }, Screens: 10),
- new("search", async (page, persona) =>
- {
- var other = Pasture.Personas().First(p => p.Root != persona.Root);
- await Visit(page, $"/search?q={Uri.EscapeDataString(other.UserName)}");
- }),
- new("explore-hashtags", async (page, _) => await Visit(page, "/explore")),
- new("explore-posts", ExploreTab(1)),
- new("explore-links", ExploreTab(2)),
- new("explore-people", ExploreTab(3)),
- new("explore-followed", ExploreTab(4)),
- new("lists", async (page, _) => await Visit(page, "/lists")),
- new("groups", async (page, _) => await Visit(page, "/groups")),
- new("filters", async (page, _) => await Visit(page, "/filters")),
- new("scheduled", async (page, _) => await Visit(page, "/scheduled")),
- new("notifications-filtered", async (page, _) => await Visit(page, "/notifications/filtered")),
- new("settings", async (page, _) =>
- {
- await Visit(page, "/settings");
- await OpenEveryCollapse(page);
- }, Screens: 30),
- new("administration", async (page, _) =>
- {
- await Visit(page, "/administration");
- await OpenEveryCollapse(page);
- }, () => Admin, Screens: 30),
- new("login", async (page, _) => await Visit(page, "/login"), SignedOut: true),
- new("setup", async (page, _) => await Visit(page, "/setup"), SignedOut: true),
- new("join", async (page, _) => await Visit(page, "/join"), SignedOut: true),
- new("styleguide", async (page, _) => await Visit(page, "/styleguide"), SignedOut: true, Screens: 40, Gating: false)
- ];
-
- public static Route Get(string slug) => All.Single(r => r.Slug == slug);
-
- static async Task Visit(IPage page, string path)
- {
- await page.GotoAsync(Pasture.Site + path);
- await Fit.Settle(page);
- }
-
- // the feed's tab at this place: Home, Local, Federation, Nearby, Groups, Direct, Notifications, Favourites,
- // Bookmarks, Lists (FeedTabs.Always)
- static Func Tab(int index) => async (page, _) =>
- {
- await page.Locator("[role=tablist]").First.Locator("[role=tab]").Nth(index).TapAsync();
- await Fit.Settle(page);
- };
-
- static Func ExploreTab(int index) => async (page, _) =>
- {
- await Visit(page, "/explore");
- await page.Locator("[role=tablist]").First.Locator("[role=tab]").Nth(index).TapAsync();
- await Fit.Settle(page);
- };
-
- // a town post of the kind wanted, opened as its thread by a persona it was for
- static Route Thread(string slug, Func wanted)
- {
- JsonNode Pick() => Expected.Value.FirstOrDefault(i => wanted(i!) && ReaderOf(i) is not null);
- return new(slug, async (page, _) =>
- {
- if (Pick() is not { } item)
- {
- Assert.Skip($"the town holds no post for {slug}");
- return;
- }
- await page.GotoAsync($"{Pasture.Site}/expand/{item["id"]}");
- await page.Locator($"article[data-uri='{item["uri"]}']").First.WaitForAsync(new() { Timeout = 30000 });
- await Fit.Settle(page);
- }, () => Pick() is { } item ? ReaderOf(item) : Sweeper(), Screens: 10);
- }
-
- static Pasture.Persona ReaderOf(JsonNode item)
- {
- var personas = Pasture.Personas();
- return item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == (string)r)).FirstOrDefault(p => p is not null);
- }
-
- static async Task OpenEveryCollapse(IPage page)
- {
- var closed = page.Locator(".collapse-title[aria-expanded=false]");
- for (var i = 0; i < 20 && await closed.CountAsync() > 0; i++)
- {
- await closed.First.TapAsync();
- await Fit.Settle(page);
- }
- }
- }
-}
diff --git a/tests/e2e/Mobile/fit.js b/tests/e2e/Mobile/fit.js
deleted file mode 100644
index 7230f04..0000000
--- a/tests/e2e/Mobile/fit.js
+++ /dev/null
@@ -1,313 +0,0 @@
-// The phone tests' measurements (Mobile/Fit.cs), added to every page of a phone session as an init script. Each call
-// returns plain data, so no element handle outlives a scroll: Virtualize recycles the feed's cards.
-// settle() waits for fonts, for the page to stop changing, two frames and the pictures in view
-// primary() marks the page's main scroller, the one the sweep steps through
-// step(f) scrolls it by f of its height; false at its end
-// measure(s) what doesn't fit: sideways overflow, boxes beyond the screen, cut popovers, small targets, small text;
-// with a selector, the targets inside what it matches only (an open menu or dialog)
-window.__fit = (() => {
- const scrolls = value => value === "auto" || value === "scroll"
- const shown = el => el.checkVisibility({ opacityProperty: true, visibilityProperty: true })
- let styles = new Map()
- const style = el => {
- let found = styles.get(el)
- if (!found)
- styles.set(el, found = getComputedStyle(el))
- return found
- }
-
- // a short name for an element: its id, else its first data-* attribute, else tags and classes up to three levels
- function name(el, depth = 0) {
- if (el === document.scrollingElement || el === document.documentElement || el === document.body)
- return "document"
- if (el.id)
- return "#" + el.id
- const data = [...el.attributes].find(a => a.name.startsWith("data-") && !a.name.startsWith("data-fit"))
- const own = el.tagName.toLowerCase() + (data ? `[${data.name}]` : [...el.classList].slice(0, 3).map(c => "." + c).join(""))
- const parent = el.parentElement
- return data || depth >= 2 || !parent || parent === document.body ? own : `${name(parent, depth + 1)} > ${own}`
- }
-
- const text = el => (el.getAttribute("aria-label") || el.getAttribute("title") || el.innerText || el.value || "")
- .trim().replace(/\s+/g, " ").slice(0, 40)
-
- // the card an element is in (its ActivityPub id), for finding it again
- const card = el => el.closest("[data-uri]")?.getAttribute("data-uri") ?? null
-
- function describe(el, box) {
- return {
- element: name(el), text: text(el), card: card(el),
- box: [box.left, box.top, box.width, box.height].map(Math.round)
- }
- }
-
- function scrollers() {
- const found = [document.scrollingElement]
- for (const el of document.body.querySelectorAll("*")) {
- const s = style(el)
- if ((scrolls(s.overflowX) || scrolls(s.overflowY)) && el.clientWidth > 0 && el.clientHeight > 0 && shown(el))
- found.push(el)
- }
- return found
- }
-
- // the part of an element's box that can be seen: cut by every ancestor that clips, up to the screen for a fixed one
- function visibleRect(el) {
- const own = el.getBoundingClientRect()
- const box = { left: own.left, top: own.top, right: own.right, bottom: own.bottom }
- if (style(el).position !== "fixed")
- for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
- const s = style(a)
- if (s.overflowX !== "visible" || s.overflowY !== "visible") {
- const clip = a.getBoundingClientRect()
- if (s.overflowX !== "visible") {
- box.left = Math.max(box.left, clip.left)
- box.right = Math.min(box.right, clip.right)
- }
- if (s.overflowY !== "visible") {
- box.top = Math.max(box.top, clip.top)
- box.bottom = Math.min(box.bottom, clip.bottom)
- }
- }
- if (s.position === "fixed")
- break
- }
- return box
- }
-
- // the part of the screen an element's clipping ancestors let through: where a tap can reach it at all
- function clipOf(el) {
- const box = { left: 0, top: 0, right: innerWidth, bottom: innerHeight }
- if (style(el).position === "fixed")
- return box
- for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
- const s = style(a)
- if (s.overflowX !== "visible" || s.overflowY !== "visible") {
- const clip = a.getBoundingClientRect()
- box.left = Math.max(box.left, clip.left)
- box.right = Math.min(box.right, clip.right)
- box.top = Math.max(box.top, clip.top)
- box.bottom = Math.min(box.bottom, clip.bottom)
- }
- if (s.position === "fixed")
- break
- }
- return box
- }
-
- // an element inside a nested clipping box (a sideways strip, a truncated name) doesn't widen the scroller holding it
- function clippedWithin(el, scroller) {
- for (let a = el.parentElement; a && a !== scroller; a = a.parentElement)
- if (style(a).overflowX !== "visible")
- return true
- return false
- }
-
- // sideways scroll in the document or in a scroller that isn't meant to scroll sideways (data-scroll-x), with the
- // element reaching furthest right
- function overflow() {
- const found = []
- for (const scroller of scrollers()) {
- if (scroller.hasAttribute?.("data-scroll-x"))
- continue
- const extra = scroller.scrollWidth - scroller.clientWidth
- if (extra <= 1)
- continue
- const limit = scroller === document.scrollingElement ? innerWidth : scroller.getBoundingClientRect().right
- let worst = null, right = limit + 0.5
- for (const el of scroller.querySelectorAll("*")) {
- const r = el.getBoundingClientRect()
- if (r.width > 0 && r.right > right && !el.closest("[data-scroll-x]") && !clippedWithin(el, scroller)) {
- worst = el
- right = r.right
- }
- }
- found.push({
- element: name(scroller), extra: Math.round(extra),
- culprit: worst ? { ...describe(worst, worst.getBoundingClientRect()), past: Math.round(right - limit) } : null
- })
- }
- return found
- }
-
- // what can be seen of it lies past the screen's sides: the outermost such element only
- function beyond() {
- const found = [], reported = []
- for (const el of document.body.querySelectorAll("*")) {
- if (found.length >= 30)
- break
- if (el.closest("[data-scroll-x]") !== null && !el.hasAttribute("data-scroll-x"))
- continue
- const own = el.getBoundingClientRect()
- if (own.width <= 1 && own.height <= 1 || reported.some(r => r.contains(el)) || !shown(el))
- continue
- const seen = visibleRect(el)
- if (seen.right - seen.left <= 0.5 || seen.bottom - seen.top <= 0.5)
- continue
- if (seen.right > innerWidth + 1 || seen.left < -1) {
- reported.push(el)
- found.push(describe(el, own))
- }
- }
- return found
- }
-
- // open menus, dialogs, sheets and tips must be whole: inside the screen and not cut by what holds them
- function popovers() {
- const found = []
- for (const el of document.querySelectorAll(".dropdown-content, .modal-open > .modal-box, [data-sheet], [data-nav-panel], .tooltip-content")) {
- if (!shown(el))
- continue
- const own = el.getBoundingClientRect()
- if (own.width < 1 || own.height < 1)
- continue
- const seen = visibleRect(el)
- const outside = own.left < -1 || own.right > innerWidth + 1 || own.top < -1 || own.bottom > innerHeight + 1
- const cut = seen.left > own.left + 1 || seen.right < own.right - 1 || seen.top > own.top + 1 || seen.bottom < own.bottom - 1
- if (outside || cut)
- found.push({ ...describe(el, own), outside, cut })
- }
- return found
- }
-
- const candidates = "a[href], button, select, textarea, summary, [role=button], [role=tab], [role=link], [tabindex='0'], "
- + "input:not([type=hidden]), label:has(> input[type=checkbox]), label:has(> input[type=radio]), label:has(> .toggle)"
-
- function exempt(el) {
- return el.tagName === "A" && el.closest(".post-content") !== null
- || el.matches("video, audio, :disabled, [aria-disabled=true], .btn-disabled, .modal-backdrop > button")
- || el.closest("[aria-hidden=true], [data-touch-exempt], [inert]") !== null
- // a checkbox, radio or toggle inside a label is measured as its label
- || el.matches("input[type=checkbox], input[type=radio]") && el.closest("label") !== null
- }
-
- // a control floating over the page (the round New post button) covers what scrolls under it, by design: a probe that
- // lands on one says nothing about the target's own room
- function floating(hit, el) {
- for (let a = hit; a && a !== document.body; a = a.parentElement)
- if (style(a).position === "fixed")
- return !a.contains(el)
- return false
- }
-
- // a tap at this point reaches the element: on it, inside it, on its label's control, or on a label holding it
- function reaches(el, x, y) {
- const hit = document.elementFromPoint(x, y)
- if (!hit)
- return false
- if (hit === el || el.contains(hit))
- return true
- if (el.tagName === "LABEL" && el.control && (hit === el.control || el.control.contains(hit)))
- return true
- const label = hit.closest("label")
- return label !== null && label.contains(el)
- }
-
- // a target needs a 44x44 CSS px hit area (Apple's guideline, WCAG 2.5.5): the centre and four points 21px away must
- // reach it, so a neighbour reaching into that square fails it as much as a small box does. Only targets a tap can reach
- // now are judged; one under an overlay or out of view is judged at another step
- function targets(scope) {
- const small = [], tiny = []
- const within = scope ? [...document.querySelectorAll(scope)].flatMap(s => [...s.querySelectorAll(candidates)]) : document.querySelectorAll(candidates)
- for (const el of within) {
- if (exempt(el) || !shown(el))
- continue
- const own = el.getBoundingClientRect()
- if (own.width < 1 || own.height < 1)
- continue
- const x = own.left + own.width / 2, y = own.top + own.height / 2
- if (x < 0 || x >= innerWidth || y < 0 || y >= innerHeight)
- continue
- const seen = visibleRect(el)
- if (x < seen.left || x > seen.right || y < seen.top || y > seen.bottom || !reaches(el, x, y))
- continue
- if (el.matches("input[type=range]")) {
- if (own.height < 28 || own.width < 120)
- small.push({ ...describe(el, own), failed: ["range"] })
- continue
- }
- // a probe beyond what the scroller shows (a card half under the feed's top edge) can't be tapped there anyway
- const clip = clipOf(el)
- const failed = [], hit = {}
- for (const [dx, dy, side] of [[-21, 0, "left"], [21, 0, "right"], [0, -21, "up"], [0, 21, "down"]]) {
- const px = Math.min(clip.right - 1, Math.max(clip.left, x + dx)), py = Math.min(clip.bottom - 1, Math.max(clip.top, y + dy))
- if (!reaches(el, px, py)) {
- const other = document.elementFromPoint(px, py)
- if (other && floating(other, el))
- continue
- failed.push(side)
- hit[side] = other ? name(other) : null
- }
- }
- if (failed.length > 0)
- small.push({ ...describe(el, own), failed, hit })
- if (text(el) && el.innerText?.trim() && parseFloat(style(el).fontSize) < 12)
- tiny.push({ ...describe(el, own), fontSize: parseFloat(style(el).fontSize) })
- }
- return { small, tiny }
- }
-
- function measure(scope) {
- styles = new Map()
- const { small, tiny } = targets(scope)
- const result = { url: location.pathname + location.search, overflow: overflow(), beyond: beyond(), popovers: popovers(), small, tiny }
- styles = new Map()
- return result
- }
-
- function primary() {
- styles = new Map()
- document.querySelectorAll("[data-fit-primary]").forEach(e => e.removeAttribute("data-fit-primary"))
- let best = null, area = 0
- for (const el of scrollers()) {
- if (el.scrollHeight <= el.clientHeight + 4)
- continue
- const size = el.clientWidth * el.clientHeight
- if (size > area) {
- best = el
- area = size
- }
- }
- best ??= document.scrollingElement
- best.setAttribute("data-fit-primary", "")
- styles = new Map()
- return name(best)
- }
-
- function step(fraction) {
- const el = document.querySelector("[data-fit-primary]") ?? document.scrollingElement
- const before = el.scrollTop
- el.scrollTo({ top: before + el.clientHeight * fraction, behavior: "instant" })
- return el.scrollTop > before + 1
- }
-
- async function settle() {
- await document.fonts.ready
- await new Promise(resolve => {
- let timer
- const done = () => {
- observer.disconnect()
- clearTimeout(timer)
- clearTimeout(cap)
- resolve()
- }
- const observer = new MutationObserver(() => {
- clearTimeout(timer)
- timer = setTimeout(done, 400)
- })
- observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true })
- timer = setTimeout(done, 400)
- const cap = setTimeout(done, 8000)
- })
- await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
- const until = Date.now() + 3000
- const inView = img => {
- const r = img.getBoundingClientRect()
- return r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth
- }
- while (Date.now() < until && [...document.images].some(img => inView(img) && !img.complete))
- await new Promise(resolve => setTimeout(resolve, 100))
- }
-
- return { settle, primary, step, measure }
-})()
diff --git a/tests/e2e/Perf/FeedPerfTests.cs b/tests/e2e/Perf/FeedPerfTests.cs
deleted file mode 100644
index 225a5a4..0000000
--- a/tests/e2e/Perf/FeedPerfTests.cs
+++ /dev/null
@@ -1,83 +0,0 @@
-using System.Diagnostics;
-using System.Text.Json.Nodes;
-
-using decePubClient.E2E.Support;
-
-using Microsoft.Playwright;
-
-namespace decePubClient.E2E.Perf
-{
- // The home feed with thousands of posts behind it: load0's home, filled by SocialPub's load runs
- // (tools/pasture/load.sh, the flood peer). Scrolls it until 100, 500, 1000 and 2000 posts are loaded (the feed renders
- // only the cards near the view, so it counts what it holds, data-feed-count) and measures, at each, how long the
- // pages took, the page's size (DOM nodes, JS and .NET heaps) and the long tasks; and the time to the first card. Writes perf.json next to the test output; nothing is asserted beyond reaching the cards, the numbers are
- // for comparing runs.
- [Trait("Category", "Perf")]
- public sealed class FeedPerfTests(Browser browser)
- {
- static readonly int[] Steps = [100, 500, 1000, 2000];
-
- [Fact]
- public async Task A_long_home_feed_scrolls_to_two_thousand_cards()
- {
- var persona = new Pasture.Persona("load0", "load0", "pastureroot", "Pasture-Pass-1!", null, null, null);
- await using var session = await SignIn.As(browser, persona, fresh: true);
- var page = session.Page;
- await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
- await page.AddInitScriptAsync(@"window.__longTasks = []; new PerformanceObserver(list => list.getEntries().forEach(e => window.__longTasks.push(e.duration))).observe({ type: 'longtask', buffered: true });");
-
- var clock = Stopwatch.StartNew();
- await page.GotoAsync($"{Pasture.Site}/");
- var cards = page.Locator("article[data-uri]");
- await cards.First.WaitForAsync(new() { Timeout = 60000 });
- var firstCard = clock.Elapsed.TotalMilliseconds;
- var loaded = page.Locator("[data-feed-count]");
- async Task Loaded() => int.Parse(await loaded.GetAttributeAsync("data-feed-count") ?? "0");
- Assert.SkipWhen(await Loaded() < 20, "load0's home is short: run SocialPub's tools/pasture/load.sh first");
-
- var report = new JsonObject { ["firstCardMs"] = Math.Round(firstCard), ["steps"] = new JsonArray() };
- foreach (var target in Steps)
- {
- var step = Stopwatch.StartNew();
- var count = await Loaded();
- var stalled = Stopwatch.StartNew();
- while (count < target && stalled.Elapsed < TimeSpan.FromSeconds(20))
- {
- await page.EvaluateAsync("() => { const s = document.getElementById('feed-scroll'); s.scrollTop = s.scrollHeight; }");
- await page.WaitForTimeoutAsync(150);
- var now = await Loaded();
- if (now > count)
- stalled.Restart();
- count = now;
- }
- var metrics = await page.EvaluateAsync