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 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("() => 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().Contains(text) == true, 15), "the post was never published"); made = latest!["id"]!.GetValue(); }); } 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().Contains(text) == true, 15), "the reply was never published"); made = latest!["id"]!.GetValue(); Assert.NotNull(latest["in_reply_to_id"]?.GetValue()); }); } 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("() => document.getElementById('feed-scroll').clientHeight"); Assert.True(feedHeight >= 180, $"the feed is {feedHeight}px tall"); Assert.True(await page.EvaluateAsync("() => 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); } [Fact] public async Task Deleting_a_post_asks_first() { var persona = Persona(); var text = $"to be deleted {Guid.NewGuid():N}"[..26]; var made = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "public" }))!; var id = made["id"]!.GetValue(); await using var session = await SignIn.As(browser, persona, Phone); try { await Flow(session, "delete", async page => { var card = page.Locator($"#feed-scroll article[data-uri='{made["uri"]}']").First; await card.WaitForAsync(new() { Timeout = 30000 }); var more = card.Locator("footer [title='Other'] > [role=button]"); await more.TapAsync(); await card.Locator("[data-sheet] button", new() { HasText = "Delete" }).TapAsync(); var confirm = page.Locator("[data-confirm].modal-open"); await Assertions.Expect(confirm).ToBeVisibleAsync(); await AssertWhole(page, "[data-confirm] .modal-box"); await page.Locator("[data-confirm-no]").TapAsync(); await Assertions.Expect(confirm).ToHaveCountAsync(0); Assert.NotNull(await PersonaApi.Call(persona, $"/api/v1/statuses/{id}")); await more.TapAsync(); await card.Locator("[data-sheet] button", new() { HasText = "Delete" }).TapAsync(); await page.Locator("[data-confirm-yes]").TapAsync(); await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 15000 }); Assert.True(await Pasture.Eventually(async () => await PersonaApi.Call(persona, $"/api/v1/statuses/{id}") is null, 15), "the post is still there"); }); } finally { await PersonaApi.Call(persona, $"/api/v1/statuses/{id}", HttpMethod.Delete); } } // a tap on a post (in its margin, not on a link or a control) opens its thread [Fact] public async Task A_tap_on_a_post_opens_its_thread() { await using var session = await SignIn.As(browser, Persona(), Phone); await Flow(session, "thread", async page => { var card = Card(page, "a[data-thread-link]"); var box = (await card.BoundingBoxAsync())!; await card.TapAsync(new() { Position = new() { X = box.Width - 5, Y = box.Height * 0.35f } }); await page.WaitForURLAsync(url => new Uri(url).AbsolutePath.StartsWith("/expand/"), new() { Timeout = 15000 }); Assert.Empty(await page.Locator("[data-globe-pinned]").AllAsync()); }); } // a picture opens in the app, the whole screen, and closes [Fact] public async Task A_picture_opens_in_the_app() { var expected = (await Pasture.Town("expect")).AsArray(); var personas = Pasture.Personas(); var item = expected.FirstOrDefault(i => i!["alts"]!.AsArray().Count > 0 && i["readers"]!.AsArray().Any(r => personas.Any(p => p.UserName == (string)r))); Assert.SkipWhen(item is null, "the town holds no post with pictures"); var reader = personas.First(p => item!["readers"]!.AsArray().Any(r => (string)r == p.UserName)); await using var session = await SignIn.As(browser, reader, Phone); await Flow(session, "picture", async page => { await page.GotoAsync($"{Pasture.Site}/expand/{item!["id"]}"); var card = page.Locator($"article[data-uri='{item["uri"]}']").First; await card.WaitForAsync(new() { Timeout = 30000 }); var hidden = card.GetByRole(AriaRole.Button).Filter(new() { HasText = "Sensitive media" }); for (var i = 0; i < 8 && await hidden.CountAsync() > 0; i++) await hidden.First.TapAsync(); await card.Locator("button[data-picture]").First.TapAsync(); var viewer = page.Locator("[data-media-viewer].modal-open .modal-box"); await Assertions.Expect(viewer).ToBeVisibleAsync(); var box = (await viewer.BoundingBoxAsync())!; var size = page.ViewportSize!; Assert.True(box.Width >= size.Width - 2 && box.Height >= size.Height - 2, $"the picture viewer is {box.Width}x{box.Height}"); await AssertWhole(page, "[data-media-viewer] .modal-box"); if (await page.Locator("[data-picture-next]:enabled").CountAsync() > 0) await page.Locator("[data-picture-next]").TapAsync(); await page.Locator("[data-media-viewer] [data-modal-close]").TapAsync(); await Assertions.Expect(page.Locator("[data-media-viewer].modal-open")).ToHaveCountAsync(0); }); } static async Task Latest(Pasture.Persona persona) => (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault(); } }