using System.Text.Json.Nodes; using decePubClient.E2E.Support; using Microsoft.Playwright; namespace decePubClient.E2E.Actions { // A list in decePub: made on /lists, filled with an account the persona follows (found by its name among the accounts // it follows), read as the feed's Lists tab, made exclusive and deleted. Each step is checked against PrivaPub's own // answer to the persona's token, so the page and the server agree on the list, its members and its posts. public sealed class ListTests(Browser browser) { static Task Api(Pasture.Persona persona, string path, HttpMethod method = null, JsonObject body = null) => PersonaApi.Call(persona, path, method, body); // an account the persona follows that has written something static async Task Followed(Pasture.Persona persona) => (await Api(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=80"))!.AsArray() .FirstOrDefault(a => a!["statuses_count"]!.GetValue() > 0 && !a["acct"]!.GetValue().StartsWith("st_")); [Fact] public async Task An_account_joins_and_leaves_a_list_from_its_profile() { var persona = Pasture.Personas().First(); var member = await Followed(persona); Assert.SkipWhen(member is null, $"{persona.UserName} follows nobody who has posted"); var memberId = member!["id"]!.GetValue(); var listId = (await Api(persona, "/api/v1/lists", HttpMethod.Post, new JsonObject { ["title"] = $"e2e {Guid.NewGuid():N}"[..12] }))!["id"]!.GetValue(); await using var session = await SignIn.As(browser, persona); var page = session.Page; try { await Results.Cell("client.action.list-member", "privapub", persona.UserName, async () => { async Task InList() => (await Api(persona, $"/api/v1/lists/{listId}/accounts"))!.AsArray().Any(a => a!["id"]!.GetValue() == memberId); await page.GotoAsync($"{Pasture.Site}/account/{memberId}"); var profile = page.Locator("article[data-account-id]").First; await profile.WaitForAsync(new() { Timeout = 30000 }); await profile.GetByRole(AriaRole.Button, new() { Name = "Lists" }).ClickAsync(); var box = profile.Locator($"[data-list-id='{listId}'] input"); await box.CheckAsync(); Assert.True(await Pasture.Eventually(InList, 15), "the profile's checkbox never put the account in the list"); await box.UncheckAsync(); Assert.True(await Pasture.Eventually(async () => !await InList(), 15), "the profile's checkbox never took the account out"); await session.AssertGuards(); }, page); } finally { await Api(persona, $"/api/v1/lists/{listId}", HttpMethod.Delete); } } [Fact] public async Task A_list_is_made_filled_read_as_a_feed_and_deleted() { var persona = Pasture.Personas().First(); var member = await Followed(persona); Assert.SkipWhen(member is null, $"{persona.UserName} follows nobody who has posted"); var memberId = member!["id"]!.GetValue(); var title = $"e2e {Guid.NewGuid():N}"[..12]; // lists an earlier failed run left behind foreach (var left in (await Api(persona, "/api/v1/lists"))!.AsArray().Where(l => l!["title"]!.GetValue().StartsWith("e2e "))) await Api(persona, $"/api/v1/lists/{left!["id"]!.GetValue()}", HttpMethod.Delete); await using var session = await SignIn.As(browser, persona); var page = session.Page; await Results.Cell("client.action.list", "privapub", persona.UserName, async () => { await page.GotoAsync($"{Pasture.Site}/lists"); await page.Locator("[data-list-new] input, input[data-list-new]").First.FillAsync(title); await page.GetByRole(AriaRole.Button, new() { Name = "Make a list" }).ClickAsync(); var article = page.Locator("article[data-list-id]").Filter(new() { HasText = title }); await article.WaitForAsync(new() { Timeout = 15000 }); var listId = await article.GetAttributeAsync("data-list-id"); Assert.Contains((await Api(persona, "/api/v1/lists"))!.AsArray(), l => l!["id"]!.GetValue() == listId); // the list opens once made: find the member among the accounts followed, and add it await article.Locator("[data-list-find] input, input[data-list-find]").First.FillAsync(member["username"]!.GetValue()); await article.Locator("[data-list-find] input, input[data-list-find]").First.PressAsync("Enter"); var candidate = article.Locator($"[data-candidate-id='{memberId}']"); await candidate.WaitForAsync(new() { Timeout = 15000 }); await candidate.GetByRole(AriaRole.Button, new() { Name = "Add" }).ClickAsync(); await article.Locator($"[data-member-id='{memberId}']").WaitForAsync(new() { Timeout = 15000 }); Assert.Equal([memberId], (await Api(persona, $"/api/v1/lists/{listId}/accounts"))!.AsArray().Select(a => a!["id"]!.GetValue())); // read as a feed: the cards are the list timeline's first page, as PrivaPub answers it await article.GetByRole(AriaRole.Button, new() { Name = "Read the list" }).ClickAsync(); await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 }); var expected = (await Api(persona, $"/api/v1/timelines/list/{listId}?limit=20"))!.AsArray() .Select(s => (s!["reblog"] ?? s)["uri"]!.GetValue()).ToHashSet(); var shown = await page.Locator("article[data-uri]").EvaluateAllAsync("cards => cards.map(c => c.dataset.uri)"); Assert.NotEmpty(expected); Assert.Subset(expected, shown.ToHashSet()); // exclusive, then deleted await page.GotoAsync($"{Pasture.Site}/lists"); article = page.Locator($"article[data-list-id='{listId}']"); await article.GetByRole(AriaRole.Button, new() { Name = "Edit" }).ClickAsync(); await article.GetByLabel("Keep its members out of the home feed").CheckAsync(); await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync(); Assert.True(await Pasture.Eventually(async () => (await Api(persona, $"/api/v1/lists/{listId}"))?["exclusive"]?.GetValue() == true, 15), "PrivaPub never made the list exclusive"); // the icon's glyph (CSS content) is part of a button's name, so names are matched as substrings await article.GetByRole(AriaRole.Button, new() { Name = "Delete" }).First.ClickAsync(); await article.GetByRole(AriaRole.Button, new() { Name = "Delete it for good" }).ClickAsync(); await Assertions.Expect(article).ToHaveCountAsync(0, new() { Timeout = 15000 }); Assert.Null(await Api(persona, $"/api/v1/lists/{listId}")); await session.AssertGuards(); }, page); } } }