v3: the frontend gets a test suite, and the bugs it turned up are fixed — deno task test:web runs 612 tests over src/ in about 11s: Vitest on jsdom with Testing Library, built on the app's own Vite config so the SWC/Lingui transform and the .po loader are what ship. src/test/ holds the harness: a routing fetch stub (queued once-responses, fallback defaults), a FakeWebSocket the tests open, feed and drop, jsdom polyfills, fixtures, and two render helpers — one over hand-set context values, one over the real provider tree (auth, socket, player, follows, categories, chat). It covers every util and hook, every provider (the socket protocol end to end: votes and likes with ack/timeout/error rollback, chat sends/echoes/retries/read markers/reconcile, reconnect backfill), the components (header and search history, cards, Markdown, thumbnails, media and global player, the text editor's mentions/emoji/uploads, comments, the new-dump modal, the chat), and every page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s

Fixed along the way. Dumps: clearing a dump's note sent `comment: undefined`, which JSON drops, so the server kept the old one; it's sent as null now. Replacing a file dropped a visibility change made in the same save (the file endpoint doesn't read it); it follows as a separate update. Renaming a dump onto a new slug kept showing the old title until reload. The thumbnail picker and delete on the edit page failed without a word, the latter as an unhandled rejection. Playlists: an edit that kept the slug waited on the socket's echo to show, and a new cover never appeared (the upload's answer was ignored, a refused one said nothing); covers were also served stale for an hour, since `max-age=3600` met an unversioned URL — setPlaylistImage now bumps updated_at and every cover URL carries `?v=` (playlistImageUrl). Removing a dump, reordering, and deleting a playlist ignored non-OK answers, leaving page and server apart; they restore, reload or report. Profile: clearing "Who am I?" sent undefined and came back on reload; your own upvoted list (and the upvoted page) was empty until the socket's welcome, and forever without one; "View all" under Following linked to a route that doesn't exist and is a Load more now. Chat: Escape on "Delete this message?" closed the whole chat — ConfirmModal takes Escape first, in the capture phase. Player: a pause or play from a media key, headset or the OS wasn't reflected (MediaPlayer follows the element's own play/pause events), and the seek bar tracked dragging with mouse events only, so on touch the thumb fought timeupdate. Feeds: a guest on /~/followed got a blank page; a server error in the followed feed read as "Could not connect". Search: a failed later page threw away the results shown. Notifications: a failed mark-read replaced a loaded list with an error. Silent failures now say so: deleting a comment, the voters/likers "Load more" (stuck on "Loading…"), and the password-reset request, which said "sent" whatever happened.

Notifications also stop hiding what was new: the bell still clears on opening the page, but the dots and the "N new" count stay for the visit instead of vanishing the moment the mark-read lands, and one arriving while the page is open is marked read too, so the bell doesn't count it.

Accessibility: Modal and ConfirmModal are dialogs (role, aria-modal, labelled/described); playlist membership rows are checkboxes that work from the keyboard; a dump's title, and the profile's email and description, can be opened for editing from the keyboard (the pencil shows on focus); the category editor's ✓ and the global player's video button have names. Player, rich-content and download labels were hard-coded English and are translated, with the new French strings. Lint: fast-refresh and render-purity rules are off for src/test/ only, where probes hand their context out on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 14:35:41 +00:00
parent 25601edaf8
commit 54e86c4366
78 changed files with 10816 additions and 691 deletions

View File

@@ -24,7 +24,7 @@ import {
deserializePlaylist,
deserializePlaylistWithDumps,
} from "../model.ts";
import { playlistUrl } from "../utils/urls.ts";
import { playlistImageUrl, playlistUrl } from "../utils/urls.ts";
import { can } from "../utils/permissions.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
@@ -34,6 +34,7 @@ import { relativeTime } from "../utils/relativeTime.ts";
import { DumpCard } from "../components/DumpCard.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { ImagePicker } from "../components/ImagePicker.tsx";
import { Markdown } from "../components/Markdown.tsx";
@@ -108,6 +109,8 @@ export function PlaylistDetail() {
const [editOpen, setEditOpen] = useState(false);
const headerRef = useHeightTransition<HTMLDivElement>(editOpen);
const [confirmDelete, setConfirmDelete] = useState(false);
// A delete, removal or reorder the server refused.
const [actionError, setActionError] = useState<string | null>(null);
// Mirrors activeDumpIds for use in effects without adding it as a dep.
// Updated on every render via useLayoutEffect so it's always current.
@@ -469,16 +472,21 @@ export function PlaylistDetail() {
activeDumpIds.has(d.id)
);
try {
await authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
dumpIds: activeDumps.map((d) => d.id),
} satisfies ReorderPlaylistRequest,
),
});
const res = await authFetch(
`${API_URL}/api/playlists/${playlistId}/order`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
dumpIds: activeDumps.map((d) => d.id),
} satisfies ReorderPlaylistRequest,
),
},
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch {
// Back to the order the server actually kept.
fetchPlaylist();
}
};
@@ -488,10 +496,13 @@ export function PlaylistDetail() {
// Fire-and-forget the API call; animate immediately
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
method: "DELETE",
}).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}).catch(() => {
// On failure, cancel the fade and restore the item
// Refused or offline: cancel the fade and restore the item
cancels.current.get(dumpId)?.();
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
setActionError(t`The dump couldn't be removed. Try again.`);
});
setActiveDumpIds((prev) => {
const n = new Set(prev);
@@ -538,14 +549,34 @@ export function PlaylistDetail() {
const handleEditSaved = (updated: Playlist) => {
setEditOpen(false);
navigate(playlistUrl(updated), { replace: true });
// Shown at once rather than left to the socket's echo, which may never
// come (disconnected); a new slug also moves the URL.
setState((prev) =>
prev.status === "loaded"
? {
...prev,
playlist: { ...prev.playlist, ...updated, dumps: prev.playlist.dumps },
}
: prev
);
if (state.status === "loaded" && updated.slug !== state.playlist.slug) {
navigate(playlistUrl(updated), { replace: true });
}
};
const handleDelete = async () => {
if (!playlistId) return;
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
method: "DELETE",
});
setConfirmDelete(false);
setActionError(null);
try {
const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
method: "DELETE",
});
if (!res.ok) await expectOk(res); // throws the server's message
} catch (err) {
setActionError(friendlyFetchError(err));
return;
}
navigate("/");
};
@@ -604,7 +635,7 @@ export function PlaylistDetail() {
<>
{playlist.imageMime && (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
src={playlistImageUrl(playlist)}
alt=""
className="playlist-detail-img"
/>
@@ -676,6 +707,10 @@ export function PlaylistDetail() {
</div>
</div>
{actionError && (
<ErrorCard title={t`Something went wrong`} message={actionError} />
)}
{visibleDumps.length === 0
? (
<p className="empty-state">
@@ -819,10 +854,17 @@ function PlaylistEditForm(
if (image) {
const fd = new FormData();
fd.append("file", image);
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
method: "POST",
body: fd,
});
// The upload answers with the playlist as it now stands (new cover,
// new updatedAt), which is what the page should show.
onSaved(deserializePlaylist(
await expectOk<RawPlaylist>(
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
method: "POST",
body: fd,
}),
),
));
return;
}
onSaved(updated);
@@ -840,9 +882,7 @@ function PlaylistEditForm(
render={({ field }) => (
<ImagePicker
src={imagePreview ??
(playlist.imageMime
? `${API_URL}/api/playlists/${playlist.id}/image`
: null)}
(playlist.imageMime ? playlistImageUrl(playlist) : null)}
alt="Cover"
size={72}
className="animate-in"