* feat(workspace): show the one-time older-images notice in the shell After the switch everyone lands on Library, so someone who has not dismissed the notice might never be prompted. It now shows above every workspace page until dismissed, sharing pm_update_notice_dismissed with System > Sources and the admin modal. Browser specs start with it dismissed. Closes #527 * test(workspace): turn the older-images notice on where specs expect it Two System specs read the Sources notice from empty storage, and the new spec read a job list earlier specs had filled.
608 lines
41 KiB
JavaScript
608 lines
41 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();
|
||
}
|
||
|
||
// `openSources` dismisses the notice itself unless a test asks for it.
|
||
test.use({ olderImagesNotice: true });
|
||
|
||
/** 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('D158: Save prompts from other workflows saves save_foreign_runs live; off while monitoring is off (#247)', async ({ page, request }, testInfo) => {
|
||
try {
|
||
const layout = await openSources(page);
|
||
const rows = layout.locator('section.surface > .setting-row');
|
||
await expect(rows.locator(':scope > div > label')).toHaveText(['Watch for new outputs', 'Save prompts from other workflows', 'Worker threads']);
|
||
const foreign = layout.getByRole('switch', { name: 'Save prompts from other workflows', exact: true });
|
||
const foreignRow = rows.filter({ has: page.locator('#foreign-runs') });
|
||
await expect(foreignRow.locator(':scope > div > p')).toHaveText('New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.');
|
||
await expect(foreign).toBeChecked();
|
||
await expect(foreign).toBeEnabled();
|
||
// Keyboard on desktop, a tap on the touch project.
|
||
if (testInfo.project.name === 'touch') await foreign.tap();
|
||
else {
|
||
await foreign.focus();
|
||
await page.keyboard.press('Space');
|
||
}
|
||
await expect(foreign).not.toBeChecked();
|
||
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 response = await saved;
|
||
expect(response.request().postDataJSON()).toEqual(expect.objectContaining({ save_foreign_runs: false }));
|
||
expect((await response.json()).restart_required_fields).toEqual([]);
|
||
// Applied live: the monitor reads it for every file, so no restart is named.
|
||
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
|
||
expect((await serverJson(request, SETTINGS_URL)).settings.save_foreign_runs).toBe(false);
|
||
// With Watch for new outputs drafted off nothing is watched: the row is disabled and its note says why.
|
||
const monitoring = layout.getByRole('switch', { name: 'Watch for new outputs', exact: true });
|
||
await monitoring.uncheck();
|
||
await expect(foreign).toBeDisabled();
|
||
const note = foreignRow.locator('p.small');
|
||
await expect(note).toHaveText('Applies while Watch for new outputs is on.');
|
||
await expect(foreign).toHaveAttribute('aria-describedby', await note.getAttribute('id'));
|
||
await monitoring.check();
|
||
await expect(foreign).toBeEnabled();
|
||
await expect(note).toHaveCount(0);
|
||
await expect(foreign).not.toHaveAttribute('aria-describedby', /./);
|
||
await expect(statusOf(page)).toHaveText('No unsaved changes.');
|
||
} finally {
|
||
// The server is shared by the spec's tests: put the default back whatever happened.
|
||
await request.post(SETTINGS_URL, { data: { save_foreign_runs: true } });
|
||
}
|
||
});
|
||
|
||
test('D158: saved off while the monitor still watches until a restart, the foreign row can still be set (D128)', async ({ page, request }) => {
|
||
await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output', 'input'] } })).toBeOK();
|
||
try {
|
||
const layout = await openSources(page);
|
||
await layout.getByRole('switch', { name: 'Watch for new outputs', exact: true }).uncheck();
|
||
await saveButton(page).click();
|
||
await expect(sourceList(page).locator('.operation p').first()).toHaveText('output · watching until restart');
|
||
const foreign = layout.getByRole('switch', { name: 'Save prompts from other workflows', exact: true });
|
||
await expect(foreign).toBeEnabled();
|
||
await expect(layout.locator('.setting-row').filter({ has: page.locator('#foreign-runs') }).locator('p.small'))
|
||
.toHaveText('Applies while Watch for new outputs is on.');
|
||
await foreign.uncheck();
|
||
const saved = page.waitForRequest(item => item.method() === 'POST' && item.url().endsWith(SETTINGS_URL));
|
||
await saveButton(page).click();
|
||
expect((await saved).postDataJSON()).toEqual({ save_foreign_runs: false, expected_revision: expect.any(String) });
|
||
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
|
||
} finally {
|
||
await request.post(SETTINGS_URL, { data: { save_foreign_runs: true } });
|
||
}
|
||
});
|
||
|
||
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']);
|
||
});
|