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(); } public async Task Open(string storageState = null) { var context = await Chromium.NewContextAsync(new() { IgnoreHTTPSErrors = true, Locale = "en-GB", TimezoneId = "Europe/Rome", ViewportSize = new() { Width = 1440, Height = 900 }, ServiceWorkers = ServiceWorkerPolicy.Block, StorageStatePath = storageState }); return new Session(context, await context.NewPageAsync()); } } // 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 Session(IBrowserContext context, IPage page) { Context = context; Page = page; 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)); } public async ValueTask DisposeAsync() => await Context.CloseAsync(); } }