Deleting a post, taking it off the persona's wall, muting or blocking an account (from a card or a profile), leaving a group, dropping a scheduled post and signing out acted at the first tap, a few pixels from their neighbours. They now ask first: new ConfirmService (Ask returns whether the reader confirmed, as PublicNoticeService does) and ConfirmHost in MainLayout, a sheet from the bottom on a phone and a dialog from md, with Cancel and the action named, in red when it deletes or cuts off. Logout is a button that asks, then goes to /logout as before. The reversible ones (unmute, unblock, removing a conversation from the list, a picture from the composer) don't ask; the inline confirms of lists, filters and the login's deletion stay. The desktop tests that delete a reply, take a post off a wall or drop a scheduled post confirm; a phone flow deletes a post after cancelling once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
162 lines
7.2 KiB
C#
162 lines
7.2 KiB
C#
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<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
|
|
|
|
public static TheoryData<string, string> Targets()
|
|
{
|
|
var data = new TheoryData<string, string>();
|
|
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<string>() == origin
|
|
&& i["visibility"]!.GetValue<string>() is "public" or "community"
|
|
&& i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue<string>())));
|
|
if (item is not null)
|
|
data.Add(origin, item["ref"]!.GetValue<string>());
|
|
}
|
|
return data;
|
|
}
|
|
|
|
static (JsonNode Item, Pasture.Persona Reader) Target(string reference)
|
|
{
|
|
var item = Expected.Value.First(i => i!["ref"]!.GetValue<string>() == reference)!;
|
|
var personas = Pasture.Personas();
|
|
var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue<string>()))
|
|
.First(p => p is not null);
|
|
return (item, reader);
|
|
}
|
|
|
|
static async Task<int> Count(string origin, string uri, string field)
|
|
{
|
|
var row = (await Pasture.Stored(origin, uri))[uri]!;
|
|
return row[field]?.GetValue<int?>() ?? 0;
|
|
}
|
|
|
|
static async Task<bool> 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<string>()}");
|
|
var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue<string>()}']").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<string>();
|
|
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<string>();
|
|
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<string>();
|
|
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<bool>() && row["parent_uri"]?.GetValue<string>() == 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<bool>() || row["deleted"]!.GetValue<bool>();
|
|
}, 45), $"the deleted reply is still on {origin}");
|
|
await session.AssertGuards();
|
|
});
|
|
}
|
|
}
|
|
}
|