* feat(thumbnails): thumbnail format, quality and size as server settings
Thumbnail format (webp default, jxl, jpeg), quality (1-100, default 80)
and size limit (128-2048 px, default 512) are server settings in the
config.json thumbnails section, saved with the settings revision. The
never-read gallery thumbnail_size is gone: older saves wrote its unused
default 256 into config.json.
One module names previews (<stem>_thumb.webp/.jxl/.jpg) and finds the
preview a source owns, preferring the configured format; legacy names
are still found. The encoder module writes every preview atomically and
verifies it decodes. JPEG XL needs no new dependency: Pillow (natively
or with pillow_jxl), ffmpeg with libjxl, or cjxl; without one the
settings refuse jxl (400 jxl-unavailable) and GET says why. Encoder
processes are tracked, bounded and killed at shutdown.
* feat(thumbnails): every preview writer and lookup follows the thumbnail settings
Automatic previews (capture and first view), the thumbnail job, the
legacy gallery routes and the history route write <stem>_thumb.webp
(or .jxl/.jpg) with the configured quality and size limit, for images
and videos alike: video frames, ffmpeg's included (a PNG on stdout,
never upscaled), and the placeholder are encoded like images.
Listings, duplicates, enrichment, the autotag scan and the history
route show any preview the file owns, the current format first, so
older _thumb.png previews keep working until converted. A request for
an older name answers the current preview. Previews are served with
an explicit image/webp or image/jxl type and the same cache headers.
The plan and status count missing, outdated (another format) and
ready previews; the missing job never writes beside an outdated one.
Removing a file removes its previews in every format; the clear
removes .webp and .jxl too. Encoder processes are killed at shutdown.
A preview is only a placeholder: inspection refuses paths under
thumbnails/ and always reads the original, and the inspector cover
accepts the listing's preview in any format the file owns.
* feat(thumbnails): Convert outdated thumbnails as an Activity job
POST /jobs/thumbnails {mode: "convert"} starts "Convert outdated
thumbnails" under the thumbnail job's kind, guarded admission, worker
threads and cancel at a file boundary. For each file whose preview is
in another format, it writes the new preview from the source (atomic,
verified; a video with no decodable frame fails instead of getting the
placeholder), then removes that file's old-format previews in its
thumbnails folder. A failure keeps the old preview; orphan previews
and links are never touched. The final line reports converted, failed
and bytes before and after. The plan counts it with mode=convert; the
legacy gallery stream refuses to attach to a running conversion.
* feat(thumbnails): thumbnail settings in Appearance and Convert outdated in the workspace
System > Appearance gains a Thumbnails surface: format (WebP, JPEG XL,
JPEG), quality and size limit, saved to the server with the settings
revision. JPEG XL is offered only when the server has an encoder, else
the option is disabled with the server's reason (D130); a note says
when this browser cannot show JPEG XL. Saving a new format converts
nothing: the row then says previews keep their old format.
Thumbnail maintenance gains the Convert outdated scope and names the
server's settings instead of its own quality and size fields (D131).
Overview's thumbnail row reads N missing, M outdated, K failed and
offers Convert outdated while previews are outdated.
The workspace decodes a tiny JPEG XL once: where it cannot, cards, the
Library and the inspector cover load the original for a .jxl preview,
and any preview that fails to load falls back to the original. The
viewer and downloads keep using the original (regression test).
README explains enabling JPEG XL; TESTING.md records D130 and D131.
* test(thumbnails): coverage floors for the thumbnail modules and WebP in page workflows
The two new frontend modules enter the coverage baseline (98% lines);
other floors only rise. The visual fixture's preview is WebP and
Appearance has its Thumbnails surface in the System shell check.
* fix(thumbnails): say when the thumbnail settings could not be read
Like Appearance's other server rows, the Thumbnails rows show 'Server
settings could not be read.' while the read failed, instead of only
staying disabled. Firefox exposed the gap: its JPEG XL note arrived
first and changed the row notes the page workflow counted.
* test(thumbnails): accept the legacy preview's spelling a case-insensitive disk finds
On Windows and macOS the '.jpg' lookup finds an older release's
b_thumb.JPG first, so the listed URL spells '.jpg'; it names the same
file there. The test now accepts either spelling and checks the URL
serves that preview.
* fix(thumbnails): address the final review of the thumbnail format work
- ffmpeg writes JPEG XL with -update 1, so a % in a file name is never an
image-sequence pattern, and reads videos as file:<path>. The clear and a
startup sweep remove temp files that interrupted writes left behind.
- Convert outdated converts each group of same-stem files once, from the
first source in the R2 order. An old preview that is already gone counts
as removed, and each file's bytes count once. A current preview is only
kept if it decodes.
- An encoder that fails makes the write detect encoders again once. Another
encoder is used if one is found; with none, the file is written as WebP
and the availability Appearance shows is refreshed.
- Changing the thumbnail format, quality or size while Convert outdated runs
returns 409 busy. If the format still changes underneath, the job stops
without removing anything.
- Legacy preview spellings are matched case-insensitively, so X_thumb.JPG
counts as outdated.
- History's cover uses the same lookup as Images. It serves .jxl only when
Accept lists image/jxl, and otherwise the original or the placeholder.
- A video whose thumbnail the browser cannot show keeps its own element
(Images) or its label (Library).
* fix(thumbnails): keep the new preview, probe encoders only when gone, sweep reused PIDs
- Convert never removes a path that is the preview it just wrote. It
compares the device and inode, or the resolved and normcased paths when
the disk has no inode numbers. This covers x_thumb.JPG on a
case-insensitive disk.
- A JPEG XL encode failure re-runs encoder detection only when the
encoder's binary or module is gone. A "no encoder" result is checked
again on the settings read, on a jxl save, and after 60 s, so one slow
probe no longer turns JPEG XL off until a restart.
- The startup sweep now also removes temp files that carry this process's
PID but predate its start. Containers reuse PIDs.
* fix(history): let the cover ask for JPEG XL when this browser decodes it
Firefox can decode JPEG XL without listing image/jxl in Accept. When the
workspace's probe decodes JPEG XL, it adds jxl=1 to the History cover URL.
The server serves a .jxl preview when that flag is set or when Accept
lists image/jxl. In every other case it falls back as before. The flag
accepts only 0 or 1, and the response stays no-store.
* fix(thumbnails): keep going past the per-batch cap until every preview is made
The missing, rebuild and Convert outdated jobs stopped after
MAX_THUMBNAIL_FILES (10,000) files and said "N more on the next run", but
nothing ever started that next run. The cap now bounds one batch. After
each batch the job plans again, leaving out every file it already worked
on, and continues until nothing is left, the job is cancelled, or a batch
makes no progress. That last case stops on files that fail every time
instead of looping over them.
Activity progress counts against the overall total, and the final line and
the legacy stream's complete frame report every batch. Admission, worker
threads, cancellation and the #316 rules are unchanged. The Thumbnail
maintenance dialog now counts all the work instead of saying "more on the
next run".
538 lines
37 KiB
JavaScript
538 lines
37 KiB
JavaScript
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();
|
||
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).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']);
|
||
});
|