test/dashboard-fleet-jobs.test.js
Rama master · réplica de lectura del repositorio original.
// Fleet runs (UISpec P0-3 / AC-FLEET-1..5).
//
// The pool-wide probe/refresh used to be ONE blocking POST: the browser could
// not show progress, could not cancel, and could only read failures by
// selecting rows first. These tests pin the replacement contract:
// - a run advances `done` per account, so a poller sees several n/N values
// - cancel stops SCHEDULING (the in-flight account finishes)
// - failures and skips keep their per-account email + reason
// - the legacy blocking routes share the same loop and the same skip
// classification (skipped is never success)
import { afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { setTimeout as sleep } from 'node:timers/promises';
import {
addAccountByKey,
configureBindHost,
removeAccount,
setAccountStatus,
_resetLockoutForTests,
} from '../src/auth.js';
import { config } from '../src/config.js';
import { handleDashboardApi } from '../src/dashboard/api.js';
import {
__resetFleetJobsForTest,
awaitFleetJob,
cancelFleetJob,
getFleetJob,
publicFleetJob,
startFleetJob,
} from '../src/dashboard/fleet-jobs.js';
import { _resetRuntimeConfigForTests } from '../src/runtime-config.js';
const originalDashboardPassword = config.dashboardPassword;
const originalApiKey = config.apiKey;
const originalAllowNoAuth = process.env.DASHBOARD_ALLOW_NO_AUTH;
const created = new Set();
afterEach(() => {
for (const id of created) removeAccount(id);
created.clear();
__resetFleetJobsForTest();
_resetRuntimeConfigForTests();
_resetLockoutForTests();
config.dashboardPassword = originalDashboardPassword;
config.apiKey = originalApiKey;
if (originalAllowNoAuth === undefined) delete process.env.DASHBOARD_ALLOW_NO_AUTH;
else process.env.DASHBOARD_ALLOW_NO_AUTH = originalAllowNoAuth;
configureBindHost('0.0.0.0');
});
function enableOpenLocal() {
_resetRuntimeConfigForTests();
config.dashboardPassword = '';
config.apiKey = '';
configureBindHost('127.0.0.1');
process.env.DASHBOARD_ALLOW_NO_AUTH = '1';
}
function add(label, patch = {}) {
const account = addAccountByKey(`fleet-key-${label}-${Date.now()}-${Math.random().toString(16).slice(2)}`, `${label}@fixture.invalid`);
Object.assign(account, patch);
created.add(account.id);
return account;
}
function fakeRes() {
return {
statusCode: 0,
body: '',
writeHead(status) { this.statusCode = status; },
end(chunk) { this.body += chunk ? String(chunk) : ''; },
json() { return this.body ? JSON.parse(this.body) : null; },
};
}
async function call(method, path, body) {
const res = fakeRes();
// The real server hands handleDashboardApi a subpath without the query
// string; dashboardUrl(req) carries the query. Mirror that here.
const subpath = path.split('?')[0];
await handleDashboardApi(method, subpath, body, { url: `/dashboard/api${path}`, headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res);
return { status: res.statusCode, body: res.json() };
}
describe('fleet job progress and cancel (AC-FLEET-1)', () => {
it('advances n/N per account and stops scheduling on cancel', async () => {
for (let i = 0; i < 12; i++) add(`progress-${i}`);
const ops = {
probe: async (account) => {
await sleep(15);
return { id: account.id, email: account.email, ok: true, tier: 'pro' };
},
};
const job = startFleetJob({ op: 'probe', ops });
assert.equal(job.total, 12);
assert.equal(job.state, 'running');
const seen = new Set();
for (let i = 0; i < 200 && job.state === 'running'; i++) {
await sleep(5);
const snap = publicFleetJob(job);
seen.add(snap.done);
if (snap.done >= 4) cancelFleetJob(job.id);
}
await job.promise;
assert.equal(job.state, 'cancelled');
assert.ok(seen.size >= 3, `a poller must observe several progress values, saw ${[...seen].join(',')}`);
assert.ok(job.done >= 4, 'already-finished items are kept');
assert.ok(job.done < 12, 'cancel stops scheduling new accounts');
assert.equal(job.items.length, job.done);
assert.equal(job.current, null);
});
it('reports a finished run as done, and cancel after the fact is a no-op', async () => {
add('done-1');
add('done-2');
const job = startFleetJob({ op: 'probe', ops: { probe: async (a) => ({ ok: true, email: a.email }) } });
await awaitFleetJob(job.id);
assert.equal(job.state, 'done');
assert.equal(job.done, 2);
assert.equal(job.okCount, 2);
const again = cancelFleetJob(job.id);
assert.equal(again.state, 'done', 'a finished job cannot be cancelled');
});
});
describe('fleet job payload (AC-FLEET-2)', () => {
it('pages items by `since` so a poll only carries the delta', async () => {
add('delta-1');
add('delta-2');
add('delta-3');
const job = startFleetJob({ op: 'probe', ops: { probe: async (a) => ({ ok: true, email: a.email }) } });
await job.promise;
const all = publicFleetJob(job, { since: 0 });
assert.equal(all.items.length, 3);
assert.equal(all.itemCount, 3);
assert.equal(all.since, 0);
const tail = publicFleetJob(job, { since: 2 });
assert.equal(tail.items.length, 1);
assert.equal(tail.since, 2);
const beyond = publicFleetJob(job, { since: 99 });
assert.deepEqual(beyond.items, [], 'a cursor past the end yields no items');
});
it('keeps the email and the human-mappable reason for failures and skips', async () => {
const ok = add('detail-ok');
const failed = add('detail-fail');
const skipped = add('detail-skip');
const ops = {
probe: async (account) => {
if (account.id === failed.id) return { ok: false, error: 'ERR_PROBE_FAILED' };
if (account.id === skipped.id) return { ok: false, skipped: true, reason: 'ls_not_idle_resident' };
return { ok: true, email: account.email };
},
};
const job = startFleetJob({ op: 'probe', ops });
await job.promise;
assert.equal(job.okCount, 1);
assert.equal(job.failCount, 1);
assert.equal(job.skippedCount, 1);
const byId = new Map(job.items.map((item) => [item.id, item]));
assert.equal(byId.get(failed.id).email, 'detail-fail@fixture.invalid');
assert.equal(byId.get(failed.id).error, 'ERR_PROBE_FAILED');
assert.equal(byId.get(skipped.id).skipped, true);
assert.equal(byId.get(skipped.id).reason, 'ls_not_idle_resident');
assert.equal(byId.get(ok.id).ok, true);
});
it('targets active accounts only and reports how many were excluded', async () => {
add('target-active-1');
add('target-active-2');
const off = add('target-off');
const banned = add('target-banned');
setAccountStatus(off.id, 'disabled');
setAccountStatus(banned.id, 'banned');
const job = startFleetJob({ op: 'refresh', ops: { refresh: async (a) => ({ ok: true, email: a.email }) } });
await job.promise;
assert.equal(job.total, 2);
assert.equal(job.excluded, 2, 'disabled/banned accounts are named, not silently dropped');
// The UI lists the excluded accounts (P1-7), so the count must come with names.
assert.deepEqual(
job.excludedAccounts.map((a) => a.email).sort(),
['target-banned@fixture.invalid', 'target-off@fixture.invalid'],
);
const snapshot = publicFleetJob(job);
assert.equal(snapshot.excludedAccounts.length, 2);
assert.ok(snapshot.items.every((item) => Number.isFinite(item.at) && item.at > 0),
'every attempt carries the time it ran');
});
it('runs only the caller-selected ids, and de-dupes them', async () => {
// "Run on the checked rows" must not become "run on the pool": the route
// forwards `ids` from the selection bar, and a dropped list would refresh
// every account behind the operator's back.
const picked = add('ids-picked');
const other = add('ids-other');
const seen = [];
const job = startFleetJob({
op: 'refresh',
ids: [picked.id, picked.id, 'ghost-id'],
ops: { refresh: async (a) => { seen.push(a.id); return { ok: true, email: a.email }; } },
});
await job.promise;
assert.deepEqual(seen, [picked.id], 'only the selected, existing account runs (once)');
assert.ok(!seen.includes(other.id), 'an unselected account was refreshed');
assert.equal(job.total, 1);
assert.equal(job.requested, 3, 'the requested count is what the caller sent');
assert.equal(job.excluded, 0, 'an explicit selection is not a pool sweep');
});
});
describe('fleet routes', () => {
it('POST /accounts/fleet-jobs starts a job the client can poll and cancel', async () => {
enableOpenLocal();
add('route-1');
add('route-2');
const started = await call('POST', '/accounts/fleet-jobs', { op: 'probe' });
assert.equal(started.status, 200);
assert.equal(started.body.success, true);
const jobId = started.body.job.id;
assert.ok(jobId);
const job = await awaitFleetJob(jobId);
assert.equal(job.state, 'done');
const polled = await call('GET', `/accounts/fleet-jobs/${jobId}?since=0`);
assert.equal(polled.status, 200);
assert.equal(polled.body.job.state, 'done');
assert.equal(polled.body.job.total, 2);
assert.equal(polled.body.job.items.length, 2);
for (const item of polled.body.job.items) {
assert.equal(item.ok, false, 'no language server in the test process');
assert.equal(item.skipped, true);
assert.equal(typeof item.reason, 'string');
}
const cancelled = await call('POST', `/accounts/fleet-jobs/${jobId}/cancel`, {});
assert.equal(cancelled.status, 200);
assert.equal(cancelled.body.job.state, 'done');
});
it('rejects an unknown op and an unknown job id', async () => {
enableOpenLocal();
const bad = await call('POST', '/accounts/fleet-jobs', { op: 'delete' });
assert.equal(bad.status, 400);
assert.equal(bad.body.error, 'ERR_FLEET_OP_INVALID');
const missing = await call('GET', '/accounts/fleet-jobs/fleet-does-not-exist', {});
assert.equal(missing.status, 404);
assert.equal(missing.body.error, 'ERR_FLEET_JOB_NOT_FOUND');
assert.equal(getFleetJob('fleet-does-not-exist'), null);
});
it('legacy POST /accounts/probe-all shares the loop and never counts a skip as success', async () => {
enableOpenLocal();
add('legacy-1');
add('legacy-2');
const res = await call('POST', '/accounts/probe-all', {});
assert.equal(res.status, 200);
assert.equal(res.body.success, true);
assert.equal(res.body.total, 2);
assert.equal(res.body.attempted, 0);
assert.equal(res.body.skippedCount, 2);
assert.equal(res.body.okCount, 0);
for (const item of res.body.results) {
assert.equal(item.ok, false);
assert.equal(item.skipped, true);
}
});
});