ba01be1c5d
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.
128 lines
4.3 KiB
TypeScript
128 lines
4.3 KiB
TypeScript
// @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);
|
|
});
|
|
});
|