Owner decision 2026-10-17, with SocialPub's PrivaPub.Tests. The e2e project leaves the solution, the csproj's exclusions, Tailwind's sources and .gitignore; comments no longer point at it, and CLAUDE.md's e2e section becomes Verifying: a publish with the Pasture environment, which SocialPub's tools/pasture/peers/decepub.sh serves as https://decepub.test. appsettings.Pasture.json stays for that, and the deploy still fails if it ever ships. The cards' data-* hooks stay in the markup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5796d62406
commit
e4414f1ac9
54 files changed
+22
-4720
No files matched your search
@@ -368,6 +368,3 @@ FodyWeavers.xsd
|
|||||||
.tools/
|
.tools/
|
||||||
wwwroot/css/app.css
|
wwwroot/css/app.css
|
||||||
|
|
||||||
# the e2e tests: the published app they serve and the browser state they keep
|
|
||||||
tests/e2e/.publish/
|
|
||||||
tests/e2e/.state/
|
|
||||||
@@ -45,63 +45,21 @@ dotnet publish decePubClient.csproj -c Release -o publish # static site lands
|
|||||||
|
|
||||||
`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There is no linter.
|
`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There is no linter.
|
||||||
|
|
||||||
## Testing: end to end against the pasture
|
## Verifying
|
||||||
|
|
||||||
`tests/e2e` (Playwright for .NET 1.61.0 on xunit v3) runs the published app in Chromium against SocialPub's pasture: a
|
There is no test project (owner decision 2026-10-17: `tests/e2e` was removed, as was SocialPub's `PrivaPub.Tests`). Don't
|
||||||
private fediverse of PrivaPub and its peers (GoToSocial, Mastodon, Misskey, Sharkey, Akkoma, Lemmy) seeded with a fake
|
add one back unless asked. Verify by building, running against a local PrivaPub, and in SocialPub's pasture:
|
||||||
community, the town (see `../SocialPub/CLAUDE.md`, "The town"). Never in CI: it needs podman and the pasture.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
../SocialPub/tools/pasture/run.sh up gts mastodon misskey sharkey akkoma lemmy # PASTURE_PORT if 6971 is taken
|
dotnet publish decePubClient.csproj -c Release -p:WasmApplicationEnvironmentName=Pasture -o publish
|
||||||
../SocialPub/tools/pasture/town.sh seed village
|
../SocialPub/tools/pasture/run.sh up gts decepub # serves publish/wwwroot as https://decepub.test (peers/decepub.sh)
|
||||||
tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentName=Pasture, serves https://decepub.test, runs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **The app under test is the deploy's own build** with the environment baked in: Blazor on .NET 10 ignores the
|
- Blazor on .NET 10 ignores the `Blazor-Environment` header, so the environment is chosen at publish time:
|
||||||
`Blazor-Environment` header, so `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is chosen at publish
|
`wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is published only then, and the deploy fails if it
|
||||||
time and published only then; the deploy fails if it ever ships.
|
ever ships.
|
||||||
- Chromium resolves `*.test` to Caddy on `127.0.0.1:6443` (`--host-resolver-rules`) and ignores the pasture CA. The
|
- A browser reaches `*.test` through the pasture's Caddy on `127.0.0.1:6443` (Chromium: `--host-resolver-rules="MAP *.test
|
||||||
tests use the Chromium headless shell 1228 cached in `~/.cache/ms-playwright` and never install a browser.
|
127.0.0.1:6443"`), and must accept the pasture's CA.
|
||||||
- **Guards on every test** (`Support/Browser.cs`): the app talks only to its own site and to PrivaPub, a root JWT never
|
|
||||||
goes to `/api` nor a persona token to `/clientapi`, no 5xx, no Blazor error bar.
|
|
||||||
- **The tests ask the town** (`Support/Pasture.cs`) which accounts exist and what each object holds (`town.sh expect`),
|
|
||||||
and check the other side of the wire through `town.sh stored`. Each render and action test is also a cell of the
|
|
||||||
town's report (`out/client.jsonl`).
|
|
||||||
- Cards carry `data-uri` (their ActivityPub id) and the empty feed `data-feed-empty`, for the tests. The feed renders
|
|
||||||
only the cards near the view, so a test looks for recent cards, and counts what is loaded by `data-feed-count`.
|
|
||||||
- **Phones** (`Mobile/`, `Category=Mobile`): `Browser.Open(state, Phone)` opens Playwright's own device presets
|
|
||||||
(shipped in its package): 360×800, iPhone SE (3rd gen), Pixel 7, and the Pixel 7 held sideways, with touch, a coarse
|
|
||||||
pointer and reduced motion; a phone session fails at once if the page doesn't see a touch screen.
|
|
||||||
`SignIn.As(browser, persona, phone)` reuses the desktop sign-in, and any saved sign-in that has expired is renewed.
|
|
||||||
- `RouteSweepTests` measures every page, one screen at a time down its main scroller (`Mobile/fit.js`). It checks:
|
|
||||||
- sideways scroll, unless the strip is marked `data-scroll-x`;
|
|
||||||
- boxes past the screen's sides;
|
|
||||||
- open menus, sheets and dialogs that are cut or outside the screen;
|
|
||||||
- targets without a 44px hit area: hit-tested at their centre and 21px around it, so a crowding neighbour fails
|
|
||||||
them too. Inline links in post text, native media controls and disabled controls are exempt, a fixed floating
|
|
||||||
control covering a target doesn't count, and `data-touch-exempt` exempts one explicitly;
|
|
||||||
- control text under 12px.
|
|
||||||
|
|
||||||
Screenshots, findings and a contact sheet go to `tests/e2e/out/mobile/<run>/`. Cells are `client.mobile.fit.*` and
|
|
||||||
`client.mobile.touch.*`, with the phone as the ref.
|
|
||||||
- `FlowTests` drives the narrowest phone through the menu, a dialog, a new post, a reply, a reaction, a deletion, the
|
|
||||||
full-screen globe, a phone held sideways, a tap into a thread, a picture and the Back button (`client.mobile.flow.*`).
|
|
||||||
- `DesktopUnchangedTests` holds desktop's control sizes.
|
|
||||||
- **The globe** (`Render/GlobeTests.cs`): the instruments are built, with the Sun drawn over its subsolar point.
|
|
||||||
- On desktop, a located post written from Milano is pinned, and the globe is photographed in both themes and again
|
|
||||||
turned to the subsolar point.
|
|
||||||
- On a phone, the banner draws little, full screen draws everything with the camera moved back, and closing returns
|
|
||||||
to the banner.
|
|
||||||
- An idle globe draws no frame.
|
|
||||||
|
|
||||||
Photos and stats go to `tests/e2e/out/globe/`.
|
|
||||||
- **Restore** (`Category=Restore`, in `Actions/BackupsTests.cs`): a real restore of the pasture from Administration. It
|
|
||||||
ends every session, the town's too, so it runs only when asked (`--filter "Category=Restore"`, never next to other
|
|
||||||
tests) and signs the town in again (`town.sh renew privapub`). Normal runs use `--filter "Category!=Perf&Category!=Restore"`.
|
|
||||||
- **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests`
|
|
||||||
scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time,
|
|
||||||
DOM nodes, JS and .NET heaps and long tasks to `perf.json`.
|
|
||||||
- The root project globs `**/*.cs`, so it excludes `tests/**` (`DefaultItemExcludes`), and Tailwind does not scan it.
|
|
||||||
|
|
||||||
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
|
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
|
||||||
|
|
||||||
@@ -167,7 +125,7 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
|
|||||||
- Layout differences between a phone and a desktop use the width variants at the call site (`max-md:`, `md:`).
|
- Layout differences between a phone and a desktop use the width variants at the call site (`max-md:`, `md:`).
|
||||||
- How big a thing is to a finger uses the pointer: `Styles/touch.css` under `@media (pointer: coarse)`, and
|
- How big a thing is to a finger uses the pointer: `Styles/touch.css` under `@media (pointer: coarse)`, and
|
||||||
`pointer-coarse:` at a call site.
|
`pointer-coarse:` at a call site.
|
||||||
- A mouse matches none of it, so desktop keeps daisyUI's sizes (`Mobile/DesktopUnchangedTests` holds them).
|
- A mouse matches none of it, so desktop keeps daisyUI's sizes.
|
||||||
- A window at most 30rem tall (a phone held sideways) is the `short:` variant.
|
- A window at most 30rem tall (a phone held sideways) is the `short:` variant.
|
||||||
- **`touch.css`** is plain CSS in Tailwind's utilities layer, so like `neo.css` it beats daisyUI's sublayers. It sets
|
- **`touch.css`** is plain CSS in Tailwind's utilities layer, so like `neo.css` it beats daisyUI's sublayers. It sets
|
||||||
daisyUI's own size variables:
|
daisyUI's own size variables:
|
||||||
@@ -180,8 +138,7 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
|
|||||||
Its `touch-target` utility gives a small link or button a 44px hit area. It can't be used on something that clips
|
Its `touch-target` utility gives a small link or button a 44px hit area. It can't be used on something that clips
|
||||||
its own overflow (a `truncate`d name takes `pointer-coarse:py-3.5` instead), nor on a daisyUI tooltip, which draws
|
its own overflow (a `truncate`d name takes `pointer-coarse:py-3.5` instead), nor on a daisyUI tooltip, which draws
|
||||||
its arrow with `::after` (NerdTip takes minimum sizes).
|
its arrow with `::after` (NerdTip takes minimum sizes).
|
||||||
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`). The
|
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`).
|
||||||
phone sweep fails anything less.
|
|
||||||
- **Overlays on a phone rise from the bottom.**
|
- **Overlays on a phone rise from the bottom.**
|
||||||
- `DModal Placement="ResponsiveBottom"` is a sheet below md and a centred dialog from md. Every DModal has a visible
|
- `DModal Placement="ResponsiveBottom"` is a sheet below md and a centred dialog from md. Every DModal has a visible
|
||||||
close button (`ShowClose`, named by `CloseText`).
|
close button (`ShowClose`, named by `CloseText`).
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page
|
The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page
|
||||||
about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a
|
about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a
|
||||||
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
|
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
|
||||||
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id,
|
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id.
|
||||||
which the e2e tests (tests/e2e) find it by. The persona's filters apply where the page says the card is (FilterIn): a
|
The persona's filters apply where the page says the card is (FilterIn): a
|
||||||
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their author deletes them through /clientapi. Nerd stats are on
|
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their author deletes them through /clientapi. Nerd stats are on
|
||||||
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
|
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
|
||||||
@if (!IsFilteredOut)
|
@if (!IsFilteredOut)
|
||||||
@@ -572,8 +572,8 @@
|
|||||||
|
|
||||||
// A card re-renders when something it shows may have changed: its own actions and toggles (its events), a new copy of
|
// A card re-renders when something it shows may have changed: its own actions and toggles (its events), a new copy of
|
||||||
// its post (an edit, a grouped notification, a place found), where it is shown, or the persona and connection. Without
|
// its post (an edit, a grouped notification, a place found), where it is shown, or the persona and connection. Without
|
||||||
// this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll to
|
// this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll
|
||||||
// (tests/e2e/Perf/FeedPerfTests).
|
// through.
|
||||||
object renderedWith;
|
object renderedWith;
|
||||||
int handlingEvents;
|
int handlingEvents;
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -91,8 +91,7 @@
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty
|
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty *@
|
||||||
(tests/e2e/Perf/FeedPerfTests) *@
|
|
||||||
<Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
|
<Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
|
||||||
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||||
</Virtualize>
|
</Virtualize>
|
||||||
|
|||||||
+1
-2
@@ -1,12 +1,11 @@
|
|||||||
/* Entry point compiled by Tailwind CSS standalone (tools/tailwind.sh, TailwindCompile in decePubClient.csproj) into
|
/* Entry point compiled by Tailwind CSS standalone (tools/tailwind.sh, TailwindCompile in decePubClient.csproj) into
|
||||||
wwwroot/css/app.css. Tailwind scans every file under the project that .gitignore doesn't exclude, except tools/
|
wwwroot/css/app.css. Tailwind scans every file under the project that .gitignore doesn't exclude, except tools/
|
||||||
(daisyui-llms.txt names every daisyUI class), tests/ and the vendored CSS. */
|
(daisyui-llms.txt names every daisyUI class) and the vendored CSS. */
|
||||||
@import "tailwindcss" source("../");
|
@import "tailwindcss" source("../");
|
||||||
|
|
||||||
@source not "../wwwroot/vendor";
|
@source not "../wwwroot/vendor";
|
||||||
@source not "../Docs";
|
@source not "../Docs";
|
||||||
@source not "../tools";
|
@source not "../tools";
|
||||||
@source not "../tests";
|
|
||||||
|
|
||||||
@plugin "../tools/tailwind/daisyui.mjs" {
|
@plugin "../tools/tailwind/daisyui.mjs" {
|
||||||
themes: false;
|
themes: false;
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
daisyUI's sizes. The rules are plain CSS in Tailwind's utilities layer: like neo.css's @utility overrides they beat
|
daisyUI's sizes. The rules are plain CSS in Tailwind's utilities layer: like neo.css's @utility overrides they beat
|
||||||
daisyUI's rules, which sit in sublayers of it, and they set daisyUI's own size variables where it has one.
|
daisyUI's rules, which sit in sublayers of it, and they set daisyUI's own size variables where it has one.
|
||||||
|
|
||||||
A target gets a 44px hit area (Apple's guideline, WCAG 2.5.5), which tests/e2e/Mobile measures. Buttons, tabs and
|
A target gets a 44px hit area (Apple's guideline, WCAG 2.5.5). Buttons, tabs and
|
||||||
fields are 44px tall; the extra small buttons and the badges that act stay 36px to the eye and reach 44px through a
|
fields are 44px tall; the extra small buttons and the badges that act stay 36px to the eye and reach 44px through a
|
||||||
transparent ::after, so the rows that hold them leave 8px between them on touch (pointer-coarse:gap-2), or two hit
|
transparent ::after, so the rows that hold them leave 8px between them on touch (pointer-coarse:gap-2), or two hit
|
||||||
areas would overlap. */
|
areas would overlap. */
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
|
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
|
||||||
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
|
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
|
||||||
<!-- tests/e2e is its own project; without this the app would compile its .cs files too -->
|
|
||||||
<DefaultItemExcludes>$(DefaultItemExcludes);tests/**</DefaultItemExcludes>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -40,7 +38,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<TailwindSource Include="Styles/**/*.css;**/*.razor;**/*.cs;wwwroot/index.html" Exclude="bin/**;obj/**;.tools/**;tools/**;tests/**" />
|
<TailwindSource Include="Styles/**/*.css;**/*.razor;**/*.cs;wwwroot/index.html" Exclude="bin/**;obj/**;.tools/**;tools/**" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Unpacks the vendored binary when it is missing or the vendored archive changed (an upgrade). -->
|
<!-- Unpacks the vendored binary when it is missing or the vendored archive changed (an upgrade). -->
|
||||||
@@ -69,7 +67,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Points the development build at a local PrivaPub; the published app reads appsettings.json only. -->
|
<!-- Points the development build at a local PrivaPub; the published app reads appsettings.json only. -->
|
||||||
<Content Update="wwwroot\appsettings.Development.json" CopyToPublishDirectory="Never" />
|
<Content Update="wwwroot\appsettings.Development.json" CopyToPublishDirectory="Never" />
|
||||||
<!-- Points the app at the pasture's PrivaPub (tests/e2e/run.sh publishes with WasmApplicationEnvironmentName=Pasture);
|
<!-- Points the app at the pasture's PrivaPub (a publish with WasmApplicationEnvironmentName=Pasture, which SocialPub's
|
||||||
|
tools/pasture/peers/decepub.sh serves);
|
||||||
any other publish leaves it out, and the deploy checks that it did. -->
|
any other publish leaves it out, and the deploy checks that it did. -->
|
||||||
<Content Update="wwwroot\appsettings.Pasture.json" CopyToPublishDirectory="Never" Condition="'$(WasmApplicationEnvironmentName)' != 'Pasture'" />
|
<Content Update="wwwroot\appsettings.Pasture.json" CopyToPublishDirectory="Never" Condition="'$(WasmApplicationEnvironmentName)' != 'Pasture'" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "decePubClient", "decePubCli
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SocialPub.ClientModels", "..\SocialPub\SocialPub\SocialPub.ClientModels\SocialPub.ClientModels.csproj", "{5DA9311B-B6BC-4EC6-A26E-10367288F52B}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SocialPub.ClientModels", "..\SocialPub\SocialPub\SocialPub.ClientModels\SocialPub.ClientModels.csproj", "{5DA9311B-B6BC-4EC6-A26E-10367288F52B}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
|
||||||
EndProject
|
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "e2e", "e2e", "{0EB607AC-6329-6575-AD8B-E5558B048B88}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "decePubClient.E2E", "tests\e2e\decePubClient.E2E.csproj", "{DB238929-3C3E-4841-B782-3BF0CC53B20E}"
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -47,26 +41,10 @@ Global
|
|||||||
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x64.Build.0 = Release|Any CPU
|
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x86.ActiveCfg = Release|Any CPU
|
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x86.Build.0 = Release|Any CPU
|
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
|
||||||
{0EB607AC-6329-6575-AD8B-E5558B048B88} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
|
||||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E} = {0EB607AC-6329-6575-AD8B-E5558B048B88}
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {1B399226-FEE0-4572-A4A7-783DA83E7DDD}
|
SolutionGuid = {1B399226-FEE0-4572-A4A7-783DA83E7DDD}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// A login of its own, made for the test: its password changed in Settings, then the whole of it deleted there, each
|
|
||||||
// checked against PrivaPub's own login
|
|
||||||
public sealed class AccountTests(Browser browser)
|
|
||||||
{
|
|
||||||
const string FirstPassword = "Pasture-Pass-1!";
|
|
||||||
const string SecondPassword = "Pasture-Pass-2!";
|
|
||||||
|
|
||||||
static async Task<HttpStatusCode> Login(string root, string password)
|
|
||||||
{
|
|
||||||
using var http = Pasture.Http();
|
|
||||||
using var response = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/login", new { userName = root, password });
|
|
||||||
return response.StatusCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_logins_password_is_changed_and_the_login_deleted_from_settings()
|
|
||||||
{
|
|
||||||
var stamp = DateTime.UtcNow.ToString("MMddHHmmss");
|
|
||||||
var root = $"e2eroot{stamp}";
|
|
||||||
using (var http = Pasture.Http())
|
|
||||||
{
|
|
||||||
using var signup = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/signup", new { userName = root, password = FirstPassword });
|
|
||||||
Assert.SkipWhen(!signup.IsSuccessStatusCode, $"PrivaPub refuses sign-ups ({(int)signup.StatusCode})");
|
|
||||||
var jwt = JsonNode.Parse(await signup.Content.ReadAsStringAsync())!["token"]!.GetValue<string>();
|
|
||||||
using var insert = new HttpRequestMessage(HttpMethod.Post, Pasture.Api + "/clientapi/avatar/private/insert")
|
|
||||||
{
|
|
||||||
Content = JsonContent.Create(new { userName = $"e2eacct{stamp}", name = "an e2e persona", biography = "for the account test" })
|
|
||||||
};
|
|
||||||
insert.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
|
||||||
using var inserted = await http.SendAsync(insert);
|
|
||||||
Assert.True(inserted.IsSuccessStatusCode, $"the persona could not be made ({(int)inserted.StatusCode})");
|
|
||||||
}
|
|
||||||
var persona = new Pasture.Persona($"e2eacct{stamp}", "an e2e persona", root, FirstPassword, null, null, null);
|
|
||||||
await using var session = await SignIn.As(browser, persona, fresh: true);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.action.account", "privapub", root, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
|
||||||
await page.Locator("[data-settings-account]").ClickAsync();
|
|
||||||
await page.Locator("input[data-account-current]").FillAsync(FirstPassword);
|
|
||||||
await page.Locator("input[data-account-new]").FillAsync(SecondPassword);
|
|
||||||
await page.Locator("input[data-account-repeat]").FillAsync(SecondPassword);
|
|
||||||
await page.Locator("[data-account-password-save]").ClickAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(async () => await Login(root, SecondPassword) == HttpStatusCode.OK, 15), "the new password never worked");
|
|
||||||
Assert.NotEqual(HttpStatusCode.OK, await Login(root, FirstPassword));
|
|
||||||
|
|
||||||
await page.Locator("input[data-account-delete-password]").FillAsync(SecondPassword);
|
|
||||||
await page.Locator("input[data-account-delete-understood]").CheckAsync();
|
|
||||||
await page.Locator("[data-account-delete-confirm]").ClickAsync();
|
|
||||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/login", new() { Timeout = 30000 });
|
|
||||||
Assert.True(await Pasture.Eventually(async () => await Login(root, SecondPassword) != HttpStatusCode.OK, 15), "the deleted login still signs in");
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// What a reader does to a post from each platform, done in decePub and checked on the post's own server: a favourite
|
|
||||||
// and a boost arrive there (and leave when undone), a reply lands under the post and is gone once deleted. town.sh
|
|
||||||
// stored reads the origin's database, so nothing here trusts decePub's own view of what it did.
|
|
||||||
public sealed class ActionMatrixTests(Browser browser)
|
|
||||||
{
|
|
||||||
static readonly Lazy<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
|
|
||||||
|
|
||||||
public static TheoryData<string, string> Targets()
|
|
||||||
{
|
|
||||||
var data = new TheoryData<string, string>();
|
|
||||||
var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet();
|
|
||||||
foreach (var origin in new[] { "gts", "mastodon", "misskey", "sharkey", "akkoma", "lemmy" })
|
|
||||||
{
|
|
||||||
var item = Expected.Value.FirstOrDefault(i => i!["origin"]!.GetValue<string>() == origin
|
|
||||||
&& i["visibility"]!.GetValue<string>() is "public" or "community"
|
|
||||||
&& i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue<string>())));
|
|
||||||
if (item is not null)
|
|
||||||
data.Add(origin, item["ref"]!.GetValue<string>());
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
static (JsonNode Item, Pasture.Persona Reader) Target(string reference)
|
|
||||||
{
|
|
||||||
var item = Expected.Value.First(i => i!["ref"]!.GetValue<string>() == reference)!;
|
|
||||||
var personas = Pasture.Personas();
|
|
||||||
var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue<string>()))
|
|
||||||
.First(p => p is not null);
|
|
||||||
return (item, reader);
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task<int> Count(string origin, string uri, string field)
|
|
||||||
{
|
|
||||||
var row = (await Pasture.Stored(origin, uri))[uri]!;
|
|
||||||
return row[field]?.GetValue<int?>() ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task<bool> IsActive(ILocator button) =>
|
|
||||||
((await button.GetAttributeAsync("class")) ?? "").Split(' ').Contains("btn-active");
|
|
||||||
|
|
||||||
async Task<(Session Session, ILocator Card)> Open(JsonNode item, Pasture.Persona reader)
|
|
||||||
{
|
|
||||||
var session = await SignIn.As(browser, reader);
|
|
||||||
await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue<string>()}");
|
|
||||||
var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue<string>()}']").First;
|
|
||||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
return (session, card);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[MemberData(nameof(Targets))]
|
|
||||||
public async Task A_favourite_reaches_the_author_and_its_undo_too(string origin, string reference)
|
|
||||||
{
|
|
||||||
var (item, reader) = Target(reference);
|
|
||||||
var uri = item["uri"]!.GetValue<string>();
|
|
||||||
await Results.Cell("client.action.favourite", origin, reference, async () =>
|
|
||||||
{
|
|
||||||
var (session, card) = await Open(item, reader);
|
|
||||||
await using var _ = session;
|
|
||||||
var favourite = card.Locator("footer button[title='Favourite']").First;
|
|
||||||
// the town may have done it already as this persona: start from the undone state
|
|
||||||
if (await IsActive(favourite))
|
|
||||||
{
|
|
||||||
var done = await Count(origin, uri, "likes");
|
|
||||||
await favourite.ClickAsync();
|
|
||||||
await Pasture.Eventually(async () => await Count(origin, uri, "likes") < done, 60);
|
|
||||||
await Assertions.Expect(favourite).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active"));
|
|
||||||
}
|
|
||||||
var before = await Count(origin, uri, "likes");
|
|
||||||
await favourite.ClickAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "likes") > before),
|
|
||||||
$"the favourite never reached {origin}");
|
|
||||||
await favourite.ClickAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "likes") == before, 60),
|
|
||||||
$"undoing the favourite never reached {origin}");
|
|
||||||
await session.AssertGuards();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[MemberData(nameof(Targets))]
|
|
||||||
public async Task A_boost_reaches_the_author_and_its_undo_too(string origin, string reference)
|
|
||||||
{
|
|
||||||
Assert.SkipWhen(origin == "lemmy", "Lemmy keeps no boosts of a thread");
|
|
||||||
var (item, reader) = Target(reference);
|
|
||||||
var uri = item["uri"]!.GetValue<string>();
|
|
||||||
await Results.Cell("client.action.boost", origin, reference, async () =>
|
|
||||||
{
|
|
||||||
var (session, card) = await Open(item, reader);
|
|
||||||
await using var _ = session;
|
|
||||||
var boost = card.Locator("footer button[title='Boost']").First;
|
|
||||||
// the town may have done it already as this persona: start from the undone state
|
|
||||||
if (await IsActive(boost))
|
|
||||||
{
|
|
||||||
var done = await Count(origin, uri, "boosts");
|
|
||||||
await boost.ClickAsync();
|
|
||||||
await Pasture.Eventually(async () => await Count(origin, uri, "boosts") < done, 60);
|
|
||||||
await Assertions.Expect(boost).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active"));
|
|
||||||
}
|
|
||||||
var before = await Count(origin, uri, "boosts");
|
|
||||||
await boost.ClickAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "boosts") > before),
|
|
||||||
$"the boost never reached {origin}");
|
|
||||||
await boost.ClickAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "boosts") == before, 60),
|
|
||||||
$"undoing the boost never reached {origin}");
|
|
||||||
await session.AssertGuards();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[MemberData(nameof(Targets))]
|
|
||||||
public async Task A_reply_lands_under_the_post_and_its_delete_removes_it(string origin, string reference)
|
|
||||||
{
|
|
||||||
var (item, reader) = Target(reference);
|
|
||||||
var uri = item["uri"]!.GetValue<string>();
|
|
||||||
await Results.Cell("client.action.reply", origin, reference, async () =>
|
|
||||||
{
|
|
||||||
var (session, card) = await Open(item, reader);
|
|
||||||
await using var _ = session;
|
|
||||||
await card.Locator("footer button[title='Reply']").First.ClickAsync();
|
|
||||||
var text = $"an e2e reply to {reference} ({Guid.NewGuid():N})";
|
|
||||||
// after the @names the composer prefills, as a reader types
|
|
||||||
var box = card.Locator("textarea").Last;
|
|
||||||
await box.FillAsync(((await box.InputValueAsync()).TrimEnd() + " " + text).Trim());
|
|
||||||
var published = session.Page.WaitForResponseAsync(r => r.Url.EndsWith("/api/v1/statuses") && r.Request.Method == "POST");
|
|
||||||
await card.Locator("button[type=submit]").Last.ClickAsync();
|
|
||||||
var response = await published;
|
|
||||||
Assert.True(response.Ok, $"publishing the reply answered {response.Status}");
|
|
||||||
var replyUri = (await response.JsonAsync())?.GetProperty("uri").GetString();
|
|
||||||
Assert.False(string.IsNullOrEmpty(replyUri));
|
|
||||||
|
|
||||||
Assert.True(await Pasture.Eventually(async () =>
|
|
||||||
{
|
|
||||||
var row = (await Pasture.Stored(origin, replyUri))[replyUri]!;
|
|
||||||
return row["exists"]!.GetValue<bool>() && row["parent_uri"]?.GetValue<string>() == uri;
|
|
||||||
}, 45), $"the reply never landed under the post on {origin}");
|
|
||||||
|
|
||||||
var reply = session.Page.Locator($"article[data-uri='{replyUri}']").First;
|
|
||||||
await reply.WaitForAsync(new() { Timeout = 15000 });
|
|
||||||
await reply.Locator("footer [title='Other']").First.ClickAsync();
|
|
||||||
await reply.GetByRole(AriaRole.Button).Filter(new() { HasText = "Delete" }).First.ClickAsync();
|
|
||||||
await session.Page.Locator("[data-confirm-yes]").ClickAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(async () =>
|
|
||||||
{
|
|
||||||
var row = (await Pasture.Stored(origin, replyUri))[replyUri]!;
|
|
||||||
return !row["exists"]!.GetValue<bool>() || row["deleted"]!.GetValue<bool>();
|
|
||||||
}, 45), $"the deleted reply is still on {origin}");
|
|
||||||
await session.AssertGuards();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// Administration as the pasture's administrator (pastureroot, whom the scenarios make): a server blocked for everyone
|
|
||||||
// and unblocked, the federation statistics, and the reports moderators handle; each change checked on /clientapi
|
|
||||||
public sealed class AdministrationTests(Browser browser)
|
|
||||||
{
|
|
||||||
const string Root = PastureAdmin.Root;
|
|
||||||
static readonly Pasture.Persona Admin = new("alice", "alice", Root, PastureAdmin.Password, null, null, null);
|
|
||||||
|
|
||||||
static Task<JsonNode> AsAdmin(string path) => PastureAdmin.Get(path);
|
|
||||||
|
|
||||||
static async Task<ILocator> Open(IPage page, string section)
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/administration");
|
|
||||||
var header = page.Locator(".collapse-title", new() { HasText = section }).First;
|
|
||||||
await header.ClickAsync();
|
|
||||||
return header.Locator("xpath=..");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_server_is_blocked_for_everyone_and_unblocked()
|
|
||||||
{
|
|
||||||
const string server = "instance-block-by-e2e.example";
|
|
||||||
await using var session = await SignIn.As(browser, Admin);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.admin.server-block", "privapub", Root, async () =>
|
|
||||||
{
|
|
||||||
var panel = await Open(page, "Blocked servers");
|
|
||||||
var blocks = panel.Locator("[data-server-blocks]");
|
|
||||||
await blocks.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await blocks.Locator("[data-server-block-domain]").FillAsync(server);
|
|
||||||
await blocks.GetByRole(AriaRole.Button, new() { Name = "Block server" }).ClickAsync();
|
|
||||||
var row = blocks.Locator($"[data-server-block='{server}']");
|
|
||||||
await Assertions.Expect(row).ToContainTextAsync("Suspended", new() { Timeout = 15000 });
|
|
||||||
Assert.Contains((await AsAdmin("/clientapi/admin/domainblocks/list"))!.AsArray(), b => b!["domain"]!.GetValue<string>() == server);
|
|
||||||
|
|
||||||
await row.GetByRole(AriaRole.Button, new() { Name = "Unblock" }).ClickAsync();
|
|
||||||
await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
|
||||||
Assert.DoesNotContain((await AsAdmin("/clientapi/admin/domainblocks/list"))!.AsArray(), b => b!["domain"]!.GetValue<string>() == server);
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task The_federation_statistics_and_the_reports_are_shown()
|
|
||||||
{
|
|
||||||
await using var session = await SignIn.As(browser, Admin);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.admin.statistics", "privapub", Root, async () =>
|
|
||||||
{
|
|
||||||
var panel = await Open(page, "Statistics");
|
|
||||||
await Assertions.Expect(panel.Locator("[data-statistics-overview]")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
|
||||||
var expected = (await AsAdmin("/clientapi/admin/statistics/hosts?days=30&sort=traffic&page=1&limit=50"))!["hosts"]!.AsArray()
|
|
||||||
.Select(h => h!["host"]!.GetValue<string>()).Take(5).ToList();
|
|
||||||
Assert.NotEmpty(expected);
|
|
||||||
foreach (var host in expected)
|
|
||||||
await Assertions.Expect(panel.Locator($"[data-statistics-host='{host}']")).ToHaveCountAsync(1, new() { Timeout = 15000 });
|
|
||||||
|
|
||||||
var reports = await Open(page, "Reports");
|
|
||||||
await Assertions.Expect(reports.Locator("[data-reports]")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
|
||||||
var open = (await AsAdmin("/clientapi/moderator/reports?resolved=false"))!.AsArray().Count;
|
|
||||||
await Assertions.Expect(reports.Locator("[data-report-id]")).ToHaveCountAsync(open, new() { Timeout = 15000 });
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// An article in a feed shows its title, its excerpt and a Read button, and its whole text once asked: a WriteFreely
|
|
||||||
// blog the persona follows publishes one (SocialPub's peers/writefreely.sh keeps the blog's token).
|
|
||||||
public sealed class ArticleTests(Browser browser)
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task An_article_is_folded_in_the_feed_and_read_on_demand()
|
|
||||||
{
|
|
||||||
var tokenFile = Path.Combine(Pasture.Root, ".state", "writefreely", "wfuser.token");
|
|
||||||
Assert.SkipUnless(File.Exists(tokenFile), "no WriteFreely blog: run tools/pasture/run.sh add writefreely");
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var blog = (await PersonaApi.Call(persona, "/api/v2/search?q=wfuser%40writefreely.test&resolve=true&type=accounts"))?["accounts"]?.AsArray().FirstOrDefault();
|
|
||||||
Assert.SkipWhen(blog is null, "PrivaPub cannot resolve the WriteFreely blog");
|
|
||||||
var blogId = blog!["id"]!.GetValue<string>();
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/accounts/{blogId}/follow", HttpMethod.Post);
|
|
||||||
Assert.True(await Pasture.Eventually(async () =>
|
|
||||||
(await PersonaApi.Call(persona, $"/api/v1/accounts/relationships?id[]={blogId}"))![0]!["following"]!.GetValue<bool>(), 45), "the blog never accepted");
|
|
||||||
|
|
||||||
var title = $"An article to fold {Guid.NewGuid():N}"[..30];
|
|
||||||
var ending = $"the last words {Guid.NewGuid():N}"[..24];
|
|
||||||
var body = string.Join(" ", Enumerable.Repeat("A long paragraph that a feed should not show whole.", 12)) + " " + ending;
|
|
||||||
using var http = Pasture.Http();
|
|
||||||
using var publish = new HttpRequestMessage(HttpMethod.Post, "https://writefreely.test/api/collections/wfuser/posts")
|
|
||||||
{
|
|
||||||
Content = new StringContent(new JsonObject { ["title"] = title, ["body"] = body }.ToJsonString(), Encoding.UTF8, "application/json")
|
|
||||||
};
|
|
||||||
publish.Headers.Authorization = new AuthenticationHeaderValue("Token", File.ReadAllText(tokenFile).Trim());
|
|
||||||
(await http.SendAsync(publish)).EnsureSuccessStatusCode();
|
|
||||||
JsonNode article = null;
|
|
||||||
Assert.True(await Pasture.Eventually(async () =>
|
|
||||||
(article = (await PersonaApi.Call(persona, "/api/v1/timelines/home?limit=40"))!.AsArray()
|
|
||||||
.FirstOrDefault(s => s!["privapub"]?["title"]?.GetValue<string>() == title)) is not null, 60), "the article never reached the persona");
|
|
||||||
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.render.article", "writefreely", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
var card = page.Locator($"article[data-uri='{article!["uri"]!.GetValue<string>()}']").First;
|
|
||||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
|
|
||||||
await Assertions.Expect(card).ToContainTextAsync(title);
|
|
||||||
await Assertions.Expect(card.Locator("[data-article]")).ToBeVisibleAsync();
|
|
||||||
await Assertions.Expect(card).Not.ToContainTextAsync(ending);
|
|
||||||
await card.Locator("[data-article-open]").ClickAsync();
|
|
||||||
await Assertions.Expect(card).ToContainTextAsync(ending, new() { Timeout = 5000 });
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/accounts/{blogId}/unfollow", HttpMethod.Post);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
using System.Formats.Tar;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// The server's backups from Administration (owner decision 2026-10-07), as the pasture's administrator: one made now,
|
|
||||||
// downloaded through its link (a tar whose files are the backup's), deleted and uploaded again from the browser in
|
|
||||||
// pieces; each step checked on /clientapi
|
|
||||||
public sealed class BackupsTests(Browser browser)
|
|
||||||
{
|
|
||||||
const string Root = PastureAdmin.Root;
|
|
||||||
static readonly Pasture.Persona Admin = new("alice", "alice", Root, PastureAdmin.Password, null, null, null);
|
|
||||||
|
|
||||||
internal static async Task<ILocator> Open(IPage page)
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/administration");
|
|
||||||
var header = page.Locator(".collapse-title", new() { HasText = "Backups" }).First;
|
|
||||||
await header.ClickAsync();
|
|
||||||
var panel = header.Locator("xpath=..").Locator("[data-backups]");
|
|
||||||
await panel.Locator("[data-backup-now]").WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
return panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static async Task<string> BackUp(ILocator panel)
|
|
||||||
{
|
|
||||||
var before = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Select(b => b!["id"]!.GetValue<string>()).ToHashSet();
|
|
||||||
await panel.Locator("[data-backup-now]").ClickAsync();
|
|
||||||
var made = default(string);
|
|
||||||
Assert.True(await Pasture.Eventually(async () =>
|
|
||||||
{
|
|
||||||
var list = (await PastureAdmin.Get("/clientapi/admin/backups"))!;
|
|
||||||
made = list["backups"]!.AsArray().Select(b => b!["id"]!.GetValue<string>()).FirstOrDefault(id => !before.Contains(id));
|
|
||||||
return made is not null && list["running"] is null;
|
|
||||||
}, 300), "the backup was never made");
|
|
||||||
await Assertions.Expect(panel.Locator($"[data-backup='{made}']")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
|
||||||
return made;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_backup_is_made_downloaded_and_uploaded_again()
|
|
||||||
{
|
|
||||||
await using var session = await SignIn.As(browser, Admin);
|
|
||||||
var page = session.Page;
|
|
||||||
var file = Path.Combine(Path.GetTempPath(), $"decepub-backup-{Guid.NewGuid():N}.tar");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.admin.backup", "privapub", Root, async () =>
|
|
||||||
{
|
|
||||||
var panel = await Open(page);
|
|
||||||
var id = await BackUp(panel);
|
|
||||||
var row = panel.Locator($"[data-backup='{id}']");
|
|
||||||
await Assertions.Expect(row).ToContainTextAsync("Made by hand");
|
|
||||||
|
|
||||||
// the link, for the password
|
|
||||||
await row.Locator("[data-backup-download]").ClickAsync();
|
|
||||||
var dialog = page.Locator("[data-backup-download-dialog]");
|
|
||||||
await dialog.Locator("[data-backup-password]").FillAsync(PastureAdmin.Password);
|
|
||||||
await dialog.Locator("[data-backup-confirm]").ClickAsync();
|
|
||||||
var link = dialog.Locator("[data-backup-link]");
|
|
||||||
await Assertions.Expect(link).ToBeVisibleAsync(new() { Timeout = 15000 });
|
|
||||||
var download = await page.RunAndWaitForDownloadAsync(() => link.ClickAsync(), new() { Timeout = 120000 });
|
|
||||||
await download.SaveAsAsync(file);
|
|
||||||
var names = new List<string>();
|
|
||||||
await using (var reader = new TarReader(File.OpenRead(file)))
|
|
||||||
while (await reader.GetNextEntryAsync() is { } entry)
|
|
||||||
names.Add(entry.Name);
|
|
||||||
Assert.Contains($"{id}/manifest.json", names);
|
|
||||||
Assert.Contains(names, n => n.StartsWith($"{id}/db/", StringComparison.Ordinal));
|
|
||||||
await dialog.Locator("[data-backup-dialog-close]").ClickAsync();
|
|
||||||
|
|
||||||
// deleted (asked first), then uploaded again from the file
|
|
||||||
await row.Locator("[data-backup-delete]").ClickAsync();
|
|
||||||
await page.Locator("[data-confirm-yes]").ClickAsync();
|
|
||||||
await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
|
||||||
Assert.DoesNotContain((await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray(), b => b!["id"]!.GetValue<string>() == id);
|
|
||||||
|
|
||||||
await panel.Locator("input[data-backup-upload]").SetInputFilesAsync(file);
|
|
||||||
await panel.Locator("[data-backup-upload-start]").ClickAsync();
|
|
||||||
await Assertions.Expect(row).ToContainTextAsync("Uploaded", new() { Timeout = 120000 });
|
|
||||||
var uploaded = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Single(b => b!["id"]!.GetValue<string>() == id)!;
|
|
||||||
Assert.Equal("uploaded", uploaded["kind"]!.GetValue<string>());
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
File.Delete(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A real restore of the pasture from Administration: PrivaPub stops and restores as it starts again, every session
|
|
||||||
// ends, and the page says so; signed in again, the last restore's report is there. It restores the whole server and
|
|
||||||
// ends the town's sessions, so it runs only when asked (--filter "Category=Restore"), alone, and signs the town in
|
|
||||||
// again (town.sh renew privapub).
|
|
||||||
[Trait("Category", "Restore")]
|
|
||||||
public sealed class RestoreTests(Browser browser)
|
|
||||||
{
|
|
||||||
static readonly Pasture.Persona Admin = new("alice", "alice", PastureAdmin.Root, PastureAdmin.Password, null, null, null);
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_backup_is_restored_and_everyone_signs_in_again()
|
|
||||||
{
|
|
||||||
await using var session = await SignIn.As(browser, Admin);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.admin.restore", "privapub", PastureAdmin.Root, async () =>
|
|
||||||
{
|
|
||||||
var panel = await BackupsTests.Open(page);
|
|
||||||
var id = await BackupsTests.BackUp(panel);
|
|
||||||
var host = (await PastureAdmin.Get("/clientapi/admin/backups"))!["host"]!.GetValue<string>();
|
|
||||||
|
|
||||||
await panel.Locator($"[data-backup='{id}'] [data-backup-restore]").ClickAsync();
|
|
||||||
var dialog = page.Locator("[data-backup-restore-dialog]");
|
|
||||||
await dialog.Locator("[data-backup-password]").FillAsync(PastureAdmin.Password);
|
|
||||||
var confirm = dialog.Locator("[data-backup-confirm]");
|
|
||||||
await dialog.Locator("[data-backup-host]").FillAsync("elsewhere.test");
|
|
||||||
await Assertions.Expect(confirm).ToBeDisabledAsync();
|
|
||||||
await dialog.Locator("[data-backup-host]").FillAsync(host);
|
|
||||||
await confirm.ClickAsync();
|
|
||||||
await Assertions.Expect(page.Locator("[data-backup-restarting]")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
|
||||||
|
|
||||||
// once PrivaPub is back, the session it ended sends the page to sign in again
|
|
||||||
await page.WaitForURLAsync(url => url.Contains("/login"), new() { Timeout = 300000 });
|
|
||||||
await Pasture.RenewTown();
|
|
||||||
}, page);
|
|
||||||
|
|
||||||
await using var again = await SignIn.As(browser, Admin);
|
|
||||||
var panelAgain = await BackupsTests.Open(again.Page);
|
|
||||||
await Assertions.Expect(panelAgain.Locator("[data-last-restore]")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
|
||||||
Assert.NotNull((await PastureAdmin.Get("/clientapi/admin/backups"))!["lastRestore"]);
|
|
||||||
await again.AssertGuards();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// The composer beyond words: a poll in a language picked, the persona's own post edited and its history shown, and a
|
|
||||||
// quote, each checked on PrivaPub
|
|
||||||
public sealed class ComposerTests(Browser browser)
|
|
||||||
{
|
|
||||||
static async Task OpenHome(IPage page)
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task<JsonNode> Latest(Pasture.Persona persona) =>
|
|
||||||
(await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault();
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_poll_is_published_from_the_composer()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var text = $"which one {Guid.NewGuid():N}"[..18];
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
string made = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.action.poll", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await OpenHome(page);
|
|
||||||
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
|
|
||||||
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
|
||||||
await page.Locator("button[data-poll-toggle]").ClickAsync();
|
|
||||||
var choices = page.Locator("[data-poll-editor] input[type=text]");
|
|
||||||
await choices.Nth(0).FillAsync("tea");
|
|
||||||
await choices.Nth(1).FillAsync("coffee");
|
|
||||||
// and in the language picked
|
|
||||||
await page.Locator("[data-compose-language]").First.SelectOptionAsync("it");
|
|
||||||
// (the composer's own submit: a card's time is a "Post" button too, its nerd stats)
|
|
||||||
await page.Locator("form:has(textarea[data-compose]) button[type=submit]").First.ClickAsync();
|
|
||||||
JsonNode latest = null;
|
|
||||||
Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue<string>().Contains(text) == true, 15),
|
|
||||||
"the poll was never published");
|
|
||||||
made = latest!["id"]!.GetValue<string>();
|
|
||||||
Assert.Equal(new[] { "tea", "coffee" }, latest["poll"]!["options"]!.AsArray().Select(o => o!["title"]!.GetValue<string>()));
|
|
||||||
Assert.Equal("it", latest["language"]?.GetValue<string>());
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (made is not null)
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// a mention being written is offered the accounts PrivaPub knows, and a click writes the whole handle out
|
|
||||||
[Fact]
|
|
||||||
public async Task A_mention_being_written_is_completed_from_the_accounts_the_server_knows()
|
|
||||||
{
|
|
||||||
var personas = Pasture.Personas();
|
|
||||||
Assert.SkipWhen(personas.Count < 2, "the town has one persona");
|
|
||||||
var (author, other) = (personas[0], personas[1]);
|
|
||||||
var acct = (await PersonaApi.Call(other, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue<string>();
|
|
||||||
await using var session = await SignIn.As(browser, author);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.action.mention-suggestion", "privapub", author.UserName, async () =>
|
|
||||||
{
|
|
||||||
await OpenHome(page);
|
|
||||||
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
|
|
||||||
var box = page.Locator("textarea[data-compose]").First;
|
|
||||||
await box.FillAsync("hello @" + acct[..Math.Min(4, acct.Length)]);
|
|
||||||
await page.Locator($"[data-suggestions] [data-suggestion='@{acct}']").ClickAsync(new() { Timeout = 15000 });
|
|
||||||
await Assertions.Expect(box).ToHaveValueAsync($"hello @{acct} ");
|
|
||||||
await Assertions.Expect(page.Locator("[data-suggestions]")).ToHaveCountAsync(0);
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task The_personas_own_post_is_edited_and_quoted_from_its_card()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var text = $"first words {Guid.NewGuid():N}"[..20];
|
|
||||||
var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "public" }))!;
|
|
||||||
var id = post["id"]!.GetValue<string>();
|
|
||||||
string quote = null;
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.action.edit-quote", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await OpenHome(page);
|
|
||||||
var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").First;
|
|
||||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
|
|
||||||
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
|
||||||
await card.Locator("button[data-edit]").ClickAsync();
|
|
||||||
var box = card.Locator("textarea[data-compose]");
|
|
||||||
await Assertions.Expect(box).ToHaveValueAsync(text, new() { Timeout = 10000 });
|
|
||||||
await box.FillAsync(text + " (edited)");
|
|
||||||
await card.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
|
||||||
await Assertions.Expect(card).ToContainTextAsync(text + " (edited)", new() { Timeout = 10000 });
|
|
||||||
Assert.Contains("(edited)", (await PersonaApi.Call(persona, $"/api/v1/statuses/{id}"))!["content"]!.GetValue<string>());
|
|
||||||
await card.Locator("button[data-history]").ClickAsync();
|
|
||||||
var versions = card.Locator("[data-history-list] [data-version]");
|
|
||||||
await Assertions.Expect(versions).ToHaveCountAsync(2, new() { Timeout = 10000 });
|
|
||||||
await Assertions.Expect(versions.First).ToContainTextAsync(text + " (edited)");
|
|
||||||
await Assertions.Expect(versions.Last).ToContainTextAsync(text);
|
|
||||||
await Assertions.Expect(versions.Last).Not.ToContainTextAsync("(edited)");
|
|
||||||
await page.Keyboard.PressAsync("Escape");
|
|
||||||
await Assertions.Expect(card.Locator("[data-history-list]")).ToHaveCountAsync(0);
|
|
||||||
|
|
||||||
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
|
||||||
await card.Locator("button[data-quote]").ClickAsync();
|
|
||||||
await card.Locator("textarea[data-compose]").FillAsync("quoting myself");
|
|
||||||
await card.GetByRole(AriaRole.Button, new() { Name = "Quote" }).Last.ClickAsync();
|
|
||||||
JsonNode latest = null;
|
|
||||||
Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue<string>().Contains("quoting myself") == true, 15),
|
|
||||||
"the quote was never published");
|
|
||||||
quote = latest!["id"]!.GetValue<string>();
|
|
||||||
Assert.Equal(id, (latest["quote"]?["quoted_status"]?["id"] ?? latest["quote"]?["quoted_status_id"])?.GetValue<string>());
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (quote is not null)
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{quote}", HttpMethod.Delete);
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// Direct conversations: a message from another persona badges the Direct tab and its card says unread; marking it
|
|
||||||
// read and taking it off the list are checked on PrivaPub
|
|
||||||
public sealed class ConversationTests(Browser browser)
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task A_new_message_is_unread_until_marked_and_leaves_the_list_when_removed()
|
|
||||||
{
|
|
||||||
var personas = Pasture.Personas();
|
|
||||||
Assert.SkipWhen(personas.Count < 2, "the town has fewer than two personas");
|
|
||||||
var (reader, writer) = (personas[0], personas[1]);
|
|
||||||
var message = (await PersonaApi.Call(writer, "/api/v1/statuses", HttpMethod.Post, new JsonObject
|
|
||||||
{
|
|
||||||
["status"] = $"@{reader.UserName} just between us {Guid.NewGuid():N}"[..40], ["visibility"] = "direct"
|
|
||||||
}))!;
|
|
||||||
var uri = message["uri"]!.GetValue<string>();
|
|
||||||
async Task<JsonNode> Conversation() => (await PersonaApi.Call(reader, "/api/v1/conversations?limit=40"))!.AsArray()
|
|
||||||
.FirstOrDefault(c => c!["last_status"]?["uri"]?.GetValue<string>() == uri);
|
|
||||||
await using var session = await SignIn.As(browser, reader);
|
|
||||||
var page = session.Page;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.action.conversation", "privapub", reader.UserName, async () =>
|
|
||||||
{
|
|
||||||
Assert.True(await Pasture.Eventually(async () => (await Conversation())?["unread"]?.GetValue<bool>() == true, 15),
|
|
||||||
"the message never made an unread conversation");
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.Locator("[data-unread-conversations]").WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
|
|
||||||
await page.GetByRole(AriaRole.Tab).Filter(new() { Has = page.Locator(".ion-md-mail") }).ClickAsync();
|
|
||||||
var card = page.Locator($"article[data-uri='{uri}']");
|
|
||||||
await card.Locator("[data-conversation-unread]").WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await card.Locator("[data-conversation-read]").ClickAsync();
|
|
||||||
await Assertions.Expect(card.Locator("[data-conversation-unread]")).ToHaveCountAsync(0, new() { Timeout = 10000 });
|
|
||||||
Assert.False((await Conversation())!["unread"]!.GetValue<bool>());
|
|
||||||
|
|
||||||
await card.Locator("[data-conversation-remove]").ClickAsync();
|
|
||||||
await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 10000 });
|
|
||||||
Assert.Null(await Conversation());
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await PersonaApi.Call(writer, $"/api/v1/statuses/{message["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// Posts written for later, hashtags followed and what Explore shows, each checked against PrivaPub's own answer
|
|
||||||
public sealed class DiscoveryTests(Browser browser)
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task A_post_scheduled_in_the_composer_waits_is_moved_and_is_dropped()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var text = $"later {Guid.NewGuid():N}"[..20];
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.action.schedule", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
|
|
||||||
await page.Locator("textarea").First.FillAsync(text);
|
|
||||||
await page.Locator("button[data-schedule]").ClickAsync();
|
|
||||||
await page.Locator("input[data-schedule-at]").FillAsync(DateTime.Now.AddHours(2).ToString("yyyy-MM-ddTHH:mm"));
|
|
||||||
await page.GetByRole(AriaRole.Button, new() { Name = "Schedule" }).ClickAsync();
|
|
||||||
JsonNode waiting = null;
|
|
||||||
Assert.True(await Pasture.Eventually(async () => (waiting = (await PersonaApi.Call(persona, "/api/v1/scheduled_statuses"))!.AsArray()
|
|
||||||
.FirstOrDefault(s => s!["params"]!["text"]?.GetValue<string>() == text)) is not null, 15), "PrivaPub never kept the scheduled post");
|
|
||||||
var id = waiting!["id"]!.GetValue<string>();
|
|
||||||
Assert.DoesNotContain((await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=10"))!.AsArray(),
|
|
||||||
s => s!["content"]!.GetValue<string>().Contains(text));
|
|
||||||
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/scheduled");
|
|
||||||
var item = page.Locator($"article[data-scheduled-id='{id}']");
|
|
||||||
await Assertions.Expect(item).ToContainTextAsync(text, new() { Timeout = 15000 });
|
|
||||||
var later = DateTime.Now.AddHours(5);
|
|
||||||
await item.Locator("input[type=datetime-local]").FillAsync(later.ToString("yyyy-MM-ddTHH:mm"));
|
|
||||||
await item.GetByRole(AriaRole.Button, new() { Name = "Move" }).ClickAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(async () =>
|
|
||||||
(await PersonaApi.Call(persona, $"/api/v1/scheduled_statuses/{id}"))?["scheduled_at"]?.GetValue<DateTime>().ToUniversalTime()
|
|
||||||
is { } at && Math.Abs((at - later.ToUniversalTime()).TotalMinutes) < 2, 15), "PrivaPub never moved the post");
|
|
||||||
await item.GetByRole(AriaRole.Button, new() { Name = "Never publish it" }).ClickAsync();
|
|
||||||
await page.Locator("[data-confirm-yes]").ClickAsync();
|
|
||||||
await Assertions.Expect(item).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
|
||||||
Assert.Null(await PersonaApi.Call(persona, $"/api/v1/scheduled_statuses/{id}"));
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_hashtag_is_followed_from_its_feed_tab_and_listed_in_explore()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var tag = $"e2etag{Guid.NewGuid():N}"[..14];
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.action.follow-tag", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
// a reader finds the hashtag through search, which opens it as a tab of the feed
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/search?q={Uri.EscapeDataString("#" + tag)}");
|
|
||||||
await page.GetByRole(AriaRole.Button, new() { Name = "#" + tag }).ClickAsync(new() { Timeout = 30000 });
|
|
||||||
var follow = page.Locator("button[data-tag-follow]");
|
|
||||||
await Assertions.Expect(follow).ToContainTextAsync($"Follow #{tag}", new() { Timeout = 30000 });
|
|
||||||
await follow.ClickAsync();
|
|
||||||
await Assertions.Expect(follow).ToContainTextAsync($"Stop following #{tag}", new() { Timeout = 15000 });
|
|
||||||
Assert.True((await PersonaApi.Call(persona, $"/api/v1/tags/{tag}"))!["following"]!.GetValue<bool>());
|
|
||||||
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/explore");
|
|
||||||
await page.GetByRole(AriaRole.Tab, new() { Name = "Followed hashtags" }).ClickAsync();
|
|
||||||
var row = page.Locator($"[data-tag='{tag}']");
|
|
||||||
await row.WaitForAsync(new() { Timeout = 15000 });
|
|
||||||
await row.GetByRole(AriaRole.Button, new() { Name = "Unfollow" }).ClickAsync();
|
|
||||||
await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
|
||||||
Assert.False((await PersonaApi.Call(persona, $"/api/v1/tags/{tag}"))!["following"]!.GetValue<bool>());
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/tags/{tag}/unfollow", HttpMethod.Post);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Explore_shows_what_privapub_says_is_trending_and_its_directory()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var trending = (await PersonaApi.Call(persona, "/api/v1/trends/tags?limit=20"))!.AsArray().Select(t => t!["name"]!.GetValue<string>()).ToList();
|
|
||||||
var directory = (await PersonaApi.Call(persona, "/api/v1/directory?order=active&local=false&limit=40"))!.AsArray().Select(a => a!["id"]!.GetValue<string>()).ToList();
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.render.explore", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/explore");
|
|
||||||
await page.Locator("[data-tag], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
var shownTags = await page.Locator("[data-tag]").EvaluateAllAsync<string[]>("rows => rows.map(r => r.dataset.tag)");
|
|
||||||
Assert.Equal(trending, shownTags);
|
|
||||||
|
|
||||||
await page.GetByRole(AriaRole.Tab, new() { Name = "People" }).ClickAsync();
|
|
||||||
await page.Locator("[data-account], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
var shownPeople = await page.Locator("[data-account]").EvaluateAllAsync<string[]>("rows => rows.map(r => r.dataset.account)");
|
|
||||||
Assert.Equal(directory, shownPeople);
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// Joining another server's event from its card (an owner decision): the persona is asked once that taking part is
|
|
||||||
// public, Mobilizon makes it a participant and accepts, and leaving takes it off there. The event is the one
|
|
||||||
// SocialPub's tools/pasture/scenarios/mobilizon.sh keeps for these tests.
|
|
||||||
public sealed class EventTests(Browser browser)
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task A_persona_joins_a_mobilizon_event_from_its_card_and_leaves_it()
|
|
||||||
{
|
|
||||||
var kept = Path.Combine(Pasture.Root, ".state", "mobilizon-kept-event.json");
|
|
||||||
Assert.SkipUnless(File.Exists(kept), "no Mobilizon event kept for the client: run tools/pasture/interop.sh mobilizon");
|
|
||||||
var url = JsonNode.Parse(File.ReadAllText(kept))!["url"]!.GetValue<string>();
|
|
||||||
var personas = Pasture.Personas();
|
|
||||||
Assert.SkipWhen(personas.Count == 0, "the town has no personas");
|
|
||||||
var reader = personas[0];
|
|
||||||
var status = (await PersonaApi.Call(reader, $"/api/v2/search?q={Uri.EscapeDataString(url)}&resolve=true&type=statuses"))?["statuses"]?.AsArray().FirstOrDefault();
|
|
||||||
Assert.SkipWhen(status is null, "PrivaPub cannot resolve the kept Mobilizon event");
|
|
||||||
var id = status!["id"]!.GetValue<string>();
|
|
||||||
async Task<string> Participation() =>
|
|
||||||
(await PersonaApi.Call(reader, $"/api/v1/statuses/{id}"))?["privapub"]?["event"]?["participation"]?.GetValue<string>();
|
|
||||||
// a run cut short may have left the persona in: start from outside
|
|
||||||
if (await Participation() is not null)
|
|
||||||
await PersonaApi.Call(reader, $"/api/privapub/v1/statuses/{id}/leave", HttpMethod.Post);
|
|
||||||
await using var session = await SignIn.As(browser, reader);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.action.join", "mobilizon", "kept-event", async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/expand/{id}");
|
|
||||||
var card = page.Locator($"article[data-uri='{status["uri"]!.GetValue<string>()}']").First;
|
|
||||||
await card.Locator("[data-event-join]").ClickAsync(new() { Timeout = 30000 });
|
|
||||||
await VoteAndReactTests.AcceptNoticeIfAsked(page);
|
|
||||||
await card.Locator("[data-event-leave]").WaitForAsync(new() { Timeout = 15000 });
|
|
||||||
Assert.True(await Pasture.Eventually(async () => await Participation() == "accepted", 45), "Mobilizon never accepted the Join");
|
|
||||||
Assert.Equal("participant", await MobilizonRole(reader.ActorUri, url));
|
|
||||||
|
|
||||||
await page.ReloadAsync();
|
|
||||||
await Assertions.Expect(card.Locator("[data-participation]")).ToHaveAttributeAsync("data-participation", "accepted", new() { Timeout = 30000 });
|
|
||||||
await card.Locator("[data-event-leave]").ClickAsync();
|
|
||||||
await Assertions.Expect(card.Locator("[data-participation]")).ToHaveAttributeAsync("data-participation", "none", new() { Timeout = 15000 });
|
|
||||||
Assert.Null(await Participation());
|
|
||||||
Assert.True(await Pasture.Eventually(async () => await MobilizonRole(reader.ActorUri, url) == "", 45),
|
|
||||||
"the persona is still a participant on Mobilizon");
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
// the persona's role in the event, from Mobilizon's database; empty when it takes no part
|
|
||||||
static async Task<string> MobilizonRole(string actorUri, string eventUrl)
|
|
||||||
{
|
|
||||||
static string Quoted(string value) => "'" + value.Replace("'", "''") + "'";
|
|
||||||
var start = new ProcessStartInfo("podman") { RedirectStandardOutput = true, RedirectStandardError = true };
|
|
||||||
foreach (var arg in new[] { "exec", "pasture-postgis", "psql", "-U", "pasture", "-d", "mobilizon", "-tAc",
|
|
||||||
"select p.role from participants p join actors a on a.id = p.actor_id join events e on e.id = p.event_id " +
|
|
||||||
$"where a.url = {Quoted(actorUri)} and e.url = {Quoted(eventUrl)}" })
|
|
||||||
start.ArgumentList.Add(arg);
|
|
||||||
using var process = Process.Start(start)!;
|
|
||||||
var output = await process.StandardOutput.ReadToEndAsync();
|
|
||||||
await process.WaitForExitAsync();
|
|
||||||
return output.Trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// A filter in decePub: made on /filters with a word only one post has, which the home feed then folds behind the
|
|
||||||
// filter's title (warn) and leaves out once the filter hides; PrivaPub keeps what the page sent.
|
|
||||||
public sealed class FilterTests(Browser browser)
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task A_filters_word_folds_then_hides_a_post_in_the_home_feed()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var word = $"zq{Guid.NewGuid():N}"[..10];
|
|
||||||
var title = $"e2e {word}";
|
|
||||||
foreach (var left in (await PersonaApi.Call(persona, "/api/v2/filters"))!.AsArray().Where(f => f!["title"]!.GetValue<string>().StartsWith("e2e ")))
|
|
||||||
await PersonaApi.Call(persona, $"/api/v2/filters/{left!["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
|
||||||
var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = $"a post about {word}", ["visibility"] = "unlisted" }))!;
|
|
||||||
var uri = post["uri"]!.GetValue<string>();
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.action.filter", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/filters");
|
|
||||||
await page.Locator("input[data-filter-new]").FillAsync(title);
|
|
||||||
await page.GetByRole(AriaRole.Button, new() { Name = "Make a filter" }).ClickAsync();
|
|
||||||
var article = page.Locator("article[data-filter-id]").Filter(new() { HasText = title });
|
|
||||||
await article.Locator("input[data-filter-keyword-new]").FillAsync(word);
|
|
||||||
await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
|
||||||
var filterId = await article.GetAttributeAsync("data-filter-id");
|
|
||||||
Assert.True(await Pasture.Eventually(async () => (await PersonaApi.Call(persona, $"/api/v2/filters/{filterId}"))?["keywords"]?.AsArray()
|
|
||||||
.Any(k => k!["keyword"]!.GetValue<string>() == word) == true, 15), "PrivaPub never got the filter's word");
|
|
||||||
|
|
||||||
// warn: the card is folded behind the filter's title, and opens on request
|
|
||||||
// the feed opens on its default tab, Home, when no tab is remembered
|
|
||||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
var card = page.Locator($"article[data-uri='{uri}']");
|
|
||||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await Assertions.Expect(card.Locator("[data-filtered]")).ToContainTextAsync(title);
|
|
||||||
await Assertions.Expect(card).Not.ToContainTextAsync($"a post about {word}");
|
|
||||||
await card.GetByRole(AriaRole.Button, new() { Name = "Show anyway" }).ClickAsync();
|
|
||||||
await Assertions.Expect(card).ToContainTextAsync($"a post about {word}");
|
|
||||||
|
|
||||||
// hide: the card is left out
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/filters");
|
|
||||||
article = page.Locator($"article[data-filter-id='{filterId}']");
|
|
||||||
await article.GetByRole(AriaRole.Button, new() { Name = "Edit" }).ClickAsync();
|
|
||||||
await article.Locator("select").First.SelectOptionAsync("Hide");
|
|
||||||
await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(async () => (await PersonaApi.Call(persona, $"/api/v2/filters/{filterId}"))?["filter_action"]?
|
|
||||||
.GetValue<string>() == "hide", 15), "PrivaPub never made the filter hide");
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await Assertions.Expect(page.Locator($"article[data-uri='{uri}']")).ToHaveCountAsync(0);
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{post["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
|
||||||
foreach (var made in (await PersonaApi.Call(persona, "/api/v2/filters"))!.AsArray().Where(f => f!["title"]!.GetValue<string>() == title))
|
|
||||||
await PersonaApi.Call(persona, $"/api/v2/filters/{made!["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// A list in decePub: made on /lists, filled with an account the persona follows (found by its name among the accounts
|
|
||||||
// it follows), read as the feed's Lists tab, made exclusive and deleted. Each step is checked against PrivaPub's own
|
|
||||||
// answer to the persona's token, so the page and the server agree on the list, its members and its posts.
|
|
||||||
public sealed class ListTests(Browser browser)
|
|
||||||
{
|
|
||||||
static Task<JsonNode> Api(Pasture.Persona persona, string path, HttpMethod method = null, JsonObject body = null) =>
|
|
||||||
PersonaApi.Call(persona, path, method, body);
|
|
||||||
|
|
||||||
// an account the persona follows that has written something
|
|
||||||
static async Task<JsonNode> Followed(Pasture.Persona persona) =>
|
|
||||||
(await Api(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=80"))!.AsArray()
|
|
||||||
.FirstOrDefault(a => a!["statuses_count"]!.GetValue<int>() > 0 && !a["acct"]!.GetValue<string>().StartsWith("st_"));
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task An_account_joins_and_leaves_a_list_from_its_profile()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var member = await Followed(persona);
|
|
||||||
Assert.SkipWhen(member is null, $"{persona.UserName} follows nobody who has posted");
|
|
||||||
var memberId = member!["id"]!.GetValue<string>();
|
|
||||||
var listId = (await Api(persona, "/api/v1/lists", HttpMethod.Post, new JsonObject { ["title"] = $"e2e {Guid.NewGuid():N}"[..12] }))!["id"]!.GetValue<string>();
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.action.list-member", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
async Task<bool> InList() =>
|
|
||||||
(await Api(persona, $"/api/v1/lists/{listId}/accounts"))!.AsArray().Any(a => a!["id"]!.GetValue<string>() == memberId);
|
|
||||||
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/account/{memberId}");
|
|
||||||
var profile = page.Locator("article[data-account-id]").First;
|
|
||||||
await profile.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await profile.GetByRole(AriaRole.Button, new() { Name = "Lists" }).ClickAsync();
|
|
||||||
var box = profile.Locator($"[data-list-id='{listId}'] input");
|
|
||||||
await box.CheckAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(InList, 15), "the profile's checkbox never put the account in the list");
|
|
||||||
await box.UncheckAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(async () => !await InList(), 15), "the profile's checkbox never took the account out");
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await Api(persona, $"/api/v1/lists/{listId}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_list_is_made_filled_read_as_a_feed_and_deleted()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var member = await Followed(persona);
|
|
||||||
Assert.SkipWhen(member is null, $"{persona.UserName} follows nobody who has posted");
|
|
||||||
var memberId = member!["id"]!.GetValue<string>();
|
|
||||||
var title = $"e2e {Guid.NewGuid():N}"[..12];
|
|
||||||
// lists an earlier failed run left behind
|
|
||||||
foreach (var left in (await Api(persona, "/api/v1/lists"))!.AsArray().Where(l => l!["title"]!.GetValue<string>().StartsWith("e2e ")))
|
|
||||||
await Api(persona, $"/api/v1/lists/{left!["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.action.list", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/lists");
|
|
||||||
await page.Locator("[data-list-new] input, input[data-list-new]").First.FillAsync(title);
|
|
||||||
await page.GetByRole(AriaRole.Button, new() { Name = "Make a list" }).ClickAsync();
|
|
||||||
var article = page.Locator("article[data-list-id]").Filter(new() { HasText = title });
|
|
||||||
await article.WaitForAsync(new() { Timeout = 15000 });
|
|
||||||
var listId = await article.GetAttributeAsync("data-list-id");
|
|
||||||
Assert.Contains((await Api(persona, "/api/v1/lists"))!.AsArray(), l => l!["id"]!.GetValue<string>() == listId);
|
|
||||||
|
|
||||||
// the list opens once made: find the member among the accounts followed, and add it
|
|
||||||
await article.Locator("[data-list-find] input, input[data-list-find]").First.FillAsync(member["username"]!.GetValue<string>());
|
|
||||||
await article.Locator("[data-list-find] input, input[data-list-find]").First.PressAsync("Enter");
|
|
||||||
var candidate = article.Locator($"[data-candidate-id='{memberId}']");
|
|
||||||
await candidate.WaitForAsync(new() { Timeout = 15000 });
|
|
||||||
await candidate.GetByRole(AriaRole.Button, new() { Name = "Add" }).ClickAsync();
|
|
||||||
await article.Locator($"[data-member-id='{memberId}']").WaitForAsync(new() { Timeout = 15000 });
|
|
||||||
Assert.Equal([memberId], (await Api(persona, $"/api/v1/lists/{listId}/accounts"))!.AsArray().Select(a => a!["id"]!.GetValue<string>()));
|
|
||||||
|
|
||||||
// read as a feed: the cards are the list timeline's first page, as PrivaPub answers it
|
|
||||||
await article.GetByRole(AriaRole.Button, new() { Name = "Read the list" }).ClickAsync();
|
|
||||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
var expected = (await Api(persona, $"/api/v1/timelines/list/{listId}?limit=20"))!.AsArray()
|
|
||||||
.Select(s => (s!["reblog"] ?? s)["uri"]!.GetValue<string>()).ToHashSet();
|
|
||||||
var shown = await page.Locator("article[data-uri]").EvaluateAllAsync<string[]>("cards => cards.map(c => c.dataset.uri)");
|
|
||||||
Assert.NotEmpty(expected);
|
|
||||||
Assert.Subset(expected, shown.ToHashSet());
|
|
||||||
|
|
||||||
// exclusive, then deleted
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/lists");
|
|
||||||
article = page.Locator($"article[data-list-id='{listId}']");
|
|
||||||
await article.GetByRole(AriaRole.Button, new() { Name = "Edit" }).ClickAsync();
|
|
||||||
await article.GetByLabel("Keep its members out of the home feed").CheckAsync();
|
|
||||||
await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(async () => (await Api(persona, $"/api/v1/lists/{listId}"))?["exclusive"]?.GetValue<bool>() == true, 15),
|
|
||||||
"PrivaPub never made the list exclusive");
|
|
||||||
// the icon's glyph (CSS content) is part of a button's name, so names are matched as substrings
|
|
||||||
await article.GetByRole(AriaRole.Button, new() { Name = "Delete" }).First.ClickAsync();
|
|
||||||
await article.GetByRole(AriaRole.Button, new() { Name = "Delete it for good" }).ClickAsync();
|
|
||||||
await Assertions.Expect(article).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
|
||||||
Assert.Null(await Api(persona, $"/api/v1/lists/{listId}"));
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// Media from the composer and the profile, checked on PrivaPub: a picture uploaded as soon as it is picked and posted
|
|
||||||
// with its description, the reminder before a picture without one, a failed upload retried without the rest being sent
|
|
||||||
// again, and an avatar set and removed
|
|
||||||
public sealed class MediaTests(Browser browser)
|
|
||||||
{
|
|
||||||
// a 1×1 PNG
|
|
||||||
static readonly byte[] Png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==");
|
|
||||||
|
|
||||||
static async Task OpenComposer(IPage page)
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
static Task Attach(IPage page, string name) =>
|
|
||||||
page.Locator("input[data-attach]").SetInputFilesAsync(new FilePayload { Name = name, MimeType = "image/png", Buffer = Png });
|
|
||||||
|
|
||||||
static ILocator Submit(IPage page) => page.Locator("form:has(textarea[data-compose]) button[type=submit]").First;
|
|
||||||
|
|
||||||
static async Task<JsonNode> Published(Pasture.Persona persona, string text)
|
|
||||||
{
|
|
||||||
JsonNode latest = null;
|
|
||||||
Assert.True(await Pasture.Eventually(async () =>
|
|
||||||
(latest = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault())?["content"]
|
|
||||||
?.GetValue<string>().Contains(text) == true, 15), "the post was never published");
|
|
||||||
return latest;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_picture_is_uploaded_when_picked_and_posted_with_its_description()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var text = $"a picture {Guid.NewGuid():N}"[..20];
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
string made = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.action.media", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await OpenComposer(page);
|
|
||||||
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
|
||||||
await Attach(page, "dot.png");
|
|
||||||
// uploaded on its own, before anything is published
|
|
||||||
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
|
||||||
await page.Locator("[data-alt-text]").First.FillAsync("a single dot");
|
|
||||||
await Submit(page).ClickAsync();
|
|
||||||
|
|
||||||
var post = await Published(persona, text);
|
|
||||||
made = post["id"]!.GetValue<string>();
|
|
||||||
Assert.Equal("a single dot", post["media_attachments"]![0]!["description"]!.GetValue<string>());
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (made is not null)
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_picture_without_a_description_is_asked_about_once()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var text = $"undescribed {Guid.NewGuid():N}"[..22];
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
string made = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.action.media-reminder", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await OpenComposer(page);
|
|
||||||
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
|
||||||
await Attach(page, "dot.png");
|
|
||||||
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
|
||||||
await Submit(page).ClickAsync();
|
|
||||||
|
|
||||||
await Assertions.Expect(page.Locator("[data-alt-reminder]")).ToBeVisibleAsync();
|
|
||||||
await page.Locator("[data-alt-publish-anyway]").ClickAsync();
|
|
||||||
|
|
||||||
var post = await Published(persona, text);
|
|
||||||
made = post["id"]!.GetValue<string>();
|
|
||||||
Assert.Null(post["media_attachments"]![0]!["description"]?.GetValue<string>());
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (made is not null)
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_failed_upload_is_retried_alone()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var text = $"retried {Guid.NewGuid():N}"[..18];
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
string made = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.action.media-retry", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
// the second upload is refused once, as a server would refuse it
|
|
||||||
var uploads = 0;
|
|
||||||
await page.RouteAsync("**/api/v2/media", async route =>
|
|
||||||
{
|
|
||||||
if (Interlocked.Increment(ref uploads) == 2)
|
|
||||||
await route.FulfillAsync(new() { Status = 422, ContentType = "application/json", Body = "{\"error\":\"Validation failed: try again\"}" });
|
|
||||||
else
|
|
||||||
await route.ContinueAsync();
|
|
||||||
});
|
|
||||||
await OpenComposer(page);
|
|
||||||
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
|
||||||
await Attach(page, "first.png");
|
|
||||||
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
|
||||||
await Attach(page, "second.png");
|
|
||||||
await Assertions.Expect(page.Locator("[data-upload-state=failed]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
|
||||||
await Assertions.Expect(page.Locator("[data-upload-state=failed]")).ToContainTextAsync("try again");
|
|
||||||
|
|
||||||
await page.Locator("[data-upload-retry]").ClickAsync();
|
|
||||||
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(2, new() { Timeout = 20000 });
|
|
||||||
foreach (var alt in await page.Locator("[data-alt-text]").AllAsync())
|
|
||||||
await alt.FillAsync("a dot");
|
|
||||||
await Submit(page).ClickAsync();
|
|
||||||
|
|
||||||
var post = await Published(persona, text);
|
|
||||||
made = post["id"]!.GetValue<string>();
|
|
||||||
Assert.Equal(2, post["media_attachments"]!.AsArray().Count);
|
|
||||||
// the first file was sent once: one upload for it, two for the second (refused, then retried)
|
|
||||||
Assert.Equal(3, uploads);
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (made is not null)
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task An_avatar_is_set_and_removed_from_settings()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().Last();
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.action.avatar", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
|
||||||
var header = page.Locator(".collapse-title", new() { HasText = "Profile" }).First;
|
|
||||||
await header.ClickAsync();
|
|
||||||
var editor = header.Locator("xpath=..").Locator("[data-profile-editor]");
|
|
||||||
await editor.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
|
|
||||||
await editor.Locator("input[data-profile-avatar]").SetInputFilesAsync(new FilePayload { Name = "me.png", MimeType = "image/png", Buffer = Png });
|
|
||||||
await editor.Locator("[data-profile-save]").ClickAsync();
|
|
||||||
await Assertions.Expect(page.GetByText("Profile saved.")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
|
||||||
var set = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["avatar"]!.GetValue<string>();
|
|
||||||
Assert.DoesNotContain("missing", set);
|
|
||||||
|
|
||||||
await editor.Locator("[data-profile-avatar-remove]").ClickAsync();
|
|
||||||
await Assertions.Expect(page.GetByText("Avatar removed.")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
|
||||||
Assert.Contains("missing", (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["avatar"]!.GetValue<string>());
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// The persona's notification policy from Settings: people it does not follow are filtered, a like from one waits in a
|
|
||||||
// request the Notifications tab leads to, and letting the request in brings the notification back; the policy is put back to accepting everything, and
|
|
||||||
// the stranger's permission is taken away again
|
|
||||||
public sealed class NotificationPolicyTests(Browser browser)
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task A_strangers_like_waits_in_a_request_the_persona_lets_in()
|
|
||||||
{
|
|
||||||
var personas = Pasture.Personas();
|
|
||||||
var author = personas[0];
|
|
||||||
// (a persona of the town the author does not follow)
|
|
||||||
var stranger = default(Pasture.Persona);
|
|
||||||
foreach (var candidate in personas.Skip(1))
|
|
||||||
if ((await PersonaApi.Call(author, $"/api/v1/accounts/relationships?id[]={candidate.LocalId}"))![0]!["following"]!.GetValue<bool>() == false)
|
|
||||||
{
|
|
||||||
stranger = candidate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Assert.SkipWhen(stranger is null, "the town's first persona follows every other");
|
|
||||||
var strangerAcct = (await PersonaApi.Call(stranger!, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue<string>();
|
|
||||||
var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
|
|
||||||
new JsonObject { ["status"] = $"like me, stranger {Guid.NewGuid():N}"[..30], ["visibility"] = "public" }))!;
|
|
||||||
var id = post["id"]!.GetValue<string>();
|
|
||||||
await using var session = await SignIn.As(browser, author);
|
|
||||||
var page = session.Page;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.action.notification-policy", "privapub", author.UserName, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
|
||||||
var header = page.Locator(".collapse-title", new() { HasText = "Notifications" }).First;
|
|
||||||
await header.ClickAsync();
|
|
||||||
var panel = page.Locator("[data-notification-policy]");
|
|
||||||
await panel.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await panel.Locator("[data-policy='for_not_following']").SelectOptionAsync("filter");
|
|
||||||
await panel.Locator("[data-policy-save]").ClickAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(async () =>
|
|
||||||
(await PersonaApi.Call(author, "/api/v2/notifications/policy"))!["for_not_following"]!.GetValue<string>() == "filter", 15), "the policy was never saved");
|
|
||||||
|
|
||||||
await PersonaApi.Call(stranger!, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
|
|
||||||
Assert.DoesNotContain((await PersonaApi.Call(author, "/api/v1/notifications?types[]=favourite"))!.AsArray(),
|
|
||||||
n => n!["account"]!["acct"]!.GetValue<string>() == strangerAcct && n["status"]?["id"]?.GetValue<string>() == id);
|
|
||||||
|
|
||||||
// the Notifications tab says requests are waiting, and leads to them
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.GetByRole(AriaRole.Tab).Filter(new() { Has = page.Locator(".ion-md-notifications") }).ClickAsync();
|
|
||||||
await page.Locator("[data-filtered-requests]").ClickAsync(new() { Timeout = 30000 });
|
|
||||||
var request = page.Locator($"[data-notification-requests] [data-request='{strangerAcct}']");
|
|
||||||
await request.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await request.Locator("[data-request-accept]").ClickAsync();
|
|
||||||
await Assertions.Expect(request).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
|
||||||
Assert.Contains((await PersonaApi.Call(author, "/api/v1/notifications?types[]=favourite"))!.AsArray(),
|
|
||||||
n => n!["account"]!["acct"]!.GetValue<string>() == strangerAcct && n["status"]?["id"]?.GetValue<string>() == id);
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await PersonaApi.Call(author, "/api/v2/notifications/policy", HttpMethod.Patch, new JsonObject { ["for_not_following"] = "accept" });
|
|
||||||
// (letting a request in lets its account in for good, which no API takes back)
|
|
||||||
await Pasture.Mongo($"db.NotificationPermission.deleteMany({{AvatarId: '{author.LocalId}', FromActorURI: '{stranger!.ActorUri}'}})");
|
|
||||||
await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// Notifications as groups: two likes of one post are one card naming both, the tab's badge counts what is unread, and
|
|
||||||
// reading the tab moves the marker so PrivaPub counts nothing unread
|
|
||||||
public sealed class NotificationTests(Browser browser)
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task Two_likes_of_one_post_are_one_card_and_reading_them_clears_the_badge()
|
|
||||||
{
|
|
||||||
var personas = Pasture.Personas();
|
|
||||||
Assert.SkipWhen(personas.Count < 3, "the town has fewer than three personas");
|
|
||||||
var (author, first, second) = (personas[0], personas[1], personas[2]);
|
|
||||||
var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
|
|
||||||
new JsonObject { ["status"] = $"like me {Guid.NewGuid():N}"[..16], ["visibility"] = "public" }))!;
|
|
||||||
var id = post["id"]!.GetValue<string>();
|
|
||||||
await using var session = await SignIn.As(browser, author);
|
|
||||||
var page = session.Page;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await PersonaApi.Call(first, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
|
|
||||||
await PersonaApi.Call(second, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
|
|
||||||
await Results.Cell("client.render.notification-group", "privapub", author.UserName, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.Locator("[data-unread]").WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
|
|
||||||
await page.GetByRole(AriaRole.Tab).Filter(new() { Has = page.Locator(".ion-md-notifications") }).ClickAsync();
|
|
||||||
var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").Filter(new() { Has = page.Locator("[data-notification='favourite']") });
|
|
||||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await Assertions.Expect(card.Locator("[data-notification='favourite']")).ToHaveAttributeAsync("data-count", "2");
|
|
||||||
await Assertions.Expect(card).ToContainTextAsync($"{second.Name} and {first.Name} favourited your post");
|
|
||||||
Assert.True(await Pasture.Eventually(async () =>
|
|
||||||
(await PersonaApi.Call(author, "/api/v2/notifications/unread_count"))!["count"]!.GetValue<int>() == 0, 15), "reading the tab left notifications unread");
|
|
||||||
await Assertions.Expect(page.Locator("[data-unread]")).ToHaveCountAsync(0);
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
using System.IO.Compression;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// A persona's archive from Settings (owner decision 2026-10-07): one town persona's archive made and downloaded through
|
|
||||||
// its link, then imported into another, whose profile shows the posts while nobody was told; the copies are taken away
|
|
||||||
// again after, so the town stays as seeded
|
|
||||||
public sealed class PersonaArchiveTests(Browser browser)
|
|
||||||
{
|
|
||||||
static async Task<ILocator> Open(IPage page)
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
|
||||||
var header = page.Locator(".collapse-title", new() { HasText = "Data import/export" }).First;
|
|
||||||
await header.ClickAsync();
|
|
||||||
var panel = header.Locator("xpath=..").Locator("[data-archive]");
|
|
||||||
await panel.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
return panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task An_archive_is_downloaded_and_imported_into_another_persona()
|
|
||||||
{
|
|
||||||
var personas = Pasture.Personas();
|
|
||||||
var (source, target) = (personas[0], personas[1]);
|
|
||||||
var file = Path.Combine(Path.GetTempPath(), $"decepub-archive-{Guid.NewGuid():N}.zip");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using (var session = await SignIn.As(browser, source))
|
|
||||||
{
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.action.archive-export", "privapub", source.UserName, async () =>
|
|
||||||
{
|
|
||||||
var panel = await Open(page);
|
|
||||||
await panel.Locator("[data-archive-make]").ClickAsync();
|
|
||||||
var get = panel.Locator("[data-archive-get-link]");
|
|
||||||
await get.WaitForAsync(new() { Timeout = 120000 });
|
|
||||||
await get.ClickAsync();
|
|
||||||
var link = panel.Locator("[data-archive-link]");
|
|
||||||
var download = await page.RunAndWaitForDownloadAsync(() => link.ClickAsync(), new() { Timeout = 120000 });
|
|
||||||
await download.SaveAsAsync(file);
|
|
||||||
using var archive = ZipFile.OpenRead(file);
|
|
||||||
Assert.Contains(archive.Entries, e => e.FullName == "outbox.json");
|
|
||||||
Assert.Contains(archive.Entries, e => e.FullName == "actor.json");
|
|
||||||
Assert.DoesNotContain(archive.Entries, e => e.FullName.Contains(source.Root, StringComparison.Ordinal));
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
await using (var session = await SignIn.As(browser, target))
|
|
||||||
{
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.action.archive-import", "privapub", target.UserName, async () =>
|
|
||||||
{
|
|
||||||
var panel = await Open(page);
|
|
||||||
await panel.Locator("input[data-archive-file]").SetInputFilesAsync(file);
|
|
||||||
foreach (var part in new[] { "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags", "pins" })
|
|
||||||
await panel.Locator($"[data-archive-part='{part}'] input, input[data-archive-part='{part}']").First.UncheckAsync();
|
|
||||||
await panel.Locator("[data-archive-import-start]").ClickAsync();
|
|
||||||
var report = panel.Locator("[data-archive-report][data-archive-state='done']");
|
|
||||||
await report.WaitForAsync(new() { Timeout = 300000 });
|
|
||||||
await Assertions.Expect(report.Locator("[data-archive-count='posts']")).ToBeVisibleAsync();
|
|
||||||
|
|
||||||
var copies = (await PersonaApi.Call(target, $"/api/v1/accounts/{target.LocalId}/statuses?limit=40"))!.AsArray();
|
|
||||||
Assert.NotEmpty(copies);
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
File.Delete(file);
|
|
||||||
// the copies go, as if never imported: the town stays as seeded
|
|
||||||
await Pasture.Mongo($"const copies = db.Post.find({{GroupUserId:'{target.LocalId}', ImportedFromURI:{{$exists:true, $ne:null}}}}, {{_id:1}}).toArray().map(p => p._id); "
|
|
||||||
+ "db.MediaAttachment.deleteMany({PostId:{$in:copies.map(id => id.toString())}}); db.Post.deleteMany({_id:{$in:copies}})");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// A persona pins its own post from the card, finds it above its posts on its profile, and unpins it. PrivaPub tells
|
|
||||||
// the persona's followers with Add and Remove; SocialPub's tools/pasture/scenarios/pins.sh checks that side.
|
|
||||||
public sealed class PinTests(Browser browser)
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task A_persona_pins_its_post_from_the_card_and_its_profile_shows_it_first()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var text = $"pin me {Guid.NewGuid():N}"[..20];
|
|
||||||
var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "public" }))!;
|
|
||||||
var id = post["id"]!.GetValue<string>();
|
|
||||||
var me = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["id"]!.GetValue<string>();
|
|
||||||
async Task<bool> Pinned() =>
|
|
||||||
(await PersonaApi.Call(persona, $"/api/v1/accounts/{me}/statuses?pinned=true"))!.AsArray().Any(s => s!["id"]!.GetValue<string>() == id);
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.action.pin", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/account/{me}");
|
|
||||||
var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").First;
|
|
||||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
|
|
||||||
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
|
||||||
await card.Locator("button[data-pin]").ClickAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(Pinned, 15), "the pin never reached PrivaPub");
|
|
||||||
|
|
||||||
await page.ReloadAsync();
|
|
||||||
var pinnedCard = page.Locator($"[data-pinned] article[data-uri='{post["uri"]!.GetValue<string>()}']");
|
|
||||||
await pinnedCard.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
Assert.Equal(1, await page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").CountAsync());
|
|
||||||
|
|
||||||
await pinnedCard.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
|
||||||
await Assertions.Expect(pinnedCard.Locator("button[data-pin]")).ToContainTextAsync("Unpin", new() { Timeout = 5000 });
|
|
||||||
await pinnedCard.Locator("button[data-pin]").ClickAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(async () => !await Pinned(), 15), "the unpin never reached PrivaPub");
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// Accounts in decePub: found by searching their handle (PrivaPub resolves it), opened as a profile, followed and
|
|
||||||
// unfollowed there; the follow is checked on the account's own server, where the persona must appear as a follower.
|
|
||||||
public sealed class ProfileTests(Browser browser)
|
|
||||||
{
|
|
||||||
static readonly string[] Platforms = ["gts", "mastodon", "akkoma", "misskey", "sharkey"];
|
|
||||||
|
|
||||||
public static TheoryData<string> Origins() => new(Platforms);
|
|
||||||
|
|
||||||
// an account of the platform that the persona does not follow yet, from the town's own sessions
|
|
||||||
static (string Platform, string UserName, string Token) Stranger(string platform, Pasture.Persona persona)
|
|
||||||
{
|
|
||||||
var sessions = JsonNode.Parse(File.ReadAllText(Path.Combine(Pasture.Root, ".state", "town", "sessions.json")))!.AsObject();
|
|
||||||
var candidates = sessions.Where(kv => kv.Key.StartsWith(platform + "/") && !kv.Key.Contains("/st_"))
|
|
||||||
.Select(kv => (platform, kv.Value!["account"]!["username"]!.GetValue<string>(), kv.Value!["token"]!.GetValue<string>()))
|
|
||||||
.ToList();
|
|
||||||
return candidates.Last();
|
|
||||||
}
|
|
||||||
|
|
||||||
// the newest GoToSocial account that SocialPub's moves scenario moved (scenarios/moves.sh), as PrivaPub knows it
|
|
||||||
static async Task<(string Old, string New)> Moved()
|
|
||||||
{
|
|
||||||
var start = new System.Diagnostics.ProcessStartInfo("podman") { RedirectStandardOutput = true, RedirectStandardError = true };
|
|
||||||
foreach (var arg in new[] { "exec", "pasture-mongo", "mongosh", "--quiet", "PrivaPub", "--eval",
|
|
||||||
"const a = db.ForeignAvatar.find({ActorURI: {$regex: 'gts.test.users.mvold'}, MovedToURL: {$ne: null}}).sort({_id: -1}).limit(1).toArray()[0];" +
|
|
||||||
"print(a ? a.ActorURI + ' ' + a.MovedToURL : '')" })
|
|
||||||
start.ArgumentList.Add(arg);
|
|
||||||
using var process = System.Diagnostics.Process.Start(start)!;
|
|
||||||
var output = (await process.StandardOutput.ReadToEndAsync()).Trim();
|
|
||||||
await process.WaitForExitAsync();
|
|
||||||
var parts = output.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
return parts.Length == 2 ? (parts[0], parts[1]) : default;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_moved_accounts_profile_says_where_it_went()
|
|
||||||
{
|
|
||||||
var (old, moved) = await Moved();
|
|
||||||
Assert.SkipWhen(old is null, "no moved account: run SocialPub's tools/pasture/interop.sh moves");
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var handle = old.Split("/users/")[1] + "@gts.test";
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
await Results.Cell("client.render.moved", "gts", handle, async () =>
|
|
||||||
{
|
|
||||||
await session.Page.GotoAsync($"{Pasture.Site}/search?q={Uri.EscapeDataString("@" + handle)}");
|
|
||||||
var banner = session.Page.Locator("[data-moved]");
|
|
||||||
await banner.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await Assertions.Expect(banner).ToContainTextAsync("@" + moved.Split("/users/")[1] + "@gts.test");
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, session.Page);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[MemberData(nameof(Origins))]
|
|
||||||
public async Task A_remote_account_found_by_its_handle_is_followed_and_unfollowed_from_its_profile(string platform)
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var (_, userName, _) = Stranger(platform, persona);
|
|
||||||
var handle = $"{userName}@{platform}.test";
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
await Results.Cell("client.action.follow", platform, handle, async () =>
|
|
||||||
{
|
|
||||||
await session.Page.GotoAsync($"{Pasture.Site}/search?q={Uri.EscapeDataString("@" + handle)}");
|
|
||||||
var profile = session.Page.Locator("article[data-account-id]").First;
|
|
||||||
await profile.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await Assertions.Expect(profile).ToContainTextAsync("@" + handle);
|
|
||||||
|
|
||||||
var follow = profile.GetByRole(AriaRole.Button).Filter(new() { HasTextRegex = new System.Text.RegularExpressions.Regex("^(Follow|Unfollow|Cancel the request)$") }).First;
|
|
||||||
if ((await follow.InnerTextAsync()).Trim() != "Follow")
|
|
||||||
{
|
|
||||||
await follow.ClickAsync();
|
|
||||||
await Assertions.Expect(follow).ToHaveTextAsync("Follow", new() { Timeout = 15000 });
|
|
||||||
}
|
|
||||||
await follow.ClickAsync();
|
|
||||||
await Assertions.Expect(follow).Not.ToHaveTextAsync("Follow", new() { Timeout = 15000 });
|
|
||||||
Assert.True(await Pasture.Eventually(async () => await FollowedOn(platform, userName, persona.UserName), 45),
|
|
||||||
$"{handle} never learnt that {persona.UserName} follows it");
|
|
||||||
|
|
||||||
await follow.ClickAsync();
|
|
||||||
await Assertions.Expect(follow).ToHaveTextAsync("Follow", new() { Timeout = 15000 });
|
|
||||||
Assert.True(await Pasture.Eventually(async () => !await FollowedOn(platform, userName, persona.UserName), 45),
|
|
||||||
$"{handle} still has {persona.UserName} as a follower after the unfollow");
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, session.Page);
|
|
||||||
}
|
|
||||||
|
|
||||||
// whether the peer itself lists the persona as following (or asking to follow) its account
|
|
||||||
static async Task<bool> FollowedOn(string platform, string userName, string persona)
|
|
||||||
{
|
|
||||||
var result = await Pasture.Town("drive", platform, userName, "relationship", $"{{\"acct\": \"{persona}@privapub.test\"}}");
|
|
||||||
return result?["followed_by"]?.GetValue<bool>() == true || (await Pasture.Town("drive", platform, userName, "pending", "{}"))
|
|
||||||
.AsArray().Any(a => a?.GetValue<string>()?.StartsWith(persona + "@") == true);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task The_personas_own_profile_lists_its_posts()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
await Results.Cell("client.render.profile", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await session.Page.GotoAsync($"{Pasture.Site}/account/{persona.LocalId}");
|
|
||||||
var profile = session.Page.Locator("article[data-account-id]").First;
|
|
||||||
await profile.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await Assertions.Expect(profile).ToContainTextAsync("@" + persona.UserName);
|
|
||||||
await session.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, session.Page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// A persona reports a remote account from its profile, with a category, its words and a copy to the account's server:
|
|
||||||
// PrivaPub's moderators see it, forwarded (/clientapi/moderator/reports). And who liked or boosted a post is listed
|
|
||||||
// from its card
|
|
||||||
public sealed class ReportTests(Browser browser)
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task A_remote_account_is_reported_from_its_profile()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var target = (await PersonaApi.Call(persona, "/api/v2/search?q=mastouser%40mastodon.test&resolve=true&type=accounts"))?["accounts"]?.AsArray().FirstOrDefault();
|
|
||||||
Assert.SkipWhen(target is null, "no Mastodon: run tools/pasture/run.sh add mastodon");
|
|
||||||
var targetId = target!["id"]!.GetValue<string>();
|
|
||||||
var words = $"reported by the e2e tests {Guid.NewGuid():N}"[..40];
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.action.report", "mastodon", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/account/{Uri.EscapeDataString(targetId)}");
|
|
||||||
// (the profile's own button: each of its posts' cards has one too)
|
|
||||||
await page.Locator($"article[data-account-id='{targetId}'] button[data-report]").ClickAsync(new() { Timeout = 30000 });
|
|
||||||
var dialog = page.Locator("[data-report-dialog]");
|
|
||||||
await dialog.Locator("[data-report-category]").SelectOptionAsync("Spam");
|
|
||||||
await dialog.Locator("[data-report-comment]").FillAsync(words);
|
|
||||||
await Assertions.Expect(dialog.Locator("[data-report-forward]")).ToBeCheckedAsync();
|
|
||||||
await dialog.Locator("[data-report-send]").ClickAsync();
|
|
||||||
await Assertions.Expect(dialog).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
|
||||||
|
|
||||||
var report = (await PastureAdmin.Get("/clientapi/moderator/reports?resolved=false"))!.AsArray()
|
|
||||||
.FirstOrDefault(r => r!["comment"]?.GetValue<string>() == words);
|
|
||||||
Assert.NotNull(report);
|
|
||||||
Assert.Equal((targetId, "spam", true), (report!["targetAccountId"]!.GetValue<string>(), report["category"]!.GetValue<string>(),
|
|
||||||
report["forwarded"]!.GetValue<bool>()));
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Who_liked_and_boosted_a_post_is_listed_from_its_card()
|
|
||||||
{
|
|
||||||
var personas = Pasture.Personas();
|
|
||||||
Assert.SkipWhen(personas.Count < 2, "the town has one persona");
|
|
||||||
var (author, fan) = (personas[0], personas[1]);
|
|
||||||
var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
|
|
||||||
new JsonObject { ["status"] = $"like and boost me {Guid.NewGuid():N}"[..28], ["visibility"] = "public" }))!;
|
|
||||||
var id = post["id"]!.GetValue<string>();
|
|
||||||
var fanAcct = (await PersonaApi.Call(fan, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue<string>();
|
|
||||||
await PersonaApi.Call(fan, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
|
|
||||||
await PersonaApi.Call(fan, $"/api/v1/statuses/{id}/reblog", HttpMethod.Post);
|
|
||||||
await using var session = await SignIn.As(browser, author);
|
|
||||||
var page = session.Page;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.render.interacted-by", "privapub", author.UserName, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/expand/{Uri.EscapeDataString(id)}");
|
|
||||||
var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").First;
|
|
||||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
foreach (var (button, list) in new[] { ("data-liked-by", "likes"), ("data-boosted-by", "boosts") })
|
|
||||||
{
|
|
||||||
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
|
||||||
await card.Locator($"button[{button}]").ClickAsync();
|
|
||||||
var dialog = card.Locator($"[data-interacted-by='{list}']");
|
|
||||||
await Assertions.Expect(dialog.Locator($"[data-account='{fanAcct}']")).ToHaveCountAsync(1, new() { Timeout = 15000 });
|
|
||||||
await page.Keyboard.PressAsync("Escape");
|
|
||||||
await Assertions.Expect(dialog).ToHaveCountAsync(0);
|
|
||||||
}
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// Settings as a persona: its profile saved (update_credentials) and read back from PrivaPub, a server blocked and
|
|
||||||
// unblocked, the follows exported and servers imported in Mastodon's files, and a new persona made; what a test changes
|
|
||||||
// is put back through the API afterwards, but for the persona (PrivaPub keeps every persona, so the test makes it under
|
|
||||||
// the scenarios' own root, never a town one)
|
|
||||||
public sealed class SettingsTests(Browser browser)
|
|
||||||
{
|
|
||||||
static async Task<ILocator> Open(IPage page, string section)
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
|
||||||
var header = page.Locator(".collapse-title", new() { HasText = section }).First;
|
|
||||||
await header.ClickAsync();
|
|
||||||
return header.Locator("xpath=..");
|
|
||||||
}
|
|
||||||
|
|
||||||
static JsonObject Restore(JsonNode account)
|
|
||||||
{
|
|
||||||
var fields = new JsonObject();
|
|
||||||
var source = account["source"]!;
|
|
||||||
var kept = source["fields"]!.AsArray();
|
|
||||||
for (var i = 0; i < 4; i++)
|
|
||||||
fields[i.ToString()] = new JsonObject
|
|
||||||
{
|
|
||||||
["name"] = i < kept.Count ? kept[i]!["name"]!.GetValue<string>() : "",
|
|
||||||
["value"] = i < kept.Count ? kept[i]!["value"]!.GetValue<string>() : ""
|
|
||||||
};
|
|
||||||
return new JsonObject
|
|
||||||
{
|
|
||||||
["display_name"] = account["display_name"]!.GetValue<string>(),
|
|
||||||
["note"] = source["note"]!.GetValue<string>(),
|
|
||||||
["locked"] = account["locked"]!.GetValue<bool>(),
|
|
||||||
["source"] = new JsonObject { ["privacy"] = source["privacy"]!.GetValue<string>() },
|
|
||||||
["fields_attributes"] = fields
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task The_personas_profile_is_saved_from_settings()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().Last();
|
|
||||||
var original = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!;
|
|
||||||
var mark = Guid.NewGuid().ToString("N")[..6];
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.action.profile-edit", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
var panel = await Open(page, "Profile");
|
|
||||||
var editor = panel.Locator("[data-profile-editor]");
|
|
||||||
await editor.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await Assertions.Expect(editor.Locator("[data-profile-name]")).ToHaveValueAsync(original["display_name"]!.GetValue<string>(),
|
|
||||||
new() { Timeout = 10000 });
|
|
||||||
|
|
||||||
await editor.Locator("[data-profile-name]").FillAsync($"Edited {mark}");
|
|
||||||
await editor.Locator("[data-profile-note]").FillAsync($"A biography written in the e2e tests {mark}");
|
|
||||||
var row = editor.Locator("[data-profile-field='3'] input");
|
|
||||||
await row.Nth(0).FillAsync("e2e");
|
|
||||||
await row.Nth(1).FillAsync(mark);
|
|
||||||
await editor.Locator("[data-profile-privacy]").SelectOptionAsync("Unlisted");
|
|
||||||
await editor.Locator("[data-profile-save]").ClickAsync();
|
|
||||||
await Assertions.Expect(page.GetByText("Profile saved.")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
|
||||||
|
|
||||||
var saved = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!;
|
|
||||||
Assert.Equal($"Edited {mark}", saved["display_name"]!.GetValue<string>());
|
|
||||||
Assert.Equal($"A biography written in the e2e tests {mark}", saved["source"]!["note"]!.GetValue<string>());
|
|
||||||
Assert.Equal("unlisted", saved["source"]!["privacy"]!.GetValue<string>());
|
|
||||||
Assert.Contains(saved["source"]!["fields"]!.AsArray(), f => f!["name"]!.GetValue<string>() == "e2e" && f["value"]!.GetValue<string>() == mark);
|
|
||||||
// the persona chooser shows the new name at once
|
|
||||||
await Assertions.Expect(page.GetByText($"Edited {mark}", new() { Exact = true }).First).ToBeVisibleAsync(new() { Timeout = 5000 });
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await PersonaApi.Call(persona, "/api/v1/accounts/update_credentials", HttpMethod.Patch, Restore(original));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_server_is_blocked_and_unblocked_from_settings()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().Last();
|
|
||||||
const string server = "blocked-by-e2e.example";
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.action.domain-block", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
var panel = await Open(page, "Mutes/Blocks");
|
|
||||||
var servers = panel.Locator("[data-domain-blocks]");
|
|
||||||
await servers.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
|
|
||||||
await servers.Locator("[data-domain-new]").FillAsync($"https://{server.ToUpperInvariant()}/about");
|
|
||||||
await servers.GetByRole(AriaRole.Button, new() { Name = "Block server" }).ClickAsync();
|
|
||||||
await Assertions.Expect(servers.Locator($"[data-domain='{server}']")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
|
||||||
Assert.Contains(server, (await PersonaApi.Call(persona, "/api/v1/domain_blocks"))!.AsArray().Select(d => d!.GetValue<string>()));
|
|
||||||
|
|
||||||
await servers.Locator($"[data-domain='{server}']").GetByRole(AriaRole.Button, new() { Name = "Unblock" }).ClickAsync();
|
|
||||||
await Assertions.Expect(servers.Locator($"[data-domain='{server}']")).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
|
||||||
Assert.DoesNotContain(server, (await PersonaApi.Call(persona, "/api/v1/domain_blocks"))!.AsArray().Select(d => d!.GetValue<string>()));
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/domain_blocks?domain={server}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// the follows export is Mastodon's following_accounts.csv, naming every account in full
|
|
||||||
[Fact]
|
|
||||||
public async Task The_follows_are_exported_in_mastodons_format()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var following = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=80"))!.AsArray();
|
|
||||||
Assert.SkipWhen(following.Count == 0, $"{persona.UserName} follows nobody in this town");
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.action.export", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
var panel = await Open(page, "Data import/export");
|
|
||||||
var button = panel.Locator("[data-export-kind='Follows']");
|
|
||||||
await button.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
var download = await page.RunAndWaitForDownloadAsync(() => button.ClickAsync(), new() { Timeout = 60000 });
|
|
||||||
Assert.Equal("following_accounts.csv", download.SuggestedFilename);
|
|
||||||
var lines = (await File.ReadAllLinesAsync((await download.PathAsync())!)).ToList();
|
|
||||||
Assert.Equal("Account address,Show boosts,Notify on new posts,Languages", lines[0]);
|
|
||||||
foreach (var account in following)
|
|
||||||
{
|
|
||||||
var acct = account!["acct"]!.GetValue<string>();
|
|
||||||
var full = acct.Contains('@') ? acct : $"{acct}@privapub.test";
|
|
||||||
Assert.Contains(lines, l => l.StartsWith(full + ",", StringComparison.Ordinal));
|
|
||||||
}
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Blocked_servers_are_imported_from_a_mastodon_file()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().Last();
|
|
||||||
string[] servers = ["imported-one.example", "imported-two.example"];
|
|
||||||
var csv = Path.Combine(Path.GetTempPath(), $"blocked_domains-{Guid.NewGuid():N}.csv");
|
|
||||||
await File.WriteAllTextAsync(csv, string.Join("\n", servers) + "\n");
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.action.import", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
var panel = await Open(page, "Data import/export");
|
|
||||||
var import = panel.Locator("[data-import]");
|
|
||||||
await import.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await import.Locator("[data-import-kind]").SelectOptionAsync("ServerBlocks");
|
|
||||||
await import.Locator("[data-import-file]").SetInputFilesAsync(csv);
|
|
||||||
await import.Locator("[data-import-start]").ClickAsync();
|
|
||||||
await Assertions.Expect(import.Locator("[data-import-report]")).ToContainTextAsync("2 of 2 done, 0 could not be.", new() { Timeout = 30000 });
|
|
||||||
var blocked = (await PersonaApi.Call(persona, "/api/v1/domain_blocks"))!.AsArray().Select(d => d!.GetValue<string>()).ToList();
|
|
||||||
Assert.All(servers, s => Assert.Contains(s, blocked));
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
foreach (var server in servers)
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/domain_blocks?domain={server}", HttpMethod.Delete);
|
|
||||||
File.Delete(csv);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_new_persona_is_made_and_used()
|
|
||||||
{
|
|
||||||
var name = $"e2e_{Guid.NewGuid():N}"[..12];
|
|
||||||
await using var session = await SignIn.As(browser, new Pasture.Persona("alice", "alice", "pastureroot", "Pasture-Pass-1!", null, null, null));
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.action.persona-create", "privapub", "pastureroot", async () =>
|
|
||||||
{
|
|
||||||
var panel = await Open(page, "Personas");
|
|
||||||
var form = panel.Locator("[data-persona-new]");
|
|
||||||
await form.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await form.Locator("[data-persona-username]").FillAsync(name);
|
|
||||||
await form.Locator("[data-persona-name]").FillAsync($"Made by e2e {name}");
|
|
||||||
await form.Locator("[data-persona-biography]").FillAsync("A persona the end-to-end tests made.");
|
|
||||||
await form.Locator("[data-persona-create]").ClickAsync();
|
|
||||||
var made = panel.Locator($"[data-persona='{name}']");
|
|
||||||
await made.WaitForAsync(new() { Timeout = 15000 });
|
|
||||||
|
|
||||||
await made.GetByRole(AriaRole.Button, new() { Name = "Use" }).ClickAsync();
|
|
||||||
await Assertions.Expect(made).ToContainTextAsync("in use", new() { Timeout = 15000 });
|
|
||||||
await Assertions.Expect(page.GetByText($"Made by e2e {name}", new() { Exact = true }).First).ToBeVisibleAsync();
|
|
||||||
using var http = Pasture.Http();
|
|
||||||
var webfinger = await http.GetAsync($"{Pasture.Api}/.well-known/webfinger?resource=acct:{name}@privapub.test");
|
|
||||||
Assert.True(webfinger.IsSuccessStatusCode, $"{name} is not found by WebFinger");
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// The home feed hears of a new post and of its deletion through PrivaPub's streaming API, in seconds, where polling
|
|
||||||
// would take up to 45
|
|
||||||
public sealed class StreamingTests(Browser browser)
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task A_post_made_elsewhere_waits_behind_the_new_button_and_its_deletion_takes_it_away()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var text = $"streamed {Guid.NewGuid():N}"[..21];
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
// the stream counts once the app has subscribed to the home feed on it (it opens after the first page is read,
|
|
||||||
// later on a busy page)
|
|
||||||
var subscribed = new TaskCompletionSource();
|
|
||||||
page.WebSocket += (_, socket) =>
|
|
||||||
{
|
|
||||||
if (socket.Url.Contains("/api/v1/streaming"))
|
|
||||||
socket.FrameSent += (_, frame) =>
|
|
||||||
{
|
|
||||||
if (frame.Text?.Contains("\"subscribe\"") == true && frame.Text.Contains("\"user\""))
|
|
||||||
subscribed.TrySetResult();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
JsonNode post = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell("client.render.stream", "privapub", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
|
||||||
subscribed = new();
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await subscribed.Task.WaitAsync(TimeSpan.FromSeconds(30));
|
|
||||||
// (and the server has taken the subscription in)
|
|
||||||
await Task.Delay(500);
|
|
||||||
|
|
||||||
post = await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "unlisted" });
|
|
||||||
var fresh = page.GetByRole(AriaRole.Button, new() { Name = "1 new" });
|
|
||||||
await fresh.WaitForAsync(new() { Timeout = 10000 });
|
|
||||||
await fresh.ClickAsync();
|
|
||||||
var card = page.Locator($"article[data-uri='{post!["uri"]!.GetValue<string>()}']");
|
|
||||||
await Assertions.Expect(card).ToContainTextAsync(text, new() { Timeout = 5000 });
|
|
||||||
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{post["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
|
||||||
post = null;
|
|
||||||
await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 10000 });
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (post is not null)
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{post["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// A remote thread opened in decePub fills with the replies PrivaPub never received: opening it has PrivaPub read the
|
|
||||||
// thread from its server (Mastodon's FEP-7888 context), and the page reads the thread again a few seconds later
|
|
||||||
public sealed class ThreadTests(Browser browser)
|
|
||||||
{
|
|
||||||
const string Platform = "mastodon";
|
|
||||||
const string Author = "elif_ms06";
|
|
||||||
// followed by no persona, so Mastodon never delivers her reply to PrivaPub
|
|
||||||
const string Outsider = "gigi_ms28";
|
|
||||||
|
|
||||||
static async Task<JsonNode> Drive(string user, string verb, JsonObject parameters) =>
|
|
||||||
await Pasture.Town("drive", Platform, user, verb, parameters.ToJsonString());
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_remote_thread_brings_the_replies_that_never_reached_privapub()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().First();
|
|
||||||
var mark = Guid.NewGuid().ToString("N")[..8];
|
|
||||||
var root = await Drive(Author, "post", new JsonObject { ["text"] = $"a thread to open {mark}", ["visibility"] = "public" });
|
|
||||||
var rootUri = root["uri"]!.GetValue<string>();
|
|
||||||
string replyUri = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var reply = await Drive(Outsider, "post", new JsonObject
|
|
||||||
{
|
|
||||||
["text"] = $"said where nobody listens {mark}", ["visibility"] = "public", ["reply_to_uri"] = rootUri
|
|
||||||
});
|
|
||||||
replyUri = reply["uri"]!.GetValue<string>();
|
|
||||||
var found = await PersonaApi.Call(persona, $"/api/v2/search?q={Uri.EscapeDataString(rootUri)}&resolve=true&type=statuses");
|
|
||||||
var rootId = found!["statuses"]!.AsArray().Single()!["id"]!.GetValue<string>();
|
|
||||||
await Task.Delay(3000);
|
|
||||||
Assert.SkipWhen((await Pasture.Stored("privapub", replyUri))[replyUri]?["exists"]?.GetValue<bool>() == true,
|
|
||||||
$"{Outsider}'s reply reached PrivaPub by delivery, so the thread proves nothing");
|
|
||||||
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.render.thread-backfill", Platform, persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/expand/{rootId}");
|
|
||||||
await page.Locator($"article[data-uri='{rootUri}']").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
var answer = page.Locator($"article[data-uri='{replyUri}']");
|
|
||||||
await Assertions.Expect(answer).ToContainTextAsync($"said where nobody listens {mark}", new() { Timeout = 25000 });
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (replyUri is not null)
|
|
||||||
await Drive(Outsider, "delete", new JsonObject { ["uri"] = replyUri });
|
|
||||||
await Drive(Author, "delete", new JsonObject { ["uri"] = rootUri });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// A vote in another server's poll and an emoji reaction to another server's post, done in decePub and counted on that
|
|
||||||
// server. The first of either asks the persona, once, to accept that both are public (an owner decision).
|
|
||||||
public sealed class VoteAndReactTests(Browser browser)
|
|
||||||
{
|
|
||||||
static readonly Lazy<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
|
|
||||||
|
|
||||||
static TheoryData<string, string> Pick(Func<JsonNode, bool> wanted, params string[] origins)
|
|
||||||
{
|
|
||||||
var data = new TheoryData<string, string>();
|
|
||||||
var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet();
|
|
||||||
foreach (var origin in origins)
|
|
||||||
{
|
|
||||||
var item = Expected.Value.FirstOrDefault(i => i!["origin"]!.GetValue<string>() == origin && wanted(i)
|
|
||||||
&& i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue<string>())));
|
|
||||||
if (item is not null)
|
|
||||||
data.Add(origin, item["ref"]!.GetValue<string>());
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TheoryData<string, string> Polls() =>
|
|
||||||
Pick(i => i["poll"] is JsonArray, "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo");
|
|
||||||
|
|
||||||
public static TheoryData<string, string> Reactable() =>
|
|
||||||
Pick(i => i["visibility"]!.GetValue<string>() is "public" or "unlisted", "misskey", "sharkey", "akkoma");
|
|
||||||
|
|
||||||
|
|
||||||
async Task<(JsonNode Item, Session Session, ILocator Card)> Open(string reference)
|
|
||||||
{
|
|
||||||
var item = Expected.Value.First(i => i!["ref"]!.GetValue<string>() == reference)!;
|
|
||||||
var personas = Pasture.Personas();
|
|
||||||
var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue<string>()))
|
|
||||||
.Last(p => p is not null);
|
|
||||||
var session = await SignIn.As(browser, reader);
|
|
||||||
await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue<string>()}");
|
|
||||||
var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue<string>()}']").First;
|
|
||||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
return (item, session, card);
|
|
||||||
}
|
|
||||||
|
|
||||||
// the one-time notice, when this persona has not seen it yet
|
|
||||||
internal static async Task AcceptNoticeIfAsked(IPage page)
|
|
||||||
{
|
|
||||||
var proceed = page.GetByRole(AriaRole.Button, new() { Name = "Continue" });
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await proceed.WaitForAsync(new() { Timeout = 3000 });
|
|
||||||
await proceed.ClickAsync();
|
|
||||||
}
|
|
||||||
catch (TimeoutException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task<JsonObject> Row(string origin, string uri) => (await Pasture.Stored(origin, uri))[uri]!.AsObject();
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[MemberData(nameof(Polls))]
|
|
||||||
public async Task A_vote_is_counted_on_the_polls_own_server(string origin, string reference)
|
|
||||||
{
|
|
||||||
var (item, session, card) = await Open(reference);
|
|
||||||
await using var _ = session;
|
|
||||||
var uri = item["uri"]!.GetValue<string>();
|
|
||||||
var choices = card.Locator("[data-poll] input");
|
|
||||||
Assert.SkipWhen(await choices.CountAsync() == 0, "this persona already voted, or the poll is closed");
|
|
||||||
await Results.Cell("client.action.vote", origin, reference, async () =>
|
|
||||||
{
|
|
||||||
var before = (await Row(origin, uri))["votes"]?.AsArray().Sum(v => v?.GetValue<int>() ?? 0) ?? 0;
|
|
||||||
await choices.First.CheckAsync();
|
|
||||||
await card.Locator("[data-poll]").GetByRole(AriaRole.Button, new() { Name = "Vote" }).ClickAsync();
|
|
||||||
await AcceptNoticeIfAsked(session.Page);
|
|
||||||
await Assertions.Expect(card.Locator("[data-poll] input")).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
|
||||||
Assert.True(await Pasture.Eventually(async () =>
|
|
||||||
((await Row(origin, uri))["votes"]?.AsArray().Sum(v => v?.GetValue<int>() ?? 0) ?? 0) > before, 45),
|
|
||||||
$"the vote never counted on {origin}");
|
|
||||||
await session.AssertGuards();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[MemberData(nameof(Reactable))]
|
|
||||||
public async Task A_reaction_reaches_the_posts_server_and_its_withdrawal_too(string origin, string reference)
|
|
||||||
{
|
|
||||||
var (item, session, card) = await Open(reference);
|
|
||||||
await using var _ = session;
|
|
||||||
var uri = item["uri"]!.GetValue<string>();
|
|
||||||
const string emoji = "🔥";
|
|
||||||
await Results.Cell("client.action.react", origin, reference, async () =>
|
|
||||||
{
|
|
||||||
static int Count(JsonObject row) => row["reactions"]?[emoji]?.GetValue<int>() ?? 0;
|
|
||||||
var chip = card.Locator("[data-reactions] button").Filter(new() { HasText = emoji }).First;
|
|
||||||
// a run cut short may have left the persona's reaction: start from the withdrawn state
|
|
||||||
if (await chip.CountAsync() > 0 && ((await chip.GetAttributeAsync("class")) ?? "").Split(' ').Contains("btn-active"))
|
|
||||||
{
|
|
||||||
var left = Count(await Row(origin, uri));
|
|
||||||
await chip.ClickAsync();
|
|
||||||
await Pasture.Eventually(async () => Count(await Row(origin, uri)) < left, 60);
|
|
||||||
}
|
|
||||||
var before = Count(await Row(origin, uri));
|
|
||||||
await card.Locator("footer [title='React']").First.ClickAsync();
|
|
||||||
await card.GetByRole(AriaRole.Button, new() { Name = emoji, Exact = true }).First.ClickAsync();
|
|
||||||
await AcceptNoticeIfAsked(session.Page);
|
|
||||||
await Assertions.Expect(chip).ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active"), new() { Timeout = 15000 });
|
|
||||||
Assert.True(await Pasture.Eventually(async () => Count(await Row(origin, uri)) > before, 45),
|
|
||||||
$"the reaction never reached {origin}");
|
|
||||||
|
|
||||||
await chip.ScrollIntoViewIfNeededAsync();
|
|
||||||
await chip.ClickAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(async () => Count(await Row(origin, uri)) == before, 60),
|
|
||||||
$"withdrawing the reaction never reached {origin}");
|
|
||||||
await session.AssertGuards();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Actions
|
|
||||||
{
|
|
||||||
// A Smithereen account that follows the persona writes on its wall (FEP-400e): the card says so, and the persona takes
|
|
||||||
// it off from the card's menu, which deletes it on Smithereen too. SocialPub's tools/pasture/scenarios/smithereen.sh
|
|
||||||
// checks the rest of the wire.
|
|
||||||
public sealed class WallTests(Browser browser)
|
|
||||||
{
|
|
||||||
// Smithereen's VKontakte-like API as smuser (the token SocialPub's peers/smithereen.sh keeps)
|
|
||||||
static async Task<JsonNode> Smithereen(string token, string method, params (string Name, string Value)[] args)
|
|
||||||
{
|
|
||||||
using var http = Pasture.Http();
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"https://smithereen.test/api/method/{method}")
|
|
||||||
{
|
|
||||||
Content = new FormUrlEncodedContent(args.Select(a => KeyValuePair.Create(a.Name, a.Value)).Append(KeyValuePair.Create("v", "1.0")))
|
|
||||||
};
|
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
||||||
using var response = await http.SendAsync(request);
|
|
||||||
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_post_on_the_personas_wall_says_so_and_the_persona_takes_it_off()
|
|
||||||
{
|
|
||||||
var tokenFile = Path.Combine(Pasture.Root, ".state", "smithereen", "smuser.token");
|
|
||||||
Assert.SkipUnless(File.Exists(tokenFile), "no Smithereen: run tools/pasture/run.sh add smithereen");
|
|
||||||
var token = File.ReadAllText(tokenFile).Trim();
|
|
||||||
// (one whose follows need no approval)
|
|
||||||
var persona = default(Pasture.Persona);
|
|
||||||
var me = default(JsonNode);
|
|
||||||
foreach (var candidate in Pasture.Personas())
|
|
||||||
{
|
|
||||||
me = await PersonaApi.Call(candidate, "/api/v1/accounts/verify_credentials");
|
|
||||||
if (me?["locked"]?.GetValue<bool>() == false)
|
|
||||||
{
|
|
||||||
persona = candidate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Assert.SkipWhen(persona is null, "every persona of the town is locked");
|
|
||||||
var actorUri = me["url"]!.GetValue<string>().Replace("/@", "/peasants/");
|
|
||||||
// (its profile told again, so Smithereen reads its wall; smuser follows it, as its followers may write there)
|
|
||||||
await PersonaApi.Call(persona, "/api/v1/accounts/update_credentials", HttpMethod.Patch, new JsonObject());
|
|
||||||
var personaOnSm = (await Smithereen(token, "utils.loadRemoteObject", ("q", actorUri)))?["response"]?["id"]?.ToString();
|
|
||||||
Assert.SkipWhen(string.IsNullOrEmpty(personaOnSm), "Smithereen cannot resolve the persona");
|
|
||||||
await Smithereen(token, "friends.add", ("user_id", personaOnSm));
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var smuser = (await PersonaApi.Call(persona, "/api/v2/search?q=smuser%40smithereen.test&resolve=true&type=accounts"))!["accounts"]![0]!["id"]!.GetValue<string>();
|
|
||||||
Assert.True(await Pasture.Eventually(async () =>
|
|
||||||
(await PersonaApi.Call(persona, $"/api/v1/accounts/relationships?id[]={smuser}"))![0]!["followed_by"]!.GetValue<bool>(), 45), "smuser never followed the persona");
|
|
||||||
|
|
||||||
var text = $"a word on the wall {Guid.NewGuid():N}"[..32];
|
|
||||||
// (Smithereen lets its followers write there once the persona's Accept has reached it, which may be after
|
|
||||||
// PrivaPub counts the follow)
|
|
||||||
var written = default(string);
|
|
||||||
Assert.True(await Pasture.Eventually(async () =>
|
|
||||||
!string.IsNullOrEmpty(written = (await Smithereen(token, "wall.post", ("owner_id", personaOnSm), ("message", text)))?["response"]?.ToString()), 30),
|
|
||||||
"Smithereen refused smuser's post on the persona's wall");
|
|
||||||
JsonNode status = null;
|
|
||||||
Assert.True(await Pasture.Eventually(async () =>
|
|
||||||
(status = (await PersonaApi.Call(persona, "/api/v1/timelines/home?limit=40"))!.AsArray()
|
|
||||||
.FirstOrDefault(s => s!["content"]!.GetValue<string>().Contains(text))) is not null, 60), "the post on the wall never reached the persona");
|
|
||||||
Assert.Equal(me["id"]!.GetValue<string>(), status!["privapub"]?["wall"]?["id"]?.GetValue<string>());
|
|
||||||
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.action.wall", "smithereen", persona.UserName, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
var card = page.Locator($"article[data-uri='{status["uri"]!.GetValue<string>()}']").First;
|
|
||||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
|
|
||||||
await Assertions.Expect(card.Locator("[data-wall]")).ToBeVisibleAsync();
|
|
||||||
await Assertions.Expect(card.Locator("[data-wall]")).ToContainTextAsync(me["display_name"]!.GetValue<string>() is { Length: > 0 } name ? name : me["username"]!.GetValue<string>());
|
|
||||||
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
|
||||||
await card.Locator("button[data-take-off-wall]").ClickAsync();
|
|
||||||
await session.Page.Locator("[data-confirm-yes]").ClickAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(async () => await PersonaApi.Call(persona, $"/api/v1/statuses/{status["id"]!.GetValue<string>()}") is null, 15),
|
|
||||||
"the post is still on PrivaPub");
|
|
||||||
Assert.True(await Pasture.Eventually(async () =>
|
|
||||||
(await Smithereen(token, "wall.getById", ("posts", written)))?["response"] is not JsonArray { Count: > 0 }, 30), "the post is still on Smithereen");
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
// (the town as it was: smuser no longer follows the persona)
|
|
||||||
await Smithereen(token, "friends.delete", ("user_id", personaOnSm));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Login
|
|
||||||
{
|
|
||||||
// The two ways in now that sign-up is closed: the server's first login, made from the client while it has no
|
|
||||||
// administrator, and a login made through a group's invitation
|
|
||||||
public sealed class SignUpTests(Browser browser)
|
|
||||||
{
|
|
||||||
const string Password = "Pasture-Pass-1!";
|
|
||||||
|
|
||||||
static async Task<bool> SetupPending()
|
|
||||||
{
|
|
||||||
using var http = Pasture.Http();
|
|
||||||
return JsonNode.Parse(await http.GetStringAsync(Pasture.Api + "/clientapi/user/setup"))!["isPending"]!.GetValue<bool>();
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task<string> Jwt(string root, string password)
|
|
||||||
{
|
|
||||||
using var http = Pasture.Http();
|
|
||||||
using var login = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/login", new { userName = root, password });
|
|
||||||
login.EnsureSuccessStatusCode();
|
|
||||||
return (await login.Content.ReadFromJsonAsync<JsonObject>())!["token"]!.GetValue<string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task<JsonNode> Send(HttpMethod method, string path, string jwt, object body = null)
|
|
||||||
{
|
|
||||||
using var http = Pasture.Http();
|
|
||||||
using var request = new HttpRequestMessage(method, Pasture.Api + path) { Content = body is null ? null : JsonContent.Create(body) };
|
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
|
||||||
using var response = await http.SendAsync(request);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task The_first_login_sets_the_server_up_and_becomes_its_administrator()
|
|
||||||
{
|
|
||||||
var stamp = DateTime.UtcNow.ToString("MMddHHmmss");
|
|
||||||
var root = $"e2eadmin{stamp}";
|
|
||||||
var persona = $"e2efounder{stamp}";
|
|
||||||
// the pasture's administrator closed setup: it is opened for this test, and closed again whatever happens
|
|
||||||
await Pasture.Mongo("db.ServerSetup.deleteOne({_id: 'setup'})");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Assert.True(await SetupPending());
|
|
||||||
await using var session = await browser.Open();
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.action.setup", "privapub", root, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/login");
|
|
||||||
await page.Locator("[data-setup-offer]").ClickAsync();
|
|
||||||
await page.Locator("input[data-setup-username]").FillAsync(root);
|
|
||||||
await page.Locator("input[data-setup-password]").FillAsync(Password);
|
|
||||||
await page.Locator("input[data-setup-repeat]").FillAsync(Password);
|
|
||||||
await page.Locator("input[data-setup-persona-username]").FillAsync(persona);
|
|
||||||
await page.Locator("input[data-setup-persona-name]").FillAsync("The e2e founder");
|
|
||||||
await page.Locator("[data-setup-submit]").ClickAsync();
|
|
||||||
|
|
||||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 });
|
|
||||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await page.Locator("nav a[href='administration']").First.WaitForAsync(new() { Timeout = 15000 });
|
|
||||||
Assert.False(await SetupPending());
|
|
||||||
var jwt = await Jwt(root, Password);
|
|
||||||
var personas = (await Send(HttpMethod.Get, "/clientapi/avatar/private/list", jwt))!.AsArray();
|
|
||||||
Assert.Equal(persona, Assert.Single(personas)!["userName"]!.GetValue<string>());
|
|
||||||
await session.AssertGuards();
|
|
||||||
|
|
||||||
// set up: a reader without a login is offered sign-in and invitations only
|
|
||||||
await using var stranger = await browser.Open();
|
|
||||||
await stranger.Page.GotoAsync($"{Pasture.Site}/setup");
|
|
||||||
await stranger.Page.Locator("[data-setup-done]").WaitForAsync(new() { Timeout = 15000 });
|
|
||||||
await stranger.Page.GotoAsync($"{Pasture.Site}/login");
|
|
||||||
await stranger.Page.Locator("[data-join-offer]").WaitForAsync(new() { Timeout = 15000 });
|
|
||||||
Assert.Equal(0, await stranger.Page.Locator("[data-setup-offer]").CountAsync());
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
// the pasture's administrator stays pastureroot: the test's is demoted, and setup stays closed
|
|
||||||
await Pasture.Mongo($"db.RootUser.updateOne({{UserName: '{root}'}}, {{$set: {{Policies: ['isUser']}}}}); " +
|
|
||||||
"if (!db.ServerSetup.findOne({_id: 'setup'})) db.ServerSetup.insertOne({_id: 'setup', AdminId: 'e2e', SealedAt: new Date()})");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task An_invitation_link_makes_a_login_whose_persona_joins_the_group()
|
|
||||||
{
|
|
||||||
var stamp = DateTime.UtcNow.ToString("MMddHHmmss");
|
|
||||||
// the circle's owner is a login of the test's own (the pasture's sign-up is open), so the town's sessions don't matter
|
|
||||||
var ownerJwt = default(string);
|
|
||||||
using (var http = Pasture.Http())
|
|
||||||
{
|
|
||||||
using var signup = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/signup", new { userName = $"e2eowner{stamp}", password = Password });
|
|
||||||
Assert.SkipWhen(!signup.IsSuccessStatusCode, $"PrivaPub refuses sign-ups ({(int)signup.StatusCode})");
|
|
||||||
ownerJwt = (await signup.Content.ReadFromJsonAsync<JsonObject>())!["token"]!.GetValue<string>();
|
|
||||||
}
|
|
||||||
var ownerId = (await Send(HttpMethod.Post, "/clientapi/avatar/private/insert", ownerJwt,
|
|
||||||
new { userName = $"e2ehost{stamp}", name = "An e2e host", biography = "for the invitation test" }))!["id"]!.GetValue<string>();
|
|
||||||
var group = await Send(HttpMethod.Post, "/clientapi/group/insert", ownerJwt,
|
|
||||||
new { avatarId = ownerId, userName = $"e2ecircle{stamp}", name = "An e2e circle", isCommunity = false });
|
|
||||||
var code = group!["invitationCode"]!.GetValue<string>();
|
|
||||||
var root = $"e2ejoin{stamp}";
|
|
||||||
var persona = $"e2eguest{stamp}";
|
|
||||||
await using var session = await browser.Open();
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.action.invitation", "privapub", root, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/join?code={Uri.EscapeDataString(code)}");
|
|
||||||
Assert.Equal(code, await page.Locator("input[data-join-code]").InputValueAsync());
|
|
||||||
await page.Locator("input[data-join-username]").FillAsync(root);
|
|
||||||
await page.Locator("input[data-join-password]").FillAsync(Password);
|
|
||||||
await page.Locator("input[data-join-repeat]").FillAsync(Password);
|
|
||||||
await page.Locator("input[data-join-persona-username]").FillAsync(persona);
|
|
||||||
await page.Locator("[data-join-submit]").ClickAsync();
|
|
||||||
|
|
||||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 });
|
|
||||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
var members = (await Send(HttpMethod.Get,
|
|
||||||
$"/clientapi/group/members?avatarId={Uri.EscapeDataString(ownerId)}&groupId={Uri.EscapeDataString(group["id"]!.GetValue<string>())}", ownerJwt))!.AsArray();
|
|
||||||
Assert.Contains(members, m => (m!["handle"]?.GetValue<string>() ?? "").Contains(persona) || m["name"]?.GetValue<string>() == persona);
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Mobile
|
|
||||||
{
|
|
||||||
// Controls the sweep doesn't reach, since it measures pages with their sections closed: Settings' Data import/export
|
|
||||||
// opened on the narrowest phone, its file inputs and checkboxes thumb-sized and drawn like the rest of the app (a
|
|
||||||
// ticked box filled, not a tick on nothing), in both themes. Photos go to tests/e2e/out/mobile-controls/.
|
|
||||||
[Trait("Category", "Mobile")]
|
|
||||||
public sealed class ControlsTests(Browser browser)
|
|
||||||
{
|
|
||||||
static readonly Phone Phone = Phone.Narrow360;
|
|
||||||
static readonly string OutDir = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "out", "mobile-controls");
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Data_transfer_controls_are_thumb_sized_and_drawn_like_the_rest()
|
|
||||||
{
|
|
||||||
await using var session = await SignIn.As(browser, Pasture.Personas().First(), Phone);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.mobile.controls.data-transfer", "privapub", Phone.Key, async () =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
|
||||||
var header = page.Locator(".collapse-title", new() { HasText = "Data import/export" }).First;
|
|
||||||
await header.TapAsync();
|
|
||||||
var archive = page.Locator("[data-archive]");
|
|
||||||
await archive.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
|
|
||||||
Fit.AssertTouchable(await Fit.Now(page, "[data-archive]"));
|
|
||||||
Fit.AssertTouchable(await Fit.Now(page, "[data-import]"));
|
|
||||||
|
|
||||||
// a ticked box is filled: daisyUI leaves it transparent unless a colour asks otherwise
|
|
||||||
var ticked = archive.Locator("input[type=checkbox]:checked").First;
|
|
||||||
var fill = await ticked.EvaluateAsync<string>("e => getComputedStyle(e).backgroundColor");
|
|
||||||
Assert.False(fill is "transparent" or "rgba(0, 0, 0, 0)", $"a ticked checkbox is drawn on nothing ({fill})");
|
|
||||||
// a file input is as tall as the other fields on a touch screen
|
|
||||||
foreach (var input in await page.Locator("[data-archive] input[type=file], [data-import] input[type=file]").AllAsync())
|
|
||||||
Assert.True((await input.BoundingBoxAsync())!.Height >= 44, $"a file input is {(await input.BoundingBoxAsync())!.Height}px tall: "
|
|
||||||
+ await input.EvaluateAsync<string>("e => { const s = getComputedStyle(e); return `${e.className} --size=${s.getPropertyValue('--size')} height=${s.height} coarse=${matchMedia('(pointer: coarse)').matches}`; }"));
|
|
||||||
// photos the size of the screen: one of an element taller than the screen makes Playwright resize the page,
|
|
||||||
// which drops the touch emulation for what comes after
|
|
||||||
Directory.CreateDirectory(OutDir);
|
|
||||||
foreach (var theme in new[] { "neo-dark", "neo-light" })
|
|
||||||
{
|
|
||||||
await page.EvaluateAsync("t => document.documentElement.setAttribute('data-theme', t)", theme);
|
|
||||||
foreach (var (part, what) in new[] { ("archive", "[data-archive-make]"), ("restore", "input[data-archive-file]"), ("csv", "[data-import]") })
|
|
||||||
{
|
|
||||||
await page.Locator(what).First.EvaluateAsync("e => e.scrollIntoView({ block: 'start' })");
|
|
||||||
await page.ScreenshotAsync(new() { Path = Path.Combine(OutDir, $"{part}-{theme}.png"), Animations = ScreenshotAnimations.Disabled });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Mobile
|
|
||||||
{
|
|
||||||
// What phones get stays on phones: at 1440x900 with a mouse the controls keep daisyUI's sizes, measured on probes made
|
|
||||||
// with the app's own classes, and the nav menu stays open beside the feed.
|
|
||||||
[Trait("Category", "Mobile")]
|
|
||||||
public sealed class DesktopUnchangedTests(Browser browser)
|
|
||||||
{
|
|
||||||
const string Probe = """
|
|
||||||
() => ["btn", "btn btn-sm", "btn btn-xs", "input", "input input-sm"].map(classes => {
|
|
||||||
const el = document.createElement(classes.startsWith("input") ? "input" : "button")
|
|
||||||
el.className = classes
|
|
||||||
document.body.append(el)
|
|
||||||
const height = el.getBoundingClientRect().height
|
|
||||||
el.remove()
|
|
||||||
return height
|
|
||||||
})
|
|
||||||
""";
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Desktop_keeps_its_sizes()
|
|
||||||
{
|
|
||||||
var persona = Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
|
|
||||||
await using var session = await SignIn.As(browser, persona);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.mobile.desktop", "privapub", "desktop", async () =>
|
|
||||||
{
|
|
||||||
Assert.False(await page.EvaluateAsync<bool>("() => matchMedia('(pointer: coarse)').matches"), "the desktop session has a coarse pointer");
|
|
||||||
// btn, btn btn-sm, btn btn-xs, input, input input-sm
|
|
||||||
var heights = await page.EvaluateAsync<double[]>(Probe);
|
|
||||||
Assert.Equal([40, 32, 24, 40, 32], heights.Select(h => Math.Round(h)).ToArray());
|
|
||||||
await Assertions.Expect(page.Locator("nav a.btn").First).ToBeVisibleAsync();
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Mobile
|
|
||||||
{
|
|
||||||
// The phone sweep: a page measured and photographed one screen at a time down its main scroller (fit.js measures),
|
|
||||||
// what doesn't fit kept as violations.json next to the screenshots, and a contact sheet of every route at every phone
|
|
||||||
// in tests/e2e/out/mobile/<run>/index.html, for the eye.
|
|
||||||
public static class Fit
|
|
||||||
{
|
|
||||||
public static readonly string Script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Mobile", "fit.js"));
|
|
||||||
|
|
||||||
static readonly string RunDir = Path.Combine(TestsRoot(), "out", "mobile", DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"));
|
|
||||||
static readonly object SheetGate = new();
|
|
||||||
|
|
||||||
static string TestsRoot()
|
|
||||||
{
|
|
||||||
var dir = AppContext.BaseDirectory;
|
|
||||||
while (dir is not null && !File.Exists(Path.Combine(dir, "decePubClient.E2E.csproj")))
|
|
||||||
dir = Path.GetDirectoryName(dir);
|
|
||||||
return dir ?? throw new InvalidOperationException("decePubClient.E2E.csproj not found above the test output");
|
|
||||||
}
|
|
||||||
|
|
||||||
// what a sweep found, each finding once however many screens showed it
|
|
||||||
public sealed class Report
|
|
||||||
{
|
|
||||||
readonly HashSet<string> seen = [];
|
|
||||||
public List<JsonObject> Overflow { get; } = [];
|
|
||||||
public List<JsonObject> Beyond { get; } = [];
|
|
||||||
public List<JsonObject> Popovers { get; } = [];
|
|
||||||
public List<JsonObject> Small { get; } = [];
|
|
||||||
public List<JsonObject> Tiny { get; } = [];
|
|
||||||
public int Screens { get; set; }
|
|
||||||
|
|
||||||
public void Take(JsonNode measured, int screen)
|
|
||||||
{
|
|
||||||
Add(Overflow, measured["overflow"], "overflow", screen);
|
|
||||||
Add(Beyond, measured["beyond"], "beyond", screen);
|
|
||||||
Add(Popovers, measured["popovers"], "popover", screen);
|
|
||||||
Add(Small, measured["small"], "small", screen);
|
|
||||||
Add(Tiny, measured["tiny"], "tiny", screen);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Add(List<JsonObject> into, JsonNode found, string kind, int screen)
|
|
||||||
{
|
|
||||||
foreach (var item in found!.AsArray().Select(i => i!.AsObject()))
|
|
||||||
{
|
|
||||||
var key = $"{kind}|{item["element"]}|{item["text"]}|{item["card"]}";
|
|
||||||
if (!seen.Add(key))
|
|
||||||
continue;
|
|
||||||
var copy = Clean(item.DeepClone()).AsObject();
|
|
||||||
copy["screen"] = screen;
|
|
||||||
into.Add(copy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Playwright marks the objects it hands back with $id; the findings don't need them
|
|
||||||
static JsonNode Clean(JsonNode node)
|
|
||||||
{
|
|
||||||
if (node is JsonObject obj)
|
|
||||||
{
|
|
||||||
obj.Remove("$id");
|
|
||||||
foreach (var (_, value) in obj)
|
|
||||||
Clean(value);
|
|
||||||
}
|
|
||||||
else if (node is JsonArray array)
|
|
||||||
foreach (var value in array)
|
|
||||||
Clean(value);
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Json() => new JsonObject
|
|
||||||
{
|
|
||||||
["screens"] = Screens,
|
|
||||||
["overflow"] = new JsonArray(Overflow.Select(o => (JsonNode)o.DeepClone()).ToArray()),
|
|
||||||
["beyond"] = new JsonArray(Beyond.Select(o => (JsonNode)o.DeepClone()).ToArray()),
|
|
||||||
["popovers"] = new JsonArray(Popovers.Select(o => (JsonNode)o.DeepClone()).ToArray()),
|
|
||||||
["small"] = new JsonArray(Small.Select(o => (JsonNode)o.DeepClone()).ToArray()),
|
|
||||||
["tiny"] = new JsonArray(Tiny.Select(o => (JsonNode)o.DeepClone()).ToArray())
|
|
||||||
}.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Task Settle(IPage page) => page.EvaluateAsync("() => window.__fit.settle()");
|
|
||||||
|
|
||||||
public static async Task<Report> Sweep(IPage page, Support.Phone phone, string slug, int screens)
|
|
||||||
{
|
|
||||||
var dir = Path.Combine(RunDir, phone.Key, slug);
|
|
||||||
Directory.CreateDirectory(dir);
|
|
||||||
var report = new Report();
|
|
||||||
await Settle(page);
|
|
||||||
await page.EvaluateAsync("() => window.__fit.primary()");
|
|
||||||
for (var screen = 0; screen < screens; screen++)
|
|
||||||
{
|
|
||||||
var measured = JsonNode.Parse((await page.EvaluateAsync<JsonElement>("() => window.__fit.measure()")).GetRawText())!;
|
|
||||||
report.Take(measured, screen);
|
|
||||||
await page.ScreenshotAsync(new()
|
|
||||||
{
|
|
||||||
Path = Path.Combine(dir, $"{screen:00}.png"), Animations = ScreenshotAnimations.Disabled, Caret = ScreenshotCaret.Hide
|
|
||||||
});
|
|
||||||
report.Screens = screen + 1;
|
|
||||||
if (!await page.EvaluateAsync<bool>("() => window.__fit.step(0.8)"))
|
|
||||||
break;
|
|
||||||
await Settle(page);
|
|
||||||
}
|
|
||||||
await File.WriteAllTextAsync(Path.Combine(dir, "violations.json"), report.Json());
|
|
||||||
ContactSheet();
|
|
||||||
return report;
|
|
||||||
}
|
|
||||||
|
|
||||||
// one measurement of the page as it is now; with a scope (an open menu, sheet or dialog), its targets only
|
|
||||||
public static async Task<Report> Now(IPage page, string scope = null)
|
|
||||||
{
|
|
||||||
await Settle(page);
|
|
||||||
var report = new Report();
|
|
||||||
var measured = JsonNode.Parse((await page.EvaluateAsync<JsonElement>("scope => window.__fit.measure(scope)", scope)).GetRawText())!;
|
|
||||||
report.Take(measured, 0);
|
|
||||||
report.Screens = 1;
|
|
||||||
return report;
|
|
||||||
}
|
|
||||||
|
|
||||||
// nothing scrolls sideways, nothing lies past the screen's sides, and every open menu, dialog or tip is whole
|
|
||||||
public static void AssertFits(Report report)
|
|
||||||
{
|
|
||||||
var problems = report.Overflow.Select(o => $"sideways scroll in {o["element"]} (+{o["extra"]}px), furthest: {o["culprit"]?.ToJsonString()}")
|
|
||||||
.Concat(report.Beyond.Select(b => $"past the screen: {b.ToJsonString()}"))
|
|
||||||
.Concat(report.Popovers.Select(p => $"cut or outside: {p.ToJsonString()}"))
|
|
||||||
.ToList();
|
|
||||||
Assert.True(problems.Count == 0, $"{problems.Count} fit problems:\n" + string.Join("\n", problems.Take(12)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// every target a thumb can reach has a 44px hit area, and no control's text is under 12px
|
|
||||||
public static void AssertTouchable(Report report)
|
|
||||||
{
|
|
||||||
var problems = report.Small.Select(s => $"small target: {s.ToJsonString()}")
|
|
||||||
.Concat(report.Tiny.Select(t => $"small text: {t.ToJsonString()}"))
|
|
||||||
.ToList();
|
|
||||||
Assert.True(problems.Count == 0, $"{problems.Count} touch problems:\n" + string.Join("\n", problems.Take(12)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// every route as a row, every phone as a column: the first screen and how many findings
|
|
||||||
static void ContactSheet()
|
|
||||||
{
|
|
||||||
lock (SheetGate)
|
|
||||||
{
|
|
||||||
var phones = Directory.GetDirectories(RunDir).Select(Path.GetFileName).Order().ToList();
|
|
||||||
var routes = phones.SelectMany(p => Directory.GetDirectories(Path.Combine(RunDir, p)).Select(Path.GetFileName)).Distinct().Order().ToList();
|
|
||||||
var html = new StringBuilder("<!doctype html><meta charset=utf-8><title>decePub on phones</title>")
|
|
||||||
.Append("<style>body{font:13px system-ui;background:#222;color:#ddd}td{vertical-align:top;padding:6px}img{width:180px;display:block}")
|
|
||||||
.Append("a{color:#9cf}.bad{color:#f88}</style><table><tr><th></th>");
|
|
||||||
foreach (var phone in phones)
|
|
||||||
html.Append("<th>").Append(WebUtility.HtmlEncode(phone)).Append("</th>");
|
|
||||||
html.Append("</tr>");
|
|
||||||
foreach (var route in routes)
|
|
||||||
{
|
|
||||||
html.Append("<tr><th>").Append(WebUtility.HtmlEncode(route)).Append("</th>");
|
|
||||||
foreach (var phone in phones)
|
|
||||||
{
|
|
||||||
var dir = Path.Combine(RunDir, phone, route);
|
|
||||||
html.Append("<td>");
|
|
||||||
if (File.Exists(Path.Combine(dir, "violations.json")))
|
|
||||||
{
|
|
||||||
var found = JsonNode.Parse(File.ReadAllText(Path.Combine(dir, "violations.json")))!;
|
|
||||||
var fit = found["overflow"]!.AsArray().Count + found["beyond"]!.AsArray().Count + found["popovers"]!.AsArray().Count;
|
|
||||||
var touch = found["small"]!.AsArray().Count + found["tiny"]!.AsArray().Count;
|
|
||||||
var link = $"{phone}/{route}";
|
|
||||||
html.Append($"<a href=\"{link}/00.png\"><img src=\"{link}/00.png\"></a>")
|
|
||||||
.Append($"<span class=\"{(fit + touch > 0 ? "bad" : "")}\">fit {fit} · touch {touch} · {found["screens"]} screens</span> ")
|
|
||||||
.Append($"<a href=\"{link}/violations.json\">findings</a>");
|
|
||||||
}
|
|
||||||
html.Append("</td>");
|
|
||||||
}
|
|
||||||
html.Append("</tr>");
|
|
||||||
}
|
|
||||||
File.WriteAllText(Path.Combine(RunDir, "index.html"), html.Append("</table>").ToString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Mobile
|
|
||||||
{
|
|
||||||
// What a thumb does on the narrowest phone, step by step: every menu, sheet and dialog that opens is whole on the
|
|
||||||
// screen and its targets have 44px hit areas, and each one closes the way a phone user expects. One cell each,
|
|
||||||
// client.mobile.flow.*.
|
|
||||||
[Trait("Category", "Mobile")]
|
|
||||||
public sealed class FlowTests(Browser browser)
|
|
||||||
{
|
|
||||||
static readonly Phone Phone = Phone.Narrow360;
|
|
||||||
|
|
||||||
static Pasture.Persona Persona() => Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
|
|
||||||
|
|
||||||
static async Task Flow(Session session, string name, Func<IPage, Task> steps)
|
|
||||||
{
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell($"client.mobile.flow.{name}", "privapub", Phone.Key, async () =>
|
|
||||||
{
|
|
||||||
await steps(page);
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
// what opened is whole (not cut, inside the screen) and its targets are thumb-sized; the rest of the page is the
|
|
||||||
// sweep's
|
|
||||||
static async Task AssertWhole(IPage page, string scope)
|
|
||||||
{
|
|
||||||
var report = await Fit.Now(page, scope);
|
|
||||||
Assert.True(report.Popovers.Count == 0, "cut or outside: " + string.Join("\n", report.Popovers.Select(p => p.ToJsonString())));
|
|
||||||
Fit.AssertTouchable(report);
|
|
||||||
}
|
|
||||||
|
|
||||||
static ILocator Card(IPage page, string has = null) =>
|
|
||||||
(has is null ? page.Locator("#feed-scroll article[data-uri]") : page.Locator($"#feed-scroll article[data-uri]:has({has})")).First;
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task The_menu_closes_after_a_choice_an_outside_tap_and_escape()
|
|
||||||
{
|
|
||||||
await using var session = await SignIn.As(browser, Persona(), Phone);
|
|
||||||
await Flow(session, "nav", async page =>
|
|
||||||
{
|
|
||||||
var toggle = page.Locator("[data-nav-toggle]");
|
|
||||||
var panel = page.Locator("[data-nav-panel]");
|
|
||||||
await toggle.TapAsync();
|
|
||||||
await Assertions.Expect(panel).ToBeVisibleAsync();
|
|
||||||
await AssertWhole(page, "[data-nav-panel]");
|
|
||||||
await panel.Locator("a[href='explore']").TapAsync();
|
|
||||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore");
|
|
||||||
await Assertions.Expect(panel).ToBeHiddenAsync();
|
|
||||||
|
|
||||||
await toggle.TapAsync();
|
|
||||||
await Assertions.Expect(panel).ToBeVisibleAsync();
|
|
||||||
var size = page.ViewportSize!;
|
|
||||||
await page.Touchscreen.TapAsync(size.Width - 12, size.Height - 40);
|
|
||||||
await Assertions.Expect(panel).ToBeHiddenAsync();
|
|
||||||
|
|
||||||
await toggle.TapAsync();
|
|
||||||
await Assertions.Expect(panel).ToBeVisibleAsync();
|
|
||||||
await page.Keyboard.PressAsync("Escape");
|
|
||||||
await Assertions.Expect(panel).ToBeHiddenAsync();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_dialog_rises_from_the_bottom_and_closes_from_its_button()
|
|
||||||
{
|
|
||||||
await using var session = await SignIn.As(browser, Persona(), Phone);
|
|
||||||
await Flow(session, "dialog", async page =>
|
|
||||||
{
|
|
||||||
var card = Card(page, "[data-liked-by]");
|
|
||||||
if (await card.CountAsync() == 0)
|
|
||||||
Assert.Skip("no post in the home feed has been liked");
|
|
||||||
await card.ScrollIntoViewIfNeededAsync();
|
|
||||||
await card.Locator("footer [title='Other'] > [role=button]").TapAsync();
|
|
||||||
await AssertWhole(page, "[data-sheet]");
|
|
||||||
await card.Locator("[data-liked-by]").TapAsync();
|
|
||||||
var dialog = page.Locator(".modal-open .modal-box").First;
|
|
||||||
await Assertions.Expect(dialog).ToBeVisibleAsync();
|
|
||||||
await AssertWhole(page, ".modal-open .modal-box");
|
|
||||||
var box = (await dialog.BoundingBoxAsync())!;
|
|
||||||
Assert.True(box.Y + box.Height >= page.ViewportSize!.Height - 2, "the dialog doesn't rise from the bottom of the screen");
|
|
||||||
await page.Locator(".modal-open [data-modal-close]").TapAsync();
|
|
||||||
await Assertions.Expect(page.Locator(".modal-open")).ToHaveCountAsync(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_new_post_is_written_in_a_sheet_from_a_scrolled_feed()
|
|
||||||
{
|
|
||||||
var persona = Persona();
|
|
||||||
var text = $"from a phone {Guid.NewGuid():N}"[..24];
|
|
||||||
await using var session = await SignIn.As(browser, persona, Phone);
|
|
||||||
string made = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Flow(session, "compose", async page =>
|
|
||||||
{
|
|
||||||
await page.EvaluateAsync("() => document.getElementById('feed-scroll').scrollTo({ top: 1500, behavior: 'instant' })");
|
|
||||||
await page.Locator("[data-compose-open]").TapAsync();
|
|
||||||
var sheet = page.Locator("[data-sheet]:has(textarea[data-compose])").First;
|
|
||||||
await Assertions.Expect(sheet).ToBeVisibleAsync();
|
|
||||||
await AssertWhole(page, "[data-sheet]");
|
|
||||||
Assert.True(await page.EvaluateAsync<bool>("() => document.activeElement?.matches('textarea[data-compose]') === true"),
|
|
||||||
"the composer's words don't have the focus");
|
|
||||||
await page.Keyboard.TypeAsync(text);
|
|
||||||
await sheet.Locator("button[type=submit]").TapAsync();
|
|
||||||
await Assertions.Expect(sheet).ToBeHiddenAsync(new() { Timeout = 15000 });
|
|
||||||
JsonNode latest = null;
|
|
||||||
Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue<string>().Contains(text) == true, 15),
|
|
||||||
"the post was never published");
|
|
||||||
made = latest!["id"]!.GetValue<string>();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (made is not null)
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_reply_is_written_in_a_sheet()
|
|
||||||
{
|
|
||||||
var persona = Persona();
|
|
||||||
var text = $"a phone reply {Guid.NewGuid():N}"[..24];
|
|
||||||
await using var session = await SignIn.As(browser, persona, Phone);
|
|
||||||
string made = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Flow(session, "reply", async page =>
|
|
||||||
{
|
|
||||||
var card = Card(page, "footer button[title='Reply']");
|
|
||||||
await card.Locator("footer button[title='Reply']").TapAsync();
|
|
||||||
var sheet = page.Locator("[data-sheet]:has(textarea[data-compose])").First;
|
|
||||||
await Assertions.Expect(sheet).ToBeVisibleAsync();
|
|
||||||
await AssertWhole(page, "[data-sheet]");
|
|
||||||
await sheet.Locator("textarea[data-compose]").FillAsync(text);
|
|
||||||
await sheet.Locator("button[type=submit]").TapAsync();
|
|
||||||
await Assertions.Expect(sheet).ToBeHiddenAsync(new() { Timeout = 15000 });
|
|
||||||
JsonNode latest = null;
|
|
||||||
Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue<string>().Contains(text) == true, 15),
|
|
||||||
"the reply was never published");
|
|
||||||
made = latest!["id"]!.GetValue<string>();
|
|
||||||
Assert.NotNull(latest["in_reply_to_id"]?.GetValue<string>());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (made is not null)
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_reaction_is_picked_from_a_sheet()
|
|
||||||
{
|
|
||||||
await using var session = await SignIn.As(browser, Persona(), Phone);
|
|
||||||
await Flow(session, "react", async page =>
|
|
||||||
{
|
|
||||||
var card = Card(page, "footer [title='React']");
|
|
||||||
var uri = await card.GetAttributeAsync("data-uri");
|
|
||||||
card = page.Locator($"#feed-scroll article[data-uri='{uri}']").First;
|
|
||||||
await card.Locator("footer [title='React'] > [role=button]").TapAsync();
|
|
||||||
await AssertWhole(page, "[data-sheet]");
|
|
||||||
await card.Locator("[data-sheet] button", new() { HasText = "😮" }).First.TapAsync();
|
|
||||||
var notice = page.Locator("[data-public-notice].modal-open");
|
|
||||||
if (await notice.IsVisibleAsync() || await Pasture.Eventually(() => notice.IsVisibleAsync(), 2))
|
|
||||||
{
|
|
||||||
await AssertWhole(page, "[data-public-notice] .modal-box");
|
|
||||||
await page.Locator("[data-public-notice-yes]").TapAsync();
|
|
||||||
}
|
|
||||||
var mine = card.Locator("[data-reactions] button.btn-active", new() { HasText = "😮" });
|
|
||||||
await Assertions.Expect(mine).ToBeVisibleAsync(new() { Timeout = 15000 });
|
|
||||||
// and taken back
|
|
||||||
await mine.TapAsync();
|
|
||||||
await Assertions.Expect(mine).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task The_globe_opens_full_screen_from_its_banner_and_closes()
|
|
||||||
{
|
|
||||||
await using var session = await SignIn.As(browser, Persona(), Phone);
|
|
||||||
await Flow(session, "globe", async page =>
|
|
||||||
{
|
|
||||||
await page.Locator("button[title='Globe']").TapAsync();
|
|
||||||
var expand = page.Locator("[data-globe-expand]");
|
|
||||||
await Assertions.Expect(expand).ToBeVisibleAsync();
|
|
||||||
await expand.TapAsync();
|
|
||||||
var full = page.Locator("aside[role=dialog]");
|
|
||||||
await Assertions.Expect(full).ToBeVisibleAsync();
|
|
||||||
var box = (await full.BoundingBoxAsync())!;
|
|
||||||
var size = page.ViewportSize!;
|
|
||||||
Assert.True(Math.Abs(box.Width - size.Width) < 2 && Math.Abs(box.Height - size.Height) < 2,
|
|
||||||
$"the globe is {box.Width}x{box.Height}, not the screen's {size.Width}x{size.Height}");
|
|
||||||
await AssertWhole(page, "aside[role=dialog]");
|
|
||||||
await page.Locator("[data-globe-close]").TapAsync();
|
|
||||||
await Assertions.Expect(full).ToHaveCountAsync(0);
|
|
||||||
await Assertions.Expect(expand).ToBeVisibleAsync();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// a phone held sideways keeps its feed: no banner squeezing it, the globe full screen from the tabs instead
|
|
||||||
[Fact]
|
|
||||||
public async Task A_phone_held_sideways_keeps_its_feed()
|
|
||||||
{
|
|
||||||
await using var session = await SignIn.As(browser, Persona(), Phone.Pixel7Landscape);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.mobile.flow.landscape", "privapub", Phone.Pixel7Landscape.Key, async () =>
|
|
||||||
{
|
|
||||||
Assert.False(await page.Locator("[data-globe-expand]").IsVisibleAsync(), "the globe's banner squeezes the feed");
|
|
||||||
var feedHeight = await page.EvaluateAsync<double>("() => document.getElementById('feed-scroll').clientHeight");
|
|
||||||
Assert.True(feedHeight >= 180, $"the feed is {feedHeight}px tall");
|
|
||||||
Assert.True(await page.EvaluateAsync<bool>("() => document.scrollingElement.scrollWidth <= innerWidth + 1"), "the page scrolls sideways");
|
|
||||||
await page.Locator("[data-globe-full]").TapAsync();
|
|
||||||
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToBeVisibleAsync();
|
|
||||||
await page.Locator("[data-globe-close]").TapAsync();
|
|
||||||
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToHaveCountAsync(0);
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Deleting_a_post_asks_first()
|
|
||||||
{
|
|
||||||
var persona = Persona();
|
|
||||||
var text = $"to be deleted {Guid.NewGuid():N}"[..26];
|
|
||||||
var made = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "public" }))!;
|
|
||||||
var id = made["id"]!.GetValue<string>();
|
|
||||||
await using var session = await SignIn.As(browser, persona, Phone);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Flow(session, "delete", async page =>
|
|
||||||
{
|
|
||||||
var card = page.Locator($"#feed-scroll article[data-uri='{made["uri"]}']").First;
|
|
||||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
var more = card.Locator("footer [title='Other'] > [role=button]");
|
|
||||||
await more.TapAsync();
|
|
||||||
await card.Locator("[data-sheet] button", new() { HasText = "Delete" }).TapAsync();
|
|
||||||
var confirm = page.Locator("[data-confirm].modal-open");
|
|
||||||
await Assertions.Expect(confirm).ToBeVisibleAsync();
|
|
||||||
await AssertWhole(page, "[data-confirm] .modal-box");
|
|
||||||
await page.Locator("[data-confirm-no]").TapAsync();
|
|
||||||
await Assertions.Expect(confirm).ToHaveCountAsync(0);
|
|
||||||
Assert.NotNull(await PersonaApi.Call(persona, $"/api/v1/statuses/{id}"));
|
|
||||||
|
|
||||||
await more.TapAsync();
|
|
||||||
await card.Locator("[data-sheet] button", new() { HasText = "Delete" }).TapAsync();
|
|
||||||
await page.Locator("[data-confirm-yes]").TapAsync();
|
|
||||||
await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
|
||||||
Assert.True(await Pasture.Eventually(async () => await PersonaApi.Call(persona, $"/api/v1/statuses/{id}") is null, 15), "the post is still there");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// a tap on a post (in its margin, not on a link or a control) opens its thread
|
|
||||||
[Fact]
|
|
||||||
public async Task A_tap_on_a_post_opens_its_thread()
|
|
||||||
{
|
|
||||||
await using var session = await SignIn.As(browser, Persona(), Phone);
|
|
||||||
await Flow(session, "thread", async page =>
|
|
||||||
{
|
|
||||||
var card = Card(page, "a[data-thread-link]");
|
|
||||||
var box = (await card.BoundingBoxAsync())!;
|
|
||||||
await card.TapAsync(new() { Position = new() { X = box.Width - 5, Y = box.Height * 0.35f } });
|
|
||||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath.StartsWith("/expand/"), new() { Timeout = 15000 });
|
|
||||||
Assert.Empty(await page.Locator("[data-globe-pinned]").AllAsync());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// a picture opens in the app, the whole screen, and closes
|
|
||||||
[Fact]
|
|
||||||
public async Task A_picture_opens_in_the_app()
|
|
||||||
{
|
|
||||||
var expected = (await Pasture.Town("expect")).AsArray();
|
|
||||||
var personas = Pasture.Personas();
|
|
||||||
var item = expected.FirstOrDefault(i => i!["alts"]!.AsArray().Count > 0
|
|
||||||
&& i["readers"]!.AsArray().Any(r => personas.Any(p => p.UserName == (string)r)));
|
|
||||||
Assert.SkipWhen(item is null, "the town holds no post with pictures");
|
|
||||||
var reader = personas.First(p => item!["readers"]!.AsArray().Any(r => (string)r == p.UserName));
|
|
||||||
await using var session = await SignIn.As(browser, reader, Phone);
|
|
||||||
await Flow(session, "picture", async page =>
|
|
||||||
{
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/expand/{item!["id"]}");
|
|
||||||
var card = page.Locator($"article[data-uri='{item["uri"]}']").First;
|
|
||||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
var hidden = card.GetByRole(AriaRole.Button).Filter(new() { HasText = "Sensitive media" });
|
|
||||||
for (var i = 0; i < 8 && await hidden.CountAsync() > 0; i++)
|
|
||||||
await hidden.First.TapAsync();
|
|
||||||
await card.Locator("button[data-picture]").First.TapAsync();
|
|
||||||
var viewer = page.Locator("[data-media-viewer].modal-open .modal-box");
|
|
||||||
await Assertions.Expect(viewer).ToBeVisibleAsync();
|
|
||||||
var box = (await viewer.BoundingBoxAsync())!;
|
|
||||||
var size = page.ViewportSize!;
|
|
||||||
Assert.True(box.Width >= size.Width - 2 && box.Height >= size.Height - 2, $"the picture viewer is {box.Width}x{box.Height}");
|
|
||||||
await AssertWhole(page, "[data-media-viewer] .modal-box");
|
|
||||||
if (await page.Locator("[data-picture-next]:enabled").CountAsync() > 0)
|
|
||||||
await page.Locator("[data-picture-next]").TapAsync();
|
|
||||||
await page.Locator("[data-media-viewer] [data-modal-close]").TapAsync();
|
|
||||||
await Assertions.Expect(page.Locator("[data-media-viewer].modal-open")).ToHaveCountAsync(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// the Back button closes what is open, one at a time, and stays on the page
|
|
||||||
[Fact]
|
|
||||||
public async Task Back_closes_what_is_open()
|
|
||||||
{
|
|
||||||
await using var session = await SignIn.As(browser, Persona(), Phone);
|
|
||||||
await Flow(session, "back", async page =>
|
|
||||||
{
|
|
||||||
var home = page.Url;
|
|
||||||
// a page to go back to, so a Back that isn't held would leave the feed
|
|
||||||
await page.Locator("[data-nav-toggle]").TapAsync();
|
|
||||||
await page.Locator("[data-nav-panel] a[href='explore']").TapAsync();
|
|
||||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore");
|
|
||||||
await page.Locator("[data-nav-toggle]").TapAsync();
|
|
||||||
await page.Locator("[data-nav-panel] a[href='/']").First.TapAsync();
|
|
||||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/");
|
|
||||||
await page.Locator("#feed-scroll article[data-uri]").First.WaitForAsync();
|
|
||||||
|
|
||||||
// (history.back(), as the Back button does: a Back held to close an overlay loads nothing, which GoBackAsync would
|
|
||||||
// wait for)
|
|
||||||
async Task BackCloses(ILocator open, string what)
|
|
||||||
{
|
|
||||||
await Assertions.Expect(open).ToBeVisibleAsync();
|
|
||||||
await page.EvaluateAsync("() => history.back()");
|
|
||||||
await Assertions.Expect(open).ToBeHiddenAsync(new() { Timeout = 5000 });
|
|
||||||
Assert.True(new Uri(page.Url).AbsolutePath == "/", $"Back after the {what} left the feed for {page.Url}");
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.Locator("[data-nav-toggle]").TapAsync();
|
|
||||||
await BackCloses(page.Locator("[data-nav-panel]"), "menu");
|
|
||||||
|
|
||||||
var card = Card(page, "footer [title='Other']");
|
|
||||||
await card.Locator("footer [title='Other'] > [role=button]").TapAsync();
|
|
||||||
await BackCloses(card.Locator(".dropdown-open > [data-sheet]"), "More sheet");
|
|
||||||
|
|
||||||
await page.Locator("[data-compose-open]").TapAsync();
|
|
||||||
await BackCloses(page.Locator("[data-sheet]:has(textarea[data-compose])"), "composer");
|
|
||||||
|
|
||||||
await page.Locator("button[title='Globe']").TapAsync();
|
|
||||||
await page.Locator("[data-globe-expand]").TapAsync();
|
|
||||||
await BackCloses(page.Locator("aside[role=dialog]"), "full-screen globe");
|
|
||||||
|
|
||||||
// with nothing open, Back leaves the feed as before
|
|
||||||
await page.EvaluateAsync("() => history.back()");
|
|
||||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore", new() { Timeout = 10000 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task<JsonNode> Latest(Pasture.Persona persona) =>
|
|
||||||
(await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Mobile
|
|
||||||
{
|
|
||||||
// Every page at every portrait phone: nothing scrolls sideways or lies past the screen, every open menu and dialog is
|
|
||||||
// whole, every target has a 44px hit area and no control's text is under 12px. Two cells per page and phone,
|
|
||||||
// client.mobile.fit.* and client.mobile.touch.*; the screenshots and findings go to tests/e2e/out/mobile/.
|
|
||||||
[Trait("Category", "Mobile")]
|
|
||||||
public sealed class RouteSweepTests(Browser browser)
|
|
||||||
{
|
|
||||||
public static TheoryData<string, string> Cases()
|
|
||||||
{
|
|
||||||
var data = new TheoryData<string, string>();
|
|
||||||
foreach (var phone in Phone.Portrait)
|
|
||||||
foreach (var route in Route.All)
|
|
||||||
data.Add(phone.Key, route.Slug);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[MemberData(nameof(Cases))]
|
|
||||||
public async Task Fits_a_phone(string phoneKey, string slug)
|
|
||||||
{
|
|
||||||
var phone = Phone.ByKey(phoneKey);
|
|
||||||
var route = Route.Get(slug);
|
|
||||||
var persona = route.Persona();
|
|
||||||
await using var session = route.SignedOut ? await browser.Open(phone: phone) : await SignIn.As(browser, persona, phone);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await route.Open(session.Page, persona);
|
|
||||||
}
|
|
||||||
catch (Exception e) when (e is not Xunit.Sdk.SkipException)
|
|
||||||
{
|
|
||||||
await SignIn.Evidence(session.Page, $"open-{phone.Key}-{slug}");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
var report = await Fit.Sweep(session.Page, phone, slug, route.Screens);
|
|
||||||
if (!route.Gating)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var problems = new List<string>();
|
|
||||||
foreach (var (feature, check) in new (string, Action<Fit.Report>)[]
|
|
||||||
{
|
|
||||||
($"client.mobile.fit.{slug}", Fit.AssertFits), ($"client.mobile.touch.{slug}", Fit.AssertTouchable)
|
|
||||||
})
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Results.Cell(feature, "privapub", phone.Key, () =>
|
|
||||||
{
|
|
||||||
check(report);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
problems.Add(e.Message);
|
|
||||||
}
|
|
||||||
await session.AssertGuards();
|
|
||||||
Assert.True(problems.Count == 0, string.Join("\n\n", problems));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Mobile
|
|
||||||
{
|
|
||||||
// The pages the phone sweep visits, each opened as a reader would get there: signed in as a town persona (the one with
|
|
||||||
// sibling personas, unless the page shows another's post), or signed out for the login page. Screens is how far down
|
|
||||||
// the page's main scroller the sweep measures.
|
|
||||||
public sealed record Route(string Slug, Func<IPage, Pasture.Persona, Task> Open, Func<Pasture.Persona> Reader = null,
|
|
||||||
int Screens = 8, bool SignedOut = false, bool Gating = true)
|
|
||||||
{
|
|
||||||
static readonly Lazy<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
|
|
||||||
|
|
||||||
static Pasture.Persona Sweeper() => Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
|
|
||||||
|
|
||||||
static readonly Pasture.Persona Admin = new("alice", "alice", PastureAdmin.Root, PastureAdmin.Password, null, null, null);
|
|
||||||
|
|
||||||
public Pasture.Persona Persona() => (Reader ?? Sweeper)();
|
|
||||||
|
|
||||||
public static readonly Route[] All =
|
|
||||||
[
|
|
||||||
new("feed-home", (_, _) => Task.CompletedTask),
|
|
||||||
new("feed-local", Tab(1), Screens: 6),
|
|
||||||
new("feed-federated", Tab(2), Screens: 6),
|
|
||||||
new("feed-nearby", Tab(3), Screens: 4),
|
|
||||||
new("feed-groups", Tab(4), Screens: 6),
|
|
||||||
new("feed-direct", Tab(5), Screens: 6),
|
|
||||||
new("feed-notifications", Tab(6), Screens: 6),
|
|
||||||
new("feed-favourites", Tab(7), Screens: 4),
|
|
||||||
new("feed-bookmarks", Tab(8), Screens: 4),
|
|
||||||
new("feed-lists", Tab(9), Screens: 4),
|
|
||||||
Thread("thread-poll", i => i["poll"] is JsonArray),
|
|
||||||
Thread("thread-pictures", i => i["alts"]!.AsArray().Count > 0),
|
|
||||||
Thread("thread-titled", i => i["title"] is not null),
|
|
||||||
Thread("thread-quote", i => i["quote"] is not null),
|
|
||||||
Thread("thread-warning", i => i["cw"] is not null),
|
|
||||||
Thread("thread-replies", i => (string)i["kind"] == "thread"),
|
|
||||||
new("profile-own", async (page, persona) => await Visit(page, $"/account/{persona.LocalId}"), Screens: 10),
|
|
||||||
new("profile-followed", async (page, persona) =>
|
|
||||||
{
|
|
||||||
var followed = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=1"))?.AsArray().FirstOrDefault();
|
|
||||||
if (followed is null)
|
|
||||||
Assert.Skip("the persona follows nobody");
|
|
||||||
await Visit(page, $"/account/{followed["id"]}");
|
|
||||||
}, Screens: 10),
|
|
||||||
new("search", async (page, persona) =>
|
|
||||||
{
|
|
||||||
var other = Pasture.Personas().First(p => p.Root != persona.Root);
|
|
||||||
await Visit(page, $"/search?q={Uri.EscapeDataString(other.UserName)}");
|
|
||||||
}),
|
|
||||||
new("explore-hashtags", async (page, _) => await Visit(page, "/explore")),
|
|
||||||
new("explore-posts", ExploreTab(1)),
|
|
||||||
new("explore-links", ExploreTab(2)),
|
|
||||||
new("explore-people", ExploreTab(3)),
|
|
||||||
new("explore-followed", ExploreTab(4)),
|
|
||||||
new("lists", async (page, _) => await Visit(page, "/lists")),
|
|
||||||
new("groups", async (page, _) => await Visit(page, "/groups")),
|
|
||||||
new("filters", async (page, _) => await Visit(page, "/filters")),
|
|
||||||
new("scheduled", async (page, _) => await Visit(page, "/scheduled")),
|
|
||||||
new("notifications-filtered", async (page, _) => await Visit(page, "/notifications/filtered")),
|
|
||||||
new("settings", async (page, _) =>
|
|
||||||
{
|
|
||||||
await Visit(page, "/settings");
|
|
||||||
await OpenEveryCollapse(page);
|
|
||||||
}, Screens: 30),
|
|
||||||
new("administration", async (page, _) =>
|
|
||||||
{
|
|
||||||
await Visit(page, "/administration");
|
|
||||||
await OpenEveryCollapse(page);
|
|
||||||
}, () => Admin, Screens: 30),
|
|
||||||
new("login", async (page, _) => await Visit(page, "/login"), SignedOut: true),
|
|
||||||
new("setup", async (page, _) => await Visit(page, "/setup"), SignedOut: true),
|
|
||||||
new("join", async (page, _) => await Visit(page, "/join"), SignedOut: true),
|
|
||||||
new("styleguide", async (page, _) => await Visit(page, "/styleguide"), SignedOut: true, Screens: 40, Gating: false)
|
|
||||||
];
|
|
||||||
|
|
||||||
public static Route Get(string slug) => All.Single(r => r.Slug == slug);
|
|
||||||
|
|
||||||
static async Task Visit(IPage page, string path)
|
|
||||||
{
|
|
||||||
await page.GotoAsync(Pasture.Site + path);
|
|
||||||
await Fit.Settle(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
// the feed's tab at this place: Home, Local, Federation, Nearby, Groups, Direct, Notifications, Favourites,
|
|
||||||
// Bookmarks, Lists (FeedTabs.Always)
|
|
||||||
static Func<IPage, Pasture.Persona, Task> Tab(int index) => async (page, _) =>
|
|
||||||
{
|
|
||||||
await page.Locator("[role=tablist]").First.Locator("[role=tab]").Nth(index).TapAsync();
|
|
||||||
await Fit.Settle(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
static Func<IPage, Pasture.Persona, Task> ExploreTab(int index) => async (page, _) =>
|
|
||||||
{
|
|
||||||
await Visit(page, "/explore");
|
|
||||||
await page.Locator("[role=tablist]").First.Locator("[role=tab]").Nth(index).TapAsync();
|
|
||||||
await Fit.Settle(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
// a town post of the kind wanted, opened as its thread by a persona it was for
|
|
||||||
static Route Thread(string slug, Func<JsonNode, bool> wanted)
|
|
||||||
{
|
|
||||||
JsonNode Pick() => Expected.Value.FirstOrDefault(i => wanted(i!) && ReaderOf(i) is not null);
|
|
||||||
return new(slug, async (page, _) =>
|
|
||||||
{
|
|
||||||
if (Pick() is not { } item)
|
|
||||||
{
|
|
||||||
Assert.Skip($"the town holds no post for {slug}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/expand/{item["id"]}");
|
|
||||||
await page.Locator($"article[data-uri='{item["uri"]}']").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
await Fit.Settle(page);
|
|
||||||
}, () => Pick() is { } item ? ReaderOf(item) : Sweeper(), Screens: 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Pasture.Persona ReaderOf(JsonNode item)
|
|
||||||
{
|
|
||||||
var personas = Pasture.Personas();
|
|
||||||
return item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == (string)r)).FirstOrDefault(p => p is not null);
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task OpenEveryCollapse(IPage page)
|
|
||||||
{
|
|
||||||
var closed = page.Locator(".collapse-title[aria-expanded=false]");
|
|
||||||
for (var i = 0; i < 20 && await closed.CountAsync() > 0; i++)
|
|
||||||
{
|
|
||||||
await closed.First.TapAsync();
|
|
||||||
await Fit.Settle(page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
})()
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Perf
|
|
||||||
{
|
|
||||||
// The home feed with thousands of posts behind it: load0's home, filled by SocialPub's load runs
|
|
||||||
// (tools/pasture/load.sh, the flood peer). Scrolls it until 100, 500, 1000 and 2000 posts are loaded (the feed renders
|
|
||||||
// only the cards near the view, so it counts what it holds, data-feed-count) and measures, at each, how long the
|
|
||||||
// pages took, the page's size (DOM nodes, JS and .NET heaps) and the long tasks; and the time to the first card. Writes perf.json next to the test output; nothing is asserted beyond reaching the cards, the numbers are
|
|
||||||
// for comparing runs.
|
|
||||||
[Trait("Category", "Perf")]
|
|
||||||
public sealed class FeedPerfTests(Browser browser)
|
|
||||||
{
|
|
||||||
static readonly int[] Steps = [100, 500, 1000, 2000];
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_long_home_feed_scrolls_to_two_thousand_cards()
|
|
||||||
{
|
|
||||||
var persona = new Pasture.Persona("load0", "load0", "pastureroot", "Pasture-Pass-1!", null, null, null);
|
|
||||||
await using var session = await SignIn.As(browser, persona, fresh: true);
|
|
||||||
var page = session.Page;
|
|
||||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
|
||||||
await page.AddInitScriptAsync(@"window.__longTasks = []; new PerformanceObserver(list => list.getEntries().forEach(e => window.__longTasks.push(e.duration))).observe({ type: 'longtask', buffered: true });");
|
|
||||||
|
|
||||||
var clock = Stopwatch.StartNew();
|
|
||||||
await page.GotoAsync($"{Pasture.Site}/");
|
|
||||||
var cards = page.Locator("article[data-uri]");
|
|
||||||
await cards.First.WaitForAsync(new() { Timeout = 60000 });
|
|
||||||
var firstCard = clock.Elapsed.TotalMilliseconds;
|
|
||||||
var loaded = page.Locator("[data-feed-count]");
|
|
||||||
async Task<int> Loaded() => int.Parse(await loaded.GetAttributeAsync("data-feed-count") ?? "0");
|
|
||||||
Assert.SkipWhen(await Loaded() < 20, "load0's home is short: run SocialPub's tools/pasture/load.sh first");
|
|
||||||
|
|
||||||
var report = new JsonObject { ["firstCardMs"] = Math.Round(firstCard), ["steps"] = new JsonArray() };
|
|
||||||
foreach (var target in Steps)
|
|
||||||
{
|
|
||||||
var step = Stopwatch.StartNew();
|
|
||||||
var count = await Loaded();
|
|
||||||
var stalled = Stopwatch.StartNew();
|
|
||||||
while (count < target && stalled.Elapsed < TimeSpan.FromSeconds(20))
|
|
||||||
{
|
|
||||||
await page.EvaluateAsync("() => { const s = document.getElementById('feed-scroll'); s.scrollTop = s.scrollHeight; }");
|
|
||||||
await page.WaitForTimeoutAsync(150);
|
|
||||||
var now = await Loaded();
|
|
||||||
if (now > count)
|
|
||||||
stalled.Restart();
|
|
||||||
count = now;
|
|
||||||
}
|
|
||||||
var metrics = await page.EvaluateAsync<JsonElementHolder>(@"() => {
|
|
||||||
const heap = performance.memory ? performance.memory.usedJSHeapSize : null;
|
|
||||||
const runtime = globalThis.getDotnetRuntime ? globalThis.getDotnetRuntime(0) : null;
|
|
||||||
const wasm = runtime && runtime.Module && runtime.Module.HEAPU8 ? runtime.Module.HEAPU8.length : null;
|
|
||||||
const tasks = window.__longTasks.splice(0);
|
|
||||||
return { json: JSON.stringify({ dom: document.getElementsByTagName('*').length, jsHeap: heap, wasmHeap: wasm,
|
|
||||||
longTasks: tasks.length, longTaskMs: Math.round(tasks.reduce((a, b) => a + b, 0)) }) };
|
|
||||||
}");
|
|
||||||
var row = JsonNode.Parse(metrics.Json)!.AsObject();
|
|
||||||
row["target"] = target;
|
|
||||||
row["posts"] = count;
|
|
||||||
row["cards"] = await cards.CountAsync();
|
|
||||||
row["seconds"] = Math.Round(step.Elapsed.TotalSeconds, 1);
|
|
||||||
report["steps"]!.AsArray().Add(row);
|
|
||||||
if (count < target)
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var path = Path.Combine(AppContext.BaseDirectory, "perf.json");
|
|
||||||
await File.WriteAllTextAsync(path, report.ToJsonString(new() { WriteIndented = true }));
|
|
||||||
TestContext.Current.SendDiagnosticMessage(report.ToJsonString());
|
|
||||||
Assert.True(report["steps"]!.AsArray().Count > 0);
|
|
||||||
await session.AssertGuards();
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class JsonElementHolder
|
|
||||||
{
|
|
||||||
public string Json { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Render
|
|
||||||
{
|
|
||||||
// The globe's instruments (wwwroot/js/instruments.js): drawn in the desktop column and in a phone's full screen with
|
|
||||||
// the Sun where it should be, only the day/night line in a phone's banner, and an idle globe that draws nothing.
|
|
||||||
// Screenshots for the eye go to tests/e2e/out/globe/.
|
|
||||||
public sealed class GlobeTests(Browser browser)
|
|
||||||
{
|
|
||||||
static readonly string OutDir = Path.Combine(Directory.GetParent(AppContext.BaseDirectory)!.Parent!.Parent!.Parent!.FullName, "out", "globe");
|
|
||||||
|
|
||||||
static Pasture.Persona Persona() => Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
|
|
||||||
|
|
||||||
static async Task<JsonElement> Stats(IPage page) =>
|
|
||||||
await page.EvaluateAsync<JsonElement>("async () => (await import('./js/globe.js')).stats()");
|
|
||||||
|
|
||||||
static async Task Live(IPage page)
|
|
||||||
{
|
|
||||||
await page.Locator("[data-globe-state=live]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).TryGetProperty("instruments", out var i) && i.ValueKind == JsonValueKind.Object, 15),
|
|
||||||
"the instruments never reported");
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task Photo(IPage page, ILocator what, string name)
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(OutDir);
|
|
||||||
await page.WaitForTimeoutAsync(1500);
|
|
||||||
await what.ScreenshotAsync(new() { Path = Path.Combine(OutDir, name + ".png") });
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task The_desktop_column_draws_the_instruments_with_the_sun_in_place()
|
|
||||||
{
|
|
||||||
await using var session = await SignIn.As(browser, Persona());
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.render.globe", "privapub", "desktop", async () =>
|
|
||||||
{
|
|
||||||
await Live(page);
|
|
||||||
// a located post, written here from Milano and read back in Nearby, pinned: the globe shows its moment, its
|
|
||||||
// place and its day
|
|
||||||
await page.Context.GrantPermissionsAsync(["geolocation"]);
|
|
||||||
await page.Context.SetGeolocationAsync(new() { Latitude = 45.4642f, Longitude = 9.19f });
|
|
||||||
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
|
|
||||||
var text = $"the globe's instruments {Guid.NewGuid():N}"[..32];
|
|
||||||
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
|
||||||
await page.Locator("button[title='Located post']").ClickAsync();
|
|
||||||
var submit = page.Locator("form:has(textarea[data-compose]) button[type=submit]").First;
|
|
||||||
await Assertions.Expect(submit).ToBeEnabledAsync();
|
|
||||||
await submit.ClickAsync();
|
|
||||||
await Assertions.Expect(page.Locator("textarea[data-compose]")).ToHaveCountAsync(0);
|
|
||||||
await page.Locator("[role=tab][title='Nearby']").ClickAsync();
|
|
||||||
await page.GetByRole(AriaRole.Button, new() { Name = "Use my position" }).ClickAsync();
|
|
||||||
var card = page.Locator("#feed-scroll article[data-lat][data-post-at]", new() { HasText = text }).First;
|
|
||||||
await card.WaitForAsync(new() { Timeout = 20000 });
|
|
||||||
// the view flies to it first (with the pointer off the feed: a hovered post holds the view), then the card is pinned
|
|
||||||
await page.Mouse.MoveAsync(2, 2);
|
|
||||||
Assert.True(await Pasture.Eventually(async () =>
|
|
||||||
{
|
|
||||||
var view = await Stats(page);
|
|
||||||
return view.GetProperty("centroidLatitude").ValueKind == JsonValueKind.Number
|
|
||||||
&& Math.Abs(view.GetProperty("latitude").GetDouble() - view.GetProperty("centroidLatitude").GetDouble()) < 1;
|
|
||||||
}, 10), "the globe never looked at the located post");
|
|
||||||
var box = (await card.BoundingBoxAsync())!;
|
|
||||||
await card.ClickAsync(new() { Position = new() { X = box.Width - 6, Y = box.Height * 0.35f } });
|
|
||||||
await Assertions.Expect(page.Locator("[data-globe-pinned]")).ToHaveCountAsync(1);
|
|
||||||
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("instruments").GetProperty("chips").GetInt32() >= 6, 10),
|
|
||||||
"the pinned post's instruments have few chips");
|
|
||||||
var stats = await Stats(page);
|
|
||||||
var instruments = stats.GetProperty("instruments");
|
|
||||||
Assert.True(instruments.GetProperty("built").GetBoolean(), $"the instruments weren't built: {instruments}");
|
|
||||||
Assert.Equal("full", instruments.GetProperty("detail").GetString());
|
|
||||||
Assert.True(instruments.GetProperty("subsolarError").GetDouble() < 0.01, $"the Sun is drawn {instruments.GetProperty("subsolarError")} units off");
|
|
||||||
var globe = page.Locator("aside").First;
|
|
||||||
await Photo(page, globe, "desktop-light");
|
|
||||||
await File.WriteAllTextAsync(Path.Combine(OutDir, "desktop.json"), (await Stats(page)).ToString());
|
|
||||||
await page.EvaluateAsync("() => document.documentElement.setAttribute('data-theme', 'neo-dark')");
|
|
||||||
await Photo(page, globe, "desktop-dark");
|
|
||||||
// turned to the subsolar point, by dragging (about 3 px a degree; dragging left looks east, up looks south), from a
|
|
||||||
// little west and north of it, so that its ray stands out: the Sun's own instruments
|
|
||||||
var canvas = (await globe.Locator("canvas").First.BoundingBoxAsync())!;
|
|
||||||
var (cx, cy) = (canvas.X + canvas.Width / 2, canvas.Y + canvas.Height / 2);
|
|
||||||
for (var step = 0; step < 8; step++)
|
|
||||||
{
|
|
||||||
var now = await Stats(page);
|
|
||||||
var dLng = (now.GetProperty("sun").GetProperty("longitude").GetDouble() - 18 - now.GetProperty("longitude").GetDouble() + 540) % 360 - 180;
|
|
||||||
var dLat = now.GetProperty("sun").GetProperty("latitude").GetDouble() + 12 - now.GetProperty("latitude").GetDouble();
|
|
||||||
if (Math.Abs(dLng) < 8 && Math.Abs(dLat) < 8)
|
|
||||||
break;
|
|
||||||
await page.Mouse.MoveAsync(cx, cy);
|
|
||||||
await page.Mouse.DownAsync();
|
|
||||||
await page.Mouse.MoveAsync(cx - (float)Math.Clamp(dLng * 3, -200, 200), cy + (float)Math.Clamp(dLat * 3, -200, 200), new() { Steps = 10 });
|
|
||||||
await page.Mouse.UpAsync();
|
|
||||||
await page.WaitForTimeoutAsync(800);
|
|
||||||
}
|
|
||||||
await Photo(page, globe, "desktop-sun");
|
|
||||||
await File.WriteAllTextAsync(Path.Combine(OutDir, "desktop-sun.json"), (await Stats(page)).ToString());
|
|
||||||
// and taken back by its author, the one thing a located post's card offers
|
|
||||||
await card.Locator("[data-located-delete]").ClickAsync();
|
|
||||||
await page.Locator("[data-confirm-yes]").ClickAsync();
|
|
||||||
await Assertions.Expect(card).ToHaveCountAsync(0);
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_phone_banner_draws_little_and_its_full_screen_everything()
|
|
||||||
{
|
|
||||||
await using var session = await SignIn.As(browser, Persona(), Phone.Pixel7);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.render.globe", "privapub", "phone", async () =>
|
|
||||||
{
|
|
||||||
await page.Locator("button[title='Globe']").TapAsync();
|
|
||||||
await Live(page);
|
|
||||||
Assert.Equal("banner", (await Stats(page)).GetProperty("instruments").GetProperty("detail").GetString());
|
|
||||||
await page.Locator("[data-globe-expand]").TapAsync();
|
|
||||||
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToBeVisibleAsync();
|
|
||||||
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("instruments").GetProperty("detail").GetString() == "full", 10),
|
|
||||||
"the full screen globe has the banner's instruments");
|
|
||||||
// the camera moves back for a screen taller than wide, so the globe fits across it
|
|
||||||
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("altitude").GetDouble() > 3.5, 10),
|
|
||||||
"the full screen globe kept the banner's camera");
|
|
||||||
Assert.True((await Stats(page)).GetProperty("instruments").GetProperty("built").GetBoolean(), "the instruments weren't built");
|
|
||||||
await Photo(page, page.Locator("aside[role=dialog]"), "phone-full");
|
|
||||||
// and closed, the banner again
|
|
||||||
await page.Locator("[data-globe-close]").TapAsync();
|
|
||||||
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToHaveCountAsync(0);
|
|
||||||
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("instruments").GetProperty("detail").GetString() == "banner", 10),
|
|
||||||
"the banner kept the full screen's instruments");
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
// with reduced motion nothing turns or flies: once settled, no frame is drawn, the instruments' neither
|
|
||||||
[Fact]
|
|
||||||
public async Task An_idle_globe_draws_nothing()
|
|
||||||
{
|
|
||||||
await using var session = await SignIn.As(browser, Persona(), Phone.Pixel7);
|
|
||||||
var page = session.Page;
|
|
||||||
await Results.Cell("client.render.globe-idle", "privapub", "phone", async () =>
|
|
||||||
{
|
|
||||||
await page.Locator("button[title='Globe']").TapAsync();
|
|
||||||
await page.Locator("[data-globe-expand]").TapAsync();
|
|
||||||
await Live(page);
|
|
||||||
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("idle").GetBoolean(), 20), "the globe never settled");
|
|
||||||
var before = await Stats(page);
|
|
||||||
await page.WaitForTimeoutAsync(3000);
|
|
||||||
var after = await Stats(page);
|
|
||||||
Assert.Equal(before.GetProperty("frame").GetInt32(), after.GetProperty("frame").GetInt32());
|
|
||||||
Assert.Equal(before.GetProperty("instruments").GetProperty("frames").GetInt32(), after.GetProperty("instruments").GetProperty("frames").GetInt32());
|
|
||||||
Assert.Equal(0, after.GetProperty("fps").GetInt32());
|
|
||||||
await session.AssertGuards();
|
|
||||||
}, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
|
||||||
|
|
||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Render
|
|
||||||
{
|
|
||||||
// One object of each kind from each platform, opened in decePub (/expand/{id}) by a persona it was for, and checked
|
|
||||||
// against what its author put in it: the text, the content warning, every picture's alt text and its proxied source,
|
|
||||||
// the poll's options, the quoted post. town.sh expect says what each object holds and who may read it.
|
|
||||||
public sealed class RenderMatrixTests(Browser browser)
|
|
||||||
{
|
|
||||||
static readonly Lazy<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
|
|
||||||
|
|
||||||
public static TheoryData<string, string, string> Samples()
|
|
||||||
{
|
|
||||||
var data = new TheoryData<string, string, string>();
|
|
||||||
var seen = new HashSet<string>();
|
|
||||||
var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet();
|
|
||||||
foreach (var item in Expected.Value)
|
|
||||||
{
|
|
||||||
var key = $"{item!["origin"]}|{item["kind"]}|{item["visibility"]}";
|
|
||||||
if (!item["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue<string>())) || !seen.Add(key))
|
|
||||||
continue;
|
|
||||||
data.Add(item["origin"]!.GetValue<string>(), $"{item["kind"]}.{item["visibility"]}", item["ref"]!.GetValue<string>());
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[MemberData(nameof(Samples))]
|
|
||||||
public async Task Renders_as_written(string origin, string kind, string reference)
|
|
||||||
{
|
|
||||||
var item = Expected.Value.First(i => i!["ref"]!.GetValue<string>() == reference)!;
|
|
||||||
var personas = Pasture.Personas();
|
|
||||||
var reader = item["readers"]!.AsArray().Select(r => r!.GetValue<string>())
|
|
||||||
.Select(name => personas.FirstOrDefault(p => p.UserName == name)).First(p => p is not null);
|
|
||||||
|
|
||||||
await Results.Cell($"client.render.{kind}", origin, reference, async () =>
|
|
||||||
{
|
|
||||||
await using var session = await SignIn.As(browser, reader);
|
|
||||||
await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue<string>()}");
|
|
||||||
var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue<string>()}']").First;
|
|
||||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
|
|
||||||
var marker = item["marker"]!.GetValue<string>();
|
|
||||||
if (item["cw"]?.GetValue<string>() is { } cw)
|
|
||||||
{
|
|
||||||
await Assertions.Expect(card).ToContainTextAsync(cw);
|
|
||||||
Assert.False(await card.GetByText(marker).IsVisibleAsync(), "the text shows before its content warning is opened");
|
|
||||||
await card.GetByText(cw).First.ClickAsync();
|
|
||||||
}
|
|
||||||
await Assertions.Expect(card.Locator(".post-content").First).ToContainTextAsync(marker);
|
|
||||||
|
|
||||||
// each reveal re-renders the media, so reveal the first hidden one until none is left
|
|
||||||
var hidden = card.GetByRole(AriaRole.Button).Filter(new() { HasText = "Sensitive media" });
|
|
||||||
for (var i = 0; i < 8 && await hidden.CountAsync() > 0; i++)
|
|
||||||
await hidden.First.ClickAsync();
|
|
||||||
foreach (var alt in item["alts"]!.AsArray().Select(a => a!.GetValue<string>()))
|
|
||||||
{
|
|
||||||
var picture = card.Locator($"img[alt='{alt}']").First;
|
|
||||||
await Assertions.Expect(picture).ToBeVisibleAsync();
|
|
||||||
var src = new Uri(await picture.GetAttributeAsync("src") ?? "");
|
|
||||||
Assert.Equal("privapub.test", src.Host);
|
|
||||||
Assert.True(await picture.EvaluateAsync<bool>("img => img.complete && img.naturalWidth > 0"), $"picture '{alt}' did not load");
|
|
||||||
}
|
|
||||||
if (item["poll"] is JsonArray options)
|
|
||||||
foreach (var option in options)
|
|
||||||
await Assertions.Expect(card).ToContainTextAsync(option!.GetValue<string>());
|
|
||||||
if (item["quote"]?.GetValue<string>() is { } quoted)
|
|
||||||
{
|
|
||||||
var quotedRef = Expected.Value.FirstOrDefault(i => i!["uri"]!.GetValue<string>() == quoted)?["marker"]?.GetValue<string>();
|
|
||||||
Assert.True(await card.Locator("article").CountAsync() > 0, "the quoted post is not shown");
|
|
||||||
if (quotedRef is not null)
|
|
||||||
await Assertions.Expect(card.Locator("article").First).ToContainTextAsync(quotedRef);
|
|
||||||
}
|
|
||||||
if (item["title"]?.GetValue<string>() is { } title)
|
|
||||||
await Assertions.Expect(card).ToContainTextAsync(title);
|
|
||||||
await session.AssertGuards();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
[assembly: AssemblyFixture(typeof(decePubClient.E2E.Support.Browser))]
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Support
|
|
||||||
{
|
|
||||||
// One Chromium for the whole run. *.test names go to Caddy on 127.0.0.1:6443 (Chromium's own --resolve), whose
|
|
||||||
// certificates come from the pasture's private CA, so certificate errors are ignored; nothing else is reachable.
|
|
||||||
public sealed class Browser : IAsyncLifetime
|
|
||||||
{
|
|
||||||
IPlaywright _playwright;
|
|
||||||
public IBrowser Chromium { get; private set; }
|
|
||||||
|
|
||||||
public async ValueTask InitializeAsync()
|
|
||||||
{
|
|
||||||
_playwright = await Playwright.CreateAsync();
|
|
||||||
Chromium = await _playwright.Chromium.LaunchAsync(new()
|
|
||||||
{
|
|
||||||
Headless = Environment.GetEnvironmentVariable("E2E_HEADED") != "1",
|
|
||||||
Args = ["--host-resolver-rules=MAP *.test 127.0.0.1:6443"]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
if (Chromium is not null)
|
|
||||||
await Chromium.CloseAsync();
|
|
||||||
_playwright?.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
// a desktop window of 1440x900, or a phone: touch, a coarse pointer, its size and scale, reduced motion so nothing is
|
|
||||||
// measured halfway through a transition, and the mobile tests' measuring script (Mobile/fit.js) on every page
|
|
||||||
public async Task<Session> Open(string storageState = null, Phone phone = null)
|
|
||||||
{
|
|
||||||
var options = phone is null
|
|
||||||
? new BrowserNewContextOptions { ViewportSize = new() { Width = 1440, Height = 900 } }
|
|
||||||
: new BrowserNewContextOptions(_playwright.Devices[phone.Preset]) { ReducedMotion = ReducedMotion.Reduce };
|
|
||||||
if (phone is { Width: > 0 })
|
|
||||||
{
|
|
||||||
options.ViewportSize = new() { Width = phone.Width, Height = phone.Height };
|
|
||||||
options.ScreenSize = new() { Width = phone.Width, Height = phone.Height };
|
|
||||||
}
|
|
||||||
options.IgnoreHTTPSErrors = true;
|
|
||||||
options.Locale = "en-GB";
|
|
||||||
options.TimezoneId = "Europe/Rome";
|
|
||||||
options.ServiceWorkers = ServiceWorkerPolicy.Block;
|
|
||||||
options.StorageStatePath = storageState;
|
|
||||||
var context = await Chromium.NewContextAsync(options);
|
|
||||||
if (phone is not null)
|
|
||||||
await context.AddInitScriptAsync(Mobile.Fit.Script);
|
|
||||||
return new Session(context, await context.NewPageAsync(), phone);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A phone the tests hold: one of Playwright's device presets (they ship inside its package, nothing is downloaded),
|
|
||||||
// at its own size or at another one
|
|
||||||
public sealed record Phone(string Key, string Preset, int Width = 0, int Height = 0)
|
|
||||||
{
|
|
||||||
public static readonly Phone Narrow360 = new("narrow360", "Pixel 7", 360, 800);
|
|
||||||
public static readonly Phone IPhoneSE3 = new("iphone-se3", "iPhone SE (3rd gen)");
|
|
||||||
public static readonly Phone Pixel7 = new("pixel7", "Pixel 7");
|
|
||||||
public static readonly Phone Pixel7Landscape = new("pixel7-landscape", "Pixel 7 landscape");
|
|
||||||
|
|
||||||
public static readonly Phone[] Portrait = [Narrow360, IPhoneSE3, Pixel7];
|
|
||||||
|
|
||||||
public static Phone ByKey(string key) => new[] { Narrow360, IPhoneSE3, Pixel7, Pixel7Landscape }.Single(p => p.Key == key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A browser tab and what it did: every request's host, every console error, every 5xx and Blazor's error bar. The
|
|
||||||
// guards hold for every test: the app talks only to its own site and to PrivaPub, and never fails silently.
|
|
||||||
public sealed class Session : IAsyncDisposable
|
|
||||||
{
|
|
||||||
public IBrowserContext Context { get; }
|
|
||||||
public IPage Page { get; }
|
|
||||||
public List<string> Hosts { get; } = [];
|
|
||||||
public List<string> ConsoleErrors { get; } = [];
|
|
||||||
public List<string> ServerErrors { get; } = [];
|
|
||||||
public List<(string Url, string Authorization)> ApiCalls { get; } = [];
|
|
||||||
|
|
||||||
public Phone Phone { get; }
|
|
||||||
|
|
||||||
public Session(IBrowserContext context, IPage page, Phone phone = null)
|
|
||||||
{
|
|
||||||
Context = context;
|
|
||||||
Page = page;
|
|
||||||
Phone = phone;
|
|
||||||
page.Request += (_, request) =>
|
|
||||||
{
|
|
||||||
var uri = new Uri(request.Url);
|
|
||||||
if (uri.Scheme is "http" or "https")
|
|
||||||
lock (Hosts)
|
|
||||||
Hosts.Add(uri.Host);
|
|
||||||
if (uri.Host == "privapub.test")
|
|
||||||
lock (ApiCalls)
|
|
||||||
ApiCalls.Add((request.Url, request.Headers.TryGetValue("authorization", out var auth) ? auth : null));
|
|
||||||
};
|
|
||||||
page.Console += (_, message) =>
|
|
||||||
{
|
|
||||||
if (message.Type == "error")
|
|
||||||
lock (ConsoleErrors)
|
|
||||||
ConsoleErrors.Add(message.Text);
|
|
||||||
};
|
|
||||||
page.Response += (_, response) =>
|
|
||||||
{
|
|
||||||
if (response.Status >= 500)
|
|
||||||
lock (ServerErrors)
|
|
||||||
ServerErrors.Add($"{response.Status} {response.Url}");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task AssertGuards()
|
|
||||||
{
|
|
||||||
Assert.All(Hosts.Distinct(), host => Assert.Contains(host, new[] { "decepub.test", "privapub.test" }));
|
|
||||||
Assert.Empty(ServerErrors);
|
|
||||||
Assert.False(await Page.Locator("#blazor-error-ui").IsVisibleAsync(), "Blazor's error bar is showing");
|
|
||||||
// /clientapi carries the root's JWT and /api a persona's token: no credential ever crosses over
|
|
||||||
var clientApi = ApiCalls.Where(c => c.Authorization is not null && new Uri(c.Url).AbsolutePath.StartsWith("/clientapi/"))
|
|
||||||
.Select(c => c.Authorization).ToHashSet();
|
|
||||||
var mastodonApi = ApiCalls.Where(c => c.Authorization is not null && new Uri(c.Url).AbsolutePath.StartsWith("/api/"))
|
|
||||||
.Select(c => c.Authorization).ToHashSet();
|
|
||||||
Assert.Empty(clientApi.Intersect(mastodonApi));
|
|
||||||
Assert.All(clientApi, auth => Assert.Equal(3, auth["Bearer ".Length..].Split('.').Length));
|
|
||||||
}
|
|
||||||
|
|
||||||
// a phone session that answers like a desktop would test desktop sizes without saying so: the page must see touch
|
|
||||||
public async Task AssertPhone()
|
|
||||||
{
|
|
||||||
if (Phone is null)
|
|
||||||
return;
|
|
||||||
Assert.True(await Page.EvaluateAsync<bool>("() => matchMedia('(pointer: coarse)').matches && matchMedia('(hover: none)').matches"),
|
|
||||||
$"{Phone.Key} is not emulated as a touch screen");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() => await Context.CloseAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Support
|
|
||||||
{
|
|
||||||
// The pasture and its town as the tests see them: the sites, the accounts the town made (read from the town's own
|
|
||||||
// state), and the town's command line for what each server holds, so a test checks the other side of the wire.
|
|
||||||
public static class Pasture
|
|
||||||
{
|
|
||||||
public const string Site = "https://decepub.test";
|
|
||||||
public const string Api = "https://privapub.test";
|
|
||||||
|
|
||||||
public static readonly string Root = Path.GetFullPath(Path.Combine(RepoRoot(), "..", "SocialPub", "tools", "pasture"));
|
|
||||||
|
|
||||||
static string RepoRoot()
|
|
||||||
{
|
|
||||||
var dir = AppContext.BaseDirectory;
|
|
||||||
while (dir is not null && !File.Exists(Path.Combine(dir, "decePubClient.csproj")))
|
|
||||||
dir = Path.GetDirectoryName(dir);
|
|
||||||
return dir ?? throw new InvalidOperationException("decePubClient.csproj not found above the test output");
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record Persona(string UserName, string Name, string Root, string Password, string Token, string LocalId, string ActorUri);
|
|
||||||
|
|
||||||
// every PrivaPub persona the town made, with its root's login
|
|
||||||
public static IReadOnlyList<Persona> Personas()
|
|
||||||
{
|
|
||||||
var sessions = JsonNode.Parse(File.ReadAllText(Path.Combine(Root, ".state", "town", "sessions.json")))!.AsObject();
|
|
||||||
var personas = new List<Persona>();
|
|
||||||
foreach (var (key, value) in sessions)
|
|
||||||
{
|
|
||||||
// st_* are the drivers' selftest accounts, not the town's
|
|
||||||
if (!key.StartsWith("privapub/") || key.StartsWith("privapub/st_") || value?["account"]?["root"]?.GetValue<string>() is not { } root)
|
|
||||||
continue;
|
|
||||||
var account = value["account"]!;
|
|
||||||
personas.Add(new Persona(account["username"]!.GetValue<string>(), account["name"]?.GetValue<string>(), root,
|
|
||||||
account["password"]!.GetValue<string>(),
|
|
||||||
value["token"]!.GetValue<string>(), value["local_id"]!.GetValue<string>(), value["actor_uri"]!.GetValue<string>()));
|
|
||||||
}
|
|
||||||
return personas;
|
|
||||||
}
|
|
||||||
|
|
||||||
// tools/pasture/town.sh <args>, its standard output parsed as JSON
|
|
||||||
public static async Task<JsonNode> Town(params string[] args)
|
|
||||||
{
|
|
||||||
var start = new ProcessStartInfo("python3") { RedirectStandardOutput = true, RedirectStandardError = true };
|
|
||||||
start.ArgumentList.Add(Path.Combine(Root, "town", "town.py"));
|
|
||||||
foreach (var arg in args)
|
|
||||||
start.ArgumentList.Add(arg);
|
|
||||||
using var process = Process.Start(start)!;
|
|
||||||
var output = await process.StandardOutput.ReadToEndAsync();
|
|
||||||
var error = await process.StandardError.ReadToEndAsync();
|
|
||||||
await process.WaitForExitAsync();
|
|
||||||
if (process.ExitCode != 0)
|
|
||||||
throw new InvalidOperationException($"town.sh {string.Join(' ', args)} failed: {error}");
|
|
||||||
return JsonNode.Parse(output.Trim().Split('\n').Last())!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// the town's PrivaPub accounts signed in again (town.sh renew privapub): a restore ends every session
|
|
||||||
public static async Task RenewTown()
|
|
||||||
{
|
|
||||||
var start = new ProcessStartInfo("python3") { RedirectStandardOutput = true, RedirectStandardError = true };
|
|
||||||
foreach (var arg in new[] { Path.Combine(Root, "town", "town.py"), "renew", "privapub" })
|
|
||||||
start.ArgumentList.Add(arg);
|
|
||||||
using var process = Process.Start(start)!;
|
|
||||||
await process.StandardOutput.ReadToEndAsync();
|
|
||||||
var error = await process.StandardError.ReadToEndAsync();
|
|
||||||
await process.WaitForExitAsync();
|
|
||||||
if (process.ExitCode != 0)
|
|
||||||
throw new InvalidOperationException($"town.sh renew privapub failed: {error}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// a script on PrivaPub's own database in the pasture, to undo what no API undoes
|
|
||||||
public static async Task Mongo(string script)
|
|
||||||
{
|
|
||||||
var start = new ProcessStartInfo("podman") { RedirectStandardOutput = true, RedirectStandardError = true };
|
|
||||||
foreach (var arg in new[] { "exec", "pasture-mongo", "mongosh", "--quiet", "PrivaPub", "--eval", script })
|
|
||||||
start.ArgumentList.Add(arg);
|
|
||||||
using var process = Process.Start(start)!;
|
|
||||||
var error = await process.StandardError.ReadToEndAsync();
|
|
||||||
await process.StandardOutput.ReadToEndAsync();
|
|
||||||
await process.WaitForExitAsync();
|
|
||||||
if (process.ExitCode != 0)
|
|
||||||
throw new InvalidOperationException($"mongosh failed: {error}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// what a server holds of each object, from its database (town.sh stored)
|
|
||||||
public static async Task<JsonObject> Stored(string platform, params string[] uris) =>
|
|
||||||
(await Town(["stored", platform, .. uris])).AsObject();
|
|
||||||
|
|
||||||
public static async Task<bool> Eventually(Func<Task<bool>> condition, int seconds = 30)
|
|
||||||
{
|
|
||||||
var until = DateTime.UtcNow.AddSeconds(seconds);
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
if (await condition())
|
|
||||||
return true;
|
|
||||||
if (DateTime.UtcNow > until)
|
|
||||||
return false;
|
|
||||||
await Task.Delay(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
|
||||||
|
|
||||||
// an HttpClient that reaches https://<name>.test through Caddy on 127.0.0.1:6443, as the browser does
|
|
||||||
public static HttpClient Http() => new(new SocketsHttpHandler
|
|
||||||
{
|
|
||||||
ConnectCallback = async (context, token) =>
|
|
||||||
{
|
|
||||||
var socket = new System.Net.Sockets.Socket(System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp);
|
|
||||||
await socket.ConnectAsync("127.0.0.1", 6443, token);
|
|
||||||
return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true);
|
|
||||||
},
|
|
||||||
SslOptions = { RemoteCertificateValidationCallback = (_, _, _, _) => true }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Support
|
|
||||||
{
|
|
||||||
// PrivaPub's /clientapi as the pasture's administrator (pastureroot, whom the scenarios make): what its moderators
|
|
||||||
// and administrators see
|
|
||||||
public static class PastureAdmin
|
|
||||||
{
|
|
||||||
public const string Root = "pastureroot";
|
|
||||||
public const string Password = "Pasture-Pass-1!";
|
|
||||||
|
|
||||||
public static async Task<JsonNode> Get(string path)
|
|
||||||
{
|
|
||||||
using var http = Pasture.Http();
|
|
||||||
var login = await http.PostAsJsonAsync($"{Pasture.Api}/clientapi/user/login", new { userName = Root, password = Password });
|
|
||||||
login.EnsureSuccessStatusCode();
|
|
||||||
var jwt = (await login.Content.ReadFromJsonAsync<JsonObject>())!["token"]!.GetValue<string>();
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, Pasture.Api + path);
|
|
||||||
request.Headers.Authorization = new("Bearer", jwt);
|
|
||||||
using var response = await http.SendAsync(request);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Support
|
|
||||||
{
|
|
||||||
// PrivaPub's Mastodon API as a persona (its town token), for what a test sets up or checks behind the page
|
|
||||||
public static class PersonaApi
|
|
||||||
{
|
|
||||||
// the answer's JSON, or null for a 404
|
|
||||||
public static async Task<JsonNode> Call(Pasture.Persona persona, string path, HttpMethod method = null, JsonObject body = null)
|
|
||||||
{
|
|
||||||
using var http = Pasture.Http();
|
|
||||||
using var request = new HttpRequestMessage(method ?? HttpMethod.Get, Pasture.Api + path);
|
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", persona.Token);
|
|
||||||
if (body is not null)
|
|
||||||
request.Content = new StringContent(body.ToJsonString(), System.Text.Encoding.UTF8, "application/json");
|
|
||||||
using var response = await http.SendAsync(request);
|
|
||||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
||||||
return null;
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Support
|
|
||||||
{
|
|
||||||
// Each render and action test is also a cell of the town's matrix, appended to the pasture's out/client.jsonl in the
|
|
||||||
// cell format town.sh check writes, so report.html shows decePub next to the servers. A failure that an open gap of the
|
|
||||||
// town's gaps.json names (a peer's own bug, say) is skipped with the gap's id rather than failed, as the report counts
|
|
||||||
// it an expected failure.
|
|
||||||
public static class Results
|
|
||||||
{
|
|
||||||
static readonly object Gate = new();
|
|
||||||
static readonly string FilePath = Path.Combine(Pasture.Root, "out", "client.jsonl");
|
|
||||||
static readonly Lazy<JsonArray> Gaps = new(() => JsonNode.Parse(File.ReadAllText(Path.Combine(Pasture.Root, "town", "gaps.json")))!.AsArray());
|
|
||||||
|
|
||||||
// the open gap whose match (regular expressions over the cell's fields) fits this cell, if any
|
|
||||||
static string KnownGap(string feature, string origin)
|
|
||||||
{
|
|
||||||
var fields = new Dictionary<string, string> { ["feature"] = feature, ["origin"] = origin, ["observer"] = "decepub" };
|
|
||||||
return Gaps.Value.Where(g => g?["status"]?.GetValue<string>() != "closed" && g?["match"] is JsonObject)
|
|
||||||
.FirstOrDefault(g => g!["match"]!.AsObject().All(m => Regex.IsMatch(fields.GetValueOrDefault(m.Key, ""), $"^(?:{m.Value!.GetValue<string>()})$")))
|
|
||||||
?["id"]?.GetValue<string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task Cell(string feature, string origin, string reference, Func<Task> test) =>
|
|
||||||
await Cell(feature, origin, reference, test, page: null);
|
|
||||||
|
|
||||||
// with a page, a failure also leaves its screenshot and text in the test output's .state/ (SignIn.Evidence)
|
|
||||||
public static async Task Cell(string feature, string origin, string reference, Func<Task> test, Microsoft.Playwright.IPage page)
|
|
||||||
{
|
|
||||||
var ok = false;
|
|
||||||
string got = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await test();
|
|
||||||
ok = true;
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
got = e.Message.Length > 400 ? e.Message[..400] : e.Message;
|
|
||||||
if (page is not null)
|
|
||||||
await SignIn.Evidence(page, $"{feature}-{origin}-{reference}".Replace('/', '_').Replace('@', '_'));
|
|
||||||
if (KnownGap(feature, origin) is { } gap)
|
|
||||||
Assert.Skip($"known gap {gap}: {got}");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
var line = JsonSerializer.Serialize(new
|
|
||||||
{
|
|
||||||
source = "client", cell = $"{feature}|{origin}|decepub|{(origin == "privapub" ? "local" : "in")}", feature, origin,
|
|
||||||
observer = "decepub", direction = origin == "privapub" ? "local" : "in", ok, @ref = reference, got,
|
|
||||||
t = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
|
|
||||||
});
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
|
|
||||||
File.AppendAllText(FilePath, line + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
using Microsoft.Playwright;
|
|
||||||
|
|
||||||
namespace decePubClient.E2E.Support
|
|
||||||
{
|
|
||||||
// Signs a town persona in through the real login page and persona chooser, once per persona per run; later sessions
|
|
||||||
// start from the saved browser state (the AuthData decePub keeps in localStorage).
|
|
||||||
public static class SignIn
|
|
||||||
{
|
|
||||||
static readonly string StateDir = Path.Combine(AppContext.BaseDirectory, ".state");
|
|
||||||
|
|
||||||
public static async Task<Session> As(Browser browser, Pasture.Persona persona, bool fresh = false)
|
|
||||||
{
|
|
||||||
var state = Path.Combine(StateDir, persona.UserName + ".json");
|
|
||||||
if (!fresh && File.Exists(state))
|
|
||||||
{
|
|
||||||
var known = await browser.Open(state);
|
|
||||||
await known.Page.GotoAsync(Pasture.Site + "/");
|
|
||||||
if (!await Stale(known.Page))
|
|
||||||
{
|
|
||||||
await known.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
return known;
|
|
||||||
}
|
|
||||||
await known.DisposeAsync();
|
|
||||||
}
|
|
||||||
var session = await browser.Open();
|
|
||||||
await Through(session.Page, persona);
|
|
||||||
Directory.CreateDirectory(StateDir);
|
|
||||||
await session.Context.StorageStateAsync(new() { Path = state });
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
// on a phone: the state the desktop sign-in saved, and the feed as on a first visit (no FeedFilter), so the globe
|
|
||||||
// banner starts closed as a phone's does
|
|
||||||
public static async Task<Session> As(Browser browser, Pasture.Persona persona, Phone phone)
|
|
||||||
{
|
|
||||||
var state = Path.Combine(StateDir, persona.UserName + ".json");
|
|
||||||
if (!File.Exists(state))
|
|
||||||
await (await As(browser, persona)).DisposeAsync();
|
|
||||||
var session = await browser.Open(state, phone);
|
|
||||||
await session.Page.GotoAsync(Pasture.Site + "/");
|
|
||||||
if (await Stale(session.Page))
|
|
||||||
{
|
|
||||||
// the saved sign-in has expired (the page went to /login): sign in again on a desktop and start over
|
|
||||||
await session.DisposeAsync();
|
|
||||||
await (await As(browser, persona, fresh: true)).DisposeAsync();
|
|
||||||
session = await browser.Open(state, phone);
|
|
||||||
}
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await session.Page.GotoAsync(Pasture.Site + "/");
|
|
||||||
await session.Page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
|
||||||
await session.Page.GotoAsync(Pasture.Site + "/");
|
|
||||||
await session.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
await Evidence(session.Page, $"signin-{phone.Key}-{persona.UserName}");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
await session.AssertPhone();
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task<bool> Stale(IPage page)
|
|
||||||
{
|
|
||||||
var until = DateTime.UtcNow.AddSeconds(15);
|
|
||||||
while (DateTime.UtcNow < until)
|
|
||||||
{
|
|
||||||
if (new Uri(page.Url).AbsolutePath.StartsWith("/login"))
|
|
||||||
return true;
|
|
||||||
if (await page.Locator("article[data-uri], [data-feed-empty]").CountAsync() > 0)
|
|
||||||
return false;
|
|
||||||
await page.WaitForTimeoutAsync(250);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task Through(IPage page, Pasture.Persona persona)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Steps(page, persona);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
await Evidence(page, "signin-" + persona.UserName);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// a screenshot and the page's text next to the test output, for a failure seen only in a headless browser
|
|
||||||
public static async Task Evidence(IPage page, string name)
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(StateDir);
|
|
||||||
await page.ScreenshotAsync(new() { Path = Path.Combine(StateDir, name + ".png"), FullPage = true });
|
|
||||||
await File.WriteAllTextAsync(Path.Combine(StateDir, name + ".txt"), page.Url + "\n" + await page.InnerTextAsync("body"));
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task Steps(IPage page, Pasture.Persona persona)
|
|
||||||
{
|
|
||||||
await page.GotoAsync(Pasture.Site + "/login");
|
|
||||||
await page.Locator("input[autocomplete=username]").FillAsync(persona.Root);
|
|
||||||
await page.Locator("input[autocomplete=current-password]").FillAsync(persona.Password);
|
|
||||||
await page.Locator("button[type=submit]").ClickAsync();
|
|
||||||
// a root with several personas picks one; one with a single persona goes on by itself, its button disabled while it
|
|
||||||
// does. Only the login page's chooser is clicked: the feed's persona switcher names the persona too
|
|
||||||
var chooser = page.Locator("section button:enabled").Filter(new() { HasText = persona.UserName + "@privapub.test" });
|
|
||||||
var until = DateTime.UtcNow.AddSeconds(30);
|
|
||||||
while (new Uri(page.Url).AbsolutePath != "/" && DateTime.UtcNow < until)
|
|
||||||
{
|
|
||||||
if (await chooser.CountAsync() > 0)
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await chooser.First.ClickAsync(new() { Timeout = 2000 });
|
|
||||||
}
|
|
||||||
catch (TimeoutException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
await page.WaitForTimeoutAsync(250);
|
|
||||||
}
|
|
||||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 });
|
|
||||||
// (a new persona's feed is empty)
|
|
||||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<!-- The decePub end-to-end tests: the published app (environment Pasture) in Chromium against the pasture's PrivaPub
|
|
||||||
and its peers, run by run.sh. Never in CI: they need podman and the pasture. Playwright 1.61.0 is pinned because
|
|
||||||
its browsers.json names Chromium 1228, the build cached in ~/.cache/ms-playwright; the tests never install one. -->
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<Nullable>disable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
<RootNamespace>decePubClient.E2E</RootNamespace>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.Playwright" Version="1.61.0" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
|
|
||||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Using Include="Xunit" />
|
|
||||||
<None Update="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
|
||||||
<None Update="Mobile/fit.js" CopyToOutputDirectory="PreserveNewest" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -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 "$@"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
|
||||||
"parallelizeTestCollections": false,
|
|
||||||
"maxParallelThreads": 1
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user