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

@@ -12,6 +12,7 @@ import { formatBytes } from "../utils/format.ts";
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import RichContentCard from "../components/RichContentCard.tsx";
@@ -55,6 +56,8 @@ export function DumpEdit() {
const [refreshing, setRefreshing] = useState(false);
const [refreshError, setRefreshError] = useState<string | null>(null);
const [thumbUploading, setThumbUploading] = useState(false);
const [thumbError, setThumbError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
if (!selectedDump) return;
@@ -105,54 +108,49 @@ export function DumpEdit() {
}
};
const handleThumbnailChange = async (file: File) => {
const changeThumbnail = async (init: RequestInit) => {
if (state.status !== "loaded") return;
setThumbUploading(true);
setThumbError(null);
try {
const formData = new FormData();
formData.append("file", file);
const res = await authFetch(
`${API_URL}/api/dumps/${state.dump.id}/thumbnail`,
{ method: "PUT", body: formData },
const updated = await expectOk<RawDump>(
await authFetch(
`${API_URL}/api/dumps/${state.dump.id}/thumbnail`,
init,
),
);
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) {
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
}
setState({ status: "loaded", dump: deserializeDump(updated) });
} catch (err) {
setThumbError(friendlyFetchError(err));
} finally {
setThumbUploading(false);
}
};
const handleThumbnailReset = async () => {
if (state.status !== "loaded") return;
setThumbUploading(true);
try {
const res = await authFetch(
`${API_URL}/api/dumps/${state.dump.id}/thumbnail`,
{ method: "DELETE" },
);
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) {
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
}
} finally {
setThumbUploading(false);
}
const handleThumbnailChange = (file: File) => {
const formData = new FormData();
formData.append("file", file);
return changeThumbnail({ method: "PUT", body: formData });
};
const handleThumbnailReset = () => changeThumbnail({ method: "DELETE" });
const handleDelete = async () => {
if (state.status !== "loaded") return;
const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
method: "DELETE",
});
if (!res.ok) {
setState({ status: "error", error: `Delete failed (${res.status})` });
setConfirmDelete(false);
setDeleteError(null);
try {
const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
method: "DELETE",
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error?.message ?? t`Delete failed (${res.status})`);
}
} catch (err) {
setDeleteError(friendlyFetchError(err));
return;
}
navigate("/", { state: { deletedDumpId: state.dump.id } });
};
@@ -252,6 +250,8 @@ export function DumpEdit() {
}
currentThumbnailSrc={currentThumbnailSrc}
thumbUploading={thumbUploading}
thumbError={thumbError}
deleteError={deleteError}
onThumbnailChange={handleThumbnailChange}
onThumbnailReset={handleThumbnailReset}
onRequestDelete={() => setConfirmDelete(true)}
@@ -276,6 +276,8 @@ interface DumpEditFormProps {
preview: React.ReactNode;
currentThumbnailSrc: string | null;
thumbUploading: boolean;
thumbError: string | null;
deleteError: string | null;
onThumbnailChange: (file: File) => void;
onThumbnailReset: () => void;
onRequestDelete: () => void;
@@ -295,6 +297,8 @@ function DumpEditForm({
preview,
currentThumbnailSrc,
thumbUploading,
thumbError,
deleteError,
onThumbnailChange,
onThumbnailReset,
onRequestDelete,
@@ -343,18 +347,29 @@ function DumpEditForm({
method: "PUT",
body: formData,
});
// Replacing the file doesn't touch visibility, so a change made in
// the same save goes separately, once the file is in.
if (isPrivate !== dump.isPrivate) {
await expectOk<RawDump>(res);
res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
method: "PUT",
body: JSON.stringify({ isPrivate } satisfies UpdateDumpRequest),
});
}
} else {
// An emptied note is sent as null: left out, the server keeps the old
// one.
const body: UpdateDumpRequest = dump.kind === "url"
? {
url: url.trim() || undefined,
title: trimmedTitle,
comment: comment.trim() || undefined,
comment: comment.trim() || null,
isPrivate,
categoryIds,
}
: {
title: trimmedTitle,
comment: comment.trim() || undefined,
comment: comment.trim() || null,
isPrivate,
categoryIds,
};
@@ -400,6 +415,11 @@ function DumpEditForm({
</button>
)}
</div>
{thumbError && (
<span className="form-hint form-hint--error" role="alert">
{thumbError}
</span>
)}
</div>
{dump.kind === "url"
@@ -470,6 +490,9 @@ function DumpEditForm({
</div>
<div className="dump-edit-footer">
{deleteError && (
<ErrorCard title={t`Could not delete`} message={deleteError} />
)}
<div className="form-actions">
<button
type="button"