Media survive failures: uploaded when attached, the server's limits, alt text asked for

The composer uploads each file as soon as it is attached (streamed from the browser file, under its own ten-minute
timeout), shows it uploading, processing (a 202 followed until ready), ready or failed with Retry, and cancels one
removed; publishing sends the ids, so a failed publish or a retried upload sends nothing twice. Size limits and types
come from /api/v2/instance, a file without a browser type gets one from its extension, and each refusal names the file
and why. Previews are 320 px thumbnails, not whole files in memory. A picture without a description is asked about once
("Publish anyway"; a setting turns it off), descriptions are sent with PUT, and an edit keeps the post's media, language
and sensitive flag.

Profile pictures are checked against the server's types and can be removed. A card shows "Media unavailable" with the
original's link when a picture fails, the blurhash behind a sensitive placeholder, and plays a GIF only on tap under
reduced motion. A failed export says so instead of downloading part of a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 11:56:59 +02:00
1 parent 9d662f702b
commit 70cb389537
26 files changed
+883 -132

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
using System.Text.Json.Nodes;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Actions
{
// Media from the composer and the profile, checked on PrivaPub: a picture uploaded as soon as it is picked and posted
// with its description, the reminder before a picture without one, a failed upload retried without the rest being sent
// again, and an avatar set and removed
public sealed class MediaTests(Browser browser)
{
// a 1×1 PNG
static readonly byte[] Png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==");
static async Task OpenComposer(IPage page)
{
await page.GotoAsync($"{Pasture.Site}/");
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
await page.GotoAsync($"{Pasture.Site}/");
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
}
static Task Attach(IPage page, string name) =>
page.Locator("input[data-attach]").SetInputFilesAsync(new FilePayload { Name = name, MimeType = "image/png", Buffer = Png });
static ILocator Submit(IPage page) => page.Locator("form:has(textarea[data-compose]) button[type=submit]").First;
static async Task<JsonNode> Published(Pasture.Persona persona, string text)
{
JsonNode latest = null;
Assert.True(await Pasture.Eventually(async () =>
(latest = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault())?["content"]
?.GetValue<string>().Contains(text) == true, 15), "the post was never published");
return latest;
}
[Fact]
public async Task A_picture_is_uploaded_when_picked_and_posted_with_its_description()
{
var persona = Pasture.Personas().First();
var text = $"a picture {Guid.NewGuid():N}"[..20];
await using var session = await SignIn.As(browser, persona);
var page = session.Page;
string made = null;
try
{
await Results.Cell("client.action.media", "privapub", persona.UserName, async () =>
{
await OpenComposer(page);
await page.Locator("textarea[data-compose]").First.FillAsync(text);
await Attach(page, "dot.png");
// uploaded on its own, before anything is published
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
await page.Locator("[data-alt-text]").First.FillAsync("a single dot");
await Submit(page).ClickAsync();
var post = await Published(persona, text);
made = post["id"]!.GetValue<string>();
Assert.Equal("a single dot", post["media_attachments"]![0]!["description"]!.GetValue<string>());
await session.AssertGuards();
}, page);
}
finally
{
if (made is not null)
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
}
}
[Fact]
public async Task A_picture_without_a_description_is_asked_about_once()
{
var persona = Pasture.Personas().First();
var text = $"undescribed {Guid.NewGuid():N}"[..22];
await using var session = await SignIn.As(browser, persona);
var page = session.Page;
string made = null;
try
{
await Results.Cell("client.action.media-reminder", "privapub", persona.UserName, async () =>
{
await OpenComposer(page);
await page.Locator("textarea[data-compose]").First.FillAsync(text);
await Attach(page, "dot.png");
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
await Submit(page).ClickAsync();
await Assertions.Expect(page.Locator("[data-alt-reminder]")).ToBeVisibleAsync();
await page.Locator("[data-alt-publish-anyway]").ClickAsync();
var post = await Published(persona, text);
made = post["id"]!.GetValue<string>();
Assert.Null(post["media_attachments"]![0]!["description"]?.GetValue<string>());
await session.AssertGuards();
}, page);
}
finally
{
if (made is not null)
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
}
}
[Fact]
public async Task A_failed_upload_is_retried_alone()
{
var persona = Pasture.Personas().First();
var text = $"retried {Guid.NewGuid():N}"[..18];
await using var session = await SignIn.As(browser, persona);
var page = session.Page;
string made = null;
try
{
await Results.Cell("client.action.media-retry", "privapub", persona.UserName, async () =>
{
// the second upload is refused once, as a server would refuse it
var uploads = 0;
await page.RouteAsync("**/api/v2/media", async route =>
{
if (Interlocked.Increment(ref uploads) == 2)
await route.FulfillAsync(new() { Status = 422, ContentType = "application/json", Body = "{\"error\":\"Validation failed: try again\"}" });
else
await route.ContinueAsync();
});
await OpenComposer(page);
await page.Locator("textarea[data-compose]").First.FillAsync(text);
await Attach(page, "first.png");
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
await Attach(page, "second.png");
await Assertions.Expect(page.Locator("[data-upload-state=failed]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
await Assertions.Expect(page.Locator("[data-upload-state=failed]")).ToContainTextAsync("try again");
await page.Locator("[data-upload-retry]").ClickAsync();
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(2, new() { Timeout = 20000 });
foreach (var alt in await page.Locator("[data-alt-text]").AllAsync())
await alt.FillAsync("a dot");
await Submit(page).ClickAsync();
var post = await Published(persona, text);
made = post["id"]!.GetValue<string>();
Assert.Equal(2, post["media_attachments"]!.AsArray().Count);
// the first file was sent once: one upload for it, two for the second (refused, then retried)
Assert.Equal(3, uploads);
await session.AssertGuards();
}, page);
}
finally
{
if (made is not null)
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
}
}
[Fact]
public async Task An_avatar_is_set_and_removed_from_settings()
{
var persona = Pasture.Personas().Last();
await using var session = await SignIn.As(browser, persona);
var page = session.Page;
await Results.Cell("client.action.avatar", "privapub", persona.UserName, async () =>
{
await page.GotoAsync($"{Pasture.Site}/settings");
var header = page.Locator(".collapse-title", new() { HasText = "Profile" }).First;
await header.ClickAsync();
var editor = header.Locator("xpath=..").Locator("[data-profile-editor]");
await editor.WaitForAsync(new() { Timeout = 30000 });
await editor.Locator("input[data-profile-avatar]").SetInputFilesAsync(new FilePayload { Name = "me.png", MimeType = "image/png", Buffer = Png });
await editor.Locator("[data-profile-save]").ClickAsync();
await Assertions.Expect(page.GetByText("Profile saved.")).ToBeVisibleAsync(new() { Timeout = 15000 });
var set = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["avatar"]!.GetValue<string>();
Assert.DoesNotContain("missing", set);
await editor.Locator("[data-profile-avatar-remove]").ClickAsync();
await Assertions.Expect(page.GetByText("Avatar removed.")).ToBeVisibleAsync(new() { Timeout = 15000 });
Assert.Contains("missing", (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["avatar"]!.GetValue<string>());
await session.AssertGuards();
}, page);
}
}
}