- The palette launcher's name replaced its visible "Jump to…" (and the key cap's its "Ctrl K"):
a voice-control "click Jump to" matched nothing (WCAG 2.5.3). Each name contains its text now.
- SegmentedToggle's `title` was only a tooltip on a non-focusable box — the live search's "how
many per batch" hint reached no keyboard or screen-reader user. It is the group's accessible
description, standalone and captioned alike.
- The unused-key contract let a template like `card.${status}` cover its whole namespace, which hid
five dead keys (deleted). Dynamic keys are matched by shape (`editor.*.hint`); a bare namespace
shape covers nothing, and the audit/quota namespaces are now held both ways by backendIds.
- CLAUDE.md's "FE control" row matches the lint rule it cites; each no-restricted-syntax entry has
its reason directly above it.
254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
import { readdirSync, readFileSync } from "node:fs";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
// Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these
|
|
// up with the raw id as the fallback — `t(\`config.fields.${key}.label\`, key)` — so a missing key never
|
|
// fails loudly: the admin Configuration page showed `maintenance`, `tmdb`, `tmdb_api_key`,
|
|
// `plex_overlay_map`… as bare identifiers, for months, and the i18n parity test stayed green because EN
|
|
// and HU were missing the SAME keys (owner report, E11 S5). The ids live in Python, so the check reads
|
|
// the registries straight from the backend sources:
|
|
// - `sysconfig.SPECS` → `config.groups.<group>`, `config.fields.<key>.label` + `.hint`, and for a
|
|
// "choice" key `config.choices.<key>.<option>` — the options read from its Literal in config.py
|
|
// - `scheduler.JOB_INTERVALS` → `scheduler.jobs.<id>` + `scheduler.jobDesc.<id>`
|
|
// - the `audit` action enum → `auditActions.<id>`; the `quota` action enum → `quotaActions.<id>`
|
|
// and the other way round: a name left behind for an id the backend dropped is dead weight that reads
|
|
// as a live setting to the next person (`plex_sync_interval_min` moved to the scheduler long ago).
|
|
// Progress phases are not covered: jobs pass most of them as variables, not literals a scan can see.
|
|
|
|
const backend = (file: string) =>
|
|
readFileSync(new URL(`../../../backend/app/${file}`, import.meta.url), "utf-8");
|
|
|
|
const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record<
|
|
string,
|
|
{ default: Record<string, unknown> }
|
|
>;
|
|
|
|
function flatten(obj: Record<string, unknown>, prefix: string, out: Set<string>): Set<string> {
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
const key = `${prefix}.${k}`;
|
|
if (v && typeof v === "object" && !Array.isArray(v))
|
|
flatten(v as Record<string, unknown>, key, out);
|
|
else out.add(key);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const keysOf = (lang: string): Set<string> => {
|
|
const out = new Set<string>();
|
|
for (const [path, mod] of Object.entries(mods)) {
|
|
const m = path.match(/\/locales\/([^/]+)\/([^/]+)\.json$/);
|
|
if (m?.[1] === lang) flatten(mod.default, m[2]!, out);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const specs = [
|
|
...backend("sysconfig.py").matchAll(/ConfigSpec\(\s*"(\w+)",\s*"(\w+)",\s*"(\w+)",\s*"(\w+)"/g),
|
|
].map((m) => ({ key: m[1]!, type: m[2]!, group: m[3]!, attr: m[4]! }));
|
|
// A choice key's options, as config.py spells them: `<attr>: <Alias> = …` → `<Alias> = Literal[…]`.
|
|
const configPy = backend("config.py");
|
|
const choicesOf = (attr: string): string[] => {
|
|
const alias = configPy.match(new RegExp(`^\\s+${attr}: (\\w+) =`, "m"))?.[1];
|
|
const body = alias && configPy.match(new RegExp(`^${alias} = Literal\\[([^\\]]*)\\]`, "m"))?.[1];
|
|
return [...(body ?? "").matchAll(/"(\w+)"/g)].map((m) => m[1]!);
|
|
};
|
|
const choiceIds = specs
|
|
.filter((s) => s.type === "choice")
|
|
.flatMap((s) => choicesOf(s.attr).map((c) => `config.choices.${s.key}.${c}`));
|
|
const jobs = [
|
|
...(backend("scheduler.py").split("JOB_INTERVALS")[1] ?? "")
|
|
.split("}")[0]!
|
|
.matchAll(/"(\w+)": settings\./g),
|
|
].map((m) => m[1]!);
|
|
const enumValues = (file: string) =>
|
|
[...backend(file).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm)].map((m) => m[1]!);
|
|
const auditActions = enumValues("audit.py");
|
|
const quotaActions = enumValues("quota.py");
|
|
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are read from
|
|
// `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict
|
|
// ruff wraps over several lines still matches.
|
|
const adminCodes = [
|
|
...(
|
|
readFileSync(new URL("../lib/api/core.ts", import.meta.url), "utf-8").match(
|
|
/ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/,
|
|
)?.[1] ?? ""
|
|
).matchAll(/"(\w+)"/g),
|
|
].map((m) => m[1]!);
|
|
const adminRefusals = [
|
|
...backend("routes/admin.py").matchAll(/\{\s*"code":\s*"(\w+)",\s*"reason":\s*"(\w+)"/g),
|
|
].filter((m) => adminCodes.includes(m[1]!));
|
|
const adminReasons = [...new Set(adminRefusals.map((m) => m[2]!))];
|
|
// The download failure catalog's host-specific entries → `downloads.errors.fail.catalog.<detail>`.
|
|
// A code holds a dot (`facebook.login_wall`), so the reverse check strips the prefix and the part
|
|
// rather than splitting on dots.
|
|
const failDetails = [
|
|
...new Set([...backend("downloads/errors.py").matchAll(/detail="([\w.]+)"/g)].map((m) => m[1]!)),
|
|
];
|
|
const DETAIL = "downloads.errors.fail.catalog.";
|
|
|
|
// What a structured 404 says is gone → `errors.notFound.<what>`: every `not_found("x")` and every
|
|
// `owned_or_404(..., what="x")` / `get_or_404(..., what="x")` under routes/, read from the sources.
|
|
const routeSources = (dir = "routes"): string[] =>
|
|
readdirSync(new URL(`../../../backend/app/${dir}/`, import.meta.url), {
|
|
withFileTypes: true,
|
|
}).flatMap((e) =>
|
|
e.isDirectory()
|
|
? routeSources(`${dir}/${e.name}`)
|
|
: e.name.endsWith(".py")
|
|
? [backend(`${dir}/${e.name}`)]
|
|
: [],
|
|
);
|
|
const goneWhats = [
|
|
...new Set(
|
|
routeSources().flatMap((src) =>
|
|
[...src.matchAll(/not_found\("(\w+)"\)|what="(\w+)"/g)].map((m) => (m[1] ?? m[2])!),
|
|
),
|
|
),
|
|
];
|
|
// 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 = [
|
|
...(
|
|
backend("youtube/client.py").match(/^YouTubeRefusal = Literal\[([^\]]*)\]/m)?.[1] ?? ""
|
|
).matchAll(/"(\w+)"/g),
|
|
].map((m) => m[1]!);
|
|
|
|
/** The ids under `prefix` a locale names, `prefix.<id>` or `prefix.<id>.<anything>`. */
|
|
const idsUnder = (keys: Set<string>, prefix: string) =>
|
|
new Set(
|
|
[...keys]
|
|
.filter((k) => k.startsWith(`${prefix}.`))
|
|
.map((k) => k.split(".")[prefix.split(".").length]!),
|
|
);
|
|
|
|
describe("backend ids have their names in both languages", () => {
|
|
it("read the registries (a scan that matches nothing is green over nothing)", () => {
|
|
expect(specs.map((s) => s.key)).toEqual(expect.arrayContaining(["smtp_host", "tmdb_language"]));
|
|
// Every choice key resolved its options (a Literal the scan can't read would check nothing).
|
|
for (const s of specs.filter((x) => x.type === "choice"))
|
|
expect(choicesOf(s.attr).length, s.key).toBeGreaterThan(1);
|
|
expect(choiceIds).toContain("config.choices.log_level.TRACE");
|
|
expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"]));
|
|
expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"]));
|
|
expect(quotaActions.length).toBeGreaterThan(0);
|
|
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "setpw"]));
|
|
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([]);
|
|
});
|
|
|
|
for (const lang of ["en", "hu"]) {
|
|
const keys = keysOf(lang);
|
|
const missing = (wanted: string[]) => wanted.filter((k) => !keys.has(k));
|
|
|
|
it(`${lang}: every config key and group`, () => {
|
|
expect(
|
|
missing([
|
|
...new Set(specs.map((s) => `config.groups.${s.group}`)),
|
|
...specs.flatMap((s) => [`config.fields.${s.key}.label`, `config.fields.${s.key}.hint`]),
|
|
]),
|
|
).toEqual([]);
|
|
const known = new Set(specs.map((s) => s.key));
|
|
expect([...idsUnder(keys, "config.fields")].filter((k) => !known.has(k))).toEqual([]);
|
|
const groups = new Set(specs.map((s) => s.group));
|
|
expect([...idsUnder(keys, "config.groups")].filter((g) => !groups.has(g))).toEqual([]);
|
|
});
|
|
|
|
it(`${lang}: every option of every choice key, and none for an option that is gone`, () => {
|
|
expect(missing(choiceIds)).toEqual([]);
|
|
expect(
|
|
[...keys].filter((k) => k.startsWith("config.choices.") && !choiceIds.includes(k)),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it(`${lang}: every scheduler job`, () => {
|
|
expect(
|
|
missing(jobs.flatMap((j) => [`scheduler.jobs.${j}`, `scheduler.jobDesc.${j}`])),
|
|
).toEqual([]);
|
|
for (const prefix of ["scheduler.jobs", "scheduler.jobDesc"])
|
|
expect([...idsUnder(keys, prefix)].filter((j) => !jobs.includes(j))).toEqual([]);
|
|
});
|
|
|
|
it(`${lang}: every audit and quota action`, () => {
|
|
expect(auditActions.filter((a) => !idsUnder(keys, "auditActions").has(a))).toEqual([]);
|
|
expect(quotaActions.filter((a) => !idsUnder(keys, "quotaActions").has(a))).toEqual([]);
|
|
// …and no name for an action the backend no longer has (the unused-key contract leaves
|
|
// these two namespaces to this check).
|
|
expect([...idsUnder(keys, "auditActions")].filter((a) => !auditActions.includes(a))).toEqual(
|
|
[],
|
|
);
|
|
expect([...idsUnder(keys, "quotaActions")].filter((a) => !quotaActions.includes(a))).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
|
|
it(`${lang}: every download failure-catalog entry`, () => {
|
|
expect(
|
|
missing(failDetails.flatMap((d) => [`${DETAIL}${d}.short`, `${DETAIL}${d}.body`])),
|
|
).toEqual([]);
|
|
const named = new Set(
|
|
[...keys]
|
|
.filter((k) => k.startsWith(DETAIL))
|
|
.map((k) => k.slice(DETAIL.length).replace(/\.(short|body)$/, "")),
|
|
);
|
|
expect([...named].filter((d) => !failDetails.includes(d))).toEqual([]);
|
|
});
|
|
|
|
it(`${lang}: every "that is gone" a 404 names, and every YouTube refusal`, () => {
|
|
expect(missing(goneWhats.map((w) => `errors.notFound.${w}`))).toEqual([]);
|
|
expect(
|
|
[...idsUnder(keys, "errors.notFound")].filter(
|
|
(w) => w !== "generic" && !goneWhats.includes(w),
|
|
),
|
|
).toEqual([]);
|
|
expect(missing(youtubeReasons.map((r) => `errors.youtube.${r}`))).toEqual([]);
|
|
expect(
|
|
[...idsUnder(keys, "errors.youtube")].filter((r) => !youtubeReasons.includes(r)),
|
|
).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(
|
|
[],
|
|
);
|
|
});
|
|
}
|
|
});
|