Files
ComfyAssets-ComfyUI_PromptM…/tests/browser/workspace-system-sources.spec.mjs
T
Vito d868745194 fix(lora): cache LoraManager's layout and close the #355 follow-ups (#362) (#364)
* feat(lora): cache LoraManager's layout behind file signatures (#362, phase 1)

A new py/lora_layout_cache.py keeps one (install, layout) entry and reuses it
while the configured path, the plugin settings file's and the settings file's
signatures (st_mtime_ns, st_size), the install and every media root's real
path are unchanged and the entry is younger than 30 s. A load whose watched
file changed within 3 s (git's racy rule), could not be checked, or whose
settings were unreadable serves its request only. clear() bumps a generation
so a load that started earlier stores nothing. Hits take no lock.

Not wired yet; phase 2 puts it behind the image hot paths.

* fix(lora): treat an offline Windows share as unvalidated and make cache tests portable (#362)

Review findings on phase 1: Windows raises FileNotFoundError with winerror
53/67 for an offline share, which is a temporary failure, not a missing file.
Test times are whole milliseconds (NTFS keeps 100 ns steps) and a file system
that rounds them skips; cleanup no longer ignores errors; tests added for
ValueError, NotADirectoryError, a failed reload, resolve failures and the
watched-file set.

* perf(lora): serve the image hot paths from the layout cache (#362, phase 2)

_lora_image_dirs takes (install, layout) from lora_utils.cached_lora_manager_layout
instead of resolving the install and reading LoraManager's settings files on
every Library, inspect, reveal and serve request. serve_image builds the
allow-list in the executor instead of on the event loop. A finished import
(System job or legacy scan, both through replace_lora_imports) clears the
cache, so files linked under a folder LoraManager just created are served at
once. The integration-off check and the configured-path rule still run before
the cache.

* fix(lora): fingerprint every scanned sidecar, kept or skipped (#362, phase 3)

Discovery keeps every metadata file it saw (scanned_files, resolved and
sorted; scanned is now its length). The fingerprint hashes each one's path,
size, modification time and whether it was kept, so a skipped sidecar added,
removed or edited between the review and Start refuses Start with
plan-changed, as the review's counts promised.

* fix(lora): warn about refused default LoRA folders and log what failed (#362, phase 4)

ComfyUI's default LoRA folders already pass the folder guard; a refused one
that exists is now listed in the layout's refused_defaults, and the layout
cache warns once per newly refused folder. The cache writes one INFO line
when a stored layout differs from the last one logged. BAD_SIDECAR_ROOT and
the module docstring name a folder that contains the home folder. A relative
preview_url writes a DEBUG line before the suffix search; a non-OSError
failure while loading trigger words logs its traceback. Review follow-ups
harden two serve tests.

* fix(lora): skip a sidecar that is not UTF-8 instead of failing the load (#362)

Review follow-up on phase 4: read_lora_metadata caught JSONDecodeError but not
UnicodeDecodeError, so one non-UTF-8 sidecar failed the whole trigger-word
load (now logged with a traceback every retry) and the import review. It is
skipped like any unreadable sidecar. The relative preview_url DEBUG line uses
lazy formatting.

* docs(testing): the LoRA linked-only gate, layout cache and fingerprint scope (#362, phase 5)

TESTING.md's LoRA reference import contract now states what #358 and #362
shipped: the linked-only gate and _media_roots, linked_only media, the cover
link order, the guard on ComfyUI's default LoRA folders and refused_defaults,
the folder-unreadable code, the trigger cache's catch and 60 s retry, the
fingerprint over every scanned sidecar, and the layout cache's key,
signatures, racy window, maximum age, root re-check and clears. The targeted
command includes tests.test_lora_layout_cache and tests.test_api_images.

* fix(lora): a cached layout needs the same home folder; give the rebuild poll its time (#362)

Final review follow-ups: the folder guard compares against the home folder,
so a cache hit now also needs Path.home() unchanged. TESTING.md says the
configured-path rule runs on the cache's result and that a non-UTF-8
sidecar is skipped.

CI: 'Rebuild all is explicit...' (webkit) polled the job for the default
5 s while it regenerated 24 previews; the failure screenshot shows 12 of 24
done at the deadline on the busy runner (0.17 s locally). The poll now
allows 30 s for that real work.
2026-10-27 05:00:55 -07:00

540 lines
37 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 { seedConcept } from './concept-inventory.mjs';
// System › Sources & scans (#249 System S4 Task 11) on the concept-shaped real fixture, whose two sources are this
// server's `output` and `input` folders: the source list, monitoring and worker rows read GET /settings (S4 Task 9),
// Add/Remove edit the host draft behind the one Save row, and every scan starts the session job through the shared
// "Review metadata scan" (D55), scoped to one source from its row (S4 Task 10). The hooks are workspace_system_fixture.py's.
const workspace = '/prompt_manager/workspace';
const SETTINGS_URL = '/prompt_manager/settings';
const NOTICE_KEY = 'pm_update_notice_dismissed';
const OUTSIDE_REASON = 'Gallery root must be inside a ComfyUI directory (output, input, temp, user) or one listed in PROMPT_MANAGER_EXTRA_GALLERY_ROOTS';
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await seedConcept(request);
await expect(await request.post('/__test__/system/reset')).toBeOK();
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
page.systemErrors = [];
page.on('pageerror', error => page.systemErrors.push(error.message));
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
await request.post('/__test__/release');
await request.post('/__test__/jobs/restart');
await request.post('/__test__/system/reset');
expect(page.systemErrors).toEqual([]);
});
const toast = page => page.locator('#workspace-toast');
const layoutOf = page => page.locator('#workspace-main .settings-layout');
const sourceList = page => layoutOf(page).locator('#source-list');
const statusOf = page => layoutOf(page).locator('#settings-status');
const saveButton = page => layoutOf(page).getByRole('button', { name: 'Save preferences', exact: true });
const dialog = page => page.locator('dialog#modal[open]');
async function serverJson(request, url) {
const response = await request.get(url);
await expect(response).toBeOK();
return response.json();
}
/** Open Sources with the update notice dismissed (or shown, `notice: true`) once the source list has loaded. */
async function openSources(page, { notice = false } = {}) {
if (!notice) await page.addInitScript(key => { if (location.protocol.startsWith('http')) localStorage.setItem(key, '3.2.0'); }, NOTICE_KEY);
await page.goto(`${workspace}#/system/sources`);
await expect(sourceList(page).locator('.operation').first()).toBeVisible();
await expect(layoutOf(page).locator('#workers')).toBeEnabled();
return layoutOf(page);
}
/** The source list's rows: `[h3, p, [buttons]]`, then its note line when there is one. */
function rowsOf(page) {
return sourceList(page).evaluate(list => [...list.children].map(row => row.matches('.operation')
? [row.querySelector('h3').textContent, row.querySelector('p').textContent,
[...row.querySelectorAll('.row-actions > button')].map(button => button.textContent)]
: [row.localName, row.className, row.textContent]));
}
const sourceRow = (page, title) => sourceList(page).locator('.operation').filter({ has: page.getByRole('heading', { name: title, exact: true }) });
test('notice Later persists the 3.2.0 dismissal (pm_update_notice_dismissed) without scanning', async ({ page, request }) => {
const writes = [];
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) writes.push(item.url()); });
const layout = await openSources(page, { notice: true });
const notice = layout.locator(':scope > .notice');
await expect(notice.locator('h2')).toHaveText('Bring older images into your prompt history');
await expect(notice.locator('p')).toHaveText('The schema upgrades automatically. An optional metadata scan links existing positives and negatives without regenerating all thumbnails.');
await expect(notice.locator('.row-actions > button')).toHaveText(['Later', 'Review scan']);
await notice.getByRole('button', { name: 'Later', exact: true }).click();
await expect(layout.locator(':scope > .notice')).toHaveCount(0);
expect(await page.evaluate(key => localStorage.getItem(key), NOTICE_KEY)).toBe('3.2.0');
await expect(dialog(page)).toHaveCount(0);
// The dismissal is the legacy admin page's own, so neither page shows the notice again.
await page.reload();
await expect(sourceList(page).locator('.operation').first()).toBeVisible();
await expect(layout.locator(':scope > .notice')).toHaveCount(0);
expect(writes).toEqual([]);
expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]);
});
test('Review scan opens the shared Review metadata scan with all sources', async ({ page, request }) => {
const layout = await openSources(page, { notice: true });
// The notice's Review scan dismisses it, as the legacy page's does, and reviews every source.
await layout.locator(':scope > .notice').getByRole('button', { name: 'Review scan', exact: true }).click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Review metadata scan');
await expect(dialog(page).locator('dl.meta-grid > div > dd')).toHaveText(['2 output folders', 'New and unresolved files']);
expect(await page.evaluate(key => localStorage.getItem(key), NOTICE_KEY)).toBe('3.2.0');
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(layout.locator(':scope > .notice')).toHaveCount(0);
// Enrich existing metadata is the same review.
const enrich = layout.locator('.operation').filter({ hasText: 'Enrich existing metadata' });
await expect(enrich.locator('p')).toHaveText('Resolve both prompts, deduplicate recipes, preserve uncertain links.');
await enrich.getByRole('button', { name: 'Review scan', exact: true }).click();
await expect(dialog(page).locator('dl.meta-grid > div > dd')).toHaveText(['2 output folders', 'New and unresolved files']);
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]);
});
test('Scan changes opens it with that one source ("1 output folder") and starts a scoped job that opens in Activity', async ({ page, request }) => {
await expect(await request.post('/__test__/system/monitor', { data: { watching: ['input'] } })).toBeOK();
const bodies = [];
page.on('request', item => { if (item.method() === 'POST' && item.url().endsWith('/prompt_manager/jobs/scan')) bodies.push(item.postDataJSON()); });
await openSources(page);
const input = sourceRow(page, 'ComfyUI input');
await expect(input.locator('p')).toHaveText('input · watching');
await input.getByRole('button', { name: 'Scan changes', exact: true }).click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Review metadata scan');
await expect(dialog(page).locator('dl.meta-grid > div > dd')).toHaveText(['1 output folder', 'New and unresolved files']);
await dialog(page).getByRole('button', { name: 'Start scan', exact: true }).click();
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
// The toast says it started (or, for a scan this quick, that it already finished); Activity shows its progress.
await expect(toast(page)).toHaveText(/^Scan (started\.|finished · )/);
expect(bodies).toEqual([{ scope: 'new', roots: [1], paths: ['input'] }]);
const id = page.url().split('/').pop();
// The job walks only that source (the count is recorded once the walk has listed it).
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.scope)
.toMatchObject({ files: 'new', roots: [1], output_folders: 1 });
await expect(page.locator('#workspace-main h1')).toHaveText('Scan for metadata');
});
test('Add source validates on the server, then marks the host draft dirty; Save persists', async ({ page, request }) => {
// A folder inside ComfyUI's output folder that is not a source yet.
await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 1 } })).toBeOK();
const layout = await openSources(page);
expect(await rowsOf(page)).toEqual([
['ComfyUI output', 'output · manual scan', ['Scan folder', 'Remove']],
['ComfyUI input', 'input · manual scan', ['Scan folder', 'Remove']],
]);
await expect(layout.locator('.panel-heading > h2')).toHaveText('Image sources');
const add = layout.locator('.panel-heading').getByRole('button', { name: '+ Add source', exact: true });
await add.click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Add image source');
await expect(dialog(page).locator('label[for="action-input"]')).toHaveText('Folder path');
await expect(dialog(page).locator('#action-input')).toHaveValue('');
await expect(dialog(page).locator('.dialog-actions > button')).toHaveText(['Cancel', 'Add']);
await dialog(page).locator('#action-input').fill('output/unthumbnailed');
const validated = page.waitForResponse(response => response.url().endsWith('/prompt_manager/settings/validate-source'));
await dialog(page).getByRole('button', { name: 'Add', exact: true }).click();
expect(await (await validated).json()).toEqual({ success: true, valid: true, label: 'unthumbnailed', path: 'output/unthumbnailed', code: null, reason: null });
await expect(dialog(page)).toHaveCount(0);
await expect(add).toBeFocused();
// Validation wrote nothing: the draft is dirty and the server still has two sources.
expect(await rowsOf(page)).toEqual([
['ComfyUI output', 'output · manual scan', ['Scan folder', 'Remove']],
['ComfyUI input', 'input · manual scan', ['Scan folder', 'Remove']],
['unthumbnailed', 'output/unthumbnailed · not saved yet', ['Remove']],
]);
await expect(statusOf(page)).toHaveText('Unsaved changes.');
expect((await serverJson(request, SETTINGS_URL)).sources.effective).toHaveLength(2);
const saved = page.waitForRequest(item => item.method() === 'POST' && item.url().endsWith(SETTINGS_URL));
await saveButton(page).click();
const body = (await saved).postDataJSON();
expect(body.gallery_root_paths).toEqual(['output', 'input', 'output/unthumbnailed']);
expect(typeof body.expected_revision).toBe('string');
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
// The list is the server's again: three effective sources, each scannable.
await expect(sourceRow(page, 'unthumbnailed').locator('p')).toHaveText('output/unthumbnailed · manual scan');
await expect(sourceRow(page, 'unthumbnailed').getByRole('button', { name: 'Scan folder', exact: true })).toBeVisible();
expect((await serverJson(request, SETTINGS_URL)).sources.effective.map(source => source.path)).toEqual(['output', 'input', 'output/unthumbnailed']);
});
test('invalid source shows the server reason', async ({ page, request }) => {
const layout = await openSources(page);
await layout.getByRole('button', { name: '+ Add source', exact: true }).click();
const field = dialog(page).locator('#action-input');
for (const [path, reason, submit] of [
['output/not-there', 'Gallery root does not exist', 'click'],
// Enter in the field adds too (M6).
['/etc', OUTSIDE_REASON, 'Enter'],
]) {
await field.fill(path);
if (submit === 'Enter') await field.press('Enter');
else await dialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(dialog(page).locator('p.small[role="alert"]')).toHaveText(reason);
await expect(field).toHaveAttribute('aria-invalid', 'true');
await expect(field).toHaveValue(path);
}
// A folder already listed (the server's answer names it) is not added twice.
await field.fill('input');
await dialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(dialog(page).locator('p.small[role="alert"]')).toHaveText('This folder is already a source.');
// A check that cannot finish says so and adds nothing.
await page.route('**/prompt_manager/settings/validate-source', route => route.fulfill({ status: 500,
json: { success: false, code: null, error: 'Failed to check the source' } }));
await field.fill('output/not-checked');
await dialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(dialog(page).locator('p.small[role="alert"]')).toHaveText('The folder could not be checked. Try again.');
await page.unroute('**/prompt_manager/settings/validate-source');
// Typing again clears the reason.
await field.fill('output/unthumbnailed');
await expect(dialog(page).locator('p.small[role="alert"]')).toHaveCount(0);
await expect(field).not.toHaveAttribute('aria-invalid', 'true');
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(statusOf(page)).toHaveText('No unsaved changes.');
expect((await serverJson(request, SETTINGS_URL)).sources.configured).toHaveLength(2);
});
test('Remove marks the draft; removing the last entry states auto-detect', async ({ page, request }) => {
await openSources(page);
await sourceRow(page, 'ComfyUI input').getByRole('button', { name: 'Remove', exact: true }).click();
expect(await rowsOf(page)).toEqual([['ComfyUI output', 'output · manual scan', ['Scan folder', 'Remove']]]);
await expect(statusOf(page)).toHaveText('Unsaved changes.');
await sourceRow(page, 'ComfyUI output').getByRole('button', { name: 'Remove', exact: true }).click();
expect(await rowsOf(page)).toEqual([['p', 'small', 'No source is configured. After saving, ComfyUI’s output folder is used.']]);
await saveButton(page).click();
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
// Saved: ComfyUI's output folder is detected and listed without Remove, its row saying it is the default.
await expect.poll(rowsOf.bind(null, page)).toEqual([
['ComfyUI output', 'ComfyUI/output · manual scan · default', ['Scan folder']],
]);
const { sources } = await serverJson(request, SETTINGS_URL);
expect([sources.configured, sources.auto_detected]).toEqual([[], true]);
});
test('a configured source that does not exist says why and can be removed', async ({ page, request }) => {
await expect(await request.post('/__test__/system/missing-source')).toBeOK();
await openSources(page);
// The fixture's unmounted drive is not inside a ComfyUI folder, so the server says that (it never probes outside).
expect((await rowsOf(page)).at(-1)).toEqual(['unmounted-drive', `unmounted-drive · ${OUTSIDE_REASON}`, ['Remove']]);
await sourceRow(page, 'unmounted-drive').getByRole('button', { name: 'Remove', exact: true }).click();
await expect(sourceList(page).locator('.operation')).toHaveCount(2);
await saveButton(page).click();
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
expect((await serverJson(request, SETTINGS_URL)).sources.configured.map(source => source.path)).toEqual(['output', 'input']);
});
test('watching only with a live observer; restart pending otherwise', async ({ page, request }) => {
// No image monitor in this process: every source is scanned by hand.
await openSources(page);
await expect(sourceList(page).locator('.operation p')).toHaveText(['output · manual scan', 'input · manual scan']);
// A live observer that scheduled output: output is watched; input was added since it started.
await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output'] } })).toBeOK();
await page.reload();
await expect(sourceList(page).locator('.operation p')).toHaveText(['output · watching', 'input · restart pending']);
await expect(sourceList(page).locator('.operation .row-actions > button:first-child')).toHaveText(['Scan changes', 'Scan folder']);
// An observer that died watches nothing.
await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output', 'input'], alive: false } })).toBeOK();
await page.reload();
await expect(sourceList(page).locator('.operation p')).toHaveText(['output · restart pending', 'input · restart pending']);
});
test('Watch for new outputs saves monitoring_enabled; a restart applies it', async ({ page, request }) => {
await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output', 'input'] } })).toBeOK();
const layout = await openSources(page);
const monitoring = layout.getByRole('switch', { name: 'Watch for new outputs', exact: true });
await expect(monitoring).toBeChecked();
await expect(layout.locator('.setting-row').filter({ has: page.locator('#monitoring') }).locator('p'))
.toHaveText('New files enter the same library. Existing scans do not count as new runs.');
await monitoring.uncheck();
await expect(statusOf(page)).toHaveText('Unsaved changes.');
const saved = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(SETTINGS_URL));
await saveButton(page).click();
const answer = await (await saved).json();
expect(answer.restart_required_fields).toEqual(['monitoring_enabled']);
// D128: saved for the next start, and the status says a restart applies it.
await expect(statusOf(page)).toHaveText('Saved to the server configuration. Restart ComfyUI to apply: Watch for new outputs.');
// The running observer keeps watching until ComfyUI restarts, and the list says so.
const { settings, monitor } = await serverJson(request, SETTINGS_URL);
expect([settings.monitoring_enabled, monitor.observer_alive]).toEqual([false, true]);
await expect(sourceList(page).locator('.operation p')).toHaveText(['output · watching until restart', 'input · watching until restart']);
await expect(sourceList(page).locator('.operation .row-actions > button:first-child')).toHaveText(['Scan changes', 'Scan changes']);
// Switched back on before the restart: the observer still runs, so nothing waits for one.
await monitoring.check();
await saveButton(page).click();
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
await expect(sourceList(page).locator('.operation p')).toHaveText(['output · watching', 'input · watching']);
});
test('worker range max is cpu_count', async ({ page, request }) => {
const { settings } = await serverJson(request, SETTINGS_URL);
const layout = await openSources(page);
const workers = layout.locator('#workers');
await expect(layout.locator('label[for="workers"]')).toHaveText('Worker threads');
await expect(workers).toHaveAttribute('type', 'range');
await expect(workers).toHaveAttribute('min', '1');
await expect(workers).toHaveAttribute('max', String(settings.cpu_count));
await expect(workers).toHaveValue(String(settings.worker_threads));
const next = settings.worker_threads === 1 ? settings.cpu_count : 1;
await workers.fill(String(next));
await expect(statusOf(page)).toHaveText(next === settings.worker_threads ? 'No unsaved changes.' : 'Unsaved changes.');
test.skip(next === settings.worker_threads, 'One core: the range has a single value');
const saved = page.waitForRequest(item => item.method() === 'POST' && item.url().endsWith(SETTINGS_URL));
await saveButton(page).click();
expect((await saved).postDataJSON().worker_threads).toBe(next);
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
expect((await serverJson(request, SETTINGS_URL)).settings.worker_threads).toBe(next);
});
test('Refresh files calls /gallery/rescan and toasts its counts', async ({ page }) => {
const layout = await openSources(page);
const refresh = layout.locator('.operation').filter({ has: page.getByRole('heading', { name: 'Refresh files', exact: true }) });
await expect(refresh.locator('p')).toHaveText('Read every source again to find files added or removed outside ComfyUI.');
const answered = page.waitForResponse(response => response.url().endsWith('/prompt_manager/gallery/rescan'));
await refresh.getByRole('button', { name: 'Refresh', exact: true }).click();
const { total, roots } = await (await answered).json();
expect(roots).toBe(2);
await expect(toast(page)).toHaveText(`Files refreshed. ${total.toLocaleString('en-US')} files in 2 output folders.`);
// A failed refresh says so.
await page.route('**/prompt_manager/gallery/rescan', route => route.fulfill({ status: 500, json: { success: false, error: 'busy disk' } }));
await refresh.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect(toast(page)).toHaveText('Files could not be refreshed. Try again.');
});
const THUMBNAIL_PLAN_URL = '/prompt_manager/images/thumbnails/plan';
const THUMBNAIL_JOB_URL = '/prompt_manager/jobs/thumbnails';
const planCount = value => value.toLocaleString('en-US');
/** Open "Thumbnail maintenance" from Sources' Thumbnail generation → Review options. */
async function openThumbnailReview(page) {
const layout = await openSources(page);
const thumbnails = layout.locator('.operation').filter({ has: page.getByRole('heading', { name: 'Thumbnail generation', exact: true }) });
await expect(thumbnails.locator('p')).toHaveText('Generate missing previews; rebuilding all is a separate explicit option.');
await thumbnails.getByRole('button', { name: 'Review options', exact: true }).click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Thumbnail maintenance');
return dialog(page);
}
/** The thumbnail requests this page sends: plan reads (their query) and job starts (their body). */
function thumbnailRequests(page) {
const seen = { plans: [], starts: [] };
page.on('request', item => {
const url = new URL(item.url());
if (url.pathname === THUMBNAIL_PLAN_URL) seen.plans.push(Object.fromEntries(url.searchParams));
if (item.method() === 'POST' && url.pathname === THUMBNAIL_JOB_URL) seen.starts.push(item.postDataJSON());
});
return seen;
}
const missingLine = plan => `${planCount(plan.targets)} ${plan.targets === 1 ? 'preview' : 'previews'} to generate · ` +
`${planCount(plan.fresh)} already up to date.`;
const SETTINGS_LINE = 'Previews are written as WebP · quality 80 · 512 px. Change this in System › Appearance.';
test('Review options shows the plan counts for the chosen scope and the server settings before Start', async ({ page, request }) => {
await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 3 } })).toBeOK();
const seen = thumbnailRequests(page);
const plan = await serverJson(request, `${THUMBNAIL_PLAN_URL}?mode=missing`);
expect(plan.targets).toBe(3);
const review = await openThumbnailReview(page);
// D131: Scope, the plan line, the server's settings line, the actions (no quality or size field of its own).
await expect(review.locator('#modal-body > *')).toHaveCount(4);
await expect(review.locator('.field > label')).toHaveText(['Scope']);
await expect(review.locator('#thumb-mode option')).toHaveText(['Missing thumbnails only', 'Rebuild all (explicit opt-in)',
'Convert outdated (removes older formats)']);
await expect(review.locator('#thumb-mode')).toHaveValue('missing');
const [line, settings] = [review.locator('#modal-body > p.small').first(), review.locator('#modal-body > p.small').last()];
await expect(line).toHaveAttribute('role', 'status');
await expect(line).toHaveText(missingLine(plan));
await expect(settings).toHaveText(SETTINGS_LINE);
expect(seen.plans).toEqual([{ mode: 'missing' }]);
await expect(review.locator('.dialog-actions > button')).toHaveText(['Cancel', 'Start']);
// Reviewing starts nothing; Cancel closes.
await review.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
expect(seen.starts).toEqual([]);
expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]);
});
test('Convert outdated replaces older-format previews from their originals and removes the old files (#317)', async ({ page, request }) => {
const { files } = await (await request.post('/__test__/system/legacy-previews', { data: { count: 3 } })).json();
const before = await serverJson(request, '/prompt_manager/images/thumbnails/status');
expect(before.outdated).toBe(3);
const seen = thumbnailRequests(page);
const review = await openThumbnailReview(page);
const line = review.locator('#modal-body > p.small').first();
await expect(line).toHaveText(/ already up to date\.$/);
await review.locator('#thumb-mode').selectOption('convert');
await expect(line).toHaveText('3 previews in an older format to convert.');
// Choosing the scope converts nothing: only Start does.
expect(seen.starts).toEqual([]);
await review.getByRole('button', { name: 'Start', exact: true }).click();
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
expect(seen.starts).toEqual([{ mode: 'convert' }]);
const id = page.url().split('/').pop();
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).toBe('finished');
const { job } = await serverJson(request, `/prompt_manager/jobs/${id}`);
expect(job).toMatchObject({ kind: 'thumbnails', title: 'Convert outdated thumbnails', scope: { mode: 'convert', format: 'webp' } });
expect(job.status_line).toMatch(/^3 converted · 0 failed · .+ → .+$/);
await expect(page.locator('#workspace-main h1')).toHaveText('Convert outdated thumbnails');
const after = await serverJson(request, '/prompt_manager/images/thumbnails/status');
expect(after.outdated).toBe(0);
// The listing now names the WebP previews; the old PNG previews are gone.
for (const file of files) {
const name = file.replace(/\.png$/, '');
expect((await request.get(`/prompt_manager/images/serve/thumbnails/${name}_thumb.webp?root=0`)).headers()['content-type']).toBe('image/webp');
expect((await request.get(`/prompt_manager/images/serve/${file}?root=0`)).ok()).toBe(true);
}
});
test('Start closes the dialog, toasts, and opens the job in Activity', async ({ page, request }) => {
await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 2 } })).toBeOK();
const seen = thumbnailRequests(page);
const review = await openThumbnailReview(page);
await review.getByRole('button', { name: 'Start', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
// The toast says it started (or, for a run this quick, that it already finished); Activity shows its progress.
await expect(toast(page)).toHaveText(/^(Thumbnail generation started\.|Thumbnails finished · 2 generated · 0 errors)$/);
expect(seen.starts).toEqual([{ mode: 'missing' }]);
const id = page.url().split('/').pop();
const { job } = await serverJson(request, `/prompt_manager/jobs/${id}`);
expect(job).toMatchObject({ kind: 'thumbnails', title: 'Thumbnail generation',
scope: { mode: 'missing', format: 'webp', size: 512, quality: 80 } });
await expect(page.locator('#workspace-main h1')).toHaveText('Thumbnail generation');
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).toBe('finished');
});
test('Rebuild all is explicit and the plan line says every preview is regenerated', async ({ page, request }) => {
const seen = thumbnailRequests(page);
const rebuild = await serverJson(request, `${THUMBNAIL_PLAN_URL}?mode=rebuild`);
expect(rebuild.targets).toBeGreaterThan(0);
const review = await openThumbnailReview(page);
// Missing thumbnails only is the default; rebuilding is the explicit second option.
await expect(review.locator('#thumb-mode')).toHaveValue('missing');
const line = review.locator('#modal-body > p.small').first();
await expect(line).toHaveText(/ already up to date\.$/);
await review.locator('#thumb-mode').selectOption('rebuild');
await expect(line).toHaveText(`Every preview is regenerated: ${planCount(rebuild.targets)} files.`);
expect(seen.plans).toEqual([{ mode: 'missing' }, { mode: 'rebuild' }]);
// Back and forth reads each scope once.
await review.locator('#thumb-mode').selectOption('missing');
await expect(line).toHaveText(/ already up to date\.$/);
await review.locator('#thumb-mode').selectOption('rebuild');
await expect(line).toHaveText(`Every preview is regenerated: ${planCount(rebuild.targets)} files.`);
expect(seen.plans).toHaveLength(2);
await review.getByRole('button', { name: 'Start', exact: true }).click();
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
expect(seen.starts).toEqual([{ mode: 'rebuild' }]);
const id = page.url().split('/').pop();
// Rebuild regenerates all 24 previews with real image work: on a busy CI
// runner that took over 5 s (12 of 24 done at the default poll budget).
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state, { timeout: 30000 }).toBe('finished');
});
test('a plan that cannot be read says so; Start still starts', async ({ page }) => {
await page.route(`**${THUMBNAIL_PLAN_URL}?*`, route => route.fulfill({ status: 500, json: { success: false, error: 'disk' } }));
const review = await openThumbnailReview(page);
await expect(review.locator('#modal-body > p.small').first()).toHaveText('The previews could not be counted.');
await expect(review.getByRole('button', { name: 'Start', exact: true })).not.toHaveAttribute('aria-disabled');
await review.getByRole('button', { name: 'Cancel', exact: true }).click();
});
test('a refused start shows the busy link toast', async ({ page, request }) => {
const running = await (await request.post('/__test__/jobs/start', { data: { title: 'Scan for metadata' } })).json();
const review = await openThumbnailReview(page);
await review.getByRole('button', { name: 'Start', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
await expect(toast(page)).toHaveText('Another job is running: Scan for metadata.');
await expect(toast(page).getByRole('link', { name: 'Scan for metadata', exact: true })).toHaveAttribute('href', `#/activity/${running.job.id}`);
await expect(page).toHaveURL(`${workspace}#/system/sources`);
expect((await serverJson(request, '/prompt_manager/jobs')).jobs.map(job => job.kind)).toEqual(['scan']);
await request.post(`/__test__/jobs/${running.job.id}/finish`, { data: { state: 'finished' } });
});
test('a save refused by a running scan shows the busy link toast', async ({ page, request }) => {
const running = await (await request.post('/__test__/jobs/start', { data: { title: 'Scan for metadata' } })).json();
await openSources(page);
await sourceRow(page, 'ComfyUI input').getByRole('button', { name: 'Remove', exact: true }).click();
await saveButton(page).click();
await expect(toast(page)).toHaveText('Another job is running: Scan for metadata.');
await expect(toast(page).getByRole('link', { name: 'Scan for metadata', exact: true })).toHaveAttribute('href', `#/activity/${running.job.id}`);
await expect(statusOf(page)).toHaveText('Some changes were not saved. Review the highlighted settings.');
// The draft is kept for the next Save, and the server still has both sources.
await expect(sourceList(page).locator('.operation')).toHaveCount(1);
expect((await serverJson(request, SETTINGS_URL)).sources.configured).toHaveLength(2);
// An answer that does not name its job (an older server): the toast names the running one from the job list.
await page.route(`**${SETTINGS_URL}`, route => (route.request().method() === 'POST'
? route.fulfill({ status: 409, json: { success: false, code: 'busy', error: 'Scan for metadata already running' } })
: route.continue()));
await page.locator('#workspace-toast').evaluate(node => { node.textContent = ''; });
await saveButton(page).click();
await expect(toast(page)).toHaveText('Another job is running: Scan for metadata.');
await expect(toast(page).getByRole('link', { name: 'Scan for metadata', exact: true })).toHaveAttribute('href', `#/activity/${running.job.id}`);
await page.unroute(`**${SETTINGS_URL}`);
await request.post(`/__test__/jobs/${running.job.id}/finish`, { data: { state: 'finished' } });
});
test('a source list that cannot be read says so and retries', async ({ page }) => {
await page.addInitScript(key => { if (location.protocol.startsWith('http')) localStorage.setItem(key, '3.2.0'); }, NOTICE_KEY);
let failing = true;
await page.route(`**${SETTINGS_URL}`, route => (failing && route.request().method() === 'GET'
? route.fulfill({ status: 500, json: { success: false, error: 'Injected failure' } }) : route.continue()));
await page.goto(`${workspace}#/system/sources`);
await expect(sourceList(page).getByRole('alert')).toHaveText('Sources could not be loaded. Your saved data has not changed.');
await expect(sourceList(page).locator('.operation')).toHaveCount(0);
// The server settings rows say so too, and stay disabled.
await expect(layoutOf(page).locator('#workers')).toBeDisabled();
failing = false;
await sourceList(page).getByRole('button', { name: 'Retry', exact: true }).click();
await expect(sourceList(page).locator('.operation')).toHaveCount(2);
});
test('a scan from a list that changed since it was read starts nothing, says why and reads the list again', async ({ page, request }) => {
await openSources(page);
// The legacy page (or another tab) removes ComfyUI output: the page's "input" is index 1, now past the end.
await expect(await request.post(SETTINGS_URL, { data: { gallery_root_paths: ['input'] } })).toBeOK();
const refused = page.waitForResponse(response => response.url().endsWith('/prompt_manager/jobs/scan'));
await sourceRow(page, 'ComfyUI input').getByRole('button', { name: 'Scan folder', exact: true }).click();
await dialog(page).getByRole('button', { name: 'Start scan', exact: true }).click();
const answer = await refused;
expect([answer.status(), (await answer.json()).code]).toEqual([409, 'sources-changed']);
await expect(toast(page)).toHaveText('The sources changed since they were loaded. Reload them.');
// No scan started (the other save ran as its own source-change hold).
expect((await serverJson(request, '/prompt_manager/jobs')).jobs.map(job => job.kind)).toEqual(['source_change']);
// Read again: the one source is index 0 now, and its scan starts.
await expect.poll(rowsOf.bind(null, page)).toEqual([['ComfyUI input', 'input · manual scan', ['Scan folder', 'Remove']]]);
await expect(statusOf(page)).toHaveText('No unsaved changes.');
const started = page.waitForRequest(item => item.method() === 'POST' && item.url().endsWith('/prompt_manager/jobs/scan'));
await sourceRow(page, 'ComfyUI input').getByRole('button', { name: 'Scan folder', exact: true }).click();
await dialog(page).getByRole('button', { name: 'Start scan', exact: true }).click();
expect((await started).postDataJSON()).toEqual({ scope: 'new', roots: [0], paths: ['input'] });
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
});
test('a save refused for one unusable source names it and marks its row', async ({ page, request }) => {
await expect(await request.post('/__test__/system/missing-source', { data: { inside: true } })).toBeOK();
await openSources(page);
await expect(sourceRow(page, 'unplugged').locator('p')).toHaveText('output/unplugged · manual scan');
// The drive is unplugged after the page read the list; removing another source resends the whole list.
await expect(await request.post('/__test__/system/unplug')).toBeOK();
await sourceRow(page, 'ComfyUI input').getByRole('button', { name: 'Remove', exact: true }).click();
const saved = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(SETTINGS_URL));
await saveButton(page).click();
const answer = await saved;
expect(answer.status()).toBe(400);
expect(await answer.json()).toMatchObject({ code: 'invalid-source', source: 'output/unplugged', index: 1 });
await expect(statusOf(page)).toHaveText("output/unplugged can't be used: Gallery root does not exist. Remove it or reconnect it, then save.");
await expect(sourceRow(page, 'unplugged')).toHaveAttribute('aria-invalid', 'true');
await expect(sourceList(page).locator('.operation[aria-invalid="true"]')).toHaveCount(1);
expect((await serverJson(request, SETTINGS_URL)).sources.configured).toHaveLength(3);
// Removed, the list saves.
await sourceRow(page, 'unplugged').getByRole('button', { name: 'Remove', exact: true }).click();
await expect(sourceList(page).locator('.operation[aria-invalid="true"]')).toHaveCount(0);
await saveButton(page).click();
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
expect((await serverJson(request, SETTINGS_URL)).sources.configured.map(source => source.path)).toEqual(['output']);
});