A card's time is a "Post" button now (its nerd stats open with a tap), so the poll test submits through the composer's own submit button instead of the last button named Post; the feed-memory test opens a thread from the Expand button, not from the first expand/ link, which on a desktop is now the phone-only Thread entry. The Back flow presses Back with history.back(): a Back held to close an overlay loads nothing, which GoBackAsync waited for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
364 lines
16 KiB
C#
364 lines
16 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);
|
|
}
|
|
|
|
[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<string>();
|
|
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);
|
|
});
|
|
}
|
|
|
|
// the Back button closes what is open, one at a time, and stays on the page
|
|
[Fact]
|
|
public async Task Back_closes_what_is_open()
|
|
{
|
|
await using var session = await SignIn.As(browser, Persona(), Phone);
|
|
await Flow(session, "back", async page =>
|
|
{
|
|
var home = page.Url;
|
|
// a page to go back to, so a Back that isn't held would leave the feed
|
|
await page.Locator("[data-nav-toggle]").TapAsync();
|
|
await page.Locator("[data-nav-panel] a[href='explore']").TapAsync();
|
|
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore");
|
|
await page.Locator("[data-nav-toggle]").TapAsync();
|
|
await page.Locator("[data-nav-panel] a[href='/']").First.TapAsync();
|
|
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/");
|
|
await page.Locator("#feed-scroll article[data-uri]").First.WaitForAsync();
|
|
|
|
// (history.back(), as the Back button does: a Back held to close an overlay loads nothing, which GoBackAsync would
|
|
// wait for)
|
|
async Task BackCloses(ILocator open, string what)
|
|
{
|
|
await Assertions.Expect(open).ToBeVisibleAsync();
|
|
await page.EvaluateAsync("() => history.back()");
|
|
await Assertions.Expect(open).ToBeHiddenAsync(new() { Timeout = 5000 });
|
|
Assert.True(new Uri(page.Url).AbsolutePath == "/", $"Back after the {what} left the feed for {page.Url}");
|
|
}
|
|
|
|
await page.Locator("[data-nav-toggle]").TapAsync();
|
|
await BackCloses(page.Locator("[data-nav-panel]"), "menu");
|
|
|
|
var card = Card(page, "footer [title='Other']");
|
|
await card.Locator("footer [title='Other'] > [role=button]").TapAsync();
|
|
await BackCloses(card.Locator(".dropdown-open > [data-sheet]"), "More sheet");
|
|
|
|
await page.Locator("[data-compose-open]").TapAsync();
|
|
await BackCloses(page.Locator("[data-sheet]:has(textarea[data-compose])"), "composer");
|
|
|
|
await page.Locator("button[title='Globe']").TapAsync();
|
|
await page.Locator("[data-globe-expand]").TapAsync();
|
|
await BackCloses(page.Locator("aside[role=dialog]"), "full-screen globe");
|
|
|
|
// with nothing open, Back leaves the feed as before
|
|
await page.EvaluateAsync("() => history.back()");
|
|
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore", new() { Timeout = 10000 });
|
|
});
|
|
}
|
|
|
|
static async Task<JsonNode> Latest(Pasture.Persona persona) =>
|
|
(await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault();
|
|
}
|
|
}
|