diff --git a/src/cli/program.test-mocks.ts b/src/cli/program.test-mocks.ts index ab0d6b497bf..cf71122749f 100644 --- a/src/cli/program.test-mocks.ts +++ b/src/cli/program.test-mocks.ts @@ -1,78 +1,104 @@ -import { Mock, vi } from "vitest"; +import { vi, type Mock } from "vitest"; -export const messageCommand: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const statusCommand: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const configureCommand: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const configureCommandWithSections: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const setupCommand: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const onboardCommand: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const callGateway: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const runChannelLogin: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const runChannelLogout: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const runTui: Mock<(...args: unknown[]) => unknown> = vi.fn(); +type AnyMock = Mock<(...args: unknown[]) => unknown>; -export const loadAndMaybeMigrateDoctorConfig: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const ensureConfigReady: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const ensurePluginRegistryLoaded: Mock<(...args: unknown[]) => unknown> = vi.fn(); +const programMocks = vi.hoisted(() => ({ + messageCommand: vi.fn(), + statusCommand: vi.fn(), + configureCommand: vi.fn(), + configureCommandWithSections: vi.fn(), + setupCommand: vi.fn(), + onboardCommand: vi.fn(), + callGateway: vi.fn(), + runChannelLogin: vi.fn(), + runChannelLogout: vi.fn(), + runTui: vi.fn(), + loadAndMaybeMigrateDoctorConfig: vi.fn(), + ensureConfigReady: vi.fn(), + ensurePluginRegistryLoaded: vi.fn(), + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), + }, +})); -export const runtime: { +export const messageCommand = programMocks.messageCommand as AnyMock; +export const statusCommand = programMocks.statusCommand as AnyMock; +export const configureCommand = programMocks.configureCommand as AnyMock; +export const configureCommandWithSections = programMocks.configureCommandWithSections as AnyMock; +export const setupCommand = programMocks.setupCommand as AnyMock; +export const onboardCommand = programMocks.onboardCommand as AnyMock; +export const callGateway = programMocks.callGateway as AnyMock; +export const runChannelLogin = programMocks.runChannelLogin as AnyMock; +export const runChannelLogout = programMocks.runChannelLogout as AnyMock; +export const runTui = programMocks.runTui as AnyMock; +export const loadAndMaybeMigrateDoctorConfig = + programMocks.loadAndMaybeMigrateDoctorConfig as AnyMock; +export const ensureConfigReady = programMocks.ensureConfigReady as AnyMock; +export const ensurePluginRegistryLoaded = programMocks.ensurePluginRegistryLoaded as AnyMock; + +export const runtime = programMocks.runtime as { log: Mock<(...args: unknown[]) => void>; error: Mock<(...args: unknown[]) => void>; exit: Mock<(...args: unknown[]) => never>; -} = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(() => { - throw new Error("exit"); - }), }; -export function installBaseProgramMocks() { - vi.mock("../commands/message.js", () => ({ messageCommand })); - vi.mock("../commands/status.js", () => ({ statusCommand })); - vi.mock("../commands/configure.js", () => ({ - CONFIGURE_WIZARD_SECTIONS: [ - "workspace", - "model", - "web", - "gateway", - "daemon", - "channels", - "skills", - "health", - ], - configureCommand, - configureCommandWithSections, - configureCommandFromSectionsArg: (sections: unknown, runtime: unknown) => { - const resolved = Array.isArray(sections) ? sections : []; - if (resolved.length > 0) { - return configureCommandWithSections(resolved, runtime); - } - return configureCommand({}, runtime); - }, - })); - vi.mock("../commands/setup.js", () => ({ setupCommand })); - vi.mock("../commands/onboard.js", () => ({ onboardCommand })); - vi.mock("../runtime.js", () => ({ defaultRuntime: runtime })); - vi.mock("./channel-auth.js", () => ({ runChannelLogin, runChannelLogout })); - vi.mock("../tui/tui.js", () => ({ runTui })); - vi.mock("../gateway/call.js", () => ({ - callGateway, - randomIdempotencyKey: () => "idem-test", - buildGatewayConnectionDetails: () => ({ - url: "ws://127.0.0.1:1234", - urlSource: "test", - message: "Gateway target: ws://127.0.0.1:1234", - }), - })); - vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) })); -} +// Keep these mocks at top level so Vitest does not warn about hoisted nested mocks. +vi.mock("../commands/message.js", () => ({ messageCommand: programMocks.messageCommand })); +vi.mock("../commands/status.js", () => ({ statusCommand: programMocks.statusCommand })); +vi.mock("../commands/configure.js", () => ({ + CONFIGURE_WIZARD_SECTIONS: [ + "workspace", + "model", + "web", + "gateway", + "daemon", + "channels", + "skills", + "health", + ], + configureCommand: programMocks.configureCommand, + configureCommandWithSections: programMocks.configureCommandWithSections, + configureCommandFromSectionsArg: (sections: unknown, runtime: unknown) => { + const resolved = Array.isArray(sections) ? sections : []; + if (resolved.length > 0) { + return programMocks.configureCommandWithSections(resolved, runtime); + } + return programMocks.configureCommand({}, runtime); + }, +})); +vi.mock("../commands/setup.js", () => ({ setupCommand: programMocks.setupCommand })); +vi.mock("../commands/onboard.js", () => ({ onboardCommand: programMocks.onboardCommand })); +vi.mock("../runtime.js", () => ({ defaultRuntime: programMocks.runtime })); +vi.mock("./channel-auth.js", () => ({ + runChannelLogin: programMocks.runChannelLogin, + runChannelLogout: programMocks.runChannelLogout, +})); +vi.mock("../tui/tui.js", () => ({ runTui: programMocks.runTui })); +vi.mock("../gateway/call.js", () => ({ + callGateway: programMocks.callGateway, + randomIdempotencyKey: () => "idem-test", + buildGatewayConnectionDetails: () => ({ + url: "ws://127.0.0.1:1234", + urlSource: "test", + message: "Gateway target: ws://127.0.0.1:1234", + }), +})); +vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) })); +vi.mock("./plugin-registry.js", () => ({ + ensurePluginRegistryLoaded: programMocks.ensurePluginRegistryLoaded, +})); +vi.mock("../commands/doctor-config-flow.js", () => ({ + loadAndMaybeMigrateDoctorConfig: programMocks.loadAndMaybeMigrateDoctorConfig, +})); +vi.mock("./program/config-guard.js", () => ({ + ensureConfigReady: programMocks.ensureConfigReady, +})); +vi.mock("./preaction.js", () => ({ registerPreActionHooks: () => {} })); -export function installSmokeProgramMocks() { - vi.mock("./plugin-registry.js", () => ({ ensurePluginRegistryLoaded })); - vi.mock("../commands/doctor-config-flow.js", () => ({ - loadAndMaybeMigrateDoctorConfig, - })); - vi.mock("./program/config-guard.js", () => ({ ensureConfigReady })); - vi.mock("./preaction.js", () => ({ registerPreActionHooks: () => {} })); -} +export function installBaseProgramMocks() {} + +export function installSmokeProgramMocks() {} diff --git a/src/infra/warning-filter.test.ts b/src/infra/warning-filter.test.ts index 7ce9854aa9a..da4b9dad163 100644 --- a/src/infra/warning-filter.test.ts +++ b/src/infra/warning-filter.test.ts @@ -74,6 +74,7 @@ describe("warning filter", () => { it("installs once and suppresses known warnings at emit time", async () => { const seenWarnings: Array<{ code?: string; name: string; message: string }> = []; + const stderrWrites: string[] = []; const onWarning = (warning: Error & { code?: string }) => { seenWarnings.push({ code: warning.code, @@ -81,6 +82,12 @@ describe("warning filter", () => { message: warning.message, }); }; + const stderrWriteSpy = vi.spyOn(process.stderr, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + stderrWrites.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }) as typeof process.stderr.write); process.on("warning", onWarning); try { @@ -135,7 +142,9 @@ describe("warning filter", () => { warning.code === "DEP0040" && warning.message === "The punycode module is deprecated.", ), ).toBeDefined(); + expect(stderrWrites.join("")).toContain("Visible warning"); } finally { + stderrWriteSpy.mockRestore(); process.off("warning", onWarning); } }); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 45710ef08bf..d442685a3ff 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -7,13 +7,13 @@ import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { withEnv } from "../test-utils/env.js"; async function importFreshPluginTestModules() { vi.resetModules(); - vi.unmock("node:fs"); - vi.unmock("node:fs/promises"); - vi.unmock("node:module"); - vi.unmock("./hook-runner-global.js"); - vi.unmock("./hooks.js"); - vi.unmock("./loader.js"); - vi.unmock("jiti"); + vi.doUnmock("node:fs"); + vi.doUnmock("node:fs/promises"); + vi.doUnmock("node:module"); + vi.doUnmock("./hook-runner-global.js"); + vi.doUnmock("./hooks.js"); + vi.doUnmock("./loader.js"); + vi.doUnmock("jiti"); const [loader, hookRunnerGlobal, hooks, runtime, registry] = await Promise.all([ import("./loader.js"), import("./hook-runner-global.js"), diff --git a/ui/src/i18n/lib/translate.ts b/ui/src/i18n/lib/translate.ts index fc18f36c8e5..11759bc6d8d 100644 --- a/ui/src/i18n/lib/translate.ts +++ b/ui/src/i18n/lib/translate.ts @@ -1,3 +1,4 @@ +import { getSafeLocalStorage } from "../../local-storage.ts"; import { en } from "../locales/en.ts"; import { DEFAULT_LOCALE, @@ -22,8 +23,8 @@ class I18nManager { } private readStoredLocale(): string | null { - const storage = globalThis.localStorage; - if (!storage || typeof storage.getItem !== "function") { + const storage = getSafeLocalStorage(); + if (!storage) { return null; } try { @@ -34,8 +35,8 @@ class I18nManager { } private persistLocale(locale: Locale) { - const storage = globalThis.localStorage; - if (!storage || typeof storage.setItem !== "function") { + const storage = getSafeLocalStorage(); + if (!storage) { return; } try { diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts index d373d3a47c9..14344b9079b 100644 --- a/ui/src/i18n/test/translate.test.ts +++ b/ui/src/i18n/test/translate.test.ts @@ -92,6 +92,22 @@ describe("i18n", () => { expect(fresh.t("common.health")).toBe("健康状况"); }); + it("skips node localStorage accessors that warn without a storage file", async () => { + vi.resetModules(); + vi.unstubAllGlobals(); + vi.stubGlobal("navigator", { language: "en-US" } as Navigator); + const warningSpy = vi.spyOn(process, "emitWarning"); + + const fresh = await import("../lib/translate.ts"); + + expect(fresh.i18n.getLocale()).toBe("en"); + expect(warningSpy).not.toHaveBeenCalledWith( + "`--localstorage-file` was provided without a valid path", + expect.anything(), + expect.anything(), + ); + }); + it("keeps the version label available in shipped locales", () => { expect((pt_BR.common as { version?: string }).version).toBeTruthy(); expect((zh_CN.common as { version?: string }).version).toBeTruthy(); diff --git a/ui/src/local-storage.ts b/ui/src/local-storage.ts new file mode 100644 index 00000000000..a1e80d9d32a --- /dev/null +++ b/ui/src/local-storage.ts @@ -0,0 +1,25 @@ +function isStorage(value: unknown): value is Storage { + return ( + Boolean(value) && + typeof (value as Storage).getItem === "function" && + typeof (value as Storage).setItem === "function" + ); +} + +export function getSafeLocalStorage(): Storage | null { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + + if (process.env.VITEST) { + return descriptor && !descriptor.get && isStorage(descriptor.value) ? descriptor.value : null; + } + + if (typeof window !== "undefined" && typeof document !== "undefined") { + try { + return isStorage(window.localStorage) ? window.localStorage : null; + } catch { + return null; + } + } + + return descriptor && !descriptor.get && isStorage(descriptor.value) ? descriptor.value : null; +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 328f2cb6e33..11bcacae1ee 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -4,6 +4,7 @@ import { parseAgentSessionKey, } from "../../../src/routing/session-key.js"; import { t } from "../i18n/index.ts"; +import { getSafeLocalStorage } from "../local-storage.ts"; import { refreshChatAvatar } from "./app-chat.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts"; import { @@ -181,7 +182,7 @@ type DismissedUpdateBanner = { function loadDismissedUpdateBanner(): DismissedUpdateBanner | null { try { - const raw = localStorage.getItem(UPDATE_BANNER_DISMISS_KEY); + const raw = getSafeLocalStorage()?.getItem(UPDATE_BANNER_DISMISS_KEY); if (!raw) { return null; } @@ -225,7 +226,7 @@ function dismissUpdateBanner(updateAvailable: unknown) { dismissedAtMs: Date.now(), }; try { - localStorage.setItem(UPDATE_BANNER_DISMISS_KEY, JSON.stringify(payload)); + getSafeLocalStorage()?.setItem(UPDATE_BANNER_DISMISS_KEY, JSON.stringify(payload)); } catch { // ignore } diff --git a/ui/src/ui/chat/deleted-messages.ts b/ui/src/ui/chat/deleted-messages.ts index 21094bb9e83..316b659baa8 100644 --- a/ui/src/ui/chat/deleted-messages.ts +++ b/ui/src/ui/chat/deleted-messages.ts @@ -1,3 +1,5 @@ +import { getSafeLocalStorage } from "../../local-storage.ts"; + const PREFIX = "openclaw:deleted:"; export class DeletedMessages { @@ -30,7 +32,7 @@ export class DeletedMessages { private load(): void { try { - const raw = localStorage.getItem(this.key); + const raw = getSafeLocalStorage()?.getItem(this.key); if (!raw) { return; } @@ -45,7 +47,7 @@ export class DeletedMessages { private save(): void { try { - localStorage.setItem(this.key, JSON.stringify([...this._keys])); + getSafeLocalStorage()?.setItem(this.key, JSON.stringify([...this._keys])); } catch { // ignore } diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 5b7549c8d64..7dcc0b62e19 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { getSafeLocalStorage } from "../../local-storage.ts"; import type { AssistantIdentity } from "../assistant-identity.ts"; import { icons } from "../icons.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts"; @@ -322,7 +323,7 @@ type DeleteConfirmSide = "left" | "right"; function shouldSkipDeleteConfirm(): boolean { try { - return localStorage.getItem(SKIP_DELETE_CONFIRM_KEY) === "1"; + return getSafeLocalStorage()?.getItem(SKIP_DELETE_CONFIRM_KEY) === "1"; } catch { return false; } @@ -370,7 +371,7 @@ function renderDeleteButton(onDelete: () => void, side: DeleteConfirmSide) { yes.addEventListener("click", () => { if (check.checked) { try { - localStorage.setItem(SKIP_DELETE_CONFIRM_KEY, "1"); + getSafeLocalStorage()?.setItem(SKIP_DELETE_CONFIRM_KEY, "1"); } catch {} } popover.remove(); diff --git a/ui/src/ui/chat/pinned-messages.ts b/ui/src/ui/chat/pinned-messages.ts index a3e77a9483b..3bd7b9d6603 100644 --- a/ui/src/ui/chat/pinned-messages.ts +++ b/ui/src/ui/chat/pinned-messages.ts @@ -1,3 +1,5 @@ +import { getSafeLocalStorage } from "../../local-storage.ts"; + const PREFIX = "openclaw:pinned:"; export class PinnedMessages { @@ -42,7 +44,7 @@ export class PinnedMessages { private load(): void { try { - const raw = localStorage.getItem(this.key); + const raw = getSafeLocalStorage()?.getItem(this.key); if (!raw) { return; } @@ -57,7 +59,7 @@ export class PinnedMessages { private save(): void { try { - localStorage.setItem(this.key, JSON.stringify([...this._indices])); + getSafeLocalStorage()?.setItem(this.key, JSON.stringify([...this._indices])); } catch { // ignore } diff --git a/ui/src/ui/controllers/usage.ts b/ui/src/ui/controllers/usage.ts index 0fe257ae8e7..5862bd82e72 100644 --- a/ui/src/ui/controllers/usage.ts +++ b/ui/src/ui/controllers/usage.ts @@ -1,3 +1,4 @@ +import { getSafeLocalStorage } from "../../local-storage.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; import type { SessionsUsageResult, CostUsageSummary, SessionUsageTimeSeries } from "../types.ts"; import type { SessionLogEntry } from "../views/usage.ts"; @@ -39,14 +40,7 @@ const LEGACY_USAGE_DATE_PARAMS_INVALID_RE = /invalid sessions\.usage params/i; let legacyUsageDateParamsCache: Set | null = null; function getLocalStorage(): Storage | null { - // Support browser runtime and node tests (when localStorage is stubbed globally). - if (typeof window !== "undefined" && window.localStorage) { - return window.localStorage; - } - if (typeof localStorage !== "undefined") { - return localStorage; - } - return null; + return getSafeLocalStorage(); } function loadLegacyUsageDateParamsCache(): Set { diff --git a/ui/src/ui/device-auth.ts b/ui/src/ui/device-auth.ts index 1adcf7deda9..1238a859f1c 100644 --- a/ui/src/ui/device-auth.ts +++ b/ui/src/ui/device-auth.ts @@ -5,12 +5,13 @@ import { storeDeviceAuthTokenInStore, } from "../../../src/shared/device-auth-store.js"; import type { DeviceAuthStore } from "../../../src/shared/device-auth.js"; +import { getSafeLocalStorage } from "../local-storage.ts"; const STORAGE_KEY = "openclaw.device.auth.v1"; function readStore(): DeviceAuthStore | null { try { - const raw = window.localStorage.getItem(STORAGE_KEY); + const raw = getSafeLocalStorage()?.getItem(STORAGE_KEY); if (!raw) { return null; } @@ -32,7 +33,7 @@ function readStore(): DeviceAuthStore | null { function writeStore(store: DeviceAuthStore) { try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + getSafeLocalStorage()?.setItem(STORAGE_KEY, JSON.stringify(store)); } catch { // best-effort } diff --git a/ui/src/ui/device-identity.ts b/ui/src/ui/device-identity.ts index 947b8185038..ff20c68649e 100644 --- a/ui/src/ui/device-identity.ts +++ b/ui/src/ui/device-identity.ts @@ -1,4 +1,5 @@ import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519"; +import { getSafeLocalStorage } from "../local-storage.ts"; type StoredIdentity = { version: 1; @@ -58,8 +59,9 @@ async function generateIdentity(): Promise { } export async function loadOrCreateDeviceIdentity(): Promise { + const storage = getSafeLocalStorage(); try { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = storage?.getItem(STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw) as StoredIdentity; if ( @@ -74,7 +76,7 @@ export async function loadOrCreateDeviceIdentity(): Promise { ...parsed, deviceId: derivedId, }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + storage?.setItem(STORAGE_KEY, JSON.stringify(updated)); return { deviceId: derivedId, publicKey: parsed.publicKey, @@ -100,7 +102,7 @@ export async function loadOrCreateDeviceIdentity(): Promise { privateKey: identity.privateKey, createdAtMs: Date.now(), }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); + storage?.setItem(STORAGE_KEY, JSON.stringify(stored)); return identity; } diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 450c5124592..0b23b3436a4 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -16,6 +16,7 @@ type PersistedUiSettings = Omit = {}; try { - const raw = localStorage.getItem(KEY); + const raw = storage?.getItem(KEY); if (raw) { const parsed = JSON.parse(raw) as PersistedUiSettings; if (parsed.sessionsByGateway && typeof parsed.sessionsByGateway === "object") { @@ -291,5 +294,5 @@ function persistSettings(next: UiSettings) { sessionsByGateway, ...(next.locale ? { locale: next.locale } : {}), }; - localStorage.setItem(KEY, JSON.stringify(persisted)); + storage?.setItem(KEY, JSON.stringify(persisted)); } diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 860727c1927..ab55db6935f 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -2,6 +2,7 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; +import { getSafeLocalStorage } from "../../local-storage.ts"; import { renderChatSessionSelect } from "../app-render.helpers.ts"; import type { AppViewState } from "../app-view-state.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; @@ -482,7 +483,7 @@ describe("chat view", () => { it("opens delete confirm on the left for user messages", () => { try { - localStorage.removeItem("openclaw:skipDeleteConfirm"); + getSafeLocalStorage()?.removeItem("openclaw:skipDeleteConfirm"); } catch { /* noop */ } @@ -515,7 +516,7 @@ describe("chat view", () => { it("opens delete confirm on the right for assistant messages", () => { try { - localStorage.removeItem("openclaw:skipDeleteConfirm"); + getSafeLocalStorage()?.removeItem("openclaw:skipDeleteConfirm"); } catch { /* noop */ }