refactor: extract shared types + safe chrome wrappers into src/lib

- 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>
This commit is contained in:
john kevin asprec
2026-07-14 21:33:56 +08:00
parent 04a4a2dc99
commit 324cfcc486
10 changed files with 303 additions and 124 deletions

View File

@@ -0,0 +1,149 @@
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();
});
});