Requiring an explicit tab_id forced every navigate or close through a preceding tabs_list call, which costs an MCP client a full round trip just to learn the ID the browser already considers current. navigate and tabs_close now resolve the active tab when tab_id is omitted, matching the screenshot tool. Resolution is explicit rather than forwarding None into the SDK, so the acting tool knows which tab it touched; tabs_close reports it, since closing the wrong tab is not recoverable. This stays in the MCP layer: the SDK and CLI signatures are unchanged.
162 lines
6.3 KiB
TypeScript
162 lines
6.3 KiB
TypeScript
// @ts-nocheck
|
|
import { test, mock } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { JobManager, JOB_TIMEOUT_MS, MAX_FINISHED_JOBS, MAX_RUNNING_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: rejects new background jobs above the running-job cap', async () => {
|
|
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
|
globalThis.chrome = makeChromeMock();
|
|
const mgr = new JobManager();
|
|
for (let i = 0; i < MAX_RUNNING_JOBS; i++) {
|
|
await mgr.start(`running${i}`, {}, () => new Promise(() => {}));
|
|
}
|
|
await assert.rejects(
|
|
() => mgr.start('overflow', {}, async () => 'nope'),
|
|
/too many background jobs running/,
|
|
);
|
|
assert.equal(mgr.list().filter(job => job.status === 'running').length, MAX_RUNNING_JOBS);
|
|
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();
|
|
});
|