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
93 lines
3.7 KiB
C#
93 lines
3.7 KiB
C#
using Microsoft.Playwright;
|
|
|
|
namespace decePubClient.E2E.Support
|
|
{
|
|
// Signs a town persona in through the real login page and persona chooser, once per persona per run; later sessions
|
|
// start from the saved browser state (the AuthData decePub keeps in localStorage).
|
|
public static class SignIn
|
|
{
|
|
static readonly string StateDir = Path.Combine(AppContext.BaseDirectory, ".state");
|
|
|
|
public static async Task<Session> As(Browser browser, Pasture.Persona persona, bool fresh = false)
|
|
{
|
|
var state = Path.Combine(StateDir, persona.UserName + ".json");
|
|
if (!fresh && File.Exists(state))
|
|
{
|
|
var known = await browser.Open(state);
|
|
await known.Page.GotoAsync(Pasture.Site + "/");
|
|
await known.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
return known;
|
|
}
|
|
var session = await browser.Open();
|
|
await Through(session.Page, persona);
|
|
Directory.CreateDirectory(StateDir);
|
|
await session.Context.StorageStateAsync(new() { Path = state });
|
|
return session;
|
|
}
|
|
|
|
// on a phone: the state the desktop sign-in saved, and the feed as on a first visit (no FeedFilter), so the globe
|
|
// banner starts closed as a phone's does
|
|
public static async Task<Session> As(Browser browser, Pasture.Persona persona, Phone phone)
|
|
{
|
|
var state = Path.Combine(StateDir, persona.UserName + ".json");
|
|
if (!File.Exists(state))
|
|
await (await As(browser, persona)).DisposeAsync();
|
|
var session = await browser.Open(state, phone);
|
|
await session.Page.GotoAsync(Pasture.Site + "/");
|
|
await session.Page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
|
await session.Page.GotoAsync(Pasture.Site + "/");
|
|
await session.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
await session.AssertPhone();
|
|
return session;
|
|
}
|
|
|
|
public static async Task Through(IPage page, Pasture.Persona persona)
|
|
{
|
|
try
|
|
{
|
|
await Steps(page, persona);
|
|
}
|
|
catch
|
|
{
|
|
await Evidence(page, "signin-" + persona.UserName);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
// a screenshot and the page's text next to the test output, for a failure seen only in a headless browser
|
|
public static async Task Evidence(IPage page, string name)
|
|
{
|
|
Directory.CreateDirectory(StateDir);
|
|
await page.ScreenshotAsync(new() { Path = Path.Combine(StateDir, name + ".png"), FullPage = true });
|
|
await File.WriteAllTextAsync(Path.Combine(StateDir, name + ".txt"), page.Url + "\n" + await page.InnerTextAsync("body"));
|
|
}
|
|
|
|
static async Task Steps(IPage page, Pasture.Persona persona)
|
|
{
|
|
await page.GotoAsync(Pasture.Site + "/login");
|
|
await page.Locator("input[autocomplete=username]").FillAsync(persona.Root);
|
|
await page.Locator("input[autocomplete=current-password]").FillAsync(persona.Password);
|
|
await page.Locator("button[type=submit]").ClickAsync();
|
|
// a root with several personas picks one; one with a single persona goes on by itself, its button disabled while it
|
|
// does. Only the login page's chooser is clicked: the feed's persona switcher names the persona too
|
|
var chooser = page.Locator("section button:enabled").Filter(new() { HasText = persona.UserName + "@privapub.test" });
|
|
var until = DateTime.UtcNow.AddSeconds(30);
|
|
while (new Uri(page.Url).AbsolutePath != "/" && DateTime.UtcNow < until)
|
|
{
|
|
if (await chooser.CountAsync() > 0)
|
|
try
|
|
{
|
|
await chooser.First.ClickAsync(new() { Timeout = 2000 });
|
|
}
|
|
catch (TimeoutException)
|
|
{
|
|
}
|
|
await page.WaitForTimeoutAsync(250);
|
|
}
|
|
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 });
|
|
// (a new persona's feed is empty)
|
|
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
}
|
|
}
|
|
}
|