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/
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.
## Testing: end to end against the pasture
## Verifying
`tests/e2e` (Playwright for .NET 1.61.0 on xunit v3) runs the published app in Chromium against SocialPub's pasture: a
private fediverse of PrivaPub and its peers (GoToSocial, Mastodon, Misskey, Sharkey, Akkoma, Lemmy) seeded with a fake
community, the town (see `../SocialPub/CLAUDE.md`, "The town"). Never in CI: it needs podman and the pasture.
There is no test project (owner decision 2026-10-17: `tests/e2e` was removed, as was SocialPub's `PrivaPub.Tests`). Don't
add one back unless asked. Verify by building, running against a local PrivaPub, and in SocialPub's pasture:
```sh
../SocialPub/tools/pasture/run.sh up gts mastodon misskey sharkey akkoma lemmy # PASTURE_PORT if 6971 is taken
../SocialPub/tools/pasture/town.sh seed village
tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentName=Pasture, serves https://decepub.test, runs
dotnet publish decePubClient.csproj -c Release -p:WasmApplicationEnvironmentName=Pasture -o publish
../SocialPub/tools/pasture/run.sh up gts decepub # serves publish/wwwroot as https://decepub.test (peers/decepub.sh)
```
- **The app under test is the deploy's own build** with the environment baked in: Blazor on .NET 10 ignores the
`Blazor-Environment` header, so `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is chosen at publish
time and published only then; the deploy fails if it ever ships.
- Chromium resolves `*.test` to Caddy on `127.0.0.1:6443` (`--host-resolver-rules`) and ignores the pasture CA. The
tests use the Chromium headless shell 1228 cached in `~/.cache/ms-playwright` and never install a browser.
- **Guards on every test** (`Support/Browser.cs`): the app talks only to its own site and to PrivaPub, a root JWT never
goes to `/api` nor a persona token to `/clientapi`, no 5xx, no Blazor error bar.
- **The tests ask the town** (`Support/Pasture.cs`) which accounts exist and what each object holds (`town.sh expect`),
and check the other side of the wire through `town.sh stored`. Each render and action test is also a cell of the
town's report (`out/client.jsonl`).
- Cards carry `data-uri` (their ActivityPub id) and the empty feed `data-feed-empty`, for the tests. The feed renders
only the cards near the view, so a test looks for recent cards, and counts what is loaded by `data-feed-count`.
- **Phones** (`Mobile/`, `Category=Mobile`): `Browser.Open(state, Phone)` opens Playwright's own device presets
(shipped in its package): 360×800, iPhone SE (3rd gen), Pixel 7, and the Pixel 7 held sideways, with touch, a coarse
pointer and reduced motion; a phone session fails at once if the page doesn't see a touch screen.
`SignIn.As(browser, persona, phone)` reuses the desktop sign-in, and any saved sign-in that has expired is renewed.
- `RouteSweepTests` measures every page, one screen at a time down its main scroller (`Mobile/fit.js`). It checks:
- sideways scroll, unless the strip is marked `data-scroll-x`;
- boxes past the screen's sides;
- open menus, sheets and dialogs that are cut or outside the screen;
- targets without a 44px hit area: hit-tested at their centre and 21px around it, so a crowding neighbour fails
them too. Inline links in post text, native media controls and disabled controls are exempt, a fixed floating
control covering a target doesn't count, and `data-touch-exempt` exempts one explicitly;
- control text under 12px.
Screenshots, findings and a contact sheet go to `tests/e2e/out/mobile/<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.
- Blazor on .NET 10 ignores the `Blazor-Environment` header, so the environment is chosen at publish time:
`wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is published only then, and the deploy fails if it
ever ships.
- A browser reaches `*.test` through the pasture's Caddy on `127.0.0.1:6443` (Chromium: `--host-resolver-rules="MAP *.test
127.0.0.1:6443"`), and must accept the pasture's CA.
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
@@ -167,7 +125,7 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
- Layout differences between a phone and a desktop use the width variants at the call site (`max-md:`, `md:`).
- How big a thing is to a finger uses the pointer: `Styles/touch.css` under `@media (pointer: coarse)`, and
`pointer-coarse:` at a call site.
- A mouse matches none of it, so desktop keeps daisyUI's sizes (`Mobile/DesktopUnchangedTests` holds them).
- A mouse matches none of it, so desktop keeps daisyUI's sizes.
- A window at most 30rem tall (a phone held sideways) is the `short:` variant.
- **`touch.css`** is plain CSS in Tailwind's utilities layer, so like `neo.css` it beats daisyUI's sublayers. It sets
daisyUI's own size variables:
@@ -180,8 +138,7 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
Its `touch-target` utility gives a small link or button a 44px hit area. It can't be used on something that clips
its own overflow (a `truncate`d name takes `pointer-coarse:py-3.5` instead), nor on a daisyUI tooltip, which draws
its arrow with `::after` (NerdTip takes minimum sizes).
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`). The
phone sweep fails anything less.
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`).
- **Overlays on a phone rise from the bottom.**
- `DModal Placement="ResponsiveBottom"` is a sheet below md and a centred dialog from md. Every DModal has a visible
close button (`ShowClose`, named by `CloseText`).
+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
about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id,
which the e2e tests (tests/e2e) find it by. The persona's filters apply where the page says the card is (FilterIn): a
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id.
The persona's filters apply where the page says the card is (FilterIn): a
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their author deletes them through /clientapi. Nerd stats are on
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
@if (!IsFilteredOut)
@@ -572,8 +572,8 @@
// A card re-renders when something it shows may have changed: its own actions and toggles (its events), a new copy of
// its post (an edit, a grouped notification, a place found), where it is shown, or the persona and connection. Without
// this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll to
// (tests/e2e/Perf/FeedPerfTests).
// this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll
// through.
object renderedWith;
int handlingEvents;
+1 -2
View File
@@ -91,8 +91,7 @@
</p>
}
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty
(tests/e2e/Perf/FeedPerfTests) *@
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty *@
<Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
</Virtualize>
+1 -2
View File
@@ -1,12 +1,11 @@
/* Entry point compiled by Tailwind CSS standalone (tools/tailwind.sh, TailwindCompile in decePubClient.csproj) into
wwwroot/css/app.css. Tailwind scans every file under the project that .gitignore doesn't exclude, except tools/
(daisyui-llms.txt names every daisyUI class), tests/ and the vendored CSS. */
(daisyui-llms.txt names every daisyUI class) and the vendored CSS. */
@import "tailwindcss" source("../");
@source not "../wwwroot/vendor";
@source not "../Docs";
@source not "../tools";
@source not "../tests";
@plugin "../tools/tailwind/daisyui.mjs" {
themes: false;
+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 rules, which sit in sublayers of it, and they set daisyUI's own size variables where it has one.
A target gets a 44px hit area (Apple's guideline, WCAG 2.5.5), which tests/e2e/Mobile measures. Buttons, tabs and
A target gets a 44px hit area (Apple's guideline, WCAG 2.5.5). Buttons, tabs and
fields are 44px tall; the extra small buttons and the badges that act stay 36px to the eye and reach 44px through a
transparent ::after, so the rows that hold them leave 8px between them on touch (pointer-coarse:gap-2), or two hit
areas would overlap. */
+3 -4
View File
@@ -6,8 +6,6 @@
<ImplicitUsings>enable</ImplicitUsings>
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
<!-- tests/e2e is its own project; without this the app would compile its .cs files too -->
<DefaultItemExcludes>$(DefaultItemExcludes);tests/**</DefaultItemExcludes>
</PropertyGroup>
<ItemGroup>
@@ -40,7 +38,7 @@
</PropertyGroup>
<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>
<!-- Unpacks the vendored binary when it is missing or the vendored archive changed (an upgrade). -->
@@ -69,7 +67,8 @@
<ItemGroup>
<!-- Points the development build at a local PrivaPub; the published app reads appsettings.json only. -->
<Content Update="wwwroot\appsettings.Development.json" CopyToPublishDirectory="Never" />
<!-- Points the app at the pasture's PrivaPub (tests/e2e/run.sh publishes with WasmApplicationEnvironmentName=Pasture);
<!-- 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. -->
<Content Update="wwwroot\appsettings.Pasture.json" CopyToPublishDirectory="Never" Condition="'$(WasmApplicationEnvironmentName)' != 'Pasture'" />
</ItemGroup>
-22
View File
@@ -7,12 +7,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "decePubClient", "decePubCli
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SocialPub.ClientModels", "..\SocialPub\SocialPub\SocialPub.ClientModels\SocialPub.ClientModels.csproj", "{5DA9311B-B6BC-4EC6-A26E-10367288F52B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "e2e", "e2e", "{0EB607AC-6329-6575-AD8B-E5558B048B88}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "decePubClient.E2E", "tests\e2e\decePubClient.E2E.csproj", "{DB238929-3C3E-4841-B782-3BF0CC53B20E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -47,26 +41,10 @@ Global
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x64.Build.0 = Release|Any CPU
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x86.ActiveCfg = Release|Any CPU
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x86.Build.0 = Release|Any CPU
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x64.ActiveCfg = Debug|Any CPU
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x64.Build.0 = Debug|Any CPU
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x86.ActiveCfg = Debug|Any CPU
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x86.Build.0 = Debug|Any CPU
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|Any CPU.Build.0 = Release|Any CPU
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x64.ActiveCfg = Release|Any CPU
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x64.Build.0 = Release|Any CPU
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x86.ActiveCfg = Release|Any CPU
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{0EB607AC-6329-6575-AD8B-E5558B048B88} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{DB238929-3C3E-4841-B782-3BF0CC53B20E} = {0EB607AC-6329-6575-AD8B-E5558B048B88}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {1B399226-FEE0-4572-A4A7-783DA83E7DDD}
EndGlobalSection
-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
}