using System.Text.Json.Nodes; using decePubClient.E2E.Support; using Microsoft.Playwright; namespace decePubClient.E2E.Actions { // What a reader does to a post from each platform, done in decePub and checked on the post's own server: a favourite // and a boost arrive there (and leave when undone), a reply lands under the post and is gone once deleted. town.sh // stored reads the origin's database, so nothing here trusts decePub's own view of what it did. public sealed class ActionMatrixTests(Browser browser) { static readonly Lazy Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray()); public static TheoryData Targets() { var data = new TheoryData(); var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet(); foreach (var origin in new[] { "gts", "mastodon", "misskey", "sharkey", "akkoma", "lemmy" }) { var item = Expected.Value.FirstOrDefault(i => i!["origin"]!.GetValue() == origin && i["visibility"]!.GetValue() is "public" or "community" && i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue()))); if (item is not null) data.Add(origin, item["ref"]!.GetValue()); } return data; } static (JsonNode Item, Pasture.Persona Reader) Target(string reference) { var item = Expected.Value.First(i => i!["ref"]!.GetValue() == reference)!; var personas = Pasture.Personas(); var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue())) .First(p => p is not null); return (item, reader); } static async Task Count(string origin, string uri, string field) { var row = (await Pasture.Stored(origin, uri))[uri]!; return row[field]?.GetValue() ?? 0; } static async Task IsActive(ILocator button) => ((await button.GetAttributeAsync("class")) ?? "").Split(' ').Contains("btn-active"); async Task<(Session Session, ILocator Card)> Open(JsonNode item, Pasture.Persona reader) { var session = await SignIn.As(browser, reader); await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue()}"); var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue()}']").First; await card.WaitForAsync(new() { Timeout = 30000 }); return (session, card); } [Theory] [MemberData(nameof(Targets))] public async Task A_favourite_reaches_the_author_and_its_undo_too(string origin, string reference) { var (item, reader) = Target(reference); var uri = item["uri"]!.GetValue(); await Results.Cell("client.action.favourite", origin, reference, async () => { var (session, card) = await Open(item, reader); await using var _ = session; var favourite = card.Locator("footer button[title='Favourite']").First; // the town may have done it already as this persona: start from the undone state if (await IsActive(favourite)) { var done = await Count(origin, uri, "likes"); await favourite.ClickAsync(); await Pasture.Eventually(async () => await Count(origin, uri, "likes") < done, 60); await Assertions.Expect(favourite).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active")); } var before = await Count(origin, uri, "likes"); await favourite.ClickAsync(); Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "likes") > before), $"the favourite never reached {origin}"); await favourite.ClickAsync(); Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "likes") == before, 60), $"undoing the favourite never reached {origin}"); await session.AssertGuards(); }); } [Theory] [MemberData(nameof(Targets))] public async Task A_boost_reaches_the_author_and_its_undo_too(string origin, string reference) { Assert.SkipWhen(origin == "lemmy", "Lemmy keeps no boosts of a thread"); var (item, reader) = Target(reference); var uri = item["uri"]!.GetValue(); await Results.Cell("client.action.boost", origin, reference, async () => { var (session, card) = await Open(item, reader); await using var _ = session; var boost = card.Locator("footer button[title='Boost']").First; // the town may have done it already as this persona: start from the undone state if (await IsActive(boost)) { var done = await Count(origin, uri, "boosts"); await boost.ClickAsync(); await Pasture.Eventually(async () => await Count(origin, uri, "boosts") < done, 60); await Assertions.Expect(boost).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active")); } var before = await Count(origin, uri, "boosts"); await boost.ClickAsync(); Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "boosts") > before), $"the boost never reached {origin}"); await boost.ClickAsync(); Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "boosts") == before, 60), $"undoing the boost never reached {origin}"); await session.AssertGuards(); }); } [Theory] [MemberData(nameof(Targets))] public async Task A_reply_lands_under_the_post_and_its_delete_removes_it(string origin, string reference) { var (item, reader) = Target(reference); var uri = item["uri"]!.GetValue(); await Results.Cell("client.action.reply", origin, reference, async () => { var (session, card) = await Open(item, reader); await using var _ = session; await card.Locator("footer button[title='Reply']").First.ClickAsync(); var text = $"an e2e reply to {reference} ({Guid.NewGuid():N})"; // after the @names the composer prefills, as a reader types var box = card.Locator("textarea").Last; await box.FillAsync(((await box.InputValueAsync()).TrimEnd() + " " + text).Trim()); var published = session.Page.WaitForResponseAsync(r => r.Url.EndsWith("/api/v1/statuses") && r.Request.Method == "POST"); await card.Locator("button[type=submit]").Last.ClickAsync(); var response = await published; Assert.True(response.Ok, $"publishing the reply answered {response.Status}"); var replyUri = (await response.JsonAsync())?.GetProperty("uri").GetString(); Assert.False(string.IsNullOrEmpty(replyUri)); Assert.True(await Pasture.Eventually(async () => { var row = (await Pasture.Stored(origin, replyUri))[replyUri]!; return row["exists"]!.GetValue() && row["parent_uri"]?.GetValue() == uri; }, 45), $"the reply never landed under the post on {origin}"); var reply = session.Page.Locator($"article[data-uri='{replyUri}']").First; await reply.WaitForAsync(new() { Timeout = 15000 }); await reply.Locator("footer [title='Other']").First.ClickAsync(); await reply.GetByRole(AriaRole.Button).Filter(new() { HasText = "Delete" }).First.ClickAsync(); await session.Page.Locator("[data-confirm-yes]").ClickAsync(); Assert.True(await Pasture.Eventually(async () => { var row = (await Pasture.Stored(origin, replyUri))[replyUri]!; return !row["exists"]!.GetValue() || row["deleted"]!.GetValue(); }, 45), $"the deleted reply is still on {origin}"); await session.AssertGuards(); }); } } }