ext(login): wire 8 smart-input affordances into renderForm()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
122
extension/src/popup/components/types/__tests__/login.test.ts
Normal file
122
extension/src/popup/components/types/__tests__/login.test.ts
Normal file
@@ -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, '"').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 = '<div id="app"></div>';
|
||||
// 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 = '<div id="app"></div>';
|
||||
(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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof setInterval> | 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
|
||||
<div class="pad">
|
||||
${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })}
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
|
||||
<div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
|
||||
<div class="form-group"><label class="label" for="f-url">url</label>
|
||||
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-url">url</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
|
||||
<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
|
||||
</div>
|
||||
<div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group"><label class="label" for="f-username">username</label>
|
||||
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com"></div>
|
||||
<div class="form-group"><label class="label" for="f-password">password</label>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-password">password</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-password" type="password" value="${escapeHtml(password)}">
|
||||
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">✨</button>
|
||||
</div></div>
|
||||
<div class="form-group"><label class="label" for="f-totp">totp secret (base32)</label>
|
||||
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP"></div>
|
||||
<button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal">⊙</button>
|
||||
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">↻</button>
|
||||
</div>
|
||||
<div id="strength-bar-row" class="strength-bar-row" hidden>
|
||||
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div class="strength-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-totp">totp secret (base32)</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
|
||||
<button id="totp-qr-btn" class="glyph-btn" type="button" title="paste / upload QR">◫</button>
|
||||
</div>
|
||||
<div id="totp-preview-row" class="totp-preview" hidden>
|
||||
<span class="totp-code">…</span>
|
||||
<span class="totp-countdown">…</span>
|
||||
</div>
|
||||
<div id="totp-qr-panel" class="totp-qr-panel" hidden>
|
||||
<input id="totp-qr-file" type="file" accept="image/*" />
|
||||
<div style="font-size:10px;color:var(--text-dim,#6b7888);margin-top:4px;">paste image, drop image, or pick a file</div>
|
||||
<div id="totp-qr-error" class="totp-qr-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group"><label class="label" for="f-group">group</label>
|
||||
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>
|
||||
<div class="form-group"><label class="label" for="f-notes">notes</label>
|
||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea></div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="notes-with-toggle">
|
||||
<label class="label" for="f-notes" style="margin:0;flex:1;">notes</label>
|
||||
<button id="notes-mono-btn" class="glyph-btn" type="button" title="toggle monospace">≡</button>
|
||||
</div>
|
||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
|
||||
</div>
|
||||
|
||||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
|
||||
<div class="form-actions">
|
||||
@@ -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()) {
|
||||
|
||||
@@ -7,8 +7,13 @@ const DATALIST_ID = 'groups-datalist';
|
||||
export async function wireGroupAutocomplete(form: HTMLElement, opts: GroupAutocompleteOpts): Promise<void> {
|
||||
const input = form.querySelector<HTMLInputElement>('#f-group');
|
||||
if (!input) return;
|
||||
const resp = await opts.sendMessage({ type: 'list_groups' });
|
||||
if (!resp.ok || !resp.data) return;
|
||||
let resp: Awaited<ReturnType<typeof opts.sendMessage>>;
|
||||
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.
|
||||
|
||||
@@ -10,14 +10,20 @@ export async function wireNotesMonoToggle(form: HTMLElement, opts: NotesMonoOpts
|
||||
if (!btn || !ta) return;
|
||||
|
||||
const key = `notesMono.${opts.itemId || '__new__'}`;
|
||||
|
||||
// chrome.storage may be absent in test environments — guard gracefully.
|
||||
if (typeof chrome !== 'undefined' && chrome.storage?.local) {
|
||||
const stored = await new Promise<boolean>((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);
|
||||
if (typeof chrome !== 'undefined' && chrome.storage?.local) {
|
||||
chrome.storage.local.set({ [key]: next }, () => { /* fire and forget */ });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user