`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.
78 lines
3.1 KiB
TypeScript
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);
|
|
});
|
|
});
|