Files
openclaw/test/scripts/native-app-i18n.test.ts
Peter Steinberger 62bd760c0e chore(swift): enforce current formatting and lint rules (#103313)
* style: apply SwiftFormat 0.62.1 rules

Refs #103202

* ci: enforce deterministic Swift lint

Refs #103202

* refactor: keep gateway connect lint-clean

Refs #103202

* style: keep iOS typography checks warning-free

* ci: route MLX Swift changes through pre-push

* fix: preserve native i18n extraction after Swift cleanup

* refactor: keep rebased Swift surfaces lint-clean

* style: format latest Swift additions

* chore: refresh native i18n inventory

* style: keep generated Swift formatter-clean

* fix: preserve node route invalidation callbacks

* fix: keep native translation IDs stable

* fix: retain native translation identifiers

* fix: preserve translations across Swift source moves
2026-07-10 11:54:08 +01:00

560 lines
20 KiB
TypeScript

import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
assignNativeI18nIds,
collectNativeI18nEntries,
isConditionalBranchIdentifier,
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("keeps IDs stable across extractor classification changes", () => {
const candidate = {
kind: "ui-call",
line: 10,
path: "apps/ios/example.swift",
source: "Gateway status",
surface: "apple" as const,
};
const initial = assignNativeI18nIds([candidate]);
const reclassified = { ...candidate, kind: "ui-call-multiline", line: 20 };
expect(assignNativeI18nIds([reclassified])[0]?.id).toBe(initial[0]?.id);
expect(
assignNativeI18nIds(
[reclassified],
[{ ...candidate, id: "native.apple.existing-translation" }],
)[0]?.id,
).toBe("native.apple.existing-translation");
});
it("preserves registered IDs when Swift entries move between files", async () => {
const entries = await collectNativeI18nEntries();
const idsByLocation = new Map(
entries.map((entry) => [`${entry.path}\0${entry.source}`, entry.id]),
);
const onboardingPath = "apps/ios/Sources/Onboarding/OnboardingWizardConnectionSections.swift";
const sendingPath =
"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Sending.swift";
const movedEntries = [
{ id: "native.apple.95e2c98254da2aba", path: onboardingPath, source: "Home Network" },
{
id: "native.apple.d9a6d673aa6693ee",
path: onboardingPath,
source: "LAN or Tailscale host",
},
{ id: "native.apple.431d02f8b68a96cf", path: onboardingPath, source: "Remote Domain" },
{ id: "native.apple.7021301971f631bf", path: onboardingPath, source: "VPS with domain" },
{
id: "native.apple.7451f8d052016642",
path: onboardingPath,
source: "Same Machine (Dev)",
},
{
id: "native.apple.22e740296a762256",
path: onboardingPath,
source: "For local iOS app development",
},
{
id: "native.apple.e1b1ccbfc9e73df8",
path: onboardingPath,
source: "Manual Connection",
},
{ id: "native.apple.b7dc527c2a7e95cb", path: onboardingPath, source: "Continue" },
{
id: "native.apple.93d3e17fabd5e082",
path: onboardingPath,
source: "Developer mode",
},
{
id: "native.apple.e8b90e582100294d",
path: onboardingPath,
source: "Connection Failed",
},
{
id: "native.apple.9e208d090ce2e84f",
path: onboardingPath,
source: "Needs attention",
},
{
id: "native.apple.e71e20089bcc4cfb",
path: onboardingPath,
source: "Ready to Connect",
},
{ id: "native.apple.cf616b515da5bc19", path: onboardingPath, source: "Security" },
{
id: "native.apple.4014217851d06190",
path: onboardingPath,
source: "Plaintext (local network)",
},
{
id: "native.apple.db7b52a1bbc6fac5",
path: onboardingPath,
source: "Use Manual Setup",
},
{ id: "native.apple.7dbdf9a439f64f08", path: onboardingPath, source: "Setup Link" },
{
id: "native.apple.6bfb611862fb1687",
path: onboardingPath,
source:
"Plaintext may expose credentials. Continue only if you trust this local network and host.",
},
{
id: "native.apple.3329c7f367f10c78",
path: onboardingPath,
source: "Review this endpoint. Credentials are applied only after you tap Connect.",
},
{
id: "native.apple.2f00ef4bc35ecb8d",
path: onboardingPath,
source: "No gateways found yet.",
},
{
id: "native.apple.94c9697fb748d05d",
path: onboardingPath,
source: "Restart Discovery",
},
{
id: "native.apple.07ebd3b75969629f",
path: onboardingPath,
source: "Discovered Gateways",
},
{
id: "native.apple.2b45abdc56b2caed",
path: sendingPath,
source: "delivery unconfirmed",
},
{
id: "native.apple.722f1f90b97e8e45",
path: sendingPath,
source: "queued after route change",
},
];
for (const entry of movedEntries) {
expect(idsByLocation.get(`${entry.path}\0${entry.source}`)).toBe(entry.id);
}
});
it("detects conditional branch identifiers without regex backtracking", () => {
expect(isConditionalBranchIdentifier("isEnabled")).toBe(true);
expect(isConditionalBranchIdentifier("hasFA2Enabled")).toBe(true);
expect(isConditionalBranchIdentifier("abc123A")).toBe(false);
expect(isConditionalBranchIdentifier("already_lowercase")).toBe(false);
expect(isConditionalBranchIdentifier(`a${"A".repeat(4_096)}!`)).toBe(false);
});
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 === "Preview · $domain")).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 === "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 === "Needs setup")).toBe(true);
expect(
entries.some(
(entry) =>
entry.source === "Choose a supported ${issue.target.title} provider on the Gateway",
),
).toBe(true);
expect(
entries.some(
(entry) => entry.source === "Talk failed: Realtime provider closed unexpectedly.",
),
).toBe(true);
expect(entries.some((entry) => entry.source === "Scan QR code")).toBe(true);
expect(entries.some((entry) => entry.source === "Test connection")).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.surface === "android" &&
entry.kind === "ui-named-argument" &&
entry.source === "Search OpenClaw",
),
).toBe(true);
expect(
entries.some(
(entry) =>
entry.path.endsWith("/ChatMessageActions.kt") &&
entry.kind === "ui-named-argument" &&
entry.source === "Message actions",
),
).toBe(true);
expect(
entries.some(
(entry) =>
entry.path.endsWith("/ChatMessageActions.kt") &&
entry.kind === "ui-named-argument" &&
entry.source === "Reply",
),
).toBe(true);
expect(
entries.some(
(entry) =>
entry.path.endsWith("/ChatMessageActions.kt") &&
entry.kind === "ui-chooser" &&
entry.source === "Share message",
),
).toBe(true);
expect(entries.some((entry) => entry.source === "What would you like to work on?")).toBe(true);
expect(entries.some((entry) => entry.source === "Check OpenClaw status")).toBe(true);
expect(entries.some((entry) => entry.source === "What can I control here?")).toBe(true);
expect(entries.some((entry) => entry.source === "Help me start voice chat")).toBe(true);
expect(
entries.some(
(entry) =>
entry.source ===
"Summarize the current OpenClaw status and tell me what needs attention.",
),
).toBe(true);
expect(
entries.some(
(entry) =>
entry.source ===
"Show me which phone controls and device capabilities are available right now.",
),
).toBe(true);
expect(
entries.some(
(entry) => entry.source === "Help me start a realtime voice session from this phone.",
),
).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.path.endsWith("/ChatSheets.swift") &&
entry.kind === "ui-named-argument" &&
entry.source === "Search sessions",
),
).toBe(true);
expect(entries.some((entry) => entry.source === "Don't 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.path === "apps/ios/Sources/Gateway/GatewayConnectionController.swift" &&
entry.kind === "ui-localized-call-multiline" &&
entry.source ===
"Enable Gateway TLS, or enter your Tailscale Serve HTTPS host in Manual Setup. Use Unencrypted only with a trusted private-LAN address.",
),
).toBe(true);
expect(
entries.some(
(entry) =>
entry.path === "apps/ios/Sources/Gateway/GatewayConnectionController.swift" &&
entry.kind === "ui-localized-call-multiline" &&
entry.source ===
"Can't reach gateway at \\(host):\\(port). Verify Tailscale Serve is enabled and publishes this Gateway.",
),
).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.source ===
"Direct mode supports device info, status, and notifications. Chat, Talk, and approvals still use the iPhone.",
),
).toBe(true);
expect(
entries.some(
(entry) =>
entry.source ===
"The watch receives a one-time pairing code and stores its own device token. A reachable secure Gateway URL is required away from the iPhone.",
),
).toBe(true);
expect(
entries.some(
(entry) =>
entry.source ===
"Let an authorized agent move the pointer, click, and type on this Mac. Also requires Accessibility, Screen Recording, and gateway command authorization. High risk.",
),
).toBe(true);
expect(
entries.some(
(entry) =>
entry.source ===
"The details are listed on each option above. You can fix the login and retry, or connect with an API key or token below.",
),
).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",
);
});
});