Files
paperclip/ui/src/i18n/locale-validation.test.ts
T
Dotta e2d7263b07 [codex] Add minimal i18next i18n foundation (#5943)
## Thinking Path

> - Paperclip orchestrates AI-agent companies through a web control
plane.
> - The UI currently renders operator-facing copy directly from React
components.
> - Internationalization needs a smallest-possible starting point before
broader locale work can proceed.
> - The package declarations for `i18next` and `react-i18next` landed
separately, so this PR can stay focused on the implementation slice.
> - The implementation keeps the first surface English-only and
deliberately tiny while using the established `i18next` +
`react-i18next` runtime.
> - Future language contributions should be able to add a single locale
JSON file, with validation guarding key shape, interpolation parity,
suspicious payloads, and string length.
> - Locale strings must remain display-only UI copy and must not flow
into prompts, agent instructions, tool calls, shell commands, issue
content, approvals, adapter config, or other LLM-visible control paths.

## What Changed

- Initialized `i18next` behind the existing `@/i18n` boundary with fixed
English resources, fallback English, no detector plugin, no backend
plugin, no language picker, and no rich-text translation component.
- Kept `ui/src/i18n/locales/en.json` as the English source locale and
converted the validated JSON locale registry into i18next resources
before app rendering.
- Routed the no-companies start page title, description, and button
through `t(key, { defaultValue })` while preserving unchanged rendered
English copy.
- Added locale validation and focused Vitest coverage for missing/extra
keys, non-string leaves, interpolation parity, suspicious
executable/link payloads, and length caps.
- Addressed Greptile i18n review feedback: case-insensitive
event-handler detection, multi-violation diagnostics,
future-locale-friendly registration test, surfaced i18next init errors,
and removed the redundant side-effect import.
- Rebasing note: rebased onto current `public-gh/master` after the
package-only PR landed; this PR no longer changes `ui/package.json` or
`pnpm-lock.yaml`.

## Verification

- `pnpm install --no-lockfile --ignore-scripts` to install local
dependencies without reading or writing `pnpm-lock.yaml`.
- `pnpm --filter @paperclipai/ui exec vitest run
src/i18n/locale-validation.test.ts` -> passed, 7 tests.
- `pnpm --filter @paperclipai/ui typecheck` -> passed.
- `git diff --name-only public-gh/master...HEAD` shows only i18n
implementation files and the touched App copy call site; no package
manifest or lockfile changes remain in this PR.
- Visual impact is intentionally unchanged for the touched no-companies
copy because the English translations match the previous literal
strings.

## Risks

- Locale validation reduces prompt-injection risk, but the main safety
invariant is architectural: locale strings must remain display-only and
must never be used as LLM-visible control text.
- This intentionally does not add non-English locales, a language
picker, browser detection, HTTP/backend locale loading, server
localization, adapter localization, broad copy migration, or new package
scripts.
- Repository-wide CI may still depend on the separate lockfile-refresh
workflow for the already-merged package declaration, but this PR no
longer introduces package manifest or lockfile changes itself.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5, tool-enabled coding agent in medium reasoning
mode.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots, or documented why screenshots are not applicable because
there is no intended visual change
- [x] I have updated relevant documentation to reflect my changes, or
confirmed no docs changed because behavior/commands did not change
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-15 11:11:02 -05:00

103 lines
3.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { t } from ".";
import en from "./locales/en.json";
import { localeMessages } from "./locales";
import { validateLocaleMessages } from "./locale-validation";
describe("locale validation", () => {
it("resolves English messages with key and default fallbacks", () => {
expect(t("app.noCompanies.title")).toBe(en.app.noCompanies.title);
expect(t("app.missing", { defaultValue: "Fallback" })).toBe("Fallback");
expect(t("app.missing")).toBe("app.missing");
});
it("accepts registered locale files", () => {
expect(Object.keys(localeMessages)).toContain("en");
for (const [locale, messages] of Object.entries(localeMessages)) {
expect(validateLocaleMessages(messages), locale).toEqual([]);
}
});
it("rejects missing and extra nested keys", () => {
expect(
validateLocaleMessages({
app: {
noCompanies: {
title: en.app.noCompanies.title,
description: en.app.noCompanies.description,
unexpected: "Unexpected",
},
},
}),
).toEqual(
expect.arrayContaining([
"app.noCompanies.newCompany is missing",
"app.noCompanies.unexpected is not defined in English",
]),
);
});
it("rejects non-string leaves", () => {
expect(
validateLocaleMessages({
app: {
noCompanies: {
...en.app.noCompanies,
title: ["Create your first company"],
},
},
}),
).toEqual(expect.arrayContaining(["app.noCompanies.title must be a string"]));
});
it("requires interpolation placeholders to match English", () => {
const reference = {
message: "Invite {{name}} to {{company}}",
};
expect(validateLocaleMessages({ message: "Invite {{name}}" }, reference)).toEqual([
'message interpolation placeholders must match English exactly: expected ["company","name"], received ["name"]',
]);
});
it("rejects executable, raw HTML, and unexpected link payloads not present in English", () => {
const reference = {
script: "Create company",
handler: "Create company",
js: "Create company",
data: "Create company",
url: "Create company",
html: "Create company",
};
expect(
validateLocaleMessages(
{
script: "<script>alert(1)</script>",
handler: '<span ONCLICK="alert(1)">Create</span>',
js: "javascript:alert(1)",
data: "data:text/html,hello",
url: "https://example.test",
html: "<strong>Create company</strong>",
},
reference,
),
).toEqual(
expect.arrayContaining([
"script contains disallowed <script",
"handler contains disallowed event-handler attribute",
"js contains disallowed javascript:",
"data contains disallowed data:",
"url contains disallowed unexpected URL",
"html contains disallowed raw HTML tag",
]),
);
});
it("caps localized string length relative to English", () => {
expect(validateLocaleMessages({ message: "x".repeat(200) }, { message: "Short" })).toEqual([
"message is too long: 200 characters exceeds 133",
]);
});
});