DEV-C P2: Promise.all meant one rejected RPC failed the whole render.
allSettled + per-slot fallback keeps the active-devices surface usable
when the revoked-list feed (or one bad ssh fingerprint) is down.
Two call sites converted in devices.ts:
1. list_devices + list_revoked pair — revoked failures now render an
inline "couldn't load" slot instead of failing the page.
2. sshFingerprint map — one bad public key falls back to '(unknown)'
instead of killing the whole device list.
trash.ts only has a single sendMessage in its load path on this branch,
so it has no Promise.all to migrate. Plan was written against a slightly
different snapshot; documented divergence in report.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
150 lines
5.5 KiB
TypeScript
150 lines
5.5 KiB
TypeScript
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 = '<div id="app"></div>';
|
|
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<typeof vi.fn>)
|
|
.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<typeof vi.fn>).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<HTMLButtonElement>('#back-btn')?.click();
|
|
|
|
expect(navigate).toHaveBeenCalledWith('list');
|
|
});
|
|
|
|
it('clicking register button reveals an inline name input', async () => {
|
|
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
|
|
mockListPair([{ name: 'CLI', public_key: 'k', added_at: 1 }]);
|
|
|
|
await renderDevices(app);
|
|
app.querySelector<HTMLButtonElement>('#register-btn')!.click();
|
|
|
|
expect(app.querySelector<HTMLInputElement>('#register-name-input')).not.toBeNull();
|
|
expect(app.querySelector<HTMLButtonElement>('#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<typeof vi.fn>)
|
|
.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<typeof vi.fn>)
|
|
.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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Test Browser' });
|
|
|
|
await renderDevices(app);
|
|
app.querySelector<HTMLButtonElement>('#register-btn')!.click();
|
|
const input = app.querySelector<HTMLInputElement>('#register-name-input')!;
|
|
input.value = 'Test Browser';
|
|
app.querySelector<HTMLButtonElement>('#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' });
|
|
});
|
|
});
|