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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
63f2517f5b
commit
ffd4acd3d0
4 files changed
+92
-20
No files matched your search
@@ -10,14 +10,14 @@ namespace decePubClient.E2E.Mobile
|
|||||||
public sealed class DesktopUnchangedTests(Browser browser)
|
public sealed class DesktopUnchangedTests(Browser browser)
|
||||||
{
|
{
|
||||||
const string Probe = """
|
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")
|
const el = document.createElement(classes.startsWith("input") ? "input" : "button")
|
||||||
el.className = classes
|
el.className = classes
|
||||||
document.body.append(el)
|
document.body.append(el)
|
||||||
const height = el.getBoundingClientRect().height
|
const height = el.getBoundingClientRect().height
|
||||||
el.remove()
|
el.remove()
|
||||||
return [classes, height]
|
return height
|
||||||
}))
|
})
|
||||||
""";
|
""";
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -29,12 +29,9 @@ namespace decePubClient.E2E.Mobile
|
|||||||
await Results.Cell("client.mobile.desktop", "privapub", "desktop", async () =>
|
await Results.Cell("client.mobile.desktop", "privapub", "desktop", async () =>
|
||||||
{
|
{
|
||||||
Assert.False(await page.EvaluateAsync<bool>("() => matchMedia('(pointer: coarse)').matches"), "the desktop session has a coarse pointer");
|
Assert.False(await page.EvaluateAsync<bool>("() => matchMedia('(pointer: coarse)').matches"), "the desktop session has a coarse pointer");
|
||||||
var heights = await page.EvaluateAsync<Dictionary<string, double>>(Probe);
|
// btn, btn btn-sm, btn btn-xs, input, input input-sm
|
||||||
Assert.Equal(40, heights["btn"], 0.5);
|
var heights = await page.EvaluateAsync<double[]>(Probe);
|
||||||
Assert.Equal(32, heights["btn btn-sm"], 0.5);
|
Assert.Equal([40, 32, 24, 40, 32], heights.Select(h => Math.Round(h)).ToArray());
|
||||||
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 Assertions.Expect(page.Locator("nav a.btn").First).ToBeVisibleAsync();
|
||||||
await session.AssertGuards();
|
await session.AssertGuards();
|
||||||
}, page);
|
}, page);
|
||||||
|
|||||||
@@ -25,7 +25,15 @@ namespace decePubClient.E2E.Mobile
|
|||||||
var route = Route.Get(slug);
|
var route = Route.Get(slug);
|
||||||
var persona = route.Persona();
|
var persona = route.Persona();
|
||||||
await using var session = route.SignedOut ? await browser.Open(phone: phone) : await SignIn.As(browser, persona, phone);
|
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);
|
var report = await Fit.Sweep(session.Page, phone, slug, route.Screens);
|
||||||
if (!route.Gating)
|
if (!route.Gating)
|
||||||
return;
|
return;
|
||||||
|
|||||||
+38
-5
@@ -74,6 +74,34 @@ window.__fit = (() => {
|
|||||||
return box
|
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
|
// 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
|
// element reaching furthest right
|
||||||
function overflow() {
|
function overflow() {
|
||||||
@@ -88,7 +116,7 @@ window.__fit = (() => {
|
|||||||
let worst = null, right = limit + 0.5
|
let worst = null, right = limit + 0.5
|
||||||
for (const el of scroller.querySelectorAll("*")) {
|
for (const el of scroller.querySelectorAll("*")) {
|
||||||
const r = el.getBoundingClientRect()
|
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
|
worst = el
|
||||||
right = r.right
|
right = r.right
|
||||||
}
|
}
|
||||||
@@ -187,14 +215,19 @@ window.__fit = (() => {
|
|||||||
small.push({ ...describe(el, own), failed: ["range"] })
|
small.push({ ...describe(el, own), failed: ["range"] })
|
||||||
continue
|
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"]]) {
|
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))
|
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))
|
if (!reaches(el, px, py)) {
|
||||||
failed.push(side)
|
failed.push(side)
|
||||||
|
const other = document.elementFromPoint(px, py)
|
||||||
|
hit[side] = other ? name(other) : null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (failed.length > 0)
|
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)
|
if (text(el) && el.innerText?.trim() && parseFloat(style(el).fontSize) < 12)
|
||||||
tiny.push({ ...describe(el, own), fontSize: parseFloat(style(el).fontSize) })
|
tiny.push({ ...describe(el, own), fontSize: parseFloat(style(el).fontSize) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ namespace decePubClient.E2E.Support
|
|||||||
{
|
{
|
||||||
var known = await browser.Open(state);
|
var known = await browser.Open(state);
|
||||||
await known.Page.GotoAsync(Pasture.Site + "/");
|
await known.Page.GotoAsync(Pasture.Site + "/");
|
||||||
await known.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
if (!await Stale(known.Page))
|
||||||
return known;
|
{
|
||||||
|
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();
|
var session = await browser.Open();
|
||||||
await Through(session.Page, persona);
|
await Through(session.Page, persona);
|
||||||
@@ -34,13 +38,43 @@ namespace decePubClient.E2E.Support
|
|||||||
await (await As(browser, persona)).DisposeAsync();
|
await (await As(browser, persona)).DisposeAsync();
|
||||||
var session = await browser.Open(state, phone);
|
var session = await browser.Open(state, phone);
|
||||||
await session.Page.GotoAsync(Pasture.Site + "/");
|
await session.Page.GotoAsync(Pasture.Site + "/");
|
||||||
await session.Page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
if (await Stale(session.Page))
|
||||||
await session.Page.GotoAsync(Pasture.Site + "/");
|
{
|
||||||
await session.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
// 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();
|
await session.AssertPhone();
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async Task<bool> 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)
|
public static async Task Through(IPage page, Pasture.Persona persona)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
Reference in new issue
Block a user