- src/lib/types.ts: message contract (both ANALYZE_TEXT shapes preserved), config/response types, storage-key constants. - src/lib/messaging.ts: single safeStorageGet/safeStorageSet/safeSendMessage/ isExtensionValid implementation replacing the three divergent copies in Options, Popup, and content. Content script keeps its refresh-toast behavior via an onContextInvalidated callback. - New '@lib' import alias (wxt force-overwrites '~' and '@' to srcDir, so those cannot point at ./src); wired in wxt.config, tsconfig, vitest. - tests/unit/messaging.test.ts: 14 unit tests over the wrappers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
150 lines
5.3 KiB
TypeScript
150 lines
5.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import {
|
|
isExtensionValid,
|
|
safeStorageGet,
|
|
safeStorageSet,
|
|
safeSendMessage,
|
|
} from '@lib/messaging';
|
|
|
|
// Rebuild the chrome mock per test so each case controls runtime.id presence.
|
|
function mockChrome({ valid = true }: { valid?: boolean } = {}) {
|
|
const local = {
|
|
get: vi.fn((_keys: string[], cb: (r: Record<string, unknown>) => void) => cb({})),
|
|
set: vi.fn((_data: Record<string, unknown>, cb?: () => void) => cb && cb()),
|
|
};
|
|
const session = {
|
|
get: vi.fn((_keys: string[], cb: (r: Record<string, unknown>) => void) => cb({})),
|
|
set: vi.fn((_data: Record<string, unknown>, cb?: () => void) => cb && cb()),
|
|
};
|
|
global.chrome = {
|
|
storage: { local, session },
|
|
runtime: {
|
|
id: valid ? 'test-extension-id' : undefined,
|
|
sendMessage: vi.fn().mockResolvedValue({ result: 'ok' }),
|
|
},
|
|
} as any;
|
|
return { local, session };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('isExtensionValid', () => {
|
|
it('is true when chrome.runtime.id exists', () => {
|
|
mockChrome({ valid: true });
|
|
expect(isExtensionValid()).toBe(true);
|
|
});
|
|
|
|
it('is false when the extension context is invalidated (no runtime.id)', () => {
|
|
mockChrome({ valid: false });
|
|
expect(isExtensionValid()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('safeStorageGet', () => {
|
|
it('reads from chrome.storage.local by default', () => {
|
|
const { local } = mockChrome();
|
|
const cb = vi.fn();
|
|
safeStorageGet(['provider', 'model'], cb);
|
|
expect(local.get).toHaveBeenCalledWith(['provider', 'model'], cb);
|
|
expect(cb).toHaveBeenCalledWith({});
|
|
});
|
|
|
|
it('reads from an explicitly passed storage area (e.g. session)', () => {
|
|
const { local, session } = mockChrome();
|
|
const cb = vi.fn();
|
|
safeStorageGet(['lexai_popup_input'], cb, (global.chrome as any).storage.session);
|
|
expect(session.get).toHaveBeenCalled();
|
|
expect(local.get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('is a silent no-op when the extension context is gone', () => {
|
|
const { local } = mockChrome({ valid: false });
|
|
const cb = vi.fn();
|
|
safeStorageGet(['provider'], cb);
|
|
expect(local.get).not.toHaveBeenCalled();
|
|
expect(cb).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('swallows a throwing storage call with a warning', () => {
|
|
mockChrome();
|
|
(global.chrome as any).storage.local.get = vi.fn(() => {
|
|
throw new Error('Extension context invalidated.');
|
|
});
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
expect(() => safeStorageGet(['provider'], vi.fn())).not.toThrow();
|
|
expect(warn).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('safeStorageSet', () => {
|
|
it('writes to chrome.storage.local by default and honors the callback', () => {
|
|
const { local } = mockChrome();
|
|
const done = vi.fn();
|
|
safeStorageSet({ provider: 'openai' }, done);
|
|
expect(local.set).toHaveBeenCalledWith({ provider: 'openai' }, done);
|
|
expect(done).toHaveBeenCalled();
|
|
});
|
|
|
|
it('writes without a callback', () => {
|
|
const { local } = mockChrome();
|
|
safeStorageSet({ writingStyle: 'Formal' });
|
|
expect(local.set).toHaveBeenCalledWith({ writingStyle: 'Formal' });
|
|
});
|
|
|
|
it('writes to an explicitly passed storage area', () => {
|
|
const { session } = mockChrome();
|
|
safeStorageSet({ lexai_popup_input: 'draft' }, undefined, (global.chrome as any).storage.session);
|
|
expect(session.set).toHaveBeenCalledWith({ lexai_popup_input: 'draft' });
|
|
});
|
|
|
|
it('is a no-op when the extension context is gone', () => {
|
|
const { local } = mockChrome({ valid: false });
|
|
safeStorageSet({ provider: 'openai' });
|
|
expect(local.set).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('safeSendMessage', () => {
|
|
it('resolves with the background response', async () => {
|
|
mockChrome();
|
|
const res = await safeSendMessage({ type: 'ANALYZE_TEXT', payload: { text: 'hi', action: 'grammar' } });
|
|
expect(res).toEqual({ result: 'ok' });
|
|
expect((global.chrome as any).runtime.sendMessage).toHaveBeenCalledWith({
|
|
type: 'ANALYZE_TEXT',
|
|
payload: { text: 'hi', action: 'grammar' },
|
|
});
|
|
});
|
|
|
|
it('returns null and fires onContextInvalidated when the context is gone up front', async () => {
|
|
mockChrome({ valid: false });
|
|
const onInvalid = vi.fn();
|
|
const res = await safeSendMessage({ type: 'LIST_MODELS' }, onInvalid);
|
|
expect(res).toBeNull();
|
|
expect(onInvalid).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('returns null and fires onContextInvalidated when sendMessage rejects with an invalidation error', async () => {
|
|
mockChrome();
|
|
(global.chrome as any).runtime.sendMessage = vi
|
|
.fn()
|
|
.mockRejectedValue(new Error('Extension context invalidated.'));
|
|
const onInvalid = vi.fn();
|
|
const res = await safeSendMessage({ type: 'COPY_AS', text: 'x', format: 'markdown' }, onInvalid);
|
|
expect(res).toBeNull();
|
|
expect(onInvalid).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('returns null and warns (no invalidation callback) on other send failures', async () => {
|
|
mockChrome();
|
|
(global.chrome as any).runtime.sendMessage = vi.fn().mockRejectedValue(new Error('boom'));
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const onInvalid = vi.fn();
|
|
const res = await safeSendMessage({ type: 'LIST_MODELS' }, onInvalid);
|
|
expect(res).toBeNull();
|
|
expect(onInvalid).not.toHaveBeenCalled();
|
|
expect(warn).toHaveBeenCalled();
|
|
});
|
|
});
|