* feat(saved-views): keep a collection in a saved view Images saved views gain a collection key (#331 D140): empty or a collection id of 1 to 9 digits. Writes refuse favorites together with a collection; older rows read an empty collection, and a stored row with both opens the collection, as the listing does for a URL with both. Part of #331 * feat(collections): store collections in the library database * feat(collections): add, remove and rename collections and keep members through duplicate cleanup * feat(collections): add collection routes and the Images collection filter * feat(images): store images' own categories in the library database (#332) One new table, image_categories, and its category index, created empty by a forward-only migration that reads and writes only its own objects. Startup checks the structure; restore also checks the values. The store sets, clears and reads own categories by file key (the deepest output source holding the file and the path below it), and copies a removed duplicate's category to a kept copy that has none. * feat(images): duplicate cleanup keeps images' own categories (#332) The kept copy's own category wins; a kept copy without one receives the removed copy's, keyed by the deepest output source holding each file. The removed copy's row is never deleted, so a give-back or a failed delete loses nothing. The job log reports how many were kept. * feat(images): set, remove and list images' own categories (#332) PUT /prompt_manager/images/category sets the own category of up to 500 listed files, DELETE removes it (listed or not), and GET /prompt_manager/images/categories lists the values whose file is listed. Targets are the Images listing identity of an output source, checked by media_target (shared with Collections). The routes never touch prompts, recipes or links; POST /prompt_manager/bulk/category is unchanged. * fix(images): key symlinked output folders and keep key work off the event loop (#332) Review fixes: a source matches by its configured spelling too, so a detected output folder that is a symlink keys its files (routes and duplicate cleanup). File keys are computed in the executor. A path no source holds is a plain 403, a lone surrogate a 400. The install repairs a table that lacks its category index instead of refusing to open. * fix(collections): address review findings * feat(images): set or remove a category on all N matching images (#332) The category routes also accept the Images listing filter of "Select all N matching" (#392) instead of explicit items, resolved now and capped at 500 images like an explicit selection. * feat(collections): browse a collection from the sidebar The sidebar gains the concept's COLLECTIONS group (D136): one row per collection with its folder icon, name and listed-member count, without the PROPOSED badge; the label shows once a collection exists. A row opens #/collection/<id>, the Images page filtered to that collection (D137): the collection's name as h1, Collection as breadcrumb and title, Edit to rename or delete it through the saved views' Edit dialog with collection nouns, and a note counting members the sources do not list now. An unknown or deleted collection shows a page that says so with a link to Images. A saved view on a deleted collection lists nothing and says so (MC6, D140). The list is read again after a restore and the counts 300 ms after a change. Part of #331 * test(collections): compare the COLLECTIONS group with the concept The concept hides COLLECTIONS at 950 px and below, which would leave a phone no way to open a collection; production shows the rows there in the wrapped layout of every other sidebar nav (D136). The shell comparison now compares the group instead of filtering it out: the concept fixture seeds Landscapes (12) and Experiments (8), the rows compare by name order, and at phone width the nav compares against the concept's own sidebar-nav layout. The collection rows are checked at 1600x1100 and 390x844 in both themes. Part of #331 * test(vendor): count the #332 host case in the execution matrix image-attribution.spec.mjs gains the image category route case, so the execution config lists 92 tests (3 attribution cases per project). * test(browser): install the slideshow clock before wrapping setTimeout The slideshow fixture wrapped window.setTimeout before page.clock.install(), so the viewer's timer ran on real time and clock.runFor() did not drive it; a late WebKit timer failed 'slideshow exposes exact state labels' on CI. * fix(collections): address review findings - A list read superseded by a newer one (the 300 ms count read, a restore) no longer shows "could not be loaded": the collection and saved-view pages wait for the list to settle. - A listing that finds the open collection gone shows the deleted page once the list no longer holds it; when the list read fails or still holds it, the page keeps the listing's error and Retry instead of staying blank, and each failed read is reported again. - The missing-members note follows every list read and goes away at zero. - A rename that settles after its Edit dialog closed leaves any dialog opened since alone. - The deleted-collection, list-failed and saved-view failure pages focus their title after a route change, as every page does. Part of #331 * test(coverage): keep main's floors for files this PR does not touch coverage:update raised nine floors of untouched files from one run; metadata-autocomplete.js branches then measured 84.84 against 85. * test(legacy): record the image category routes in the legacy contract snapshot (#332) #332 adds PUT/DELETE /prompt_manager/images/category and GET /prompt_manager/images/categories; the legacy pages' routes are unchanged. * test(duplicates): a removed copy with a collection and a category moves both (#331, #332)
840 lines
54 KiB
JavaScript
840 lines
54 KiB
JavaScript
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||
import { test, expect } from './fixtures.mjs';
|
||
import { seedConcept } from './concept-inventory.mjs';
|
||
import { readCoverageTable, parseControl, CONTROL_EXEMPTIONS } from '../../tools/vendor/legacy-coverage.mjs';
|
||
|
||
// System › Overview (#249 System S2 Task 6) on the concept-shaped real fixture: the metrics, rows, Statistics dialog and
|
||
// Health report read the server, never sample values (D93, D95, D96). The hooks are workspace_system_fixture.py's.
|
||
const workspace = '/prompt_manager/workspace';
|
||
const OVERVIEW_URL = '/prompt_manager/system/overview';
|
||
const THUMBNAILS_URL = '/prompt_manager/images/thumbnails/status';
|
||
const number = value => value.toLocaleString('en-US');
|
||
|
||
test.beforeEach(async ({ page, request, browserName }) => {
|
||
await startCoverage(page, browserName);
|
||
page.manifest = 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__/system/reset');
|
||
expect(page.systemErrors).toEqual([]);
|
||
});
|
||
|
||
async function serverJson(request, url) {
|
||
const response = await request.get(url);
|
||
await expect(response).toBeOK();
|
||
return response.json();
|
||
}
|
||
|
||
const layoutOf = page => page.locator('#workspace-main .settings-layout');
|
||
|
||
async function openOverview(page) {
|
||
await page.goto(`${workspace}#/system/overview`);
|
||
await expect(layoutOf(page).locator(':scope > .summary-metrics')).toBeVisible();
|
||
// The Backup status row is its own read of /backup/status (S9): compare once it answered.
|
||
await expect(layoutOf(page).locator('.operation').filter({ has: page.locator('h3', { hasText: 'Backup status' }) })
|
||
.locator('p')).not.toHaveText('Checking backups…');
|
||
return layoutOf(page);
|
||
}
|
||
|
||
/** The rows of one Overview surface: `[h3, p, button]`. */
|
||
function rowsOf(layout, heading) {
|
||
return layout.locator('.two-col > section.surface').filter({ has: layout.page().getByRole('heading', { name: heading, exact: true }) })
|
||
.locator('.operation').evaluateAll(rows => rows.map(row => [row.querySelector('h3').textContent,
|
||
row.querySelector('p').textContent, row.querySelector('.row-actions button').textContent]));
|
||
}
|
||
|
||
test('overview shows real counts and Not checked', async ({ page, request }) => {
|
||
const writes = [];
|
||
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) writes.push(item.url()); });
|
||
const overview = await serverJson(request, OVERVIEW_URL);
|
||
const thumbnails = await serverJson(request, THUMBNAILS_URL);
|
||
// The concept fixture: six positives, their saved negatives and 24 linked images, all with thumbnails.
|
||
expect([overview.library.positive_prompts, overview.images.indexed, thumbnails.pending]).toEqual([6, 24, 0]);
|
||
// I2: the Overview never walks the sources for thumbnails; the row reads the status route itself.
|
||
expect(overview).not.toHaveProperty('thumbnails');
|
||
// The fixture records no ambiguous file: the Needs review queue is empty.
|
||
expect(overview.needs_review).toEqual({ pending: 0 });
|
||
const layout = await openOverview(page);
|
||
expect(await layout.locator('.summary-metrics > .metric').evaluateAll(items => items.map(item =>
|
||
[item.querySelector('strong').textContent, item.querySelector('span').textContent]))).toEqual([
|
||
[number(overview.library.positive_prompts), 'Positive prompts'],
|
||
[number(overview.library.negative_variants), 'Negative variants'],
|
||
[number(overview.images.indexed), 'Images'],
|
||
['Not checked', 'Database integrity'],
|
||
]);
|
||
expect(await rowsOf(layout, 'Library health')).toEqual([
|
||
['0 ambiguous image links', 'Review metadata before assigning a positive / negative pair.', 'Review metadata'],
|
||
['Backup status', 'No backup recorded.', 'View backups'],
|
||
// D129: the fixture's database is configured explicitly, so it reads as config.json's choice and does not move.
|
||
['Library location', 'browser.db · The database path set in config.json.', 'Details'],
|
||
['No missing thumbnails', 'Generate only missing previews when necessary.', 'Review thumbnails'],
|
||
]);
|
||
expect(await rowsOf(layout, 'Workspace preferences')).toEqual([
|
||
['One display configuration', 'Grid and density settings are shared across views.', 'Appearance'],
|
||
['2 output sources', 'Monitoring and enrichment share one source list.', 'Manage sources'],
|
||
['Statistics', 'Runs and ratings by category, with library totals.', 'Explore'],
|
||
]);
|
||
expect(writes).toEqual([]);
|
||
});
|
||
|
||
// D129 (#249 S6b): where the library lives. The hook makes the fixture's database read as a node-folder library; its
|
||
// Move library is a stand-in that touches no file (workspace_system_fixture.py).
|
||
const banner = page => page.locator('#library-banner');
|
||
const LIBRARY_URL = '/prompt_manager/system/library';
|
||
|
||
test('a node-folder library is offered a move above every page; Keep it here stops the offer', async ({ page, request }) => {
|
||
await expect(await request.post('/__test__/system/library', { data: { state: 'node_folder' } })).toBeOK();
|
||
await page.goto(`${workspace}#/images`);
|
||
await expect(banner(page).locator('.notice h2')).toHaveText('Your library is inside the node folder');
|
||
await expect(banner(page).locator('.notice p')).toHaveText(/^Your library is stored inside the node folder, where an update or reinstall can delete it\./);
|
||
await expect(banner(page).locator('.notice .row-actions button')).toHaveText(['Keep it here', 'Move library']);
|
||
await expect(banner(page).getByRole('button', { name: 'Move library', exact: true })).toHaveClass(/ca-primary/);
|
||
await page.goto(`${workspace}#/library`);
|
||
await expect(banner(page).locator('.notice')).toBeVisible();
|
||
await banner(page).getByRole('button', { name: 'Keep it here', exact: true }).click();
|
||
await expect(banner(page)).toBeHidden();
|
||
const { location } = await serverJson(request, LIBRARY_URL);
|
||
expect([location.keep, location.offer, location.movable]).toEqual([true, false, true]);
|
||
await page.reload();
|
||
const layout = await openOverview(page);
|
||
await expect(banner(page)).toBeHidden();
|
||
const row = layout.locator('.operation').filter({ hasText: 'Library location' });
|
||
await expect(row.locator('p')).toHaveText('browser.db · The node folder, where an update or reinstall can delete it. '
|
||
+ 'You chose to keep it here.');
|
||
// The row stays: the move is still one press away.
|
||
await expect(row.locator('.row-actions button')).toHaveText('Move library');
|
||
});
|
||
|
||
test('Move library is reviewed, runs as an Activity job and ends the offer', async ({ page, request }) => {
|
||
await expect(await request.post('/__test__/system/library', { data: { state: 'node_folder' } })).toBeOK();
|
||
const layout = await openOverview(page);
|
||
const row = layout.locator('.operation').filter({ hasText: 'Library location' });
|
||
await expect(row.locator('p')).toHaveText('browser.db · The node folder, where an update or reinstall can delete it.');
|
||
const move = banner(page).getByRole('button', { name: 'Move library', exact: true });
|
||
await move.click();
|
||
const dialog = page.locator('dialog#modal[open]');
|
||
await expect(dialog.locator('#modal-title')).toHaveText('Move library');
|
||
expect(await dialog.locator('dl.meta-grid > div').evaluateAll(items => items.map(item =>
|
||
[item.querySelector('dt').textContent, item.querySelector('dd').textContent]))).toEqual([
|
||
['From', 'browser.db'], ['To', 'user/__promptmanager/prompts.db'], ['Size', expect.stringMatching(/ (KB|MB)$/)],
|
||
['Moves with it', 'config.json and the database backups'], ['Old files', 'Renamed *.moved-to-…, never deleted'],
|
||
]);
|
||
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Cancel', 'Move library']);
|
||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
|
||
await expect(move).toBeFocused();
|
||
await row.getByRole('button', { name: 'Move library', exact: true }).click();
|
||
await dialog.getByRole('button', { name: 'Move library', exact: true }).click();
|
||
await expect(page).toHaveURL(/#\/activity\/[^/]+$/);
|
||
await expect(page.locator('#workspace-main')).toContainText(
|
||
/Moved the library to user\/__promptmanager: \d+ prompts? and 0 images verified\. The old files were renamed browser\.db\.moved-to-__promptmanager; nothing was deleted\./);
|
||
// The offer ends with the job; the Overview says where the library lives now.
|
||
await expect(banner(page)).toBeHidden();
|
||
const after = await openOverview(page);
|
||
await expect(after.locator('.operation').filter({ hasText: 'Library location' }).locator('p'))
|
||
.toHaveText('browser.db · ComfyUI\'s user folder, which updates and reinstalls of the node leave alone.');
|
||
});
|
||
|
||
test('a move that fails says so and keeps the offer; a refused start says why', async ({ page, request }) => {
|
||
await expect(await request.post('/__test__/system/library', { data: { state: 'failing' } })).toBeOK();
|
||
await page.goto(`${workspace}#/images`);
|
||
await banner(page).getByRole('button', { name: 'Move library', exact: true }).click();
|
||
await page.locator('dialog#modal[open]').getByRole('button', { name: 'Move library', exact: true }).click();
|
||
await expect(page).toHaveURL(/#\/activity\/[^/]+$/);
|
||
await expect(page.locator('#workspace-main')).toContainText(
|
||
'The copy did not pass verification. Nothing was moved. The library is still in the node folder, unchanged.');
|
||
await expect(banner(page).locator('.notice')).toBeVisible();
|
||
await expect(await request.post('/__test__/system/fail', { data: { path: `${LIBRARY_URL}/move` } })).toBeOK();
|
||
await banner(page).getByRole('button', { name: 'Move library', exact: true }).click();
|
||
await page.locator('dialog#modal[open]').getByRole('button', { name: 'Move library', exact: true }).click();
|
||
await expect(page.locator('#workspace-toast')).toHaveText('Move library could not start. Try again.');
|
||
});
|
||
|
||
test('a library that does not move shows its details', async ({ page }) => {
|
||
const layout = await openOverview(page);
|
||
await expect(banner(page)).toBeHidden();
|
||
const details = layout.locator('.operation').filter({ hasText: 'Library location' }).getByRole('button', { name: 'Details', exact: true });
|
||
await details.click();
|
||
const dialog = page.locator('dialog#modal[open]');
|
||
await expect(dialog.locator('#modal-title')).toHaveText('Library location');
|
||
expect(await dialog.locator('dl.meta-grid > div').evaluateAll(items => items.map(item =>
|
||
[item.querySelector('dt').textContent, item.querySelector('dd').textContent.replace(/[\d.,]+ (KB|MB)$/, 'N')]))).toEqual([
|
||
['Database', 'browser.db'], ['Why here', 'The database path set in config.json'],
|
||
['Beside it', 'config.json, the database backups and the logs'], ['Size', 'N'],
|
||
]);
|
||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||
await expect(details).toBeFocused();
|
||
});
|
||
|
||
test('Run health checks opens Health report and updates metric 4', async ({ page, request }) => {
|
||
await page.goto(`${workspace}#/system/maintenance`);
|
||
const layout = layoutOf(page);
|
||
await expect(layout.locator(':scope > .notice h2')).toHaveText('Check first. Review changes. Then apply.');
|
||
// Maintenance is native (S7): its two surfaces follow the notice.
|
||
await expect(layout.locator(':scope > section.surface > h2')).toHaveText(['Database', 'Images & cache']);
|
||
const run = layout.getByRole('button', { name: 'Run health checks', exact: true });
|
||
await run.click();
|
||
const dialog = page.locator('dialog#modal[open]');
|
||
await expect(dialog.locator('#modal-title')).toHaveText('Health report');
|
||
const { report } = await serverJson(request, '/prompt_manager/system/health');
|
||
expect(report.status).toBe('incomplete');
|
||
expect(await dialog.locator('.operation-list > .operation').evaluateAll(rows => rows.map(row =>
|
||
[row.querySelector('h3').textContent, row.querySelector('span.chip').textContent]))).toEqual([
|
||
['Integrity check', 'Passed'], ['Foreign keys & pair references', 'Passed'], ['Canonical hashes', 'Passed'],
|
||
['Pair uniqueness', 'Passed'], ['Missing files · output', 'Passed'], ['Missing files · input', 'Passed'],
|
||
['Ambiguous metadata', 'Not available yet'], ['Consistency', 'Passed'],
|
||
]);
|
||
await expect(dialog.locator('#modal-body > p.small')).toHaveText(/^Checked .+\. Some checks could not run, so this report is incomplete\.$/);
|
||
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Close', 'Review missing links']);
|
||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
|
||
await expect(run).toBeFocused();
|
||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click();
|
||
await expect(layout.locator('.metric').nth(3).locator('strong')).toHaveText('Incomplete');
|
||
|
||
// A missing file and an unmounted source: the report counts the readable sources and names the other one.
|
||
await expect(await request.post('/__test__/system/missing-file')).toBeOK();
|
||
await expect(await request.post('/__test__/system/missing-source')).toBeOK();
|
||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Maintenance', exact: true }).click();
|
||
await layout.getByRole('button', { name: 'Run health checks', exact: true }).click();
|
||
await expect(dialog.locator('.operation').filter({ hasText: 'Missing files' }).locator('.chip'))
|
||
.toHaveText(['1 link needs review', 'Passed', 'Not available']);
|
||
await expect(dialog.locator('.operation h3').filter({ hasText: 'Missing files' }))
|
||
.toHaveText(['Missing files · output', 'Missing files · input', 'Missing files · unmounted-drive']);
|
||
// Review missing links reviews a fresh Missing-file links plan (S7); nothing is cleaned up.
|
||
await dialog.getByRole('button', { name: 'Review missing links', exact: true }).click();
|
||
await expect(dialog.locator('#modal-title')).toHaveText('Review maintenance plan');
|
||
await expect(dialog.locator('#modal-body > h3')).toHaveText('Missing-file links');
|
||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
|
||
await expect(page).toHaveURL(`${workspace}#/system/maintenance`);
|
||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click();
|
||
await expect(layout.locator('.metric').nth(3).locator('strong')).toHaveText('1 issue');
|
||
});
|
||
|
||
test('a health check that fails says so and keeps the last result', async ({ page, request }) => {
|
||
await expect(await request.post('/__test__/system/fail', { data: { path: '/prompt_manager/system/health' } })).toBeOK();
|
||
await page.goto(`${workspace}#/system/maintenance`);
|
||
const run = layoutOf(page).getByRole('button', { name: 'Run health checks', exact: true });
|
||
await run.click();
|
||
await expect(page.locator('#workspace-toast')).toHaveText('The health check could not finish. Nothing was changed.');
|
||
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
|
||
await expect(run).not.toHaveAttribute('aria-disabled', 'true');
|
||
await openOverview(page);
|
||
await expect(layoutOf(page).locator('.metric').nth(3).locator('strong')).toHaveText('Not checked');
|
||
});
|
||
|
||
test('thumbnail row reflects the status endpoint', async ({ page, request }) => {
|
||
await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 3 } })).toBeOK();
|
||
const status = await serverJson(request, '/prompt_manager/images/thumbnails/status');
|
||
expect(status.pending).toBe(3);
|
||
const layout = await openOverview(page);
|
||
const row = layout.locator('.operation').filter({ has: page.getByRole('button', { name: 'Review thumbnails', exact: true }) });
|
||
await expect(row.locator('h3')).toHaveText('3 missing thumbnails');
|
||
});
|
||
|
||
test('Overview Review thumbnails opens the same dialog', async ({ page, request }) => {
|
||
await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 2 } })).toBeOK();
|
||
const plan = await serverJson(request, '/prompt_manager/images/thumbnails/plan?mode=missing');
|
||
const starts = [];
|
||
page.on('request', item => { if (item.method() === 'POST' && item.url().endsWith('/prompt_manager/jobs/thumbnails')) starts.push(item.postDataJSON()); });
|
||
const layout = await openOverview(page);
|
||
const row = layout.locator('.operation').filter({ has: page.getByRole('button', { name: 'Review thumbnails', exact: true }) });
|
||
await expect(row.locator('h3')).toHaveText('2 missing thumbnails');
|
||
await row.getByRole('button', { name: 'Review thumbnails', exact: true }).click();
|
||
const review = page.locator('dialog#modal[open]');
|
||
await expect(review.locator('#modal-title')).toHaveText('Thumbnail maintenance');
|
||
await expect(page).toHaveURL(`${workspace}#/system/overview`);
|
||
await expect(review.locator('.field > label')).toHaveText(['Scope']);
|
||
await expect(review.locator('#modal-body > p.small').first()).toHaveText(`2 previews to generate · ${number(plan.fresh)} already up to date.`);
|
||
// Escape closes it and focus returns to the row's button.
|
||
await page.keyboard.press('Escape');
|
||
await expect(review).toHaveCount(0);
|
||
await expect(row.getByRole('button', { name: 'Review thumbnails', exact: true })).toBeFocused();
|
||
await row.getByRole('button', { name: 'Review thumbnails', exact: true }).click();
|
||
await review.getByRole('button', { name: 'Start', exact: true }).click();
|
||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||
expect(starts).toEqual([{ mode: 'missing' }]);
|
||
const id = page.url().split('/').pop();
|
||
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).toBe('finished');
|
||
});
|
||
|
||
test('the thumbnail row counts outdated previews and offers Convert outdated (#317)', async ({ page, request }) => {
|
||
await expect(await request.post('/__test__/system/legacy-previews', { data: { count: 2 } })).toBeOK();
|
||
const layout = await openOverview(page);
|
||
const row = layout.locator('.operation').filter({ has: page.getByRole('button', { name: 'Review thumbnails', exact: true }) });
|
||
await expect(row.locator('h3')).toHaveText('No missing thumbnails · 2 outdated');
|
||
await expect(row.locator('.row-actions > button')).toHaveText(['Review thumbnails', 'Convert outdated']);
|
||
await row.getByRole('button', { name: 'Convert outdated', exact: true }).click();
|
||
const review = page.locator('dialog#modal[open]');
|
||
await expect(review.locator('#modal-title')).toHaveText('Thumbnail maintenance');
|
||
// It opens on the Convert outdated scope; nothing starts until Start.
|
||
await expect(review.locator('#thumb-mode')).toHaveValue('convert');
|
||
await expect(review.locator('#modal-body > p.small').first()).toHaveText('2 previews in an older format to convert.');
|
||
expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]);
|
||
await page.keyboard.press('Escape');
|
||
await expect(row.getByRole('button', { name: 'Convert outdated', exact: true })).toBeFocused();
|
||
});
|
||
|
||
test('Appearance › Thumbnails saves the server thumbnail settings and never converts by itself (#317)', async ({ page, request }) => {
|
||
await expect(await request.post('/__test__/system/jxl-encoder', { data: { available: false } })).toBeOK();
|
||
await expect(await request.post('/__test__/system/legacy-previews', { data: { count: 1 } })).toBeOK();
|
||
await page.goto(`${workspace}#/system/appearance`);
|
||
const surface = page.locator('#workspace-main .settings-layout > section.surface').nth(1);
|
||
await expect(surface.locator(':scope > h2')).toHaveText('Thumbnails');
|
||
await expect(surface.locator('.setting-row label')).toHaveText(['Thumbnail format', 'Thumbnail quality', 'Thumbnail size limit']);
|
||
const format = surface.locator('#thumbnail-format');
|
||
await expect(format).toBeEnabled();
|
||
await expect(format).toHaveValue('webp');
|
||
await expect(surface.locator('#thumbnail-quality')).toHaveValue('80');
|
||
await expect(surface.locator('#thumbnail-edge')).toHaveValue('512');
|
||
// D130: JPEG XL is offered only with an encoder; without one the row says why, in the server's words.
|
||
await expect(format.locator('option[value="jxl"]')).toBeDisabled();
|
||
await expect(surface.locator('.setting-row').first().locator('p.small').first()).toContainText('No JPEG XL encoder found.');
|
||
await format.selectOption('jpeg');
|
||
await surface.locator('#thumbnail-quality').fill('65');
|
||
await surface.locator('#thumbnail-quality').dispatchEvent('change');
|
||
await page.getByRole('button', { name: 'Save preferences', exact: true }).click();
|
||
await expect(page.locator('#settings-status')).toHaveText('Saved to the server configuration.');
|
||
const { settings } = await serverJson(request, '/prompt_manager/settings');
|
||
expect([settings.thumbnail_format, settings.thumbnail_quality, settings.thumbnail_edge]).toEqual(['jpeg', 65, 512]);
|
||
// Changing the format converts nothing: the row says so and the old preview is still listed.
|
||
await expect(surface.locator('.operation p')).toHaveText('Format saved. Existing previews keep their old format until you convert them.');
|
||
expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]);
|
||
expect((await serverJson(request, '/prompt_manager/images/thumbnails/status')).outdated).toBeGreaterThan(0);
|
||
// With an encoder JPEG XL can be chosen.
|
||
await expect(await request.post('/__test__/system/jxl-encoder', { data: { available: true } })).toBeOK();
|
||
await page.reload();
|
||
await expect(page.locator('#workspace-main #thumbnail-format option[value="jxl"]')).toBeEnabled();
|
||
await expect(page.locator('#workspace-main #thumbnail-format')).toHaveValue('jpeg');
|
||
});
|
||
|
||
test('the thumbnail row paints when its own status read answers', async ({ page, request }) => {
|
||
const held = async () => (await serverJson(request, '/__test__/hold')).held[THUMBNAILS_URL] ?? 0;
|
||
await expect(await request.post('/__test__/hold', { data: { path: THUMBNAILS_URL } })).toBeOK();
|
||
const layout = await openOverview(page);
|
||
const row = layout.locator('.operation').filter({ has: page.getByRole('button', { name: 'Review thumbnails', exact: true }) });
|
||
// The other values do not wait for the walk of every source.
|
||
await expect(row.locator('h3')).toHaveText('Checking thumbnails…');
|
||
await expect(layout.locator('.summary-metrics > .metric')).toHaveCount(4);
|
||
await expect.poll(held).toBe(1);
|
||
await expect(await request.post('/__test__/release')).toBeOK();
|
||
await expect(row.locator('h3')).toHaveText('No missing thumbnails');
|
||
});
|
||
|
||
test('a failed thumbnail read is unavailable, never zero', async ({ page }) => {
|
||
await page.route(`**${THUMBNAILS_URL}`, route => route.fulfill({ status: 500, contentType: 'application/json',
|
||
body: JSON.stringify({ success: false, error: 'Injected failure' }) }));
|
||
const layout = await openOverview(page);
|
||
const row = layout.locator('.operation').filter({ has: page.getByRole('button', { name: 'Review thumbnails', exact: true }) });
|
||
await expect(row.locator('h3')).toHaveText('Thumbnail status unavailable');
|
||
await expect(layout.getByRole('alert')).toHaveCount(0);
|
||
await expect(layout.locator('.metric').nth(2).locator('strong')).toHaveText('24');
|
||
});
|
||
|
||
test('ambiguous row reads the pending count and Review metadata opens Needs review', async ({ page }) => {
|
||
const layout = await openOverview(page);
|
||
const row = layout.locator('.operation').filter({ hasText: 'ambiguous image links' });
|
||
await expect(row.locator('h3')).toHaveText('0 ambiguous image links');
|
||
await expect(row.locator('p')).toHaveText('Review metadata before assigning a positive / negative pair.');
|
||
const review = row.getByRole('button', { name: 'Review metadata', exact: true });
|
||
await expect(review).not.toHaveAttribute('aria-disabled');
|
||
await review.click();
|
||
// The concept's go-review: the Needs review page.
|
||
await expect(page).toHaveURL(`${workspace}#/review`);
|
||
await expect(page.getByRole('heading', { name: 'Metadata review', exact: true })).toBeVisible();
|
||
});
|
||
|
||
test('View backups / Appearance / Manage sources switch sections', async ({ page }) => {
|
||
for (const [label, section, pressed] of [['View backups', 'backups', 'Backup & export'], ['Appearance', 'appearance', 'Appearance'],
|
||
['Manage sources', 'sources', 'Sources & scans']]) {
|
||
const layout = await openOverview(page);
|
||
await layout.getByRole('button', { name: label, exact: true }).click();
|
||
await expect(page).toHaveURL(`${workspace}#/system/${section}`);
|
||
await expect(page.locator('#workspace-main .section-nav [aria-pressed="true"]')).toHaveText(pressed);
|
||
await expect(layout.locator(':scope > .summary-metrics')).toHaveCount(0);
|
||
}
|
||
});
|
||
|
||
test('statistics dialog lists runs by category and library totals', async ({ page, request }) => {
|
||
const statistics = await serverJson(request, '/prompt_manager/system/statistics');
|
||
expect(statistics.by_category.length).toBeGreaterThan(1);
|
||
const layout = await openOverview(page);
|
||
const explore = layout.getByRole('button', { name: 'Explore', exact: true });
|
||
await explore.click();
|
||
const dialog = page.locator('dialog#modal[open]');
|
||
await expect(dialog.locator('#modal-title')).toHaveText('Usage statistics');
|
||
const tables = dialog.locator('#modal-body > table');
|
||
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Library totals']);
|
||
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs', 'Avg. rating']);
|
||
const cells = table => table.locator('tbody tr').evaluateAll(rows => rows.map(row => [...row.cells].map(cell => cell.textContent)));
|
||
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized',
|
||
number(row.runs), row.average_rating === null ? 'Not rated' : `${number(row.average_rating)} / 5`]));
|
||
expect(await cells(tables.nth(1))).toEqual([['Prompts', number(statistics.totals.total_prompts)],
|
||
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)],
|
||
['Rated images', number(statistics.rated_images)]]);
|
||
await page.keyboard.press('Escape');
|
||
await expect(explore).toBeFocused();
|
||
// A statistics read that fails says so; the Overview stays.
|
||
await expect(await request.post('/__test__/system/fail', { data: { path: '/prompt_manager/system/statistics' } })).toBeOK();
|
||
await explore.click();
|
||
await expect(page.locator('#workspace-toast')).toHaveText('Statistics could not be loaded. Your saved data has not changed.');
|
||
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
|
||
});
|
||
|
||
test('load failure shows Retry and no zeros', async ({ page, request }) => {
|
||
await expect(await request.post('/__test__/system/fail', { data: { path: OVERVIEW_URL } })).toBeOK();
|
||
await page.goto(`${workspace}#/system/overview`);
|
||
const layout = layoutOf(page);
|
||
await expect(layout.getByRole('alert')).toHaveText('The overview could not be loaded. Your saved data has not changed.');
|
||
await expect(layout.locator('.metric')).toHaveCount(0);
|
||
await expect(layout).not.toContainText('0');
|
||
await layout.getByRole('button', { name: 'Retry', exact: true }).click();
|
||
await expect(layout.locator('.summary-metrics > .metric')).toHaveCount(4);
|
||
await expect(layout.getByRole('alert')).toHaveCount(0);
|
||
});
|
||
|
||
test('out-of-order overview responses never repaint a newer section', async ({ page, request }) => {
|
||
const held = async () => (await serverJson(request, '/__test__/hold')).held[OVERVIEW_URL] ?? 0;
|
||
await expect(await request.post('/__test__/hold', { data: { path: OVERVIEW_URL } })).toBeOK();
|
||
await page.goto(`${workspace}#/system/overview`);
|
||
const layout = layoutOf(page);
|
||
await expect(layout.getByRole('status')).toHaveText('Loading the overview…');
|
||
await expect.poll(held).toBe(1);
|
||
// Leave for Appearance, come back (a second read), then leave again before either answers.
|
||
const nav = page.locator('#workspace-main .section-nav');
|
||
await nav.getByRole('button', { name: 'Appearance', exact: true }).click();
|
||
await nav.getByRole('button', { name: 'Overview', exact: true }).click();
|
||
await expect.poll(held).toBe(2);
|
||
// Diagnostics & logs is native (S12): its body is its two surfaces.
|
||
await nav.getByRole('button', { name: 'Diagnostics & logs', exact: true }).click();
|
||
await expect(layout.locator(':scope > section.surface')).toHaveCount(2);
|
||
await expect(await request.post('/__test__/release')).toBeOK();
|
||
await expect.poll(held).toBe(0);
|
||
// Both late answers reached a section that had left: Diagnostics keeps its own body.
|
||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||
await expect(layout.locator('.summary-metrics')).toHaveCount(0);
|
||
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
|
||
.toEqual(['section.surface', 'section.surface', 'div.save-row']);
|
||
// Back on Overview, one fresh read paints one set of metrics.
|
||
await nav.getByRole('button', { name: 'Overview', exact: true }).click();
|
||
await expect(layout.locator('.summary-metrics')).toHaveCount(1);
|
||
await expect(layout.locator('.two-col')).toHaveCount(1);
|
||
});
|
||
|
||
// System › Appearance (#249 System S3 Task 8): the browser display defaults (`gallerySettings`, preferences.mjs) and the
|
||
// two host fields behind the one Save row, and the consumers that read them on their next visit.
|
||
const SETTINGS_URL = '/prompt_manager/settings';
|
||
const INSPECT_PATH = '/prompt_manager/images/inspect/';
|
||
const statusOf = page => page.locator('#workspace-main #settings-status');
|
||
const saveOf = page => page.locator('#workspace-main .save-row > button');
|
||
const storedSettings = page => page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings') || 'null'));
|
||
const imageCards = page => page.locator('#workspace-main article.media-card');
|
||
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||
|
||
async function openAppearance(page) {
|
||
await page.goto(`${workspace}#/system/appearance`);
|
||
await expect(page.locator('#workspace-main .settings-layout > section.surface > h2').first()).toHaveText('Browsing & display');
|
||
// The host rows are enabled once the server settings answered.
|
||
await expect(page.locator('#timeout')).toBeEnabled();
|
||
}
|
||
|
||
async function saveAppearance(page, text) {
|
||
await expect(saveOf(page)).not.toHaveAttribute('aria-disabled', 'true');
|
||
await saveOf(page).click();
|
||
await expect(statusOf(page)).toHaveText(text);
|
||
}
|
||
|
||
/** Set toggles (by row id) through their Toolkit switches. */
|
||
async function toggle(page, values) {
|
||
for (const [id, checked] of Object.entries(values)) await page.locator(`#${id}`).setChecked(checked, { force: true });
|
||
}
|
||
|
||
/**
|
||
* A request barrier: one request issued and answered after everything the page has asked for so far, so a read the
|
||
* last action started has been seen (Playwright reports a page's requests in order).
|
||
*/
|
||
async function requestBarrier(page) {
|
||
expect(await page.evaluate(url => fetch(url, { cache: 'no-store' }).then(response => response.ok), SETTINGS_URL)).toBe(true);
|
||
}
|
||
|
||
async function openImages(page) {
|
||
await page.goto(`${workspace}#/images`);
|
||
await expect(imageCards(page).first()).toBeVisible();
|
||
}
|
||
|
||
test('defaults apply to a new visit but not to the current view or a saved view', async ({ page, request }) => {
|
||
const created = await request.post('/prompt_manager/saved-views', { data: { page: 'images', name: 'Everything',
|
||
state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' } } });
|
||
expect(created.status()).toBe(201);
|
||
const view = (await created.json()).view;
|
||
try {
|
||
await openImages(page);
|
||
// The current view: the toolbar sets List.
|
||
await page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'List', exact: true }).click();
|
||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
|
||
await openAppearance(page);
|
||
await expect(page.locator('#default-view')).toHaveValue('grid');
|
||
await page.locator('#default-view').selectOption('masonry');
|
||
await page.locator('#density').selectOption('compact');
|
||
await expect(statusOf(page)).toHaveText('Unsaved changes.');
|
||
await saveAppearance(page, 'Saved in this browser.');
|
||
const stored = await storedSettings(page);
|
||
expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['masonry', 'compact']);
|
||
// Back in Images (the page session's last visit): the current view keeps its own layout and size.
|
||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
|
||
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'comfortable');
|
||
// The saved view opens as before.
|
||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||
await expect(imageCards(page).first()).toBeVisible();
|
||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'grid');
|
||
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'comfortable');
|
||
// A new visit starts from the defaults.
|
||
await openImages(page);
|
||
await page.reload();
|
||
await expect(imageCards(page).first()).toBeVisible();
|
||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'masonry');
|
||
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
|
||
await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true }))
|
||
.toHaveAttribute('aria-pressed', 'true');
|
||
} finally {
|
||
await request.delete(`/prompt_manager/saved-views/${view.id}`);
|
||
}
|
||
});
|
||
|
||
test('the defaults reach the Images page only; embedded galleries keep their own presentation', async ({ page }) => {
|
||
await openAppearance(page);
|
||
await page.locator('#default-view').selectOption('list');
|
||
await page.locator('#density').selectOption('compact');
|
||
await saveAppearance(page, 'Saved in this browser.');
|
||
// The prompt history's shared gallery has no toolbar to change them, so it opens as it always did.
|
||
await page.goto(`${workspace}#/library/${page.manifest.prompts[0].positive_id}`);
|
||
await expect(imageCards(page).first()).toBeVisible();
|
||
await expect(page.locator('#workspace-main .image-grid')).toHaveAttribute('data-layout', 'grid');
|
||
await expect(page.locator('#workspace-main .image-grid')).toHaveAttribute('data-density', 'comfortable');
|
||
// The Images page starts from them.
|
||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||
await expect(imageCards(page).first()).toBeVisible();
|
||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
|
||
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
|
||
});
|
||
|
||
test('metadata off shows an explicit load and loads once on request', async ({ page }) => {
|
||
await openAppearance(page);
|
||
await toggle(page, { 'metadata-auto': false });
|
||
await saveAppearance(page, 'Saved in this browser.');
|
||
const reads = [];
|
||
page.on('request', item => { if (new URL(item.url()).pathname.startsWith(INSPECT_PATH)) reads.push(item.url()); });
|
||
await openImages(page);
|
||
const first = imageCards(page).first();
|
||
await first.locator('.image-card-actions button').first().click();
|
||
await expect(panel(page).getByRole('status')).toHaveText('Metadata loads when you ask for it.');
|
||
const load = panel(page).getByRole('button', { name: 'Load metadata', exact: true });
|
||
await expect(load).toBeVisible();
|
||
expect(reads).toEqual([]);
|
||
await load.click();
|
||
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
|
||
expect(reads).toHaveLength(1);
|
||
// The same file again reads nothing more (the cache is on).
|
||
await first.locator('.image-card-actions button').first().click();
|
||
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
|
||
await requestBarrier(page);
|
||
expect(reads).toHaveLength(1);
|
||
// The viewer's details use the same inspector: another file waits there too, and loads on request.
|
||
await imageCards(page).nth(1).locator('.image-open').click();
|
||
const viewerDetails = page.locator('#viewer-details');
|
||
await expect(viewerDetails.getByRole('status')).toHaveText('Metadata loads when you ask for it.');
|
||
expect(reads).toHaveLength(1);
|
||
await viewerDetails.getByRole('button', { name: 'Load metadata', exact: true }).click();
|
||
await expect(viewerDetails.locator('.panel-heading h2')).toBeVisible();
|
||
expect(reads).toHaveLength(2);
|
||
});
|
||
|
||
test('cache off rereads the current selection; on reuses the one current record', async ({ page }) => {
|
||
const reads = [];
|
||
page.on('request', item => { if (new URL(item.url()).pathname.startsWith(INSPECT_PATH)) reads.push(item.url()); });
|
||
for (const cache of [true, false]) {
|
||
if (!cache) {
|
||
await openAppearance(page);
|
||
await toggle(page, { 'metadata-cache': false });
|
||
await saveAppearance(page, 'Saved in this browser.');
|
||
await page.reload();
|
||
}
|
||
await openImages(page);
|
||
const info = imageCards(page).first().locator('.image-card-actions button').first();
|
||
await info.click();
|
||
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
|
||
await expect.poll(() => reads.length).toBeGreaterThan(0);
|
||
await requestBarrier(page);
|
||
reads.length = 0;
|
||
// Inspect the selected file twice more.
|
||
for (const _ of [1, 2]) {
|
||
const answered = cache ? null : page.waitForResponse(response => new URL(response.url()).pathname.startsWith(INSPECT_PATH));
|
||
await info.click();
|
||
if (answered) await answered;
|
||
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
|
||
}
|
||
await requestBarrier(page);
|
||
expect(reads, `cache ${cache}`).toHaveLength(cache ? 0 : 2);
|
||
}
|
||
});
|
||
|
||
test('paths hidden only changes display', async ({ page }) => {
|
||
const rows = async () => page.locator('#viewer-details details').filter({ hasText: 'File & provenance' }).locator('dt')
|
||
.evaluateAll(terms => terms.map(term => term.textContent));
|
||
const openViewer = async () => {
|
||
await openImages(page);
|
||
await imageCards(page).first().locator('.image-open').click();
|
||
const provenance = page.locator('#viewer-details details').filter({ hasText: 'File & provenance' });
|
||
await provenance.locator('summary').click();
|
||
await expect(provenance.locator('dl')).toBeVisible();
|
||
return provenance;
|
||
};
|
||
let provenance = await openViewer();
|
||
expect(await rows()).toContain('Path');
|
||
const before = { show: await provenance.getByRole('button', { name: /^Show .* in folder$/ }).count(),
|
||
download: await provenance.getByRole('link', { name: 'Download original', exact: true }).getAttribute('href') };
|
||
await page.keyboard.press('Escape');
|
||
await openAppearance(page);
|
||
await toggle(page, { paths: false });
|
||
await saveAppearance(page, 'Saved in this browser.');
|
||
provenance = await openViewer();
|
||
const shown = await rows();
|
||
expect(shown).not.toContain('Path');
|
||
expect(shown).toEqual(expect.arrayContaining(['Source', 'Size']));
|
||
// Everything else is as it was: the folder action and the original download stay.
|
||
expect(await provenance.getByRole('button', { name: /^Show .* in folder$/ }).count()).toBe(before.show);
|
||
await expect(provenance.getByRole('link', { name: 'Download original', exact: true })).toHaveAttribute('href', before.download);
|
||
});
|
||
|
||
test('video defaults reach the viewer', async ({ page }) => {
|
||
await openAppearance(page);
|
||
await toggle(page, { 'video-auto': true, 'video-muted': false, 'video-loop': false });
|
||
await saveAppearance(page, 'Saved in this browser.');
|
||
expect(await page.evaluate(async () => (await import('/prompt_manager/js/workspace/media-viewer.mjs')).readViewerPreferences()))
|
||
.toEqual({ videoAutoplay: true, videoMute: false, videoLoop: false });
|
||
const stored = await storedSettings(page);
|
||
expect([stored.videoAutoplay, stored.videoMute, stored.videoLoop]).toEqual([true, false, false]);
|
||
});
|
||
|
||
test('host fields round-trip through /settings with revision', async ({ page, request }) => {
|
||
const posts = [];
|
||
page.on('request', item => { if (item.method() === 'POST' && new URL(item.url()).pathname === SETTINGS_URL) posts.push(item.postDataJSON()); });
|
||
const before = await serverJson(request, SETTINGS_URL);
|
||
await openAppearance(page);
|
||
await expect(page.locator('#open-mode')).toHaveValue(before.settings.webui_display_mode);
|
||
await expect(page.locator('#timeout')).toHaveValue(String(before.settings.result_timeout));
|
||
// Only whole seconds from 0 to 300: anything else is highlighted and never sent.
|
||
for (const bad of ['301', '-1', '2.5']) {
|
||
await page.locator('#timeout').fill(bad);
|
||
await page.locator('#timeout').press('Tab');
|
||
await expect(page.locator('#timeout')).toHaveAttribute('aria-invalid', 'true');
|
||
await expect(statusOf(page)).toHaveText('Some changes were not saved. Review the highlighted settings.');
|
||
await expect(saveOf(page)).toHaveAttribute('aria-disabled', 'true');
|
||
}
|
||
await page.locator('#timeout').fill('0');
|
||
await page.locator('#timeout').press('Tab');
|
||
await expect(page.locator('#timeout')).not.toHaveAttribute('aria-invalid');
|
||
await page.locator('#open-mode').selectOption('popup');
|
||
await saveAppearance(page, 'Saved to the server configuration.');
|
||
expect(posts).toEqual([{ webui_display_mode: 'popup', result_timeout: 0, expected_revision: before.revision }]);
|
||
const after = await serverJson(request, SETTINGS_URL);
|
||
expect([after.settings.webui_display_mode, after.settings.result_timeout]).toEqual(['popup', 0]);
|
||
expect(after.revision).not.toBe(before.revision);
|
||
// No browser key was written for a host field.
|
||
expect(await storedSettings(page)).toBeNull();
|
||
});
|
||
|
||
test('server settings that cannot be read say so in their rows', async ({ page }) => {
|
||
await page.route(url => url.pathname === SETTINGS_URL, route => (route.request().method() === 'GET'
|
||
? route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ success: false, error: 'Injected failure' }) })
|
||
: route.continue()));
|
||
await page.goto(`${workspace}#/system/appearance`);
|
||
// #317: the Thumbnails rows are server settings too.
|
||
for (const id of ['open-mode', 'timeout', 'thumbnail-format', 'thumbnail-quality', 'thumbnail-edge']) {
|
||
const row = page.locator('#workspace-main .setting-row').filter({ has: page.locator(`#${id}`) });
|
||
await expect(row.locator('p.small').first()).toHaveText('Server settings could not be read.');
|
||
await expect(page.locator(`#${id}`)).toBeDisabled();
|
||
}
|
||
// The browser rows are this browser's, and work as before.
|
||
await expect(page.locator('#default-view')).toBeEnabled();
|
||
await expect(page.locator('#workspace-main .setting-row p.small', { hasText: 'Server settings could not be read.' })).toHaveCount(5);
|
||
await expect(page.locator('#workspace-main section.surface').first().locator('.setting-row p.small')).toHaveCount(2);
|
||
});
|
||
|
||
test('stale revision shows the conflict status', async ({ page, request }) => {
|
||
await openAppearance(page);
|
||
// Another page saves after this one read the settings.
|
||
const current = await serverJson(request, SETTINGS_URL);
|
||
await expect(await request.post(SETTINGS_URL, { data: { result_timeout: 42, expected_revision: current.revision } })).toBeOK();
|
||
await page.locator('#open-mode').selectOption('popup');
|
||
await saveAppearance(page, 'Settings changed elsewhere. Reload this section to see them.');
|
||
await expect(page.locator('#open-mode')).toHaveAttribute('aria-invalid', 'true');
|
||
// The draft is kept and the section now shows the other page's value.
|
||
await expect(page.locator('#open-mode')).toHaveValue('popup');
|
||
await expect(page.locator('#timeout')).toHaveValue('42');
|
||
expect((await serverJson(request, SETTINGS_URL)).settings.webui_display_mode).toBe('newtab');
|
||
// With the reloaded revision, Save succeeds.
|
||
await saveAppearance(page, 'Saved to the server configuration.');
|
||
expect((await serverJson(request, SETTINGS_URL)).settings.webui_display_mode).toBe('popup');
|
||
});
|
||
|
||
test('reload keeps saved values; a new tab reads them', async ({ page, context }) => {
|
||
await openAppearance(page);
|
||
await page.locator('#image-quality').selectOption('high');
|
||
await toggle(page, { 'card-details': false });
|
||
await page.locator('#open-mode').selectOption('popup');
|
||
await saveAppearance(page, 'Saved in this browser. Saved to the server configuration.');
|
||
await page.reload();
|
||
await expect(page.locator('#timeout')).toBeEnabled();
|
||
await expect(page.locator('#image-quality')).toHaveValue('high');
|
||
await expect(page.locator('#card-details')).not.toBeChecked();
|
||
await expect(page.locator('#open-mode')).toHaveValue('popup');
|
||
const other = await context.newPage();
|
||
await openAppearance(other);
|
||
await expect(other.locator('#image-quality')).toHaveValue('high');
|
||
await expect(other.locator('#card-details')).not.toBeChecked();
|
||
// A change saved in one tab reaches the other's clean controls; a draft it overtakes is flagged and kept.
|
||
await other.locator('#density').selectOption('large');
|
||
await page.locator('#density').selectOption('compact');
|
||
await saveAppearance(page, 'Saved in this browser.');
|
||
await expect(other.locator('#density')).toHaveValue('large');
|
||
await expect(other.locator('#settings-status')).toHaveText('Settings changed elsewhere. Reload this section to see them.');
|
||
await expect(other.locator('#density')).toHaveAttribute('aria-invalid', 'true');
|
||
await page.locator('#image-quality').selectOption('low');
|
||
await saveAppearance(page, 'Saved in this browser.');
|
||
await expect(other.locator('#image-quality')).toHaveValue('low');
|
||
await other.close();
|
||
});
|
||
|
||
test('a change another tab saved while System was closed is a conflict on return, never saved over silently', async ({ page, context }) => {
|
||
await openAppearance(page);
|
||
await page.locator('#density').selectOption('compact');
|
||
await expect(statusOf(page)).toHaveText('Unsaved changes.');
|
||
// Leave System for Images: the draft stays for the page session, but System no longer listens.
|
||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||
await expect(imageCards(page).first()).toBeVisible();
|
||
const other = await context.newPage();
|
||
await openAppearance(other);
|
||
await other.locator('#density').selectOption('large');
|
||
await saveAppearance(other, 'Saved in this browser.');
|
||
await other.close();
|
||
// Back in System: the draft is kept and flagged, and nothing was written over the other tab's value.
|
||
await page.evaluate(() => { location.hash = '#/system/appearance'; });
|
||
await expect(page.locator('#workspace-main .settings-layout > section.surface > h2').first()).toHaveText('Browsing & display');
|
||
await expect(statusOf(page)).toHaveText('Settings changed elsewhere. Reload this section to see them.');
|
||
await expect(page.locator('#density')).toHaveAttribute('aria-invalid', 'true');
|
||
await expect(page.locator('#density')).toHaveValue('compact');
|
||
expect((await storedSettings(page)).workspaceDensity).toBe('large');
|
||
// Saving after the conflict was shown is an explicit choice: it wins.
|
||
await saveAppearance(page, 'Saved in this browser.');
|
||
expect((await storedSettings(page)).workspaceDensity).toBe('compact');
|
||
await expect(page.locator('#density')).not.toHaveAttribute('aria-invalid');
|
||
});
|
||
|
||
test('a browser that refuses storage is saved for this tab only, never "Saved"', async ({ page }) => {
|
||
await openAppearance(page);
|
||
await page.evaluate(() => {
|
||
Storage.prototype.setItem = function setItem() { throw new DOMException('Quota exceeded', 'QuotaExceededError'); };
|
||
});
|
||
await page.locator('#default-view').selectOption('list');
|
||
await saveAppearance(page, 'Saved for this tab only; browser storage is unavailable.');
|
||
expect(await storedSettings(page)).toBeNull();
|
||
// This tab's next Images visit still uses it.
|
||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||
await expect(imageCards(page).first()).toBeVisible();
|
||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
|
||
});
|
||
|
||
test('added rows reach their consumers', async ({ page }) => {
|
||
await openImages(page);
|
||
await expect(imageCards(page).first().locator('.media-info h3')).toBeVisible();
|
||
await openAppearance(page);
|
||
await toggle(page, { 'card-details': false, 'viewer-details': true });
|
||
await saveAppearance(page, 'Saved in this browser.');
|
||
await page.reload();
|
||
await openImages(page);
|
||
// Show image details on cards off: no title or file line; Info and Tag stay.
|
||
const card = imageCards(page).first();
|
||
await expect(card.locator('.media-info h3')).toBeHidden();
|
||
await expect(card.locator('.image-card-actions button')).toHaveCount(2);
|
||
// Collapse viewer details by default: the viewer opens with its details hidden behind one toggle.
|
||
await card.locator('.image-open').click();
|
||
const details = page.locator('#viewer-details');
|
||
await expect(page.locator('#viewer-dialog')).toBeVisible();
|
||
await expect(details).toBeHidden();
|
||
const show = page.locator('#viewer-toolbar').getByRole('button', { name: 'Show details', exact: true });
|
||
await expect(show).toHaveAttribute('aria-expanded', 'false');
|
||
await show.click();
|
||
await expect(details).toBeVisible();
|
||
await expect(page.locator('#viewer-toolbar').getByRole('button', { name: 'Hide details', exact: true })).toHaveAttribute('aria-expanded', 'true');
|
||
});
|
||
|
||
test('reset restores defaults without touching other keys', async ({ page }) => {
|
||
await page.goto(`${workspace}#/system/appearance`);
|
||
await page.evaluate(() => localStorage.setItem('gallerySettings', JSON.stringify({ gridColumns: 5, defaultLimit: 200,
|
||
thumbnailPromptDismissed: 'kept', workspaceLayout: 'list', videoMute: false, showImageInfo: false })));
|
||
await page.reload();
|
||
await expect(page.locator('#timeout')).toBeEnabled();
|
||
await expect(page.locator('#default-view')).toHaveValue('list');
|
||
const reset = page.locator('#workspace-main .operation').filter({ hasText: 'Reset display defaults' }).getByRole('button', { name: 'Reset', exact: true });
|
||
await reset.click();
|
||
await expect(page.locator('#default-view')).toHaveValue('grid');
|
||
await expect(page.locator('#video-muted')).toBeChecked();
|
||
await expect(statusOf(page)).toHaveText('Unsaved changes.');
|
||
await saveAppearance(page, 'Saved in this browser.');
|
||
const stored = await storedSettings(page);
|
||
expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'grid',
|
||
videoMute: true, showImageInfo: true });
|
||
});
|
||
|
||
// TESTING.md "Legacy System capability coverage" (#249 System S12, #329): each `covered` row's native control is on its
|
||
// System section, found by role and accessible name (a `term` by its text, a disclosure by its summary). Collapsed
|
||
// `details` are opened first, so a control inside one counts once it is shown. tests/vendor/legacy-coverage.test.mjs
|
||
// checks the rest of each row.
|
||
test('every covered legacy System capability has its native control', async ({ page, request }) => {
|
||
// Download is offered for a model that is not on disk ("Download again" otherwise): the selected default's file is
|
||
// missing here.
|
||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
|
||
try {
|
||
await findControls(page);
|
||
} finally {
|
||
await request.post('/__test__/tagging/reset', { data: {} });
|
||
}
|
||
});
|
||
|
||
async function findControls(page) {
|
||
const covered = readCoverageTable().filter(row => row.Status === 'covered')
|
||
.map(row => ({ capability: row.Capability, ...parseControl(row['Native control']) }));
|
||
// A covered row without a control is one of the recorded exemptions, never silently skipped.
|
||
expect(covered.filter(row => !row.section).map(row => row.capability)).toEqual(Object.keys(CONTROL_EXEMPTIONS));
|
||
const controls = covered.filter(row => row.section);
|
||
expect(controls.length).toBeGreaterThan(40);
|
||
const sections = [...new Set(controls.map(row => row.section))];
|
||
for (const section of sections) {
|
||
await page.goto(`${workspace}#/system/${section}`);
|
||
const main = page.locator('#workspace-main');
|
||
await expect(layoutOf(page).locator(':scope > *').first()).toBeVisible();
|
||
for (const details of await main.locator('details:not([open]) > summary').all()) await details.click();
|
||
for (const { capability, role, name, row } of controls.filter(item => item.section === section)) {
|
||
// A name several rows share is looked up inside the row the table names.
|
||
const within = row ? main.locator('.operation').filter({ has: page.locator(`h3:text-is("${row}")`) }) : main;
|
||
const exact = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
|
||
// A `dt` (term) and a `details` disclosure (group) have no name from content: the term's text, the summary's.
|
||
const control = role === 'term' ? within.getByRole('term').filter({ hasText: exact })
|
||
: role === 'group' ? within.locator('details').filter({ has: page.locator(':scope > summary', { hasText: exact }) })
|
||
.or(within.getByRole('group', { name, exact: true }))
|
||
: within.getByRole(role, { name, exact: true });
|
||
await expect(control.first(), `${capability}: ${section} · ${role} · "${name}"${row ? ` in "${row}"` : ''}`).toBeVisible();
|
||
}
|
||
}
|
||
}
|