diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index d098185..c154735 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -67,29 +67,7 @@ function parseUrlParams(): { view?: View; type?: string; id?: string } | null { // --- State --- -export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault' | 'trash' | 'devices' | 'field-history'; - -export interface PopupState { - view: View; - entries: Array<[ItemId, ManifestEntry]>; - selectedId: ItemId | null; - selectedItem: Item | null; - selectedIndex: number; - searchQuery: string; - activeGroup: string | null; - error: string | null; - loading: boolean; - // Captured tab snapshot taken at popup-open. Used by fill_credentials - // to guard against TOCTOU navigation — the SW re-checks this URL's - // hostname against the tab's live URL before forwarding fill_credentials - // to the content script. See router/popup-only.ts#handleFillCredentials. - capturedTabId: number | null; - capturedUrl: string; - newType: import('../shared/types').ItemType | null; - vaultSettings: import('../shared/types').VaultSettings | null; - generatorDefaults: import('../shared/types').GeneratorRequest | null; - historyItemId: import('../shared/types').ItemId | null; -} +import type { View, PopupState } from '../shared/popup-state'; let currentState: PopupState = { view: 'locked', diff --git a/extension/src/shared/__tests__/state.test.ts b/extension/src/shared/__tests__/state.test.ts new file mode 100644 index 0000000..0a707d2 --- /dev/null +++ b/extension/src/shared/__tests__/state.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + registerHost, + __resetHostForTests, + getState, + setState, + navigate, + sendMessage, +} from '../state'; +import type { StateHost } from '../state'; +import type { PopupState } from '../popup-state'; + +function makeHost(initial?: Partial): StateHost { + let state: PopupState = { + view: 'list', + entries: [], + selectedId: null, + selectedItem: null, + selectedIndex: 0, + searchQuery: '', + activeGroup: null, + error: null, + loading: false, + capturedTabId: null, + capturedUrl: '', + newType: null, + vaultSettings: null, + generatorDefaults: null, + historyItemId: null, + ...initial, + }; + + return { + getState: () => state, + setState: (partial) => { state = { ...state, ...partial }; }, + navigate: vi.fn(), + sendMessage: vi.fn().mockResolvedValue({ ok: true }), + escapeHtml: (s) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), + }; +} + +describe('shared/state', () => { + beforeEach(() => { + __resetHostForTests(); + }); + + it('register-then-getState round-trips', () => { + const host = makeHost({ view: 'detail' }); + registerHost(host); + expect(getState().view).toBe('detail'); + }); + + it('double-register throws', () => { + registerHost(makeHost()); + expect(() => registerHost(makeHost())).toThrow(/already registered/); + }); + + it('__resetHostForTests clears the singleton', () => { + registerHost(makeHost()); + __resetHostForTests(); + expect(() => getState()).toThrow(/No state host/); + }); + + it('getState without host throws', () => { + expect(() => getState()).toThrow(/No state host/); + }); + + it('setState merges partial state', () => { + const host = makeHost(); + registerHost(host); + setState({ loading: true }); + expect(getState().loading).toBe(true); + }); + + it('navigate delegates to host', () => { + const host = makeHost(); + registerHost(host); + navigate('settings'); + expect(host.navigate).toHaveBeenCalledWith('settings', undefined); + }); + + it('sendMessage delegates to host', async () => { + const host = makeHost(); + registerHost(host); + const resp = await sendMessage({ type: 'is_unlocked' }); + expect(host.sendMessage).toHaveBeenCalledWith({ type: 'is_unlocked' }); + expect(resp).toEqual({ ok: true }); + }); +}); diff --git a/extension/src/shared/popup-state.ts b/extension/src/shared/popup-state.ts new file mode 100644 index 0000000..09e94e7 --- /dev/null +++ b/extension/src/shared/popup-state.ts @@ -0,0 +1,57 @@ +// State shared between popup and vault surfaces. Kept here (not in popup/) so +// shared/state.ts can import without creating a popup→shared circular dep. + +import type { + Item, + ItemId, + ItemType, + ManifestEntry, + GeneratorRequest, + VaultSettings, +} from './types'; + +export type View = + | 'locked' + | 'list' + | 'detail' + | 'add' + | 'edit' + | 'settings' + | 'settings-vault' + | 'trash' + | 'devices' + | 'field-history' + // Vault-tab-only views; popup never navigates to these. Kept in the union so + // a single typed StateHost contract serves both surfaces (popup + vault). + | 'history' + | 'backup' + | 'import'; + +export interface PopupState { + view: View; + entries: Array<[ItemId, ManifestEntry]>; + selectedId: ItemId | null; + selectedItem: Item | null; + selectedIndex: number; + searchQuery: string; + activeGroup: string | null; + error: string | null; + loading: boolean; + // Captured tab snapshot taken at popup-open. Used by fill_credentials + // to guard against TOCTOU navigation — the SW re-checks this URL's + // hostname against the tab's live URL before forwarding fill_credentials + // to the content script. See router/popup-only.ts#handleFillCredentials. + capturedTabId: number | null; + capturedUrl: string; + newType: ItemType | null; + vaultSettings: VaultSettings | null; + generatorDefaults: GeneratorRequest | null; + historyItemId: ItemId | null; + // Vault-tab-only fields. The popup surface leaves these at their defaults + // (unlocked=false implicit via separate lock-screen view, drawer/panel false). + // Kept on the shared shape so VaultState satisfies StateHost.getState() + // without a cast. + unlocked?: boolean; + drawerOpen?: boolean; + typePanelOpen?: boolean; +} diff --git a/extension/src/shared/state.ts b/extension/src/shared/state.ts index 07f914b..30deeaf 100644 --- a/extension/src/shared/state.ts +++ b/extension/src/shared/state.ts @@ -1,15 +1,21 @@ -/// Service-locator for cross-bundle state access. -/// -/// Both popup.ts and vault.ts register themselves as the "host". -/// All popup components import from here instead of from popup.ts, -/// so the same component code works in either bundle. +// extension/src/shared/state.ts +// +// Single channel for popup and vault-tab UI to read/write app state and +// dispatch messages to the service worker. Two registered hosts (popup, +// vault tab) implement StateHost; each surface calls registerHost(this) at +// boot. +// +// The vault_locked intercept (lines 47-74 in vault.ts pre-Phase-4) lifts +// into sendMessage() here in Phase 4. Phase 1 lays the wrapper signature; +// the body is a thin pass-through until Phase 4. import type { Request, Response } from './messages'; +import type { PopupState, View } from './popup-state'; export interface StateHost { - getState(): any; - setState(partial: any): void; - navigate(view: string, extras?: any): void; + getState(): PopupState; + setState(partial: Partial): void; + navigate(view: View, extras?: Partial): void; sendMessage(request: Request): Promise; escapeHtml(s: string): string; popOutToTab(): void; @@ -19,24 +25,36 @@ export interface StateHost { let host: StateHost | null = null; -export function registerHost(h: StateHost): void { host = h; } +export function registerHost(h: StateHost): void { + if (host) throw new Error('state host already registered'); + host = h; +} -export function getState(): any { +/** Test-only — vitest beforeEach() calls this to break inter-test leakage. */ +export function __resetHostForTests(): void { + host = null; +} + +export function getState(): PopupState { if (!host) throw new Error('No state host registered'); return host.getState(); } -export function setState(partial: any): void { +export function setState(partial: Partial): void { if (!host) throw new Error('No state host registered'); host.setState(partial); } -export function navigate(view: string, extras?: any): void { +export function navigate(view: View, extras?: Partial): void { if (!host) throw new Error('No state host registered'); host.navigate(view, extras); } -export function sendMessage(request: Request): Promise { +/** + * Phase 4 will add a vault_locked intercept here. For now, this is a pure + * pass-through so the signature is stable for Phase 4 to fill. + */ +export async function sendMessage(request: Request): Promise { if (!host) throw new Error('No state host registered'); return host.sendMessage(request); } @@ -52,7 +70,7 @@ export function popOutToTab(): void { } export function isInTab(): boolean { - if (!host) return false; + if (!host) throw new Error('No state host registered'); return host.isInTab(); } diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index 9bbdc57..421c918 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -229,11 +229,11 @@ const state: VaultState = { registerHost({ getState: () => state, - setState: (partial: any) => { + setState: (partial) => { Object.assign(state, partial); renderPane(); }, - navigate: (view: string, extras?: any) => { + navigate: (view, extras) => { Object.assign(state, { view, error: null, loading: false, ...extras }); setHash(view as VaultView); applyShellViewClass();