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()); } } } }