From ffd4acd3d07dcd09a254547b0666d6a6dbf49b56 Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 6 Oct 2026 23:13:23 +0200 Subject: [PATCH] Phone tests: expired sign-ins, evidence, and probes that only judge what can be tapped A saved sign-in whose session expired landed on the login page and every route its persona opened timed out; both the desktop and the phone sign-in now see that and sign in afresh. A route that fails to open leaves its screenshot and text, as a failed sign-in does. A target's probes stay inside what its scroller shows (a card half under the feed's top edge was failed for the part out of sight), a failed probe names what it hit, and the element blamed for sideways scroll is never one inside a nested clipping box, which can't widen the page. The desktop guard reads its probe sizes as a list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- tests/e2e/Mobile/DesktopUnchangedTests.cs | 15 ++++---- tests/e2e/Mobile/RouteSweepTests.cs | 10 +++++- tests/e2e/Mobile/fit.js | 43 +++++++++++++++++++--- tests/e2e/Support/SignIn.cs | 44 ++++++++++++++++++++--- 4 files changed, 92 insertions(+), 20 deletions(-) diff --git a/tests/e2e/Mobile/DesktopUnchangedTests.cs b/tests/e2e/Mobile/DesktopUnchangedTests.cs index 86200a1..5699b73 100644 --- a/tests/e2e/Mobile/DesktopUnchangedTests.cs +++ b/tests/e2e/Mobile/DesktopUnchangedTests.cs @@ -10,14 +10,14 @@ namespace decePubClient.E2E.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 => { + () => ["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] - })) + return height + }) """; [Fact] @@ -29,12 +29,9 @@ namespace decePubClient.E2E.Mobile 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); + // btn, btn btn-sm, btn btn-xs, input, input input-sm + var heights = await page.EvaluateAsync(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); diff --git a/tests/e2e/Mobile/RouteSweepTests.cs b/tests/e2e/Mobile/RouteSweepTests.cs index 160db71..5b3ff05 100644 --- a/tests/e2e/Mobile/RouteSweepTests.cs +++ b/tests/e2e/Mobile/RouteSweepTests.cs @@ -25,7 +25,15 @@ namespace decePubClient.E2E.Mobile 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); + 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; diff --git a/tests/e2e/Mobile/fit.js b/tests/e2e/Mobile/fit.js index 12afdf1..0de81a8 100644 --- a/tests/e2e/Mobile/fit.js +++ b/tests/e2e/Mobile/fit.js @@ -74,6 +74,34 @@ window.__fit = (() => { 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() { @@ -88,7 +116,7 @@ window.__fit = (() => { 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]")) { + if (r.width > 0 && r.right > right && !el.closest("[data-scroll-x]") && !clippedWithin(el, scroller)) { worst = el right = r.right } @@ -187,14 +215,19 @@ window.__fit = (() => { small.push({ ...describe(el, own), failed: ["range"] }) continue } - const failed = [] + // 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(innerWidth - 1, Math.max(0, x + dx)), py = Math.min(innerHeight - 1, Math.max(0, y + dy)) - if (!reaches(el, px, py)) + 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)) { failed.push(side) + const other = document.elementFromPoint(px, py) + hit[side] = other ? name(other) : null + } } if (failed.length > 0) - small.push({ ...describe(el, own), failed }) + 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) }) } diff --git a/tests/e2e/Support/SignIn.cs b/tests/e2e/Support/SignIn.cs index 48184a7..ca81fa0 100644 --- a/tests/e2e/Support/SignIn.cs +++ b/tests/e2e/Support/SignIn.cs @@ -15,8 +15,12 @@ namespace decePubClient.E2E.Support { var known = await browser.Open(state); await known.Page.GotoAsync(Pasture.Site + "/"); - await known.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 }); - return known; + 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); @@ -34,13 +38,43 @@ namespace decePubClient.E2E.Support 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 }); + 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 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