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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
172 lines
7.1 KiB
C#
172 lines
7.1 KiB
C#
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/<run>/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<string> seen = [];
|
|
public List<JsonObject> Overflow { get; } = [];
|
|
public List<JsonObject> Beyond { get; } = [];
|
|
public List<JsonObject> Popovers { get; } = [];
|
|
public List<JsonObject> Small { get; } = [];
|
|
public List<JsonObject> 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<JsonObject> 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<Report> 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<JsonElement>("() => 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<bool>("() => 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("<!doctype html><meta charset=utf-8><title>decePub on phones</title>")
|
|
.Append("<style>body{font:13px system-ui;background:#222;color:#ddd}td{vertical-align:top;padding:6px}img{width:180px;display:block}")
|
|
.Append("a{color:#9cf}.bad{color:#f88}</style><table><tr><th></th>");
|
|
foreach (var phone in phones)
|
|
html.Append("<th>").Append(WebUtility.HtmlEncode(phone)).Append("</th>");
|
|
html.Append("</tr>");
|
|
foreach (var route in routes)
|
|
{
|
|
html.Append("<tr><th>").Append(WebUtility.HtmlEncode(route)).Append("</th>");
|
|
foreach (var phone in phones)
|
|
{
|
|
var dir = Path.Combine(RunDir, phone, route);
|
|
html.Append("<td>");
|
|
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($"<a href=\"{link}/00.png\"><img src=\"{link}/00.png\"></a>")
|
|
.Append($"<span class=\"{(fit + touch > 0 ? "bad" : "")}\">fit {fit} · touch {touch} · {found["screens"]} screens</span> ")
|
|
.Append($"<a href=\"{link}/violations.json\">findings</a>");
|
|
}
|
|
html.Append("</td>");
|
|
}
|
|
html.Append("</tr>");
|
|
}
|
|
File.WriteAllText(Path.Combine(RunDir, "index.html"), html.Append("</table>").ToString());
|
|
}
|
|
}
|
|
}
|
|
}
|