Files

136 lines
5.5 KiB
C#
Raw Permalink Normal View History

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();
}
2026-10-06 22:40:28 +02:00
// 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)
{
2026-10-06 22:40:28 +02:00
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 })
{
2026-10-06 22:40:28 +02:00
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);
}
}
2026-10-06 22:40:28 +02:00
// 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; } = [];
2026-10-06 22:40:28 +02:00
public Phone Phone { get; }
public Session(IBrowserContext context, IPage page, Phone phone = null)
{
Context = context;
Page = page;
2026-10-06 22:40:28 +02:00
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));
}
2026-10-06 22:40:28 +02:00
// 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();
}
}