ext(login): wire 8 smart-input affordances into renderForm()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-01 22:32:14 -04:00
parent e6eb698c4c
commit b450ecd1cc
4 changed files with 220 additions and 16 deletions

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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();
});
});

View File

@@ -22,10 +22,20 @@ import {
wireAttachmentsDisclosure, wireAttachmentsDisclosure,
teardownAttachmentsDisclosure, teardownAttachmentsDisclosure,
} from '../attachments-disclosure'; } 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 /// Called by the dispatcher before each render. Stops any in-flight
/// tickers / intervals / listeners the previous view may have attached. /// tickers / intervals / listeners the previous view may have attached.
export function teardown(): void { export function teardown(): void {
for (const fn of pendingAffordanceTeardowns) {
try { fn(); } catch { /* best effort */ }
}
pendingAffordanceTeardowns = [];
teardownAttachmentsDisclosure(); teardownAttachmentsDisclosure();
stopTotpTicker(); stopTotpTicker();
if (activeKeyHandler) { if (activeKeyHandler) {
@@ -202,6 +212,7 @@ let totpTickerId: ReturnType<typeof setInterval> | null = null;
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null; let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
let sectionsExpanded = false; let sectionsExpanded = false;
let pendingAffordanceTeardowns: Array<() => void> = [];
function stopTotpTicker(): void { function stopTotpTicker(): void {
if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; } if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; }
} }
@@ -247,23 +258,63 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
<div class="pad"> <div class="pad">
${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })} ${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })}
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''} ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label> <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> <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> <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> <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"> <div class="inline-row">
<input id="f-password" type="password" value="${escapeHtml(password)}"> <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> <button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal"></button>
</div></div> <button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">↻</button>
<div class="form-group"><label class="label" for="f-totp">totp secret (base32)</label> </div>
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP"></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> <div class="form-group"><label class="label" for="f-group">group</label>
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div> <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)} ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''} ${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
<div class="form-actions"> <div class="form-actions">
@@ -305,6 +356,26 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
wireDisclosure(); 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) => { document.getElementById('gen-btn')?.addEventListener('click', (e) => {
const trigger = e.currentTarget as HTMLElement; const trigger = e.currentTarget as HTMLElement;
if (isGeneratorPanelOpen()) { if (isGeneratorPanelOpen()) {

View File

@@ -7,8 +7,13 @@ const DATALIST_ID = 'groups-datalist';
export async function wireGroupAutocomplete(form: HTMLElement, opts: GroupAutocompleteOpts): Promise<void> { export async function wireGroupAutocomplete(form: HTMLElement, opts: GroupAutocompleteOpts): Promise<void> {
const input = form.querySelector<HTMLInputElement>('#f-group'); const input = form.querySelector<HTMLInputElement>('#f-group');
if (!input) return; if (!input) return;
const resp = await opts.sendMessage({ type: 'list_groups' }); let resp: Awaited<ReturnType<typeof opts.sendMessage>>;
if (!resp.ok || !resp.data) return; 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 // Datalists must live in the document, not nested inside an input. Reuse if
// we've already mounted one this session. // we've already mounted one this session.

View File

@@ -10,14 +10,20 @@ export async function wireNotesMonoToggle(form: HTMLElement, opts: NotesMonoOpts
if (!btn || !ta) return; if (!btn || !ta) return;
const key = `notesMono.${opts.itemId || '__new__'}`; const key = `notesMono.${opts.itemId || '__new__'}`;
const stored = await new Promise<boolean>((resolve) => {
chrome.storage.local.get([key], (result) => resolve(!!result[key])); // chrome.storage may be absent in test environments — guard gracefully.
}); if (typeof chrome !== 'undefined' && chrome.storage?.local) {
if (stored) ta.classList.add('f-notes--mono'); 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', () => { btn.addEventListener('click', () => {
const next = !ta.classList.contains('f-notes--mono'); const next = !ta.classList.contains('f-notes--mono');
ta.classList.toggle('f-notes--mono', next); 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 */ });
}
}); });
} }