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
137 lines
5.5 KiB
C#
137 lines
5.5 KiB
C#
using Microsoft.Playwright;
|
|
|
|
[assembly: AssemblyFixture(typeof(decePubClient.E2E.Support.Browser))]
|
|
|
|
namespace decePubClient.E2E.Support
|
|
{
|
|
// One Chromium for the whole run. *.test names go to Caddy on 127.0.0.1:6443 (Chromium's own --resolve), whose
|
|
// certificates come from the pasture's private CA, so certificate errors are ignored; nothing else is reachable.
|
|
public sealed class Browser : IAsyncLifetime
|
|
{
|
|
IPlaywright _playwright;
|
|
public IBrowser Chromium { get; private set; }
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
_playwright = await Playwright.CreateAsync();
|
|
Chromium = await _playwright.Chromium.LaunchAsync(new()
|
|
{
|
|
Headless = Environment.GetEnvironmentVariable("E2E_HEADED") != "1",
|
|
Args = ["--host-resolver-rules=MAP *.test 127.0.0.1:6443"]
|
|
});
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (Chromium is not null)
|
|
await Chromium.CloseAsync();
|
|
_playwright?.Dispose();
|
|
}
|
|
|
|
// 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<Session> Open(string storageState = null, Phone phone = null)
|
|
{
|
|
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 })
|
|
{
|
|
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
|
|
{
|
|
public IBrowserContext Context { get; }
|
|
public IPage Page { get; }
|
|
public List<string> Hosts { get; } = [];
|
|
public List<string> ConsoleErrors { get; } = [];
|
|
public List<string> ServerErrors { get; } = [];
|
|
public List<(string Url, string Authorization)> ApiCalls { get; } = [];
|
|
|
|
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);
|
|
if (uri.Scheme is "http" or "https")
|
|
lock (Hosts)
|
|
Hosts.Add(uri.Host);
|
|
if (uri.Host == "privapub.test")
|
|
lock (ApiCalls)
|
|
ApiCalls.Add((request.Url, request.Headers.TryGetValue("authorization", out var auth) ? auth : null));
|
|
};
|
|
page.Console += (_, message) =>
|
|
{
|
|
if (message.Type == "error")
|
|
lock (ConsoleErrors)
|
|
ConsoleErrors.Add(message.Text);
|
|
};
|
|
page.Response += (_, response) =>
|
|
{
|
|
if (response.Status >= 500)
|
|
lock (ServerErrors)
|
|
ServerErrors.Add($"{response.Status} {response.Url}");
|
|
};
|
|
}
|
|
|
|
public async Task AssertGuards()
|
|
{
|
|
Assert.All(Hosts.Distinct(), host => Assert.Contains(host, new[] { "decepub.test", "privapub.test" }));
|
|
Assert.Empty(ServerErrors);
|
|
Assert.False(await Page.Locator("#blazor-error-ui").IsVisibleAsync(), "Blazor's error bar is showing");
|
|
// /clientapi carries the root's JWT and /api a persona's token: no credential ever crosses over
|
|
var clientApi = ApiCalls.Where(c => c.Authorization is not null && new Uri(c.Url).AbsolutePath.StartsWith("/clientapi/"))
|
|
.Select(c => c.Authorization).ToHashSet();
|
|
var mastodonApi = ApiCalls.Where(c => c.Authorization is not null && new Uri(c.Url).AbsolutePath.StartsWith("/api/"))
|
|
.Select(c => c.Authorization).ToHashSet();
|
|
Assert.Empty(clientApi.Intersect(mastodonApi));
|
|
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<bool>("() => matchMedia('(pointer: coarse)').matches && matchMedia('(hover: none)').matches"),
|
|
$"{Phone.Key} is not emulated as a touch screen");
|
|
}
|
|
|
|
public async ValueTask DisposeAsync() => await Context.CloseAsync();
|
|
}
|
|
}
|