Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
346 lines
13 KiB
TypeScript
346 lines
13 KiB
TypeScript
import { readFile, stat } from "node:fs/promises";
|
||
import path from "node:path";
|
||
import { describe, expect, it } from "vitest";
|
||
import {
|
||
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("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 === "$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 === "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.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.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.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",
|
||
);
|
||
});
|
||
});
|