Files
openclaw/test/scripts/native-app-i18n.test.ts
Jesse Merhi 64318d7624 Rework Android gateway onboarding setup (#98752)
* feat(android): rework gateway onboarding setup

* fix(android): restore protocol mismatch onboarding guidance

* fix(android): sync onboarding native i18n

* fix(android): preserve LAN manual setup prefill

* fix(android): refine onboarding node approval step

* fix(android): polish onboarding recovery actions

* test(android): cover onboarding protocol mismatch copy

* fix(android): separate onboarding node approval

* fix(android): guard onboarding node approval transitions

* fix(android): keep onboarding approval states reachable

* fix(android): wait for node access before onboarding continue

* fix(android): refresh node approval after operator handoff

* fix(android): refresh onboarding approval states

* fix(android): require approval after onboarding permission changes

* fix(android): keep onboarding approval gates active

* fix(android): preserve permission reapproval state

* fix(android): skip node approval on legacy gateways

* fix(android): wait for node approval refresh

* fix(android): preserve camera setting on upgrade

* fix(android): avoid stuck approval check spinner

* fix(android): request talk secrets on operator connects

* fix(android): avoid missed node approval completion

* fix(android): keep nearby LAN setup local

* fix(android): complete onboarding after node approval

* fix(android): reconcile onboarding with gateway auth plans

* chore(android): refresh native i18n inventory after rebase

* Fix Android onboarding review edge cases

* Fix native i18n onboarding sentinels
2026-07-03 21:20:12 +10:00

314 lines
12 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 === "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 === "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",
);
});
});