Files
ComfyAssets-ComfyUI_PromptM…/tests/browser/library-delete-concept.spec.mjs
T
Vito Sansevero ba30b8f94c feat(library): delete archived prompts from the archived shelf (#492)
The archived shelf's bar offers Delete… after Restore. Its review is the
permanent delete only (no Archive choice: the prompts are archived
already), with #482's grid, safety backup, review digests, records hold
and epoch fence. A prompt used as another prompt's negative stays
archived; one restored since the shelf was read is kept and counted.

The preview and delete routes take an optional shelf ('active' or
'archived'). The held write classifies the same shelf, so the permanent
delete is backed up; on this shelf the write archives and rewrites
nothing and counts every reviewed id it did not delete as changed.
2026-10-29 13:49:44 -07:00

149 lines
9.8 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.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
// #443 D152: the Library's Delete… review against the concept's own reviewed archive: the recipe editor's "Archive…"
// (app.js 976), which opens `maintenancePlan("archive <title>")` (953–958, 1309–1310). The concept's shell, grid tokens,
// `p.small` and actions are kept; its title, demo note, rows, note text and `ca-primary` start are replaced by D152's,
// and the start takes the concept's `danger` class, measured on the editor's own "Archive…". The bar's Delete… is
// compared with the concept Images bar's danger action, "Remove links…" (app.js 432).
test.use({ locale: 'en-US', timezoneId: 'UTC' });
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await seedConcept(request);
page.conceptErrors = [];
page.on('pageerror', error => page.conceptErrors.push(error.message));
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.conceptErrors).toEqual([]);
});
/** Runs in the page: the open review dialog's title, body tree, grid, notes and actions with the concept's tokens. */
function measureReview() {
const dialog = document.querySelector('dialog#modal[open]');
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
const text = node => node?.textContent.trim() ?? null;
const body = dialog.querySelector('#modal-body');
const grid = body.querySelector(':scope > dl.meta-grid');
const notes = [...body.querySelectorAll(':scope > p.small')];
const actions = body.querySelector(':scope > .dialog-actions');
return {
title: text(dialog.querySelector('#modal-title')),
tree: [...body.children].map(name).filter(item => item !== 'p.demo-note' && item !== 'h3'),
grid: { rows: [...grid.children].map(row => [text(row.querySelector('dt')), text(row.querySelector('dd'))]),
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'),
dt: css(grid.querySelector('dt'), 'fontSize', 'color', 'textTransform'), dd: css(grid.querySelector('dd'), 'fontSize', 'color') },
notes: notes.map(note => ({ text: text(note), ...css(note, 'fontSize', 'color', 'marginTop') })),
actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
buttons: [...actions.children].map(item => ({ text: text(item), className: item.className,
...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
};
}
/** Runs in the page: a button's class and tokens (the concept's danger button, or production's). */
function measureButton(selector) {
const node = document.querySelector(selector);
const style = getComputedStyle(node);
return { className: node.className, ...Object.fromEntries(['backgroundColor', 'color', 'fontSize', 'fontWeight', 'paddingTop',
'paddingLeft', 'borderTopColor', 'borderTopLeftRadius', 'height'].map(key => [key, key === 'height'
? `${Math.round(node.getBoundingClientRect().height * 100) / 100}px` : style[key]])) };
}
async function conceptReview(page, theme) {
await openConcept(page, 'prompt/alpine', theme);
await page.locator('#main .heading-actions').getByRole('button', { name: 'Edit metadata', exact: true }).click();
const archive = page.locator('dialog#modal[open] .dialog-actions button', { hasText: 'Archive…' });
await expect(archive).toBeVisible();await page.mouse.move(0, 0);
const danger = await page.evaluate(measureButton, 'dialog#modal[open] .dialog-actions button.danger');
await archive.click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan');
await page.mouse.move(0, 0);
return { danger, review: await page.evaluate(measureReview) };
}
async function seedMixed(request) {
// One unpaired prompt beside the concept's paired ones, so the review deletes one and archives one.
await expect(await request.post('/prompt_manager/save', { data: { text: 'Unpaired study', category: 'Studies' } })).toBeOK();
const items = (await (await request.get('/prompt_manager/library?limit=100')).json()).items;
return { unpaired: items.find(item => item.text === 'Unpaired study'), paired: items.find(item => item.variant_count > 0) };
}
async function productionReview(page, request, theme) {
const { unpaired, paired } = await seedMixed(request);
await openProduction(page, 'library', theme);
for (const item of [unpaired, paired]) await page.locator(`article[data-prompt-id="${item.id}"] .image-overlays input`).check();
await page.locator('#library-bulkbar .bulkbar').getByRole('button', { name: 'Delete…', exact: true }).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Delete or archive selected prompts');
await page.mouse.move(0, 0);
return page.evaluate(measureReview);
}
/** D152's changes to the concept's review; everything else must equal the concept. */
function mapDeleteConcept({ danger, review }) {
const [cancel] = review.actions.buttons;
const note = review.notes.at(-1);
return {
...review,
title: 'Delete or archive selected prompts',
// #482: the paired prompt is deletable, so the review asks Archive or Delete permanently (a D90 radio row).
tree: ['dl.meta-grid', 'div.field', 'p.small', 'p.small', 'div.dialog-actions'],
grid: { ...review.grid, rows: [['Delete permanently', '1 prompt not in a saved pair'],
['Archive', '1 prompt in a saved pair: hidden, restorable'],
['Image links removed', '0 images will no longer show these prompts (files stay on disk)'],
['Backup', 'Create and verify before deleting']] },
notes: [
{ ...note, text: 'Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the '
+ 'pair and its runs stay intact. To delete them later or bring them back, press Show archived prompts, select them '
+ 'and press Delete… or Restore.' },
// A second paragraph takes the concept's own `p + p` margin (style.css 93-95).
{ ...note, marginTop: '10px', text: "Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and "
+ 'image links. Deleted prompts cannot be brought back one by one: 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. Image files, favorites, collections and recorded runs stay. A later scan, a '
+ 'new image or a node run with the same text adds it again as a new prompt.' },
],
actions: { ...review.actions, tree: [review.actions.tree[0], 'button.danger'], buttons: [cancel, { text: 'Delete 1 and archive 1 prompt', className: danger.className,
backgroundColor: danger.backgroundColor, color: danger.color, fontSize: danger.fontSize, paddingTop: danger.paddingTop,
paddingLeft: danger.paddingLeft, borderTopColor: danger.borderTopColor }] },
};
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`the Delete… review matches the concept's reviewed archive ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
const concept = await conceptReview(page, theme);
expect(concept.review.tree).toEqual(['dl.meta-grid', 'p.small', 'div.dialog-actions']);
await page.screenshot({ path: testInfo.outputPath(`concept-archive-review-${theme}-${width}.png`) });
const production = await productionReview(page, request, theme);
await page.screenshot({ path: testInfo.outputPath(`production-delete-review-${theme}-${width}.png`) });
expect(diffInventory(mapDeleteConcept(concept), production)).toEqual([]);
});
}
test('the Delete… review comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const concept = await conceptReview(page, 'dark');
await productionReview(page, request, 'dark');
const expected = mapDeleteConcept(concept);
const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path);
expect(await paths()).toEqual([]);
await mutate(page, { css: '#modal-body .dialog-actions .danger { padding-left: 2px }' });
expect(await paths()).toContain('actions.buttons.1.paddingLeft');
});
test('the bar\'s Delete… matches the concept Images bar\'s danger action', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await openConcept(page, 'images', 'dark');
await page.locator('#image-grid .media-card').first().locator('input[data-select]').check();
await expect(page.locator('#bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0, 0);
const reference = await page.evaluate(measureButton, '#bulkbar .bulkbar button.danger');
const { paired } = await seedMixed(request);
await openProduction(page, 'library', 'dark');
await page.locator(`article[data-prompt-id="${paired.id}"] .image-overlays input`).check();
await expect(page.locator('#library-bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0, 0);
expect(await page.evaluate(measureButton, '#library-bulkbar .bulkbar button.danger')).toEqual(reference);
});