ext: popup, options, and the panel bridge (build order step 6)
Popup and Options are separate documents from the background page and can't reach its live VeloxTransport directly, so background/bridge.ts relays it over one browser.runtime.connect port per document (call/subscribe/getStatus/reconnect/pair/unpair/setOverride in; result/event/status/pairError out). shared/panel-client.ts is the client side both surfaces use. Popup (src/popup/): status dot + text, active-downloads list driven by event.task.progress/added/state/removed (repaints ride the event's own <=4 Hz cap rather than adding a second timer), pause/resume buttons, "Start it" wired to a reconnect request. State lives in a DOM-free store.ts for unit testing; rendering uses createElement, not innerHTML (web-ext lint flags the latter). Options (src/options/): transport override select, pairing (code entry + pair/unpair, backed by two new WebSocketTransport methods, pairWithCode/unpair), and the daemon's capture policy mirrored via capture.getRules. The capture-policy form is editable only when the active transport is native messaging (uds) -- settings.set and rules.upsert are privileged, uds-only methods per shared/protocol METHODS, so WebSocket can't write them no matter what the page shows; CLAUDE.md section 2 rules out working around that locally. The bridge's status payload adds a kind field (which transport is live) for this to key off. Default category is the one piece of state that's genuinely the extension's own, not the daemon's, and lives in browser.storage.local via options/prefs.ts. Pure decisions (statusLine, pairingAvailable, captureRulesEditable) are split into view.ts for unit testing without a DOM. manifest.json registers the popup action and options_ui page (and, in the same edit, the content_scripts entry the next commit's media detection needs -- split by file, not by manifest line). build.mjs gains popup/options as further esbuild entry points, plus copying their static HTML/CSS into dist/. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Ed8KEmAW48v4YHdxLtqsMB
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PanelBridge, type PanelRequest, type PanelResponse, type PortLike } from '../../src/background/bridge.js';
|
||||
import type { TransportStatus, VeloxTransport } from '../../src/background/transport/index.js';
|
||||
|
||||
function fakePort(): PortLike & { received: PanelResponse[]; emit(msg: PanelRequest): void; close(): void } {
|
||||
const msgListeners = new Set<(msg: PanelRequest) => void>();
|
||||
const discListeners = new Set<() => void>();
|
||||
const received: PanelResponse[] = [];
|
||||
return {
|
||||
name: 'velox-panel',
|
||||
received,
|
||||
postMessage: (m) => received.push(m),
|
||||
onMessage: { addListener: (cb) => msgListeners.add(cb) },
|
||||
onDisconnect: { addListener: (cb) => discListeners.add(cb) },
|
||||
emit: (msg) => msgListeners.forEach((cb) => cb(msg)),
|
||||
close: () => discListeners.forEach((cb) => cb()),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeTransport(status: TransportStatus): VeloxTransport & { fireStatus(s: TransportStatus): void; calls: unknown[] } {
|
||||
const stateListeners = new Set<(s: TransportStatus) => void>();
|
||||
const eventListeners = new Map<string, Set<(p: unknown) => void>>();
|
||||
const calls: unknown[] = [];
|
||||
return {
|
||||
kind: 'ws',
|
||||
state: status.state,
|
||||
status,
|
||||
calls,
|
||||
connect: async () => undefined,
|
||||
disconnect: () => undefined,
|
||||
call: (async (method: string, params: unknown) => {
|
||||
calls.push({ method, params });
|
||||
if (method === 'boom') throw Object.assign(new Error('nope'), { code: -32010 });
|
||||
return { echoed: params };
|
||||
}) as VeloxTransport['call'],
|
||||
on: ((event: string, cb: (p: unknown) => void) => {
|
||||
let s = eventListeners.get(event);
|
||||
if (!s) eventListeners.set(event, (s = new Set()));
|
||||
s.add(cb);
|
||||
}) as VeloxTransport['on'],
|
||||
off: (event: string, cb: (p: unknown) => void) => {
|
||||
eventListeners.get(event)?.delete(cb);
|
||||
},
|
||||
onStateChange: (cb: (s: TransportStatus) => void) => {
|
||||
stateListeners.add(cb);
|
||||
return () => stateListeners.delete(cb);
|
||||
},
|
||||
fireStatus: (s: TransportStatus) => stateListeners.forEach((cb) => cb(s)),
|
||||
} as unknown as VeloxTransport & { fireStatus(s: TransportStatus): void; calls: unknown[] };
|
||||
}
|
||||
|
||||
const CONNECTED: TransportStatus = {
|
||||
state: 'connected',
|
||||
needsPairing: false,
|
||||
fatal: null,
|
||||
retryAfterSec: null,
|
||||
sessionId: 's1',
|
||||
daemonVersion: '1.0.0',
|
||||
capabilities: [],
|
||||
};
|
||||
|
||||
describe('PanelBridge', () => {
|
||||
it('sends the current status on connect', () => {
|
||||
const t = fakeTransport(CONNECTED);
|
||||
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
|
||||
const port = fakePort();
|
||||
bridge.attach({ addListener: (cb) => cb(port) });
|
||||
|
||||
expect(port.received[0]).toEqual({ type: 'status', status: { ...CONNECTED, kind: 'ws' } });
|
||||
});
|
||||
|
||||
it('forwards a call and relays the result', async () => {
|
||||
const t = fakeTransport(CONNECTED);
|
||||
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
|
||||
const port = fakePort();
|
||||
bridge.attach({ addListener: (cb) => cb(port) });
|
||||
|
||||
port.emit({ type: 'call', id: 7, method: 'download.list', params: {} });
|
||||
await vi.waitFor(() => expect(port.received.some((m) => m.type === 'result')).toBe(true));
|
||||
|
||||
const result = port.received.find((m) => m.type === 'result');
|
||||
expect(result).toEqual({ type: 'result', id: 7, ok: true, result: { echoed: {} } });
|
||||
});
|
||||
|
||||
it('relays a call error with its code', async () => {
|
||||
const t = fakeTransport(CONNECTED);
|
||||
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
|
||||
const port = fakePort();
|
||||
bridge.attach({ addListener: (cb) => cb(port) });
|
||||
|
||||
port.emit({ type: 'call', id: 1, method: 'boom' as never, params: {} });
|
||||
await vi.waitFor(() => expect(port.received.some((m) => m.type === 'result')).toBe(true));
|
||||
|
||||
const result = port.received.find((m) => m.type === 'result');
|
||||
expect(result).toEqual({ type: 'result', id: 1, ok: false, error: { code: -32010, message: 'nope' } });
|
||||
});
|
||||
|
||||
it('relays a call error when there is no transport yet', async () => {
|
||||
const bridge = new PanelBridge({ getTransport: () => undefined, setOverride: async () => undefined });
|
||||
const port = fakePort();
|
||||
bridge.attach({ addListener: (cb) => cb(port) });
|
||||
|
||||
port.emit({ type: 'call', id: 2, method: 'download.list', params: {} });
|
||||
await vi.waitFor(() => expect(port.received.some((m) => m.type === 'result')).toBe(true));
|
||||
|
||||
const result = port.received.find((m) => m.type === 'result');
|
||||
expect(result).toEqual({ type: 'result', id: 2, ok: false, error: { message: 'transport not ready' } });
|
||||
});
|
||||
|
||||
it('a disconnected port stops receiving after disconnect', () => {
|
||||
const t = fakeTransport(CONNECTED);
|
||||
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
|
||||
const port = fakePort();
|
||||
bridge.attach({ addListener: (cb) => cb(port) });
|
||||
|
||||
port.close();
|
||||
expect(() => port.emit({ type: 'getStatus' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('relays event.speed.global to the port after subscribing', () => {
|
||||
const stateListeners = new Set<(s: TransportStatus) => void>();
|
||||
const eventCbs = new Map<string, (p: unknown) => void>();
|
||||
const t: VeloxTransport = {
|
||||
kind: 'ws',
|
||||
state: 'connected',
|
||||
status: CONNECTED,
|
||||
connect: async () => undefined,
|
||||
disconnect: () => undefined,
|
||||
call: (async () => ({})) as VeloxTransport['call'],
|
||||
on: ((event: string, cb: (p: unknown) => void) => {
|
||||
eventCbs.set(event, cb);
|
||||
}) as VeloxTransport['on'],
|
||||
off: () => undefined,
|
||||
onStateChange: (cb) => {
|
||||
stateListeners.add(cb);
|
||||
return () => stateListeners.delete(cb);
|
||||
},
|
||||
};
|
||||
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
|
||||
const port = fakePort();
|
||||
bridge.attach({ addListener: (cb) => cb(port) });
|
||||
|
||||
port.emit({ type: 'subscribe', events: ['event.speed.global'] });
|
||||
eventCbs.get('event.speed.global')?.({ bytesPerSec: 4096 });
|
||||
|
||||
expect(port.received).toContainEqual({ type: 'event', event: 'event.speed.global', payload: { bytesPerSec: 4096 } });
|
||||
});
|
||||
|
||||
it('pairs with a code and reports the resulting status', async () => {
|
||||
const pairWithCode = vi.fn(async () => undefined);
|
||||
const t = { ...fakeTransport(CONNECTED), pairWithCode };
|
||||
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
|
||||
const port = fakePort();
|
||||
bridge.attach({ addListener: (cb) => cb(port) });
|
||||
|
||||
port.emit({ type: 'pair', code: '4821' });
|
||||
await vi.waitFor(() => expect(pairWithCode).toHaveBeenCalledWith('4821'));
|
||||
});
|
||||
|
||||
it('reports a pairError when the transport has no pairWithCode (e.g. native transport)', async () => {
|
||||
const t = fakeTransport(CONNECTED); // no pairWithCode
|
||||
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
|
||||
const port = fakePort();
|
||||
bridge.attach({ addListener: (cb) => cb(port) });
|
||||
|
||||
port.emit({ type: 'pair', code: '4821' });
|
||||
await vi.waitFor(() => expect(port.received.some((m) => m.type === 'pairError')).toBe(true));
|
||||
});
|
||||
|
||||
it('unpairs via the transport and reports status', async () => {
|
||||
const unpair = vi.fn(async () => undefined);
|
||||
const t = { ...fakeTransport(CONNECTED), unpair };
|
||||
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
|
||||
const port = fakePort();
|
||||
bridge.attach({ addListener: (cb) => cb(port) });
|
||||
|
||||
port.emit({ type: 'unpair' });
|
||||
await vi.waitFor(() => expect(unpair).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('delegates setOverride to the deps and reports the new status', async () => {
|
||||
const setOverride = vi.fn(async () => undefined);
|
||||
const t = fakeTransport(CONNECTED);
|
||||
const bridge = new PanelBridge({ getTransport: () => t, setOverride });
|
||||
const port = fakePort();
|
||||
bridge.attach({ addListener: (cb) => cb(port) });
|
||||
|
||||
port.emit({ type: 'setOverride', override: 'uds' });
|
||||
await vi.waitFor(() => expect(setOverride).toHaveBeenCalledWith('uds'));
|
||||
});
|
||||
|
||||
it('getStatus answers with the disconnected sentinel when there is no transport', () => {
|
||||
const bridge = new PanelBridge({ getTransport: () => undefined, setOverride: async () => undefined });
|
||||
const port = fakePort();
|
||||
bridge.attach({ addListener: (cb) => cb(port) });
|
||||
port.received.length = 0;
|
||||
|
||||
port.emit({ type: 'getStatus' });
|
||||
expect(port.received[0]).toEqual({
|
||||
type: 'status',
|
||||
status: { state: 'disconnected', needsPairing: false, fatal: null, retryAfterSec: null, sessionId: null, daemonVersion: null, capabilities: [], kind: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getDefaultCategoryId, setDefaultCategoryId } from '../../src/options/prefs.js';
|
||||
|
||||
describe('options/prefs', () => {
|
||||
beforeEach(async () => {
|
||||
await browser.storage.local.clear();
|
||||
});
|
||||
|
||||
it('defaults to null (no preference set)', async () => {
|
||||
expect(await getDefaultCategoryId()).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips a chosen category', async () => {
|
||||
await setDefaultCategoryId('cat-videos');
|
||||
expect(await getDefaultCategoryId()).toBe('cat-videos');
|
||||
});
|
||||
|
||||
it('clears back to null', async () => {
|
||||
await setDefaultCategoryId('cat-videos');
|
||||
await setDefaultCategoryId(null);
|
||||
expect(await getDefaultCategoryId()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { captureRulesEditable, pairingAvailable, statusLine, unpairAvailable } from '../../src/options/view.js';
|
||||
import type { PanelStatus } from '../../src/background/bridge.js';
|
||||
|
||||
function status(over: Partial<PanelStatus> = {}): PanelStatus {
|
||||
return {
|
||||
state: 'connected',
|
||||
needsPairing: false,
|
||||
fatal: null,
|
||||
retryAfterSec: null,
|
||||
sessionId: null,
|
||||
daemonVersion: null,
|
||||
capabilities: [],
|
||||
kind: 'ws',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('statusLine', () => {
|
||||
it('names the state, version, and transport kind', () => {
|
||||
expect(statusLine(status({ daemonVersion: '1.2.3' }))).toBe('connected · v1.2.3 · via WebSocket');
|
||||
});
|
||||
|
||||
it('calls out native messaging', () => {
|
||||
expect(statusLine(status({ kind: 'uds' }))).toBe('connected · via native messaging');
|
||||
});
|
||||
|
||||
it('surfaces "needs pairing" with no retry hint', () => {
|
||||
expect(statusLine(status({ state: 'disconnected', needsPairing: true, retryAfterSec: null, kind: null }))).toBe(
|
||||
'disconnected · needs pairing',
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces a pairing lockout with the retry hint', () => {
|
||||
expect(
|
||||
statusLine(status({ state: 'disconnected', needsPairing: true, retryAfterSec: 45, kind: 'ws' })),
|
||||
).toBe('disconnected · via WebSocket · pairing locked (45s)');
|
||||
});
|
||||
|
||||
it('surfaces a fatal condition', () => {
|
||||
expect(statusLine(status({ fatal: 'protocol mismatch' }))).toContain('protocol mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pairingAvailable', () => {
|
||||
it('is available on ws and before the kind is known', () => {
|
||||
expect(pairingAvailable(status({ kind: 'ws' }))).toBe(true);
|
||||
expect(pairingAvailable(status({ kind: null }))).toBe(true);
|
||||
});
|
||||
|
||||
it('is not available on native messaging (no token concept there)', () => {
|
||||
expect(pairingAvailable(status({ kind: 'uds' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unpairAvailable', () => {
|
||||
it('only on ws, never before the kind is known (nothing to revoke yet)', () => {
|
||||
expect(unpairAvailable(status({ kind: 'ws' }))).toBe(true);
|
||||
expect(unpairAvailable(status({ kind: 'uds' }))).toBe(false);
|
||||
expect(unpairAvailable(status({ kind: null }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('captureRulesEditable', () => {
|
||||
it('only true over native messaging, matching METHODS privileged/uds-only', () => {
|
||||
expect(captureRulesEditable(status({ kind: 'uds' }))).toBe(true);
|
||||
expect(captureRulesEditable(status({ kind: 'ws' }))).toBe(false);
|
||||
expect(captureRulesEditable(status({ kind: null }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatBytes, formatEta, formatSpeed, PopupStore, progressPercent } from '../../src/popup/store.js';
|
||||
import type { TaskSummary } from '../../src/shared/protocol/index.js';
|
||||
|
||||
function summary(over: Partial<TaskSummary> = {}): TaskSummary {
|
||||
return {
|
||||
taskId: 't1',
|
||||
filename: 'file.zip',
|
||||
saveDir: '/home/u/Downloads',
|
||||
url: 'https://example.com/file.zip',
|
||||
downloadedBytes: 0,
|
||||
state: 'downloading',
|
||||
speedBps: 0,
|
||||
resumable: true,
|
||||
segments: 1,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PopupStore', () => {
|
||||
it('starts empty', () => {
|
||||
const s = new PopupStore();
|
||||
expect(s.rows()).toEqual([]);
|
||||
expect(s.size).toBe(0);
|
||||
});
|
||||
|
||||
it('seeds from download.list', () => {
|
||||
const s = new PopupStore();
|
||||
s.setInitial([summary({ taskId: 'a' }), summary({ taskId: 'b', filename: 'b.zip' })]);
|
||||
expect(s.size).toBe(2);
|
||||
});
|
||||
|
||||
it('adds a row on event.task.added', () => {
|
||||
const s = new PopupStore();
|
||||
s.onAdded({ taskId: 't1', summary: summary() });
|
||||
expect(s.rows()).toHaveLength(1);
|
||||
expect(s.rows()[0]!.filename).toBe('file.zip');
|
||||
});
|
||||
|
||||
it('applies event.task.progress as a patch, not a rebuild', () => {
|
||||
const s = new PopupStore();
|
||||
s.setInitial([summary({ taskId: 't1', sizeBytes: 1000 })]);
|
||||
s.onProgress({ at: '2026-01-01T00:00:00Z', tasks: [{ taskId: 't1', downloadedBytes: 500, speedBps: 2048, etaSeconds: 4 }] });
|
||||
const row = s.rows()[0]!;
|
||||
expect(row.downloadedBytes).toBe(500);
|
||||
expect(row.speedBps).toBe(2048);
|
||||
expect(row.etaSeconds).toBe(4);
|
||||
expect(row.filename).toBe('file.zip'); // untouched by the patch
|
||||
});
|
||||
|
||||
it('ignores a progress tick for an unknown task rather than inserting a partial row', () => {
|
||||
const s = new PopupStore();
|
||||
s.onProgress({ at: '2026-01-01T00:00:00Z', tasks: [{ taskId: 'ghost', downloadedBytes: 1, speedBps: 1 }] });
|
||||
expect(s.size).toBe(0);
|
||||
});
|
||||
|
||||
it('replaces the row on event.task.state when a summary is attached', () => {
|
||||
const s = new PopupStore();
|
||||
s.setInitial([summary({ taskId: 't1', state: 'downloading' })]);
|
||||
s.onState({ taskId: 't1', state: 'paused', summary: summary({ taskId: 't1', state: 'paused' }) });
|
||||
expect(s.rows()[0]!.state).toBe('paused');
|
||||
});
|
||||
|
||||
it('updates just the state when event.task.state has no summary', () => {
|
||||
const s = new PopupStore();
|
||||
s.setInitial([summary({ taskId: 't1', state: 'downloading' })]);
|
||||
s.onState({ taskId: 't1', state: 'failed' });
|
||||
expect(s.rows()[0]!.state).toBe('failed');
|
||||
});
|
||||
|
||||
it('drops the row on event.task.removed', () => {
|
||||
const s = new PopupStore();
|
||||
s.setInitial([summary({ taskId: 't1' })]);
|
||||
s.onRemoved({ taskId: 't1', deletedFile: false });
|
||||
expect(s.size).toBe(0);
|
||||
});
|
||||
|
||||
it('sorts active tasks before paused/finished ones, then by filename', () => {
|
||||
const s = new PopupStore();
|
||||
s.setInitial([
|
||||
summary({ taskId: 'z', filename: 'z-paused.zip', state: 'paused' }),
|
||||
summary({ taskId: 'b', filename: 'b-active.zip', state: 'downloading' }),
|
||||
summary({ taskId: 'a', filename: 'a-active.zip', state: 'downloading' }),
|
||||
]);
|
||||
expect(s.rows().map((r) => r.taskId)).toEqual(['a', 'b', 'z']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('format helpers', () => {
|
||||
it('formats bytes', () => {
|
||||
expect(formatBytes(500)).toBe('500 B');
|
||||
expect(formatBytes(2048)).toBe('2.0 KB');
|
||||
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB');
|
||||
});
|
||||
|
||||
it('formats speed, blank when idle', () => {
|
||||
expect(formatSpeed(0)).toBe('');
|
||||
expect(formatSpeed(1024)).toBe('1.0 KB/s');
|
||||
});
|
||||
|
||||
it('formats eta, blank when unknown', () => {
|
||||
expect(formatEta(null)).toBe('');
|
||||
expect(formatEta(-1)).toBe('');
|
||||
expect(formatEta(45)).toBe('45s');
|
||||
expect(formatEta(125)).toBe('2m 5s');
|
||||
});
|
||||
|
||||
it('computes a progress percent, null when size is unknown', () => {
|
||||
expect(progressPercent({ taskId: 't', filename: 'f', state: 'downloading', downloadedBytes: 50, sizeBytes: 100, speedBps: 0, etaSeconds: null })).toBe(50);
|
||||
expect(progressPercent({ taskId: 't', filename: 'f', state: 'downloading', downloadedBytes: 50, sizeBytes: null, speedBps: 0, etaSeconds: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user