* feat(system): read-only health check and truthful overview counts POST /prompt_manager/system/health runs the health check off-loop as a read registry job of kind "health" (one at a time, beside a scan, not cancellable, no file outcomes) and keeps the last report; GET returns it or null. The check reads one snapshot on its own mode=ro connection, closed in finally: integrity_check(100), foreign_key_check, stored hashes against utils.hashing, duplicate pair rows, the legacy consistency queries (now shared with check_consistency), and image links whose file is gone, counted only under readable sources. An unavailable source, or the absent Needs-review queue, makes its check unavailable and the report incomplete instead of producing a count. GET /prompt_manager/system/overview returns Library-eligible positive prompts and active negative variants, indexed images, the last health summary or null, backup null, the Needs-review count null, the effective sources with whether they were auto-detected, and the thumbnail plan's missing count. GET /prompt_manager/system/statistics lists runs and average rating per category of Library parents, with the /stats totals. * feat(system): native System route, drafts and shared review dialog - #/system/<section> renders the concept's System shell: section nav, settings layout and Save row with truthful status (D92); each section not delivered yet hands off to its legacy tools (D94). Overview's System card opens #/system/overview (Q12). - A module-level draft store saves browser, then host (expected_revision), then runtime; failed domains stay dirty, a stale revision is a conflict. - System row builders (setting, operation, section nav) on the scope. - openReviewDialog and backupNotice extracted from the scan review, which is now a thin caller with identical markup. - Overview images gain a "rated" count (images whose linked prompt is rated); the missing-files health check reports per source, so partial availability is visible. * feat(system): native Overview with health report and statistics System > Overview renders the concept's metrics and its Library health and Workspace preferences surfaces from GET /system/overview: real counts, "Not checked" until a health check ran, no invented review count, backup or thumbnail status (D93). Explore opens "Usage statistics" with the real runs and ratings per category and the Library totals (D96); the statistics route now also returns rated_images. Maintenance shows the concept's notice with Run health checks above its hand-off: the read-only check runs as a registry read job and opens the "Health report" with a chip per real result, missing files per source (D95). A load failure shows Retry and no zeros; late answers never repaint a newer section. Adds the System concept comparison (shell, Overview, both dialogs, the notice, mutation checks), the System workflow spec and its fixture hooks, and the TESTING.md "Native System" section. * test(system): coverage floors for the native Overview Adds the system-overview.mjs baseline and raises the system.mjs floors to their measured coverage; no other floor changes. * fix(system): keep health reports per database and off the event loop - A restore that replaces the database forgets the stored health report, and a check that started before the swap is not kept (database generation stamped at start). - The Overview no longer walks every source for the missing-thumbnail count; the page reads /images/thumbnails/status on its own. - Image sources for the health check and the Overview resolve in the executor; a failure to start the check answers the coded envelope. - Any exception fails only its own check; missing files reads its rows in the snapshot and stats the files after it ended. - The read-only URI keeps Windows UNC paths (empty authority). - An integrity count at SQLite's limit is flagged as capped. * fix(system): thumbnail row read, tracked health job and guarded host save - The Overview's thumbnail row is its own read of the thumbnail status: "Checking thumbnails…" until it answers, unavailable when it fails. - The health job is tracked in the Activity store (new store.track) and followed through its event stream instead of polling every 150 ms. - A host save before a read returned a revision fails without a request; System reads the host values on mount and after a conflict. - "100+ problems found" when the integrity count is capped. - D-NEW-S2-5 is D123 everywhere; the unused Overview hand-off is gone. - The concept spec builds the health report from the concept's own rows and asserts the full unmapped path list; surface tops pin the 18px offset. - TESTING.md: D96's reason, the read-only (WAL/SHM) standard, the UNC URI and the new behavior. Coverage floors raised for the touched files. * fix(system): make two health tests portable to Windows - Windows forbids "?" in file names, so the URI-character open test uses "#", "%" and a space there. - ntpath.realpath on Python 3.11+ returns a NUL-byte path instead of raising, so the bad-path test raises as POSIX does to reach the missing-files failure path on every platform.
437 lines
23 KiB
JavaScript
437 lines
23 KiB
JavaScript
// Activity (#249 Activity-1): the session job store behind the top-bar button and pill and the Activity page.
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const source = import('../../web/js/workspace/activity-store.mjs');
|
|
|
|
const ID_A = 'a'.repeat(32);
|
|
const ID_B = 'b'.repeat(32);
|
|
const ID_C = 'c'.repeat(32);
|
|
|
|
function summary(overrides = {}) {
|
|
return { id: ID_A, kind: 'scan', title: 'Scan for metadata', state: 'running', started_at: 1000, finished_at: null,
|
|
progress: 0, status_line: '', processed: 0, total: 0,
|
|
counters: { added: 0, known: 0, review: 0, no_metadata: 0, errors: 0 }, scope: { files: 'new', output_folders: 2 },
|
|
...overrides };
|
|
}
|
|
|
|
function apiError(status, message, code) {
|
|
return Object.assign(new Error(message), { status, code });
|
|
}
|
|
|
|
/** A fake world: the API's answers, every opened stream, the pending timers and the toasts. */
|
|
function harness() {
|
|
const world = { list: [], listError: null, posts: [], postResult: null, postError: null, streams: [], timers: [],
|
|
toasts: [], gets: 0 };
|
|
const cleanups = [];
|
|
const controller = new AbortController();
|
|
const scope = { signal: controller.signal, cleanup: fn => cleanups.push(fn) };
|
|
const api = {
|
|
async get(url, options) {
|
|
assert.equal(url, '/prompt_manager/jobs');
|
|
assert.equal(options.cache, 'no-store');
|
|
world.gets += 1;
|
|
if (world.listError) throw world.listError;
|
|
return { success: true, jobs: world.list };
|
|
},
|
|
async post(url, body) {
|
|
world.posts.push([url, body]);
|
|
if (world.postError) throw world.postError;
|
|
return world.postResult;
|
|
},
|
|
};
|
|
const connect = url => {
|
|
const stream = { url, closed: false, onmessage: null, onerror: null, close() { this.closed = true; } };
|
|
world.streams.push(stream);
|
|
return stream;
|
|
};
|
|
const timers = {
|
|
setTimeout(fn, delay) { const timer = { fn, delay, cleared: false }; world.timers.push(timer); return timer; },
|
|
clearTimeout(timer) { timer.cleared = true; },
|
|
};
|
|
world.dispose = () => { controller.abort(); cleanups.forEach(fn => fn()); };
|
|
world.fire = async () => {
|
|
const timer = world.timers.find(item => !item.cleared && !item.fired);
|
|
timer.fired = true;
|
|
timer.fn();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
return timer.delay;
|
|
};
|
|
world.open = () => world.streams.filter(stream => !stream.closed);
|
|
return { world, api, scope, connect, timers, pollMs: 0, notify: message => world.toasts.push(message) };
|
|
}
|
|
|
|
const send = (stream, job) => stream.onmessage({ data: JSON.stringify(job) });
|
|
|
|
test('store merges list and SSE updates and caps backoff', async () => {
|
|
const { createActivityStore, BACKOFF_MAX_MS, LOST_DETAIL } = await source;
|
|
const { world, ...deps } = harness();
|
|
world.list = [summary(), summary({ id: ID_B, state: 'finished', finished_at: 900, counters: { added: 3, errors: 0 } })];
|
|
const store = createActivityStore(deps);
|
|
let changes = 0;
|
|
store.subscribe(() => { changes += 1; });
|
|
await store.load();
|
|
assert.equal(store.status, 'ready');
|
|
assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_A, 'running'], [ID_B, 'finished']]);
|
|
assert.deepEqual(store.running().map(job => job.id), [ID_A]);
|
|
// One stream per running job only; a finished job is not followed.
|
|
assert.deepEqual(world.streams.map(stream => stream.url), [`/prompt_manager/jobs/${ID_A}/events`]);
|
|
assert.ok(changes >= 1);
|
|
|
|
// An SSE summary replaces the listed one; garbage and another job's summary are ignored.
|
|
const [stream] = world.streams;
|
|
send(stream, summary({ progress: 42, processed: 8240, total: 19622 }));
|
|
stream.onmessage({ data: 'not json' });
|
|
send(stream, summary({ id: ID_B, progress: 99 }));
|
|
assert.equal(store.find(ID_A).progress, 42);
|
|
assert.equal(store.find(ID_B).state, 'finished');
|
|
assert.deepEqual(world.toasts, []);
|
|
|
|
// The end of the job: the finish toast, and the stream is closed.
|
|
send(stream, summary({ state: 'finished', progress: 100, finished_at: 2000, counters: { added: 1420, errors: 18 } }));
|
|
assert.deepEqual(world.toasts, ['Scan finished · 1,420 added · 18 errors']);
|
|
assert.equal(stream.closed, true);
|
|
assert.deepEqual(store.running(), []);
|
|
|
|
// A new running job from another tab, then the server goes away: every re-read waits longer, up to 30 s.
|
|
world.list = [summary({ id: ID_C, state: 'running' })];
|
|
await store.load();
|
|
const second = world.open()[0];
|
|
assert.equal(second.url, `/prompt_manager/jobs/${ID_C}/events`);
|
|
world.listError = new Error('connection refused');
|
|
second.onerror();
|
|
assert.equal(second.closed, true);
|
|
const delays = [];
|
|
for (let index = 0; index < 8; index += 1) delays.push(await world.fire());
|
|
assert.deepEqual(delays, [1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]);
|
|
assert.ok(delays.every(delay => delay <= BACKOFF_MAX_MS));
|
|
// While the server cannot be read the running job shows as ended, so no pill stays.
|
|
assert.equal(store.status, 'error');
|
|
assert.equal(store.find(ID_C).state, 'lost');
|
|
assert.deepEqual(store.running(), []);
|
|
assert.equal(typeof LOST_DETAIL, 'string');
|
|
|
|
// The server is back without the job (a restart): it stays ended; finished jobs the server no longer lists are
|
|
// dropped, so the list never outgrows the server's (review M3).
|
|
world.listError = null;
|
|
world.list = [];
|
|
await world.fire();
|
|
assert.equal(store.status, 'ready');
|
|
assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_C, 'lost']]);
|
|
assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 0, 'no retry after a good read');
|
|
assert.equal(world.toasts.length, 1, 'an ended job has no finish toast');
|
|
world.dispose();
|
|
});
|
|
|
|
test('a dropped stream of a still running job reattaches after the backoff, then resets it', async () => {
|
|
const { createActivityStore } = await source;
|
|
const { world, ...deps } = harness();
|
|
world.list = [summary()];
|
|
const store = createActivityStore(deps);
|
|
await store.load();
|
|
world.streams[0].onerror();
|
|
assert.equal(await world.fire(), 1000);
|
|
const again = world.open();
|
|
assert.equal(again.length, 1);
|
|
assert.equal(store.find(ID_A).state, 'running');
|
|
again[0].onerror();
|
|
assert.equal(await world.fire(), 2000);
|
|
send(world.open()[0], summary({ progress: 5 }));
|
|
world.open()[0].onerror();
|
|
assert.equal(await world.fire(), 1000, 'a received message resets the backoff');
|
|
// A second drop while a re-read is already scheduled does not schedule another.
|
|
world.open()[0].onerror();
|
|
store.load();
|
|
assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 1);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
world.dispose();
|
|
assert.ok(world.streams.every(stream => stream.closed), 'dispose closes every stream');
|
|
assert.ok(world.timers.every(timer => timer.cleared || timer.fired), 'dispose clears the pending re-read');
|
|
});
|
|
|
|
test('start returns busy with the running job', async () => {
|
|
const { createActivityStore } = await source;
|
|
const { world, ...deps } = harness();
|
|
const store = createActivityStore(deps);
|
|
world.postResult = { success: true, job: summary() };
|
|
const started = await store.start('scan');
|
|
assert.equal(started.job.id, ID_A);
|
|
assert.deepEqual(world.posts, [['/prompt_manager/jobs/scan', {}]]);
|
|
assert.deepEqual(world.toasts, ['Scan started.']);
|
|
assert.equal(world.open().length, 1, 'the started job is followed');
|
|
|
|
world.postError = apiError(409, 'Scan for metadata is already running', 'busy');
|
|
world.list = [summary()];
|
|
const busy = await store.start('scan');
|
|
assert.equal(busy.busy.id, ID_A);
|
|
assert.equal(busy.busy.title, 'Scan for metadata');
|
|
assert.equal(busy.message, 'Scan for metadata is already running');
|
|
assert.equal(world.toasts.length, 1, 'a refused start has no start toast');
|
|
|
|
world.postError = apiError(500, 'boom', null);
|
|
await assert.rejects(store.start('scan'), /boom/);
|
|
world.postError = null;
|
|
world.postResult = { success: true, job: { id: 'nope' } };
|
|
await assert.rejects(store.start('scan'), /Invalid job/);
|
|
|
|
world.postResult = { success: true, cancellation_requested: true };
|
|
assert.equal(await store.cancel(ID_A), true);
|
|
assert.deepEqual(world.posts.at(-1), [`/prompt_manager/jobs/${ID_A}/cancel`, {}]);
|
|
world.postResult = { success: true, cancellation_requested: false };
|
|
assert.equal(await store.cancel(ID_A), false);
|
|
world.dispose();
|
|
});
|
|
|
|
test('track follows a job another route started, with no read of its own', async () => {
|
|
const { createActivityStore } = await source;
|
|
const { world, ...deps } = harness();
|
|
const store = createActivityStore(deps);
|
|
const seen = [];
|
|
store.subscribe(() => seen.push(store.find(ID_A)?.state));
|
|
const job = store.track(summary({ kind: 'health', title: 'Health check' }));
|
|
assert.equal(job.id, ID_A);
|
|
assert.equal(world.gets, 0, 'the started job is known without a list read');
|
|
assert.deepEqual(world.open().map(stream => stream.url), [`/prompt_manager/jobs/${ID_A}/events`]);
|
|
assert.deepEqual(world.toasts, [], 'the route that started it says so itself');
|
|
send(world.open()[0], summary({ kind: 'health', title: 'Health check', state: 'finished', progress: 100 }));
|
|
assert.deepEqual(seen, ['running', 'finished']);
|
|
assert.equal(world.open().length, 0, 'an ended job is no longer followed');
|
|
assert.throws(() => store.track({ id: 'nope' }), /Invalid job/);
|
|
world.dispose();
|
|
});
|
|
|
|
test('a list that is not a job list counts as a failed read', async () => {
|
|
const { createActivityStore } = await source;
|
|
const { world, api, ...deps } = harness();
|
|
const store = createActivityStore({ ...deps, api: { ...api, get: async () => ({ success: true }) } });
|
|
await store.load();
|
|
assert.equal(store.status, 'error');
|
|
assert.equal(world.timers.length, 1);
|
|
world.dispose();
|
|
await store.load();
|
|
assert.equal(store.status, 'error', 'a disposed store reads nothing');
|
|
});
|
|
|
|
test('a visible tab reads the list again every 15 s; a hidden one skips its turn', async () => {
|
|
const { createActivityStore, POLL_MS } = await source;
|
|
const { world, ...deps } = harness();
|
|
let visible = true;
|
|
const store = createActivityStore({ ...deps, pollMs: POLL_MS, isVisible: () => visible });
|
|
await store.load();
|
|
assert.equal(world.gets, 1);
|
|
// A job started in another tab appears on the next read.
|
|
world.list = [summary()];
|
|
assert.equal(await world.fire(), 15000);
|
|
assert.equal(world.gets, 2);
|
|
assert.deepEqual(store.running().map(job => job.id), [ID_A]);
|
|
visible = false;
|
|
assert.equal(await world.fire(), 15000);
|
|
assert.equal(world.gets, 2, 'a hidden tab does not read');
|
|
visible = true;
|
|
await world.fire();
|
|
assert.equal(world.gets, 3);
|
|
// A manual read replaces the pending poll rather than adding one.
|
|
await store.load();
|
|
assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 1);
|
|
world.dispose();
|
|
assert.ok(world.timers.every(timer => timer.cleared || timer.fired));
|
|
});
|
|
|
|
test('a late or older answer never brings an ended job back, so it is announced once', async () => {
|
|
const { createActivityStore } = await source;
|
|
const { world, api, ...deps } = harness();
|
|
const answers = [];
|
|
const slowApi = { ...api, get: () => new Promise(resolve => answers.push(resolve)) };
|
|
const store = createActivityStore({ ...deps, api: slowApi });
|
|
const first = store.load();
|
|
const second = store.load();
|
|
// The newer read answers first; the older one, answering later with stale data, is dropped.
|
|
answers[1]({ success: true, jobs: [summary()] });
|
|
await second;
|
|
answers[0]({ success: true, jobs: [] });
|
|
await first;
|
|
assert.deepEqual(store.jobs().map(job => job.state), ['running']);
|
|
send(world.open()[0], summary({ state: 'finished', counters: { added: 2, errors: 0 } }));
|
|
assert.deepEqual(world.toasts, ['Scan finished · 2 added · 0 errors']);
|
|
// A list read sent before the end still says running: the job stays finished and no stream reopens.
|
|
const third = store.load();
|
|
answers[2]({ success: true, jobs: [summary()] });
|
|
await third;
|
|
assert.equal(store.find(ID_A).state, 'finished');
|
|
assert.equal(world.open().length, 0);
|
|
assert.equal(world.toasts.length, 1);
|
|
// A stale failure is dropped too.
|
|
const failing = store.load();
|
|
const newest = store.load();
|
|
answers[4]({ success: true, jobs: [] });
|
|
await newest;
|
|
answers[3](Promise.reject(new Error('late failure')));
|
|
await failing;
|
|
assert.equal(store.status, 'ready');
|
|
world.dispose();
|
|
});
|
|
|
|
test('summaries are validated and the copy follows the spec', async () => {
|
|
const { parseJob, finishToast, startToast, pillParts, outcomeLine, backoffDelay } = await source;
|
|
for (const raw of [null, [], { id: 'x', state: 'running' }, summary({ state: 'lost' }), summary({ id: ID_A.toUpperCase() })]) {
|
|
assert.equal(parseJob(raw), null);
|
|
}
|
|
const loose = parseJob({ id: ID_A, state: 'running', progress: 140.4, processed: -1, total: 1.5, counters: { added: 'x', errors: 2 },
|
|
title: ' ', started_at: 'x', scope: [] });
|
|
assert.deepEqual(loose, { id: ID_A, kind: '', title: 'Background job', state: 'running', started_at: null, finished_at: null,
|
|
progress: 100, status_line: '', processed: 0, total: 0, counters: { added: 0, errors: 2 }, scope: {}, listed: {}, log_length: 0,
|
|
cancellable: true });
|
|
assert.equal(parseJob(summary({ progress: -3 })).progress, 0);
|
|
// A held legacy job cannot be cancelled; an older server sends no key, which keeps Cancel.
|
|
assert.equal(parseJob(summary({ cancellable: false })).cancellable, false);
|
|
assert.equal(parseJob(summary({ cancellable: true })).cancellable, true);
|
|
assert.equal(parseJob(summary()).cancellable, true);
|
|
|
|
const scan = parseJob(summary({ progress: 42, processed: 8240, total: 19622, counters: { added: 1, errors: 1 } }));
|
|
assert.deepEqual(pillParts(scan), { label: 'Scanning · 42%', files: ' · 8,240 / 19,622', text: 'Scanning · 42% · 8,240 / 19,622' });
|
|
assert.equal(pillParts({ ...scan, total: 0 }).text, 'Scanning · 42%');
|
|
assert.equal(pillParts({ ...scan, kind: 'thumbnails', title: 'Thumbnails' }).text, 'Thumbnails · 42% · 8,240 / 19,622');
|
|
assert.equal(finishToast({ ...scan, state: 'finished' }), 'Scan finished · 1 added · 1 error');
|
|
assert.equal(finishToast({ ...scan, state: 'cancelled' }), 'Scan cancelled · 1 added');
|
|
assert.equal(finishToast({ ...scan, state: 'failed' }), 'Scan failed · 1 added · 1 error');
|
|
assert.equal(finishToast({ ...scan, counters: {}, state: 'finished' }), 'Scan finished · 0 added · 0 errors');
|
|
const other = { ...scan, kind: 'backup', title: 'Backup' };
|
|
assert.equal(finishToast({ ...other, state: 'finished' }), 'Backup finished.');
|
|
assert.equal(finishToast({ ...other, state: 'cancelled' }), 'Backup was cancelled.');
|
|
assert.equal(finishToast({ ...other, state: 'failed' }), 'Backup failed.');
|
|
assert.equal(startToast(scan), 'Scan started.');
|
|
assert.equal(startToast(other), 'Backup started.');
|
|
assert.equal(outcomeLine(scan), '1 added · 1 error');
|
|
assert.equal(outcomeLine(other), '');
|
|
assert.deepEqual([-1, 0, 1, 4, 5, 6, 1e9, 0.5].map(backoffDelay), [1000, 1000, 2000, 16000, 30000, 30000, 30000, 1000]);
|
|
});
|
|
|
|
// D89 (plan D66): an image tagging job's view has its own tiles, outcomes, openable rows, log and cancel labels.
|
|
test('image-tags jobs get their own tiles outcomes and pill verb', async () => {
|
|
const { parseJob, jobView, pillParts, outcomeLine, startToast } = await source;
|
|
assert.equal(typeof jobView, 'function');
|
|
const job = parseJob(summary({ kind: 'image-tags', title: 'Auto-tag images', progress: 40, processed: 2, total: 5,
|
|
counters: { tagged: 1420, unchanged: 3, skipped: 2, errors: 1 }, scope: { mode: 'auto', images: 5, model_type: 'wd14-vit' } }));
|
|
const view = jobView(job.kind);
|
|
assert.deepEqual(view.tiles, [['tagged', 'Tagged', 'added'], ['unchanged', 'Unchanged', ''], ['skipped', 'Skipped', 'warn'],
|
|
['errors', 'Errors', 'bad']]);
|
|
assert.deepEqual(view.outcomes, [['tagged', 'Tagged'], ['errors', 'Errors'], ['skipped', 'Skipped'], ['unchanged', 'Unchanged']]);
|
|
assert.deepEqual([...view.openable].sort(), ['tagged', 'unchanged']);
|
|
assert.equal(view.log, 'Tagging log');
|
|
assert.equal(view.cancel, 'Cancel tagging');
|
|
assert.deepEqual(pillParts(job), { label: 'Tagging · 40%', files: ' · 2 / 5', text: 'Tagging · 40% · 2 / 5' });
|
|
assert.equal(outcomeLine(job), '1,420 tagged · 1 error');
|
|
assert.equal(outcomeLine({ ...job, counters: {} }), '0 tagged · 0 errors');
|
|
assert.equal(startToast(job), 'Auto-tagging started.');
|
|
// The design's toast pattern, as the scan's: "<Job> finished · N tagged · M errors".
|
|
const { finishToast } = await source;
|
|
assert.equal(finishToast({ ...job, state: 'finished' }), 'Auto-tag images finished · 1,420 tagged · 1 error');
|
|
assert.equal(finishToast({ ...job, state: 'cancelled' }), 'Auto-tag images cancelled · 1,420 tagged');
|
|
assert.equal(finishToast({ ...job, state: 'failed' }), 'Auto-tag images failed · 1,420 tagged · 1 error');
|
|
assert.equal(finishToast({ ...job, title: 'Add tags to images', counters: {}, state: 'finished' }),
|
|
'Add tags to images finished · 0 tagged · 0 errors');
|
|
});
|
|
|
|
test('unknown kinds keep the generic view', async () => {
|
|
const { jobView } = await source;
|
|
const scan = jobView('scan');
|
|
assert.deepEqual(scan.tiles, [['added', 'Added to Library', 'added'], ['known', 'Already known', ''],
|
|
['review', 'Needs review', 'warn'], ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad']]);
|
|
assert.deepEqual(scan.outcomes, [['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'],
|
|
['no_metadata', 'No usable metadata'], ['known', 'Already known']]);
|
|
assert.deepEqual([...scan.openable].sort(), ['added', 'known']);
|
|
assert.deepEqual([scan.log, scan.cancel], ['Scan log', 'Cancel scan']);
|
|
for (const kind of ['maintenance', '', 'constructor', '__proto__']) {
|
|
const view = jobView(kind);
|
|
assert.deepEqual([view.tiles, view.outcomes, [...view.openable]], [scan.tiles, scan.outcomes, [...scan.openable]]);
|
|
assert.deepEqual([view.log, view.cancel], ['Job log', 'Cancel job']);
|
|
}
|
|
});
|
|
|
|
test('pagehide stops polling, closes streams and aborts the in-flight read; pageshow resumes', async () => {
|
|
const { createActivityStore } = await source;
|
|
const { world, api, ...deps } = harness();
|
|
const lifecycle = new EventTarget();
|
|
const signals = [];
|
|
let answer = null;
|
|
const pendingApi = { ...api, get: (url, options) => {
|
|
signals.push(options.signal);
|
|
world.gets += 1;
|
|
if (world.gets === 1) return Promise.resolve({ success: true, jobs: [summary()] });
|
|
return new Promise((resolve, reject) => {
|
|
answer = resolve;
|
|
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })));
|
|
});
|
|
} };
|
|
const store = createActivityStore({ ...deps, api: pendingApi, pollMs: 15000, lifecycle });
|
|
await store.load();
|
|
assert.equal(world.open().length, 1);
|
|
const poll = world.timers.find(timer => !timer.cleared && timer.delay === 15000);
|
|
assert.ok(poll, 'a visible tab polls');
|
|
// A read in flight when the page goes away (a closed tab, a test teardown) is aborted, nothing is scheduled again.
|
|
const read = store.load();
|
|
lifecycle.dispatchEvent(new Event('pagehide'));
|
|
await read;
|
|
assert.equal(signals.at(-1).aborted, true);
|
|
assert.equal(world.open().length, 0);
|
|
assert.ok(world.timers.every(timer => timer.cleared || timer.fired));
|
|
assert.equal(store.find(ID_A).state, 'running', 'a hidden page marks nothing lost');
|
|
await store.load();
|
|
assert.equal(world.gets, 2, 'a suspended store reads nothing');
|
|
// Back from the cache, it reads the list again and follows the job.
|
|
const resumed = Object.assign(new Event('pageshow'), { persisted: true });
|
|
lifecycle.dispatchEvent(resumed);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(world.gets, 3);
|
|
answer({ success: true, jobs: [summary()] });
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(world.open().length, 1);
|
|
world.dispose();
|
|
assert.equal(signals.at(-1).aborted, true, 'disposing aborts the store reads');
|
|
assert.equal(world.open().length, 0);
|
|
});
|
|
|
|
test('one failed read is not a restart; two in a row are, and a good read resets the backoff', async () => {
|
|
const { createActivityStore } = await source;
|
|
const { world, ...deps } = harness();
|
|
world.list = [summary(), summary({ id: ID_B, state: 'finished', finished_at: 900 })];
|
|
const store = createActivityStore(deps);
|
|
await store.load();
|
|
// A single failed read (a proxy's 502) keeps the job running and its stream open (review M1).
|
|
world.listError = apiError(502, 'Bad gateway', null);
|
|
await store.load();
|
|
assert.equal(store.status, 'error');
|
|
assert.equal(store.find(ID_A).state, 'running');
|
|
assert.equal(world.open().length, 1);
|
|
assert.equal(await world.fire(), 1000);
|
|
// The second failure in a row: the server is gone, so the running job ended with it.
|
|
assert.equal(store.find(ID_A).state, 'lost');
|
|
assert.equal(world.open().length, 0);
|
|
for (let index = 0; index < 3; index += 1) await world.fire();
|
|
// The server is back with a new running job: the backoff starts again at 1 s (review M2).
|
|
world.listError = null;
|
|
world.list = [summary({ id: ID_C })];
|
|
await world.fire();
|
|
assert.equal(store.status, 'ready');
|
|
assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_C, 'running'], [ID_A, 'lost']]);
|
|
world.open()[0].onerror();
|
|
assert.equal(await world.fire(), 1000);
|
|
// A summary carries what its detail lists, for the job view's reads (review M12).
|
|
send(world.open()[0], summary({ id: ID_C, listed: { added: 3, errors: -1 }, log_length: 7 }));
|
|
assert.deepEqual([store.find(ID_C).listed, store.find(ID_C).log_length], [{ added: 3, errors: 0 }, 7]);
|
|
world.dispose();
|
|
});
|
|
|
|
test('numbers group the way the server writes them, whatever the browser locale', async () => {
|
|
const { pillParts, finishToast, parseJob } = await source;
|
|
const original = Number.prototype.toLocaleString;
|
|
Number.prototype.toLocaleString = function (locale, ...rest) { return original.call(this, locale ?? 'de-DE', ...rest); };
|
|
try {
|
|
const job = parseJob(summary({ progress: 41, processed: 8240, total: 19622, counters: { added: 1420, errors: 18 } }));
|
|
assert.equal(pillParts(job).text, 'Scanning · 41% · 8,240 / 19,622');
|
|
assert.equal(finishToast({ ...job, state: 'finished' }), 'Scan finished · 1,420 added · 18 errors');
|
|
} finally {
|
|
Number.prototype.toLocaleString = original;
|
|
}
|
|
});
|