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