From 63d83810d7ea5158f331f60c9116973244a4a623 Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 6 Oct 2026 22:40:28 +0200 Subject: [PATCH] Phones in the end-to-end tests 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 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- tests/e2e/Mobile/DesktopUnchangedTests.cs | 43 ++++ tests/e2e/Mobile/Fit.cs | 171 ++++++++++++++ tests/e2e/Mobile/RouteSweepTests.cs | 54 +++++ tests/e2e/Mobile/Routes.cs | 134 +++++++++++ tests/e2e/Mobile/fit.js | 267 ++++++++++++++++++++++ tests/e2e/Support/Browser.cs | 57 ++++- tests/e2e/Support/SignIn.cs | 16 ++ tests/e2e/decePubClient.E2E.csproj | 1 + 8 files changed, 732 insertions(+), 11 deletions(-) create mode 100644 tests/e2e/Mobile/DesktopUnchangedTests.cs create mode 100644 tests/e2e/Mobile/Fit.cs create mode 100644 tests/e2e/Mobile/RouteSweepTests.cs create mode 100644 tests/e2e/Mobile/Routes.cs create mode 100644 tests/e2e/Mobile/fit.js diff --git a/tests/e2e/Mobile/DesktopUnchangedTests.cs b/tests/e2e/Mobile/DesktopUnchangedTests.cs new file mode 100644 index 0000000..86200a1 --- /dev/null +++ b/tests/e2e/Mobile/DesktopUnchangedTests.cs @@ -0,0 +1,43 @@ +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 = """ + () => Object.fromEntries(["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 [classes, height] + })) + """; + + [Fact] + public async Task Desktop_keeps_its_sizes() + { + var persona = Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First(); + await using var session = await SignIn.As(browser, persona); + var page = session.Page; + await Results.Cell("client.mobile.desktop", "privapub", "desktop", async () => + { + Assert.False(await page.EvaluateAsync("() => matchMedia('(pointer: coarse)').matches"), "the desktop session has a coarse pointer"); + var heights = await page.EvaluateAsync>(Probe); + Assert.Equal(40, heights["btn"], 0.5); + Assert.Equal(32, heights["btn btn-sm"], 0.5); + Assert.Equal(24, heights["btn btn-xs"], 0.5); + Assert.Equal(40, heights["input"], 0.5); + Assert.Equal(32, heights["input input-sm"], 0.5); + await Assertions.Expect(page.Locator("nav a.btn").First).ToBeVisibleAsync(); + await session.AssertGuards(); + }, page); + } + } +} diff --git a/tests/e2e/Mobile/Fit.cs b/tests/e2e/Mobile/Fit.cs new file mode 100644 index 0000000..56a5ff9 --- /dev/null +++ b/tests/e2e/Mobile/Fit.cs @@ -0,0 +1,171 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +using Microsoft.Playwright; + +namespace decePubClient.E2E.Mobile +{ + // The phone sweep: a page measured and photographed one screen at a time down its main scroller (fit.js measures), + // what doesn't fit kept as violations.json next to the screenshots, and a contact sheet of every route at every phone + // in tests/e2e/out/mobile//index.html, for the eye. + public static class Fit + { + public static readonly string Script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Mobile", "fit.js")); + + static readonly string RunDir = Path.Combine(TestsRoot(), "out", "mobile", DateTime.UtcNow.ToString("yyyyMMdd-HHmmss")); + static readonly object SheetGate = new(); + + static string TestsRoot() + { + var dir = AppContext.BaseDirectory; + while (dir is not null && !File.Exists(Path.Combine(dir, "decePubClient.E2E.csproj"))) + dir = Path.GetDirectoryName(dir); + return dir ?? throw new InvalidOperationException("decePubClient.E2E.csproj not found above the test output"); + } + + // what a sweep found, each finding once however many screens showed it + public sealed class Report + { + readonly HashSet seen = []; + public List Overflow { get; } = []; + public List Beyond { get; } = []; + public List Popovers { get; } = []; + public List Small { get; } = []; + public List Tiny { get; } = []; + public int Screens { get; set; } + + public void Take(JsonNode measured, int screen) + { + Add(Overflow, measured["overflow"], "overflow", screen); + Add(Beyond, measured["beyond"], "beyond", screen); + Add(Popovers, measured["popovers"], "popover", screen); + Add(Small, measured["small"], "small", screen); + Add(Tiny, measured["tiny"], "tiny", screen); + } + + void Add(List into, JsonNode found, string kind, int screen) + { + foreach (var item in found!.AsArray().Select(i => i!.AsObject())) + { + var key = $"{kind}|{item["element"]}|{item["text"]}|{item["card"]}"; + if (!seen.Add(key)) + continue; + var copy = Clean(item.DeepClone()).AsObject(); + copy["screen"] = screen; + into.Add(copy); + } + } + + // Playwright marks the objects it hands back with $id; the findings don't need them + static JsonNode Clean(JsonNode node) + { + if (node is JsonObject obj) + { + obj.Remove("$id"); + foreach (var (_, value) in obj) + Clean(value); + } + else if (node is JsonArray array) + foreach (var value in array) + Clean(value); + return node; + } + + public string Json() => new JsonObject + { + ["screens"] = Screens, + ["overflow"] = new JsonArray(Overflow.Select(o => (JsonNode)o.DeepClone()).ToArray()), + ["beyond"] = new JsonArray(Beyond.Select(o => (JsonNode)o.DeepClone()).ToArray()), + ["popovers"] = new JsonArray(Popovers.Select(o => (JsonNode)o.DeepClone()).ToArray()), + ["small"] = new JsonArray(Small.Select(o => (JsonNode)o.DeepClone()).ToArray()), + ["tiny"] = new JsonArray(Tiny.Select(o => (JsonNode)o.DeepClone()).ToArray()) + }.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + + public static Task Settle(IPage page) => page.EvaluateAsync("() => window.__fit.settle()"); + + public static async Task Sweep(IPage page, Support.Phone phone, string slug, int screens) + { + var dir = Path.Combine(RunDir, phone.Key, slug); + Directory.CreateDirectory(dir); + var report = new Report(); + await Settle(page); + await page.EvaluateAsync("() => window.__fit.primary()"); + for (var screen = 0; screen < screens; screen++) + { + var measured = JsonNode.Parse((await page.EvaluateAsync("() => window.__fit.measure()")).GetRawText())!; + report.Take(measured, screen); + await page.ScreenshotAsync(new() + { + Path = Path.Combine(dir, $"{screen:00}.png"), Animations = ScreenshotAnimations.Disabled, Caret = ScreenshotCaret.Hide + }); + report.Screens = screen + 1; + if (!await page.EvaluateAsync("() => window.__fit.step(0.8)")) + break; + await Settle(page); + } + await File.WriteAllTextAsync(Path.Combine(dir, "violations.json"), report.Json()); + ContactSheet(); + return report; + } + + // nothing scrolls sideways, nothing lies past the screen's sides, and every open menu, dialog or tip is whole + public static void AssertFits(Report report) + { + var problems = report.Overflow.Select(o => $"sideways scroll in {o["element"]} (+{o["extra"]}px), furthest: {o["culprit"]?.ToJsonString()}") + .Concat(report.Beyond.Select(b => $"past the screen: {b.ToJsonString()}")) + .Concat(report.Popovers.Select(p => $"cut or outside: {p.ToJsonString()}")) + .ToList(); + Assert.True(problems.Count == 0, $"{problems.Count} fit problems:\n" + string.Join("\n", problems.Take(12))); + } + + // every target a thumb can reach has a 44px hit area, and no control's text is under 12px + public static void AssertTouchable(Report report) + { + var problems = report.Small.Select(s => $"small target: {s.ToJsonString()}") + .Concat(report.Tiny.Select(t => $"small text: {t.ToJsonString()}")) + .ToList(); + Assert.True(problems.Count == 0, $"{problems.Count} touch problems:\n" + string.Join("\n", problems.Take(12))); + } + + // every route as a row, every phone as a column: the first screen and how many findings + static void ContactSheet() + { + lock (SheetGate) + { + var phones = Directory.GetDirectories(RunDir).Select(Path.GetFileName).Order().ToList(); + var routes = phones.SelectMany(p => Directory.GetDirectories(Path.Combine(RunDir, p)).Select(Path.GetFileName)).Distinct().Order().ToList(); + var html = new StringBuilder("decePub on phones") + .Append(""); + foreach (var phone in phones) + html.Append(""); + html.Append(""); + foreach (var route in routes) + { + html.Append(""); + foreach (var phone in phones) + { + var dir = Path.Combine(RunDir, phone, route); + html.Append(""); + } + html.Append(""); + } + File.WriteAllText(Path.Combine(RunDir, "index.html"), html.Append("
").Append(WebUtility.HtmlEncode(phone)).Append("
").Append(WebUtility.HtmlEncode(route)).Append(""); + if (File.Exists(Path.Combine(dir, "violations.json"))) + { + var found = JsonNode.Parse(File.ReadAllText(Path.Combine(dir, "violations.json")))!; + var fit = found["overflow"]!.AsArray().Count + found["beyond"]!.AsArray().Count + found["popovers"]!.AsArray().Count; + var touch = found["small"]!.AsArray().Count + found["tiny"]!.AsArray().Count; + var link = $"{phone}/{route}"; + html.Append($"") + .Append($" 0 ? "bad" : "")}\">fit {fit} · touch {touch} · {found["screens"]} screens ") + .Append($"findings"); + } + html.Append("
").ToString()); + } + } + } +} diff --git a/tests/e2e/Mobile/RouteSweepTests.cs b/tests/e2e/Mobile/RouteSweepTests.cs new file mode 100644 index 0000000..160db71 --- /dev/null +++ b/tests/e2e/Mobile/RouteSweepTests.cs @@ -0,0 +1,54 @@ +using decePubClient.E2E.Support; + +namespace decePubClient.E2E.Mobile +{ + // Every page at every portrait phone: nothing scrolls sideways or lies past the screen, every open menu and dialog is + // whole, every target has a 44px hit area and no control's text is under 12px. Two cells per page and phone, + // client.mobile.fit.* and client.mobile.touch.*; the screenshots and findings go to tests/e2e/out/mobile/. + [Trait("Category", "Mobile")] + public sealed class RouteSweepTests(Browser browser) + { + public static TheoryData Cases() + { + var data = new TheoryData(); + foreach (var phone in Phone.Portrait) + foreach (var route in Route.All) + data.Add(phone.Key, route.Slug); + return data; + } + + [Theory] + [MemberData(nameof(Cases))] + public async Task Fits_a_phone(string phoneKey, string slug) + { + var phone = Phone.ByKey(phoneKey); + var route = Route.Get(slug); + var persona = route.Persona(); + await using var session = route.SignedOut ? await browser.Open(phone: phone) : await SignIn.As(browser, persona, phone); + await route.Open(session.Page, persona); + var report = await Fit.Sweep(session.Page, phone, slug, route.Screens); + if (!route.Gating) + return; + + var problems = new List(); + foreach (var (feature, check) in new (string, Action)[] + { + ($"client.mobile.fit.{slug}", Fit.AssertFits), ($"client.mobile.touch.{slug}", Fit.AssertTouchable) + }) + try + { + await Results.Cell(feature, "privapub", phone.Key, () => + { + check(report); + return Task.CompletedTask; + }); + } + catch (Exception e) + { + problems.Add(e.Message); + } + await session.AssertGuards(); + Assert.True(problems.Count == 0, string.Join("\n\n", problems)); + } + } +} diff --git a/tests/e2e/Mobile/Routes.cs b/tests/e2e/Mobile/Routes.cs new file mode 100644 index 0000000..6cf1bf7 --- /dev/null +++ b/tests/e2e/Mobile/Routes.cs @@ -0,0 +1,134 @@ +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); + } + } + } +} diff --git a/tests/e2e/Mobile/fit.js b/tests/e2e/Mobile/fit.js new file mode 100644 index 0000000..12afdf1 --- /dev/null +++ b/tests/e2e/Mobile/fit.js @@ -0,0 +1,267 @@ +// 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() what doesn't fit: sideways overflow, boxes beyond the screen, cut popovers, small targets, small text +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 + } + + // 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]")) { + 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 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() { + const small = [], tiny = [] + for (const el of document.querySelectorAll(candidates)) { + 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 + } + const failed = [] + for (const [dx, dy, side] of [[-21, 0, "left"], [21, 0, "right"], [0, -21, "up"], [0, 21, "down"]]) { + const px = Math.min(innerWidth - 1, Math.max(0, x + dx)), py = Math.min(innerHeight - 1, Math.max(0, y + dy)) + if (!reaches(el, px, py)) + failed.push(side) + } + if (failed.length > 0) + small.push({ ...describe(el, own), failed }) + 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() { + styles = new Map() + const { small, tiny } = targets() + const result = { url: location.pathname + location.search, overflow: overflow(), beyond: beyond(), popovers: popovers(), small, tiny } + styles = new Map() + return result + } + + function primary() { + styles = new Map() + document.querySelectorAll("[data-fit-primary]").forEach(e => e.removeAttribute("data-fit-primary")) + let best = null, area = 0 + for (const el of scrollers()) { + if (el.scrollHeight <= el.clientHeight + 4) + continue + const size = el.clientWidth * el.clientHeight + if (size > area) { + best = el + area = size + } + } + best ??= document.scrollingElement + best.setAttribute("data-fit-primary", "") + styles = new Map() + return name(best) + } + + function step(fraction) { + const el = document.querySelector("[data-fit-primary]") ?? document.scrollingElement + const before = el.scrollTop + el.scrollTo({ top: before + el.clientHeight * fraction, behavior: "instant" }) + return el.scrollTop > before + 1 + } + + async function settle() { + await document.fonts.ready + await new Promise(resolve => { + let timer + const done = () => { + observer.disconnect() + clearTimeout(timer) + clearTimeout(cap) + resolve() + } + const observer = new MutationObserver(() => { + clearTimeout(timer) + timer = setTimeout(done, 400) + }) + observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true }) + timer = setTimeout(done, 400) + const cap = setTimeout(done, 8000) + }) + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))) + const until = Date.now() + 3000 + const inView = img => { + const r = img.getBoundingClientRect() + return r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth + } + while (Date.now() < until && [...document.images].some(img => inView(img) && !img.complete)) + await new Promise(resolve => setTimeout(resolve, 100)) + } + + return { settle, primary, step, measure } +})() diff --git a/tests/e2e/Support/Browser.cs b/tests/e2e/Support/Browser.cs index 0889966..6ab5193 100644 --- a/tests/e2e/Support/Browser.cs +++ b/tests/e2e/Support/Browser.cs @@ -28,21 +28,44 @@ namespace decePubClient.E2E.Support _playwright?.Dispose(); } - public async Task Open(string storageState = null) + // a desktop window of 1440x900, or a phone: touch, a coarse pointer, its size and scale, reduced motion so nothing is + // measured halfway through a transition, and the mobile tests' measuring script (Mobile/fit.js) on every page + public async Task Open(string storageState = null, Phone phone = null) { - var context = await Chromium.NewContextAsync(new() + 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 }) { - IgnoreHTTPSErrors = true, - Locale = "en-GB", - TimezoneId = "Europe/Rome", - ViewportSize = new() { Width = 1440, Height = 900 }, - ServiceWorkers = ServiceWorkerPolicy.Block, - StorageStatePath = storageState - }); - return new Session(context, await context.NewPageAsync()); + 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 @@ -54,10 +77,13 @@ namespace decePubClient.E2E.Support public List ServerErrors { get; } = []; public List<(string Url, string Authorization)> ApiCalls { get; } = []; - public Session(IBrowserContext context, IPage page) + 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); @@ -96,6 +122,15 @@ namespace decePubClient.E2E.Support Assert.All(clientApi, auth => Assert.Equal(3, auth["Bearer ".Length..].Split('.').Length)); } + // a phone session that answers like a desktop would test desktop sizes without saying so: the page must see touch + public async Task AssertPhone() + { + if (Phone is null) + return; + Assert.True(await Page.EvaluateAsync("() => matchMedia('(pointer: coarse)').matches && matchMedia('(hover: none)').matches"), + $"{Phone.Key} is not emulated as a touch screen"); + } + public async ValueTask DisposeAsync() => await Context.CloseAsync(); } } diff --git a/tests/e2e/Support/SignIn.cs b/tests/e2e/Support/SignIn.cs index 28b7c8c..48184a7 100644 --- a/tests/e2e/Support/SignIn.cs +++ b/tests/e2e/Support/SignIn.cs @@ -25,6 +25,22 @@ namespace decePubClient.E2E.Support return session; } + // on a phone: the state the desktop sign-in saved, and the feed as on a first visit (no FeedFilter), so the globe + // banner starts closed as a phone's does + public static async Task As(Browser browser, Pasture.Persona persona, Phone phone) + { + var state = Path.Combine(StateDir, persona.UserName + ".json"); + if (!File.Exists(state)) + await (await As(browser, persona)).DisposeAsync(); + var session = await browser.Open(state, phone); + await session.Page.GotoAsync(Pasture.Site + "/"); + 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 }); + await session.AssertPhone(); + return session; + } + public static async Task Through(IPage page, Pasture.Persona persona) { try diff --git a/tests/e2e/decePubClient.E2E.csproj b/tests/e2e/decePubClient.E2E.csproj index cfe09ac..21b3b1a 100644 --- a/tests/e2e/decePubClient.E2E.csproj +++ b/tests/e2e/decePubClient.E2E.csproj @@ -22,6 +22,7 @@ +