kimi-code/apps/kimi-web/test/storage-logic.test.ts
qer fc3d69dbdc
feat(web): add completion sound and question notifications (#1179)
* feat(web): play a sound when a turn completes

Synthesize a short chime when a session finishes a turn. Opt-in via Settings -> Notifications (off by default); the audio context is unlocked on the first user gesture so it also plays while the tab is backgrounded.

* feat(web): notify and play a sound when a question needs an answer

Reuse the existing notification/sound toggles so they also fire when the agent asks a question (the awaiting-answer state). Generalize the Settings labels to cover both cases.

* fix(web): don't queue the chime on a suspended audio context

A suspended AudioContext has a frozen clock, so tones scheduled on it would play stale when the context later resumes (e.g. on the next click). Only schedule the chime when the context is actually running; if it is still suspended, try to unlock it for next time and skip this one.

* fix(web): gate question notifications behind explicit opt-in

Question notifications surface question text, so they must not fire for users who only opted into turn-completion alerts (which default on). Split question notifications into their own persisted preference that defaults off, with a separate Settings toggle. Completion notifications keep their existing default-on behavior.

* fix(web): show the question text in question notifications

Lead with the actionable question text in the desktop notification body, keeping the short header as context (e.g. 'Storage: Which database?'). Previously the header alone was shown, so users had to open the tab to learn what was being asked.
2026-06-28 21:02:00 +08:00

225 lines
6.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
loadCollapsedWorkspaces,
loadUnread,
loadWorkspaceOrder,
saveCollapsedWorkspaces,
saveUnread,
saveWorkspaceOrder,
STORAGE_KEYS,
draftStorageKey,
safeGetJson,
safeGetString,
safeRemove,
safeSetJson,
safeSetString,
} from '../src/lib/storage';
function createMemoryStorage(): Storage {
const data = new Map<string, string>();
return {
get length() {
return data.size;
},
clear() {
data.clear();
},
getItem(key: string) {
return data.get(key) ?? null;
},
key(index: number) {
return Array.from(data.keys()).at(index) ?? null;
},
removeItem(key: string) {
data.delete(key);
},
setItem(key: string, value: string) {
data.set(key, String(value));
},
};
}
function installStorage(storage: Storage): void {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: storage,
});
}
let backing: Storage;
beforeEach(() => {
backing = createMemoryStorage();
installStorage(backing);
});
afterEach(() => {
installStorage(createMemoryStorage());
});
describe('safeGetString / safeSetString', () => {
it('round-trips a value', () => {
safeSetString('k', 'hello');
expect(safeGetString('k')).toBe('hello');
});
it('returns null for a missing key', () => {
expect(safeGetString('missing')).toBeNull();
});
it('overwrites an existing value', () => {
safeSetString('k', 'a');
safeSetString('k', 'b');
expect(safeGetString('k')).toBe('b');
});
});
describe('safeRemove', () => {
it('removes an existing key', () => {
safeSetString('k', 'v');
safeRemove('k');
expect(safeGetString('k')).toBeNull();
});
it('is a no-op for a missing key', () => {
expect(() => safeRemove('missing')).not.toThrow();
});
});
describe('safeGetJson / safeSetJson', () => {
it('round-trips a JSON value', () => {
safeSetJson('k', { a: 1, b: [2, 3] });
expect(safeGetJson('k')).toEqual({ a: 1, b: [2, 3] });
});
it('returns null for a missing key', () => {
expect(safeGetJson('missing')).toBeNull();
});
it('returns null when the stored value is not valid JSON', () => {
safeSetString('k', '{not json');
expect(safeGetJson('k')).toBeNull();
});
});
describe('error swallowing', () => {
it('safeGetString returns null when storage throws', () => {
const throwing = createMemoryStorage();
throwing.getItem = () => {
throw new Error('denied');
};
installStorage(throwing);
expect(safeGetString('k')).toBeNull();
});
it('safeSetString does not throw when storage throws', () => {
const throwing = createMemoryStorage();
throwing.setItem = () => {
throw new Error('quota');
};
installStorage(throwing);
expect(() => safeSetString('k', 'v')).not.toThrow();
});
});
describe('draftStorageKey', () => {
it('uses the session id when present', () => {
expect(draftStorageKey('abc')).toBe('kimi-web.draft.abc');
});
it('falls back to __new__ when sid is empty/undefined', () => {
expect(draftStorageKey(undefined)).toBe('kimi-web.draft.__new__');
expect(draftStorageKey('')).toBe('kimi-web.draft.__new__');
});
});
describe('STORAGE_KEYS', () => {
it('keeps the legacy key strings unchanged', () => {
expect(STORAGE_KEYS.theme).toBe('kimi-web.theme');
expect(STORAGE_KEYS.activeWorkspace).toBe('kimi-active-workspace');
expect(STORAGE_KEYS.notifyOnComplete).toBe('kimi-web.notify-on-complete');
expect(STORAGE_KEYS.notifyOnQuestion).toBe('kimi-web.notify-on-question');
expect(STORAGE_KEYS.soundOnComplete).toBe('kimi-web.sound-on-complete');
expect(STORAGE_KEYS.locale).toBe('kimi-locale');
});
});
describe('loadUnread / saveUnread', () => {
it('returns an empty map when the key is missing', () => {
expect(loadUnread()).toEqual({});
});
it('keeps only true entries', () => {
safeSetString(STORAGE_KEYS.unread, JSON.stringify({ B: true, C: false, D: 'yes' }));
expect(loadUnread()).toEqual({ B: true });
});
it('drops false entries, clearing the unread dot', () => {
saveUnread({ B: true, C: true });
saveUnread({ B: false });
expect(loadUnread()).toEqual({ C: true });
});
it('merges with the latest stored value so a clear from another tab is not overwritten', () => {
// This tab marks B unread.
saveUnread({ B: true });
expect(loadUnread()).toEqual({ B: true });
// Another tab clears B and marks C (simulated by writing the key directly).
safeSetString(STORAGE_KEYS.unread, JSON.stringify({ C: true }));
// This tab marks D unread, passing only the change (not a full, stale map).
saveUnread({ D: true });
// B must NOT come back — it was cleared by the other tab.
expect(loadUnread()).toEqual({ C: true, D: true });
});
});
describe('loadCollapsedWorkspaces / saveCollapsedWorkspaces', () => {
it('returns an empty array when the key is missing', () => {
expect(loadCollapsedWorkspaces()).toEqual([]);
});
it('round-trips the collapsed ids', () => {
saveCollapsedWorkspaces(['ws-1', 'ws-2']);
expect(loadCollapsedWorkspaces()).toEqual(['ws-1', 'ws-2']);
});
it('accepts any iterable of ids', () => {
saveCollapsedWorkspaces(new Set(['ws-1', 'ws-3']));
expect(loadCollapsedWorkspaces()).toEqual(['ws-1', 'ws-3']);
});
it('drops non-string entries and returns [] for malformed values', () => {
safeSetString(STORAGE_KEYS.collapsedWorkspaces, JSON.stringify(['ws-1', 2, null, 'ws-2']));
expect(loadCollapsedWorkspaces()).toEqual(['ws-1', 'ws-2']);
safeSetString(STORAGE_KEYS.collapsedWorkspaces, JSON.stringify({ ws: true }));
expect(loadCollapsedWorkspaces()).toEqual([]);
});
});
describe('loadWorkspaceOrder / saveWorkspaceOrder', () => {
it('returns an empty array when the key is missing', () => {
expect(loadWorkspaceOrder()).toEqual([]);
});
it('round-trips the ordered ids', () => {
saveWorkspaceOrder(['ws-2', 'ws-1']);
expect(loadWorkspaceOrder()).toEqual(['ws-2', 'ws-1']);
});
it('accepts any iterable of ids', () => {
saveWorkspaceOrder(new Set(['ws-3', 'ws-1']));
expect(loadWorkspaceOrder()).toEqual(['ws-3', 'ws-1']);
});
it('drops non-string entries and returns [] for malformed values', () => {
safeSetString(STORAGE_KEYS.workspaceOrder, JSON.stringify(['ws-1', 2, null]));
expect(loadWorkspaceOrder()).toEqual(['ws-1']);
safeSetString(STORAGE_KEYS.workspaceOrder, JSON.stringify({ ws: true }));
expect(loadWorkspaceOrder()).toEqual([]);
});
});