Files
openclaw/test/scripts/native-app-i18n.test.ts

300 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
collectNativeI18nEntries,
NATIVE_I18N_LOCALES,
parseNativeI18nCommand,
syncNativeLocale,
type NativeI18nEntry,
} from "../../scripts/native-app-i18n.ts";
import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js";
describe("native app i18n inventory", () => {
it("collects stable Android and Apple UI entries", async () => {
const entries = await collectNativeI18nEntries();
const surfaces = new Set(entries.map((entry) => entry.surface));
expect(entries.length).toBeGreaterThan(100);
expect(surfaces).toEqual(new Set(["android", "apple"]));
expect(entries.every((entry) => entry.id.startsWith(`native.${entry.surface}.`))).toBe(true);
expect(new Set(entries.map((entry) => entry.id)).size).toBe(entries.length);
expect(
entries.every(
(entry) => !/(?:\/|\\)(?:Tests?|UITests?|test|Preview(?:s)?)(?:\/|\\)/u.test(entry.path),
),
).toBe(true);
expect(
entries.every(
(entry) => !/(?:Tests?|UITests?|Previews?|Testing)\.(?:swift|kt|kts)$/u.test(entry.path),
),
).toBe(true);
expect(
entries
.filter((entry) => entry.surface === "apple")
.every((entry) =>
/^(?:apps\/ios|apps\/macos\/Sources|apps\/shared\/OpenClawKit\/Sources)\//u.test(
entry.path,
),
),
).toBe(true);
expect(entries.some((entry) => entry.source === "QR Scanner Unavailable")).toBe(true);
expect(entries.some((entry) => entry.source === "Request ID: \\(value)")).toBe(true);
expect(entries.some((entry) => entry.source === "Open ${row.title}")).toBe(true);
expect(entries.some((entry) => entry.source === "$deviceModel · $appVersion")).toBe(true);
expect(entries.some((entry) => entry.source === "Approval command copied")).toBe(true);
expect(entries.some((entry) => entry.source === "Save Profile")).toBe(true);
expect(entries.some((entry) => entry.source === "Pairing required")).toBe(true);
expect(entries.some((entry) => entry.source === "Mute")).toBe(true);
expect(entries.some((entry) => entry.source === "Creating...")).toBe(true);
expect(entries.some((entry) => entry.source === "Permission required")).toBe(true);
expect(entries.some((entry) => entry.source === "Searching…")).toBe(true);
expect(entries.some((entry) => entry.source === "Run now")).toBe(true);
expect(entries.some((entry) => entry.source === "Loading chat")).toBe(true);
expect(entries.some((entry) => entry.source === "DIARY")).toBe(true);
expect(entries.some((entry) => entry.source === "ask OpenClaw $prompt")).toBe(true);
expect(entries.some((entry) => entry.source === "OpenClaw is paused")).toBe(true);
expect(
entries.some((entry) => entry.source === "Choose system, light, or dark appearance"),
).toBe(true);
expect(
entries.some(
(entry) =>
entry.path === "apps/ios/Sources/Design/TalkRuntimeIssueBanner.swift" &&
entry.kind === "ui-named-argument" &&
entry.source === "Details",
),
).toBe(true);
expect(
entries.some(
(entry) =>
entry.path === "apps/ios/Sources/Design/TalkRuntimeIssueBanner.swift" &&
entry.kind === "ui-named-argument" &&
entry.source === "Open Settings",
),
).toBe(true);
expect(entries.some((entry) => entry.source === "No sessions yet")).toBe(true);
expect(entries.some((entry) => entry.source === "Dont show this again")).toBe(true);
expect(entries.some((entry) => entry.source === "Use Manual Gateway")).toBe(true);
expect(entries.some((entry) => entry.source === "Session target")).toBe(true);
expect(
entries.some(
(entry) =>
entry.source === 'OpenClaw needs ${labels.joinToString(", ")} permissions to continue.',
),
).toBe(true);
expect(
entries.some((entry) => entry.source === "Some channel status checks did not complete."),
).toBe(true);
expect(
entries.some(
(entry) =>
entry.source === '\\(day.entryCount) \\(day.entryCount == 1 ? "entry" : "entries")',
),
).toBe(false);
expect(
entries.some(
(entry) =>
entry.source === 'Missing binaries: \\(self.missingBins.joined(separator: ", "))',
),
).toBe(true);
expect(
entries.some(
(entry) =>
entry.source ===
"Approve this device on the gateway.\n1) `\\(commandLine)`\n2) `/pair approve` in your OpenClaw chat\n\\(requestLine)\nOpenClaw will also retry automatically when you return to this app.",
),
).toBe(true);
expect(entries.some((entry) => entry.source === "Approve this device on the gateway.\n")).toBe(
false,
);
expect(
entries.some((entry) =>
entry.source.startsWith(
"Exec approvals can only be reviewed while OpenClaw is open and connected.",
),
),
).toBe(true);
expect(entries.some((entry) => entry.source === "$(PRODUCT_BUNDLE_IDENTIFIER)")).toBe(false);
expect(entries.some((entry) => entry.source === "ai.openclaw.screenRecord.writer")).toBe(false);
expect(
entries.some(
(entry) =>
entry.surface === "android" && entry.source === "INVALID_REQUEST: expected JSON object",
),
).toBe(false);
expect(
entries.some(
(entry) =>
entry.surface === "android" && ["off", "talk-orb", "pulse"].includes(entry.source),
),
).toBe(false);
expect(entries.some((entry) => entry.source === "false")).toBe(false);
expect(entries.some((entry) => entry.source === "ws")).toBe(false);
expect(entries.some((entry) => entry.source === '{"includeSecrets":true}')).toBe(false);
expect(entries.some((entry) => entry.source === "builtIn")).toBe(false);
expect(entries.some((entry) => entry.source === "State: \\(stateDir)")).toBe(true);
expect(entries.some((entry) => entry.path.endsWith("Info.plist"))).toBe(true);
expect(NATIVE_I18N_LOCALES).toHaveLength(21);
expect(NATIVE_I18N_LOCALES).toContain("sv");
});
it("creates a first-run locale artifact and leaves a complete artifact unchanged", async () => {
const tempDirs: string[] = [];
const translationsDir = makeTempDir(tempDirs, "openclaw-native-i18n-");
const entries: NativeI18nEntry[] = [
{
id: "native.android.hello",
kind: "ui-call",
line: 1,
path: "apps/android/example.kt",
source: "Hello",
surface: "android",
},
{
id: "native.apple.request",
kind: "ui-call",
line: 2,
path: "apps/ios/example.swift",
source: "Request ID: \\(requestId)",
surface: "apple",
},
{
id: "native.android.count",
kind: "ui-call",
line: 3,
path: "apps/android/example.kt",
source: "Showing ${visibleApps.size} of ${apps.size}",
surface: "android",
},
{
id: "native.apple.permissions",
kind: "ui-call",
line: 4,
path: "apps/ios/example.swift",
source: "\\(granted) of \\(total) permissions granted",
surface: "apple",
},
];
try {
const first = await syncNativeLocale("sv", entries, {
glossary: [],
translationsDir,
translate: async (pending) =>
new Map(
pending.map((entry) => {
const translated = {
"native.android.hello": "Hej",
"native.apple.request": "Begärans-ID: \\(requestId)",
"native.android.count": "${apps.size} totalt, ${visibleApps.size} visas",
"native.apple.permissions": "Av \\(total) behörigheter har \\(granted) beviljats",
}[entry.id];
return [entry.id, translated ?? entry.source];
}),
),
});
expect(first).toEqual({ changed: true, translated: 4 });
const artifactPath = path.join(translationsDir, "sv.json");
const firstContents = await readFile(artifactPath, "utf8");
const firstModifiedAt = (await stat(artifactPath)).mtimeMs;
const second = await syncNativeLocale("sv", entries, {
glossary: [],
translationsDir,
translate: async () => {
throw new Error("no-op refresh must not call the provider");
},
});
expect(second).toEqual({ changed: false, translated: 0 });
expect(await readFile(artifactPath, "utf8")).toBe(firstContents);
expect((await stat(artifactPath)).mtimeMs).toBe(firstModifiedAt);
const refreshed = await syncNativeLocale("sv", entries, {
glossary: [{ source: "Request", target: "Begäran" }],
translationsDir,
translate: async (pending) =>
new Map(pending.map((entry) => [entry.id, `refreshed:${entry.source}`])),
});
expect(refreshed).toEqual({ changed: true, translated: 4 });
const refreshedArtifact = JSON.parse(await readFile(artifactPath, "utf8")) as {
entries: Array<{ translated: string }>;
glossaryHash: string;
};
expect(refreshedArtifact.glossaryHash).toMatch(/^[a-f0-9]{64}$/u);
expect(
refreshedArtifact.entries.every((entry) => entry.translated.startsWith("refreshed:")),
).toBe(true);
} finally {
cleanupTempDirs(tempDirs);
}
});
it("rejects native printf placeholder drift", async () => {
const tempDirs: string[] = [];
const translationsDir = makeTempDir(tempDirs, "openclaw-native-i18n-");
const cases = [
{
entry: {
id: "native.android.certificate",
kind: "ui-call",
line: 1,
path: "apps/android/example.kt",
source: "Old fingerprint: %1$s\nNew fingerprint: %2$s",
surface: "android",
},
translated: "Gammalt fingeravtryck: %1$s",
},
{
entry: {
id: "native.apple.failure",
kind: "ui-call",
line: 1,
path: "apps/ios/example.swift",
source: "Send failed: %@",
surface: "apple",
},
translated: "Sändningen misslyckades",
},
] satisfies Array<{ entry: NativeI18nEntry; translated: string }>;
try {
for (const { entry, translated } of cases) {
await expect(
syncNativeLocale("sv", [entry], {
glossary: [],
translationsDir,
translate: async () => new Map([[entry.id, translated]]),
}),
).rejects.toThrow(
`native translation changed placeholders or line breaks for sv:${entry.id}`,
);
}
} finally {
cleanupTempDirs(tempDirs);
}
});
it("validates locale refresh arguments before write paths run", () => {
expect(parseNativeI18nCommand(["sync", "--write", "--locale", "sv"])).toEqual({
command: "sync",
locale: "sv",
write: true,
});
expect(() => parseNativeI18nCommand(["sync", "--write", "--locale"])).toThrow(
"requires a locale value",
);
expect(() => parseNativeI18nCommand(["sync", "--write", "--locale", "--write"])).toThrow(
"requires a locale value",
);
expect(() => parseNativeI18nCommand(["sync", "--write", "--locale", "xx"])).toThrow(
"unsupported native locale",
);
expect(() => parseNativeI18nCommand(["check", "--locale", "sv"])).toThrow(
"requires `sync --write",
);
});
});