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 @@ +