import { beforeEach, describe, expect, it, vi } from 'vitest'; import { renderDevices } from '../devices'; // Mock chrome.storage.local // @ts-expect-error test harness globalThis.chrome = { storage: { local: { get: vi.fn().mockResolvedValue({ device_name: 'Chrome on Linux' }), }, }, }; // Mock popup module vi.mock('../../../shared/state', () => ({ setState: vi.fn(), sendMessage: vi.fn(), navigate: vi.fn(), escapeHtml: (s: string) => s, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn(), })); import { sendMessage, navigate } from '../../../shared/state'; describe('devices view', () => { let app: HTMLElement; beforeEach(() => { document.body.innerHTML = '
'; app = document.getElementById('app')!; vi.clearAllMocks(); }); // The component fires list_devices + list_revoked in parallel via Promise.all, // so every render needs both mocked. Helper makes the per-test setup readable. function mockListPair(devices: unknown[], revoked: unknown[] = []): void { (sendMessage as ReturnType) .mockResolvedValueOnce({ ok: true, data: { devices } }) .mockResolvedValueOnce({ ok: true, data: { revoked } }); } it('renders empty state when no devices', async () => { mockListPair([]); await renderDevices(app); expect(app.innerHTML).toContain('No devices registered'); }); it('renders devices with "you" indicator on current device', async () => { mockListPair([ { name: 'Chrome on Linux', public_key: 'abc', added_at: 1000 }, { name: 'CLI', public_key: 'def', added_at: 500 }, ]); await renderDevices(app); expect(app.innerHTML).toContain('Chrome on Linux'); expect(app.innerHTML).toContain('← you'); expect(app.innerHTML).toContain('CLI'); // Current device should not have revoke button const rows = app.querySelectorAll('.device-row'); expect(rows[0].querySelector('[data-revoke]')).toBeNull(); expect(rows[1].querySelector('[data-revoke]')).not.toBeNull(); }); it('shows unregistered banner when current device not in list', async () => { (chrome.storage.local.get as ReturnType).mockResolvedValueOnce({ device_name: 'Unknown' }); mockListPair([{ name: 'CLI', public_key: 'abc', added_at: 1000 }]); await renderDevices(app); expect(app.innerHTML).toContain("This device isn't registered"); }); it('back button navigates to list', async () => { mockListPair([]); await renderDevices(app); app.querySelector('#back-btn')?.click(); expect(navigate).toHaveBeenCalledWith('list'); }); it('clicking register button reveals an inline name input', async () => { (chrome.storage.local.get as ReturnType).mockResolvedValueOnce({ device_name: 'Unknown' }); mockListPair([{ name: 'CLI', public_key: 'k', added_at: 1 }]); await renderDevices(app); app.querySelector('#register-btn')!.click(); expect(app.querySelector('#register-name-input')).not.toBeNull(); expect(app.querySelector('#register-confirm-btn')).not.toBeNull(); }); // Plan C Phase 5 — defensive Promise.allSettled: // a rejected secondary feed (list_revoked) should not kill the whole render. it('renders devices when revoked list fails (load-error slot shown)', async () => { (sendMessage as ReturnType) .mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] } }) .mockRejectedValueOnce(new Error('boom')); await renderDevices(app); // Primary list still rendered. expect(app.innerHTML).toContain('CLI'); // Inline fallback slot present. expect(app.innerHTML).toContain("Couldn't load revoked devices"); }); it('renders devices when revoked list returns {ok:false}', async () => { (sendMessage as ReturnType) .mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] } }) .mockResolvedValueOnce({ ok: false, error: 'list_revoked_failed' }); await renderDevices(app); expect(app.innerHTML).toContain('CLI'); expect(app.innerHTML).toContain("Couldn't load revoked devices"); }); it('confirming register sends register_this_device with the entered name', async () => { (chrome.storage.local.get as ReturnType).mockResolvedValueOnce({ device_name: 'Unknown' }); // Initial render: list_devices + list_revoked. mockListPair([{ name: 'CLI', public_key: 'k', added_at: 1 }]); // register_this_device. (sendMessage as ReturnType).mockResolvedValueOnce({ ok: true }); // Re-render: list_devices + list_revoked. mockListPair([ { name: 'CLI', public_key: 'k', added_at: 1 }, { name: 'Test Browser', public_key: 'q', added_at: 2 }, ]); // Re-render also re-reads device_name from storage. (chrome.storage.local.get as ReturnType).mockResolvedValueOnce({ device_name: 'Test Browser' }); await renderDevices(app); app.querySelector('#register-btn')!.click(); const input = app.querySelector('#register-name-input')!; input.value = 'Test Browser'; app.querySelector('#register-confirm-btn')!.click(); // Wait a microtask for the async handler to run. await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0)); expect(sendMessage).toHaveBeenCalledWith({ type: 'register_this_device', name: 'Test Browser' }); }); });