Merge phase-c-1-statehost: Plan C Phase 1 (typed StateHost + __resetHostForTests)
P1.6 closed: shared/state.ts is type-checked end to end. StateHost interface defines every field; double-registration throws; vitest gets __resetHostForTests to break inter-test leakage. View + PopupState moved to shared/popup-state.ts (broke the popup→shared circular-dep blocker). PopupState widened to absorb VaultState's vault-tab-only fields (unlocked, drawerOpen, typePanelOpen) with optional + commented justification, so the two surfaces share one typed contract. 378/378 vitest tests pass (baseline 371 + 7 new state.test.ts cases). Phase 5 still running in parallel. Cross-stream note from Phase 1 subagent: settings.ts was not touched here, so Phase 5's teardownSettingsCommon extraction rebases cleanly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -67,29 +67,7 @@ function parseUrlParams(): { view?: View; type?: string; id?: string } | null {
|
|||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
|
|
||||||
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault' | 'trash' | 'devices' | 'field-history';
|
import type { View, PopupState } from '../shared/popup-state';
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentState: PopupState = {
|
let currentState: PopupState = {
|
||||||
view: 'locked',
|
view: 'locked',
|
||||||
|
|||||||
92
extension/src/shared/__tests__/state.test.ts
Normal file
92
extension/src/shared/__tests__/state.test.ts
Normal file
@@ -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<PopupState>): 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
57
extension/src/shared/popup-state.ts
Normal file
57
extension/src/shared/popup-state.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
/// Service-locator for cross-bundle state access.
|
// extension/src/shared/state.ts
|
||||||
///
|
//
|
||||||
/// Both popup.ts and vault.ts register themselves as the "host".
|
// Single channel for popup and vault-tab UI to read/write app state and
|
||||||
/// All popup components import from here instead of from popup.ts,
|
// dispatch messages to the service worker. Two registered hosts (popup,
|
||||||
/// so the same component code works in either bundle.
|
// 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 { Request, Response } from './messages';
|
||||||
|
import type { PopupState, View } from './popup-state';
|
||||||
|
|
||||||
export interface StateHost {
|
export interface StateHost {
|
||||||
getState(): any;
|
getState(): PopupState;
|
||||||
setState(partial: any): void;
|
setState(partial: Partial<PopupState>): void;
|
||||||
navigate(view: string, extras?: any): void;
|
navigate(view: View, extras?: Partial<PopupState>): void;
|
||||||
sendMessage(request: Request): Promise<Response>;
|
sendMessage(request: Request): Promise<Response>;
|
||||||
escapeHtml(s: string): string;
|
escapeHtml(s: string): string;
|
||||||
popOutToTab(): void;
|
popOutToTab(): void;
|
||||||
@@ -19,24 +25,36 @@ export interface StateHost {
|
|||||||
|
|
||||||
let host: StateHost | null = null;
|
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');
|
if (!host) throw new Error('No state host registered');
|
||||||
return host.getState();
|
return host.getState();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setState(partial: any): void {
|
export function setState(partial: Partial<PopupState>): void {
|
||||||
if (!host) throw new Error('No state host registered');
|
if (!host) throw new Error('No state host registered');
|
||||||
host.setState(partial);
|
host.setState(partial);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigate(view: string, extras?: any): void {
|
export function navigate(view: View, extras?: Partial<PopupState>): void {
|
||||||
if (!host) throw new Error('No state host registered');
|
if (!host) throw new Error('No state host registered');
|
||||||
host.navigate(view, extras);
|
host.navigate(view, extras);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendMessage(request: Request): Promise<Response> {
|
/**
|
||||||
|
* 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<Response> {
|
||||||
if (!host) throw new Error('No state host registered');
|
if (!host) throw new Error('No state host registered');
|
||||||
return host.sendMessage(request);
|
return host.sendMessage(request);
|
||||||
}
|
}
|
||||||
@@ -52,7 +70,7 @@ export function popOutToTab(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isInTab(): boolean {
|
export function isInTab(): boolean {
|
||||||
if (!host) return false;
|
if (!host) throw new Error('No state host registered');
|
||||||
return host.isInTab();
|
return host.isInTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -229,11 +229,11 @@ const state: VaultState = {
|
|||||||
|
|
||||||
registerHost({
|
registerHost({
|
||||||
getState: () => state,
|
getState: () => state,
|
||||||
setState: (partial: any) => {
|
setState: (partial) => {
|
||||||
Object.assign(state, partial);
|
Object.assign(state, partial);
|
||||||
renderPane();
|
renderPane();
|
||||||
},
|
},
|
||||||
navigate: (view: string, extras?: any) => {
|
navigate: (view, extras) => {
|
||||||
Object.assign(state, { view, error: null, loading: false, ...extras });
|
Object.assign(state, { view, error: null, loading: false, ...extras });
|
||||||
setHash(view as VaultView);
|
setHash(view as VaultView);
|
||||||
applyShellViewClass();
|
applyShellViewClass();
|
||||||
|
|||||||
Reference in New Issue
Block a user