Files
Douyin_TikTok_Download_API/tests/unit/test_console_reason_codes.py
Evil0ctal 52f9fc98d3 feat: negotiate the console's language, and translate what the server writes
Three asks: every page localized, a switcher in the top right, and the default
taken from the request header. The switcher was already there on all 14 routes
plus Login and Setup; the other two were not, and finding out why turned up more
than the pages themselves.

The console's language was decided entirely client-side, from navigator.language.
The API had a complete negotiator - ?lang= then Accept-Language then the
configured default - and the console never reached it, so a browser asking for
Chinese got Chinese error messages inside an English shell. index.html is now
served with the negotiated language stamped onto <html lang>, marked
data-language-source="negotiated" so the SPA can tell a real signal from the
value baked in at build time, with Vary: Accept-Language and no-store so a proxy
cannot hand one visitor's language to the next. Hashed assets stay cacheable. An
explicit choice still wins, before first paint, as it already did.

A lint rule now flags user-visible literal text in JSX - text nodes, translatable
attributes, object-literal labels, {cond ? 'a' : 'b'}. It reports zero across
web/src, which is the useful finding: the pages were already done. What it
cannot see is prose the SERVER writes and the console prints verbatim, and that
was the real gap:

* all 36 setting descriptions, ten of which had no text at all;
* the entire six-step diagnostic report, 25 outcomes;
* circuit-breaker trip reasons, shown in the landing page's red banner;
* cookie-import warnings, including the one saying the pasted jar is a live
  session - the warning that most needs to be understood;
* component health details on the System page;
* scheduler reject reasons and proxy import rejections, rendered as snake_case;
* failed-task error messages, frozen in the worker's English and preferred over
  the console's own catalogue because they were non-empty.

Each now carries a stable code plus its arguments and is rendered per reader,
following what envelope.py already did for HTTP errors. Codes stay untranslated;
so does evidence - raw driver text and upstream output are shown verbatim in a
mono block, because a user pasting them into an issue needs the original.

Also: ?lang= now works in the console and is remembered per tab rather than
persisted, so a shared link does not overwrite a returning user's choice; the
document title follows the page and the language; the query cache is invalidated
on a switch so server-rendered text already on screen refreshes; language names
are endonyms in both catalogues, checked by CI, because "Chinese" is invisible to
someone scanning for 中文; and the switcher now reaches the two full-screen
states that had no shell - the fatal network screen and the error boundary -
which are exactly the screens a user is stuck on.

One fix is a security fix, not a localization one. Moving the diagnostic report
to render from already-redacted values dropped an outer redact(str(...)) pass, so
a detail value that is neither str, dict nor list - an upstream driver error
object, the single likeliest thing to carry a live session - reached the report
verbatim. redact_value now stringifies and redacts anything it does not
recognize, scalars excepted, and the text renderer re-runs the pattern pass.
Reproduced before the fix and covered by a test.

Verified: ruff and mypy clean (147 files), 1392 unit+replay, 209 integration,
100 browser_rpc, 25/25 Docker smoke, frontend typecheck/lint/i18n. In a live
container: Accept-Language: zh -> <html lang="zh"> with a Chinese title and
shell on first paint, fr -> en, ?lang= overrides the header without persisting,
Vary and no-store present on the shell and absent from /assets, and one failed
task rendered "请求参数不合法。" or its English counterpart from the same stored row.
2026-09-07 16:24:48 -07:00

134 lines
4.9 KiB
Python

"""Wire codes the console has to phrase, and the two lists that go stale.
Two enumerations leave the backend as bare snake_case strings and end up in
front of an operator: the scheduler's RejectReason, in the request log, and the
proxy importer's per-line rejection reason. Neither is an ErrorCode, so nothing
in web/scripts/check-i18n.mjs covers them, and adding a member on this side is
silent on the other: the console falls through to printing the raw code.
These tests are the join. They read the codes the server can actually emit and
check that the console both lists them and has copy for them in both languages.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
from dtk.core.types import RejectReason
REPO = Path(__file__).resolve().parents[2]
WEB = REPO / "web" / "src"
PROXY_URLS = REPO / "src" / "dtk" / "api" / "routes" / "admin" / "proxy_urls.py"
PROXY_ROUTE = REPO / "src" / "dtk" / "api" / "routes" / "admin" / "proxies.py"
pytestmark = pytest.mark.skipif(not WEB.exists(), reason="console not present")
def _string_list(source: str, declaration: str) -> set[str]:
"""The quoted entries of a `const NAME = [...]` block in a TypeScript file."""
block = re.search(re.escape(declaration) + r"[^[]*\[(.*?)\]", source, re.S)
assert block is not None, f"{declaration} is no longer declared the way this test reads it"
return set(re.findall(r"'([^']+)'", block.group(1)))
def _console_catalog(language: str) -> dict[str, str]:
raw = json.loads((WEB / "locales" / language / "console.json").read_text(encoding="utf-8"))
flat: dict[str, str] = {}
def walk(node: object, prefix: str) -> None:
if isinstance(node, dict):
for key, value in node.items():
walk(value, f"{prefix}{key}.")
else:
flat[prefix.rstrip(".")] = str(node)
walk(raw, "")
return flat
# --------------------------------------------------------------------------
# Scheduler reject reasons
# --------------------------------------------------------------------------
def _console_reject_reasons() -> set[str]:
source = (WEB / "lib" / "types.ts").read_text(encoding="utf-8")
return _string_list(source, "export const REJECT_REASONS")
def test_the_console_knows_every_reject_reason() -> None:
server = {reason.value for reason in RejectReason}
assert server <= _console_reject_reasons(), (
"web/src/lib/types.ts does not list "
f"{sorted(server - _console_reject_reasons())}; the request log would "
"show the raw code"
)
def test_the_console_invents_no_reject_reason() -> None:
"""A code the scheduler cannot emit is dead copy that reads as live."""
server = {reason.value for reason in RejectReason}
assert _console_reject_reasons() <= server, (
f"web/src/lib/types.ts lists {sorted(_console_reject_reasons() - server)}, "
"which RejectReason no longer has"
)
@pytest.mark.parametrize("language", ["en", "zh"])
def test_every_reject_reason_is_phrased(language: str) -> None:
catalog = _console_catalog(language)
missing = [
reason.value
for reason in RejectReason
if f"logs.rejectReason.{reason.value}" not in catalog
]
assert not missing, (
f"web/src/locales/{language}/console.json has no logs.rejectReason entry for {missing}"
)
# --------------------------------------------------------------------------
# Proxy import rejections
# --------------------------------------------------------------------------
def _server_import_rejections() -> set[str]:
parser = PROXY_URLS.read_text(encoding="utf-8")
reasons = set(re.findall(r"_fail\(\s*\w+\s*,\s*\"([a-z_]+)\"", parser))
assert reasons, "no _fail(..., reason) calls found; the parser was restructured"
# The route substitutes its own reason when the parser's details lack one.
route = PROXY_ROUTE.read_text(encoding="utf-8")
reasons |= set(re.findall(r"\.get\(\s*\"reason\",\s*\"([a-z_]+)\"", route))
return reasons
def _console_import_rejections() -> set[str]:
source = (WEB / "pages" / "Proxies.tsx").read_text(encoding="utf-8")
return _string_list(source, "const IMPORT_REJECTIONS")
def test_the_console_knows_every_import_rejection() -> None:
server = _server_import_rejections()
assert server <= _console_import_rejections(), (
"web/src/pages/Proxies.tsx does not list "
f"{sorted(server - _console_import_rejections())}; the import report "
"would show the raw code"
)
@pytest.mark.parametrize("language", ["en", "zh"])
def test_every_import_rejection_is_phrased(language: str) -> None:
catalog = _console_catalog(language)
missing = [
code
for code in sorted(_console_import_rejections())
if f"proxy.import.reason.{code}" not in catalog
]
assert not missing, (
f"web/src/locales/{language}/console.json has no proxy.import.reason entry for {missing}"
)