mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 13:41:37 +00:00
472 lines
16 KiB
TypeScript
472 lines
16 KiB
TypeScript
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import { describe, expect, it } from "vitest";
|
||
import {
|
||
APPLE_I18N_LOCALES,
|
||
buildIosCatalog,
|
||
checkAppleAppI18n,
|
||
compileMacosLocalizations,
|
||
findAmbiguousRuntimeInterpolations,
|
||
infoPlistTranslationCandidates,
|
||
selectInfoPlistTranslation,
|
||
} from "../../scripts/apple-app-i18n.ts";
|
||
import { NATIVE_I18N_LOCALES } from "../../scripts/native-app-i18n.ts";
|
||
|
||
describe("Apple app i18n catalogs", () => {
|
||
it("keeps generated runtime coverage complete for every native locale", async () => {
|
||
await expect(checkAppleAppI18n()).resolves.toBeUndefined();
|
||
});
|
||
|
||
it("ships translated runtime keys for iOS, watchOS, and explicit localized calls", async () => {
|
||
const catalog = JSON.parse(
|
||
await readFile("apps/ios/Resources/Localizable.xcstrings", "utf8"),
|
||
) as {
|
||
strings: Record<
|
||
string,
|
||
{ localizations?: Record<string, { stringUnit?: { state?: string; value?: string } }> }
|
||
>;
|
||
};
|
||
|
||
for (const key of [
|
||
"^[%lld agent](inflect: true) total",
|
||
"^[%lld approval](inflect: true) waiting",
|
||
"Approval needed",
|
||
"Agent: %@",
|
||
"Connect a nearby Gateway",
|
||
"Direct mode supports device info, status, and notifications. Chat, Talk, and approvals still use the iPhone.",
|
||
"Expires in %@",
|
||
"Location Services are off in iOS Settings.",
|
||
"Message Routing",
|
||
"No cards in %@",
|
||
"No proposals in %@",
|
||
"Pending review",
|
||
"Secure connection is required for this host.",
|
||
"TLS required",
|
||
"Use only on a trusted private network.",
|
||
]) {
|
||
const entry = catalog.strings[key];
|
||
expect(entry, key).toBeDefined();
|
||
const localizedValues: string[] = [];
|
||
for (const locale of ["en", ...APPLE_I18N_LOCALES]) {
|
||
const unit = entry?.localizations?.[locale]?.stringUnit;
|
||
expect(unit?.value, `${key}:${locale}`).toBeTruthy();
|
||
if (locale !== "en" && unit?.value) {
|
||
localizedValues.push(unit.value);
|
||
}
|
||
}
|
||
expect(
|
||
localizedValues.some((value) => value !== key),
|
||
key,
|
||
).toBe(true);
|
||
}
|
||
});
|
||
|
||
it("keeps the Apple and native shipped locale sets identical", () => {
|
||
expect(APPLE_I18N_LOCALES).toEqual(NATIVE_I18N_LOCALES);
|
||
});
|
||
|
||
it("selects duplicate-source translations deterministically while preserving shipped translations", () => {
|
||
const build = buildIosCatalog(
|
||
{
|
||
sourceLanguage: "en",
|
||
strings: {
|
||
"Connect now": {
|
||
localizations: {
|
||
de: { stringUnit: { state: "translated", value: "Jetzt verbinden" } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
version: 1,
|
||
entries: [
|
||
{
|
||
id: "native.apple.a",
|
||
kind: "ui-call",
|
||
line: 1,
|
||
path: "apps/ios/Sources/Example.swift",
|
||
source: "Connect now",
|
||
surface: "apple",
|
||
},
|
||
{
|
||
id: "native.apple.b",
|
||
kind: "ui-call",
|
||
line: 2,
|
||
path: "apps/ios/Sources/Other.swift",
|
||
source: "Connect now",
|
||
surface: "apple",
|
||
},
|
||
{
|
||
id: "native.apple.c",
|
||
kind: "ui-call",
|
||
line: 3,
|
||
path: "apps/ios/WatchApp/Sources/Example.swift",
|
||
source: "Connect now",
|
||
surface: "apple",
|
||
},
|
||
],
|
||
},
|
||
[
|
||
{
|
||
version: 1,
|
||
locale: "fr",
|
||
entries: [
|
||
{ id: "native.apple.a", source: "Connect now", translated: "Se connecter" },
|
||
{ id: "native.apple.b", source: "Connect now", translated: "Connexion" },
|
||
{ id: "native.apple.c", source: "Connect now", translated: "Se connecter" },
|
||
],
|
||
},
|
||
],
|
||
);
|
||
|
||
expect(build.catalog.strings?.["Connect now"]?.localizations?.de?.stringUnit?.value).toBe(
|
||
"Jetzt verbinden",
|
||
);
|
||
expect(build.catalog.version).toBe("1.0");
|
||
expect(build.catalog.strings?.["Connect now"]?.localizations?.fr?.stringUnit?.value).toBe(
|
||
"Se connecter",
|
||
);
|
||
expect(build.catalog.strings?.["Connect now"]?.localizations?.fr?.stringUnit?.state).toBe(
|
||
"translated",
|
||
);
|
||
expect(build.catalog.strings?.["Connect now"]?.localizations?.es?.stringUnit).toEqual({
|
||
state: "new",
|
||
value: "Connect now",
|
||
});
|
||
expect(build.contradictions).toEqual([
|
||
{
|
||
locale: "fr",
|
||
source: "Connect now",
|
||
translations: ["Connexion", "Se connecter"],
|
||
},
|
||
]);
|
||
|
||
const refreshed = buildIosCatalog(
|
||
build.catalog,
|
||
{
|
||
version: 1,
|
||
entries: [
|
||
{
|
||
id: "native.apple.a",
|
||
kind: "ui-call",
|
||
line: 1,
|
||
path: "apps/ios/Sources/Example.swift",
|
||
source: "Connect now",
|
||
surface: "apple",
|
||
},
|
||
],
|
||
},
|
||
[
|
||
{
|
||
version: 1,
|
||
locale: "de",
|
||
entries: [{ id: "native.apple.a", source: "Connect now", translated: "Neu verbinden" }],
|
||
},
|
||
{
|
||
version: 1,
|
||
locale: "fr",
|
||
entries: [{ id: "native.apple.a", source: "Connect now", translated: "Connectez-vous" }],
|
||
},
|
||
],
|
||
);
|
||
expect(refreshed.catalog.strings?.["Connect now"]?.localizations?.de?.stringUnit?.value).toBe(
|
||
"Jetzt verbinden",
|
||
);
|
||
expect(refreshed.catalog.strings?.["Connect now"]?.localizations?.fr?.stringUnit).toEqual({
|
||
state: "translated",
|
||
value: "Se connecter",
|
||
});
|
||
});
|
||
|
||
it("uses code-unit ordering for canonically equivalent translations", () => {
|
||
const source = "Resume";
|
||
const decomposed = "Re\u0301sume\u0301";
|
||
const composed = "Résumé";
|
||
const build = buildIosCatalog(
|
||
{ sourceLanguage: "en", strings: {} },
|
||
{
|
||
version: 1,
|
||
entries: [
|
||
{
|
||
id: "native.apple.resume-a",
|
||
kind: "ui-call",
|
||
line: 1,
|
||
path: "apps/ios/Sources/Example.swift",
|
||
source,
|
||
surface: "apple",
|
||
},
|
||
{
|
||
id: "native.apple.resume-b",
|
||
kind: "ui-call",
|
||
line: 2,
|
||
path: "apps/ios/Sources/Other.swift",
|
||
source,
|
||
surface: "apple",
|
||
},
|
||
],
|
||
},
|
||
[
|
||
{
|
||
version: 1,
|
||
locale: "fr",
|
||
entries: [
|
||
{ id: "native.apple.resume-a", source, translated: composed },
|
||
{ id: "native.apple.resume-b", source, translated: decomposed },
|
||
],
|
||
},
|
||
],
|
||
);
|
||
|
||
expect(build.catalog.strings?.[source]?.localizations?.fr?.stringUnit?.value).toBe(decomposed);
|
||
expect(build.contradictions[0]?.translations).toEqual([decomposed, composed]);
|
||
});
|
||
|
||
it("converts inflected Swift count resources into typed catalog placeholders", () => {
|
||
const source = "^[\\(count) entry](inflect: true)";
|
||
const translated = "^[\\(count) Eintrag](inflect: true)";
|
||
const build = buildIosCatalog(
|
||
{ sourceLanguage: "en", strings: {} },
|
||
{
|
||
version: 1,
|
||
entries: [
|
||
{
|
||
id: "native.apple.count",
|
||
kind: "ui-localized-call",
|
||
line: 1,
|
||
path: "apps/ios/Sources/Example.swift",
|
||
source,
|
||
surface: "apple",
|
||
},
|
||
],
|
||
},
|
||
[
|
||
{
|
||
version: 1,
|
||
locale: "de",
|
||
entries: [{ id: "native.apple.count", source, translated }],
|
||
},
|
||
],
|
||
);
|
||
|
||
const key = "^[%lld entry](inflect: true)";
|
||
expect(build.catalog.strings?.[key]?.localizations?.en?.stringUnit?.value).toBe(key);
|
||
expect(build.catalog.strings?.[key]?.localizations?.de?.stringUnit?.value).toBe(
|
||
"^[%lld Eintrag](inflect: true)",
|
||
);
|
||
});
|
||
|
||
it("rejects mixed inflected resources whose placeholder types are ambiguous", () => {
|
||
const source = "\\(name) has ^[\\(count) entry](inflect: true)";
|
||
const build = buildIosCatalog(
|
||
{ sourceLanguage: "en", strings: {} },
|
||
{
|
||
version: 1,
|
||
entries: [
|
||
{
|
||
id: "native.apple.mixed-count",
|
||
kind: "ui-localized-call",
|
||
line: 1,
|
||
path: "apps/ios/Sources/Example.swift",
|
||
source,
|
||
surface: "apple",
|
||
},
|
||
],
|
||
},
|
||
[],
|
||
);
|
||
|
||
expect(build.catalog.strings).toEqual({});
|
||
});
|
||
|
||
it("keeps custom component text on explicit localized or verbatim paths", async () => {
|
||
const design = await readFile("apps/ios/Sources/Design/OpenClawProComponents.swift", "utf8");
|
||
const agentOverview = await readFile(
|
||
"apps/ios/Sources/Design/AgentProTab+Overview.swift",
|
||
"utf8",
|
||
);
|
||
const settingsActions = await readFile(
|
||
"apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||
"utf8",
|
||
);
|
||
const settingsSections = await readFile(
|
||
"apps/ios/Sources/Design/SettingsProTabSections.swift",
|
||
"utf8",
|
||
);
|
||
const gatewayCapabilities = await readFile(
|
||
"apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift",
|
||
"utf8",
|
||
);
|
||
const talkMode = await readFile("apps/ios/Sources/Voice/TalkModeManager.swift", "utf8");
|
||
const voiceWake = await readFile("apps/ios/Sources/Voice/VoiceWakeManager.swift", "utf8");
|
||
const settings = await readFile("apps/ios/Sources/Design/SettingsProTabSupport.swift", "utf8");
|
||
const watch = await readFile("apps/ios/WatchApp/Sources/WatchInboxView.swift", "utf8");
|
||
const watchDirect = await readFile("apps/ios/WatchApp/Sources/WatchDirectNode.swift", "utf8");
|
||
|
||
expect(design).toContain(
|
||
"struct ProStatusRow: View {\n let icon: String\n let title: OpenClawTextValue\n let detail: OpenClawTextValue",
|
||
);
|
||
expect(design).not.toContain(
|
||
"struct ProStatusRow: View {\n let icon: String\n let title: String",
|
||
);
|
||
expect(watch).toContain(
|
||
"private struct WatchHeroCard: View {\n let label: WatchTextValue\n let title: WatchTextValue\n let subtitle: WatchTextValue",
|
||
);
|
||
expect(watch).toContain("case localized(LocalizedStringResource)");
|
||
expect(watch).not.toContain("WatchTextValue: ExpressibleByStringLiteral");
|
||
expect(watch).toContain("accessory: .verbatim(self.store.talkSummaryText)");
|
||
expect(watch).toContain("title: .verbatim(record.approval.commandPreview");
|
||
expect(settings).toContain(
|
||
"let title: OpenClawTextValue\n let detail: OpenClawTextValue\n let priority: OpenClawTextValue",
|
||
);
|
||
expect(settings).toContain(
|
||
"struct SettingsDetailRow: View {\n let label: LocalizedStringKey\n let value: OpenClawTextValue",
|
||
);
|
||
expect(settings).toContain("self.value.text");
|
||
expect(settings).not.toContain("Text(self.item.title)");
|
||
expect(agentOverview).toContain(
|
||
"func metricTile(\n icon: String,\n title: OpenClawTextValue,\n value: String,\n detail: OpenClawTextValue",
|
||
);
|
||
expect(settingsActions).toContain(
|
||
"func diagnosticCheckRow(\n icon: String,\n title: OpenClawTextValue,\n detail: OpenClawTextValue,\n value: OpenClawTextValue",
|
||
);
|
||
expect(settingsSections).toContain("func settingsToggle(\n _ title: LocalizedStringKey");
|
||
expect(settingsSections).toContain(
|
||
"func gatewaySecureField(\n _ placeholder: LocalizedStringKey",
|
||
);
|
||
expect(gatewayCapabilities).toContain(
|
||
'String(localized: "Secure connection is required for this host.")',
|
||
);
|
||
expect(talkMode).not.toContain('self.statusText = "');
|
||
expect(voiceWake).not.toContain('self.statusText = "');
|
||
expect(watch).toContain('format: String(localized: "Expires in %@")');
|
||
expect(watch).not.toContain('parts.append("Expires in \\(expiresText)")');
|
||
expect(watchDirect).not.toContain('self.statusText = "');
|
||
});
|
||
|
||
it("rejects interpolated runtime copy across every supported Swift syntax", () => {
|
||
const source = String.raw`
|
||
let key = LocalizedStringKey("Hello \(name)")
|
||
let detail = String(localized: """
|
||
Welcome \(name)
|
||
""")
|
||
Toggle("Enable \(feature)", isOn: $enabled)
|
||
Menu("""
|
||
Open \(item)
|
||
""") {}
|
||
view.accessibilityHint("""
|
||
Select \(item)
|
||
""")
|
||
`;
|
||
|
||
expect(findAmbiguousRuntimeInterpolations(source)).toEqual([
|
||
"interpolated localized resource",
|
||
"interpolated multiline localized resource",
|
||
"interpolated SwiftUI text literal",
|
||
"interpolated multiline SwiftUI text literal",
|
||
"interpolated multiline SwiftUI modifier literal",
|
||
]);
|
||
});
|
||
|
||
it("generates InfoPlist localizations for every shipped iOS target", async () => {
|
||
const french = await readFile("apps/ios/Sources/fr.lproj/InfoPlist.strings", "utf8");
|
||
const watchChinese = await readFile(
|
||
"apps/ios/WatchApp/zh-Hans.lproj/InfoPlist.strings",
|
||
"utf8",
|
||
);
|
||
const shareGerman = await readFile(
|
||
"apps/ios/ShareExtension/de.lproj/InfoPlist.strings",
|
||
"utf8",
|
||
);
|
||
const activityJapanese = await readFile(
|
||
"apps/ios/ActivityWidget/ja.lproj/InfoPlist.strings",
|
||
"utf8",
|
||
);
|
||
|
||
expect(french).toContain('"NSCameraUsageDescription" = ');
|
||
expect(french).toContain('"NSMicrophoneUsageDescription" = ');
|
||
expect(watchChinese).toContain('"NSLocalNetworkUsageDescription" = ');
|
||
expect(shareGerman).toContain('"CFBundleDisplayName" = "OpenClaw Share";');
|
||
expect(activityJapanese).toContain('"CFBundleDisplayName" = "OpenClaw Activity";');
|
||
});
|
||
|
||
it("refreshes InfoPlist copy from translations for the current source", () => {
|
||
expect(
|
||
selectInfoPlistTranslation(
|
||
"Use the camera to scan setup codes.",
|
||
["Utilisez l’appareil photo pour scanner les codes de configuration."],
|
||
{
|
||
source: "Old camera purpose.",
|
||
value: "Ancienne description de la caméra.",
|
||
},
|
||
),
|
||
).toBe("Utilisez l’appareil photo pour scanner les codes de configuration.");
|
||
expect(
|
||
selectInfoPlistTranslation("OpenClaw Share", [], {
|
||
source: "OpenClaw Share",
|
||
value: "OpenClaw Partager",
|
||
}),
|
||
).toBe("OpenClaw Partager");
|
||
expect(
|
||
selectInfoPlistTranslation(
|
||
"Use the camera to scan setup codes.",
|
||
["Use the camera to scan setup codes."],
|
||
{
|
||
source: "Use the camera to scan setup codes.",
|
||
value: "Utilisez l’appareil photo pour scanner les codes de configuration.",
|
||
},
|
||
),
|
||
).toBe("Utilisez l’appareil photo pour scanner les codes de configuration.");
|
||
expect(
|
||
selectInfoPlistTranslation("Use the camera for video calls.", [], {
|
||
source: "Use the camera to scan setup codes.",
|
||
value: "Utilisez l’appareil photo pour scanner les codes de configuration.",
|
||
}),
|
||
).toBe("Use the camera for video calls.");
|
||
});
|
||
|
||
it("selects InfoPlist candidates by stable ID instead of shared source text", () => {
|
||
const source = "Use the camera to scan setup codes.";
|
||
const artifact = {
|
||
version: 1,
|
||
locale: "fr",
|
||
entries: [
|
||
{
|
||
id: "native.apple.camera",
|
||
source,
|
||
translated: "Utilisez l’appareil photo pour scanner les codes de configuration.",
|
||
},
|
||
{
|
||
id: "native.apple.unrelated",
|
||
source,
|
||
translated: "Traduction pour un autre contexte.",
|
||
},
|
||
],
|
||
};
|
||
|
||
expect(infoPlistTranslationCandidates(artifact, "native.apple.camera", source)).toEqual([
|
||
"Utilisez l’appareil photo pour scanner les codes de configuration.",
|
||
]);
|
||
});
|
||
|
||
it("compiles macOS catalogs into app-bundle localization directories", async () => {
|
||
const outputDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-apple-i18n-"));
|
||
try {
|
||
await compileMacosLocalizations(outputDir);
|
||
const swedish = await readFile(
|
||
path.join(outputDir, "sv.lproj", "Localizable.strings"),
|
||
"utf8",
|
||
);
|
||
expect(swedish).toContain('"Logout" = "Logga ut";');
|
||
await expect(
|
||
readFile(path.join(outputDir, "zh-Hans.lproj", "Localizable.strings"), "utf8"),
|
||
).resolves.toContain('"Save" = ');
|
||
await expect(
|
||
readFile(path.join(outputDir, "ja.lproj", "Localizable.strings"), "utf8"),
|
||
).resolves.toContain('"Run now" = ');
|
||
} finally {
|
||
await rm(outputDir, { force: true, recursive: true });
|
||
}
|
||
});
|
||
});
|