A tap on a picture opened its file in a new tab, leaving the app (and, on a phone, its place in the feed). It now opens MediaViewer: the whole screen on a phone, a large dialog from md, the picture whole with its alt text under it, the post's other pictures by the previous and next buttons or the arrow keys, and "Open original" for whoever wants the file. Phone flow: a picture of a town post opens the viewer over the whole screen, which closes from its button. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
317 lines
14 KiB
C#
317 lines
14 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);
|
|
});
|
|
}
|
|
|
|
static async Task<JsonNode> Latest(Pasture.Persona persona) =>
|
|
(await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault();
|
|
}
|
|
}
|