Files
ComfyAssets-ComfyUI_PromptM…/tests/js/workspace-system-maintenance.test.js
T
Vito 9ab25f9aaa feat(system): Maintenance UI (System S7) (#322)
* fix(gallery): clear only Prompt Manager cache entries (#222)

The legacy gallery's Clear cache deleted every localStorage key starting
pm_ or promptManager_ (the workspace theme and the dismissed update notice
among them) and every CacheStorage cache whose name held gallery or
thumbnail, which on ComfyUI's shared origin can belong to another tool.

web/js/cache-ownership.js names what a clear may remove from an inventory
of the real writers: every key Prompt Manager writes is a preference and no
page opens CacheStorage or IndexedDB, so nothing is removed and the toast
says so. A test fails when a new storage writer is not classified. The
server thumbnail clear stays its own request; a refused or unreachable
server changes nothing and is reported, and refused browser storage is
reported as such.

* feat(system): run reviewed maintenance natively

System › Maintenance is native: the concept's notice and its Database and
Images & cache surfaces replace the hand-off.

- Integrity & relationship checks runs the health check; the report's
  Review missing links reviews a fresh Missing-file links plan.
- Review plan shows the server's read-only plan (D100): scope, protected,
  backup policy and media lines, the listed records, how long the review
  holds and the backup box; Apply reviewed operation sends only its token
  and opens the job in Activity. busy names the running job, plan-used links
  the job that applied it, an expired or replaced review is reviewed again,
  and a lost answer says Outcome not confirmed and points to Activity.
- Find duplicates starts the search; its view offers Review groups (D101):
  ten groups at a time, Keep radios per copy, each keeper sent as
  {group_id, root_index, path}; the dialog says History keeps the removed
  paths in its run records.
- Thumbnail cache opens the thumbnail dialog; Metadata cache is disabled
  with its reason while nothing is owned (D102, Q5).
- Activity descriptors for maintenance, duplicates and duplicate_cleanup.
- The legacy duplicates dialog's View Full names its source (?root=).

* test(system): compare Maintenance with the concept

system-maintenance-concept.spec compares System › Maintenance, the plan
dialog of each operation and the duplicates dialog with the concept in both
themes and both viewports, mapped only by D100–D102, and proves a mutated
row, label, danger mark or token is noticed. The duplicates dialog's Review
duplicate cleanup is the concept's plain button.

* test(system): Maintenance no longer hands off; raise coverage floors

The System shell test expected Maintenance's D94 hand-off; the section is
native now. coverage:update sets the new modules' floors from the
measured run (cache-ownership 94, maintenance-review 94,
system-maintenance 92 lines) and raises the floors other files passed.

* test(admin): close the preview worker after the last job in teardown

A scan publishes its last event, which ends the stream the test reads,
before it hands its missing previews to the automatic worker. That
hand-off can run after tearDownAsync closed the worker, so it starts a new
one whose .part files were still open when the final drain checked for
open handles (py3.10 on ubuntu, test_scan_reads_metadata_on_several_threads).
The drain now closes the worker for good once every job has ended.

* fix(system): address the S7 final review

- I1: an apply answer is a refusal only when our server sent it (a known
  code, or a 4xx whose body says success:false); a proxy's 5xx, an
  unreadable 2xx or a dropped connection says Outcome not confirmed and
  reads Activity again. ApiError gains 'refused'; the server's own
  pre-start failure is coded 'apply-failed'.
- M1: the lost-response test lets the apply run, then drops its answer.
- M2: a failed apply records its code in the job's scope; Activity offers
  Review again for an overtaken plan (Maintenance for a duplicate cleanup
  whose sources changed).
- M3: a failed duplicate cleanup toasts the server's reason.
- M4: a review button holds while its preview is read (one preview per
  double press).
- M5: the storage guard also catches bracket and property assignment and
  CacheStorage/IndexedDB opens; why the Toolkit build is skipped is
  written down.
- M6: a refused storage listing says so on the Metadata cache row.
- M7, M8: D100 text corrected; workflow_storage has its title.
- M9: a busy start names the job the server named.

* test: follow #319 in the Maintenance tests

- The thumbnail dialog has only Scope since #319.
- The admin teardown refuses new preview workers on its own API instead
  of closing with final=True, which since #319 also shuts the process-wide
  encoders that later test modules use.

* fix(system): keep the server's search-again line on a duplicate review

A duplicate cleanup review the server refuses with 409 sources-changed
showed the generic "could not be reviewed" message; it now shows the
server's "The sources changed since the search. Search for duplicates
again."

* fix(system): a plan with nothing to do offers only Close

A reviewed maintenance plan whose scope is 0 records ("No duplicate
prompt text") offered Apply reviewed operation, which started an empty
job. It now shows its rows, "There is nothing to apply; nothing was
changed." and a Close button.

* fix(system): wrap long copy paths in the duplicates review

A Keep label is the copy's source and path, and dataset folders often have
long names with no space to break at, so the review scrolled sideways.
The label now wraps anywhere inside its compare item (D101).
2026-10-25 05:51:57 -07:00

172 lines
13 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 › Maintenance (#249 System S7 Task 19): the concept's rows (app.js 688–690), the D100 plan review built from
// the server's Plan, the D101 duplicates review's keep entries and copy, and the D102 metadata cache row.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const maintenance = import('../../web/js/workspace/system-maintenance.mjs');
const review = import('../../web/js/workspace/maintenance-review.mjs');
const jobView = import('../../web/js/workspace/activity-job-view.mjs');
const CONCEPT = fs.readFileSync(path.join(__dirname, '../../docs/concepts/workspace/app.js'), 'utf8');
test('the section lists the concept rows in its order, labels and danger marks', async () => {
const { MAINTENANCE_SURFACES } = await maintenance;
const start = CONCEPT.indexOf('case "maintenance":');
const body = CONCEPT.slice(start, CONCEPT.indexOf('break;', start));
const concept = [...body.matchAll(/operation\("([^"]+)", "([^"]+)", "[^"]+", "([^"]+)"(?:, '[^']*'|, "")?(, true)?\)/g)]
.map(([, title, description, label, danger]) => [title, description, label, Boolean(danger)]);
const surfaces = [...body.matchAll(/<section class="surface"><h2>([^<]+)<\/h2>/g)].map(match => match[1]);
assert.deepEqual(MAINTENANCE_SURFACES.map(([heading]) => heading), surfaces.map(text => text.replace('&amp;', '&')));
const production = MAINTENANCE_SURFACES.flatMap(([, rows]) => rows.map(([title, description, label, , danger = false]) =>
[title, description, label, danger]));
assert.deepEqual(production, concept.map(([title, ...rest]) => [title.replace('&amp;', '&'), ...rest]));
assert.equal(production.length, 8);
});
const PLAN = Object.freeze({
success: true, token: 't'.repeat(43), operation: 'duplicate_prompts', expires_at: '2026-10-24T12:15:00+00:00',
scope: { count: 3, label: '3 duplicate prompts in 2 groups' },
protected: { count: 1, reasons: ['1 group belongs to a saved pair'] },
backup: { policy: 'on', destination_label: 'prompts.db.backup_<date and time>', expected_bytes: 4096 },
media: { deletes_files: false, label: 'No files are deleted' },
disk: { free_bytes: 2 ** 30, required_bytes: 2 ** 24, sufficient: true },
details: [{ label: 'a cat', detail: '2 copies merged into the oldest' }, { label: 'a dog', detail: '' }],
});
test("plan dialog shows the server's scope, protected, backup policy and media lines (D100)", async () => {
const { planGrid, planNote, detailLines, backupText, protectedText, OPERATION_TITLES } = await review;
assert.deepEqual(planGrid(PLAN), [
['Scope', '3 duplicate prompts in 2 groups'],
['Protected', '1 group belongs to a saved pair'],
['Backup', 'Create and verify before changes (prompts.db.backup_<date and time>)'],
['Media on disk', 'No files are deleted'],
]);
assert.equal(backupText({ policy: 'off', destination_label: 'x' }), 'No safety backup (the policy is off)');
assert.equal(backupText({ policy: 'required', destination_label: 'prompts.db.pre-workflow-storage_<date and time>' }),
'Always created and verified first (prompts.db.pre-workflow-storage_<date and time>)');
assert.equal(backupText(undefined), 'Create and verify before changes');
assert.equal(protectedText({ count: 0, reasons: [] }), 'Nothing is protected');
assert.equal(protectedText({ count: 2, reasons: [] }), '2 records kept');
assert.equal(protectedText({ count: 3, reasons: ['1 saved pair', '2 titled parents'] }), '1 saved pair · 2 titled parents');
assert.deepEqual(planGrid({ scope: { count: 1 } })[0], ['Scope', '1 record']);
assert.deepEqual(planGrid({})[3], ['Media on disk', 'No files are removed']);
const now = Date.parse('2026-10-24T12:00:00+00:00');
assert.equal(planNote(PLAN, { now }), 'Nothing changes until you apply. This review holds for 15 minutes.');
assert.equal(planNote({ ...PLAN, expires_at: 'soon' }, { now }), 'Nothing changes until you apply.');
assert.equal(planNote({ ...PLAN, expires_at: '2026-10-24T12:00:20+00:00', disk: { free_bytes: 2 ** 20, required_bytes: 3 * 2 ** 20,
sufficient: false } }, { now }), 'Nothing changes until you apply. This review holds for 1 minute. ' +
'Not enough free disk space: about 3.0 MB is needed and 1.0 MB is free.');
assert.deepEqual(detailLines(PLAN), ['a cat · 2 copies merged into the oldest', 'a dog']);
assert.deepEqual(detailLines({ details: [null, { label: 7 }] }), []);
assert.equal(OPERATION_TITLES.missing_links, 'Missing-file links');
// S7 review M8: the workflow migration's plan reads its own title (its job is started outside Maintenance).
assert.equal(OPERATION_TITLES.workflow_storage, 'Store each workflow once');
});
test('a refused apply names its reason; a lost answer is not confirmed', async () => {
const { applyRefusal, reviewRefusal, NOT_CONFIRMED, APPLY_FAILED, REVIEW_FAILED } = await review;
const error = (status, code, message, extra = {}) => Object.assign(new Error(message), { status, code, ...extra });
assert.deepEqual(applyRefusal(new TypeError('Failed to fetch')), { toast: NOT_CONFIRMED, lost: true });
assert.deepEqual(NOT_CONFIRMED, ['Outcome not confirmed. ', { text: 'Check Activity', href: '#/activity' },
' before applying it again.']);
const busy = applyRefusal(error(409, 'busy', 'busy', { running: { id: 'a'.repeat(32), title: 'Scan for metadata' } }));
assert.deepEqual(busy.toast, ['Another job is running: ', { text: 'Scan for metadata', href: `#/activity/${'a'.repeat(32)}` }, '.']);
assert.deepEqual(applyRefusal(error(409, 'plan-used', 'used', { job: { id: 'b'.repeat(32), title: 'Optimize database' } })).toast,
['This plan was already applied: ', { text: 'Optimize database', href: `#/activity/${'b'.repeat(32)}` }, '.']);
assert.deepEqual(applyRefusal(error(409, 'plan-used', 'used', { job: { id: 'b'.repeat(32), title: null } })).toast[1].text, 'its job');
assert.deepEqual(applyRefusal(error(409, 'plan-used', 'used', { job: null })), { toast: 'This plan was already applied.' });
assert.deepEqual(applyRefusal(error(410, 'plan-expired', 'This review expired. Review the plan again.')),
{ toast: 'This review expired. Review the plan again.', review: true });
assert.deepEqual(applyRefusal(error(409, 'storage-changed', 'Replaced.')), { toast: 'Replaced.', review: true });
assert.deepEqual(applyRefusal(error(507, 'insufficient-space', 'Not enough free disk space.')), { toast: 'Not enough free disk space.' });
// S7 review I1: only our server's own refusal is definitive. A proxy's 5xx, a 2xx whose body could not be read,
// or any answer without the server's refusal may have followed a started job: not confirmed, read Activity again.
assert.deepEqual(applyRefusal(error(504, null, 'Gateway Timeout')), { toast: NOT_CONFIRMED, lost: true });
assert.deepEqual(applyRefusal(error(500, null, 'Internal Server Error')), { toast: NOT_CONFIRMED, lost: true });
assert.deepEqual(applyRefusal(error(202, null, 'Invalid JSON response')), { toast: NOT_CONFIRMED, lost: true });
assert.deepEqual(applyRefusal(error(502, null, 'Bad Gateway', { refused: false })), { toast: NOT_CONFIRMED, lost: true });
assert.deepEqual(applyRefusal(error(400, null, 'token must be the plan token', { refused: true })), { toast: APPLY_FAILED });
assert.deepEqual(applyRefusal(error(500, 'apply-failed', 'Maintenance failed; nothing was changed.', { refused: true })),
{ toast: 'Maintenance failed; nothing was changed.' });
assert.equal(reviewRefusal(error(413, 'too-large', 'This plan is too large to review at once.')),
'This plan is too large to review at once.');
assert.equal(reviewRefusal(error(500, null, 'boom')), REVIEW_FAILED);
// S7 re-review N1: a duplicate review whose sources changed since the search keeps the server's "search again".
const changed = 'The sources changed since the search. Search for duplicates again.';
assert.equal(reviewRefusal(error(409, 'sources-changed', changed)), changed);
assert.equal(reviewRefusal(new TypeError('x')), REVIEW_FAILED);
});
test('keep entries name each copy by group, source index and path, never a bare path', async () => {
const { keepEntries } = await review;
// Two sources hold a file at the same relative path: only the source index tells them apart.
const output = { path: 'same.png', root: 'output', root_index: 0 };
const input = { path: 'same.png', root: 'input', root_index: 1 };
const chosen = new Map([['hash-a', input], ['hash-b', output]]);
assert.deepEqual(keepEntries(chosen), [
{ group_id: 'hash-a', root_index: 1, path: 'same.png' },
{ group_id: 'hash-b', root_index: 0, path: 'same.png' },
]);
});
test('the duplicates dialog says what removing a copy does, History included', async () => {
const { DUPLICATES_NOTE, pageText, groupText, copyLabel } = await review;
assert.match(DUPLICATES_NOTE, /History keeps the removed paths in its run records\./);
assert.match(DUPLICATES_NOTE, /deletes its file from disk/);
assert.equal(pageText(0, 10, 24), 'Groups 1–10 of 24');
assert.equal(pageText(20, 4, 24), 'Groups 21–24 of 24');
assert.equal(pageText(0, 0, 0), 'No duplicate files were found.');
assert.equal(groupText({ count: 3, size: 120 * 1024, files: [] }), '3 identical files · 120 KB each');
assert.equal(groupText({ size: 3 * 2 ** 20, files: [{}, {}] }), '2 identical files · 3.0 MB each');
assert.equal(groupText({ count: 2, size: 10, files: [] }), '2 identical files · 1 KB each');
assert.equal(copyLabel({ root: 'output', path: 'a/b.png' }), 'Keep output/a/b.png');
assert.equal(copyLabel({ root: '', path: 'b.png', missing: true }), 'Keep b.png (no longer on disk)');
});
test('Metadata cache follows Q5: disabled with its reason while nothing is owned (D102)', async () => {
const { metadataCacheDescription, METADATA_CACHE_DESCRIPTION, NOTHING_CACHED_REASON } = await maintenance;
assert.equal(METADATA_CACHE_DESCRIPTION, 'Clear only Prompt Manager metadata cached by this browser.');
assert.equal(metadataCacheDescription({ keys: [], caches: [] }), `${METADATA_CACHE_DESCRIPTION} ${NOTHING_CACHED_REASON}`);
assert.equal(metadataCacheDescription({ keys: ['k'], caches: [] }), METADATA_CACHE_DESCRIPTION);
// S7 review M6: a browser that refused the listing is not claimed empty.
assert.equal(metadataCacheDescription({ keys: [], caches: [], denied: true }),
`${METADATA_CACHE_DESCRIPTION} This browser did not allow checking its storage.`);
});
test('only a finished duplicate search with groups offers Review groups', async () => {
const { hasDuplicateGroups } = await jobView;
const job = (state, groups, kind = 'duplicates') => ({ kind, state, counters: { groups_found: groups } });
assert.equal(hasDuplicateGroups(job('finished', 2)), true);
assert.equal(hasDuplicateGroups(job('finished', 0)), false);
assert.equal(hasDuplicateGroups(job('running', 2)), false);
assert.equal(hasDuplicateGroups(job('finished', 2, 'scan')), false);
assert.equal(hasDuplicateGroups({ kind: 'duplicates', state: 'finished', counters: {} }), false);
});
test('a failed apply the library overtook offers Review again, back to its review (S7 review M2)', async () => {
const { reviewAgainOf } = await jobView;
const failed = (kind, failure, operation) => ({ kind, state: 'failed', scope: { operation, failure } });
assert.deepEqual(reviewAgainOf(failed('maintenance', 'plan-changed', 'duplicate_prompts')),
{ operation: 'duplicate_prompts' });
assert.deepEqual(reviewAgainOf(failed('maintenance', 'storage-changed', 'optimize')), { operation: 'optimize' });
// A duplicate cleanup reviewed against a search the sources overtook: search again in Maintenance.
assert.deepEqual(reviewAgainOf(failed('duplicate_cleanup', 'sources-changed', 'duplicate_files')),
{ section: '#/system/maintenance' });
assert.equal(reviewAgainOf(failed('maintenance', 'backup-failed', 'optimize')), null);
assert.equal(reviewAgainOf(failed('maintenance', 'plan-changed', 'not_an_operation')), null);
assert.equal(reviewAgainOf({ ...failed('maintenance', 'plan-changed', 'optimize'), state: 'finished' }), null);
assert.equal(reviewAgainOf(failed('scan', 'plan-changed', 'optimize')), null);
});
test('a plan with nothing to do offers no Apply (S7 live test)', async () => {
const { hasWork, NOTHING_TO_APPLY } = await review;
assert.equal(hasWork(PLAN), true);
assert.equal(hasWork({ ...PLAN, scope: { count: 0, label: 'No duplicate prompt text' } }), false);
assert.equal(hasWork({ ...PLAN, operation: 'optimize', scope: { count: 1, label: 'The whole database' } }), true);
// A plan whose count is unknown is not assumed empty: the server decides at apply.
assert.equal(hasWork({ ...PLAN, scope: {} }), true);
assert.equal(NOTHING_TO_APPLY, 'There is nothing to apply; nothing was changed.');
});