Files
thepraandClaude Opus 5.5 1c9773bab0 What can't be undone asks first
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
2026-10-06 23:28:22 +02:00

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();
});
}
}
}