diff --git a/extension/src/popup/components/types/__tests__/login.test.ts b/extension/src/popup/components/types/__tests__/login.test.ts new file mode 100644 index 0000000..50400bb --- /dev/null +++ b/extension/src/popup/components/types/__tests__/login.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../shared/state', async () => { + const navigate = vi.fn(); + const setState = vi.fn(); + const sendMessage = vi.fn(); + const getState = vi.fn(() => ({ + view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0, + searchQuery: '', activeGroup: null, error: null, loading: false, + capturedTabId: null, capturedUrl: '', newType: 'login', + generatorDefaults: null, + })); + const escapeHtml = (s: string) => s + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + return { + navigate, setState, sendMessage, getState, escapeHtml, + popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn(), + }; +}); + +// Mock setup-helpers (scheduleRate used by wirePasswordStrength) +vi.mock('../../../../setup/setup-helpers', () => ({ + scheduleRate: vi.fn(), + STRENGTH_LABELS: {}, + entropyText: vi.fn(() => ''), +})); + +import { renderForm } from '../login'; +import { sendMessage } from '../../../../shared/state'; + +describe('login form smart inputs', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + // chrome.storage.local stub (needed by wireNotesMonoToggle) + (globalThis as any).chrome = { + storage: { + local: { + get: vi.fn().mockImplementation((_keys: any, cb: any) => cb({})), + set: vi.fn().mockImplementation((_obj: any, cb: any) => cb && cb()), + }, + }, + runtime: { + sendMessage: vi.fn(), + }, + }; + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { groups: [] } }); + }); + + it('renders all 6 smart-input slots in the form', async () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + + expect(document.querySelector('#fill-from-tab-btn')).not.toBeNull(); + expect(document.querySelector('#hostname-chip-row')).not.toBeNull(); + expect(document.querySelector('#reveal-password-btn')).not.toBeNull(); + expect(document.querySelector('#strength-bar-row')).not.toBeNull(); + expect(document.querySelector('#totp-preview-row')).not.toBeNull(); + expect(document.querySelector('#totp-qr-btn')).not.toBeNull(); + expect(document.querySelector('#totp-qr-panel')).not.toBeNull(); + expect(document.querySelector('#notes-mono-btn')).not.toBeNull(); + }); +}); + +describe('Login save shape', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + (globalThis as any).chrome = { + storage: { + local: { + get: vi.fn().mockImplementation((_keys: any, cb: any) => cb({})), + set: vi.fn().mockImplementation((_obj: any, cb: any) => cb && cb()), + }, + }, + runtime: { sendMessage: vi.fn() }, + }; + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockImplementation(async (msg: any) => { + if (msg.type === 'list_groups') return { ok: true, data: { groups: [] } }; + if (msg.type === 'preview_totp_from_secret') return { ok: false }; + return { ok: true, data: { id: 'fakeid0000000000', items: [] } }; + }); + }); + + it('saves a login item with url, username, and password', async () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + + (document.getElementById('f-title') as HTMLInputElement).value = 'GitHub'; + (document.getElementById('f-url') as HTMLInputElement).value = 'https://github.com/login'; + (document.getElementById('f-username') as HTMLInputElement).value = 'alice'; + (document.getElementById('f-password') as HTMLInputElement).value = 'hunter2'; + + document.getElementById('save-btn')!.click(); + await new Promise(r => setTimeout(r, 5)); + + const calls = vi.mocked(sendMessage).mock.calls; + const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item'); + expect(addCall).toBeDefined(); + const msg = addCall![0] as { type: 'add_item'; item: any }; + expect(msg.item.type).toBe('login'); + expect(msg.item.core).toMatchObject({ + type: 'login', + username: 'alice', + password: 'hunter2', + url: 'https://github.com/login', + }); + }); + + it('rejects save when title is empty', async () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + + document.getElementById('save-btn')!.click(); + await new Promise(r => setTimeout(r, 5)); + + const calls = vi.mocked(sendMessage).mock.calls; + const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item'); + expect(addCall).toBeUndefined(); + }); +}); diff --git a/extension/src/popup/components/types/login.ts b/extension/src/popup/components/types/login.ts index 16f2308..aff7dff 100644 --- a/extension/src/popup/components/types/login.ts +++ b/extension/src/popup/components/types/login.ts @@ -22,10 +22,20 @@ import { wireAttachmentsDisclosure, teardownAttachmentsDisclosure, } from '../attachments-disclosure'; +import { wireFillFromTab, wireHostnameChip } from '../../../shared/form-affordances/url-tools'; +import { wireGroupAutocomplete } from '../../../shared/form-affordances/group-autocomplete'; +import { wirePasswordReveal, wirePasswordStrength } from '../../../shared/form-affordances/password-tools'; +import { wireTotpPreview, wireTotpQr } from '../../../shared/form-affordances/totp-tools'; +import { wireNotesMonoToggle } from '../../../shared/form-affordances/notes-tools'; +import { scheduleRate } from '../../../setup/setup-helpers'; /// Called by the dispatcher before each render. Stops any in-flight /// tickers / intervals / listeners the previous view may have attached. export function teardown(): void { + for (const fn of pendingAffordanceTeardowns) { + try { fn(); } catch { /* best effort */ } + } + pendingAffordanceTeardowns = []; teardownAttachmentsDisclosure(); stopTotpTicker(); if (activeKeyHandler) { @@ -202,6 +212,7 @@ let totpTickerId: ReturnType | null = null; let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null; let sectionsExpanded = false; +let pendingAffordanceTeardowns: Array<() => void> = []; function stopTotpTicker(): void { if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; } } @@ -247,23 +258,63 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })} ${state.error ? `
${escapeHtml(state.error)}
` : ''} +
-
-
+ +
+ +
+ + +
+ +
+
-
+ +
+
- -
-
-
+ + +
+ +
+ +
+ +
+ + +
+ + +
+
-
-
+ +
+
+ + +
+ +
+ ${renderSectionsEditor(sectionsDraft, sectionsExpanded)} ${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
@@ -305,6 +356,26 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite wireDisclosure(); } + // ---- Smart input affordances ------------------------------------------ + // Each wireXxx call attaches event listeners to the just-rendered form. + // Affordances that hold timers/intervals return a teardown fn we collect + // here and run from the form's existing teardown() entry point. + const affordanceTeardowns: Array<() => void> = []; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sm = sendMessage as any; + wireFillFromTab(app, { sendMessage: sm }); + wireHostnameChip(app); + void wireGroupAutocomplete(app, { sendMessage: sm }); + affordanceTeardowns.push(wirePasswordReveal(app)); + wirePasswordStrength(app, { scheduleRate }); + affordanceTeardowns.push(wireTotpPreview(app, { sendMessage: sm })); + wireTotpQr(app); + void wireNotesMonoToggle(app, { itemId: existing?.id ?? '' }); + + // Stash teardown-runner so the existing `teardown()` calls it. + pendingAffordanceTeardowns = affordanceTeardowns; + document.getElementById('gen-btn')?.addEventListener('click', (e) => { const trigger = e.currentTarget as HTMLElement; if (isGeneratorPanelOpen()) { diff --git a/extension/src/shared/form-affordances/group-autocomplete.ts b/extension/src/shared/form-affordances/group-autocomplete.ts index 60a4c89..fd3d64f 100644 --- a/extension/src/shared/form-affordances/group-autocomplete.ts +++ b/extension/src/shared/form-affordances/group-autocomplete.ts @@ -7,8 +7,13 @@ const DATALIST_ID = 'groups-datalist'; export async function wireGroupAutocomplete(form: HTMLElement, opts: GroupAutocompleteOpts): Promise { const input = form.querySelector('#f-group'); if (!input) return; - const resp = await opts.sendMessage({ type: 'list_groups' }); - if (!resp.ok || !resp.data) return; + let resp: Awaited>; + try { + resp = await opts.sendMessage({ type: 'list_groups' }); + } catch { + return; + } + if (!resp?.ok || !resp.data?.groups) return; // Datalists must live in the document, not nested inside an input. Reuse if // we've already mounted one this session. diff --git a/extension/src/shared/form-affordances/notes-tools.ts b/extension/src/shared/form-affordances/notes-tools.ts index 548973e..ac9ef47 100644 --- a/extension/src/shared/form-affordances/notes-tools.ts +++ b/extension/src/shared/form-affordances/notes-tools.ts @@ -10,14 +10,20 @@ export async function wireNotesMonoToggle(form: HTMLElement, opts: NotesMonoOpts if (!btn || !ta) return; const key = `notesMono.${opts.itemId || '__new__'}`; - const stored = await new Promise((resolve) => { - chrome.storage.local.get([key], (result) => resolve(!!result[key])); - }); - if (stored) ta.classList.add('f-notes--mono'); + + // chrome.storage may be absent in test environments — guard gracefully. + if (typeof chrome !== 'undefined' && chrome.storage?.local) { + const stored = await new Promise((resolve) => { + chrome.storage.local.get([key], (result) => resolve(!!result[key])); + }); + if (stored) ta.classList.add('f-notes--mono'); + } btn.addEventListener('click', () => { const next = !ta.classList.contains('f-notes--mono'); ta.classList.toggle('f-notes--mono', next); - chrome.storage.local.set({ [key]: next }, () => { /* fire and forget */ }); + if (typeof chrome !== 'undefined' && chrome.storage?.local) { + chrome.storage.local.set({ [key]: next }, () => { /* fire and forget */ }); + } }); }