The browser fixture opens phones from Playwright's own device presets (360x800, iPhone SE 375x667, Pixel 7 412x839, and the Pixel 7 held sideways), with touch, a coarse pointer and reduced motion, and a phone session fails at once if the page doesn't see a touch screen. Mobile/fit.js measures a page one screen at a time down its main scroller: sideways scroll, boxes past the screen's sides, open menus and dialogs 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) and control text under 12px. RouteSweepTests runs every feed tab, threads of each kind the town holds, profiles, search, Explore, lists, groups, filters, scheduled posts, settings and administration (every section opened) and the login page on the three portrait phones, as client.mobile.fit.* and client.mobile.touch.* cells; screenshots, findings and a contact sheet go to tests/e2e/out/mobile/. DesktopUnchangedTests holds desktop's control sizes. Category=Mobile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
135 lines
5.5 KiB
C#
135 lines
5.5 KiB
C#
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("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);
|
|
}
|
|
}
|
|
}
|
|
}
|