2026-10-05 01:23:18 +02:00
|
|
|
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-05 01:23:18 +02:00
|
|
|
{
|
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-05 01:23:18 +02:00
|
|
|
{
|
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-05 01:23:18 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 01:23:18 +02:00
|
|
|
// 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)
|
2026-10-05 01:23:18 +02:00
|
|
|
{
|
|
|
|
|
Context = context;
|
|
|
|
|
Page = page;
|
2026-10-06 22:40:28 +02:00
|
|
|
Phone = phone;
|
2026-10-05 01:23:18 +02:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 01:23:18 +02:00
|
|
|
public async ValueTask DisposeAsync() => await Context.CloseAsync();
|
|
|
|
|
}
|
|
|
|
|
}
|