Files
openclaw/test/scripts/control-ui-i18n.test.ts
Peter Steinberger 47d37804a8 improve(ui): catch i18n catalog drift locally (#107253)
* ci(ui): verify i18n catalogs locally

* fix(ui): preserve scoped i18n sync errors

* refactor(ui): share i18n raw-copy verifier

* test(ui): cover i18n lint gate

* fix(ui): route lint threads to oxlint
2026-07-14 01:12:36 -07:00

433 lines
14 KiB
TypeScript

// Control Ui I18N tests cover control ui i18n script behavior.
import { spawn, spawnSync } from "node:child_process";
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import * as ts from "typescript";
import { describe, expect, it } from "vitest";
import {
analyzeControlUiCatalogs,
assertScopedCatalogFallbackUpdate,
flattenControlUiCatalog,
} from "../../scripts/control-ui-i18n-verify.ts";
import {
appendBoundedProcessOutput,
buildBatchPrompt,
parseTranslationBatchReply,
runProcess,
shouldReuseExistingTranslation,
} from "../../scripts/control-ui-i18n.ts";
import { collectControlUiRawCopyFromSource } from "../../scripts/lib/control-ui-i18n-raw-copy.ts";
import { createTempDirTracker } from "../helpers/temp-dir.js";
function processIsAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
async function waitForProcessExit(pid: number, timeoutMs = 1_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!processIsAlive(pid)) {
return;
}
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
}
throw new Error(`process ${pid} was still alive after ${timeoutMs}ms`);
}
async function waitForChildClose(
child: ReturnType<typeof spawn>,
timeoutMs = 2_000,
): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("child did not close before timeout"));
}, timeoutMs);
child.once("close", (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal });
});
});
}
describe("control-ui-i18n process runner", () => {
it("builds a deterministic fallback list without accepting catalog drift", () => {
const source = flattenControlUiCatalog(
{ group: { first: "First {count}", second: "Second" } },
"en",
);
const missingAnalysis = analyzeControlUiCatalogs(
source,
new Map([
["de", new Map([["group.first", "Erste {count}"]])],
["fr", new Map([["group.first", "Premiere {count}"]])],
]),
);
expect(missingAnalysis).toEqual({
errors: [],
fallbacks: { "group.second": ["de", "fr"] },
});
const driftAnalysis = analyzeControlUiCatalogs(
source,
new Map([
[
"fr",
new Map([
["group.second", "Deuxieme"],
["group.first", "Premiere"],
["group.orphan", "Orpheline"],
]),
],
]),
);
expect(driftAnalysis.errors).toEqual([
"fr: orphan keys: group.orphan",
"fr: keys are not in English catalog order",
"fr:group.first expected {count} got {}",
]);
expect(driftAnalysis.fallbacks).toEqual({});
});
it("rejects invalid catalog leaf values", () => {
expect(() => flattenControlUiCatalog({ group: { title: 42 } }, "fr")).toThrow(
"fr:group.title must be a string or object",
);
});
it("allows scoped sync to remove only that locale's approved fallbacks", () => {
const current = {
fallbacks: { "group.second": ["de", "fr"] },
sourceHash: "source",
version: 1,
};
expect(() =>
assertScopedCatalogFallbackUpdate(
current,
{ ...current, fallbacks: { "group.second": ["fr"] } },
"de",
),
).not.toThrow();
expect(() =>
assertScopedCatalogFallbackUpdate(
current,
{ ...current, fallbacks: { "group.second": ["de"] } },
"de",
),
).toThrow("unrelated catalog fallback drift");
expect(() =>
assertScopedCatalogFallbackUpdate(
current,
{ ...current, fallbacks: { "group.second": ["de", "es", "fr"] } },
"de",
),
).toThrow("unrelated catalog fallback drift");
});
it("finds raw text and attributes split by template interpolation", () => {
const source =
'const jsx = <button aria-label="Archive" />; const view = html`<button title="Delete ${name}">Delete ${name}</button>`;';
const sourceFile = ts.createSourceFile(
"ui/src/pages/example.ts",
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
expect(
collectControlUiRawCopyFromSource({
filePath: path.resolve("ui/src/pages/example.ts"),
source,
sourceFile,
}).map(({ kind, text }) => ({ kind, text })),
).toEqual([
{ kind: "html-attribute", text: "Archive" },
{ kind: "html-attribute", text: "Delete" },
{ kind: "html-text", text: "Delete" },
]);
});
it("keeps verification keyless even when provider credentials exist", () => {
const result = spawnSync(
process.execPath,
["--import", "tsx", "scripts/control-ui-i18n-verify.ts", "verify"],
{
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
ANTHROPIC_API_KEY: "redacted",
OPENAI_API_KEY: "redacted",
},
},
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("catalog:");
expect(result.stdout).not.toContain("provider=openai");
expect(result.stdout).not.toContain("provider=anthropic");
});
it("rejects placeholder-corrupt batch replies before they leave the retry loop", () => {
const items = [
{
cacheKey: "cache-key",
key: "configView.viewPendingChange",
text: "View pending change ({count})",
textHash: "text-hash",
},
];
expect(() =>
parseTranslationBatchReply(
JSON.stringify({ "configView.viewPendingChange": "Pending change" }),
items,
"ar",
),
).toThrow("ar:configView.viewPendingChange expected {count} got {}");
expect(
parseTranslationBatchReply(
JSON.stringify({ "configView.viewPendingChange": "Pending change ({count})" }),
items,
"ar",
),
).toEqual(new Map([["configView.viewPendingChange", "Pending change ({count})"]]));
});
it("feeds the exact validation failure back into a retry prompt", () => {
const items = [
{
cacheKey: "cache-key",
key: "configView.viewPendingChange",
text: "View pending change ({count})",
textHash: "text-hash",
},
];
const validationError = "ar:configView.viewPendingChange expected {count} got {}";
expect(buildBatchPrompt(items, validationError)).toContain(
`failed validation. Correct that exact failure in the new response:\n${validationError}`,
);
});
it("ships no recorded English fallbacks", () => {
const metaDir = path.resolve("ui/src/i18n/.i18n");
const fallbacks = readdirSync(metaDir)
.filter((fileName) => fileName.endsWith(".meta.json"))
.flatMap((fileName) => {
const meta = JSON.parse(readFileSync(path.join(metaDir, fileName), "utf8")) as {
fallbackKeys?: string[];
locale?: string;
};
return (meta.fallbackKeys ?? []).map((key) => `${meta.locale ?? fileName}:${key}`);
});
expect(fallbacks).toEqual([]);
});
it("refreshes recorded fallback copy when sync is forced without a provider", () => {
expect(
shouldReuseExistingTranslation({
allowTranslate: false,
force: true,
isFallback: true,
}),
).toBe(false);
expect(
shouldReuseExistingTranslation({
allowTranslate: false,
force: false,
isFallback: true,
}),
).toBe(true);
});
it("keeps a bounded process output tail", () => {
const first = appendBoundedProcessOutput({ text: "", truncatedChars: 0 }, "abcdef", 5);
const second = appendBoundedProcessOutput(first, "ghij", 5);
expect(first).toEqual({ text: "bcdef", truncatedChars: 1 });
expect(second).toEqual({ text: "fghij", truncatedChars: 5 });
});
it("bounds failure diagnostics to the newest output", async () => {
await expect(
runProcess(
process.execPath,
[
"-e",
[
"process.stderr.write('stderr-begin-' + 'x'.repeat(128) + '-stderr-end', () => process.exit(2));",
].join(" "),
],
{ maxOutputChars: 64, rejectOnFailure: true },
),
).rejects.toThrow(/output truncated[\s\S]*stderr-end/u);
});
it("rejects successful commands before returning truncated stdout", async () => {
await expect(
runProcess(
process.execPath,
["-e", "process.stdout.write('x'.repeat(128), () => process.exit(0));"],
{
maxOutputChars: 12,
},
),
).rejects.toThrow("produced more than 12 stdout chars");
});
it.runIf(process.platform !== "win32")(
"kills descendant processes after the process timeout",
async () => {
const tempDirs = createTempDirTracker();
const tempDir = tempDirs.make("openclaw-control-ui-i18n-timeout-");
try {
const markerPath = path.join(tempDir, "grandchild.pid");
const grandchildScript = [
"process.on('SIGTERM', () => {});",
"setInterval(() => {}, 1000);",
].join("\n");
const parentScript = [
"const { spawn } = require('node:child_process');",
"const { writeFileSync } = require('node:fs');",
`const grandchild = spawn(process.execPath, ["-e", ${JSON.stringify(grandchildScript)}], { stdio: "ignore" });`,
`writeFileSync(${JSON.stringify(markerPath)}, String(grandchild.pid));`,
"process.on('SIGTERM', () => {});",
"setInterval(() => {}, 1000);",
].join("\n");
await expect(
runProcess(process.execPath, ["-e", parentScript], {
cwd: tempDir,
killGraceMs: 25,
timeoutMs: 500,
}),
).rejects.toThrow(`timed out after 500ms`);
const grandchildPid = Number(readFileSync(markerPath, "utf8"));
await waitForProcessExit(grandchildPid);
} finally {
tempDirs.cleanup();
}
},
);
it.runIf(process.platform !== "win32")(
"waits for all process groups before re-raising parent signals",
async () => {
const tempDirs = createTempDirTracker();
const tempDir = tempDirs.make("openclaw-control-ui-i18n-signal-");
const fastReadyPath = path.join(tempDir, "fast-ready");
const fastCommandPath = path.join(tempDir, "fast-command.mjs");
const commandPath = path.join(tempDir, "command.mjs");
const runnerPath = path.join(tempDir, "runner.mjs");
const grandchildPidPath = path.join(tempDir, "grandchild.pid");
let grandchildPid = 0;
try {
const grandchildScript = [
"process.on('SIGTERM', () => {});",
"setInterval(() => {}, 1000);",
].join("\n");
writeFileSync(
fastCommandPath,
[
"import { writeFileSync } from 'node:fs';",
`writeFileSync(${JSON.stringify(fastReadyPath)}, "ready");`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("\n"),
"utf8",
);
writeFileSync(
commandPath,
[
"import { spawn } from 'node:child_process';",
"import { writeFileSync } from 'node:fs';",
`const grandchild = spawn(process.execPath, ["--eval", ${JSON.stringify(
grandchildScript,
)}], { stdio: "ignore" });`,
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("\n"),
"utf8",
);
writeFileSync(
runnerPath,
[
`const { runProcess } = await import(${JSON.stringify(
pathToFileURL(path.resolve("scripts/control-ui-i18n.ts")).href,
)});`,
"void runProcess(process.execPath,",
` [${JSON.stringify(fastCommandPath)}],`,
" { killGraceMs: 100, timeoutMs: 30_000 },",
").catch(() => undefined);",
"void runProcess(process.execPath,",
` [${JSON.stringify(commandPath)}],`,
" { killGraceMs: 100, timeoutMs: 30_000 },",
").catch(() => undefined);",
].join("\n"),
"utf8",
);
const runner = spawn(process.execPath, ["--import", "tsx", runnerPath], {
cwd: process.cwd(),
stdio: "ignore",
});
try {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
let fastReady = false;
try {
fastReady = readFileSync(fastReadyPath, "utf8") === "ready";
} catch {}
try {
grandchildPid = Number(readFileSync(grandchildPidPath, "utf8"));
} catch {}
if (fastReady && grandchildPid > 0 && processIsAlive(grandchildPid)) {
break;
}
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
}
expect(readFileSync(fastReadyPath, "utf8")).toBe("ready");
expect(grandchildPid).toBeGreaterThan(0);
expect(processIsAlive(grandchildPid)).toBe(true);
runner.kill("SIGTERM");
await expect(waitForChildClose(runner)).resolves.toEqual({
code: null,
signal: "SIGTERM",
});
await waitForProcessExit(grandchildPid, 2_000);
} finally {
if (runner.pid && processIsAlive(runner.pid)) {
runner.kill("SIGKILL");
}
if (grandchildPid > 0 && processIsAlive(grandchildPid)) {
process.kill(grandchildPid, "SIGKILL");
}
}
} finally {
tempDirs.cleanup();
}
},
);
});