Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions apps/memos-local-plugin/tests/unit/viewer/i18n-detect-default.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { detectDefault } from "../../../viewer/src/stores/i18n";

/**
* Regression coverage for the module-level default-locale detector.
*
* Under Node (Vitest / SSR / CI without jsdom) `navigator` is undefined
* and `localStorage` access throws (swallowed by the module's try/catch).
* Before #2346 this made the `zh-*` → "zh" branch unreachable from unit
* tests — the only way to assert zh strings was to mutate `locale.value`
* directly. `detectDefault` is now exported and takes an options bag so
* both branches are reachable from Node.
*/

type Store = Map<string, string>;

/** Minimal Storage stub matching the shape `detectDefault` uses. */
function makeStorage(initial?: Record<string, string>): Storage {
const map: Store = new Map(Object.entries(initial ?? {}));
return {
get length() {
return map.size;
},
clear: () => map.clear(),
getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null),
setItem: (k: string, v: string) => {
map.set(k, String(v));
},
removeItem: (k: string) => {
map.delete(k);
},
key: (i: number) => Array.from(map.keys())[i] ?? null,
};
}

/** Storage stub whose getItem throws — mirrors browser private-mode / SSR. */
function makeThrowingStorage(): Storage {
return {
get length() {
return 0;
},
clear: () => {
throw new Error("no storage");
},
getItem: () => {
throw new Error("no storage");
},
setItem: () => {
throw new Error("no storage");
},
removeItem: () => {
throw new Error("no storage");
},
key: () => null,
};
}

describe("detectDefault (i18n locale detector)", () => {
const originalNavigator = (globalThis as { navigator?: unknown }).navigator;
const originalLocalStorage = (globalThis as { localStorage?: unknown })
.localStorage;

beforeEach(() => {
// Start each test with a clean slate — no globals leaked in.
delete (globalThis as { navigator?: unknown }).navigator;
delete (globalThis as { localStorage?: unknown }).localStorage;
});

afterEach(() => {
if (originalNavigator === undefined) {
delete (globalThis as { navigator?: unknown }).navigator;
} else {
(globalThis as { navigator?: unknown }).navigator = originalNavigator;
}
if (originalLocalStorage === undefined) {
delete (globalThis as { localStorage?: unknown }).localStorage;
} else {
(globalThis as { localStorage?: unknown }).localStorage =
originalLocalStorage;
}
});

describe("via injected options", () => {
it("returns the saved value from injected storage when it is 'zh'", () => {
const storage = makeStorage({ "memos.lang": "zh" });
expect(detectDefault({ storage })).toBe("zh");
});

it("returns the saved value from injected storage when it is 'en'", () => {
const storage = makeStorage({ "memos.lang": "en" });
expect(detectDefault({ storage })).toBe("en");
});

it("falls back to navLanguage when injected storage has no saved value", () => {
const storage = makeStorage();
expect(detectDefault({ storage, navLanguage: "zh-CN" })).toBe("zh");
expect(detectDefault({ storage, navLanguage: "en-US" })).toBe("en");
});

it("ignores unrecognised saved values and falls back to navLanguage", () => {
const storage = makeStorage({ "memos.lang": "fr" });
expect(detectDefault({ storage, navLanguage: "zh-TW" })).toBe("zh");
});

it("maps zh-* language tags case-insensitively to 'zh'", () => {
// This is the branch that was previously untestable under Node.
expect(detectDefault({ navLanguage: "zh" })).toBe("zh");
expect(detectDefault({ navLanguage: "zh-CN" })).toBe("zh");
expect(detectDefault({ navLanguage: "zh-Hans" })).toBe("zh");
expect(detectDefault({ navLanguage: "ZH-TW" })).toBe("zh");
expect(detectDefault({ navLanguage: "zh-hant-hk" })).toBe("zh");
});

it("returns 'en' for non-zh navLanguages", () => {
expect(detectDefault({ navLanguage: "en-US" })).toBe("en");
expect(detectDefault({ navLanguage: "fr-FR" })).toBe("en");
expect(detectDefault({ navLanguage: "ja-JP" })).toBe("en");
expect(detectDefault({ navLanguage: "" })).toBe("en");
});

it("honours a custom storageKey", () => {
const storage = makeStorage({ "custom.lang": "zh" });
expect(
detectDefault({ storage, storageKey: "custom.lang" }),
).toBe("zh");
expect(
detectDefault({ storage, storageKey: "memos.lang" }),
).toBe("en");
});

it("survives a throwing storage — falls back to navLanguage", () => {
const storage = makeThrowingStorage();
expect(detectDefault({ storage, navLanguage: "zh-CN" })).toBe("zh");
expect(detectDefault({ storage, navLanguage: "en-US" })).toBe("en");
});

it("returns 'en' when no options and no globals are available", () => {
// Bare Node — same environment the current test file runs in.
expect(detectDefault()).toBe("en");
});
});

describe("via global fallbacks (browser-shaped host)", () => {
it("picks up navigator.language when no options passed", () => {
(globalThis as { navigator?: { language: string } }).navigator = {
language: "zh-CN",
};
expect(detectDefault()).toBe("zh");
});

it("picks up localStorage when no options passed", () => {
(globalThis as { localStorage?: Storage }).localStorage = makeStorage({
"memos.lang": "zh",
});
expect(detectDefault()).toBe("zh");
});

it("prefers saved localStorage value over navigator.language", () => {
(globalThis as { navigator?: { language: string } }).navigator = {
language: "en-US",
};
(globalThis as { localStorage?: Storage }).localStorage = makeStorage({
"memos.lang": "zh",
});
expect(detectDefault()).toBe("zh");
});

it("prefers explicit options over ambient globals", () => {
(globalThis as { navigator?: { language: string } }).navigator = {
language: "en-US",
};
expect(detectDefault({ navLanguage: "zh-CN" })).toBe("zh");
});
});
});
95 changes: 88 additions & 7 deletions apps/memos-local-plugin/viewer/src/stores/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* interpolation trivial.
* - Language preference persists in localStorage; default language
* is inferred from `navigator.language` (zh-* → zh, else en).
* `detectDefault` is exported and accepts an options bag so tests
* and SSR hosts can drive both branches without stubbing globals.
* - Uses @preact/signals so components re-render automatically on
* language switch without subscription plumbing.
*
Expand Down Expand Up @@ -1809,14 +1811,93 @@ export type Locale = "en" | "zh";

const STORAGE_KEY = "memos.lang";

function detectDefault(): Locale {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === "en" || saved === "zh") return saved;
} catch {
// ignore
/**
* Options for {@link detectDefault}. Every field is optional — omitted
* fields fall back to the ambient browser globals (`navigator.language`,
* `globalThis.localStorage`), matching the historical behaviour of this
* module exactly. Callers under Node (Vitest / SSR) can inject a stub
* storage or an explicit `navLanguage` to exercise both branches without
* touching global state.
*
* See issue #2346 — before injection was available, the `zh-*` branch
* was unreachable from unit tests because Node has no `navigator` and
* `localStorage` throws.
*/
export interface DetectDefaultOptions {
/**
* BCP-47 language tag (e.g. `"zh-CN"`, `"en-US"`). When omitted the
* detector reads `navigator.language`, or falls back to `"en"` if
* `navigator` is undefined (as it is under Node).
*/
navLanguage?: string;
/**
* Storage object to consult for a previously saved locale. Semantics
* are asymmetric on purpose:
*
* - **omitted** (`undefined`): read ambient `globalThis.localStorage`
* (and silently fall back to `navLanguage` if that access throws –
* private mode, SSR, …). Pass a `Map`-backed stub in tests to
* simulate an empty or pre-populated store.
* - **`null`**: explicit "disable storage" sentinel. The detector
* skips storage lookup entirely and jumps straight to
* `navLanguage`. Use this in tests that want to exercise the
* navigator-only branch without providing a stub.
*
* If you want a no-op stub instead of disabling the lookup, pass an
* object whose `getItem` always returns `null` (e.g. `{ getItem: () => null }`)
* rather than `null`.
*/
storage?: Pick<Storage, "getItem"> | null;
/** Key used to read the saved locale from `storage`. */
storageKey?: string;
}

/**
* Compute the default UI locale.
*
* Priority: injected/ambient `storage[storageKey]` (if it's `"en"` or
* `"zh"`) → `navLanguage` (zh-* → `"zh"`, else `"en"`) → `"en"`.
*
* The function is deliberately pure w.r.t. its arguments so tests can
* assert on both branches without mutating global state. When called
* with no arguments (as the module does at import time) it behaves
* exactly as it always has in the browser.
*
* @internal Exported for unit tests only. Not a stable public API —
* consumers outside this module should not depend on the signature.
*
* NOTE: `setLocale` writes back to the ambient `localStorage`, not to
* an injected storage stub. The `opts.storage` seam only covers the
* read path during initial detection; a test that exercises
* `detectDefault` with a `Map`-backed stub will not observe writes
* made by `setLocale`.
*/
export function detectDefault(opts: DetectDefaultOptions = {}): Locale {
const storageKey = opts.storageKey ?? STORAGE_KEY;
let storage: Pick<Storage, "getItem"> | null;
if (opts.storage !== undefined) {
storage = opts.storage;
} else if (
typeof globalThis !== "undefined" &&
(globalThis as { localStorage?: Storage }).localStorage
) {
storage = (globalThis as { localStorage: Storage }).localStorage;
} else {
storage = null;
}
if (storage) {
try {
const saved = storage.getItem(storageKey);
if (saved === "en" || saved === "zh") return saved;
} catch {
// Access to storage can throw (private mode, SSR). Fall through
// to navLanguage-based detection.
}
}
const nav = (typeof navigator !== "undefined" && navigator.language) || "en";
const nav =
opts.navLanguage !== undefined
? opts.navLanguage
: (typeof navigator !== "undefined" && navigator.language) || "en";
return nav.toLowerCase().startsWith("zh") ? "zh" : "en";
}

Expand Down
Loading