Files
ComfyAssets-ComfyUI_PromptM…/tests/js/workspace-system-overview.test.js
T
Vito fc3266d455 fix(thumbnails): generate thumbnails automatically and use them everywhere (#316)
* fix(thumbnails): generate thumbnails automatically and use them everywhere

New outputs never got a preview, so the workspace grid loaded full-size
originals (844 of 19,907 images on a live install, exactly the newest).

- Listings always give images and videos a thumbnail URL; a missing
  preview is made by the serve route on first view (bounded, single
  flight per file) and falls back to the original, remembering files
  that cannot be decoded.
- The image monitor queues each settled output's preview on one
  background worker that follows Worker threads.
- A scan that leaves 200 or more previews missing starts the visible
  thumbnail job; fewer go through the worker.
- Automatic previews wait for a restore, a duplicate removal, a sources
  change, the gallery's clear and exclusive jobs.
- The inspector cover uses the preview; the Overview row counts
  previews that failed.

* fix(thumbnails): keep the video fallback tested and stop the preview worker before cleanup

A video listed without a preview still renders its own element and is
released on refresh. Test teardowns and the browser reset stop the
automatic preview worker before the temp tree goes, so no preview lands
after cleanup (Windows). TESTING.md documents automatic previews.

* fix(thumbnails): admit previews on the loop and never send a video to an img

Final review fixes for automatic previews:
- A video preview that cannot be made is the placeholder frame (no-store),
  never the video; OpenCV failures fall through to ffmpeg and the
  placeholder. Cards and the inspector cover swap back to the video
  element when the preview image fails.
- View requests are admitted on the event loop (3 writers, one flight per
  file, at most 64 waiting; past that the fallback at once). Writes run on
  a small dedicated pool, never the default executor. The history route
  shares the admission, flight key and link checks.
- Drive, UNC, empty and dot segments are refused as text; the folder
  listing runs inside the admission.
- Holds are checked after the slot and again before the rename; only
  decode failures are remembered.
- Bounded, logged shutdown: ffmpeg is killed, no worker restarts after
  cleanup, and a reload stops the old worker.
- The listing says whether the preview file exists (thumbnail_ready) for
  the legacy gallery's dot; media in a nested thumbnails folder keeps its
  original; file names the server quotes match in the inspector.

* fix(thumbnails): cover the image fallback card and document the admission

An image listed without a preview (a nested thumbnails folder) still
shows its original; TESTING.md describes the view admission, the video
placeholder, the holds before the rename and the bounded shutdown.

* fix(thumbnails): let an idle preview worker leave and fit case-insensitive filesystems

The capture worker exits once its queue is empty, so no thread outlives
its work; the next output starts a fresh one. The mixed-case source test
expects no folder listing where the exact name already resolves (macOS,
Windows).

* fix(thumbnails): answer shared flights with the outcome only and restart a failed worker

The history and serve routes share a flight key, so a flight now answers
only the ensure outcome whichever route started it; the serve route
derives the preview path and, for a fallback, looks its source up again
on the view pool. A view joining a history flight no longer gets a 500
or an image answered with the video placeholder.

The capture worker clears its running state however its loop ends, and a
worker that cannot start leaves the file queued for the next enqueue.
2026-10-24 08:35:14 -07:00

264 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// System › Overview (#249 System S2 Task 6): the concept's Overview copy on real values (D93), the Health report rows
// (D95), the Statistics tables (D96) and the health check run that waits for its read job.
const test = require('node:test');
const assert = require('node:assert/strict');
const overviewModule = import('../../web/js/workspace/system-overview.mjs');
const OVERVIEW = Object.freeze({
library: { positive_prompts: 1234, negative_variants: 7 },
images: { indexed: 24, rated: 20 },
health: null,
backup: null,
needs_review: { pending: null },
sources: { configured: 2, effective: 2, auto_detected: false },
});
test('metrics are the server counts and Not checked before any health check', async () => {
const { metricValues } = await overviewModule;
assert.deepEqual(metricValues(OVERVIEW), [
[(1234).toLocaleString(), 'Positive prompts'], ['7', 'Negative variants'], ['24', 'Images'],
['Not checked', 'Database integrity'],
]);
});
test('metric 4 is the last health result', async () => {
const { integrityText } = await overviewModule;
assert.equal(integrityText(null), 'Not checked');
assert.equal(integrityText({ status: 'passed', issues: 0 }), 'Passed');
assert.equal(integrityText({ status: 'issues', issues: 1 }), '1 issue');
assert.equal(integrityText({ status: 'issues', issues: 3 }), '3 issues');
assert.equal(integrityText({ status: 'incomplete', issues: 0 }), 'Incomplete');
// Issues found beside a check that could not run (Needs review, Q6) are still named.
assert.equal(integrityText({ status: 'incomplete', issues: 2 }), '2 issues');
});
test('library health rows never invent a review count, a backup or thumbnails', async () => {
const { ambiguousTitle, backupText } = await overviewModule;
assert.equal(ambiguousTitle({ pending: null }), 'Ambiguous image links');
assert.equal(ambiguousTitle(undefined), 'Ambiguous image links');
assert.equal(ambiguousTitle({ pending: 1 }), '1 ambiguous image link');
assert.equal(ambiguousTitle({ pending: 3 }), '3 ambiguous image links');
assert.equal(backupText(null), 'No retained backup record.');
assert.equal(backupText({ verified_at: 'not a time' }), 'No retained backup record.');
assert.match(backupText({ verified_at: '2026-10-22T09:30:00Z' }), /^Last verified backup .+\.$/);
});
test('the thumbnail row is its own read of the thumbnail status', async () => {
const { thumbnailTitle, THUMBNAILS_LOADING } = await overviewModule;
// Until `/images/thumbnails/status` answers the row says it is checking; a failed read is unavailable, never 0.
assert.equal(THUMBNAILS_LOADING, 'Checking thumbnails…');
assert.equal(thumbnailTitle(undefined), THUMBNAILS_LOADING);
assert.equal(thumbnailTitle({ pending: 0 }), 'No missing thumbnails');
assert.equal(thumbnailTitle({ pending: 1 }), '1 missing thumbnail');
assert.equal(thumbnailTitle({ pending: 12 }), '12 missing thumbnails');
assert.equal(thumbnailTitle(null), 'Thumbnail status unavailable');
assert.equal(thumbnailTitle({ pending: 'x' }), 'Thumbnail status unavailable');
// With no image source there is nothing to count.
assert.equal(thumbnailTitle({ pending: 0 }, { effective: 0 }), 'Thumbnail status unavailable');
assert.equal(thumbnailTitle({ pending: 2 }, { effective: 1 }), '2 missing thumbnails');
});
test('an integrity count SQLite stopped at reads as a lower bound', async () => {
const { checkChip } = await overviewModule;
assert.equal(checkChip({ id: 'integrity', status: 'issues', count: 100, capped: true }), '100+ problems found');
assert.equal(checkChip({ id: 'integrity', status: 'issues', count: 37, capped: false }), '37 problems found');
assert.equal(checkChip({ id: 'integrity', status: 'issues', count: 1 }), '1 problem found');
});
test('the sources row tells configured, available and detected sources apart', async () => {
const { sourcesCopy } = await overviewModule;
const shared = 'Monitoring and enrichment share one source list.';
assert.deepEqual(sourcesCopy({ configured: 2, effective: 2, auto_detected: false }), ['2 output sources', shared]);
assert.deepEqual(sourcesCopy({ configured: 2, effective: 1, auto_detected: false }),
['1 output source', `1 of 2 configured sources available. ${shared}`]);
assert.deepEqual(sourcesCopy({ configured: 0, effective: 1, auto_detected: true }),
['1 output source', `None configured is available; ComfyUI's output folder is used. ${shared}`]);
});
const REPORT = Object.freeze({
checked_at: '2026-10-23T08:00:00Z',
status: 'issues',
checks: [
{ id: 'integrity', label: 'Integrity check', status: 'passed', count: 0 },
{ id: 'foreign_keys', label: 'Foreign keys & pair references', status: 'issues', count: 2 },
{ id: 'canonical_hashes', label: 'Canonical hashes', status: 'issues', count: 1 },
{ id: 'pair_uniqueness', label: 'Pair uniqueness', status: 'failed', count: null },
{ id: 'missing_files', label: 'Missing files', status: 'issues', count: 2,
sources: [{ name: 'output', status: 'checked', count: 2 }] },
{ id: 'ambiguous_metadata', label: 'Ambiguous metadata', status: 'unavailable', count: null },
{ id: 'consistency', label: 'Consistency', status: 'issues', count: null },
],
});
test('health rows give each check a chip from its real status, in the server order', async () => {
const { healthRows } = await overviewModule;
assert.deepEqual(healthRows(REPORT), [
['Integrity check', 'Passed'],
['Foreign keys & pair references', '2 broken references'],
['Canonical hashes', '1 hash does not match its text'],
['Pair uniqueness', 'Could not check'],
['Missing files', '2 links need review'],
['Ambiguous metadata', 'Not available yet'],
['Consistency', 'Issues found'],
]);
});
test('missing files is reported per image source when there are several', async () => {
const { healthRows } = await overviewModule;
const report = { ...REPORT, checks: [{ id: 'missing_files', label: 'Missing files', status: 'issues', count: 1, partial: true,
sources: [{ name: 'output', status: 'checked', count: 1 }, { name: 'input', status: 'checked', count: 0 },
{ name: 'archive', status: 'unavailable', count: null }] }] };
assert.deepEqual(healthRows(report), [
['Missing files · output', '1 link needs review'],
['Missing files · input', 'Passed'],
['Missing files · archive', 'Not available'],
]);
const unavailable = { ...REPORT, checks: [{ id: 'missing_files', label: 'Missing files', status: 'unavailable', count: null, sources: [] }] };
assert.deepEqual(healthRows(unavailable), [['Missing files', 'Not available']]);
});
test('the report summary says when it was checked and whether it is complete', async () => {
const { healthSummary } = await overviewModule;
const when = new Date(REPORT.checked_at).toLocaleString();
assert.equal(healthSummary({ ...REPORT, status: 'passed' }), `Checked ${when}. Every check passed.`);
assert.equal(healthSummary({ ...REPORT, status: 'incomplete' }), `Checked ${when}. Some checks could not run, so this report is incomplete.`);
assert.equal(healthSummary({ ...REPORT, checked_at: 'x' }), 'Nothing was changed; review the checks with issues.');
});
test('statistics rows are the real runs and ratings, then the library totals', async () => {
const { categoryRows, totalsRows } = await overviewModule;
const statistics = {
by_category: [{ category: 'Landscapes', runs: 1200, average_rating: 4.5 },
{ category: null, runs: 3, average_rating: null }],
totals: { total_prompts: 12, total_tags: 15, total_images: 24 },
rated_images: 20,
};
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString(), '4.5 / 5'], ['Uncategorized', '3', 'Not rated']]);
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24'], ['Rated images', '20']]);
});
/** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */
function fakeApi(answers) {
const calls = [];
const answer = (method, url) => {
calls.push(`${method} ${url}`);
const queue = answers[`${method} ${url}`];
if (!queue?.length) throw new Error(`unexpected ${method} ${url}`);
const next = queue.shift();
return next instanceof Error ? Promise.reject(next) : Promise.resolve(next);
};
return { calls, get: url => answer('GET', url), post: url => answer('POST', url) };
}
const JOB = '0123456789abcdef0123456789abcdef';
const job = (state, id = JOB, kind = 'health') => ({ id, kind, state });
/**
* A fake Activity store holding `jobs`: `track(job)` lists a started job, `load()` applies the next of `reads` (the
* server's job lists) and `push(job)` is a stream update; each notifies as the real store does. It never polls.
*/
function fakeActivity({ jobs = [], reads = [] } = {}) {
const listeners = new Set();
const emit = () => { for (const listener of [...listeners]) listener(); };
const store = {
loads: 0,
find: id => jobs.find(item => item.id === id),
running: () => jobs.filter(item => item.state === 'running'),
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
listeners: () => listeners.size,
async load() {
store.loads += 1;
if (reads.length) jobs = reads.shift();
emit();
},
track(item) {
if (!item?.id) throw new TypeError('Invalid job');
store.push(item);
return item;
},
push(item) {
jobs = [...jobs.filter(other => other.id !== item.id), item];
emit();
},
};
return store;
}
test('a health check tracks its job in the Activity store, then reads the report', async () => {
const { runHealthCheck } = await overviewModule;
const api = fakeApi({
'POST /prompt_manager/system/health': [{ job: job('running') }],
'GET /prompt_manager/system/health': [{ report: REPORT }],
});
const activity = fakeActivity();
const checking = runHealthCheck(api, { activity });
await new Promise(resolve => setImmediate(resolve));
// The store follows the started job's event stream; nothing reads or polls the job here.
assert.equal(activity.loads, 0);
assert.equal(activity.find(JOB).state, 'running');
assert.deepEqual(api.calls, ['POST /prompt_manager/system/health']);
activity.push(job('finished'));
assert.equal(await checking, REPORT);
assert.deepEqual(api.calls, ['POST /prompt_manager/system/health', 'GET /prompt_manager/system/health']);
assert.equal(activity.listeners(), 0);
});
test('a check already running elsewhere is waited for, not refused', async () => {
const { runHealthCheck } = await overviewModule;
const busy = Object.assign(new Error('busy'), { status: 409, code: 'busy' });
const api = fakeApi({
'POST /prompt_manager/system/health': [busy],
'GET /prompt_manager/system/health': [{ report: REPORT }],
});
const activity = fakeActivity({ reads: [[job('running', 'f'.repeat(32), 'scan'), job('running')]] });
const checking = runHealthCheck(api, { activity });
await new Promise(resolve => setImmediate(resolve));
assert.equal(activity.loads, 1);
activity.push(job('finished'));
assert.equal(await checking, REPORT);
// The other check ended between the refusal and the read: its report is the latest.
const ended = fakeApi({ 'POST /prompt_manager/system/health': [busy], 'GET /prompt_manager/system/health': [{ report: REPORT }] });
assert.equal(await runHealthCheck(ended, { activity: fakeActivity({ reads: [[]] }) }), REPORT);
});
test('a failed, lost or reportless check rejects; other errors pass through', async () => {
const { runHealthCheck, waitForJob, HEALTH_FAILED_TEXT } = await overviewModule;
const failed = fakeApi({ 'POST /prompt_manager/system/health': [{ job: job('failed') }] });
await assert.rejects(runHealthCheck(failed, { activity: fakeActivity() }), { message: HEALTH_FAILED_TEXT });
// The store marks a job lost when the server no longer lists it (ComfyUI restarted).
const restarted = fakeActivity({ jobs: [job('running')] });
const waiting = waitForJob(restarted, JOB);
restarted.push(job('lost'));
assert.equal(await waiting, 'lost');
const empty = fakeApi({ 'POST /prompt_manager/system/health': [{ job: job('finished') }],
'GET /prompt_manager/system/health': [{ report: null }] });
await assert.rejects(runHealthCheck(empty, { activity: fakeActivity() }), { message: HEALTH_FAILED_TEXT });
const broken = fakeApi({ 'POST /prompt_manager/system/health': [Object.assign(new Error('server'), { status: 500 })] });
await assert.rejects(runHealthCheck(broken, { activity: fakeActivity() }), { message: 'server' });
const invalid = fakeApi({ 'POST /prompt_manager/system/health': [{ job: null }] });
await assert.rejects(runHealthCheck(invalid, { activity: fakeActivity() }), { message: 'Invalid job' });
});
test('waiting stops when its signal aborts', async () => {
const { waitForJob } = await overviewModule;
const controller = new AbortController();
const activity = fakeActivity({ jobs: [job('running')] });
const waiting = waitForJob(activity, JOB, { signal: controller.signal });
await new Promise(resolve => setImmediate(resolve));
controller.abort(new Error('left'));
await assert.rejects(waiting, { message: 'left' });
assert.equal(activity.listeners(), 0);
const gone = new AbortController();
gone.abort(new Error('already'));
await assert.rejects(waitForJob(fakeActivity(), JOB, { signal: gone.signal }), { message: 'already' });
});
test('the thumbnail row counts previews that could not be made (#249)', async () => {
const { thumbnailTitle } = await overviewModule;
assert.equal(thumbnailTitle({ pending: 12, failed: 2 }), '12 missing thumbnails · 2 failed');
assert.equal(thumbnailTitle({ pending: 1, failed: 1 }), '1 missing thumbnail · 1 failed');
assert.equal(thumbnailTitle({ pending: 0, failed: 0 }), 'No missing thumbnails');
assert.equal(thumbnailTitle({ pending: 3, failed: 'x' }), '3 missing thumbnails');
});