diff --git a/.gitignore b/.gitignore index 412743a..ece3221 100644 --- a/.gitignore +++ b/.gitignore @@ -368,6 +368,3 @@ FodyWeavers.xsd .tools/ wwwroot/css/app.css -# the e2e tests: the published app they serve and the browser state they keep -tests/e2e/.publish/ -tests/e2e/.state/ diff --git a/CLAUDE.md b/CLAUDE.md index 40f566c..361ed4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,63 +45,21 @@ dotnet publish decePubClient.csproj -c Release -o publish # static site lands `global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There is no linter. -## Testing: end to end against the pasture +## Verifying -`tests/e2e` (Playwright for .NET 1.61.0 on xunit v3) runs the published app in Chromium against SocialPub's pasture: a -private fediverse of PrivaPub and its peers (GoToSocial, Mastodon, Misskey, Sharkey, Akkoma, Lemmy) seeded with a fake -community, the town (see `../SocialPub/CLAUDE.md`, "The town"). Never in CI: it needs podman and the pasture. +There is no test project (owner decision 2026-10-17: `tests/e2e` was removed, as was SocialPub's `PrivaPub.Tests`). Don't +add one back unless asked. Verify by building, running against a local PrivaPub, and in SocialPub's pasture: ```sh -../SocialPub/tools/pasture/run.sh up gts mastodon misskey sharkey akkoma lemmy # PASTURE_PORT if 6971 is taken -../SocialPub/tools/pasture/town.sh seed village -tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentName=Pasture, serves https://decepub.test, runs +dotnet publish decePubClient.csproj -c Release -p:WasmApplicationEnvironmentName=Pasture -o publish +../SocialPub/tools/pasture/run.sh up gts decepub # serves publish/wwwroot as https://decepub.test (peers/decepub.sh) ``` -- **The app under test is the deploy's own build** with the environment baked in: Blazor on .NET 10 ignores the - `Blazor-Environment` header, so `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is chosen at publish - time and published only then; the deploy fails if it ever ships. -- Chromium resolves `*.test` to Caddy on `127.0.0.1:6443` (`--host-resolver-rules`) and ignores the pasture CA. The - tests use the Chromium headless shell 1228 cached in `~/.cache/ms-playwright` and never install a browser. -- **Guards on every test** (`Support/Browser.cs`): the app talks only to its own site and to PrivaPub, a root JWT never - goes to `/api` nor a persona token to `/clientapi`, no 5xx, no Blazor error bar. -- **The tests ask the town** (`Support/Pasture.cs`) which accounts exist and what each object holds (`town.sh expect`), - and check the other side of the wire through `town.sh stored`. Each render and action test is also a cell of the - town's report (`out/client.jsonl`). -- Cards carry `data-uri` (their ActivityPub id) and the empty feed `data-feed-empty`, for the tests. The feed renders - only the cards near the view, so a test looks for recent cards, and counts what is loaded by `data-feed-count`. -- **Phones** (`Mobile/`, `Category=Mobile`): `Browser.Open(state, Phone)` opens Playwright's own device presets - (shipped in its package): 360×800, iPhone SE (3rd gen), Pixel 7, and the Pixel 7 held sideways, with touch, a coarse - pointer and reduced motion; a phone session fails at once if the page doesn't see a touch screen. - `SignIn.As(browser, persona, phone)` reuses the desktop sign-in, and any saved sign-in that has expired is renewed. - - `RouteSweepTests` measures every page, one screen at a time down its main scroller (`Mobile/fit.js`). It checks: - - sideways scroll, unless the strip is marked `data-scroll-x`; - - boxes past the screen's sides; - - open menus, sheets and dialogs that are cut or outside the screen; - - targets without a 44px hit area: hit-tested at their centre and 21px around it, so a crowding neighbour fails - them too. Inline links in post text, native media controls and disabled controls are exempt, a fixed floating - control covering a target doesn't count, and `data-touch-exempt` exempts one explicitly; - - control text under 12px. - - Screenshots, findings and a contact sheet go to `tests/e2e/out/mobile//`. Cells are `client.mobile.fit.*` and - `client.mobile.touch.*`, with the phone as the ref. - - `FlowTests` drives the narrowest phone through the menu, a dialog, a new post, a reply, a reaction, a deletion, the - full-screen globe, a phone held sideways, a tap into a thread, a picture and the Back button (`client.mobile.flow.*`). - - `DesktopUnchangedTests` holds desktop's control sizes. -- **The globe** (`Render/GlobeTests.cs`): the instruments are built, with the Sun drawn over its subsolar point. - - On desktop, a located post written from Milano is pinned, and the globe is photographed in both themes and again - turned to the subsolar point. - - On a phone, the banner draws little, full screen draws everything with the camera moved back, and closing returns - to the banner. - - An idle globe draws no frame. - - Photos and stats go to `tests/e2e/out/globe/`. -- **Restore** (`Category=Restore`, in `Actions/BackupsTests.cs`): a real restore of the pasture from Administration. It - ends every session, the town's too, so it runs only when asked (`--filter "Category=Restore"`, never next to other - tests) and signs the town in again (`town.sh renew privapub`). Normal runs use `--filter "Category!=Perf&Category!=Restore"`. -- **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests` - scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time, - DOM nodes, JS and .NET heaps and long tasks to `perf.json`. -- The root project globs `**/*.cs`, so it excludes `tests/**` (`DefaultItemExcludes`), and Tailwind does not scan it. +- Blazor on .NET 10 ignores the `Blazor-Environment` header, so the environment is chosen at publish time: + `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is published only then, and the deploy fails if it + ever ships. +- A browser reaches `*.test` through the pasture's Caddy on `127.0.0.1:6443` (Chromium: `--host-resolver-rules="MAP *.test + 127.0.0.1:6443"`), and must accept the pasture's CA. ## CSS: Tailwind 4 + daisyUI 5, vendored, offline @@ -167,7 +125,7 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized - Layout differences between a phone and a desktop use the width variants at the call site (`max-md:`, `md:`). - How big a thing is to a finger uses the pointer: `Styles/touch.css` under `@media (pointer: coarse)`, and `pointer-coarse:` at a call site. - - A mouse matches none of it, so desktop keeps daisyUI's sizes (`Mobile/DesktopUnchangedTests` holds them). + - A mouse matches none of it, so desktop keeps daisyUI's sizes. - A window at most 30rem tall (a phone held sideways) is the `short:` variant. - **`touch.css`** is plain CSS in Tailwind's utilities layer, so like `neo.css` it beats daisyUI's sublayers. It sets daisyUI's own size variables: @@ -180,8 +138,7 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized Its `touch-target` utility gives a small link or button a 44px hit area. It can't be used on something that clips its own overflow (a `truncate`d name takes `pointer-coarse:py-3.5` instead), nor on a daisyUI tooltip, which draws its arrow with `::after` (NerdTip takes minimum sizes). -- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`). The - phone sweep fails anything less. +- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`). - **Overlays on a phone rise from the bottom.** - `DModal Placement="ResponsiveBottom"` is a sheet below md and a centred dialog from md. Every DModal has a visible close button (`ShowClose`, named by `CloseText`). diff --git a/Components/Content.razor b/Components/Content.razor index 571f9cd..0af0e0c 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -5,8 +5,8 @@ The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only - its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id, - which the e2e tests (tests/e2e) find it by. The persona's filters apply where the page says the card is (FilterIn): a + its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id. + The persona's filters apply where the page says the card is (FilterIn): a hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their author deletes them through /clientapi. Nerd stats are on the time, the author and its server's history, the place or the CDN, the media and the counts. *@ @if (!IsFilteredOut) @@ -572,8 +572,8 @@ // A card re-renders when something it shows may have changed: its own actions and toggles (its events), a new copy of // its post (an edit, a grouped notification, a place found), where it is shown, or the persona and connection. Without - // this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll to - // (tests/e2e/Perf/FeedPerfTests). + // this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll + // through. object renderedWith; int handlingEvents; diff --git a/Pages/Index.razor b/Pages/Index.razor index 234b8a3..e227f3d 100644 --- a/Pages/Index.razor +++ b/Pages/Index.razor @@ -91,8 +91,7 @@

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