Files
decePubClient/tests/e2e/Mobile/FlowTests.cs
T
thepraandClaude Opus 5.5 931b7fcff1 The globe opens full screen; a phone held sideways keeps its feed
On a phone the globe is a banner above the feed; a tap on it now opens it full screen over everything, with a close
button (Escape closes it too), the focus moving to that button and back, and the feed underneath kept as it was but out
of reach (inert). The camera refits the new shape. Held sideways, a phone (a window at most 30rem tall, the new short:
variant) has no room for the banner: the banner and its toggle give way to a button that opens the globe full screen.
isMobileMedia, which picks the first visit's default, agrees with Tailwind's md now (it was off by one) and counts a
phone held sideways. GlobePanel tells its state (data-globe-state). Phone flows: the globe opens full screen and
closes, and a sideways phone keeps a feed at least 180px tall with no sideways scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:23:14 +02:00

232 lines
9.9 KiB
C#

using System.Text.Json.Nodes;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Mobile
{
// What a thumb does on the narrowest phone, step by step: every menu, sheet and dialog that opens is whole on the
// screen and its targets have 44px hit areas, and each one closes the way a phone user expects. One cell each,
// client.mobile.flow.*.
[Trait("Category", "Mobile")]
public sealed class FlowTests(Browser browser)
{
static readonly Phone Phone = Phone.Narrow360;
static Pasture.Persona Persona() => Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
static async Task Flow(Session session, string name, Func<IPage, Task> steps)
{
var page = session.Page;
await Results.Cell($"client.mobile.flow.{name}", "privapub", Phone.Key, async () =>
{
await steps(page);
await session.AssertGuards();
}, page);
}
// what opened is whole (not cut, inside the screen) and its targets are thumb-sized; the rest of the page is the
// sweep's
static async Task AssertWhole(IPage page, string scope)
{
var report = await Fit.Now(page, scope);
Assert.True(report.Popovers.Count == 0, "cut or outside: " + string.Join("\n", report.Popovers.Select(p => p.ToJsonString())));
Fit.AssertTouchable(report);
}
static ILocator Card(IPage page, string has = null) =>
(has is null ? page.Locator("#feed-scroll article[data-uri]") : page.Locator($"#feed-scroll article[data-uri]:has({has})")).First;
[Fact]
public async Task The_menu_closes_after_a_choice_an_outside_tap_and_escape()
{
await using var session = await SignIn.As(browser, Persona(), Phone);
await Flow(session, "nav", async page =>
{
var toggle = page.Locator("[data-nav-toggle]");
var panel = page.Locator("[data-nav-panel]");
await toggle.TapAsync();
await Assertions.Expect(panel).ToBeVisibleAsync();
await AssertWhole(page, "[data-nav-panel]");
await panel.Locator("a[href='explore']").TapAsync();
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore");
await Assertions.Expect(panel).ToBeHiddenAsync();
await toggle.TapAsync();
await Assertions.Expect(panel).ToBeVisibleAsync();
var size = page.ViewportSize!;
await page.Touchscreen.TapAsync(size.Width - 12, size.Height - 40);
await Assertions.Expect(panel).ToBeHiddenAsync();
await toggle.TapAsync();
await Assertions.Expect(panel).ToBeVisibleAsync();
await page.Keyboard.PressAsync("Escape");
await Assertions.Expect(panel).ToBeHiddenAsync();
});
}
[Fact]
public async Task A_dialog_rises_from_the_bottom_and_closes_from_its_button()
{
await using var session = await SignIn.As(browser, Persona(), Phone);
await Flow(session, "dialog", async page =>
{
var card = Card(page, "[data-liked-by]");
if (await card.CountAsync() == 0)
Assert.Skip("no post in the home feed has been liked");
await card.ScrollIntoViewIfNeededAsync();
await card.Locator("footer [title='Other'] > [role=button]").TapAsync();
await AssertWhole(page, "[data-sheet]");
await card.Locator("[data-liked-by]").TapAsync();
var dialog = page.Locator(".modal-open .modal-box").First;
await Assertions.Expect(dialog).ToBeVisibleAsync();
await AssertWhole(page, ".modal-open .modal-box");
var box = (await dialog.BoundingBoxAsync())!;
Assert.True(box.Y + box.Height >= page.ViewportSize!.Height - 2, "the dialog doesn't rise from the bottom of the screen");
await page.Locator(".modal-open [data-modal-close]").TapAsync();
await Assertions.Expect(page.Locator(".modal-open")).ToHaveCountAsync(0);
});
}
[Fact]
public async Task A_new_post_is_written_in_a_sheet_from_a_scrolled_feed()
{
var persona = Persona();
var text = $"from a phone {Guid.NewGuid():N}"[..24];
await using var session = await SignIn.As(browser, persona, Phone);
string made = null;
try
{
await Flow(session, "compose", async page =>
{
await page.EvaluateAsync("() => document.getElementById('feed-scroll').scrollTo({ top: 1500, behavior: 'instant' })");
await page.Locator("[data-compose-open]").TapAsync();
var sheet = page.Locator("[data-sheet]:has(textarea[data-compose])").First;
await Assertions.Expect(sheet).ToBeVisibleAsync();
await AssertWhole(page, "[data-sheet]");
Assert.True(await page.EvaluateAsync<bool>("() => document.activeElement?.matches('textarea[data-compose]') === true"),
"the composer's words don't have the focus");
await page.Keyboard.TypeAsync(text);
await sheet.Locator("button[type=submit]").TapAsync();
await Assertions.Expect(sheet).ToBeHiddenAsync(new() { Timeout = 15000 });
JsonNode latest = null;
Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue<string>().Contains(text) == true, 15),
"the post was never published");
made = latest!["id"]!.GetValue<string>();
});
}
finally
{
if (made is not null)
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
}
}
[Fact]
public async Task A_reply_is_written_in_a_sheet()
{
var persona = Persona();
var text = $"a phone reply {Guid.NewGuid():N}"[..24];
await using var session = await SignIn.As(browser, persona, Phone);
string made = null;
try
{
await Flow(session, "reply", async page =>
{
var card = Card(page, "footer button[title='Reply']");
await card.Locator("footer button[title='Reply']").TapAsync();
var sheet = page.Locator("[data-sheet]:has(textarea[data-compose])").First;
await Assertions.Expect(sheet).ToBeVisibleAsync();
await AssertWhole(page, "[data-sheet]");
await sheet.Locator("textarea[data-compose]").FillAsync(text);
await sheet.Locator("button[type=submit]").TapAsync();
await Assertions.Expect(sheet).ToBeHiddenAsync(new() { Timeout = 15000 });
JsonNode latest = null;
Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue<string>().Contains(text) == true, 15),
"the reply was never published");
made = latest!["id"]!.GetValue<string>();
Assert.NotNull(latest["in_reply_to_id"]?.GetValue<string>());
});
}
finally
{
if (made is not null)
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
}
}
[Fact]
public async Task A_reaction_is_picked_from_a_sheet()
{
await using var session = await SignIn.As(browser, Persona(), Phone);
await Flow(session, "react", async page =>
{
var card = Card(page, "footer [title='React']");
var uri = await card.GetAttributeAsync("data-uri");
card = page.Locator($"#feed-scroll article[data-uri='{uri}']").First;
await card.Locator("footer [title='React'] > [role=button]").TapAsync();
await AssertWhole(page, "[data-sheet]");
await card.Locator("[data-sheet] button", new() { HasText = "😮" }).First.TapAsync();
var notice = page.Locator("[data-public-notice].modal-open");
if (await notice.IsVisibleAsync() || await Pasture.Eventually(() => notice.IsVisibleAsync(), 2))
{
await AssertWhole(page, "[data-public-notice] .modal-box");
await page.Locator("[data-public-notice-yes]").TapAsync();
}
var mine = card.Locator("[data-reactions] button.btn-active", new() { HasText = "😮" });
await Assertions.Expect(mine).ToBeVisibleAsync(new() { Timeout = 15000 });
// and taken back
await mine.TapAsync();
await Assertions.Expect(mine).ToHaveCountAsync(0, new() { Timeout = 15000 });
});
}
[Fact]
public async Task The_globe_opens_full_screen_from_its_banner_and_closes()
{
await using var session = await SignIn.As(browser, Persona(), Phone);
await Flow(session, "globe", async page =>
{
await page.Locator("button[title='Globe']").TapAsync();
var expand = page.Locator("[data-globe-expand]");
await Assertions.Expect(expand).ToBeVisibleAsync();
await expand.TapAsync();
var full = page.Locator("aside[role=dialog]");
await Assertions.Expect(full).ToBeVisibleAsync();
var box = (await full.BoundingBoxAsync())!;
var size = page.ViewportSize!;
Assert.True(Math.Abs(box.Width - size.Width) < 2 && Math.Abs(box.Height - size.Height) < 2,
$"the globe is {box.Width}x{box.Height}, not the screen's {size.Width}x{size.Height}");
await AssertWhole(page, "aside[role=dialog]");
await page.Locator("[data-globe-close]").TapAsync();
await Assertions.Expect(full).ToHaveCountAsync(0);
await Assertions.Expect(expand).ToBeVisibleAsync();
});
}
// a phone held sideways keeps its feed: no banner squeezing it, the globe full screen from the tabs instead
[Fact]
public async Task A_phone_held_sideways_keeps_its_feed()
{
await using var session = await SignIn.As(browser, Persona(), Phone.Pixel7Landscape);
var page = session.Page;
await Results.Cell("client.mobile.flow.landscape", "privapub", Phone.Pixel7Landscape.Key, async () =>
{
Assert.False(await page.Locator("[data-globe-expand]").IsVisibleAsync(), "the globe's banner squeezes the feed");
var feedHeight = await page.EvaluateAsync<double>("() => document.getElementById('feed-scroll').clientHeight");
Assert.True(feedHeight >= 180, $"the feed is {feedHeight}px tall");
Assert.True(await page.EvaluateAsync<bool>("() => document.scrollingElement.scrollWidth <= innerWidth + 1"), "the page scrolls sideways");
await page.Locator("[data-globe-full]").TapAsync();
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToBeVisibleAsync();
await page.Locator("[data-globe-close]").TapAsync();
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToHaveCountAsync(0);
await session.AssertGuards();
}, page);
}
static async Task<JsonNode> Latest(Pasture.Persona persona) =>
(await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault();
}
}