No test project: tests/e2e goes
Build / Build (push) Successful in 42s

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 <noreply@anthropic.com>
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 00:28:34 +02:00
1 parent 5796d62406
commit e4414f1ac9
54 files changed
+22 -4720

No files matched your search

-3
View File
@@ -368,6 +368,3 @@ FodyWeavers.xsd
.tools/ .tools/
wwwroot/css/app.css wwwroot/css/app.css
# the e2e tests: the published app they serve and the browser state they keep
tests/e2e/.publish/
tests/e2e/.state/
+12 -55
View File
@@ -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. `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 There is no test project (owner decision 2026-10-17: `tests/e2e` was removed, as was SocialPub's `PrivaPub.Tests`). Don't
private fediverse of PrivaPub and its peers (GoToSocial, Mastodon, Misskey, Sharkey, Akkoma, Lemmy) seeded with a fake add one back unless asked. Verify by building, running against a local PrivaPub, and in SocialPub's pasture:
community, the town (see `../SocialPub/CLAUDE.md`, "The town"). Never in CI: it needs podman and the pasture.
```sh ```sh
../SocialPub/tools/pasture/run.sh up gts mastodon misskey sharkey akkoma lemmy # PASTURE_PORT if 6971 is taken dotnet publish decePubClient.csproj -c Release -p:WasmApplicationEnvironmentName=Pasture -o publish
../SocialPub/tools/pasture/town.sh seed village ../SocialPub/tools/pasture/run.sh up gts decepub # serves publish/wwwroot as https://decepub.test (peers/decepub.sh)
tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentName=Pasture, serves https://decepub.test, runs
``` ```
- **The app under test is the deploy's own build** with the environment baked in: Blazor on .NET 10 ignores the - Blazor on .NET 10 ignores the `Blazor-Environment` header, so the environment is chosen at publish time:
`Blazor-Environment` header, so `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is chosen at publish `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is published only then, and the deploy fails if it
time and published only then; the deploy fails if it ever ships. ever ships.
- Chromium resolves `*.test` to Caddy on `127.0.0.1:6443` (`--host-resolver-rules`) and ignores the pasture CA. The - A browser reaches `*.test` through the pasture's Caddy on `127.0.0.1:6443` (Chromium: `--host-resolver-rules="MAP *.test
tests use the Chromium headless shell 1228 cached in `~/.cache/ms-playwright` and never install a browser. 127.0.0.1:6443"`), and must accept the pasture's CA.
- **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/<run>/`. 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.
## CSS: Tailwind 4 + daisyUI 5, vendored, offline ## 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:`). - 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 - 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. `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. - 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 - **`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: 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 `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 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). 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 - **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`).
phone sweep fails anything less.
- **Overlays on a phone rise from the bottom.** - **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 - `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`). close button (`ShowClose`, named by `CloseText`).
+4 -4
View File
@@ -5,8 +5,8 @@
The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page 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 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 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, 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 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 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. *@ the time, the author and its server's history, the place or the CDN, the media and the counts. *@
@if (!IsFilteredOut) @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 // 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 // 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 // this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll
// (tests/e2e/Perf/FeedPerfTests). // through.
object renderedWith; object renderedWith;
int handlingEvents; int handlingEvents;
+1 -2
View File
@@ -91,8 +91,7 @@
</p> </p>
} }
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty @* only the cards near the view are rendered: a feed of thousands stays as light as one of forty *@
(tests/e2e/Perf/FeedPerfTests) *@
<Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8"> <Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" /> <Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
</Virtualize> </Virtualize>
+1 -2
View File
@@ -1,12 +1,11 @@
/* Entry point compiled by Tailwind CSS standalone (tools/tailwind.sh, TailwindCompile in decePubClient.csproj) into /* 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/ 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("../"); @import "tailwindcss" source("../");
@source not "../wwwroot/vendor"; @source not "../wwwroot/vendor";
@source not "../Docs"; @source not "../Docs";
@source not "../tools"; @source not "../tools";
@source not "../tests";
@plugin "../tools/tailwind/daisyui.mjs" { @plugin "../tools/tailwind/daisyui.mjs" {
themes: false; themes: false;
+1 -1
View File
@@ -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 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. 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 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 transparent ::after, so the rows that hold them leave 8px between them on touch (pointer-coarse:gap-2), or two hit
areas would overlap. */ areas would overlap. */
+3 -4
View File
@@ -6,8 +6,6 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest> <ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData> <BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
<!-- tests/e2e is its own project; without this the app would compile its .cs files too -->
<DefaultItemExcludes>$(DefaultItemExcludes);tests/**</DefaultItemExcludes>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -40,7 +38,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<TailwindSource Include="Styles/**/*.css;**/*.razor;**/*.cs;wwwroot/index.html" Exclude="bin/**;obj/**;.tools/**;tools/**;tests/**" /> <TailwindSource Include="Styles/**/*.css;**/*.razor;**/*.cs;wwwroot/index.html" Exclude="bin/**;obj/**;.tools/**;tools/**" />
</ItemGroup> </ItemGroup>
<!-- Unpacks the vendored binary when it is missing or the vendored archive changed (an upgrade). --> <!-- Unpacks the vendored binary when it is missing or the vendored archive changed (an upgrade). -->
@@ -69,7 +67,8 @@
<ItemGroup> <ItemGroup>
<!-- Points the development build at a local PrivaPub; the published app reads appsettings.json only. --> <!-- Points the development build at a local PrivaPub; the published app reads appsettings.json only. -->
<Content Update="wwwroot\appsettings.Development.json" CopyToPublishDirectory="Never" /> <Content Update="wwwroot\appsettings.Development.json" CopyToPublishDirectory="Never" />
<!-- Points the app at the pasture's PrivaPub (tests/e2e/run.sh publishes with WasmApplicationEnvironmentName=Pasture); <!-- Points the app at the pasture's PrivaPub (a publish with WasmApplicationEnvironmentName=Pasture, which SocialPub's
tools/pasture/peers/decepub.sh serves);
any other publish leaves it out, and the deploy checks that it did. --> any other publish leaves it out, and the deploy checks that it did. -->
<Content Update="wwwroot\appsettings.Pasture.json" CopyToPublishDirectory="Never" Condition="'$(WasmApplicationEnvironmentName)' != 'Pasture'" /> <Content Update="wwwroot\appsettings.Pasture.json" CopyToPublishDirectory="Never" Condition="'$(WasmApplicationEnvironmentName)' != 'Pasture'" />
</ItemGroup> </ItemGroup>
-22
View File
@@ -7,12 +7,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "decePubClient", "decePubCli
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SocialPub.ClientModels", "..\SocialPub\SocialPub\SocialPub.ClientModels\SocialPub.ClientModels.csproj", "{5DA9311B-B6BC-4EC6-A26E-10367288F52B}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SocialPub.ClientModels", "..\SocialPub\SocialPub\SocialPub.ClientModels\SocialPub.ClientModels.csproj", "{5DA9311B-B6BC-4EC6-A26E-10367288F52B}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|x64.Build.0 = Release|Any CPU
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x86.ActiveCfg = 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 {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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection 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 GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {1B399226-FEE0-4572-A4A7-783DA83E7DDD} SolutionGuid = {1B399226-FEE0-4572-A4A7-783DA83E7DDD}
EndGlobalSection EndGlobalSection
-67
View File
@@ -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<HttpStatusCode> 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<string>();
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);
}
}
}
-161
View File
@@ -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<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
public static TheoryData<string, string> Targets()
{
var data = new TheoryData<string, string>();
var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet();
foreach (var origin in new[] { "gts", "mastodon", "misskey", "sharkey", "akkoma", "lemmy" })
{
var item = Expected.Value.FirstOrDefault(i => i!["origin"]!.GetValue<string>() == origin
&& i["visibility"]!.GetValue<string>() is "public" or "community"
&& i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue<string>())));
if (item is not null)
data.Add(origin, item["ref"]!.GetValue<string>());
}
return data;
}
static (JsonNode Item, Pasture.Persona Reader) Target(string reference)
{
var item = Expected.Value.First(i => i!["ref"]!.GetValue<string>() == reference)!;
var personas = Pasture.Personas();
var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue<string>()))
.First(p => p is not null);
return (item, reader);
}
static async Task<int> Count(string origin, string uri, string field)
{
var row = (await Pasture.Stored(origin, uri))[uri]!;
return row[field]?.GetValue<int?>() ?? 0;
}
static async Task<bool> IsActive(ILocator button) =>
((await button.GetAttributeAsync("class")) ?? "").Split(' ').Contains("btn-active");
async Task<(Session Session, ILocator Card)> Open(JsonNode item, Pasture.Persona reader)
{
var session = await SignIn.As(browser, reader);
await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue<string>()}");
var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue<string>()}']").First;
await card.WaitForAsync(new() { Timeout = 30000 });
return (session, card);
}
[Theory]
[MemberData(nameof(Targets))]
public async Task A_favourite_reaches_the_author_and_its_undo_too(string origin, string reference)
{
var (item, reader) = Target(reference);
var uri = item["uri"]!.GetValue<string>();
await Results.Cell("client.action.favourite", origin, reference, async () =>
{
var (session, card) = await Open(item, reader);
await using var _ = session;
var favourite = card.Locator("footer button[title='Favourite']").First;
// the town may have done it already as this persona: start from the undone state
if (await IsActive(favourite))
{
var done = await Count(origin, uri, "likes");
await favourite.ClickAsync();
await Pasture.Eventually(async () => await Count(origin, uri, "likes") < done, 60);
await Assertions.Expect(favourite).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active"));
}
var before = await Count(origin, uri, "likes");
await favourite.ClickAsync();
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "likes") > before),
$"the favourite never reached {origin}");
await favourite.ClickAsync();
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "likes") == before, 60),
$"undoing the favourite never reached {origin}");
await session.AssertGuards();
});
}
[Theory]
[MemberData(nameof(Targets))]
public async Task A_boost_reaches_the_author_and_its_undo_too(string origin, string reference)
{
Assert.SkipWhen(origin == "lemmy", "Lemmy keeps no boosts of a thread");
var (item, reader) = Target(reference);
var uri = item["uri"]!.GetValue<string>();
await Results.Cell("client.action.boost", origin, reference, async () =>
{
var (session, card) = await Open(item, reader);
await using var _ = session;
var boost = card.Locator("footer button[title='Boost']").First;
// the town may have done it already as this persona: start from the undone state
if (await IsActive(boost))
{
var done = await Count(origin, uri, "boosts");
await boost.ClickAsync();
await Pasture.Eventually(async () => await Count(origin, uri, "boosts") < done, 60);
await Assertions.Expect(boost).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active"));
}
var before = await Count(origin, uri, "boosts");
await boost.ClickAsync();
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "boosts") > before),
$"the boost never reached {origin}");
await boost.ClickAsync();
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "boosts") == before, 60),
$"undoing the boost never reached {origin}");
await session.AssertGuards();
});
}
[Theory]
[MemberData(nameof(Targets))]
public async Task A_reply_lands_under_the_post_and_its_delete_removes_it(string origin, string reference)
{
var (item, reader) = Target(reference);
var uri = item["uri"]!.GetValue<string>();
await Results.Cell("client.action.reply", origin, reference, async () =>
{
var (session, card) = await Open(item, reader);
await using var _ = session;
await card.Locator("footer button[title='Reply']").First.ClickAsync();
var text = $"an e2e reply to {reference} ({Guid.NewGuid():N})";
// after the @names the composer prefills, as a reader types
var box = card.Locator("textarea").Last;
await box.FillAsync(((await box.InputValueAsync()).TrimEnd() + " " + text).Trim());
var published = session.Page.WaitForResponseAsync(r => r.Url.EndsWith("/api/v1/statuses") && r.Request.Method == "POST");
await card.Locator("button[type=submit]").Last.ClickAsync();
var response = await published;
Assert.True(response.Ok, $"publishing the reply answered {response.Status}");
var replyUri = (await response.JsonAsync())?.GetProperty("uri").GetString();
Assert.False(string.IsNullOrEmpty(replyUri));
Assert.True(await Pasture.Eventually(async () =>
{
var row = (await Pasture.Stored(origin, replyUri))[replyUri]!;
return row["exists"]!.GetValue<bool>() && row["parent_uri"]?.GetValue<string>() == uri;
}, 45), $"the reply never landed under the post on {origin}");
var reply = session.Page.Locator($"article[data-uri='{replyUri}']").First;
await reply.WaitForAsync(new() { Timeout = 15000 });
await reply.Locator("footer [title='Other']").First.ClickAsync();
await reply.GetByRole(AriaRole.Button).Filter(new() { HasText = "Delete" }).First.ClickAsync();
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<bool>() || row["deleted"]!.GetValue<bool>();
}, 45), $"the deleted reply is still on {origin}");
await session.AssertGuards();
});
}
}
}
-73
View File
@@ -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<JsonNode> AsAdmin(string path) => PastureAdmin.Get(path);
static async Task<ILocator> 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<string>() == 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<string>() == 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<string>()).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);
}
}
}
-63
View File
@@ -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<string>();
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<bool>(), 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<string>() == 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<string>()}']").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);
}
}
}
-138
View File
@@ -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<ILocator> 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<string> BackUp(ILocator panel)
{
var before = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Select(b => b!["id"]!.GetValue<string>()).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<string>()).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<string>();
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<string>() == 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<string>() == id)!;
Assert.Equal("uploaded", uploaded["kind"]!.GetValue<string>());
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<string>();
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();
}
}
}
-141
View File
@@ -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<JsonNode> 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<string>().Contains(text) == true, 15),
"the poll was never published");
made = latest!["id"]!.GetValue<string>();
Assert.Equal(new[] { "tea", "coffee" }, latest["poll"]!["options"]!.AsArray().Select(o => o!["title"]!.GetValue<string>()));
Assert.Equal("it", latest["language"]?.GetValue<string>());
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<string>();
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>();
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<string>()}']").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<string>());
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<string>().Contains("quoting myself") == true, 15),
"the quote was never published");
quote = latest!["id"]!.GetValue<string>();
Assert.Equal(id, (latest["quote"]?["quoted_status"]?["id"] ?? latest["quote"]?["quoted_status_id"])?.GetValue<string>());
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);
}
}
}
}
-58
View File
@@ -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<string>();
async Task<JsonNode> Conversation() => (await PersonaApi.Call(reader, "/api/v1/conversations?limit=40"))!.AsArray()
.FirstOrDefault(c => c!["last_status"]?["uri"]?.GetValue<string>() == 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<bool>() == 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<bool>());
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<string>()}", HttpMethod.Delete);
}
}
}
}
-111
View File
@@ -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<string>() == text)) is not null, 15), "PrivaPub never kept the scheduled post");
var id = waiting!["id"]!.GetValue<string>();
Assert.DoesNotContain((await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=10"))!.AsArray(),
s => s!["content"]!.GetValue<string>().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<DateTime>().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<bool>());
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<bool>());
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<string>()).ToList();
var directory = (await PersonaApi.Call(persona, "/api/v1/directory?order=active&local=false&limit=40"))!.AsArray().Select(a => a!["id"]!.GetValue<string>()).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<string[]>("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<string[]>("rows => rows.map(r => r.dataset.account)");
Assert.Equal(directory, shownPeople);
await session.AssertGuards();
}, page);
}
}
}
-70
View File
@@ -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<string>();
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<string>();
async Task<string> Participation() =>
(await PersonaApi.Call(reader, $"/api/v1/statuses/{id}"))?["privapub"]?["event"]?["participation"]?.GetValue<string>();
// 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<string>()}']").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<string> 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();
}
}
}
-57
View File
@@ -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);
}
}
}
-72
View File
@@ -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<string>().StartsWith("e2e ")))
await PersonaApi.Call(persona, $"/api/v2/filters/{left!["id"]!.GetValue<string>()}", 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<string>();
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<string>() == 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<string>() == "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<string>()}", HttpMethod.Delete);
foreach (var made in (await PersonaApi.Call(persona, "/api/v2/filters"))!.AsArray().Where(f => f!["title"]!.GetValue<string>() == title))
await PersonaApi.Call(persona, $"/api/v2/filters/{made!["id"]!.GetValue<string>()}", HttpMethod.Delete);
}
}
}
}
-58
View File
@@ -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);
}
}
}
-115
View File
@@ -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<JsonNode> 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<JsonNode> Followed(Pasture.Persona persona) =>
(await Api(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=80"))!.AsArray()
.FirstOrDefault(a => a!["statuses_count"]!.GetValue<int>() > 0 && !a["acct"]!.GetValue<string>().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<string>();
var listId = (await Api(persona, "/api/v1/lists", HttpMethod.Post, new JsonObject { ["title"] = $"e2e {Guid.NewGuid():N}"[..12] }))!["id"]!.GetValue<string>();
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<bool> InList() =>
(await Api(persona, $"/api/v1/lists/{listId}/accounts"))!.AsArray().Any(a => a!["id"]!.GetValue<string>() == 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<string>();
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<string>().StartsWith("e2e ")))
await Api(persona, $"/api/v1/lists/{left!["id"]!.GetValue<string>()}", 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<string>() == 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<string>());
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<string>()));
// 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<string>()).ToHashSet();
var shown = await page.Locator("article[data-uri]").EvaluateAllAsync<string[]>("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<bool>() == 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);
}
}
}
-184
View File
@@ -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<JsonNode> 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<string>().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<string>();
Assert.Equal("a single dot", post["media_attachments"]![0]!["description"]!.GetValue<string>());
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<string>();
Assert.Null(post["media_attachments"]![0]!["description"]?.GetValue<string>());
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<string>();
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<string>();
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<string>());
await session.AssertGuards();
}, page);
}
}
}
@@ -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<bool>() == 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<string>();
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<string>();
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<string>() == "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<string>() == strangerAcct && n["status"]?["id"]?.GetValue<string>() == 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<string>() == strangerAcct && n["status"]?["id"]?.GetValue<string>() == 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);
}
}
}
}
-52
View File
@@ -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<string>();
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<string>()}']").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<int>() == 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);
}
}
}
}
-82
View File
@@ -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<ILocator> 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}})");
}
}
}
}
-55
View File
@@ -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<string>();
var me = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["id"]!.GetValue<string>();
async Task<bool> Pinned() =>
(await PersonaApi.Call(persona, $"/api/v1/accounts/{me}/statuses?pinned=true"))!.AsArray().Any(s => s!["id"]!.GetValue<string>() == 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<string>()}']").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<string>()}']");
await pinnedCard.WaitForAsync(new() { Timeout = 30000 });
Assert.Equal(1, await page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").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);
}
}
}
}
-118
View File
@@ -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<string> Origins() => new(Platforms);
// an account of the platform that the persona does not follow yet, from the town's own sessions
static (string Platform, string UserName, string Token) Stranger(string platform, Pasture.Persona persona)
{
var sessions = JsonNode.Parse(File.ReadAllText(Path.Combine(Pasture.Root, ".state", "town", "sessions.json")))!.AsObject();
var candidates = sessions.Where(kv => kv.Key.StartsWith(platform + "/") && !kv.Key.Contains("/st_"))
.Select(kv => (platform, kv.Value!["account"]!["username"]!.GetValue<string>(), kv.Value!["token"]!.GetValue<string>()))
.ToList();
return candidates.Last();
}
// 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<bool> FollowedOn(string platform, string userName, string persona)
{
var result = await Pasture.Town("drive", platform, userName, "relationship", $"{{\"acct\": \"{persona}@privapub.test\"}}");
return result?["followed_by"]?.GetValue<bool>() == true || (await Pasture.Town("drive", platform, userName, "pending", "{}"))
.AsArray().Any(a => a?.GetValue<string>()?.StartsWith(persona + "@") == true);
}
[Fact]
public async Task The_personas_own_profile_lists_its_posts()
{
var persona = Pasture.Personas().First();
await using var session = await SignIn.As(browser, persona);
await Results.Cell("client.render.profile", "privapub", persona.UserName, async () =>
{
await session.Page.GotoAsync($"{Pasture.Site}/account/{persona.LocalId}");
var profile = session.Page.Locator("article[data-account-id]").First;
await profile.WaitForAsync(new() { Timeout = 30000 });
await Assertions.Expect(profile).ToContainTextAsync("@" + persona.UserName);
await session.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
await session.AssertGuards();
}, session.Page);
}
}
}
-84
View File
@@ -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<string>();
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<string>() == words);
Assert.NotNull(report);
Assert.Equal((targetId, "spam", true), (report!["targetAccountId"]!.GetValue<string>(), report["category"]!.GetValue<string>(),
report["forwarded"]!.GetValue<bool>()));
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<string>();
var fanAcct = (await PersonaApi.Call(fan, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue<string>();
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<string>()}']").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);
}
}
}
}
-208
View File
@@ -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<ILocator> 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<string>() : "",
["value"] = i < kept.Count ? kept[i]!["value"]!.GetValue<string>() : ""
};
return new JsonObject
{
["display_name"] = account["display_name"]!.GetValue<string>(),
["note"] = source["note"]!.GetValue<string>(),
["locked"] = account["locked"]!.GetValue<bool>(),
["source"] = new JsonObject { ["privacy"] = source["privacy"]!.GetValue<string>() },
["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<string>(),
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<string>());
Assert.Equal($"A biography written in the e2e tests {mark}", saved["source"]!["note"]!.GetValue<string>());
Assert.Equal("unlisted", saved["source"]!["privacy"]!.GetValue<string>());
Assert.Contains(saved["source"]!["fields"]!.AsArray(), f => f!["name"]!.GetValue<string>() == "e2e" && f["value"]!.GetValue<string>() == 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<string>()));
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<string>()));
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<string>();
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<string>()).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);
}
}
}
-66
View File
@@ -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<string>()}']");
await Assertions.Expect(card).ToContainTextAsync(text, new() { Timeout = 5000 });
await PersonaApi.Call(persona, $"/api/v1/statuses/{post["id"]!.GetValue<string>()}", 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<string>()}", HttpMethod.Delete);
}
}
}
}
-61
View File
@@ -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<JsonNode> 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>();
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<string>();
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<string>();
await Task.Delay(3000);
Assert.SkipWhen((await Pasture.Stored("privapub", replyUri))[replyUri]?["exists"]?.GetValue<bool>() == 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 });
}
}
}
}
-123
View File
@@ -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<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
static TheoryData<string, string> Pick(Func<JsonNode, bool> wanted, params string[] origins)
{
var data = new TheoryData<string, string>();
var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet();
foreach (var origin in origins)
{
var item = Expected.Value.FirstOrDefault(i => i!["origin"]!.GetValue<string>() == origin && wanted(i)
&& i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue<string>())));
if (item is not null)
data.Add(origin, item["ref"]!.GetValue<string>());
}
return data;
}
public static TheoryData<string, string> Polls() =>
Pick(i => i["poll"] is JsonArray, "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo");
public static TheoryData<string, string> Reactable() =>
Pick(i => i["visibility"]!.GetValue<string>() is "public" or "unlisted", "misskey", "sharkey", "akkoma");
async Task<(JsonNode Item, Session Session, ILocator Card)> Open(string reference)
{
var item = Expected.Value.First(i => i!["ref"]!.GetValue<string>() == reference)!;
var personas = Pasture.Personas();
var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue<string>()))
.Last(p => p is not null);
var session = await SignIn.As(browser, reader);
await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue<string>()}");
var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue<string>()}']").First;
await card.WaitForAsync(new() { Timeout = 30000 });
return (item, session, card);
}
// the one-time notice, when this persona has not seen it yet
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<JsonObject> Row(string origin, string uri) => (await Pasture.Stored(origin, uri))[uri]!.AsObject();
[Theory]
[MemberData(nameof(Polls))]
public async Task A_vote_is_counted_on_the_polls_own_server(string origin, string reference)
{
var (item, session, card) = await Open(reference);
await using var _ = session;
var uri = item["uri"]!.GetValue<string>();
var choices = card.Locator("[data-poll] input");
Assert.SkipWhen(await choices.CountAsync() == 0, "this persona already voted, or the poll is closed");
await Results.Cell("client.action.vote", origin, reference, async () =>
{
var before = (await Row(origin, uri))["votes"]?.AsArray().Sum(v => v?.GetValue<int>() ?? 0) ?? 0;
await choices.First.CheckAsync();
await card.Locator("[data-poll]").GetByRole(AriaRole.Button, new() { Name = "Vote" }).ClickAsync();
await AcceptNoticeIfAsked(session.Page);
await Assertions.Expect(card.Locator("[data-poll] input")).ToHaveCountAsync(0, new() { Timeout = 15000 });
Assert.True(await Pasture.Eventually(async () =>
((await Row(origin, uri))["votes"]?.AsArray().Sum(v => v?.GetValue<int>() ?? 0) ?? 0) > before, 45),
$"the vote never counted on {origin}");
await session.AssertGuards();
});
}
[Theory]
[MemberData(nameof(Reactable))]
public async Task A_reaction_reaches_the_posts_server_and_its_withdrawal_too(string origin, string reference)
{
var (item, session, card) = await Open(reference);
await using var _ = session;
var uri = item["uri"]!.GetValue<string>();
const string emoji = "🔥";
await Results.Cell("client.action.react", origin, reference, async () =>
{
static int Count(JsonObject row) => row["reactions"]?[emoji]?.GetValue<int>() ?? 0;
var 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();
});
}
}
}
-101
View File
@@ -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<JsonNode> 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<bool>() == false)
{
persona = candidate;
break;
}
}
Assert.SkipWhen(persona is null, "every persona of the town is locked");
var actorUri = me["url"]!.GetValue<string>().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<string>();
Assert.True(await Pasture.Eventually(async () =>
(await PersonaApi.Call(persona, $"/api/v1/accounts/relationships?id[]={smuser}"))![0]!["followed_by"]!.GetValue<bool>(), 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<string>().Contains(text))) is not null, 60), "the post on the wall never reached the persona");
Assert.Equal(me["id"]!.GetValue<string>(), status!["privapub"]?["wall"]?["id"]?.GetValue<string>());
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<string>()}']").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<string>() is { Length: > 0 } name ? name : me["username"]!.GetValue<string>());
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<string>()}") 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));
}
}
}
}
-69
View File
@@ -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"));
}
}
}
-131
View File
@@ -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<bool> SetupPending()
{
using var http = Pasture.Http();
return JsonNode.Parse(await http.GetStringAsync(Pasture.Api + "/clientapi/user/setup"))!["isPending"]!.GetValue<bool>();
}
static async Task<string> 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<JsonObject>())!["token"]!.GetValue<string>();
}
static async Task<JsonNode> 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<string>());
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<JsonObject>())!["token"]!.GetValue<string>();
}
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<string>();
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<string>();
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<string>())}", ownerJwt))!.AsArray();
Assert.Contains(members, m => (m!["handle"]?.GetValue<string>() ?? "").Contains(persona) || m["name"]?.GetValue<string>() == persona);
await session.AssertGuards();
}, page);
}
}
}
-56
View File
@@ -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<string>("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<string>("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);
}
}
}
-40
View File
@@ -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<bool>("() => 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<double[]>(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);
}
}
}
-182
View File
@@ -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/<run>/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<string> seen = [];
public List<JsonObject> Overflow { get; } = [];
public List<JsonObject> Beyond { get; } = [];
public List<JsonObject> Popovers { get; } = [];
public List<JsonObject> Small { get; } = [];
public List<JsonObject> 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<JsonObject> 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<Report> 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<JsonElement>("() => 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<bool>("() => 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<Report> Now(IPage page, string scope = null)
{
await Settle(page);
var report = new Report();
var measured = JsonNode.Parse((await page.EvaluateAsync<JsonElement>("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("<!doctype html><meta charset=utf-8><title>decePub on phones</title>")
.Append("<style>body{font:13px system-ui;background:#222;color:#ddd}td{vertical-align:top;padding:6px}img{width:180px;display:block}")
.Append("a{color:#9cf}.bad{color:#f88}</style><table><tr><th></th>");
foreach (var phone in phones)
html.Append("<th>").Append(WebUtility.HtmlEncode(phone)).Append("</th>");
html.Append("</tr>");
foreach (var route in routes)
{
html.Append("<tr><th>").Append(WebUtility.HtmlEncode(route)).Append("</th>");
foreach (var phone in phones)
{
var dir = Path.Combine(RunDir, phone, route);
html.Append("<td>");
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($"<a href=\"{link}/00.png\"><img src=\"{link}/00.png\"></a>")
.Append($"<span class=\"{(fit + touch > 0 ? "bad" : "")}\">fit {fit} · touch {touch} · {found["screens"]} screens</span> ")
.Append($"<a href=\"{link}/violations.json\">findings</a>");
}
html.Append("</td>");
}
html.Append("</tr>");
}
File.WriteAllText(Path.Combine(RunDir, "index.html"), html.Append("</table>").ToString());
}
}
}
}
-363
View File
@@ -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<IPage, Task> 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<bool>("() => 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<string>().Contains(text) == true, 15),
"the post was never published");
made = latest!["id"]!.GetValue<string>();
});
}
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<string>().Contains(text) == true, 15),
"the reply was never published");
made = latest!["id"]!.GetValue<string>();
Assert.NotNull(latest["in_reply_to_id"]?.GetValue<string>());
});
}
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<double>("() => document.getElementById('feed-scroll').clientHeight");
Assert.True(feedHeight >= 180, $"the feed is {feedHeight}px tall");
Assert.True(await page.EvaluateAsync<bool>("() => 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<string>();
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<JsonNode> Latest(Pasture.Persona persona) =>
(await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault();
}
}
-62
View File
@@ -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<string, string> Cases()
{
var data = new TheoryData<string, string>();
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<string>();
foreach (var (feature, check) in new (string, Action<Fit.Report>)[]
{
($"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));
}
}
}
-136
View File
@@ -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<IPage, Pasture.Persona, Task> Open, Func<Pasture.Persona> Reader = null,
int Screens = 8, bool SignedOut = false, bool Gating = true)
{
static readonly Lazy<JsonArray> 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<IPage, Pasture.Persona, Task> Tab(int index) => async (page, _) =>
{
await page.Locator("[role=tablist]").First.Locator("[role=tab]").Nth(index).TapAsync();
await Fit.Settle(page);
};
static Func<IPage, Pasture.Persona, Task> 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<JsonNode, bool> 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);
}
}
}
}
-313
View File
@@ -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 }
})()
-83
View File
@@ -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<int> 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<JsonElementHolder>(@"() => {
const heap = performance.memory ? performance.memory.usedJSHeapSize : null;
const runtime = globalThis.getDotnetRuntime ? globalThis.getDotnetRuntime(0) : null;
const wasm = runtime && runtime.Module && runtime.Module.HEAPU8 ? runtime.Module.HEAPU8.length : null;
const tasks = window.__longTasks.splice(0);
return { json: JSON.stringify({ dom: document.getElementsByTagName('*').length, jsHeap: heap, wasmHeap: wasm,
longTasks: tasks.length, longTaskMs: Math.round(tasks.reduce((a, b) => a + b, 0)) }) };
}");
var row = JsonNode.Parse(metrics.Json)!.AsObject();
row["target"] = target;
row["posts"] = count;
row["cards"] = await cards.CountAsync();
row["seconds"] = Math.Round(step.Elapsed.TotalSeconds, 1);
report["steps"]!.AsArray().Add(row);
if (count < target)
break;
}
var path = Path.Combine(AppContext.BaseDirectory, "perf.json");
await File.WriteAllTextAsync(path, report.ToJsonString(new() { WriteIndented = true }));
TestContext.Current.SendDiagnosticMessage(report.ToJsonString());
Assert.True(report["steps"]!.AsArray().Count > 0);
await session.AssertGuards();
}
public sealed class JsonElementHolder
{
public string Json { get; set; }
}
}
}
-159
View File
@@ -1,159 +0,0 @@
using System.Text.Json;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Render
{
// The globe's instruments (wwwroot/js/instruments.js): drawn in the desktop column and in a phone's full screen with
// the Sun where it should be, only the day/night line in a phone's banner, and an idle globe that draws nothing.
// Screenshots for the eye go to tests/e2e/out/globe/.
public sealed class GlobeTests(Browser browser)
{
static readonly string OutDir = Path.Combine(Directory.GetParent(AppContext.BaseDirectory)!.Parent!.Parent!.Parent!.FullName, "out", "globe");
static Pasture.Persona Persona() => Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
static async Task<JsonElement> Stats(IPage page) =>
await page.EvaluateAsync<JsonElement>("async () => (await import('./js/globe.js')).stats()");
static async Task Live(IPage page)
{
await page.Locator("[data-globe-state=live]").First.WaitForAsync(new() { Timeout = 30000 });
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).TryGetProperty("instruments", out var i) && i.ValueKind == JsonValueKind.Object, 15),
"the instruments never reported");
}
static async Task Photo(IPage page, ILocator what, string name)
{
Directory.CreateDirectory(OutDir);
await page.WaitForTimeoutAsync(1500);
await what.ScreenshotAsync(new() { Path = Path.Combine(OutDir, name + ".png") });
}
[Fact]
public async Task The_desktop_column_draws_the_instruments_with_the_sun_in_place()
{
await using var session = await SignIn.As(browser, Persona());
var page = session.Page;
await Results.Cell("client.render.globe", "privapub", "desktop", async () =>
{
await Live(page);
// a located post, written here from Milano and read back in Nearby, pinned: the globe shows its moment, its
// place and its day
await page.Context.GrantPermissionsAsync(["geolocation"]);
await page.Context.SetGeolocationAsync(new() { Latitude = 45.4642f, Longitude = 9.19f });
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
var text = $"the globe's instruments {Guid.NewGuid():N}"[..32];
await page.Locator("textarea[data-compose]").First.FillAsync(text);
await page.Locator("button[title='Located post']").ClickAsync();
var submit = page.Locator("form:has(textarea[data-compose]) button[type=submit]").First;
await Assertions.Expect(submit).ToBeEnabledAsync();
await submit.ClickAsync();
await Assertions.Expect(page.Locator("textarea[data-compose]")).ToHaveCountAsync(0);
await page.Locator("[role=tab][title='Nearby']").ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Use my position" }).ClickAsync();
var card = page.Locator("#feed-scroll article[data-lat][data-post-at]", new() { HasText = text }).First;
await card.WaitForAsync(new() { Timeout = 20000 });
// the view flies to it first (with the pointer off the feed: a hovered post holds the view), then the card is pinned
await page.Mouse.MoveAsync(2, 2);
Assert.True(await Pasture.Eventually(async () =>
{
var view = await Stats(page);
return view.GetProperty("centroidLatitude").ValueKind == JsonValueKind.Number
&& Math.Abs(view.GetProperty("latitude").GetDouble() - view.GetProperty("centroidLatitude").GetDouble()) < 1;
}, 10), "the globe never looked at the located post");
var box = (await card.BoundingBoxAsync())!;
await card.ClickAsync(new() { Position = new() { X = box.Width - 6, Y = box.Height * 0.35f } });
await Assertions.Expect(page.Locator("[data-globe-pinned]")).ToHaveCountAsync(1);
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("instruments").GetProperty("chips").GetInt32() >= 6, 10),
"the pinned post's instruments have few chips");
var stats = await Stats(page);
var instruments = stats.GetProperty("instruments");
Assert.True(instruments.GetProperty("built").GetBoolean(), $"the instruments weren't built: {instruments}");
Assert.Equal("full", instruments.GetProperty("detail").GetString());
Assert.True(instruments.GetProperty("subsolarError").GetDouble() < 0.01, $"the Sun is drawn {instruments.GetProperty("subsolarError")} units off");
var globe = page.Locator("aside").First;
await Photo(page, globe, "desktop-light");
await File.WriteAllTextAsync(Path.Combine(OutDir, "desktop.json"), (await Stats(page)).ToString());
await page.EvaluateAsync("() => document.documentElement.setAttribute('data-theme', 'neo-dark')");
await Photo(page, globe, "desktop-dark");
// turned to the subsolar point, by dragging (about 3 px a degree; dragging left looks east, up looks south), from a
// little west and north of it, so that its ray stands out: the Sun's own instruments
var canvas = (await globe.Locator("canvas").First.BoundingBoxAsync())!;
var (cx, cy) = (canvas.X + canvas.Width / 2, canvas.Y + canvas.Height / 2);
for (var step = 0; step < 8; step++)
{
var now = await Stats(page);
var dLng = (now.GetProperty("sun").GetProperty("longitude").GetDouble() - 18 - now.GetProperty("longitude").GetDouble() + 540) % 360 - 180;
var dLat = now.GetProperty("sun").GetProperty("latitude").GetDouble() + 12 - now.GetProperty("latitude").GetDouble();
if (Math.Abs(dLng) < 8 && Math.Abs(dLat) < 8)
break;
await page.Mouse.MoveAsync(cx, cy);
await page.Mouse.DownAsync();
await page.Mouse.MoveAsync(cx - (float)Math.Clamp(dLng * 3, -200, 200), cy + (float)Math.Clamp(dLat * 3, -200, 200), new() { Steps = 10 });
await page.Mouse.UpAsync();
await page.WaitForTimeoutAsync(800);
}
await Photo(page, globe, "desktop-sun");
await File.WriteAllTextAsync(Path.Combine(OutDir, "desktop-sun.json"), (await Stats(page)).ToString());
// and taken back by its author, the one thing a located post's card offers
await card.Locator("[data-located-delete]").ClickAsync();
await page.Locator("[data-confirm-yes]").ClickAsync();
await Assertions.Expect(card).ToHaveCountAsync(0);
await session.AssertGuards();
}, page);
}
[Fact]
public async Task A_phone_banner_draws_little_and_its_full_screen_everything()
{
await using var session = await SignIn.As(browser, Persona(), Phone.Pixel7);
var page = session.Page;
await Results.Cell("client.render.globe", "privapub", "phone", async () =>
{
await page.Locator("button[title='Globe']").TapAsync();
await Live(page);
Assert.Equal("banner", (await Stats(page)).GetProperty("instruments").GetProperty("detail").GetString());
await page.Locator("[data-globe-expand]").TapAsync();
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToBeVisibleAsync();
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("instruments").GetProperty("detail").GetString() == "full", 10),
"the full screen globe has the banner's instruments");
// the camera moves back for a screen taller than wide, so the globe fits across it
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("altitude").GetDouble() > 3.5, 10),
"the full screen globe kept the banner's camera");
Assert.True((await Stats(page)).GetProperty("instruments").GetProperty("built").GetBoolean(), "the instruments weren't built");
await Photo(page, page.Locator("aside[role=dialog]"), "phone-full");
// and closed, the banner again
await page.Locator("[data-globe-close]").TapAsync();
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToHaveCountAsync(0);
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("instruments").GetProperty("detail").GetString() == "banner", 10),
"the banner kept the full screen's instruments");
await session.AssertGuards();
}, page);
}
// with reduced motion nothing turns or flies: once settled, no frame is drawn, the instruments' neither
[Fact]
public async Task An_idle_globe_draws_nothing()
{
await using var session = await SignIn.As(browser, Persona(), Phone.Pixel7);
var page = session.Page;
await Results.Cell("client.render.globe-idle", "privapub", "phone", async () =>
{
await page.Locator("button[title='Globe']").TapAsync();
await page.Locator("[data-globe-expand]").TapAsync();
await Live(page);
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("idle").GetBoolean(), 20), "the globe never settled");
var before = await Stats(page);
await page.WaitForTimeoutAsync(3000);
var after = await Stats(page);
Assert.Equal(before.GetProperty("frame").GetInt32(), after.GetProperty("frame").GetInt32());
Assert.Equal(before.GetProperty("instruments").GetProperty("frames").GetInt32(), after.GetProperty("instruments").GetProperty("frames").GetInt32());
Assert.Equal(0, after.GetProperty("fps").GetInt32());
await session.AssertGuards();
}, page);
}
}
}
-84
View File
@@ -1,84 +0,0 @@
using System.Text.Json.Nodes;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Render
{
// One object of each kind from each platform, opened in decePub (/expand/{id}) by a persona it was for, and checked
// against what its author put in it: the text, the content warning, every picture's alt text and its proxied source,
// the poll's options, the quoted post. town.sh expect says what each object holds and who may read it.
public sealed class RenderMatrixTests(Browser browser)
{
static readonly Lazy<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
public static TheoryData<string, string, string> Samples()
{
var data = new TheoryData<string, string, string>();
var seen = new HashSet<string>();
var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet();
foreach (var item in Expected.Value)
{
var key = $"{item!["origin"]}|{item["kind"]}|{item["visibility"]}";
if (!item["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue<string>())) || !seen.Add(key))
continue;
data.Add(item["origin"]!.GetValue<string>(), $"{item["kind"]}.{item["visibility"]}", item["ref"]!.GetValue<string>());
}
return data;
}
[Theory]
[MemberData(nameof(Samples))]
public async Task Renders_as_written(string origin, string kind, string reference)
{
var item = Expected.Value.First(i => i!["ref"]!.GetValue<string>() == reference)!;
var personas = Pasture.Personas();
var reader = item["readers"]!.AsArray().Select(r => r!.GetValue<string>())
.Select(name => personas.FirstOrDefault(p => p.UserName == name)).First(p => p is not null);
await Results.Cell($"client.render.{kind}", origin, reference, async () =>
{
await using var session = await SignIn.As(browser, reader);
await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue<string>()}");
var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue<string>()}']").First;
await card.WaitForAsync(new() { Timeout = 30000 });
var marker = item["marker"]!.GetValue<string>();
if (item["cw"]?.GetValue<string>() is { } cw)
{
await Assertions.Expect(card).ToContainTextAsync(cw);
Assert.False(await card.GetByText(marker).IsVisibleAsync(), "the text shows before its content warning is opened");
await card.GetByText(cw).First.ClickAsync();
}
await Assertions.Expect(card.Locator(".post-content").First).ToContainTextAsync(marker);
// each reveal re-renders the media, so reveal the first hidden one until none is left
var hidden = card.GetByRole(AriaRole.Button).Filter(new() { HasText = "Sensitive media" });
for (var i = 0; i < 8 && await hidden.CountAsync() > 0; i++)
await hidden.First.ClickAsync();
foreach (var alt in item["alts"]!.AsArray().Select(a => a!.GetValue<string>()))
{
var picture = card.Locator($"img[alt='{alt}']").First;
await Assertions.Expect(picture).ToBeVisibleAsync();
var src = new Uri(await picture.GetAttributeAsync("src") ?? "");
Assert.Equal("privapub.test", src.Host);
Assert.True(await picture.EvaluateAsync<bool>("img => img.complete && img.naturalWidth > 0"), $"picture '{alt}' did not load");
}
if (item["poll"] is JsonArray options)
foreach (var option in options)
await Assertions.Expect(card).ToContainTextAsync(option!.GetValue<string>());
if (item["quote"]?.GetValue<string>() is { } quoted)
{
var quotedRef = Expected.Value.FirstOrDefault(i => i!["uri"]!.GetValue<string>() == quoted)?["marker"]?.GetValue<string>();
Assert.True(await card.Locator("article").CountAsync() > 0, "the quoted post is not shown");
if (quotedRef is not null)
await Assertions.Expect(card.Locator("article").First).ToContainTextAsync(quotedRef);
}
if (item["title"]?.GetValue<string>() is { } title)
await Assertions.Expect(card).ToContainTextAsync(title);
await session.AssertGuards();
});
}
}
}
-136
View File
@@ -1,136 +0,0 @@
using Microsoft.Playwright;
[assembly: AssemblyFixture(typeof(decePubClient.E2E.Support.Browser))]
namespace decePubClient.E2E.Support
{
// One Chromium for the whole run. *.test names go to Caddy on 127.0.0.1:6443 (Chromium's own --resolve), whose
// certificates come from the pasture's private CA, so certificate errors are ignored; nothing else is reachable.
public sealed class Browser : IAsyncLifetime
{
IPlaywright _playwright;
public IBrowser Chromium { get; private set; }
public async ValueTask InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
Chromium = await _playwright.Chromium.LaunchAsync(new()
{
Headless = Environment.GetEnvironmentVariable("E2E_HEADED") != "1",
Args = ["--host-resolver-rules=MAP *.test 127.0.0.1:6443"]
});
}
public async ValueTask DisposeAsync()
{
if (Chromium is not null)
await Chromium.CloseAsync();
_playwright?.Dispose();
}
// a desktop window of 1440x900, or a phone: touch, a coarse pointer, its size and scale, reduced motion so nothing is
// measured halfway through a transition, and the mobile tests' measuring script (Mobile/fit.js) on every page
public async Task<Session> Open(string storageState = null, Phone phone = null)
{
var options = phone is null
? new BrowserNewContextOptions { ViewportSize = new() { Width = 1440, Height = 900 } }
: new BrowserNewContextOptions(_playwright.Devices[phone.Preset]) { ReducedMotion = ReducedMotion.Reduce };
if (phone is { Width: > 0 })
{
options.ViewportSize = new() { Width = phone.Width, Height = phone.Height };
options.ScreenSize = new() { Width = phone.Width, Height = phone.Height };
}
options.IgnoreHTTPSErrors = true;
options.Locale = "en-GB";
options.TimezoneId = "Europe/Rome";
options.ServiceWorkers = ServiceWorkerPolicy.Block;
options.StorageStatePath = storageState;
var context = await Chromium.NewContextAsync(options);
if (phone is not null)
await context.AddInitScriptAsync(Mobile.Fit.Script);
return new Session(context, await context.NewPageAsync(), phone);
}
}
// A phone the tests hold: one of Playwright's device presets (they ship inside its package, nothing is downloaded),
// at its own size or at another one
public sealed record Phone(string Key, string Preset, int Width = 0, int Height = 0)
{
public static readonly Phone Narrow360 = new("narrow360", "Pixel 7", 360, 800);
public static readonly Phone IPhoneSE3 = new("iphone-se3", "iPhone SE (3rd gen)");
public static readonly Phone Pixel7 = new("pixel7", "Pixel 7");
public static readonly Phone Pixel7Landscape = new("pixel7-landscape", "Pixel 7 landscape");
public static readonly Phone[] Portrait = [Narrow360, IPhoneSE3, Pixel7];
public static Phone ByKey(string key) => new[] { Narrow360, IPhoneSE3, Pixel7, Pixel7Landscape }.Single(p => p.Key == key);
}
// A browser tab and what it did: every request's host, every console error, every 5xx and Blazor's error bar. The
// guards hold for every test: the app talks only to its own site and to PrivaPub, and never fails silently.
public sealed class Session : IAsyncDisposable
{
public IBrowserContext Context { get; }
public IPage Page { get; }
public List<string> Hosts { get; } = [];
public List<string> ConsoleErrors { get; } = [];
public List<string> ServerErrors { get; } = [];
public List<(string Url, string Authorization)> ApiCalls { get; } = [];
public Phone Phone { get; }
public Session(IBrowserContext context, IPage page, Phone phone = null)
{
Context = context;
Page = page;
Phone = phone;
page.Request += (_, request) =>
{
var uri = new Uri(request.Url);
if (uri.Scheme is "http" or "https")
lock (Hosts)
Hosts.Add(uri.Host);
if (uri.Host == "privapub.test")
lock (ApiCalls)
ApiCalls.Add((request.Url, request.Headers.TryGetValue("authorization", out var auth) ? auth : null));
};
page.Console += (_, message) =>
{
if (message.Type == "error")
lock (ConsoleErrors)
ConsoleErrors.Add(message.Text);
};
page.Response += (_, response) =>
{
if (response.Status >= 500)
lock (ServerErrors)
ServerErrors.Add($"{response.Status} {response.Url}");
};
}
public async Task AssertGuards()
{
Assert.All(Hosts.Distinct(), host => Assert.Contains(host, new[] { "decepub.test", "privapub.test" }));
Assert.Empty(ServerErrors);
Assert.False(await Page.Locator("#blazor-error-ui").IsVisibleAsync(), "Blazor's error bar is showing");
// /clientapi carries the root's JWT and /api a persona's token: no credential ever crosses over
var clientApi = ApiCalls.Where(c => c.Authorization is not null && new Uri(c.Url).AbsolutePath.StartsWith("/clientapi/"))
.Select(c => c.Authorization).ToHashSet();
var mastodonApi = ApiCalls.Where(c => c.Authorization is not null && new Uri(c.Url).AbsolutePath.StartsWith("/api/"))
.Select(c => c.Authorization).ToHashSet();
Assert.Empty(clientApi.Intersect(mastodonApi));
Assert.All(clientApi, auth => Assert.Equal(3, auth["Bearer ".Length..].Split('.').Length));
}
// a phone session that answers like a desktop would test desktop sizes without saying so: the page must see touch
public async Task AssertPhone()
{
if (Phone is null)
return;
Assert.True(await Page.EvaluateAsync<bool>("() => matchMedia('(pointer: coarse)').matches && matchMedia('(hover: none)').matches"),
$"{Phone.Key} is not emulated as a touch screen");
}
public async ValueTask DisposeAsync() => await Context.CloseAsync();
}
}
-119
View File
@@ -1,119 +0,0 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace decePubClient.E2E.Support
{
// The pasture and its town as the tests see them: the sites, the accounts the town made (read from the town's own
// state), and the town's command line for what each server holds, so a test checks the other side of the wire.
public static class Pasture
{
public const string Site = "https://decepub.test";
public const string Api = "https://privapub.test";
public static readonly string Root = Path.GetFullPath(Path.Combine(RepoRoot(), "..", "SocialPub", "tools", "pasture"));
static string RepoRoot()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !File.Exists(Path.Combine(dir, "decePubClient.csproj")))
dir = Path.GetDirectoryName(dir);
return dir ?? throw new InvalidOperationException("decePubClient.csproj not found above the test output");
}
public sealed record Persona(string UserName, string Name, string Root, string Password, string Token, string LocalId, string ActorUri);
// every PrivaPub persona the town made, with its root's login
public static IReadOnlyList<Persona> Personas()
{
var sessions = JsonNode.Parse(File.ReadAllText(Path.Combine(Root, ".state", "town", "sessions.json")))!.AsObject();
var personas = new List<Persona>();
foreach (var (key, value) in sessions)
{
// st_* are the drivers' selftest accounts, not the town's
if (!key.StartsWith("privapub/") || key.StartsWith("privapub/st_") || value?["account"]?["root"]?.GetValue<string>() is not { } root)
continue;
var account = value["account"]!;
personas.Add(new Persona(account["username"]!.GetValue<string>(), account["name"]?.GetValue<string>(), root,
account["password"]!.GetValue<string>(),
value["token"]!.GetValue<string>(), value["local_id"]!.GetValue<string>(), value["actor_uri"]!.GetValue<string>()));
}
return personas;
}
// tools/pasture/town.sh <args>, its standard output parsed as JSON
public static async Task<JsonNode> Town(params string[] args)
{
var start = new ProcessStartInfo("python3") { RedirectStandardOutput = true, RedirectStandardError = true };
start.ArgumentList.Add(Path.Combine(Root, "town", "town.py"));
foreach (var arg in args)
start.ArgumentList.Add(arg);
using var process = Process.Start(start)!;
var output = await process.StandardOutput.ReadToEndAsync();
var error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
throw new InvalidOperationException($"town.sh {string.Join(' ', args)} failed: {error}");
return JsonNode.Parse(output.Trim().Split('\n').Last())!;
}
// the town's PrivaPub accounts signed in again (town.sh renew privapub): a restore ends every session
public static async Task RenewTown()
{
var start = new ProcessStartInfo("python3") { RedirectStandardOutput = true, RedirectStandardError = true };
foreach (var arg in new[] { Path.Combine(Root, "town", "town.py"), "renew", "privapub" })
start.ArgumentList.Add(arg);
using var process = Process.Start(start)!;
await process.StandardOutput.ReadToEndAsync();
var error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
throw new InvalidOperationException($"town.sh renew privapub failed: {error}");
}
// a script on PrivaPub's own database in the pasture, to undo what no API undoes
public static async Task Mongo(string script)
{
var start = new ProcessStartInfo("podman") { RedirectStandardOutput = true, RedirectStandardError = true };
foreach (var arg in new[] { "exec", "pasture-mongo", "mongosh", "--quiet", "PrivaPub", "--eval", script })
start.ArgumentList.Add(arg);
using var process = Process.Start(start)!;
var error = await process.StandardError.ReadToEndAsync();
await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
throw new InvalidOperationException($"mongosh failed: {error}");
}
// what a server holds of each object, from its database (town.sh stored)
public static async Task<JsonObject> Stored(string platform, params string[] uris) =>
(await Town(["stored", platform, .. uris])).AsObject();
public static async Task<bool> Eventually(Func<Task<bool>> condition, int seconds = 30)
{
var until = DateTime.UtcNow.AddSeconds(seconds);
while (true)
{
if (await condition())
return true;
if (DateTime.UtcNow > until)
return false;
await Task.Delay(1000);
}
}
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
// an HttpClient that reaches https://<name>.test through Caddy on 127.0.0.1:6443, as the browser does
public static HttpClient Http() => new(new SocketsHttpHandler
{
ConnectCallback = async (context, token) =>
{
var socket = new System.Net.Sockets.Socket(System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp);
await socket.ConnectAsync("127.0.0.1", 6443, token);
return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true);
},
SslOptions = { RemoteCertificateValidationCallback = (_, _, _, _) => true }
});
}
}
-26
View File
@@ -1,26 +0,0 @@
using System.Net.Http.Json;
using System.Text.Json.Nodes;
namespace decePubClient.E2E.Support
{
// PrivaPub's /clientapi as the pasture's administrator (pastureroot, whom the scenarios make): what its moderators
// and administrators see
public static class PastureAdmin
{
public const string Root = "pastureroot";
public const string Password = "Pasture-Pass-1!";
public static async Task<JsonNode> Get(string path)
{
using var http = Pasture.Http();
var login = await http.PostAsJsonAsync($"{Pasture.Api}/clientapi/user/login", new { userName = Root, password = Password });
login.EnsureSuccessStatusCode();
var jwt = (await login.Content.ReadFromJsonAsync<JsonObject>())!["token"]!.GetValue<string>();
using var request = new HttpRequestMessage(HttpMethod.Get, Pasture.Api + path);
request.Headers.Authorization = new("Bearer", jwt);
using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
}
}
}
-25
View File
@@ -1,25 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
namespace decePubClient.E2E.Support
{
// PrivaPub's Mastodon API as a persona (its town token), for what a test sets up or checks behind the page
public static class PersonaApi
{
// the answer's JSON, or null for a 404
public static async Task<JsonNode> Call(Pasture.Persona persona, string path, HttpMethod method = null, JsonObject body = null)
{
using var http = Pasture.Http();
using var request = new HttpRequestMessage(method ?? HttpMethod.Get, Pasture.Api + path);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", persona.Token);
if (body is not null)
request.Content = new StringContent(body.ToJsonString(), System.Text.Encoding.UTF8, "application/json");
using var response = await http.SendAsync(request);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
}
}
}
-64
View File
@@ -1,64 +0,0 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace decePubClient.E2E.Support
{
// Each render and action test is also a cell of the town's matrix, appended to the pasture's out/client.jsonl in the
// cell format town.sh check writes, so report.html shows decePub next to the servers. A failure that an open gap of the
// town's gaps.json names (a peer's own bug, say) is skipped with the gap's id rather than failed, as the report counts
// it an expected failure.
public static class Results
{
static readonly object Gate = new();
static readonly string FilePath = Path.Combine(Pasture.Root, "out", "client.jsonl");
static readonly Lazy<JsonArray> Gaps = new(() => JsonNode.Parse(File.ReadAllText(Path.Combine(Pasture.Root, "town", "gaps.json")))!.AsArray());
// the open gap whose match (regular expressions over the cell's fields) fits this cell, if any
static string KnownGap(string feature, string origin)
{
var fields = new Dictionary<string, string> { ["feature"] = feature, ["origin"] = origin, ["observer"] = "decepub" };
return Gaps.Value.Where(g => g?["status"]?.GetValue<string>() != "closed" && g?["match"] is JsonObject)
.FirstOrDefault(g => g!["match"]!.AsObject().All(m => Regex.IsMatch(fields.GetValueOrDefault(m.Key, ""), $"^(?:{m.Value!.GetValue<string>()})$")))
?["id"]?.GetValue<string>();
}
public static async Task Cell(string feature, string origin, string reference, Func<Task> test) =>
await Cell(feature, origin, reference, test, page: null);
// with a page, a failure also leaves its screenshot and text in the test output's .state/ (SignIn.Evidence)
public static async Task Cell(string feature, string origin, string reference, Func<Task> test, Microsoft.Playwright.IPage page)
{
var ok = false;
string got = null;
try
{
await test();
ok = true;
}
catch (Exception e)
{
got = e.Message.Length > 400 ? e.Message[..400] : e.Message;
if (page is not null)
await SignIn.Evidence(page, $"{feature}-{origin}-{reference}".Replace('/', '_').Replace('@', '_'));
if (KnownGap(feature, origin) is { } gap)
Assert.Skip($"known gap {gap}: {got}");
throw;
}
finally
{
var line = JsonSerializer.Serialize(new
{
source = "client", cell = $"{feature}|{origin}|decepub|{(origin == "privapub" ? "local" : "in")}", feature, origin,
observer = "decepub", direction = origin == "privapub" ? "local" : "in", ok, @ref = reference, got,
t = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
});
lock (Gate)
{
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.AppendAllText(FilePath, line + "\n");
}
}
}
}
}
-126
View File
@@ -1,126 +0,0 @@
using Microsoft.Playwright;
namespace decePubClient.E2E.Support
{
// Signs a town persona in through the real login page and persona chooser, once per persona per run; later sessions
// start from the saved browser state (the AuthData decePub keeps in localStorage).
public static class SignIn
{
static readonly string StateDir = Path.Combine(AppContext.BaseDirectory, ".state");
public static async Task<Session> As(Browser browser, Pasture.Persona persona, bool fresh = false)
{
var state = Path.Combine(StateDir, persona.UserName + ".json");
if (!fresh && File.Exists(state))
{
var known = await browser.Open(state);
await known.Page.GotoAsync(Pasture.Site + "/");
if (!await Stale(known.Page))
{
await known.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
return known;
}
await known.DisposeAsync();
}
var session = await browser.Open();
await Through(session.Page, persona);
Directory.CreateDirectory(StateDir);
await session.Context.StorageStateAsync(new() { Path = state });
return session;
}
// on a phone: the state the desktop sign-in saved, and the feed as on a first visit (no FeedFilter), so the globe
// banner starts closed as a phone's does
public static async Task<Session> As(Browser browser, Pasture.Persona persona, Phone phone)
{
var state = Path.Combine(StateDir, persona.UserName + ".json");
if (!File.Exists(state))
await (await As(browser, persona)).DisposeAsync();
var session = await browser.Open(state, phone);
await session.Page.GotoAsync(Pasture.Site + "/");
if (await Stale(session.Page))
{
// the saved sign-in has expired (the page went to /login): sign in again on a desktop and start over
await session.DisposeAsync();
await (await As(browser, persona, fresh: true)).DisposeAsync();
session = await browser.Open(state, phone);
}
try
{
await session.Page.GotoAsync(Pasture.Site + "/");
await session.Page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
await session.Page.GotoAsync(Pasture.Site + "/");
await session.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
}
catch
{
await Evidence(session.Page, $"signin-{phone.Key}-{persona.UserName}");
throw;
}
await session.AssertPhone();
return session;
}
static async Task<bool> Stale(IPage page)
{
var until = DateTime.UtcNow.AddSeconds(15);
while (DateTime.UtcNow < until)
{
if (new Uri(page.Url).AbsolutePath.StartsWith("/login"))
return true;
if (await page.Locator("article[data-uri], [data-feed-empty]").CountAsync() > 0)
return false;
await page.WaitForTimeoutAsync(250);
}
return false;
}
public static async Task Through(IPage page, Pasture.Persona persona)
{
try
{
await Steps(page, persona);
}
catch
{
await Evidence(page, "signin-" + persona.UserName);
throw;
}
}
// a screenshot and the page's text next to the test output, for a failure seen only in a headless browser
public static async Task Evidence(IPage page, string name)
{
Directory.CreateDirectory(StateDir);
await page.ScreenshotAsync(new() { Path = Path.Combine(StateDir, name + ".png"), FullPage = true });
await File.WriteAllTextAsync(Path.Combine(StateDir, name + ".txt"), page.Url + "\n" + await page.InnerTextAsync("body"));
}
static async Task Steps(IPage page, Pasture.Persona persona)
{
await page.GotoAsync(Pasture.Site + "/login");
await page.Locator("input[autocomplete=username]").FillAsync(persona.Root);
await page.Locator("input[autocomplete=current-password]").FillAsync(persona.Password);
await page.Locator("button[type=submit]").ClickAsync();
// a root with several personas picks one; one with a single persona goes on by itself, its button disabled while it
// does. Only the login page's chooser is clicked: the feed's persona switcher names the persona too
var chooser = page.Locator("section button:enabled").Filter(new() { HasText = persona.UserName + "@privapub.test" });
var until = DateTime.UtcNow.AddSeconds(30);
while (new Uri(page.Url).AbsolutePath != "/" && DateTime.UtcNow < until)
{
if (await chooser.CountAsync() > 0)
try
{
await chooser.First.ClickAsync(new() { Timeout = 2000 });
}
catch (TimeoutException)
{
}
await page.WaitForTimeoutAsync(250);
}
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 });
// (a new persona's feed is empty)
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
}
}
}
-28
View File
@@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- The decePub end-to-end tests: the published app (environment Pasture) in Chromium against the pasture's PrivaPub
and its peers, run by run.sh. Never in CI: they need podman and the pasture. Playwright 1.61.0 is pinned because
its browsers.json names Chromium 1228, the build cached in ~/.cache/ms-playwright; the tests never install one. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<RootNamespace>decePubClient.E2E</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Playwright" Version="1.61.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<None Update="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="Mobile/fit.js" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
# decePub against the pasture: publishes the app with the Pasture environment (its API is https://privapub.test), serves
# it as https://decepub.test next to the running pasture, then runs the Playwright tests. The town must be seeded first
# (../SocialPub/tools/pasture/town.sh seed village); the tests read its accounts and ask it what each server holds.
# usage: tests/e2e/run.sh [dotnet test arguments, e.g. --filter "Category!=Perf"]
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
pasture="$(cd "$repo/../SocialPub/tools/pasture" && pwd)"
dotnet=${DOTNET:-$(command -v dotnet || echo ~/.dotnet/dotnet)}
curl -fsk -o /dev/null --resolve privapub.test:6443:127.0.0.1 https://privapub.test:6443/build.json \
|| { echo "the pasture is not up: $pasture/run.sh up first" >&2; exit 1; }
[ -s "$pasture/.state/town/sessions.json" ] || { echo "the town has no accounts: $pasture/town.sh seed village first" >&2; exit 1; }
# Playwright 1.61.0 runs Chromium headless shell 1228; it is never downloaded here
browsers=~/.nuget/packages/microsoft.playwright/1.61.0/.playwright/package/browsers.json
if [ -f "$browsers" ]; then
rev=$(python3 -c "import json,sys; print(next(b['revision'] for b in json.load(open(sys.argv[1]))['browsers'] if b['name']=='chromium-headless-shell'))" "$browsers")
[ -f ~/.cache/ms-playwright/chromium_headless_shell-$rev/INSTALLATION_COMPLETE ] \
|| { echo "Chromium headless shell $rev is not in ~/.cache/ms-playwright; install it once with Playwright 1.61.0" >&2; exit 1; }
fi
"$dotnet" publish "$repo/decePubClient.csproj" -c Release -p:WasmApplicationEnvironmentName=Pasture -o "$here/.publish" -v quiet
DECEPUB_SITE="$here/.publish/wwwroot" "$pasture/run.sh" add decepub
"$dotnet" test "$here/decePubClient.E2E.csproj" -c Release "$@"
-5
View File
@@ -1,5 +0,0 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false,
"maxParallelThreads": 1
}