Files
siftlode/frontend/src/lib/mutationFailures.contract.test.ts
T
peter 8a50538275 fix(ui): a write that fails says what failed and why, wherever it fails
`req` opens its blocking dialog for 400/409/422/5xx only. A 403/404/429 was left to the caller and a
502-504 or lost connection got just the self-clearing connection notice, so 34 mutations without a
reporting onError and 15 direct api writes with a bare or missing catch failed in silence: a hidden
channel that snapped back, a share link that never revoked, a confirmed account delete that ended
with nothing said.

- lib/queryClient: one MutationCache reporter. react-query awaits it BEFORE the mutation's own
  onError, so it waits a task and skips a failure the site already reported; the toast title comes
  from `meta.failTitle`, `meta.errorShownInline` opts out.
- notifyActionError: title names WHAT failed, the line under it says WHY (`failureReason`: a
  structured localized detail, else the status). It marks the error reported (`markReported`), so
  nothing reports one failure twice; `notifyKnownFailure` for a site's own specific sentence.
- Every silent site named (45 new HU/EN titles, 4 reasons). Stale "the global dialog shows it"
  comments corrected on the job actions, channel block, Plex sync, cookies, download enqueue,
  account delete and usePref.
- Channels passed the tag hook's channel id to the reporter as the error; the tag and priority hooks
  now report themselves, with the real error.
- A contract test keeps every useMutation naming its failure; CLAUDE.md gains the pattern row.
- history.dom.test: the "navigates only once the entries are back" assertion raced an async act's
  macrotask flush (1 in 5 runs); it now asserts from a later popstate listener.
2026-09-13 01:45:28 +02:00

78 lines
3.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
// Every `useMutation` NAMES what failed (E11 S2b, owner decision: the toast's title says what, the
// line under it why). The app-wide reporter (lib/queryClient) reports a mutation that names nothing
// too — so nothing is silent any more — but under the generic "Couldn't complete that", which is the
// very toast that says nothing about what it is about when it lands a few seconds after the click.
//
// A mutation passes when it carries one of:
// - `meta: { failTitle }` the reporter's title for it,
// - `meta: { errorShownInline }` the page itself renders the failure,
// - an `onError` which reports (or rolls back — the reporter then speaks for it,
// under the title from `meta` if there is one).
//
// The assertion pins the SET of unnamed mutations, never a count, and checks the glob matched at
// all: a guard whose glob stops matching is green over nothing (E10 review round 4, round 7).
const NAMES = /\bfailTitle\b|\berrorShownInline\b|\bonError\b/;
/** The source of each `useMutation(...)` call, found by paren balance with comments stripped —
* a comment with a stray "(" would otherwise run one block into the next one's `failTitle`. */
export function mutationCalls(source: string): string[] {
const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
const calls: string[] = [];
let from = 0;
for (;;) {
const at = code.indexOf("useMutation(", from);
if (at < 0) return calls;
let depth = 0;
let i = at + "useMutation".length;
for (; i < code.length; i++) {
if (code[i] === "(") depth++;
else if (code[i] === ")" && --depth === 0) break;
}
calls.push(code.slice(at, i + 1));
from = i + 1;
}
}
function unnamed(): { hits: string[]; mutations: number } {
const files = import.meta.glob("../**/*.{ts,tsx}", {
query: "?raw",
import: "default",
eager: true,
});
const hits: string[] = [];
let mutations = 0;
for (const [rel, source] of Object.entries(files)) {
const path = rel.startsWith("./") ? `lib/${rel.slice(2)}` : rel.replace(/^\.\.\//, "");
if (/\.test\.tsx?$|\.d\.ts$/.test(path)) continue;
mutationCalls(String(source)).forEach((call, n) => {
mutations++;
if (!NAMES.test(call)) hits.push(`${path}#${n + 1}`);
});
}
return { hits: hits.sort(), mutations };
}
describe("the failed-write contract", () => {
it("has no mutation that leaves its failure unnamed", () => {
const { hits, mutations } = unnamed();
expect(mutations).toBeGreaterThan(50);
expect(hits).toEqual([]);
});
it("reads a mutation call to its end, whatever its body holds", () => {
const src = `
const a = useMutation({
mutationFn: () => api.x(fn(1), (2)),
// a stray "(" in a comment must not swallow the next call
});
const b = useMutation({ mutationFn: api.y, meta: { failTitle: "k" } });
`;
const calls = mutationCalls(src);
expect(calls).toHaveLength(2);
expect(NAMES.test(calls[0] ?? "")).toBe(false);
expect(NAMES.test(calls[1] ?? "")).toBe(true);
});
});