using System.Text.Json.Nodes; using decePubClient.E2E.Support; using Microsoft.Playwright; namespace decePubClient.E2E.Mobile { // The pages the phone sweep visits, each opened as a reader would get there: signed in as a town persona (the one with // sibling personas, unless the page shows another's post), or signed out for the login page. Screens is how far down // the page's main scroller the sweep measures. public sealed record Route(string Slug, Func Open, Func Reader = null, int Screens = 8, bool SignedOut = false, bool Gating = true) { static readonly Lazy Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray()); static Pasture.Persona Sweeper() => Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First(); static readonly Pasture.Persona Admin = new("alice", "alice", PastureAdmin.Root, PastureAdmin.Password, null, null, null); public Pasture.Persona Persona() => (Reader ?? Sweeper)(); public static readonly Route[] All = [ new("feed-home", (_, _) => Task.CompletedTask), new("feed-local", Tab(1), Screens: 6), new("feed-federated", Tab(2), Screens: 6), new("feed-nearby", Tab(3), Screens: 4), new("feed-groups", Tab(4), Screens: 6), new("feed-direct", Tab(5), Screens: 6), new("feed-notifications", Tab(6), Screens: 6), new("feed-favourites", Tab(7), Screens: 4), new("feed-bookmarks", Tab(8), Screens: 4), new("feed-lists", Tab(9), Screens: 4), Thread("thread-poll", i => i["poll"] is JsonArray), Thread("thread-pictures", i => i["alts"]!.AsArray().Count > 0), Thread("thread-titled", i => i["title"] is not null), Thread("thread-quote", i => i["quote"] is not null), Thread("thread-warning", i => i["cw"] is not null), Thread("thread-replies", i => (string)i["kind"] == "thread"), new("profile-own", async (page, persona) => await Visit(page, $"/account/{persona.LocalId}"), Screens: 10), new("profile-followed", async (page, persona) => { var followed = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=1"))?.AsArray().FirstOrDefault(); if (followed is null) Assert.Skip("the persona follows nobody"); await Visit(page, $"/account/{followed["id"]}"); }, Screens: 10), new("search", async (page, persona) => { var other = Pasture.Personas().First(p => p.Root != persona.Root); await Visit(page, $"/search?q={Uri.EscapeDataString(other.UserName)}"); }), new("explore-hashtags", async (page, _) => await Visit(page, "/explore")), new("explore-posts", ExploreTab(1)), new("explore-links", ExploreTab(2)), new("explore-people", ExploreTab(3)), new("explore-followed", ExploreTab(4)), new("lists", async (page, _) => await Visit(page, "/lists")), new("groups", async (page, _) => await Visit(page, "/groups")), new("filters", async (page, _) => await Visit(page, "/filters")), new("scheduled", async (page, _) => await Visit(page, "/scheduled")), new("notifications-filtered", async (page, _) => await Visit(page, "/notifications/filtered")), new("settings", async (page, _) => { await Visit(page, "/settings"); await OpenEveryCollapse(page); }, Screens: 30), new("administration", async (page, _) => { await Visit(page, "/administration"); await OpenEveryCollapse(page); }, () => Admin, Screens: 30), new("login", async (page, _) => await Visit(page, "/login"), SignedOut: true), new("styleguide", async (page, _) => await Visit(page, "/styleguide"), SignedOut: true, Screens: 40, Gating: false) ]; public static Route Get(string slug) => All.Single(r => r.Slug == slug); static async Task Visit(IPage page, string path) { await page.GotoAsync(Pasture.Site + path); await Fit.Settle(page); } // the feed's tab at this place: Home, Local, Federation, Nearby, Groups, Direct, Notifications, Favourites, // Bookmarks, Lists (FeedTabs.Always) static Func Tab(int index) => async (page, _) => { await page.Locator("[role=tablist]").First.Locator("[role=tab]").Nth(index).TapAsync(); await Fit.Settle(page); }; static Func ExploreTab(int index) => async (page, _) => { await Visit(page, "/explore"); await page.Locator("[role=tablist]").First.Locator("[role=tab]").Nth(index).TapAsync(); await Fit.Settle(page); }; // a town post of the kind wanted, opened as its thread by a persona it was for static Route Thread(string slug, Func wanted) { JsonNode Pick() => Expected.Value.FirstOrDefault(i => wanted(i!) && ReaderOf(i) is not null); return new(slug, async (page, _) => { if (Pick() is not { } item) { Assert.Skip($"the town holds no post for {slug}"); return; } await page.GotoAsync($"{Pasture.Site}/expand/{item["id"]}"); await page.Locator($"article[data-uri='{item["uri"]}']").First.WaitForAsync(new() { Timeout = 30000 }); await Fit.Settle(page); }, () => Pick() is { } item ? ReaderOf(item) : Sweeper(), Screens: 10); } static Pasture.Persona ReaderOf(JsonNode item) { var personas = Pasture.Personas(); return item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == (string)r)).FirstOrDefault(p => p is not null); } static async Task OpenEveryCollapse(IPage page) { var closed = page.Locator(".collapse-title[aria-expanded=false]"); for (var i = 0; i < 20 && await closed.CountAsync() > 0; i++) { await closed.First.TapAsync(); await Fit.Settle(page); } } } }