refactor(extension): class-based command registry + modular src layout
Restructure the MV3 background worker from a monolithic core.ts/index.ts
into a class-based command architecture. Behavior is identical — the 83
registered commands dispatch byte-for-byte the same as before.
Structure
- One class per command group, each extending CommandGroup and exporting a
`commands` map keyed by the full command id ("tabs.close"). Groups:
Navigation, TabsMutation, TabsQuery, Groups, Windows, Dom (dom/extract/
page), BrowserData (storage/cookies), Session (session/clients + autosave
+ lazy-tab activation), Perf (perf + jobs.status/cancel), Extension.
- CommandRegistry merges the group maps (throws on duplicate ids), routes
background specs to JobManager and paginates array results via
makePagedData. JobManager owns the job map + lifecycle. NativeConnection
owns the native-port lifecycle and the inbound message router.
- index.ts is now thin wiring: JobManager -> ctx -> assembleRegistry ->
onActivated -> NativeConnection.start().
- Infra classes live in classes/ (PascalCase, file = class name); command
groups in commands/; shared helpers split out of core.ts into core/
(errors, throttle, scripting, tab-helpers, group-helpers, storage); all
types moved into types/ (json, jobs, session, tabs, messages,
command-args) behind a barrel.
DRY cleanup
- resolveTabUrl(tabId) and assertScriptableUrl(url, action) collapse the
tab/URL-guard boilerplate duplicated across dom.ts and browser-data.ts.
- processInBatches() centralizes the throttled, cancellable batch loop
shared by tabs.close, group.close and tabs.merge_windows.
- captureCurrentSession() dedups the snapshot-and-signature block shared by
session.save and the autosave path.
- DomArgs type alias replaces 21 inline ContentArgs & { tabId? } copies.
- Drop fetchTabHtml's redundant retry loop (executeScript already retries
transient frame/tab errors), a dead tabInfo import, and two stale
comments referencing a removed asArgs helper.
Type safety & tests
- Full noImplicitAny; no `any`/`unknown` annotations remain in src.
- JS unit-test harness using node --test + node:assert (zero new deps),
bundled via the existing esbuild. Covers JobManager retention/lifecycle
and the autosave listener-wiring/debounce with an in-memory chrome mock.
- The structural pytest checks track the new file homes and the centralized
processInBatches helper.
Verification: npm run check:extension green (tsc + esbuild 84.5kb +
node --check + 18 JS tests); uv run pytest -q -> 409 passed, 105 skipped.
No version bump.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeChromeMock } from './chrome-mock';
|
||||
import { AutoSaveManager } from '../src/commands/autosave';
|
||||
|
||||
let chromeMock;
|
||||
let session;
|
||||
|
||||
// Flush pending microtasks/promise chains after advancing fake timers.
|
||||
// setImmediate stays real (only setTimeout is mocked), so it yields a
|
||||
// macrotask boundary that drains the microtask queue between iterations.
|
||||
async function drain() {
|
||||
for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r));
|
||||
}
|
||||
async function tick(ms) {
|
||||
mock.timers.tick(ms);
|
||||
await drain();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
chromeMock = makeChromeMock();
|
||||
globalThis.chrome = chromeMock;
|
||||
mock.timers.enable({ apis: ['setTimeout'] });
|
||||
session = new AutoSaveManager();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clear any pending debounce timer + instance state before tearing down timers.
|
||||
await session.setEnabled(false);
|
||||
await drain();
|
||||
mock.timers.reset();
|
||||
delete globalThis.chrome;
|
||||
});
|
||||
|
||||
const TAB_EVENTS = ['onCreated', 'onRemoved', 'onMoved', 'onAttached', 'onDetached'];
|
||||
|
||||
describe('sessionAutoSave listener wiring', () => {
|
||||
it('registers a handler on every tab mutation event + tabGroups when enabled', async () => {
|
||||
await session.setEnabled(true);
|
||||
for (const ev of TAB_EVENTS) {
|
||||
assert.equal(chromeMock.tabs[ev]._size(), 1, `tabs.${ev}`);
|
||||
}
|
||||
assert.equal(chromeMock.tabs.onUpdated._size(), 1);
|
||||
assert.equal(chromeMock.tabGroups.onUpdated._size(), 1);
|
||||
});
|
||||
|
||||
it('removes all listeners when disabled (no leaked subscriptions)', async () => {
|
||||
await session.setEnabled(true);
|
||||
await session.setEnabled(false);
|
||||
for (const ev of TAB_EVENTS) {
|
||||
assert.equal(chromeMock.tabs[ev]._size(), 0, `tabs.${ev}`);
|
||||
}
|
||||
assert.equal(chromeMock.tabs.onUpdated._size(), 0);
|
||||
assert.equal(chromeMock.tabGroups.onUpdated._size(), 0);
|
||||
});
|
||||
|
||||
it('does not double-register when enabled twice', async () => {
|
||||
await session.setEnabled(true);
|
||||
await session.setEnabled(true);
|
||||
for (const ev of TAB_EVENTS) {
|
||||
assert.equal(chromeMock.tabs[ev]._size(), 1, `tabs.${ev}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('autosave debounce / coalescing', () => {
|
||||
it('collapses a burst of tab events into a single snapshot+save', async () => {
|
||||
await session.setEnabled(true);
|
||||
chromeMock.tabs.query.mock.resetCalls();
|
||||
|
||||
// Simulate rapidly opening/closing several tabs.
|
||||
await session.autoSaveHandler();
|
||||
await session.autoSaveHandler();
|
||||
await session.autoSaveHandler();
|
||||
await session.autoSaveHandler();
|
||||
|
||||
// Nothing should have run yet — still inside the debounce window.
|
||||
assert.equal(chromeMock.tabs.query.mock.callCount(), 0);
|
||||
|
||||
await tick(3000);
|
||||
|
||||
// The whole burst produced exactly one snapshot read.
|
||||
assert.equal(chromeMock.tabs.query.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it('does not fire before the (raised) 3s debounce window elapses', async () => {
|
||||
await session.setEnabled(true);
|
||||
chromeMock.tabs.query.mock.resetCalls();
|
||||
|
||||
await session.autoSaveHandler();
|
||||
await tick(2999);
|
||||
assert.equal(chromeMock.tabs.query.mock.callCount(), 0);
|
||||
|
||||
await tick(1);
|
||||
assert.equal(chromeMock.tabs.query.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it('ignores tab updates that do not change the URL (no save scheduled)', async () => {
|
||||
await session.setEnabled(true);
|
||||
chromeMock.tabs.query.mock.resetCalls();
|
||||
|
||||
// status/favicon/title churn — not a URL change.
|
||||
await session.autoSaveUpdatedHandler(1, { status: 'loading' });
|
||||
await session.autoSaveUpdatedHandler(1, { favIconUrl: 'x' });
|
||||
await session.autoSaveUpdatedHandler(1, { title: 'y' });
|
||||
|
||||
await tick(3000);
|
||||
assert.equal(chromeMock.tabs.query.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('schedules a save when a URL change is observed', async () => {
|
||||
await session.setEnabled(true);
|
||||
chromeMock.tabs.query.mock.resetCalls();
|
||||
|
||||
await session.autoSaveUpdatedHandler(1, { url: 'https://example.com' });
|
||||
await tick(3000);
|
||||
assert.equal(chromeMock.tabs.query.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it('does nothing when autosave is disabled even if a handler fires', async () => {
|
||||
// Never enabled: storage has no autoSave flag.
|
||||
await session.autoSaveHandler();
|
||||
await tick(3000);
|
||||
assert.equal(chromeMock.tabs.query.mock.callCount(), 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// @ts-nocheck
|
||||
import { mock } from 'node:test';
|
||||
|
||||
/**
|
||||
* Minimal in-memory chrome.* stub for service-worker unit tests.
|
||||
* Only the surface the tested modules touch is implemented.
|
||||
* Mock functions use node:test's `mock.fn` (callCount/resetCalls via `.mock`).
|
||||
*/
|
||||
export function makeChromeMock() {
|
||||
const store: Record<string, any> = {};
|
||||
const listeners = new Map<string, Set<Function>>();
|
||||
|
||||
function event(name: string) {
|
||||
if (!listeners.has(name)) listeners.set(name, new Set());
|
||||
const set = listeners.get(name)!;
|
||||
return {
|
||||
addListener: (fn: Function) => set.add(fn),
|
||||
removeListener: (fn: Function) => set.delete(fn),
|
||||
hasListener: (fn: Function) => set.has(fn),
|
||||
// test helper: count registered listeners
|
||||
_size: () => set.size,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
storage: {
|
||||
local: {
|
||||
get: mock.fn(async (key: string | string[]) => {
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
const out: Record<string, any> = {};
|
||||
for (const k of keys) if (k in store) out[k] = store[k];
|
||||
return out;
|
||||
}),
|
||||
set: mock.fn(async (obj: Record<string, any>) => {
|
||||
Object.assign(store, obj);
|
||||
}),
|
||||
_store: store,
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
query: mock.fn(async () => []),
|
||||
onCreated: event('tabs.onCreated'),
|
||||
onRemoved: event('tabs.onRemoved'),
|
||||
onMoved: event('tabs.onMoved'),
|
||||
onAttached: event('tabs.onAttached'),
|
||||
onDetached: event('tabs.onDetached'),
|
||||
onUpdated: event('tabs.onUpdated'),
|
||||
},
|
||||
tabGroups: {
|
||||
query: mock.fn(async () => []),
|
||||
onUpdated: event('tabGroups.onUpdated'),
|
||||
},
|
||||
_event: event,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// @ts-nocheck
|
||||
import { test, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { JobManager, JOB_TIMEOUT_MS, MAX_FINISHED_JOBS, pruneFinishedJobs } from '../src/classes/JobManager';
|
||||
import { makeChromeMock } from './chrome-mock';
|
||||
|
||||
// Drain pending microtasks (finalize() chains several awaits). setImmediate is
|
||||
// never mocked here, so it fires after the current microtask queue is empty.
|
||||
const flush = () => new Promise(r => setImmediate(r));
|
||||
|
||||
function makeJobs(specs) {
|
||||
const map = new Map();
|
||||
for (const s of specs) map.set(s.id, { __timer: null, ...s });
|
||||
return map;
|
||||
}
|
||||
|
||||
test('pruneFinishedJobs: keeps everything when under the cap', () => {
|
||||
const jobs = makeJobs([
|
||||
{ id: 'a', status: 'done', finishedAt: 1 },
|
||||
{ id: 'b', status: 'done', finishedAt: 2 },
|
||||
]);
|
||||
const evicted = pruneFinishedJobs(jobs, 5);
|
||||
assert.deepEqual(evicted, []);
|
||||
assert.equal(jobs.size, 2);
|
||||
});
|
||||
|
||||
test('pruneFinishedJobs: evicts the oldest finished jobs beyond the cap', () => {
|
||||
const jobs = makeJobs([
|
||||
{ id: 'old', status: 'done', finishedAt: 10 },
|
||||
{ id: 'mid', status: 'error', finishedAt: 20 },
|
||||
{ id: 'new', status: 'done', finishedAt: 30 },
|
||||
]);
|
||||
const evicted = pruneFinishedJobs(jobs, 2);
|
||||
assert.deepEqual(evicted, ['old']);
|
||||
assert.deepEqual([...jobs.keys()], ['mid', 'new']);
|
||||
});
|
||||
|
||||
test('pruneFinishedJobs: never evicts running jobs, even past the cap', () => {
|
||||
const jobs = makeJobs([
|
||||
{ id: 'run1', status: 'running' },
|
||||
{ id: 'run2', status: 'running' },
|
||||
{ id: 'f1', status: 'done', finishedAt: 1 },
|
||||
{ id: 'f2', status: 'done', finishedAt: 2 },
|
||||
{ id: 'f3', status: 'done', finishedAt: 3 },
|
||||
]);
|
||||
pruneFinishedJobs(jobs, 1);
|
||||
// both running kept; only newest finished (f3) kept
|
||||
assert.deepEqual([...jobs.keys()].sort(), ['f3', 'run1', 'run2']);
|
||||
});
|
||||
|
||||
test('pruneFinishedJobs: clears the timer of every evicted job (no interval leak)', () => {
|
||||
const clear = mock.fn();
|
||||
const jobs = makeJobs([
|
||||
{ id: 'a', status: 'done', finishedAt: 1, __timer: 101 },
|
||||
{ id: 'b', status: 'done', finishedAt: 2, __timer: 102 },
|
||||
{ id: 'c', status: 'done', finishedAt: 3, __timer: 103 },
|
||||
]);
|
||||
pruneFinishedJobs(jobs, 1, clear);
|
||||
const clearedArgs = clear.mock.calls.map(c => c.arguments[0]);
|
||||
assert.ok(clearedArgs.includes(101));
|
||||
assert.ok(clearedArgs.includes(102));
|
||||
assert.ok(!clearedArgs.includes(103)); // surviving job's timer untouched
|
||||
});
|
||||
|
||||
test('pruneFinishedJobs: treats missing finishedAt as oldest (evicted first)', () => {
|
||||
const jobs = makeJobs([
|
||||
{ id: 'nofin', status: 'done' },
|
||||
{ id: 'has', status: 'done', finishedAt: 5 },
|
||||
]);
|
||||
pruneFinishedJobs(jobs, 1);
|
||||
assert.deepEqual([...jobs.keys()], ['has']);
|
||||
});
|
||||
|
||||
test('pruneFinishedJobs: defaults to MAX_FINISHED_JOBS and bounds the retained set', () => {
|
||||
const specs = Array.from({ length: MAX_FINISHED_JOBS + 25 }, (_, i) => ({
|
||||
id: `j${i}`, status: 'done', finishedAt: i,
|
||||
}));
|
||||
const jobs = makeJobs(specs);
|
||||
pruneFinishedJobs(jobs);
|
||||
assert.equal(jobs.size, MAX_FINISHED_JOBS);
|
||||
// the 25 oldest (j0..j24) are gone; newest survive
|
||||
assert.equal(jobs.has('j0'), false);
|
||||
assert.equal(jobs.has('j24'), false);
|
||||
assert.equal(jobs.has('j25'), true);
|
||||
});
|
||||
|
||||
test('JobManager: a resolving runner finishes the job and clears its timers', async () => {
|
||||
globalThis.chrome = makeChromeMock();
|
||||
const mgr = new JobManager();
|
||||
const { jobId } = await mgr.start('demo', {}, async () => ({ ok: 1 }));
|
||||
await flush();
|
||||
const job = await mgr.status({ jobId });
|
||||
assert.equal(job.status, 'done');
|
||||
assert.deepEqual(job.result, { ok: 1 });
|
||||
assert.equal(job.percent, 100);
|
||||
assert.equal(job.__timer ?? null, null);
|
||||
assert.equal(job.__watchdog ?? null, null);
|
||||
});
|
||||
|
||||
test('JobManager: watchdog finalizes a hung runner and stops the persist timer', async () => {
|
||||
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
||||
globalThis.chrome = makeChromeMock();
|
||||
const mgr = new JobManager();
|
||||
// Runner never settles — only the watchdog can finish this job.
|
||||
const { jobId } = await mgr.start('hang', {}, () => new Promise(() => {}));
|
||||
mock.timers.tick(JOB_TIMEOUT_MS);
|
||||
await flush();
|
||||
const job = await mgr.status({ jobId });
|
||||
assert.equal(job.status, 'error');
|
||||
assert.match(job.error, /timed out/);
|
||||
assert.equal(job.cancelRequested, true);
|
||||
assert.equal(job.__timer ?? null, null);
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
test('JobManager: a runner that settles after the watchdog cannot resurrect the job', async () => {
|
||||
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
||||
globalThis.chrome = makeChromeMock();
|
||||
const mgr = new JobManager();
|
||||
let settle;
|
||||
const { jobId } = await mgr.start('late', {}, () => new Promise(r => { settle = r; }));
|
||||
mock.timers.tick(JOB_TIMEOUT_MS);
|
||||
await flush();
|
||||
settle({ tooLate: true }); // runner resolves only after the watchdog fired
|
||||
await flush();
|
||||
const job = await mgr.status({ jobId });
|
||||
assert.equal(job.status, 'error'); // stayed error — finalize() ran exactly once
|
||||
assert.equal(job.result, null);
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
test('JobManager: persisted set keeps running jobs even past the finished cap', async () => {
|
||||
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
||||
globalThis.chrome = makeChromeMock();
|
||||
const mgr = new JobManager();
|
||||
await mgr.start('runner', {}, () => new Promise(() => {})); // stays running
|
||||
for (let i = 0; i < MAX_FINISHED_JOBS + 5; i++) {
|
||||
await mgr.start(`done${i}`, {}, async () => i);
|
||||
}
|
||||
await flush();
|
||||
const stored = globalThis.chrome.storage.local._store.recentJobs;
|
||||
const running = stored.filter(j => j.status === 'running');
|
||||
assert.equal(running.length, 1, 'the running job must never be evicted from storage');
|
||||
assert.ok(stored.length <= MAX_FINISHED_JOBS + 1, 'finished jobs stay capped');
|
||||
mock.timers.reset();
|
||||
});
|
||||
Reference in New Issue
Block a user