Files
kandev/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts
T
João Salavisa 5b12a37b0c feat(i18n): localize Settings → Integrations → GitHub watches and defaults (#2158)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:03:04 +02:00

233 lines
8.8 KiB
TypeScript

import { type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
/**
* Pseudo-locale coverage oracle.
*
* Under the pseudo-locale every EXTRACTED message is accented
* (`Language` → `Ĺàńĝũàĝē`). So any user-facing text that is still plain ASCII
* is, by definition, a string that was never wrapped in a Lingui macro. This
* spec crawls chrome-heavy screens and reports those leftovers.
*
* `SCREENS` mirrors `i18nGuardFiles` in eslint.i18n.options.mjs: a screen belongs
* here once the components that render it have been migrated. The eslint guard
* only sees plain literals in JSX, so it and this spec cover different halves of
* the same question — add to both in the PR that migrates a directory.
*
* Gated on `KANDEV_I18N_COVERAGE=1` rather than run in CI: with the migration
* proceeding one directory at a time, a screen's own copy can be clean while
* shared chrome it renders is not yet. It becomes a hard gate when the last
* directory lands and the env guard comes off.
*
* KANDEV_I18N_COVERAGE=1 pnpm e2e -- e2e/tests/i18n/pseudo-coverage.spec.ts
*/
const COVERAGE_ENABLED = process.env.KANDEV_I18N_COVERAGE === "1";
/**
* Migrated screens whose visible text is overwhelmingly UI chrome, not user data.
*
* `allow` extends `ALLOWED` for one screen only. Use it for text the frontend
* must NOT translate but cannot avoid rendering — records the backend owns, or a
* product name — and say where the value comes from, so the exemption stays
* auditable instead of quietly widening into a place to hide missed strings.
*/
const SCREENS: Array<{ name: string; url: string; allow?: string[] }> = [
{ name: "settings — appearance", url: "/settings/general/appearance" },
{
name: "settings — notifications",
url: "/settings/general/notifications",
// Provider names are rows in the notification_providers table. The backend
// seeds these two (apps/backend/internal/notifications/service/service.go)
// and users name their own Apprise ones, so they are data on the same
// footing as a task title. `Apprise` labels the provider type.
allow: ["Desktop Notifications", "System Notifications", "Apprise"],
},
{ name: "settings — secrets", url: "/settings/general/secrets" },
{ name: "settings — terminal", url: "/settings/general/terminal" },
{
name: "settings — sprites",
url: "/settings/general/sprites",
// Product name of the sandbox provider.
allow: ["Sprites.dev"],
},
{
name: "settings — layouts",
url: "/settings/general/layouts",
// NOTE: everything here must be a PERSISTED name. This list is load-bearing
// in the wrong direction — a broad token also hides genuinely un-migrated
// copy that happens to match it. "Default" already masked the
// default-action button once, which rendered a raw English "Default" until
// `getDefaultActionState` was fixed to resolve it through the catalog.
//
// Both groups are display strings that are also PERSISTED, so translating
// them in place would write locale-dependent values into a user's saved
// layouts and leave them there after a locale switch:
// - Built-in profile names/descriptions (lib/layout/layout-profiles.ts).
// `upsertBuiltInLayoutOverride` copies `builtIn.name` into the saved
// record the first time a built-in is customized.
// - Dockview panel titles (lib/state/layout-manager/constants.ts), which
// `toSerializedDockview` writes into the stored layout JSON. That path
// is already in `EXCLUDED` in scripts/externalize-strings.mjs.
// Localizing either needs a key/persisted-value split in those modules.
allow: [
"Default",
"Plan Mode",
"Preview Mode",
"VS Code",
"Agent with Files, Changes, PR Details, and Terminal",
"Agent and Plan side by side",
"Agent and Browser side by side",
"Agent and VS Code side by side",
"Agent",
"Plan",
"Changes",
"Files",
"Browser",
"Terminal",
"PR Details",
"Merge Request",
],
},
{
name: "settings — keyboard shortcuts",
url: "/settings/general/keyboard-shortcuts",
// Modifier/key names label a physical key and are out of scope for
// translation — the same rule the eslint guard's keyboard pattern encodes.
//
// Shortcut names come from CONFIGURABLE_SHORTCUTS in
// lib/keyboard/shortcut-overrides.ts, a registry shared with the
// un-migrated voice-mode settings page; it migrates with that page.
allow: [
"Ctrl",
"Shift",
"Alt",
"Cmd",
"Meta",
"Space",
"Enter",
"Tab",
"Command Panel",
"Command Panel (Alt)",
"File Search",
"Search Task Contents",
"Quick Chat",
"Toggle Bottom Terminal",
"Toggle Sidebar",
"New Task",
"Focus Chat Input",
"Focus CLI Chat Input",
"Toggle Plan Mode",
"Recent Task Switcher",
"Recent Task Switcher (Backward)",
"Voice Input",
"Reverse Chat Search",
"Open Task Pull Request",
],
},
{ name: "settings — task actions", url: "/settings/general/task-actions" },
// NOT YET: "settings — integrations github". The page's own copy is fully
// migrated (verified by running this oracle against it — every GitHub-owned
// string renders accented), but the route expands the Workspaces > Integrations
// branch of the settings nav, and `workspaces-group.tsx` / `settings-tree.tsx`
// are not migrated: "Workspaces", "Integrations", "Automations", "Executors",
// "Voice Mode", "Utility Agents", "External MCP", "Plugins", "System" and
// "Toggle theme" all still render plain English there. The /settings/general/*
// screens above pass because their expanded branch is `general-group.tsx`,
// which is migrated. Add this entry in the PR that migrates the settings nav —
// allowlisting those nav labels here would hide real misses instead.
];
/**
* Text that is legitimately un-accented under pseudo: brand/proper nouns, code
* identifiers, and units/symbols. Kept in sync with `words.exclude` in
* apps/web/eslint.i18n.options.mjs.
*/
const ALLOWED = [
"Kandev",
"GitHub",
"GitLab",
"Jira",
"Linear",
"Slack",
"Sentry",
"Azure DevOps",
"ACP",
"MCP",
"SSH",
"URL",
"ID",
"English",
"Pseudo",
"QA",
];
async function activatePseudo(page: Page, url: string) {
await page.goto(url);
await page.evaluate(() => {
document.cookie = "kandev_locale=pseudo; path=/; max-age=31536000; SameSite=Lax";
});
await page.reload();
await expect(page.locator("html")).toHaveAttribute("lang", "pseudo", { timeout: 15_000 });
}
/**
* Collect visible text nodes that look like un-externalized English copy.
* Nodes inside script/style, and nodes that are wholly allowlisted, are ignored.
*/
async function findUnlocalizedText(page: Page, allowed: string[]): Promise<string[]> {
return page.evaluate((allowedList) => {
const skipTags = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "CODE", "PRE", "SVG"]);
const wordlike = /[A-Za-z]{4,}/;
const accented = /[À-ɏ]/;
const found = new Set<string>();
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
const text = (node.textContent ?? "").trim();
if (!text || !wordlike.test(text) || accented.test(text)) continue;
const el = node.parentElement;
if (!el || skipTags.has(el.tagName)) continue;
// Ignore hidden nodes — not user-visible copy.
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;
// Strip allowlisted tokens; if nothing word-like remains, it's fine.
// Longest first: stripping "VS Code" before "Agent and VS Code side by
// side" would leave "side by side" behind and report it as a leftover.
let residue = text;
for (const token of [...allowedList].sort((a, b) => b.length - a.length)) {
residue = residue.split(token).join(" ");
}
if (!wordlike.test(residue)) continue;
found.add(text.slice(0, 120));
}
return [...found];
}, allowed);
}
test.describe("i18n pseudo-locale coverage", () => {
test.skip(
!COVERAGE_ENABLED,
"Set KANDEV_I18N_COVERAGE=1 to run the string-externalization oracle (hard gate at task-40).",
);
for (const screen of SCREENS) {
test(`no un-externalized copy on ${screen.name}`, async ({ testPage }) => {
await activatePseudo(testPage, screen.url);
// Let lazy panels settle before scanning.
await testPage.waitForTimeout(1_000);
const leftovers = await findUnlocalizedText(testPage, [...ALLOWED, ...(screen.allow ?? [])]);
expect(
leftovers,
`Un-externalized strings on ${screen.name}:\n${leftovers.map((s) => ` - ${s}`).join("\n")}`,
).toEqual([]);
});
}
});