Files
openclaw/test/scripts/native-app-i18n.test.ts
Josh e43c72954a Polish iOS onboarding and chat critique fixes (#99243)
* fix(ios): polish onboarding and empty chat

Co-authored-by: Josh <17843332+jcooley8@users.noreply.github.com>

* fix(ios): correlate pending chat echoes

* fix(ios): ship localized chat starters

* fix(ios): preserve chat echo identity

* fix(ios): adopt late correlated chat echoes

* fix(ios): reconcile reused chat runs

* fix(ios): preserve transformed chat history

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-04 00:38:56 -07:00

346 lines
13 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,
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 === "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",
);
});
});