2026-10-05 01:23:18 +02:00
|
|
|
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 + "/");
|
2026-10-06 23:13:23 +02:00
|
|
|
if (!await Stale(known.Page))
|
|
|
|
|
{
|
|
|
|
|
await known.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
|
|
|
|
return known;
|
|
|
|
|
}
|
|
|
|
|
await known.DisposeAsync();
|
2026-10-05 01:23:18 +02:00
|
|
|
}
|
|
|
|
|
var session = await browser.Open();
|
|
|
|
|
await Through(session.Page, persona);
|
|
|
|
|
Directory.CreateDirectory(StateDir);
|
|
|
|
|
await session.Context.StorageStateAsync(new() { Path = state });
|
|
|
|
|
return session;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 22:40:28 +02:00
|
|
|
// 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 + "/");
|
2026-10-06 23:13:23 +02:00
|
|
|
if (await Stale(session.Page))
|
|
|
|
|
{
|
|
|
|
|
// the saved sign-in has expired (the page went to /login): sign in again on a desktop and start over
|
|
|
|
|
await session.DisposeAsync();
|
|
|
|
|
await (await As(browser, persona, fresh: true)).DisposeAsync();
|
|
|
|
|
session = await browser.Open(state, phone);
|
|
|
|
|
}
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
await Evidence(session.Page, $"signin-{phone.Key}-{persona.UserName}");
|
|
|
|
|
throw;
|
|
|
|
|
}
|
2026-10-06 22:40:28 +02:00
|
|
|
await session.AssertPhone();
|
|
|
|
|
return session;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 23:13:23 +02:00
|
|
|
static async Task<bool> Stale(IPage page)
|
|
|
|
|
{
|
|
|
|
|
var until = DateTime.UtcNow.AddSeconds(15);
|
|
|
|
|
while (DateTime.UtcNow < until)
|
|
|
|
|
{
|
|
|
|
|
if (new Uri(page.Url).AbsolutePath.StartsWith("/login"))
|
|
|
|
|
return true;
|
|
|
|
|
if (await page.Locator("article[data-uri], [data-feed-empty]").CountAsync() > 0)
|
|
|
|
|
return false;
|
|
|
|
|
await page.WaitForTimeoutAsync(250);
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 01:23:18 +02:00
|
|
|
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();
|
2026-10-05 18:32:47 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-10-05 01:23:18 +02:00
|
|
|
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 });
|
2026-10-05 18:32:47 +02:00
|
|
|
// (a new persona's feed is empty)
|
|
|
|
|
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
2026-10-05 01:23:18 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|