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 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 Hosts { get; } = []; public List ConsoleErrors { get; } = []; public List 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("() => matchMedia('(pointer: coarse)').matches && matchMedia('(hover: none)').matches"), $"{Phone.Key} is not emulated as a touch screen"); } public async ValueTask DisposeAsync() => await Context.CloseAsync(); } }