Files
ComfyAssets-ComfyUI_PromptM…/tests/js/workspace-library-delete.test.js
T
Vito Sansevero 68d501b161 fix(library): act on a loaded prompt only, keep focus on a gone page, close the menu on focus loss (#512)
Review fixes: Edit metadata and the More menu are disabled while the
prompt loads or failed to load; on a gone prompt's page a focused control
hands focus to Open the Prompt library; the menu closes when focus leaves
it. A unit pins that a paired prompt with a missing digest is not called
another prompt's negative.
2026-10-29 16:24:49 -07:00

328 lines
24 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.
const test = require('node:test');
const assert = require('node:assert/strict');
// #443 D152: the Prompt library's Delete… review (delete unpaired, archive paired, keep protected) and Restore.
const source = import('../../web/js/workspace/library-delete.mjs');
const preview = (patch = {}) => ({success: true, library_epoch: 'e', delete: [], archive: [], protected: [],
already_archived: 0, not_found: 0, image_links: 0, backup: 'on', disk: null, ...patch});
test('the routes are the workspace ones, never the legacy bulk delete', async () => {
const {PREVIEW_URL, DELETE_URL, RESTORE_URL} = await source;
assert.equal(PREVIEW_URL, '/prompt_manager/library/delete/preview');
assert.equal(DELETE_URL, '/prompt_manager/library/delete');
assert.equal(RESTORE_URL, '/prompt_manager/library/restore');
});
test('the title, primary and body follow what the review will do', async () => {
const {reviewOf, reviewTitle, primaryLabel, deleteBody} = await source;
const both = reviewOf(preview({delete: [1, 2], archive: [3]}));
assert.equal(reviewTitle(both), 'Delete or archive selected prompts');
assert.equal(primaryLabel(both), 'Delete 2 and archive 1 prompt');
assert.deepEqual(deleteBody(both), {archive_ids: [3], delete_ids: [1, 2], expected_epoch: 'e', backup: 'on'});
const onlyDelete = reviewOf(preview({delete: [1]}));
assert.equal(reviewTitle(onlyDelete), 'Delete selected prompts');
assert.equal(primaryLabel(onlyDelete), 'Delete 1 prompt');
const onlyArchive = reviewOf(preview({archive: [4, 5]}));
assert.equal(reviewTitle(onlyArchive), 'Archive selected prompts');
assert.equal(primaryLabel(onlyArchive), 'Archive 2 prompts');
});
test('the grid shows each row only when it applies, with protected titles in selection order', async () => {
const {reviewOf, reviewGrid} = await source;
const names = {7: 'Seven', 8: 'Eight', 9: 'Nine', 10: 'Ten'};
const review = reviewOf(preview({delete: [1], archive: [2], protected: [7, 8, 9, 10], already_archived: 1,
not_found: 2, image_links: 3}));
assert.deepEqual(reviewGrid(review, id => names[id]), [
['Delete permanently', '1 prompt not in a saved pair'],
['Archive', '1 prompt in a saved pair: hidden, restorable'],
['Protected (kept)', '4: Seven, Eight, Nine and 1 more'],
['Already archived', '1'],
['No longer exist', '2'],
['Image links removed', '3 images will no longer show these prompts (files stay on disk)'],
['Backup', 'Create and verify before deleting'],
]);
// #512: a row with nothing in it is left out.
const archiveOnly = reviewOf(preview({archive: [2]}));
assert.deepEqual(reviewGrid(archiveOnly).map(([term]) => term), ['Archive']);
const deleteOnly = reviewOf(preview({delete: [1]}));
assert.deepEqual(reviewGrid(deleteOnly).map(([term]) => term), ['Delete permanently', 'Image links removed', 'Backup']);
const keptOnly = reviewOf(preview({protected: [7]}));
assert.deepEqual(reviewGrid(keptOnly, id => names[id]), [['Protected (kept)', '1: Seven']]);
const off = reviewOf(preview({delete: [1], backup: 'off'}));
assert.equal(reviewGrid(off).at(-1)[1], 'Off: no safety backup (System › Backup & export › Recovery policy)');
});
test('the Archive row names the saved pair, never negative variants, for one or many prompts', async () => {
const {reviewOf, reviewGrid} = await source;
// A saved-pair prompt with 0 negative variants (live prompt 7901) is archive-only for its pair, not for variants.
const one = reviewGrid(reviewOf(preview({archive: [2]}))).find(([term]) => term === 'Archive');
assert.deepEqual(one, ['Archive', '1 prompt in a saved pair: hidden, restorable']);
const many = reviewGrid(reviewOf(preview({archive: [2, 3, 4]}))).find(([term]) => term === 'Archive');
assert.deepEqual(many, ['Archive', '3 prompts in a saved pair: hidden, restorable']);
for (const [, value] of [one, many]) assert.doesNotMatch(value, /variant/);
});
test('the sentences explain archive, irreversibility per backup policy, re-creation and protection', async () => {
const {reviewOf, reviewSentences} = await source;
const [archive, remove, guarded] = reviewSentences(reviewOf(preview({delete: [1], archive: [2], protected: [3]})));
// Live test (#470): the archive reason is the saved pair, which a prompt with 0 negative variants has too.
assert.match(archive, /^Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the pair and its runs stay intact\./);
assert.doesNotMatch(archive, /variant/);
// #492: the archived shelf deletes them too.
assert.match(archive, /stay intact\. To delete them later or bring them back, press Show archived prompts, select them and press Delete… or Restore\.$/);
assert.match(remove, /the only way back is to restore the safety backup taken first \(System › Backup & export\), which returns the whole library to that moment\. It is kept among the last 10 safety backups\./);
assert.match(remove, /A later scan, a new image or a node run with the same text adds it again as a new prompt\.$/);
assert.equal(guarded, 'Protected prompts are tagged __protected__; remove the tag to delete them.');
const [off] = reviewSentences(reviewOf(preview({delete: [1], backup: 'off'})));
assert.match(off, /Deleted prompts cannot be brought back\. Image files/);
assert.doesNotMatch(off, /safety backup/);
});
test('a review that cannot start says why: nothing to change, or no room for the backup', async () => {
const {reviewOf, blockedReason, COPY} = await source;
assert.equal(blockedReason(reviewOf(preview({protected: [1]}))), COPY.none);
assert.equal(blockedReason(reviewOf(preview({delete: [1], disk: {sufficient: false, message: 'Not enough. Nothing was changed.'}}))),
'Not enough. Nothing was changed.');
assert.match(blockedReason(reviewOf(preview({delete: [1], disk: {sufficient: false}}))), /not enough free space/);
assert.equal(blockedReason(reviewOf(preview({delete: [1], disk: {sufficient: true}}))), null);
assert.equal(blockedReason(reviewOf(preview({archive: [1]}))), null);
});
test('a delete outcome counts each part, singular and plural, and names the backup', async () => {
const {reviewOf, deleteOutcome} = await source;
const review = reviewOf(preview({delete: [1, 2], archive: [3], protected: [9]}));
assert.deepEqual(deleteOutcome({success: true, deleted: 2, archived: 3, already_archived: 0, not_found: 0, changed: 0,
backup: 'prompts.db.backup_x'}, null, review), {committed: true, reload: true,
toast: 'Deleted 2 prompts. Archived 3 prompts. 1 protected prompt was kept. Safety backup saved: prompts.db.backup_x (System › Backup & export).'});
assert.equal(deleteOutcome({success: true, deleted: 1, archived: 0, already_archived: 1, not_found: 1, changed: 1,
backup: null}, null, reviewOf(preview())).toast,
'Deleted 1 prompt. 1 was already archived. 1 no longer exists. 1 changed since the review and was not deleted.');
assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0, already_archived: 2, not_found: 3, changed: 2,
backup: null}, null, reviewOf(preview({protected: [1, 2]}))).toast,
'Nothing was deleted or archived. 2 were already archived. 3 no longer exist. 2 changed since the review and were not deleted. 2 protected prompts were kept. The Library was reloaded.');
assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0}, null, reviewOf(preview())).committed, false);
});
test('a failed delete says nothing changed, or that it may have, and keeps or reloads the selection', async () => {
const {deleteOutcome, COPY} = await source;
assert.deepEqual(deleteOutcome(null, {status: 409, code: 'storage-changed'}), {toast: COPY.storage, committed: false, reload: true});
assert.equal(COPY.storage, 'The library was restored or moved since this page loaded. Nothing was deleted or archived.');
assert.deepEqual(deleteOutcome(null, {status: 409, code: 'busy', running: {title: 'Scan'}}),
{toast: 'Scan is running. Nothing was changed; try again when it finishes.', committed: false, reload: false});
assert.deepEqual(deleteOutcome(null, {status: 500, code: 'backup-failed', message: 'The safety backup could not be taken and verified; nothing was changed.'}),
{toast: 'The safety backup failed. The safety backup could not be taken and verified; nothing was changed.', committed: false, reload: false});
assert.deepEqual(deleteOutcome(null, {status: 507, code: 'insufficient-space', message: 'Not enough free disk space. Nothing was changed.'}),
{toast: 'Not enough free disk space. Nothing was changed.', committed: false, reload: false});
for (const failure of [new TypeError('Failed to fetch'), {status: 502}, {status: 503}, {status: 504}]) {
assert.deepEqual(deleteOutcome(null, failure), {toast: COPY.unconfirmed, committed: true, reload: true});
}
assert.deepEqual(deleteOutcome(null, {status: 400}), {toast: COPY.failed, committed: false, reload: false});
});
test('a failed preview reloads only after a restore; Restore outcomes have their own words', async () => {
const {previewOutcome, restoreOutcome, COPY} = await source;
assert.deepEqual(previewOutcome({status: 409, code: 'storage-changed'}), {toast: COPY.storage, committed: false, reload: true});
assert.deepEqual(previewOutcome({status: 500}), {toast: COPY.reviewFailed, committed: false, reload: false});
assert.equal(previewOutcome({status: 409, code: 'busy', running: {title: 'Optimize'}}).toast,
'Optimize is running. Nothing was changed; try again when it finishes.');
assert.deepEqual(restoreOutcome({success: true, restored: 2, unchanged: 1, not_found: 1}, null),
{toast: 'Restored 2 prompts to the Prompt library. 1 was not archived. 1 no longer exists.', committed: true, reload: true});
assert.deepEqual(restoreOutcome({success: true, restored: 0, unchanged: 2, not_found: 0}, null),
{toast: 'No prompt was restored. 2 were not archived.', committed: false, reload: true});
assert.deepEqual(restoreOutcome(null, {status: 409, code: 'storage-changed'}), {toast: COPY.restoreStorage, committed: false, reload: true});
assert.deepEqual(restoreOutcome(null, {status: 503}), {toast: COPY.restoreUnconfirmed, committed: true, reload: true});
assert.deepEqual(restoreOutcome(null, {status: 400}), {toast: COPY.restoreFailed, committed: false, reload: false});
});
test('reviewOf keeps only well-formed ids and counts from an answer', async () => {
const {reviewOf} = await source;
const review = reviewOf({library_epoch: 3, delete: [1, 'x', -2, 2], archive: null, protected: [1.5], already_archived: -1,
not_found: 'x', image_links: 2, backup: 'maybe', not_archived: -3});
assert.deepEqual(review, {shelf: 'active', notArchived: 0, epoch: null, delete: [1, 2], archive: [], protected: [], alreadyArchived: 0, notFound: 0,
imageLinks: 2, backup: 'on', disk: null, deletable: [], digests: {},
pairedCounts: {pairs: 0, archivedPairs: 0, variants: 0, keptNegatives: 0}, pairedImageLinks: 0, choice: 'archive', archiveOnly: []});
});
test('the archived notice says why (the saved pair) and names negative variants only when there are any', async () => {
const {archivedNoticeText} = await source;
assert.equal(archivedNoticeText(0), 'This prompt is archived because it is in a saved pair. It is hidden from the Prompt ' +
'library; nothing was removed.');
assert.equal(archivedNoticeText(1), 'This prompt is archived because it is in a saved pair. It and its 1 negative variant ' +
'are hidden from the Prompt library; nothing was removed.');
assert.equal(archivedNoticeText(3), 'This prompt is archived because it is in a saved pair. It and its 3 negative variants ' +
'are hidden from the Prompt library; nothing was removed.');
// An unknown count (an older server) never claims variants.
assert.equal(archivedNoticeText(undefined), archivedNoticeText(0));
});
// #482 (D160): prompts in a saved pair can be deleted permanently, with their own saved pairs.
const D1 = 'a'.repeat(64), D2 = 'b'.repeat(64);
const paired = (patch = {}) => preview({archive: [3, 4, 5], deletable: [3, 4], review_digests: {3: D1, 4: D2},
paired_counts: {pairs: 3, archived_pairs: 1, variants: 2, kept_negatives: 2}, paired_image_links: 4, ...patch});
test('reviewOf reads the deletable ids, their digests and the pair counts; the choice starts at Archive', async () => {
const {reviewOf, hasPairedChoice} = await source;
const review = reviewOf(paired({deletable: [3, 4, 99], review_digests: {3: D1, 4: 'short', 99: D1}}));
// Only reviewed archive ids with a well-formed digest can be deleted permanently.
assert.deepEqual(review.deletable, [3]);
assert.deepEqual(review.digests, {3: D1});
assert.deepEqual(review.pairedCounts, {pairs: 3, archivedPairs: 1, variants: 2, keptNegatives: 2});
assert.equal(review.pairedImageLinks, 4);
assert.equal(review.choice, 'archive');
assert.equal(hasPairedChoice(review), true);
assert.equal(hasPairedChoice(reviewOf(preview({archive: [5]}))), false);
});
test('Archive chosen keeps the #443 review; Delete permanently names the pairs, the kept negatives and archive-only', async () => {
const {reviewOf, withChoice, reviewGrid, reviewTitle, primaryLabel, deleteBody} = await source;
const review = reviewOf(paired({delete: [1], image_links: 2}));
assert.deepEqual(reviewGrid(review).map(([term]) => term), ['Delete permanently', 'Archive', 'Image links removed', 'Backup']);
assert.equal(primaryLabel(review), 'Delete 1 and archive 3 prompts');
assert.deepEqual(deleteBody(review), {archive_ids: [3, 4, 5], delete_ids: [1], expected_epoch: 'e', backup: 'on'});
const permanent = withChoice(review, 'delete');
assert.notEqual(permanent, review);
assert.equal(review.choice, 'archive');
assert.deepEqual(reviewGrid(permanent), [
['Delete permanently', '3 prompts: 1 not in a saved pair, 2 in a saved pair'],
['Saved pairs removed', '3 saved pairs (2 with a negative prompt, 1 archived)'],
['Negatives kept', '2 negative prompts are kept'],
['Archive', '1 prompt used as another prompt\'s negative: hidden, restorable'],
['Image links removed', '6 images will no longer show these prompts (files stay on disk)'],
['Backup', 'Create and verify before deleting'],
]);
assert.equal(reviewTitle(permanent), 'Delete or archive selected prompts');
assert.equal(primaryLabel(permanent), 'Delete 3 and archive 1 prompt');
assert.deepEqual(deleteBody(permanent), {archive_ids: [3, 4, 5], delete_ids: [1], expected_epoch: 'e', backup: 'on',
delete_paired_ids: [3, 4], paired_digests: {3: D1, 4: D2}});
});
test('edge counts: one prompt, no negative, nothing archive-only', async () => {
const {reviewOf, withChoice, reviewGrid, reviewTitle, primaryLabel} = await source;
// Live prompt 7896: one pair with an explicitly empty negative.
const one = withChoice(reviewOf(preview({archive: [7], deletable: [7], review_digests: {7: D1},
paired_counts: {pairs: 1, archived_pairs: 0, variants: 0, kept_negatives: 0}, paired_image_links: 0})), 'delete');
assert.deepEqual(reviewGrid(one), [
['Delete permanently', '1 prompt in a saved pair'],
['Saved pairs removed', '1 saved pair'],
['Image links removed', '0 images will no longer show these prompts (files stay on disk)'],
['Backup', 'Create and verify before deleting'],
]);
assert.equal(reviewTitle(one), 'Delete selected prompts');
assert.equal(primaryLabel(one), 'Delete 1 prompt');
});
test('Delete permanently sentences: what goes with the pairs, what stays, and how it comes back', async () => {
const {reviewOf, withChoice, reviewSentences} = await source;
const [pairs, remove, archive] = reviewSentences(withChoice(reviewOf(paired()), 'delete'));
assert.equal(pairs, 'Deleting a prompt in a saved pair also removes its saved pairs, with their titles, categories, ' +
'ratings, notes, tags, run counts and image links. Their negative prompts are kept. Recorded runs stay in History ' +
'and Activity, listed by their text.');
assert.match(remove, /the only way back is to restore the safety backup/);
assert.match(remove, /A pair node run with the same text saves a new pair\.$/);
assert.equal(archive, 'Prompts used as another prompt\'s negative are archived instead of deleted, so that ' +
'prompt\'s variant stays.');
const [, off] = reviewSentences(withChoice(reviewOf(paired({backup: 'off'})), 'delete'));
assert.match(off, /Deleted prompts cannot be brought back\./);
});
test('a short disk blocks only the permanent delete; the outcome names removed pairs', async () => {
const {reviewOf, withChoice, blockedReason, deleteOutcome} = await source;
const short = reviewOf(paired({disk: {sufficient: false, message: 'Not enough space. Nothing was changed.'}}));
assert.equal(blockedReason(short), null);
assert.equal(blockedReason(withChoice(short, 'delete')), 'Not enough space. Nothing was changed.');
const {toast} = deleteOutcome({success: true, deleted: 2, archived: 1, pairs_removed: 3, changed: 1}, null, short);
assert.equal(toast, 'Deleted 2 prompts. Removed 3 saved pairs. Archived 1 prompt. 1 changed since the review and ' +
'was not deleted.');
});
// #492: Delete… on the archived shelf: no choice, only the permanent delete; what cannot be deleted stays archived.
const shelfPreview = (patch = {}) => preview({deletable: [], review_digests: {}, paired_counts: {pairs: 0, archived_pairs: 0,
variants: 0, kept_negatives: 0}, paired_image_links: 0, not_archived: 0, ...patch});
test('the archived shelf review is the permanent delete, without the choice', async () => {
const {reviewOf, hasPairedChoice, withChoice, reviewTitle, primaryLabel, reviewGrid, deleteBody, blockedReason} = await source;
const review = reviewOf(shelfPreview({delete: [1], archive: [2, 3], deletable: [2], review_digests: {2: D1},
paired_counts: {pairs: 2, archived_pairs: 1, variants: 1, kept_negatives: 1}, paired_image_links: 1, image_links: 1,
not_archived: 1}), 'archived');
assert.equal(review.shelf, 'archived');
assert.equal(review.choice, 'delete');
assert.equal(hasPairedChoice(review), false);
assert.equal(withChoice(review, 'archive').choice, 'delete');
assert.equal(reviewTitle(review), 'Delete archived prompts');
assert.equal(primaryLabel(review), 'Delete 2 prompts');
assert.equal(blockedReason(review), null);
assert.deepEqual(reviewGrid(review), [
['Delete permanently', '2 prompts: 1 not in a saved pair, 1 in a saved pair'],
['Saved pairs removed', '2 saved pairs (1 with a negative prompt, 1 archived)'],
['Negatives kept', '1 negative prompt is kept'],
['Stays archived', "1 prompt used as another prompt's negative"],
['No longer archived (kept)', '1'],
['Image links removed', '2 images will no longer show these prompts (files stay on disk)'],
['Backup', 'Create and verify before deleting'],
]);
// Only what it deletes is sent: the stays-archived id is left out.
assert.deepEqual(deleteBody(review), {archive_ids: [2], delete_ids: [1], expected_epoch: 'e', backup: 'on',
delete_paired_ids: [2], paired_digests: {2: D1}, shelf: 'archived'});
});
test('the archived shelf: unpaired only, nothing deletable, and its sentences', async () => {
const {reviewOf, reviewGrid, reviewSentences, primaryLabel, blockedReason, COPY} = await source;
const loose = reviewOf(shelfPreview({delete: [1]}), 'archived');
assert.deepEqual(reviewGrid(loose).map(([term]) => term), ['Delete permanently', 'Image links removed', 'Backup']);
assert.equal(reviewGrid(loose)[0][1], '1 prompt not in a saved pair');
assert.equal(primaryLabel(loose), 'Delete 1 prompt');
const [remove] = reviewSentences(loose);
assert.match(remove, /^Deleting removes each prompt's text/);
assert.doesNotMatch(reviewSentences(loose).join(' '), /saved pair/);
const stays = reviewOf(shelfPreview({archive: [3], protected: [4]}), 'archived');
assert.equal(blockedReason(stays), COPY.noneArchived);
assert.equal(COPY.noneArchived, 'None of the selected prompts can be deleted.');
const paired = reviewOf(shelfPreview({archive: [2, 3], deletable: [2], review_digests: {2: D1},
paired_counts: {pairs: 1, archived_pairs: 0, variants: 0, kept_negatives: 0}}), 'archived');
const sentences = reviewSentences(paired);
assert.match(sentences[0], /^Deleting a prompt in a saved pair also removes its saved pairs/);
assert.equal(sentences.at(-1), "Prompts used as another prompt's negative stay archived, so that prompt's variant stays.");
// The active shelf is unchanged: a preview without a shelf is #482's.
assert.equal(reviewOf(shelfPreview({archive: [2], deletable: [2], review_digests: {2: D1}})).choice, 'archive');
});
test('the outcome names prompts restored since the review', async () => {
const {deleteOutcome} = await source;
const outcome = deleteOutcome({success: true, deleted: 1, archived: 0, not_archived: 2, changed: 0}, null, {protected: []});
assert.equal(outcome.toast, 'Deleted 1 prompt. 2 were restored since the review and kept.');
const none = deleteOutcome({success: true, deleted: 0, archived: 0, not_archived: 1, changed: 1}, null,
{protected: [], shelf: 'archived'});
assert.equal(none.toast, 'Nothing was deleted. 1 was restored since the review and kept. 1 changed since the review and ' +
'was not deleted. The Library was reloaded.');
});
// #512 (live test of #505/#507/#509): the review names a single prompt, and a negative selected without its positive.
test('a review from a prompt\'s own page names that prompt', async () => {
const {reviewOf, reviewTitle, blockedReason} = await source;
const single = review => ({...review, single: true});
assert.equal(reviewTitle(single(reviewOf(preview({delete: [1]})))), 'Delete this prompt');
assert.equal(reviewTitle(single(reviewOf(preview({archive: [2]})))), 'Archive this prompt');
const kept = single(reviewOf(preview({protected: [3]})));
assert.equal(reviewTitle(kept), 'Delete this prompt');
assert.equal(blockedReason(kept), 'This prompt cannot be deleted or archived.');
assert.equal(reviewTitle(single(reviewOf(preview({archive: [2], deletable: [2], review_digests: {2: 'c'.repeat(64)}}),
'archived'))), 'Delete this prompt');
});
test('a negative used by another prompt is named as such when it is archived', async () => {
const {reviewOf, reviewGrid, reviewSentences} = await source;
const negative = reviewOf(preview({archive: [5], deletable: []}));
assert.deepEqual(reviewGrid(negative), [['Archive', "1 prompt used as another prompt's negative: hidden, restorable"]]);
assert.deepEqual(reviewSentences(negative), ["Prompts used as another prompt's negative are archived instead of deleted, so " +
"that prompt's variant stays. To bring them back, press Show archived prompts, select them and press Restore."]);
const mixed = reviewOf(preview({archive: [2, 5], deletable: [2], review_digests: {2: 'c'.repeat(64)}}));
assert.deepEqual(reviewGrid(mixed)[0], ['Archive', "2 prompts in a saved pair (1 used as another prompt's negative): hidden, restorable"]);
// A paired prompt whose review digest is missing is not called another prompt's negative (review M1).
const noDigest = reviewOf(preview({archive: [2], deletable: [2], review_digests: {}}));
assert.deepEqual(reviewGrid(noDigest), [['Archive', '1 prompt in a saved pair: hidden, restorable']]);
const sentences = reviewSentences(mixed);
assert.match(sentences[0], /^Prompts in a saved pair, as its positive or its negative, are archived instead of deleted/);
assert.match(sentences[1], /^Prompts used as another prompt's negative are archived instead of deleted, so that prompt's variant stays\.$/);
});