Merge remote-tracking branch 'origin/main' into feat/331-collections-pr1

# Conflicts:
#	database/duplicate_copies.py
#	database/models.py
#	py/api/duplicate_files.py
#	tests/test_duplicate_image_categories.py
This commit is contained in:
Vito Sansevero
2026-10-28 08:52:10 -07:00
40 changed files with 2596 additions and 2686 deletions
+85 -19
View File
@@ -76,35 +76,101 @@ test("the workspace keys have their own defaults and validation", () => {
assert.equal(normalizeGallerySettings({ defaultViewMode: "masonry" }).defaultViewMode, "grid");
});
test("a legacy gallery Save keeps the workspace keys and every other stored key", () => {
// #330 S13: the legacy gallery's Page options edit only the page-only keys (PAGE_OPTION_KEYS); System › Appearance owns the rest, so a Save
// or a Reset from the legacy page keeps every other stored key.
function legacyGallery(stored, controls, extra = {}) {
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const settings = require("../../web/js/gallery-settings.js");
const storage = new Map([["gallerySettings", JSON.stringify({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1 })]]);
const controls = {
lazyLoadingToggle: { checked: true }, imageQualitySelect: { value: "high" }, defaultViewModeSelect: { value: "list" },
defaultLimitSelect: { value: "200" }, gridColumnsSelect: { value: "6" }, showImageInfoToggle: { checked: false },
autoLoadMetadataToggle: { checked: true }, cacheMetadataToggle: { checked: true }, showFilePathsToggle: { checked: false },
sidebarCollapsedByDefaultToggle: { checked: true }, infiniteScrollToggle: { checked: false }, debugModeToggle: { checked: false },
apiTimeoutInput: { value: "30" }, checkThumbnailsAtStartup: { checked: true }, videoAutoplayToggle: { checked: true },
videoMuteToggle: { checked: false }, videoLoopToggle: { checked: true },
};
const storage = new Map([["gallerySettings", JSON.stringify(stored)]]);
const context = vm.createContext({
document: { addEventListener() {}, getElementById: id => controls[id] },
window: {}, console: { debug() {}, warn() {}, error() {} }, GallerySettings: settings,
localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) },
localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value),
removeItem: key => storage.delete(key) },
...extra,
});
const file = path.join(__dirname, "../../web/js/gallery.js");
const Manager = vm.runInContext(`${fs.readFileSync(file, "utf8")}\nGalleryManager;`, context, { filename: file });
const manager = Object.create(Manager.prototype);
Object.assign(manager, { showNotification() {}, hideSettings() {}, applySettings() {} });
const calls = [];
Object.assign(manager, { showNotification: (message, type) => calls.push([type, message]), hideSettings() {},
applySettings: applied => calls.push(["apply", applied.lazyLoading]) });
return { manager, storage, calls, saved: () => JSON.parse(storage.get("gallerySettings")) };
}
const PAGE_OPTION_CONTROLS = () => ({
checkThumbnailsAtStartup: { checked: false }, lazyLoadingToggle: { checked: false },
defaultLimitSelect: { value: "200" },
gridColumnsSelect: { value: "6" }, infiniteScrollToggle: { checked: true }, debugModeToggle: { checked: true },
apiTimeoutInput: { value: "45" },
});
test("a legacy gallery Save keeps the workspace keys and every other stored key", () => {
const { manager, saved } = legacyGallery({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1,
imageQuality: "high", showImageInfo: false, videoMute: false, thumbnailPromptDismissed: "sig", defaultViewMode: "list" },
PAGE_OPTION_CONTROLS());
manager.saveSettings();
const saved = JSON.parse(storage.get("gallerySettings"));
assert.equal(saved.workspaceLayout, "masonry");
assert.equal(saved.workspaceDensity, "compact");
assert.equal(saved.someOtherTool, 1);
assert.equal(saved.defaultViewMode, "list");
assert.equal(saved.showImageInfo, false);
assert.equal(saved.videoMute, false);
const result = saved();
assert.equal(result.workspaceLayout, "masonry");
assert.equal(result.workspaceDensity, "compact");
assert.equal(result.someOtherTool, 1);
assert.equal(result.imageQuality, "high");
assert.equal(result.showImageInfo, false);
assert.equal(result.videoMute, false);
assert.equal(result.thumbnailPromptDismissed, "sig");
// Page options no longer edit the default view (S13 G5), so a stored one stays.
assert.equal(result.defaultViewMode, "list");
// The page-only keys come from the dialog.
assert.equal(result.lazyLoading, false);
assert.equal(result.debugMode, true);
assert.equal(result.checkThumbnailsAtStartup, false);
assert.equal(result.defaultLimit, 200);
assert.equal(result.gridColumns, 6);
assert.equal(result.infiniteScroll, true);
assert.equal(result.apiTimeout, 45);
});
test("a legacy gallery Reset restores only the page options and keeps every other stored key", () => {
const controls = PAGE_OPTION_CONTROLS();
const { manager, saved, calls } = legacyGallery({ workspaceLayout: "masonry", imageQuality: "high", videoMute: false,
thumbnailPromptDismissed: "sig", thumbnailsGenerated: true, lazyLoading: false, debugMode: true, defaultLimit: 200,
gridColumns: 6, infiniteScroll: true, apiTimeout: 45, checkThumbnailsAtStartup: false, defaultViewMode: "list" }, controls,
{ confirm: message => (calls.push(["confirm", message]), true) });
manager.resetSettings();
const result = saved();
const { DEFAULT_SETTINGS } = require("../../web/js/gallery-settings.js");
for (const key of ["checkThumbnailsAtStartup", "lazyLoading", "defaultLimit", "gridColumns",
"infiniteScroll", "debugMode", "apiTimeout"]) {
assert.equal(result[key], DEFAULT_SETTINGS[key], key);
}
// The default view is not a page option (S13 G5): Reset keeps a stored one.
assert.equal(result.defaultViewMode, "list");
assert.equal(result.workspaceLayout, "masonry");
assert.equal(result.imageQuality, "high");
assert.equal(result.videoMute, false);
assert.equal(result.thumbnailPromptDismissed, "sig");
assert.equal(result.thumbnailsGenerated, true);
// The dialog shows the defaults again and the page applies them.
assert.equal(controls.lazyLoadingToggle.checked, DEFAULT_SETTINGS.lazyLoading);
assert.equal(controls.apiTimeoutInput.value, DEFAULT_SETTINGS.apiTimeout);
assert.deepEqual(calls, [["confirm", "Reset these page options to their defaults?"], ["apply", DEFAULT_SETTINGS.lazyLoading],
["success", "Page options reset to defaults"]]);
});
test("Page options no longer hold the default view; System › Appearance's Default layout is the one control", () => {
const { manager } = legacyGallery({}, {});
assert.deepEqual([...manager.constructor.PAGE_OPTION_KEYS], ["checkThumbnailsAtStartup", "lazyLoading", "defaultLimit",
"gridColumns", "infiniteScroll", "debugMode", "apiTimeout"]);
});
test("a viewer mute or loop click remembers only that key and never reads the Page options dialog", () => {
const { manager, saved, calls } = legacyGallery({ defaultLimit: 200, lazyLoading: false, videoMute: true }, {});
manager.storeSetting("videoMute", false);
const result = saved();
assert.equal(result.videoMute, false);
assert.equal(result.defaultLimit, 200);
assert.equal(result.lazyLoading, false);
assert.deepEqual(calls, []);
});
+15 -12
View File
@@ -112,11 +112,12 @@ test('saving gallery settings retains the remembered thumbnail dismissal', () =>
});
// #249 System S5: the stream's work is now an Activity job that outlives the page's connection, so the gallery's
// Cancel asks the server to stop the job. The stream's first frame, `job {id, started}`, names the job; the gallery
// Cancel asks the server to stop the job. Since #330 the startup dialog is the gallery's only thumbnail stream (System
// › Sources & scans generates the rest). The stream's first frame, `job {id, started}`, names the job; the gallery
// cancels by that id, only a job this stream started, and a Cancel before the id waits for it (review I1).
const JOB = 'b'.repeat(32);
function streamFixture({ modal = false, postFails = false } = {}) {
function streamFixture({ postFails = false } = {}) {
const posts = [];
const warnings = [];
const gets = [];
@@ -146,18 +147,20 @@ function streamFixture({ modal = false, postFails = false } = {}) {
return { success: true };
} } });
const emit = (type, data) => (listeners[type] || []).forEach(handler => handler({ data: JSON.stringify(data) }));
// The dialog's Cancel runs the handler and from then on reads as cancelled, as the startup dialog does.
let cancelled = false;
const ui = { addStatusMessage() {}, progressText: element(), progressPercent: element(), progressBar: element(),
progressDetails: element(), progressETA: element(), isCancelled: () => false,
onCancel: handler => { ui.cancel = handler; } };
const result = modal ? manager.generateThumbnailsWithProgressInModal(ui)
: manager.generateThumbnailsWithProgress({ quality: 'medium' });
progressDetails: element(), progressETA: element(), isCancelled: () => cancelled,
onCancel: handler => { ui.cancel = () => { cancelled = true; handler(); }; } };
const result = manager.generateThumbnailsWithProgressInModal(ui);
const outcome = result.then(value => ({ value }), error => ({ error: error.message }));
const cancel = () => (modal ? ui.cancel() : elements.cancelThumbnailsBtn.click());
const cancel = () => ui.cancel();
const settle = () => new Promise(resolve => setImmediate(resolve));
return { emit, source, posts, gets, warnings, outcome, cancel, settle };
}
const cancelUrl = id => `/prompt_manager/jobs/${id}/cancel`;
const CANCELLED = 'Thumbnail generation cancelled; previews already written are kept';
test('Cancel before the job id arrives cancels that job once it does', async () => {
const f = streamFixture();
@@ -165,8 +168,8 @@ test('Cancel before the job id arrives cancels that job once it does', async ()
await f.settle();
assert.deepEqual(f.posts, []);
assert.equal(f.source.closed, 0); // kept open until the stream names its job
assert.deepEqual(await f.outcome, { error: 'Thumbnail generation cancelled by user' });
f.emit('status', { phase: 'scanning', message: 'Scanning…' });
assert.deepEqual(await f.outcome, { error: CANCELLED });
// The job frame is the stream's first (the dialog closes the stream on any later frame once cancelled).
f.emit('job', { id: JOB, started: true });
await f.settle();
assert.equal(JSON.stringify(f.posts), JSON.stringify([[cancelUrl(JOB), {}]]));
@@ -185,7 +188,7 @@ test('Cancel after the id cancels it at once, and a double Cancel posts once', a
await f.settle();
assert.equal(JSON.stringify(f.posts), JSON.stringify([[cancelUrl(JOB), {}]]));
assert.equal(f.source.closed, 1);
assert.deepEqual(await f.outcome, { error: 'Thumbnail generation cancelled by user' });
assert.deepEqual(await f.outcome, { error: CANCELLED });
assert.deepEqual(f.gets, []);
});
@@ -216,11 +219,11 @@ test('a cancel post that fails is reported, not thrown', async () => {
await f.settle();
assert.equal(f.posts.length, 1);
assert.equal(f.warnings.length, 1);
assert.deepEqual(await f.outcome, { error: 'Thumbnail generation cancelled by user' });
assert.deepEqual(await f.outcome, { error: CANCELLED });
});
test('the startup dialog cancels its own job by id too', async () => {
const f = streamFixture({ modal: true });
const f = streamFixture();
f.cancel();
await f.settle();
assert.deepEqual(f.posts, []);
+19 -113
View File
@@ -65,109 +65,12 @@ function load(file, className, fetchUrl, extra = {}, dom = elements()) {
const BUSY = 'Scan for metadata already running';
test('gallery cache clear reports a refused server clear and never claims success', async () => {
const calls = [];
const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', async url => {
calls.push(url);
return jsonResponse(409, { success: false, error: BUSY });
});
page.getSettings = () => ({ thumbnailsGenerated: true });
dom.getElementById('thumbnailStatus').textContent = 'Generated';
await page.clearCache();
assert.deepEqual(calls, ['/prompt_manager/images/clear-thumbnails']);
assert.deepEqual(notifications, [['error', `Thumbnails were not cleared: ${BUSY}`]]);
assert.equal(dom.nodes.thumbnailStatus.textContent, 'Generated');
assert.equal(storage.get('gallerySettings'), undefined); // thumbnailsGenerated is not reset
});
test('gallery cache clear says what it cleared and keeps preferences and other data (#222)', async () => {
const calls = [];
const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', async url => {
calls.push(url);
return jsonResponse(200, { success: true, cleared_count: 3 });
});
page.getSettings = () => ({ thumbnailsGenerated: true });
await page.clearCache();
// The server's thumbnails are cleared by their own explicit call; nothing Prompt Manager owns is cached in this
// browser, so no browser entry is removed and the toast says exactly that.
assert.deepEqual(calls, ['/prompt_manager/images/clear-thumbnails']);
assert.deepEqual(notifications, [['success',
"Thumbnails cleared; original images untouched. Nothing of Prompt Manager's is cached in this browser."]]);
assert.equal(dom.nodes.thumbnailStatus.textContent, 'Not generated');
assert.equal(JSON.parse(storage.get('gallerySettings')).thumbnailsGenerated, false);
assert.equal(storage.get('userData'), 'keep');
assert.equal(storage.get('pm_cached'), '1');
assert.equal(storage.get('pm_workspace_theme'), 'light');
assert.equal(storage.get('pm_update_notice_dismissed'), '3.3.0');
});
test("gallery cache clear never deletes another tool's gallery or thumbnail caches (#222)", async () => {
const names = new Set(['comfy-gallery', 'thumbnail-cache']);
const caches = { keys: async () => [...names], delete: async name => names.delete(name) };
const { page, notifications } = load('gallery.js', 'GalleryManager',
async () => jsonResponse(200, { success: true }), { caches, window: { caches } });
page.getSettings = () => ({});
await page.clearCache();
assert.deepEqual([...names], ['comfy-gallery', 'thumbnail-cache']);
assert.equal(notifications.at(-1)[0], 'success');
});
test('gallery cache clear reports a lost server clear instead of success', async () => {
const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', async () => {
throw new TypeError('Failed to fetch');
});
page.getSettings = () => ({ thumbnailsGenerated: true });
dom.getElementById('thumbnailStatus').textContent = 'Generated';
await page.clearCache();
assert.deepEqual(notifications, [['error', 'Thumbnails were not cleared: the server could not be reached.']]);
assert.equal(dom.nodes.thumbnailStatus.textContent, 'Generated');
assert.equal(storage.get('gallerySettings'), undefined);
});
test('gallery cache clear says so when browser storage is denied', async () => {
const denied = { get length() { throw new Error('SecurityError'); }, key() { throw new Error('SecurityError'); },
getItem() { throw new Error('SecurityError'); }, setItem() { throw new Error('SecurityError'); },
removeItem() { throw new Error('SecurityError'); } };
const { page, notifications } = load('gallery.js', 'GalleryManager',
async () => jsonResponse(200, { success: true }), { localStorage: denied });
page.getSettings = () => ({});
await page.clearCache();
assert.deepEqual(notifications, [['warning',
'Thumbnails cleared; original images untouched. This browser refused access to its storage, so nothing there was cleared.']]);
});
test('admin LoRA import shows a refusal frame and closes its modal', async () => {
const { page, dom, timers, notifications } = load('admin.js', 'PromptAdmin',
async () => sseResponse([{ type: 'error', status: BUSY, progress: 0, message: BUSY }]));
page.saveLoraSettings = async () => true;
page.loadStatistics = () => assert.fail('a refused import must not reload statistics');
await page.runLoraImport();
assert.equal(dom.nodes.loraImportStatus.textContent, `Error: ${BUSY}`);
assert.deepEqual(notifications.filter(([type]) => type === 'error'), [['error', `LoRA import failed: ${BUSY}`]]);
assert.equal(timers.length, 1);
timers[0]();
assert.deepEqual(notifications.at(-1), ['hide', 'loraImportModal']);
});
test('admin settings save shows why config.json refused it', async () => {
const message = 'config.json cannot be read, so nothing was saved. Fix or remove the file, then save again.';
const { page, notifications } = load('admin.js', 'PromptAdmin',
async () => jsonResponse(409, { success: false, persisted: false, code: 'config-unreadable', error: message }));
page.settings = {};
page._collectScanPaths = () => [];
page.saveLoraSettings = async () => assert.fail('nothing else is saved after a refusal');
page.setInfiniteScroll = () => assert.fail('nothing changes after a refusal');
await page.saveSettings();
@@ -178,27 +81,30 @@ test('admin settings save keeps its plain message when the reply has no reason',
const { page, notifications } = load('admin.js', 'PromptAdmin', async () => ({ ok: false, status: 500,
json: async () => { throw new SyntaxError('not json'); } }));
page.settings = {};
page._collectScanPaths = () => [];
await page.saveSettings();
assert.deepEqual(notifications, [['error', 'Failed to save settings']]);
});
test('admin maintenance names the job that refused it', async () => {
const { page, dom, notifications } = load('admin.js', 'PromptAdmin',
async () => jsonResponse(409, { success: false, error: BUSY }), {
escapeHtml: text => String(text),
document: { readyState: 'loading', addEventListener() {}, getElementById: id => dom.getElementById(id),
querySelectorAll: selector => (selector === '.maintenance-option:checked' ? [{ id: 'vacuum' }] : []) },
});
// #330 S13-9: the admin page's reduced Page options save sends only the list's scroll default, so a stale legacy form
// can no longer overwrite scan folders or other settings edited in System.
test('admin Page options save posts only infinite_scroll', async () => {
const sent = [];
const { page, dom, notifications } = load('admin.js', 'PromptAdmin', async (url, init) => {
sent.push([url, init.method, JSON.parse(init.body)]);
return jsonResponse(200, { success: true, restart_required: true });
});
page.settings = { infiniteScroll: false };
const scrolled = [];
page.setInfiniteScroll = (on, options) => scrolled.push([on, { ...options }]);
dom.getElementById('infiniteScrollDefault').checked = true;
await page.runMaintenance();
await page.saveSettings();
const results = dom.nodes.maintenanceResults.innerHTML;
assert.match(results, /Maintenance Failed/);
assert.match(results, new RegExp(BUSY));
assert.doesNotMatch(results, /HTTP 409/);
assert.deepEqual(notifications, [['error', '❌ Maintenance failed']]);
assert.equal(dom.nodes.runMaintenanceBtn.disabled, false);
assert.deepEqual(sent, [['/prompt_manager/settings', 'POST', { infinite_scroll: true }]]);
assert.deepEqual(scrolled, [[true, { session: false }]]);
assert.equal(page.settings.infiniteScroll, true);
assert.deepEqual(notifications, [['success', 'Page options saved'], ['hide', 'settingsModal']]);
});
+20
View File
@@ -185,6 +185,26 @@ test('legacy fill applies to local records', async () => {
assert.notEqual(zeroed.prompts.positive.text, '');
});
test('a branch named by recorded run history shows the Recorded run badge', async () => {
// #386: the server labels sides whose save node recorded history named; Execution recorded stays stronger.
const base = await localRecord(new File([fs.readFileSync(new URL('f09.png', PARITY))], 'f09.png'));
const recorded = {...base, prompts:{positive:{...base.prompts.positive, source:'recorded-run'}, negative:{...base.prompts.negative, source:'recorded-run'}}};
let view = renderFull(recorded);
try {
assert.deepEqual(view.texts(n => n.className === 'chip blue'), ['Recorded run']);
assert.ok(view.texts(n => n.tagName === 'p').includes('Prompt evidence: recorded run'));
} finally { view.done(); }
view = renderFull({...recorded, prompts:{...recorded.prompts, negative:{...recorded.prompts.negative, source:'execution'}}});
try {
assert.deepEqual(view.texts(n => n.className === 'chip blue'), ['Execution recorded']);
} finally { view.done(); }
// A recorded branch whose negative left out a run-time part is still inferred from the workflow.
view = renderFull({...recorded, prompts:{...recorded.prompts, negative:{...recorded.prompts.negative, source:'runtime-parts-omitted'}}});
try {
assert.deepEqual(view.texts(n => n.className === 'chip blue'), ['Workflow inferred']);
} finally { view.done(); }
});
// #249 PR C Task C3 (D88): one chip row, the file's current image tags first, then D21's linked tags not already shown.
function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) {
return {...record(), imageTags, library};