fix(i18n): every refusal the API answers is structured and said in the reader's language
~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.
- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
reason, …params}; localizeDetail translates errors.refused.<reason> with the
params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
answer a reason; the search scraper's failure is logged and answered as
youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
(only the framework's own can remain) is left out, so reading .detail is
safe everywhere — including destructured, the hole the raw-detail source
scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
for a detail; the contract test requires every refused reason and every
not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
lockfile lines.
This commit is contained in:
@@ -44,6 +44,25 @@ const networkProperties = [
|
||||
},
|
||||
];
|
||||
|
||||
// A `//` or `/* */` inside JSX children is not a comment — it is TEXT, rendered on the page. One
|
||||
// slipped in when a return was wrapped in a fragment (Playlists.tsx) and only a visual check caught
|
||||
// it: tsc, eslint and every test stayed green. `eslint-plugin-react` has this rule, but it would bring
|
||||
// ~1800 lockfile lines of dependencies for ten lines of logic, so it lives here.
|
||||
const jsxCommentText = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
messages: { text: "A comment inside JSX children renders as text. Wrap it in {/* */}." },
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
JSXText(node) {
|
||||
if (/^\s*(\/\/|\/\*)/m.test(node.value)) context.report({ node, messageId: "text" });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const localRules = { rules: { "jsx-comment-text": jsxCommentText } };
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
||||
{
|
||||
@@ -56,12 +75,14 @@ export default tseslint.config(
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
local: localRules,
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: "error",
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"local/jsx-comment-text": "error",
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
|
||||
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
|
||||
|
||||
@@ -105,6 +105,31 @@ const goneWhats = [
|
||||
),
|
||||
),
|
||||
];
|
||||
// Every generic refusal → `errors.refused.<reason>`: each `refused(<status>, "<reason>")` anywhere in
|
||||
// the backend (not `_refused`, a router's own family), plus the values of the settings map, which
|
||||
// `setting_refused` picks from at runtime.
|
||||
const appSources = (dir = ""): string[] =>
|
||||
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
|
||||
withFileTypes: true,
|
||||
}).flatMap((e) =>
|
||||
e.isDirectory()
|
||||
? appSources(`${dir}${e.name}/`)
|
||||
: e.name.endsWith(".py")
|
||||
? [backend(`${dir}${e.name}`)]
|
||||
: [],
|
||||
);
|
||||
const refusedReasons = [
|
||||
...new Set([
|
||||
...appSources().flatMap((src) =>
|
||||
[...src.matchAll(/(?<![\w.])refused\(\s*\d+,\s*"(\w+)"/g)].map((m) => m[1]!),
|
||||
),
|
||||
...[
|
||||
...(backend("refusal.py").split("_SETTING_REASONS = {")[1]?.split("}")[0] ?? "").matchAll(
|
||||
/:\s*"(\w+)"/g,
|
||||
),
|
||||
].map((m) => m[1]!),
|
||||
]),
|
||||
];
|
||||
// A refused YouTube action → `errors.youtube.<reason>`, the reasons read from their Literal.
|
||||
const youtubeReasons = [
|
||||
...(
|
||||
@@ -134,6 +159,9 @@ describe("backend ids have their names in both languages", () => {
|
||||
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
|
||||
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
||||
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable"]);
|
||||
expect(refusedReasons).toEqual(
|
||||
expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]),
|
||||
);
|
||||
// Every code the frontend localizes is raised somewhere the scan can see.
|
||||
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
|
||||
});
|
||||
@@ -200,6 +228,13 @@ describe("backend ids have their names in both languages", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it(`${lang}: every generic refusal the backend can answer, and none it cannot`, () => {
|
||||
expect(missing(refusedReasons.map((r) => `errors.refused.${r}`))).toEqual([]);
|
||||
expect(
|
||||
[...idsUnder(keys, "errors.refused")].filter((r) => !refusedReasons.includes(r)),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it(`${lang}: every admin refusal reason`, () => {
|
||||
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]);
|
||||
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual(
|
||||
|
||||
@@ -21,7 +21,21 @@
|
||||
"job": "This job doesn't exist any more.",
|
||||
"download": "This download doesn't exist any more.",
|
||||
"link": "This share link doesn't exist any more.",
|
||||
"notification": "This notification doesn't exist any more."
|
||||
"notification": "This notification doesn't exist any more.",
|
||||
"account": "That account no longer exists.",
|
||||
"collection": "This collection doesn't exist any more.",
|
||||
"config_key": "There's no such setting.",
|
||||
"file": "The file is no longer available.",
|
||||
"filmstrip": "There's no filmstrip for this.",
|
||||
"host": "This host isn't on the list any more.",
|
||||
"image": "There's no image for this.",
|
||||
"item": "This item doesn't exist any more.",
|
||||
"library": "This library doesn't exist any more.",
|
||||
"plex_item": "This Plex item isn't in the library any more.",
|
||||
"poster": "There's no poster for this.",
|
||||
"profile": "This download profile doesn't exist any more.",
|
||||
"season": "This season isn't in the library any more.",
|
||||
"show": "This show isn't in the library any more."
|
||||
},
|
||||
"admin": {
|
||||
"not_allowed": "Their address isn't approved any more. Add it to the whitelist first.",
|
||||
@@ -99,5 +113,98 @@
|
||||
"quota": "Today's YouTube budget for this instance is spent. It resets at midnight Pacific time — try again then.",
|
||||
"reconnect": "Google no longer accepts this account's YouTube connection. Sign out and sign in with Google again to reconnect it.",
|
||||
"unavailable": "YouTube didn't accept that just now. Please try again."
|
||||
},
|
||||
"refused": {
|
||||
"account_no_access": "That account no longer has access.",
|
||||
"account_signed_out": "That account was signed out elsewhere — sign in again.",
|
||||
"account_suspended": "That account is suspended.",
|
||||
"address_has_account": "That address already has an account — delete the account instead.",
|
||||
"admin_first": "Create the admin account first.",
|
||||
"admin_only": "Only an admin can do that.",
|
||||
"already_set_up": "This instance is already set up.",
|
||||
"already_subscribed": "You're already subscribed to this channel.",
|
||||
"channel_blocked": "You've blocked this channel.",
|
||||
"collection_fields_required": "A title and an item are both needed.",
|
||||
"collection_pending_sync": "The collection changed in Plex, but refreshing the local copy failed — it catches up on the next sync.",
|
||||
"collection_read_only": "This collection is read-only.",
|
||||
"collection_smart": "Smart and auto-list collections can't be made editable.",
|
||||
"demo_activate": "The demo account can't be activated.",
|
||||
"demo_delete": "The demo account can't be deleted.",
|
||||
"demo_role": "The demo account's role can't be changed.",
|
||||
"demo_suspend": "The demo account can't be suspended.",
|
||||
"download_not_ready": "This download isn't ready.",
|
||||
"edit_unfinished": "Only a finished download can be edited.",
|
||||
"filters_not_object": "The view's filters are malformed.",
|
||||
"google_no_userinfo": "Google didn't send back the account details. Try signing in again.",
|
||||
"image_fetch_failed": "The image couldn't be loaded.",
|
||||
"image_host": "Images from that address aren't loaded.",
|
||||
"invalid_cursor": "The list position is out of date — reload the page.",
|
||||
"invalid_email": "Enter a valid email address.",
|
||||
"invalid_interval": "The interval must be between {{min}} and {{max}} minutes.",
|
||||
"invalid_link": "That doesn't look like a valid video link.",
|
||||
"invalid_role": "Unknown role.",
|
||||
"invalid_status": "Unknown status.",
|
||||
"invite_decided": "This request has already been decided.",
|
||||
"job_running": "This job is already running.",
|
||||
"last_active_admin": "This is the only active admin — make someone else admin first.",
|
||||
"last_admin": "This is the only admin — make someone else admin first.",
|
||||
"link_gone": "This link is no longer available.",
|
||||
"link_required": "A video link or id is required.",
|
||||
"media_missing": "The media file isn't on disk.",
|
||||
"message_empty": "The message is empty.",
|
||||
"message_rate": "Too many messages — slow down a moment.",
|
||||
"message_self": "You can't message yourself.",
|
||||
"message_too_long": "The message is too long.",
|
||||
"messaging_set_up": "Secure messaging is already set up.",
|
||||
"name_required": "A name is required.",
|
||||
"no_encryption_key": "Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
|
||||
"no_messaging": "That member hasn't set up messaging yet.",
|
||||
"no_profile": "There's no download profile to use.",
|
||||
"no_subscription_id": "There's no YouTube subscription on record yet — sync your subscriptions first.",
|
||||
"not_authenticated": "You're not signed in.",
|
||||
"not_ready": "Not available yet.",
|
||||
"not_redownloadable": "This item can't be downloaded again.",
|
||||
"not_your_account": "That's not an account you've signed into here.",
|
||||
"only_admin_delete": "You're the only admin — make someone else admin before deleting your account.",
|
||||
"own_delete": "Delete your own account in Settings → Account.",
|
||||
"own_role": "You can't change your own role.",
|
||||
"own_suspend": "You can't suspend your own account.",
|
||||
"plex_disabled": "The Plex module is turned off.",
|
||||
"plex_not_configured": "Plex isn't set up — add the server address and token in Configuration → Plex.",
|
||||
"plex_unreachable": "The Plex server didn't answer. Check the address and the token, then try again.",
|
||||
"plex_write_failed": "Plex didn't accept the change. Try again.",
|
||||
"profile_name_required": "A profile name is required.",
|
||||
"quota_reserve": "Today's shared YouTube budget is down to its reserve — try again later.",
|
||||
"quota_spent": "Today's shared YouTube budget is spent. Try again tomorrow.",
|
||||
"recipient_required": "A recipient email is required.",
|
||||
"registration_closed": "Registration is currently closed.",
|
||||
"search_daily_limit": "You've reached today's YouTube search limit ({{limit}}). Try again tomorrow.",
|
||||
"search_term_required": "Enter something to search for.",
|
||||
"session_gone": "The playback session ended.",
|
||||
"setting_not_a_choice": "Pick one of: {{choices}}.",
|
||||
"setting_not_a_number": "Enter a whole number.",
|
||||
"setting_too_high": "The value can be at most {{max}}.",
|
||||
"setting_too_low": "The value must be at least {{min}}.",
|
||||
"setup_token": "The setup token is missing or wrong.",
|
||||
"share_own": "That's already your download.",
|
||||
"share_password_required": "This link needs a password.",
|
||||
"share_unfinished": "Only a finished download can be shared.",
|
||||
"smtp_failed": "The email couldn't be sent — check the SMTP settings.",
|
||||
"smtp_off": "SMTP isn't configured.",
|
||||
"spec_not_object": "The profile's settings are malformed.",
|
||||
"still_downloading": "This download is still in progress.",
|
||||
"tag_name_taken": "You already have a tag named “{{name}}”.",
|
||||
"title_required": "A title is required.",
|
||||
"too_many_attempts": "Too many attempts. Try again shortly.",
|
||||
"unknown_setting": "Unknown setting: {{key}}",
|
||||
"value_missing": "A value is required.",
|
||||
"video_gone": "This video is no longer available.",
|
||||
"view_name_taken": "You already have a view named “{{name}}”.",
|
||||
"watch_sync_off": "Turn on Plex watch-sync first.",
|
||||
"weak_password": "The password must be at least {{min}} characters.",
|
||||
"wrong_password": "The current password is incorrect.",
|
||||
"wrong_share_password": "Wrong password.",
|
||||
"youtube_editing_off": "Turn on YouTube editing in Settings first.",
|
||||
"youtube_read_scope": "Connect your YouTube account (read access) first."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,21 @@
|
||||
"job": "Ez a feladat már nem létezik.",
|
||||
"download": "Ez a letöltés már nem létezik.",
|
||||
"link": "Ez a megosztási link már nem létezik.",
|
||||
"notification": "Ez az értesítés már nem létezik."
|
||||
"notification": "Ez az értesítés már nem létezik.",
|
||||
"account": "Ez a fiók már nem létezik.",
|
||||
"collection": "Ez a gyűjtemény már nem létezik.",
|
||||
"config_key": "Nincs ilyen beállítás.",
|
||||
"file": "A fájl már nem érhető el.",
|
||||
"filmstrip": "Ehhez nincs képsor.",
|
||||
"host": "Ez a kiszolgáló már nincs a listán.",
|
||||
"image": "Ehhez nincs kép.",
|
||||
"item": "Ez az elem már nem létezik.",
|
||||
"library": "Ez a könyvtár már nem létezik.",
|
||||
"plex_item": "Ez a Plex-elem már nincs a könyvtárban.",
|
||||
"poster": "Ehhez nincs poszter.",
|
||||
"profile": "Ez a letöltési profil már nem létezik.",
|
||||
"season": "Ez az évad már nincs a könyvtárban.",
|
||||
"show": "Ez a sorozat már nincs a könyvtárban."
|
||||
},
|
||||
"admin": {
|
||||
"not_allowed": "A címe már nincs jóváhagyva. Előbb vedd fel a fehérlistára.",
|
||||
@@ -99,5 +113,98 @@
|
||||
"quota": "Ennek a példánynak elfogyott a mai YouTube-kerete. Csendes-óceáni idő szerint éjfélkor újraindul — akkor próbáld újra.",
|
||||
"reconnect": "A Google már nem fogadja el ennek a fióknak a YouTube-kapcsolatát. Jelentkezz ki, majd lépj be újra Google-lel az összekötéshez.",
|
||||
"unavailable": "A YouTube most nem fogadta el. Próbáld újra."
|
||||
},
|
||||
"refused": {
|
||||
"account_no_access": "Ennek a fióknak már nincs hozzáférése.",
|
||||
"account_signed_out": "Ezt a fiókot máshol kijelentkeztették — lépj be újra.",
|
||||
"account_suspended": "Ez a fiók fel van függesztve.",
|
||||
"address_has_account": "Ehhez a címhez már tartozik fiók — azt töröld helyette.",
|
||||
"admin_first": "Előbb hozd létre az admin fiókot.",
|
||||
"admin_only": "Ezt csak admin teheti meg.",
|
||||
"already_set_up": "Ez a példány már be van állítva.",
|
||||
"already_subscribed": "Erre a csatornára már feliratkoztál.",
|
||||
"channel_blocked": "Ezt a csatornát letiltottad.",
|
||||
"collection_fields_required": "A címet és egy elemet is meg kell adni.",
|
||||
"collection_pending_sync": "A gyűjtemény a Plexben megváltozott, de a helyi másolat frissítése nem sikerült — a következő szinkron pótolja.",
|
||||
"collection_read_only": "Ez a gyűjtemény csak olvasható.",
|
||||
"collection_smart": "Az okos és az automatikus listás gyűjtemények nem tehetők szerkeszthetővé.",
|
||||
"demo_activate": "A bemutató fiók nem aktiválható.",
|
||||
"demo_delete": "A bemutató fiók nem törölhető.",
|
||||
"demo_role": "A bemutató fiók szerepköre nem módosítható.",
|
||||
"demo_suspend": "A bemutató fiók nem függeszthető fel.",
|
||||
"download_not_ready": "Ez a letöltés még nincs kész.",
|
||||
"edit_unfinished": "Csak befejezett letöltés szerkeszthető.",
|
||||
"filters_not_object": "A nézet szűrői hibás formátumúak.",
|
||||
"google_no_userinfo": "A Google nem küldte vissza a fiók adatait. Próbálj újra belépni.",
|
||||
"image_fetch_failed": "A képet nem sikerült betölteni.",
|
||||
"image_host": "Erről a címről nem töltünk be képet.",
|
||||
"invalid_cursor": "A lista pozíciója elavult — töltsd újra az oldalt.",
|
||||
"invalid_email": "Adj meg egy érvényes e-mail címet.",
|
||||
"invalid_interval": "Az intervallumnak {{min}} és {{max}} perc között kell lennie.",
|
||||
"invalid_link": "Ez nem tűnik érvényes videólinknek.",
|
||||
"invalid_role": "Ismeretlen szerepkör.",
|
||||
"invalid_status": "Ismeretlen állapot.",
|
||||
"invite_decided": "Erről a kérésről már döntöttek.",
|
||||
"job_running": "Ez a feladat már fut.",
|
||||
"last_active_admin": "Ez az egyetlen aktív admin — előbb tegyél meg valaki mást adminnak.",
|
||||
"last_admin": "Ez az egyetlen admin — előbb tegyél meg valaki mást adminnak.",
|
||||
"link_gone": "Ez a link már nem érhető el.",
|
||||
"link_required": "Adj meg egy videólinket vagy -azonosítót.",
|
||||
"media_missing": "A médiafájl nincs a lemezen.",
|
||||
"message_empty": "Az üzenet üres.",
|
||||
"message_rate": "Túl sok üzenet — várj egy kicsit.",
|
||||
"message_self": "Magadnak nem küldhetsz üzenetet.",
|
||||
"message_too_long": "Az üzenet túl hosszú.",
|
||||
"messaging_set_up": "A biztonságos üzenetküldés már be van állítva.",
|
||||
"name_required": "Adj meg egy nevet.",
|
||||
"no_encryption_key": "Titkos értékek tárolásához állítsd be a TOKEN_ENCRYPTION_KEY-t.",
|
||||
"no_messaging": "Ez a tag még nem állította be az üzenetküldést.",
|
||||
"no_profile": "Nincs használható letöltési profil.",
|
||||
"no_subscription_id": "Még nincs rögzített YouTube-feliratkozás — előbb szinkronizáld a feliratkozásaidat.",
|
||||
"not_authenticated": "Nem vagy bejelentkezve.",
|
||||
"not_ready": "Még nem érhető el.",
|
||||
"not_redownloadable": "Ez az elem nem tölthető le újra.",
|
||||
"not_your_account": "Ebbe a fiókba itt nem léptél be.",
|
||||
"only_admin_delete": "Te vagy az egyetlen admin — a fiókod törlése előtt tegyél meg valaki mást adminnak.",
|
||||
"own_delete": "A saját fiókodat a Beállítások → Fiók alatt törölheted.",
|
||||
"own_role": "A saját szerepkörödet nem módosíthatod.",
|
||||
"own_suspend": "A saját fiókodat nem függesztheted fel.",
|
||||
"plex_disabled": "A Plex modul ki van kapcsolva.",
|
||||
"plex_not_configured": "A Plex nincs beállítva — add meg a szerver címét és a tokent a Konfiguráció → Plex alatt.",
|
||||
"plex_unreachable": "A Plex-szerver nem válaszolt. Ellenőrizd a címet és a tokent, majd próbáld újra.",
|
||||
"plex_write_failed": "A Plex nem fogadta el a módosítást. Próbáld újra.",
|
||||
"profile_name_required": "Adj meg egy profilnevet.",
|
||||
"quota_reserve": "A mai közös YouTube-keret a tartalékánál tart — próbáld később.",
|
||||
"quota_spent": "A mai közös YouTube-keret elfogyott. Próbáld holnap.",
|
||||
"recipient_required": "Adj meg egy címzett e-mail címet.",
|
||||
"registration_closed": "A regisztráció jelenleg zárva van.",
|
||||
"search_daily_limit": "Elérted a mai YouTube-keresési korlátot ({{limit}}). Próbáld holnap.",
|
||||
"search_term_required": "Írj be egy keresőkifejezést.",
|
||||
"session_gone": "A lejátszási munkamenet véget ért.",
|
||||
"setting_not_a_choice": "Válassz ezek közül: {{choices}}.",
|
||||
"setting_not_a_number": "Adj meg egy egész számot.",
|
||||
"setting_too_high": "Az érték nem lehet nagyobb, mint {{max}}.",
|
||||
"setting_too_low": "Az érték nem lehet kisebb, mint {{min}}.",
|
||||
"setup_token": "A beállítási token hiányzik vagy hibás.",
|
||||
"share_own": "Ez már a te letöltésed.",
|
||||
"share_password_required": "Ehhez a linkhez jelszó kell.",
|
||||
"share_unfinished": "Csak befejezett letöltés osztható meg.",
|
||||
"smtp_failed": "Nem sikerült elküldeni a levelet — ellenőrizd az SMTP-beállításokat.",
|
||||
"smtp_off": "Az SMTP nincs beállítva.",
|
||||
"spec_not_object": "A profil beállításai hibás formátumúak.",
|
||||
"still_downloading": "Ez a letöltés még folyamatban van.",
|
||||
"tag_name_taken": "Már van „{{name}}” nevű címkéd.",
|
||||
"title_required": "Adj meg egy címet.",
|
||||
"too_many_attempts": "Túl sok próbálkozás. Próbáld kicsit később.",
|
||||
"unknown_setting": "Ismeretlen beállítás: {{key}}",
|
||||
"value_missing": "Adj meg egy értéket.",
|
||||
"video_gone": "Ez a videó már nem érhető el.",
|
||||
"view_name_taken": "Már van „{{name}}” nevű nézeted.",
|
||||
"watch_sync_off": "Előbb kapcsold be a Plex megtekintés-szinkront.",
|
||||
"weak_password": "A jelszónak legalább {{min}} karakterből kell állnia.",
|
||||
"wrong_password": "A jelenlegi jelszó helytelen.",
|
||||
"wrong_share_password": "Hibás jelszó.",
|
||||
"youtube_editing_off": "Előbb kapcsold be a YouTube-szerkesztést a Beállításokban.",
|
||||
"youtube_read_scope": "Előbb kapcsold össze a YouTube-fiókodat (olvasási hozzáférés)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// One contract, one implementation — `notifyActionError` (see actionErrors.ts).
|
||||
//
|
||||
// `req` deliberately leaves 403/404/429 to the caller, and the routes behind those statuses
|
||||
// hand-write English. Round 5 taught the shared helper to discard a plain-string `detail` in favour
|
||||
// of the caller's already-translated fallback. Round 6 then found `ChatThread` carrying a SECOND,
|
||||
// hand-written copy of the same contract (`e.detail || t("messages.sendFailed")`) — so the central
|
||||
// fix could not reach it and the English sentence stayed in a Hungarian UI. A fix that lands in one
|
||||
// place while a copy of the rule lives in another is this repo's recurring smell; this is the check
|
||||
// that names the copies.
|
||||
//
|
||||
// The assertion pins the expected SET, never a count: a guard that counts is green the moment its
|
||||
// glob stops matching (E10 review round 4 learned that the hard way).
|
||||
|
||||
// Reads a raw `HttpError.detail` and uses it as the message a user sees. Matches `e.detail ||`,
|
||||
// `err.detail ? err.detail :`, `(e instanceof HttpError ? e.detail : undefined) ??`, a cast
|
||||
// receiver (`(e as HttpError).detail`), an optional chain (`e?.detail`) and a `.detail` passed
|
||||
// straight as a call argument (`setErr(e.detail)`).
|
||||
//
|
||||
// The receiver alternation and the absence of a `)` lookahead are both load-bearing. The first
|
||||
// version required a bare identifier before `.detail` and excluded `.detail)` — so `setErr(e.detail)`,
|
||||
// `String(e.detail)` and `(e as HttpError).detail` were all invisible, and because the file SET is
|
||||
// compared with `toEqual` a file the pattern cannot see is simply never listed: the guard stayed
|
||||
// green over a fresh copy of the contract written in any of those shapes (round 7). The `)`
|
||||
// exclusion bought nothing — the only `.detail)` in the frontend is inside `lib/api/core.ts`, which
|
||||
// `IMPLEMENTATION` skips wholesale — and re-scanning with the wider pattern returns the same five
|
||||
// files, so it removes no false positive.
|
||||
const RAW_DETAIL = /(?:\)|[A-Za-z_$][\w$]*)\s*\??\.detail\b(?!\s*=[^=])/;
|
||||
// KNOWN LIMIT: a `detail` pulled out by destructuring (`const { detail } = err`) is out of reach
|
||||
// of a line-level scan — by then there is no receiver left to match, and a pattern loose enough
|
||||
// to catch it fires on every unrelated `detail` field too (measured: `const { data: detail } =
|
||||
// useQuery(...)` in VideoInfoCard and an options destructure in plexPlayerHooks, neither of which
|
||||
// touches an HttpError). Naming the hole beats pinning two innocent files into the list below as
|
||||
// though they were copies of the contract.
|
||||
|
||||
// Every place that still prefers the server's raw string over a translated fallback. These are
|
||||
// INLINE surfaces — a form field's error line, a setup step's banner — not toasts, and each shows a
|
||||
// reason the user is mid-flow on, so converting them is a sweep of its own (with the ~60 backend
|
||||
// routes that hand-write those strings). Logged on ROADMAP.md under the bug-hunt deferrals. What
|
||||
// must not happen is a NEW one, or a toast path growing one again.
|
||||
const KNOWN_RAW_DETAIL_CONSUMERS = [
|
||||
"components/Feed.tsx", // the live-YouTube search error line
|
||||
"components/KeyGate.tsx", // the passphrase field's own refusal
|
||||
"components/SettingsPanel.tsx", // the change-password field
|
||||
"components/SetupWizard.tsx", // the first-run wizard's banner
|
||||
"components/Welcome.tsx", // the sign-in card's refusal panel
|
||||
];
|
||||
|
||||
// Where the rule itself lives, plus the request layer that produces and localizes the value.
|
||||
const IMPLEMENTATION = ["lib/actionErrors.ts", "lib/api/core.ts"];
|
||||
|
||||
function consumers(): { hits: string[]; scanned: number } {
|
||||
const files = import.meta.glob("../**/*.{ts,tsx}", {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
});
|
||||
const hits: string[] = [];
|
||||
for (const [rel, source] of Object.entries(files)) {
|
||||
// Vite resolves the glob against THIS file, so same-directory hits come back as "./x".
|
||||
const path = rel.startsWith("./") ? `lib/${rel.slice(2)}` : rel.replace(/^\.\.\//, "");
|
||||
if (/\.test\.tsx?$|\.d\.ts$/.test(path)) continue;
|
||||
if (IMPLEMENTATION.includes(path)) continue;
|
||||
const lines = String(source)
|
||||
.split("\n")
|
||||
.filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)); // a comment about the rule is not a copy of it
|
||||
if (lines.some((l) => RAW_DETAIL.test(l))) hits.push(path);
|
||||
}
|
||||
return { hits: hits.sort(), scanned: Object.keys(files).length };
|
||||
}
|
||||
|
||||
describe("the refused-write contract", () => {
|
||||
it("has no copies beyond the ones on the deferral list", () => {
|
||||
const { hits, scanned } = consumers();
|
||||
// The GLOB must have matched something, or this whole file is a green no-op — and the old
|
||||
// version of this guard did not check that: it read `actionErrors.ts` by its own URL, which
|
||||
// resolves whether or not `import.meta.glob` enumerated a single file. That only mattered
|
||||
// because the pinned set was non-empty; the moment the deferral sweep below lands and the list
|
||||
// becomes `[]`, `expect([]).toEqual([])` would pass over a glob that matched nothing (round 7).
|
||||
expect(scanned).toBeGreaterThan(100);
|
||||
expect(readFileSync(new URL("./actionErrors.ts", import.meta.url), "utf8")).toContain(
|
||||
"detailData",
|
||||
);
|
||||
expect(hits).toEqual(KNOWN_RAW_DETAIL_CONSUMERS);
|
||||
});
|
||||
|
||||
it("sees a raw detail written in any of the shapes this codebase uses", () => {
|
||||
// The pattern is the whole guard, so pin the pattern itself: each of these is a real form
|
||||
// found in this frontend or in its sibling PRs, and every one of the first five was invisible
|
||||
// to the original regex.
|
||||
for (const line of [
|
||||
" setErr(e.detail);",
|
||||
" reportError(err.detail);",
|
||||
" notify({ message: (e as HttpError).detail });",
|
||||
" toast(String(e.detail));",
|
||||
" notify({ message: (err as HttpError).detail ?? fallback });",
|
||||
' setError(e.detail ?? t("x"));',
|
||||
' const msg = err.detail || t("x");',
|
||||
" setErr(state.err?.detail);",
|
||||
])
|
||||
expect(RAW_DETAIL.test(line), line).toBe(true);
|
||||
// And it must not fire on the places that PRODUCE or localize the value, which is why
|
||||
// `IMPLEMENTATION` exists rather than a cleverer pattern: an assignment is not a read.
|
||||
for (const line of [" detail = await r.json();", " this.detail = detail;"])
|
||||
expect(RAW_DETAIL.test(line), line).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,13 @@ const GATEWAY = new Set([502, 503, 504]);
|
||||
/**
|
||||
* WHY a write failed, in the reader's language — the line under the toast's title.
|
||||
*
|
||||
* A STRUCTURED server detail (`{code, reason}`) wins: `req` has already run it through
|
||||
* The server's own reason wins: `req` has already run the structured detail through
|
||||
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
|
||||
* gone). A plain-string `detail` never does — it is whatever English the route hand-wrote, and
|
||||
* `reportFailure` leaves exactly 403/404/429 to the caller, so those used to put "No user with that
|
||||
* email." into a Hungarian UI (deep-review round 5). Otherwise the status says it.
|
||||
* gone). Every refusal the app answers is structured now (backend `app/refusal.py`), and a string
|
||||
* detail never reaches `HttpError.detail` (see there) — the hand-written English that once put
|
||||
* "No user with that email." into a Hungarian UI has nowhere left to come from. `detailData` still
|
||||
* gates it: status-0 errors carry a local sentinel in `detail`, not a reason. Otherwise the status
|
||||
* says it.
|
||||
*/
|
||||
export function failureReason(err: unknown): string {
|
||||
const t = i18n.t.bind(i18n);
|
||||
|
||||
@@ -123,6 +123,17 @@ describe("uploadReq", () => {
|
||||
expect(reportError).toHaveBeenCalledWith("downloads.errors.upload.tooLarge");
|
||||
});
|
||||
|
||||
it("offers no string detail as the reason on the upload path either", async () => {
|
||||
const p = uploadReq("/u", form());
|
||||
const x = FakeXHR.last;
|
||||
x.status = 405;
|
||||
x.responseText = '{"detail":"Method Not Allowed"}';
|
||||
x.onload!();
|
||||
const err = (await p.catch((e: unknown) => e)) as HttpError;
|
||||
expect(err).toBeInstanceOf(HttpError);
|
||||
expect(err.detail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports a connectivity failure on a network error", async () => {
|
||||
const p = uploadReq("/u", form());
|
||||
FakeXHR.last.onerror!();
|
||||
@@ -168,6 +179,23 @@ describe("req — a structured refusal reads in the user's language", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("never offers a string detail as the reason — only a structured one, translated", async () => {
|
||||
// Every refusal the app answers is structured; a string can only be the framework's own English
|
||||
// ("Method Not Allowed"). Surfaces print `err.detail` — some destructured, out of any source
|
||||
// scan's reach — so the guarantee has to live here, where the value is made.
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: "Method Not Allowed" }), {
|
||||
status: 405,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const err = (await req("/api/x", { method: "POST" }, { quiet: true }).catch(
|
||||
(e: unknown) => e,
|
||||
)) as HttpError;
|
||||
expect(err).toBeInstanceOf(HttpError);
|
||||
expect(err.detail).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["quota", "reconnect", "unavailable"])(
|
||||
"says what to do about an action YouTube refused (%s)",
|
||||
async (reason) => {
|
||||
|
||||
@@ -50,7 +50,12 @@ export function isAborted(err: unknown): boolean {
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number;
|
||||
detail?: string; // server-provided reason (FastAPI's `detail`), when present
|
||||
// The server's reason IN THE READER'S LANGUAGE, when it gave one — i.e. a structured detail run
|
||||
// through `localizeDetail`, never the raw text of a string detail. Every refusal the app answers is
|
||||
// structured (backend `app/refusal.py`, enforced by its test_http_details.py); a string can only be
|
||||
// the framework's own ("Method Not Allowed"), so it is left out rather than shown. That is what
|
||||
// makes reading `.detail` safe anywhere — including destructured, which no source scan could see.
|
||||
detail?: string;
|
||||
detailData?: unknown; // structured detail ({ code, reason, ... }) when the server sent an object
|
||||
constructor(status: number, detail?: string, detailData?: unknown) {
|
||||
super(detail || `HTTP ${status}`);
|
||||
@@ -133,6 +138,11 @@ function localizeDetail(d: unknown): string {
|
||||
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
|
||||
if (o?.code === "youtube")
|
||||
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
|
||||
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
|
||||
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
|
||||
// reads as the generic failure rather than a key.
|
||||
if (o?.code === "refused")
|
||||
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
|
||||
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
|
||||
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
|
||||
// An admin action on an account or an invite that the server refused: activating an address that
|
||||
@@ -326,8 +336,8 @@ export async function req<T = unknown>(
|
||||
let detailData: unknown;
|
||||
try {
|
||||
const body = await r.json();
|
||||
if (body && typeof body.detail === "string") detail = body.detail;
|
||||
else if (body && body.detail && typeof body.detail === "object") {
|
||||
// A string detail is not the app's (see `HttpError.detail`) and is not shown.
|
||||
if (body && body.detail && typeof body.detail === "object") {
|
||||
detailData = body.detail;
|
||||
detail = localizeDetail(body.detail); // structured error → localized message
|
||||
}
|
||||
@@ -393,8 +403,8 @@ export function uploadReq<T = unknown>(
|
||||
let detailData: unknown;
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
if (body && typeof body.detail === "string") detail = body.detail;
|
||||
else if (body && body.detail && typeof body.detail === "object") {
|
||||
// A string detail is not the app's (see `HttpError.detail`) and is not shown.
|
||||
if (body && body.detail && typeof body.detail === "object") {
|
||||
detailData = body.detail;
|
||||
detail = localizeDetail(body.detail);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { ESLint } from "eslint";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// `local/jsx-comment-text` (eslint.config.js): a `//` inside JSX children is rendered as text. One
|
||||
// reached the Playlists page past tsc, eslint and every test — only a visual check caught it. A rule
|
||||
// that matches nothing looks exactly like a clean codebase, so this asserts it fires.
|
||||
const RULE = "local/jsx-comment-text";
|
||||
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
|
||||
|
||||
async function hits(source: string): Promise<number> {
|
||||
const eslint = new ESLint({ cwd: FRONTEND });
|
||||
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
|
||||
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
|
||||
}
|
||||
|
||||
describe("a comment inside JSX children", () => {
|
||||
it("is refused — it would render as text", async () => {
|
||||
expect(
|
||||
await hits(`export const A = () => (\n <>\n // the list\n <ul />\n </>\n);\n`),
|
||||
).toBe(1);
|
||||
expect(await hits(`export const B = () => <div>/* note */</div>;\n`)).toBe(1);
|
||||
});
|
||||
|
||||
it("is fine as a real comment, and text that merely contains a slash pair is text", async () => {
|
||||
expect(await hits(`export const C = () => <div>{/* the list */}</div>;\n`)).toBe(0);
|
||||
expect(await hits(`export const D = () => <p>See https://example.com</p>;\n`)).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user