Merge remote-tracking branch 'origin/main' into fix/resolver-parity-live
This commit is contained in:
@@ -21,6 +21,7 @@ from PIL.PngImagePlugin import PngInfo
|
||||
from tests.browser.request_hold import register_request_holds
|
||||
from tests.browser import slow_reset_watch
|
||||
from tests.browser.workspace_activity_fixture import register_activity_commands
|
||||
from tests.browser.workspace_system_fixture import register_system_commands
|
||||
from tests.browser.workspace_concept_fixture import (
|
||||
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
|
||||
MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES,
|
||||
@@ -198,6 +199,7 @@ def create_app(root: Path) -> web.Application:
|
||||
register_tagging_commands(routes, api, root, model)
|
||||
reveal_stub = register_reveal_stub(routes, api, root)
|
||||
register_activity_commands(app, routes, api)
|
||||
register_system_commands(app, routes, api, root)
|
||||
request_holds = register_request_holds(app, routes)
|
||||
slow_reset_watch.install(app, api)
|
||||
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
|
||||
|
||||
// #249 System S2 Task 6: System's shell and its Overview section (app.js renderSystem 658–706), the Statistics dialog
|
||||
// ("Usage statistics", a1600–1604) and the health report (a1439–1456) against the immutable concept, on the
|
||||
// concept-shaped real fixture. Each concept measurement is mapped only by the recorded deviations (D92–D96, D121,
|
||||
// D122, D123), each commented where it applies; after that the difference must be empty, and a mutation of a
|
||||
// row, a label or a token must be noticed.
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC' });
|
||||
|
||||
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.conceptErrors = [];
|
||||
page.on('pageerror', error => page.conceptErrors.push(error.message));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, request, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
await request.post('/__test__/system/reset');
|
||||
expect(page.conceptErrors).toEqual([]);
|
||||
});
|
||||
|
||||
/** Runs in the page; self-contained. System's heading, section nav, Overview body and Save row under `mainSelector`. */
|
||||
function measureSystem(mainSelector) {
|
||||
const main = document.querySelector(mainSelector);
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const box = (node, origin = main) => {
|
||||
const rect = node.getBoundingClientRect(), base = origin.getBoundingClientRect();
|
||||
return { left: Math.round(rect.left - base.left), width: Math.round(rect.width) };
|
||||
};
|
||||
// A surface's top: from `.two-col` beside its neighbour, else from the bottom of the surface above it. This pins the
|
||||
// concept's `.surface + .surface` 18px offset, which concept.css applies inside `.two-col` too (copied, not ruled).
|
||||
const top = node => {
|
||||
const rect = node.getBoundingClientRect(), previous = node.previousElementSibling?.getBoundingClientRect();
|
||||
if (previous && rect.top >= previous.bottom) return Math.round(rect.top - previous.bottom);
|
||||
return Math.round(rect.top - node.parentElement.getBoundingClientRect().top);
|
||||
};
|
||||
const text = node => node?.textContent.trim() ?? null;
|
||||
const button = node => ({ text: text(node), className: node.className,
|
||||
...css(node, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopWidth', 'borderTopColor',
|
||||
'borderTopLeftRadius', 'opacity', 'cursor') });
|
||||
const heading = main.querySelector(':scope > .heading');
|
||||
const nav = main.querySelector(':scope > .section-nav');
|
||||
const layout = main.querySelector(':scope > .settings-layout');
|
||||
const metrics = layout.querySelector(':scope > .summary-metrics');
|
||||
const columns = layout.querySelector(':scope > .two-col');
|
||||
const save = layout.querySelector(':scope > .save-row');
|
||||
const firstMetric = metrics.querySelector('.metric');
|
||||
const firstSurface = columns.querySelector('.surface');
|
||||
const firstOperation = firstSurface.querySelector('.operation');
|
||||
return {
|
||||
tree: [...main.children].map(name),
|
||||
heading: {
|
||||
title: text(heading.querySelector('h1')), description: text(heading.querySelector('p.muted')),
|
||||
actions: [...(heading.querySelector('.heading-actions')?.children ?? [])].map(name),
|
||||
},
|
||||
nav: {
|
||||
role: nav.getAttribute('role'), label: nav.getAttribute('aria-label'),
|
||||
...css(nav, 'display', 'flexWrap', 'columnGap', 'rowGap', 'paddingBottom', 'marginBottom', 'borderBottomWidth', 'borderBottomColor'),
|
||||
box: box(nav),
|
||||
buttons: [...nav.children].map(item => ({ pressed: item.getAttribute('aria-pressed'), ...button(item) })),
|
||||
},
|
||||
layout: { tree: [...layout.children].map(name), ...css(layout, 'maxWidth'), box: box(layout) },
|
||||
metrics: {
|
||||
...css(metrics, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap', 'marginBottom'), box: box(metrics),
|
||||
items: [...metrics.children].map(item => ({ tree: [...item.children].map(name), value: text(item.querySelector('strong')),
|
||||
label: text(item.querySelector('span')), box: box(item) })),
|
||||
metric: css(firstMetric, 'paddingTop', 'paddingLeft', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius', 'backgroundColor'),
|
||||
strong: css(firstMetric.querySelector('strong'), 'display', 'fontSize', 'fontWeight', 'color'),
|
||||
span: css(firstMetric.querySelector('span'), 'fontSize', 'color', 'marginTop'),
|
||||
},
|
||||
columns: {
|
||||
...css(columns, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'), box: box(columns),
|
||||
surfaces: [...columns.children].map(surface => ({ tree: [...surface.children].map(name), box: box(surface), top: top(surface),
|
||||
heading: text(surface.querySelector(':scope > h2')),
|
||||
rows: [...surface.querySelectorAll(':scope > .operation')].map(row => ({ tree: [...row.children].map(name),
|
||||
title: text(row.querySelector('h3')), description: text(row.querySelector('p')),
|
||||
action: button(row.querySelector('.row-actions > button')) })) })),
|
||||
surface: css(firstSurface, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius'),
|
||||
h2: css(firstSurface.querySelector('h2'), 'fontSize', 'fontWeight', 'color', 'marginBottom'),
|
||||
operation: css(firstOperation, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom',
|
||||
'borderTopWidth', 'borderTopColor'),
|
||||
h3: css(firstOperation.querySelector('h3'), 'fontSize', 'fontWeight', 'color'),
|
||||
p: css(firstOperation.querySelector('p'), 'fontSize', 'color', 'marginTop'),
|
||||
actions: css(firstOperation.querySelector('.row-actions'), 'display', 'columnGap', 'flexShrink'),
|
||||
},
|
||||
save: {
|
||||
tree: [...save.children].map(name), ...css(save, 'display', 'alignItems', 'justifyContent', 'columnGap', 'marginTop', 'paddingTop'),
|
||||
status: { text: text(save.querySelector('#settings-status')), ...css(save.querySelector('#settings-status'), 'fontSize', 'color') },
|
||||
button: button(save.querySelector('button')),
|
||||
},
|
||||
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs in the page: the open dialog's title, body sequence, tables, operation rows and actions. */
|
||||
function measureDialog() {
|
||||
const dialog = document.querySelector('dialog#modal[open]');
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const body = dialog.querySelector('#modal-body');
|
||||
const firstCell = selector => body.querySelector(selector);
|
||||
const list = body.querySelector(':scope > .operation-list');
|
||||
const actions = body.querySelector(':scope > .dialog-actions');
|
||||
return {
|
||||
title: dialog.querySelector('#modal-title').textContent,
|
||||
body: [...body.children].map(name),
|
||||
tables: [...body.querySelectorAll(':scope > table')].map(table => ({
|
||||
caption: table.caption?.textContent ?? null,
|
||||
head: [...table.tHead.rows[0].cells].map(cell => [cell.localName, cell.scope, cell.textContent]),
|
||||
rows: [...table.tBodies[0].rows].map(row => [...row.cells].map(cell => cell.textContent)),
|
||||
})),
|
||||
tableStyle: firstCell(':scope > table') && {
|
||||
table: css(firstCell(':scope > table'), 'width', 'borderCollapse', 'fontSize', 'marginTop'),
|
||||
caption: css(firstCell(':scope > table caption'), 'textAlign', 'fontSize', 'fontWeight', 'color', 'paddingBottom'),
|
||||
th: css(firstCell(':scope > table thead th'), 'textAlign', 'fontSize', 'fontWeight', 'color', 'paddingTop', 'paddingLeft',
|
||||
'borderBottomWidth', 'borderBottomColor'),
|
||||
td: css(firstCell(':scope > table tbody td'), 'textAlign', 'fontSize', 'color', 'paddingTop', 'paddingLeft',
|
||||
'borderBottomWidth', 'borderBottomColor'),
|
||||
},
|
||||
operations: list && {
|
||||
...css(list, 'display', 'rowGap', 'marginTop'),
|
||||
rows: [...list.children].map(row => ({ tree: [...row.children].map(name), title: row.querySelector('h3').textContent,
|
||||
chip: row.querySelector('.chip').textContent })),
|
||||
operation: css(list.querySelector('.operation'), 'display', 'alignItems', 'justifyContent', 'paddingTop', 'borderTopWidth'),
|
||||
h3: css(list.querySelector('.operation h3'), 'fontSize', 'fontWeight', 'color'),
|
||||
chip: css(list.querySelector('.operation .chip'), 'fontSize', 'color', 'backgroundColor', 'borderTopLeftRadius', 'paddingLeft'),
|
||||
},
|
||||
actions: actions && {
|
||||
...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
|
||||
buttons: [...actions.children].map(item => ({ text: item.textContent, className: item.className,
|
||||
...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft') })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs in the page: a `.notice` (Maintenance's "Check first…") with its copy. */
|
||||
function measureNotice(mainSelector) {
|
||||
const notice = document.querySelector(`${mainSelector} .settings-layout > .notice`);
|
||||
const css = (node, ...keys) => Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]]));
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const run = notice.querySelector(':scope > button');
|
||||
return {
|
||||
tree: [...notice.children].map(name), text: [...notice.querySelectorAll(':scope > div > *')].map(node => [node.localName, node.textContent]),
|
||||
frame: css(notice, 'display', 'alignItems', 'justifyContent', 'columnGap', 'borderTopWidth', 'borderTopColor', 'backgroundColor',
|
||||
'borderTopLeftRadius', 'paddingTop', 'paddingLeft', 'marginBottom'),
|
||||
h2: css(notice.querySelector('h2'), 'fontSize', 'fontWeight', 'color'),
|
||||
p: css(notice.querySelector('p'), 'fontSize', 'color', 'marginTop'),
|
||||
run: { text: run.textContent, className: run.className, ...css(run, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft') },
|
||||
};
|
||||
}
|
||||
|
||||
async function serverJson(request, url) {
|
||||
const response = await request.get(url);
|
||||
await expect(response).toBeOK();
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const number = value => value.toLocaleString('en-US');
|
||||
const ratingText = value => value === null ? 'Not rated' : `${number(value)} / 5`;
|
||||
|
||||
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
|
||||
function mapOverviewConcept(reference, overview) {
|
||||
const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts),
|
||||
'Negative variants': number(overview.library.negative_variants), Images: number(overview.images.indexed) };
|
||||
const rows = {
|
||||
// D93 (Q6): no review queue yet, so no count and the action is unavailable (dimmed, as the Save row's D92).
|
||||
'3 ambiguous image links': row => ({ ...row, title: 'Ambiguous image links', description: 'Not available yet.',
|
||||
action: { ...row.action, opacity: '0.55', cursor: 'not-allowed' } }),
|
||||
// D93: no backup has been recorded (verified backups arrive with S8).
|
||||
'Backup status': row => ({ ...row, description: 'No retained backup record.' }),
|
||||
// D93: there is no paging, so the shared settings are grid and density.
|
||||
'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }),
|
||||
// D93: the real sources, not samples.
|
||||
'2 sample output sources': row => ({ ...row, title: `${number(overview.sources.effective)} output sources` }),
|
||||
// D96: the dialog shows runs and ratings by category and library totals, not prompt, tag or date usage.
|
||||
Statistics: row => ({ ...row, description: 'Runs and ratings by category, with library totals.' }),
|
||||
};
|
||||
return {
|
||||
...reference,
|
||||
// D92: no "Sample configuration" chip.
|
||||
heading: { ...reference.heading, actions: [] },
|
||||
// D122: the concept's literal label is its prototype action name ("system-tab sections").
|
||||
nav: { ...reference.nav, label: 'System sections' },
|
||||
metrics: { ...reference.metrics, items: reference.metrics.items.map(item => ({ ...item, value: relabel[item.label] })) },
|
||||
columns: { ...reference.columns, surfaces: reference.columns.surfaces.map(surface => ({ ...surface,
|
||||
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) })) },
|
||||
save: { ...reference.save,
|
||||
// D92: truthful status copy, and "Save preferences" is aria-disabled (dimmed) while nothing is unsaved.
|
||||
status: { ...reference.save.status, text: 'No unsaved changes.' },
|
||||
button: { ...reference.save.button, opacity: '0.55', cursor: 'not-allowed' } },
|
||||
};
|
||||
}
|
||||
|
||||
async function conceptOverview(page, theme) {
|
||||
await openConcept(page, 'system', theme);
|
||||
await expect(page.locator('#main .summary-metrics')).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureSystem, '#main');
|
||||
}
|
||||
|
||||
async function productionOverview(page, theme) {
|
||||
await openProduction(page, 'system/overview', theme);
|
||||
await expect(page.locator('#workspace-main .summary-metrics')).toBeVisible();
|
||||
// The thumbnail row is its own read (I2): compare once it answered.
|
||||
await expect(page.locator('#workspace-main .operation h3').filter({ hasText: 'thumbnails' })).toHaveText('No missing thumbnails');
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureSystem, '#workspace-main');
|
||||
}
|
||||
|
||||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
test(`system shell and overview match the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
const overview = await serverJson(request, '/prompt_manager/system/overview');
|
||||
// The fixture's thumbnails all exist, so the concept's "No missing thumbnails" is also the real title.
|
||||
expect((await serverJson(request, '/prompt_manager/images/thumbnails/status')).pending).toBe(0);
|
||||
const concept = await conceptOverview(page, theme);
|
||||
expect(concept.heading.actions).toEqual(['span.chip']);
|
||||
// Paired captures are artifacts only; there are no PNG baselines.
|
||||
await page.screenshot({ path: testInfo.outputPath(`concept-system-${theme}-${width}.png`), fullPage: true });
|
||||
const production = await productionOverview(page, theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-system-${theme}-${width}.png`), fullPage: true });
|
||||
expect(diffInventory(mapOverviewConcept(concept, overview), production)).toEqual([]);
|
||||
expect(production.pageScroll).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
// The recorded differences, by path: D93 the review row (title, Not available yet, dimmed action), the backup row, the
|
||||
// preferences row and the sources row, and metric 4; D96 the statistics row; D92 the chip, the status and the dimmed
|
||||
// Save; D122 the nav label. (The fixture's first three metrics are the concept's own counts.)
|
||||
const UNMAPPED_PATHS = [
|
||||
'columns.surfaces.0.rows.0.action.cursor', 'columns.surfaces.0.rows.0.action.opacity',
|
||||
'columns.surfaces.0.rows.0.description', 'columns.surfaces.0.rows.0.title', 'columns.surfaces.0.rows.1.description',
|
||||
'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description',
|
||||
'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label',
|
||||
'save.button.cursor', 'save.button.opacity', 'save.status.text',
|
||||
];
|
||||
|
||||
test('system comparison detects mutations', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const overview = await serverJson(request, '/prompt_manager/system/overview');
|
||||
const concept = await conceptOverview(page, 'dark');
|
||||
const expected = mapOverviewConcept(concept, overview);
|
||||
const production = await productionOverview(page, 'dark');
|
||||
expect(diffInventory(expected, production)).toEqual([]);
|
||||
// Unmapped, the concept differs exactly where a deviation is recorded, and nowhere else.
|
||||
expect(diffInventory(concept, production).map(item => item.path).sort()).toEqual(UNMAPPED_PATHS);
|
||||
const measure = () => page.evaluate(measureSystem, '#workspace-main');
|
||||
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
|
||||
|
||||
// A removed row.
|
||||
const undoRow = await mutate(page, { remove: ['#workspace-main .two-col > .surface:first-child > .operation:nth-of-type(2)'] });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.length']));
|
||||
await undoRow();
|
||||
// A changed label.
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main .metric span').textContent = 'Positive prompt'; });
|
||||
expect(await paths()).toEqual(['metrics.items.0.label']);
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main .metric span').textContent = 'Positive prompts'; });
|
||||
// A swapped token.
|
||||
const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['metrics.metric.borderTopColor', 'columns.surface.borderTopColor']));
|
||||
await undoToken();
|
||||
expect(diffInventory(expected, await measure())).toEqual([]);
|
||||
});
|
||||
|
||||
/** D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. */
|
||||
function mapStatisticsConcept(reference, statistics) {
|
||||
const [runs] = reference.tables;
|
||||
return {
|
||||
...reference,
|
||||
// No sample note (prototype copy); a second table follows the first.
|
||||
body: ['table', 'table'],
|
||||
tables: [
|
||||
{ ...runs, rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs), ratingText(row.average_rating)]) },
|
||||
{ caption: 'Library totals', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
|
||||
rows: [['Prompts', number(statistics.totals.total_prompts)], ['Tags', number(statistics.totals.total_tags)],
|
||||
['Images', number(statistics.totals.total_images)], ['Rated images', number(statistics.rated_images)]] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// The fixture's image sources and each check's real chip on it: nothing is damaged, Needs review does not exist yet (Q6).
|
||||
const FIXTURE_SOURCES = ['output', 'input'];
|
||||
const FIXTURE_CHIPS = Object.freeze({ 'Integrity check': 'Passed', 'Foreign keys & pair references': 'Passed',
|
||||
'Canonical hashes': 'Passed', 'Pair uniqueness': 'Passed', 'Missing files': 'Passed', 'Ambiguous metadata': 'Not available yet',
|
||||
Consistency: 'Passed' });
|
||||
const chipOf = title => {
|
||||
if (!(title in FIXTURE_CHIPS)) throw new Error(`No expected chip for ${title}`);
|
||||
return FIXTURE_CHIPS[title];
|
||||
};
|
||||
|
||||
/**
|
||||
* D95: the concept's health report from its own five rows, labels verbatim, titled "Health report". Only the D95 rows
|
||||
* are inserted where they are recorded (Pair uniqueness after Canonical hashes, Consistency last); Missing files has a
|
||||
* row per image source when there are several; each chip is the fixture's expected result.
|
||||
*/
|
||||
function mapHealthConcept(reference, sources = FIXTURE_SOURCES) {
|
||||
const [first] = reference.operations.rows;
|
||||
const added = title => ({ ...first, title, chip: chipOf(title) });
|
||||
const rows = reference.operations.rows.flatMap(row => {
|
||||
const mapped = row.title === 'Missing files' && sources.length > 1
|
||||
? sources.map(source => ({ ...row, title: `Missing files · ${source}`, chip: chipOf(row.title) }))
|
||||
: [{ ...row, chip: chipOf(row.title) }];
|
||||
return row.title === 'Canonical hashes' ? [...mapped, added('Pair uniqueness')] : mapped;
|
||||
});
|
||||
return {
|
||||
...reference,
|
||||
title: 'Health report',
|
||||
// No sample note (prototype copy); D95 adds the checked line after the list.
|
||||
body: ['div.operation-list', 'p.small', 'div.dialog-actions'],
|
||||
operations: { ...reference.operations, rows: [...rows, added('Consistency')] },
|
||||
};
|
||||
}
|
||||
|
||||
// Unmapped, the health report differs exactly where D95 is recorded: the title, the body sequence (no sample note, the
|
||||
// checked line), the rows after Canonical hashes (Pair uniqueness inserted, Missing files per source) and the chips of
|
||||
// the concept's sample findings.
|
||||
const UNMAPPED_HEALTH_PATHS = [
|
||||
'body.0', 'body.1',
|
||||
...[3, 4].flatMap(row => [`operations.rows.${row}.chip`, `operations.rows.${row}.title`]),
|
||||
// The concept's five rows end at row 4; rows 5–7 exist only with the D95 rows and the second source.
|
||||
...[5, 6, 7].flatMap(row => ['chip', 'title', 'tree.0', 'tree.1', 'tree.length'].map(key => `operations.rows.${row}.${key}`)),
|
||||
'operations.rows.length', 'title',
|
||||
];
|
||||
|
||||
async function conceptDialogs(page, theme, testInfo) {
|
||||
await openConcept(page, 'system', theme);
|
||||
await page.locator('#main').getByRole('button', { name: 'Explore', exact: true }).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Usage statistics');
|
||||
await page.mouse.move(0, 0);
|
||||
const statistics = await page.evaluate(measureDialog);
|
||||
await page.screenshot({ path: testInfo.outputPath(`concept-statistics-${theme}.png`) });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('#main .section-nav').getByRole('button', { name: 'Maintenance', exact: true }).click();
|
||||
const notice = await page.evaluate(measureNotice, '#main');
|
||||
await page.locator('#main .notice').getByRole('button', { name: 'Run health checks', exact: true }).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Sample health report');
|
||||
await page.mouse.move(0, 0);
|
||||
await page.screenshot({ path: testInfo.outputPath(`concept-health-${theme}.png`) });
|
||||
return { statistics, notice, health: await page.evaluate(measureDialog) };
|
||||
}
|
||||
|
||||
async function productionDialogs(page, theme, testInfo) {
|
||||
await openProduction(page, 'system/overview', theme);
|
||||
await page.locator('#workspace-main').getByRole('button', { name: 'Explore', exact: true }).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Usage statistics');
|
||||
await page.mouse.move(0, 0);
|
||||
const statistics = await page.evaluate(measureDialog);
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-statistics-${theme}.png`) });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Maintenance', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main .notice')).toBeVisible();
|
||||
const notice = await page.evaluate(measureNotice, '#workspace-main');
|
||||
await page.locator('#workspace-main .notice').getByRole('button', { name: 'Run health checks', exact: true }).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Health report');
|
||||
await page.mouse.move(0, 0);
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-health-${theme}.png`) });
|
||||
return { statistics, notice, health: await page.evaluate(measureDialog) };
|
||||
}
|
||||
|
||||
for (const theme of THEMES) test(`statistics and health dialogs match the concept ${theme}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const concept = await conceptDialogs(page, theme, testInfo);
|
||||
expect(concept.statistics.body).toEqual(['p.demo-note', 'table']);
|
||||
expect(concept.health.body).toEqual(['p.demo-note', 'div.operation-list', 'div.dialog-actions']);
|
||||
const production = await productionDialogs(page, theme, testInfo);
|
||||
const statistics = await serverJson(request, '/prompt_manager/system/statistics');
|
||||
const { report } = await serverJson(request, '/prompt_manager/system/health');
|
||||
// The fixture the expected chips describe: its two sources, and the concept's checks plus the D95 ones.
|
||||
const missing = report.checks.find(check => check.id === 'missing_files');
|
||||
expect(missing.sources.map(source => source.name)).toEqual(FIXTURE_SOURCES);
|
||||
expect(diffInventory(mapStatisticsConcept(concept.statistics, statistics), production.statistics)).toEqual([]);
|
||||
expect(diffInventory(mapHealthConcept(concept.health), production.health)).toEqual([]);
|
||||
// D123: Maintenance's notice is the concept's own, delivered ahead of the rest of the section.
|
||||
expect(diffInventory(concept.notice, production.notice)).toEqual([]);
|
||||
|
||||
// Unmapped, the dialogs differ only where D95 and D96 are recorded.
|
||||
expect(diffInventory(concept.health, production.health).map(item => item.path).sort()).toEqual(UNMAPPED_HEALTH_PATHS);
|
||||
expect(diffInventory(concept.statistics, production.statistics).map(item => item.path.split('.')[0])
|
||||
.filter((part, index, all) => all.indexOf(part) === index).sort()).toEqual(['body', 'tables']);
|
||||
|
||||
// Mutation check: a changed chip token in the health report is noticed, then undone to an exact match again.
|
||||
const undo = await mutate(page, { css: '#modal .operation .chip { background: rgb(255, 0, 0); }' });
|
||||
expect(diffInventory(mapHealthConcept(concept.health), await page.evaluate(measureDialog)).map(item => item.path))
|
||||
.toEqual(['operations.chip.backgroundColor']);
|
||||
await undo();
|
||||
expect(diffInventory(mapHealthConcept(concept.health), await page.evaluate(measureDialog))).toEqual([]);
|
||||
// A renamed check (database/health.py's label) is noticed against the concept's own label.
|
||||
await page.evaluate(() => { document.querySelector('#modal .operation h3').textContent = 'Integrity checks'; });
|
||||
expect(diffInventory(mapHealthConcept(concept.health), await page.evaluate(measureDialog)).map(item => item.path))
|
||||
.toEqual(['operations.rows.0.title']);
|
||||
await page.evaluate(() => { document.querySelector('#modal .operation h3').textContent = 'Integrity check'; });
|
||||
// And a removed "Library totals" table and a changed caption in the statistics dialog.
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click();
|
||||
await page.locator('#workspace-main').getByRole('button', { name: 'Explore', exact: true }).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Usage statistics');
|
||||
const expectedStatistics = mapStatisticsConcept(concept.statistics, statistics);
|
||||
const undoTable = await mutate(page, { remove: ['#modal-body > table:nth-of-type(2)'] });
|
||||
expect(diffInventory(expectedStatistics, await page.evaluate(measureDialog)).map(item => item.path))
|
||||
.toEqual(expect.arrayContaining(['body.length', 'tables.length']));
|
||||
await undoTable();
|
||||
await page.evaluate(() => { document.querySelector('#modal-body caption').textContent = 'Runs per category'; });
|
||||
expect(diffInventory(expectedStatistics, await page.evaluate(measureDialog)).map(item => item.path)).toEqual(['tables.0.caption']);
|
||||
});
|
||||
@@ -577,6 +577,54 @@ test('a video replaced while still starting is reset once it settles', async ({
|
||||
}
|
||||
});
|
||||
|
||||
test('leaving for another route while a video starts waits for it to settle', async ({ context, browserName }) => {
|
||||
test.setTimeout(180000);
|
||||
// A WebKit (GStreamer) video detached before its pipeline had started deadlocked the page (TESTING.md D62); a
|
||||
// route change detaches the whole page, so the shell renders the next route once the video reports metadata or an
|
||||
// error. The route changes from the video's own `loadstart`, or a few milliseconds after it.
|
||||
for (let attempt = 0; attempt < 15; attempt++) {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
if (attempt === 0) await startCoverage(page, browserName);
|
||||
await page.addInitScript(() => {
|
||||
const load = HTMLMediaElement.prototype.load;
|
||||
HTMLMediaElement.prototype.load = function () {
|
||||
if (!this.hasAttribute('src') && window.__starting?.node === this) window.__starting.reset = { readyState: this.readyState, failed: this.error !== null, connected: this.isConnected };
|
||||
return load.call(this);
|
||||
};
|
||||
});
|
||||
await openInspect(page);
|
||||
await page.evaluate(delay => {
|
||||
const slot = document.querySelector('#local-preview');
|
||||
new MutationObserver((records, observer) => {
|
||||
const video = slot.querySelector('video');
|
||||
if (!video) return;
|
||||
observer.disconnect();
|
||||
const leave = () => {
|
||||
window.__starting = { node: video, readyState: video.readyState };
|
||||
location.hash = '#/overview';
|
||||
};
|
||||
video.addEventListener('loadstart', () => { if (delay) setTimeout(leave, delay); else leave(); }, { once: true });
|
||||
}).observe(slot, { childList: true });
|
||||
}, attempt % 5);
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible({ timeout: 8000 });
|
||||
const { readyState, reset } = await page.evaluate(() => ({ readyState: window.__starting.readyState, reset: window.__starting.reset }));
|
||||
if (attempt % 5 === 0) expect(readyState).toBe(0);
|
||||
// The video was reset and left the page only once it had settled.
|
||||
expect(reset.readyState >= 1 || reset.failed).toBe(true);
|
||||
expect(reset.connected).toBe(true);
|
||||
// The page still answers.
|
||||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click();
|
||||
await chooser(page).setInputFiles(parityPng('f02.png'), { timeout: 8000 });
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
||||
if (attempt === 0) await saveCoverage(page, browserName);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('closing the viewer on a video that just started never blocks the page', async ({ context, browserName }) => {
|
||||
test.setTimeout(180000);
|
||||
// The viewer's video teardown resets its element; on WebKit a reset of a still-starting pipeline blocked the page.
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept } from './concept-inventory.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);
|
||||
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();
|
||||
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');
|
||||
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([
|
||||
['Ambiguous image links', 'Not available yet.', 'Review metadata'],
|
||||
['Backup status', 'No retained backup record.', 'View backups'],
|
||||
['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([]);
|
||||
});
|
||||
|
||||
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.');
|
||||
// The rest of Maintenance is still the existing tools' (D94).
|
||||
await expect(layout.locator(':scope > section.handoff h2')).toHaveText('Your current tools are ready');
|
||||
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 goes to Maintenance, where the cleanup's existing tool is linked.
|
||||
await dialog.getByRole('button', { name: 'Review missing links', 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');
|
||||
// Until the thumbnail review dialog (S5), Review thumbnails opens Sources.
|
||||
await row.getByRole('button', { name: 'Review thumbnails', exact: true }).click();
|
||||
await expect(page).toHaveURL(`${workspace}#/system/sources`);
|
||||
});
|
||||
|
||||
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 Not available yet (Q6)', async ({ page }) => {
|
||||
const layout = await openOverview(page);
|
||||
const row = layout.locator('.operation').filter({ hasText: 'Ambiguous image links' });
|
||||
await expect(row.locator('p')).toHaveText('Not available yet.');
|
||||
const review = row.getByRole('button', { name: 'Review metadata', exact: true });
|
||||
await expect(review).toHaveAttribute('aria-disabled', 'true');
|
||||
await review.click({ force: true });
|
||||
await expect(page).toHaveURL(`${workspace}#/system/overview`);
|
||||
});
|
||||
|
||||
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);
|
||||
await nav.getByRole('button', { name: 'Sources & scans', exact: true }).click();
|
||||
await expect(await request.post('/__test__/release')).toBeOK();
|
||||
await expect.poll(held).toBe(0);
|
||||
// Both late answers reached a section that had left: Sources keeps its own body.
|
||||
await expect(layout.locator(':scope > section.handoff')).toBeVisible();
|
||||
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.className))).toEqual(['surface handoff', '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);
|
||||
});
|
||||
@@ -18,7 +18,8 @@ const routes = [
|
||||
['tags', 'Tags & categories', null],
|
||||
// #249 local Inspect: the native Inspect a file page; Overview's tool card still links the legacy metadata page.
|
||||
['inspect', 'Inspect a file', null],
|
||||
['system', 'System', '/prompt_manager/admin'],
|
||||
// #249 System S2: native System; its undelivered sections hand off per section (D94, asserted below).
|
||||
['system', 'System', null],
|
||||
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
|
||||
['favorites', 'Favorites', null],
|
||||
// #249 Activity-1: the concept's Activity page in the tools-and-system group.
|
||||
@@ -409,6 +410,8 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
|
||||
await sidebarLink(page, 'System').click();
|
||||
// System's Overview is native (S2 Task 6); a section not delivered yet still hands off (D94).
|
||||
await main.locator('.section-nav').getByRole('button', { name: 'Integrations', exact: true }).click();
|
||||
const handoff = main.locator('section.handoff');
|
||||
await expect(handoff).toHaveClass(/\bsurface\b/);
|
||||
expect(await handoff.evaluate(surfaceStyle)).toMatchObject(SURFACE);
|
||||
@@ -417,6 +420,98 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
// #249 System S2 Task 5: the concept's renderSystem shell (app.js 698–706) at `#/system/<section>`, every section a D94
|
||||
// hand-off until its PR, and the Save row's truthful status (D92).
|
||||
const SYSTEM_SECTION_LABELS = ['Overview', 'Appearance', 'Sources & scans', 'Integrations', 'Auto-tagging', 'Maintenance',
|
||||
'Backup & export', 'Diagnostics & logs'];
|
||||
const SYSTEM_HASHES = ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics'];
|
||||
const SYSTEM_HANDOFFS = { appearance: ['/prompt_manager/gallery', '/prompt_manager/admin'], sources: ['/prompt_manager/admin', '/prompt_manager/gallery'],
|
||||
maintenance: ['/prompt_manager/admin', '/prompt_manager/gallery'] };
|
||||
|
||||
test('System is native: the concept shell, section navigation, sections, hand-offs and the Save row', async ({ page }) => {
|
||||
const mutations = [];
|
||||
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); });
|
||||
await page.goto(`${workspace}#/system`);
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.locator('h1')).toHaveText('System');
|
||||
await expect(main.locator('.heading p.muted')).toHaveText('Configuration, maintenance and recovery—with browsing kept in the workspace.');
|
||||
// D92: no "Sample configuration" chip.
|
||||
await expect(main.locator('.heading .chip')).toHaveCount(0);
|
||||
expect(await main.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
|
||||
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout']);
|
||||
const nav = main.getByRole('group', { name: 'System sections', exact: true });
|
||||
await expect(nav.getByRole('button')).toHaveText(SYSTEM_SECTION_LABELS);
|
||||
await expect(nav.getByRole('button', { name: 'Overview', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(nav.locator('[aria-pressed="true"]')).toHaveCount(1);
|
||||
await expect(page.locator('aside.sidebar a[href="#/system"]')).toHaveAttribute('aria-current', 'page');
|
||||
const layout = main.locator('.settings-layout');
|
||||
await expect(layout.locator(':scope > .save-row > span.small#settings-status')).toHaveText('No unsaved changes.');
|
||||
const save = layout.locator(':scope > .save-row > button.ca-primary');
|
||||
await expect(save).toHaveText('Save preferences');
|
||||
await expect(save).toHaveAttribute('aria-disabled', 'true');
|
||||
// Playwright waits on an aria-disabled control, so the ignored press is forced.
|
||||
await save.click({ force: true });
|
||||
await expect(layout.locator('#settings-status')).toHaveText('No unsaved changes.');
|
||||
|
||||
for (const [index, label] of SYSTEM_SECTION_LABELS.entries()) {
|
||||
const hash = SYSTEM_HASHES[index];
|
||||
const button = nav.getByRole('button', { name: label, exact: true });
|
||||
await button.click();
|
||||
await expect(page).toHaveURL(`${workspace}#/system/${hash}`);
|
||||
await expect(button).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(nav.locator('[aria-pressed="true"]')).toHaveCount(1);
|
||||
// The section changes in place: focus stays on the pressed section, not the page title.
|
||||
await expect(button).toBeFocused();
|
||||
// S2 Task 6: Overview is native (workspace-system.spec.mjs); Maintenance shows its health-check notice above
|
||||
// the hand-off (D123); every other section hands off.
|
||||
if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible();
|
||||
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
|
||||
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'],
|
||||
maintenance: ['div.notice', 'section.surface handoff', 'div.save-row'] }[hash]
|
||||
?? ['section.surface handoff', 'div.save-row']);
|
||||
if (hash === 'overview') continue;
|
||||
const handoff = layout.locator('section.handoff');
|
||||
await expect(handoff.locator('h2')).toHaveText('Your current tools are ready');
|
||||
expect(await handoff.locator('a.workspace-link').evaluateAll(links => links.map(link => link.getAttribute('href'))))
|
||||
.toEqual(SYSTEM_HANDOFFS[hash] ?? ['/prompt_manager/admin']);
|
||||
await expect(layout.locator('#settings-status')).toHaveText('No unsaved changes.');
|
||||
await expect(page.locator('aside.sidebar a[href="#/system"]')).toHaveAttribute('aria-current', 'page');
|
||||
}
|
||||
// Deep links, reload and history keep the section; an unknown section is Overview under its own hash.
|
||||
await page.reload();
|
||||
await expect(nav.getByRole('button', { name: 'Diagnostics & logs', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(`${workspace}#/system/backups`);
|
||||
await expect(nav.getByRole('button', { name: 'Backup & export', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.goto(`${workspace}#/system/general`);
|
||||
await expect(page).toHaveURL(`${workspace}#/system/overview`);
|
||||
await expect(nav.getByRole('button', { name: 'Overview', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
// The sidebar's System returns to the Overview section.
|
||||
await nav.getByRole('button', { name: 'Integrations', exact: true }).click();
|
||||
await sidebarLink(page, 'System').click();
|
||||
await expect(page).toHaveURL(`${workspace}#/system`);
|
||||
await expect(nav.getByRole('button', { name: 'Overview', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
// Leaving System and coming back renders it once, with a fresh section.
|
||||
await sidebarLink(page, 'Images').click();
|
||||
await expect(main.locator('h1')).toHaveText('Images');
|
||||
await expect(main.locator('.section-nav')).toHaveCount(0);
|
||||
await page.goBack();
|
||||
await expect(main.locator('.section-nav')).toHaveCount(1);
|
||||
await expect(main.locator('.settings-layout')).toHaveCount(1);
|
||||
expect(mutations).toEqual([]);
|
||||
});
|
||||
|
||||
test('Overview opens native System from its System card (Q12)', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const card = page.locator('#workspace-main .tool-card').filter({ has: page.getByRole('heading', { name: 'System', exact: true }) });
|
||||
await expect(card.locator('p.small')).toHaveText('Configuration, maintenance and recovery—with browsing kept in the workspace.');
|
||||
const link = card.getByRole('link', { name: 'Open System', exact: true });
|
||||
await expect(link).toHaveAttribute('href', '#/system/overview');
|
||||
await link.click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('System');
|
||||
await expect(page.locator('#workspace-main .section-nav [aria-pressed="true"]')).toHaveText('Overview');
|
||||
});
|
||||
|
||||
// Task A3: the concept sidebar (index.html h16-41, app.js nav()) rendered from real data (D27, D28).
|
||||
const NAV_TOTALS = [
|
||||
['images', 'Images', '/prompt_manager/images/output?mode=workspace&limit=1'],
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""System test hooks for the browser tests (#249 System S2 Task 6).
|
||||
|
||||
The data is the concept-shaped real fixture (``POST /__test__/concept/reset``,
|
||||
``workspace_concept_fixture.py``): its parents, saved pairs, images, ratings
|
||||
and runs are what the Overview, the Statistics dialog and the health check
|
||||
read. These per-server hooks only change what a System test needs on top:
|
||||
|
||||
- ``POST /__test__/system/reset`` restores the configured sources, forgets the
|
||||
session's health report and ends every injected failure;
|
||||
- ``POST /__test__/system/fail`` ``{path, times}`` answers the next ``times``
|
||||
requests for ``path`` (a ``/prompt_manager/system/`` route) with the routes'
|
||||
500 error envelope;
|
||||
- ``POST /__test__/system/missing-source`` configures one more source that
|
||||
does not exist (an unmounted drive);
|
||||
- ``POST /__test__/system/missing-file`` deletes the newest linked image's
|
||||
file from disk, leaving its database row (a missing-file link);
|
||||
- ``POST /__test__/system/unthumbnailed`` ``{count}`` writes ``count`` plain
|
||||
PNGs with no thumbnail into the output folder.
|
||||
|
||||
Delays use the shared request holds (``request_hold.py``).
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
|
||||
FAILABLE_PREFIX = "/prompt_manager/system/"
|
||||
MAX_UNTHUMBNAILED = 50
|
||||
|
||||
|
||||
def register_system_commands(app, routes, api, root: Path):
|
||||
from py.config import GalleryConfig
|
||||
|
||||
configured = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
failures: dict[str, int] = {}
|
||||
|
||||
@web.middleware
|
||||
async def inject_failures(request, handler):
|
||||
left = failures.get(request.path, 0)
|
||||
if left <= 0:
|
||||
return await handler(request)
|
||||
failures[request.path] = left - 1
|
||||
return web.json_response(
|
||||
{"success": False, "code": None, "error": "Injected failure"}, status=500
|
||||
)
|
||||
|
||||
app.middlewares.append(inject_failures)
|
||||
|
||||
def reset():
|
||||
failures.clear()
|
||||
GalleryConfig.MONITORING_DIRECTORIES = list(configured)
|
||||
api._health_report = None
|
||||
|
||||
@routes.post("/__test__/system/reset")
|
||||
async def system_reset(request):
|
||||
reset()
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/system/fail")
|
||||
async def system_fail(request):
|
||||
data = await request.json()
|
||||
path, times = data.get("path"), data.get("times", 1)
|
||||
if (
|
||||
not isinstance(path, str)
|
||||
or not path.startswith(FAILABLE_PREFIX)
|
||||
or isinstance(times, bool)
|
||||
or not isinstance(times, int)
|
||||
or not 1 <= times <= 10
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
failures[path] = times
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/system/missing-source")
|
||||
async def missing_source(request):
|
||||
absent = str(root / "unmounted-drive")
|
||||
if os.path.exists(absent):
|
||||
raise web.HTTPConflict()
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [*configured, absent]
|
||||
return web.json_response(
|
||||
{"success": True, "configured": len(GalleryConfig.MONITORING_DIRECTORIES)}
|
||||
)
|
||||
|
||||
@routes.post("/__test__/system/missing-file")
|
||||
async def missing_file(request):
|
||||
with api.db.model.get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT file_path FROM generated_images ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise web.HTTPNotFound()
|
||||
Path(row[0]).unlink()
|
||||
api.invalidate_gallery_cache()
|
||||
return web.json_response({"success": True, "filename": Path(row[0]).name})
|
||||
|
||||
@routes.post("/__test__/system/unthumbnailed")
|
||||
async def unthumbnailed(request):
|
||||
data = await request.json()
|
||||
count = data.get("count")
|
||||
if (
|
||||
isinstance(count, bool)
|
||||
or not isinstance(count, int)
|
||||
or not 1 <= count <= MAX_UNTHUMBNAILED
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
folder = root / "output" / "unthumbnailed"
|
||||
folder.mkdir(exist_ok=True)
|
||||
for number in range(count):
|
||||
with Image.new("RGB", (64, 64), "#2b3442") as image:
|
||||
image.save(folder / f"plain_{number + 1:03}.png")
|
||||
api.invalidate_gallery_cache()
|
||||
return web.json_response({"success": True})
|
||||
|
||||
return reset
|
||||
@@ -182,6 +182,24 @@ test('start returns busy with the running job', async () => {
|
||||
world.dispose();
|
||||
});
|
||||
|
||||
test('track follows a job another route started, with no read of its own', async () => {
|
||||
const { createActivityStore } = await source;
|
||||
const { world, ...deps } = harness();
|
||||
const store = createActivityStore(deps);
|
||||
const seen = [];
|
||||
store.subscribe(() => seen.push(store.find(ID_A)?.state));
|
||||
const job = store.track(summary({ kind: 'health', title: 'Health check' }));
|
||||
assert.equal(job.id, ID_A);
|
||||
assert.equal(world.gets, 0, 'the started job is known without a list read');
|
||||
assert.deepEqual(world.open().map(stream => stream.url), [`/prompt_manager/jobs/${ID_A}/events`]);
|
||||
assert.deepEqual(world.toasts, [], 'the route that started it says so itself');
|
||||
send(world.open()[0], summary({ kind: 'health', title: 'Health check', state: 'finished', progress: 100 }));
|
||||
assert.deepEqual(seen, ['running', 'finished']);
|
||||
assert.equal(world.open().length, 0, 'an ended job is no longer followed');
|
||||
assert.throws(() => store.track({ id: 'nope' }), /Invalid job/);
|
||||
world.dispose();
|
||||
});
|
||||
|
||||
test('a list that is not a job list counts as a failed read', async () => {
|
||||
const { createActivityStore } = await source;
|
||||
const { world, api, ...deps } = harness();
|
||||
|
||||
@@ -23,8 +23,50 @@ test('tags is a native route titled Tags; only unbuilt routes hand off to legacy
|
||||
assert.equal(routeTitle(route), route.label);
|
||||
}
|
||||
assert.equal(routeTitle(resolveRoute('#/library/7')), 'Prompt library');
|
||||
for (const id of ['images', 'library', 'tags', 'inspect', 'overview', 'activity']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
|
||||
for (const id of ['system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), true, id);
|
||||
// System is native from S2 (#249 System): its undelivered sections hand off per section (D94), not the route.
|
||||
for (const id of ['images', 'library', 'tags', 'inspect', 'overview', 'activity', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
|
||||
});
|
||||
|
||||
test('system resolves its sections; #/overview stays the production hub', async () => {
|
||||
const { resolveRoute, routeTitle, isHandoff, routes } = await source;
|
||||
// `#/system` is the Overview section and keeps the sidebar's hash.
|
||||
const system = resolveRoute('#/system');
|
||||
assert.deepEqual([system.id, system.section, system.hash, routeTitle(system), isHandoff(system)],
|
||||
['system', 'overview', '#/system', 'System', false]);
|
||||
for (const section of ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics']) {
|
||||
const route = resolveRoute(`#/system/${section}`);
|
||||
assert.deepEqual([route.id, route.section, route.hash], ['system', section, `#/system/${section}`]);
|
||||
}
|
||||
// An unknown child is the Overview section with a replaced hash.
|
||||
for (const hash of ['#/system/general', '#/system/', '#/system/__proto__', '#/system/backups/1', '#/system/<b>']) {
|
||||
const route = resolveRoute(hash);
|
||||
assert.deepEqual([route.id, route.section, route.hash], ['system', 'overview', '#/system/overview'], hash);
|
||||
}
|
||||
// D28: the production hub is untouched.
|
||||
const hub = resolveRoute('#/overview');
|
||||
assert.deepEqual([hub.id, hub.section], ['overview', undefined]);
|
||||
// Q12: Overview's System card opens the native System Overview, with no legacy href or action.
|
||||
const card = routes.find(route => route.id === 'system');
|
||||
assert.equal(card.href, '#/system/overview');
|
||||
assert.equal(card.native, true);
|
||||
assert.equal(card.action, 'Open System');
|
||||
});
|
||||
|
||||
test('the router renders again when only the System section changes', async () => {
|
||||
const { mountRouter } = await source;
|
||||
const win = new EventTarget();
|
||||
win.location = { hash: '#/system' };
|
||||
const replacements = [];
|
||||
win.history = { state: null, replaceState(state, unused, hash) { replacements.push(hash); win.location.hash = hash; } };
|
||||
const seen = [];
|
||||
const dispose = mountRouter(win, route => seen.push([route.id, route.section]));
|
||||
for (const hash of ['#/system/appearance', '#/system/appearance', '#/system/bogus', '#/system/overview', '#/images']) {
|
||||
win.location.hash = hash;
|
||||
win.dispatchEvent(new Event('hashchange'));
|
||||
}
|
||||
assert.deepEqual(seen, [['system', 'overview'], ['system', 'appearance'], ['system', 'overview'], ['images', undefined]]);
|
||||
assert.deepEqual(replacements, ['#/system/overview']);
|
||||
dispose();
|
||||
});
|
||||
|
||||
test('inspect is the native Inspect a file page with the concept description', async () => {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// System controls (#249 System S2 Task 5): the concept's `setting()` (app.js 624–634, its toggles hydrated into Toolkit
|
||||
// switches by `hydrateSwitches` 708–717), `operation()` (645–654) and `sectionNav()` (306–308) as DOM builders whose
|
||||
// listeners live on the caller's scope.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const controlsModule = import('../../web/js/workspace/system-controls.mjs');
|
||||
const coreModule = import('../../web/js/shared/toolkit/core.js');
|
||||
|
||||
/** A minimal DOM whose elements count their live listeners (a listener's signal aborts it, as in a browser). */
|
||||
function fakeDOM() {
|
||||
const previous = global.document;
|
||||
const elements = [];
|
||||
class Element {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.attributes = {};
|
||||
this.listeners = [];
|
||||
this.dataset = {};
|
||||
this.className = '';
|
||||
this.id = '';
|
||||
this.value = '';
|
||||
this._text = '';
|
||||
elements.push(this);
|
||||
}
|
||||
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
|
||||
set textContent(text) { this.children = []; this._text = String(text); }
|
||||
append(...nodes) {
|
||||
for (const node of nodes) {
|
||||
const child = typeof node === 'string' ? Object.assign(new Element('#text'), { _text: node }) : node;
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
}
|
||||
}
|
||||
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
||||
removeAttribute(name) { delete this.attributes[name]; }
|
||||
addEventListener(type, callback, options = {}) {
|
||||
if (options.signal?.aborted) return;
|
||||
const entry = { type, callback };
|
||||
this.listeners.push(entry);
|
||||
options.signal?.addEventListener('abort', () => { this.listeners = this.listeners.filter(item => item !== entry); });
|
||||
}
|
||||
removeEventListener(type, callback) { this.listeners = this.listeners.filter(item => item.type !== type || item.callback !== callback); }
|
||||
dispatch(type) { for (const { callback } of this.listeners.filter(item => item.type === type)) callback({ type, target: this }); }
|
||||
click() { this.dispatch('click'); }
|
||||
}
|
||||
global.document = {
|
||||
createElement: tag => new Element(tag),
|
||||
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
|
||||
};
|
||||
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => walk(child, found)); return found; };
|
||||
return {
|
||||
listenerCount: () => elements.reduce((sum, element) => sum + element.listeners.length, 0),
|
||||
find: (root, predicate) => walk(root).find(predicate),
|
||||
tags: node => node.children.map(child => child.tagName.toLowerCase()),
|
||||
restore() { global.document = previous; },
|
||||
};
|
||||
}
|
||||
|
||||
test('settingRow is the concept row: div > label[for] + p, then the control', async () => {
|
||||
const { settingRow } = await controlsModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const changes = [];
|
||||
const row = settingRow({ id: 'timeout', label: 'Node search auto-hide (seconds)',
|
||||
description: 'Node search behavior only. Use 0 to keep results visible.',
|
||||
control: { type: 'number', value: 30, min: 0, max: 300 }, scope, onChange: value => changes.push(value) });
|
||||
assert.equal(row.className, 'setting-row');
|
||||
assert.deepEqual(dom.tags(row), ['div', 'input']);
|
||||
const [text, input] = row.children;
|
||||
assert.deepEqual(dom.tags(text), ['label', 'p']);
|
||||
assert.equal(text.children[0].textContent, 'Node search auto-hide (seconds)');
|
||||
assert.equal(text.children[0].htmlFor, 'timeout');
|
||||
assert.equal(text.children[1].textContent, 'Node search behavior only. Use 0 to keep results visible.');
|
||||
assert.deepEqual([input.id, input.type, input.value, input.min, input.max], ['timeout', 'number', '30', '0', '300']);
|
||||
assert.equal(input.dataset.setting, 'timeout');
|
||||
input.value = '45';
|
||||
input.dispatch('change');
|
||||
assert.deepEqual(changes, [45]);
|
||||
|
||||
const select = settingRow({ id: 'open-mode', label: 'Open from ComfyUI', description: 'Keep the node pack’s popup / new-tab preference.',
|
||||
control: { type: 'select', options: [['newtab', 'New tab'], ['popup', 'Popup window']], value: 'popup' }, scope,
|
||||
onChange: value => changes.push(value) }).children[1];
|
||||
assert.equal(select.tagName, 'SELECT');
|
||||
assert.deepEqual(select.children.map(option => [option.value, option.textContent]), [['newtab', 'New tab'], ['popup', 'Popup window']]);
|
||||
assert.equal(select.value, 'popup');
|
||||
select.value = 'newtab';
|
||||
select.dispatch('change');
|
||||
assert.deepEqual(changes, [45, 'newtab']);
|
||||
|
||||
// The concept's range is 1–16 unless the row says otherwise (D113: the CPU count).
|
||||
const range = settingRow({ id: 'workers', label: 'Worker threads', description: 'Balance background scans.',
|
||||
control: { type: 'range', value: 4 } }).children[1];
|
||||
assert.deepEqual([range.type, range.min, range.max, range.value], ['range', '1', '16', '4']);
|
||||
const password = settingRow({ id: 'civitai-key', label: 'CivitAI API key', description: 'Credentials stay on the server.',
|
||||
control: { type: 'password', value: '' } }).children[1];
|
||||
assert.equal(password.type, 'password');
|
||||
|
||||
// A ready-made control is placed as given and takes the row id.
|
||||
const custom = document.createElement('textarea');
|
||||
const customRow = settingRow({ id: 'custom', label: 'Custom', description: 'Any control.', control: custom });
|
||||
assert.equal(customRow.children[1], custom);
|
||||
assert.equal(custom.id, 'custom');
|
||||
assert.throws(() => settingRow({ id: '', label: 'x', description: 'y', control: { type: 'number' } }), TypeError);
|
||||
assert.throws(() => settingRow({ id: 'x', label: 'x', description: 'y', control: { type: 'colour' } }), TypeError);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a toggle row holds the Toolkit switch, labelled by the row label', async () => {
|
||||
const { settingRow } = await controlsModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const changes = [];
|
||||
const row = settingRow({ id: 'video-auto', label: 'Autoplay videos', description: 'Videos stay within the same gallery.',
|
||||
control: { type: 'toggle', checked: false }, scope, onChange: value => changes.push(value) });
|
||||
const field = row.children[1];
|
||||
// createSwitch('', '', id): `label.ca-switch` > input[type=checkbox][role=switch] + an empty label text.
|
||||
assert.equal(field.tagName, 'LABEL');
|
||||
assert.equal(field.className, 'ca-switch');
|
||||
const input = field.children[0];
|
||||
assert.deepEqual([input.type, input.role, input.id, input.checked], ['checkbox', 'switch', 'video-auto', false]);
|
||||
assert.equal(row.children[0].children[0].htmlFor, input.id);
|
||||
assert.equal(input.dataset.setting, 'video-auto');
|
||||
input.checked = true;
|
||||
input.dispatch('change');
|
||||
assert.deepEqual(changes, [true]);
|
||||
// The concept's toggles are on unless configured off (`val !== false`).
|
||||
const on = settingRow({ id: 'video-loop', label: 'Loop videos', description: 'Repeat a clip.', control: { type: 'toggle' } });
|
||||
assert.equal(on.children[1].children[0].checked, true);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('operationRow is the concept operation with a danger class only when flagged', async () => {
|
||||
const { operationRow } = await controlsModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const clicks = [];
|
||||
const row = operationRow({ title: 'Missing-file links',
|
||||
description: 'Remove stale database links only. Files on disk are never deleted by this operation.',
|
||||
label: 'Review plan', danger: true, scope, onClick: button => clicks.push(button) });
|
||||
assert.equal(row.className, 'operation');
|
||||
assert.deepEqual(dom.tags(row), ['div', 'div']);
|
||||
assert.deepEqual(dom.tags(row.children[0]), ['h3', 'p']);
|
||||
assert.equal(row.children[0].children[0].textContent, 'Missing-file links');
|
||||
assert.equal(row.children[1].className, 'row-actions');
|
||||
const button = row.children[1].children[0];
|
||||
assert.deepEqual([button.tagName, button.type, button.textContent, button.className], ['BUTTON', 'button', 'Review plan', 'danger']);
|
||||
button.click();
|
||||
assert.deepEqual(clicks, [button]);
|
||||
const plain = operationRow({ title: 'Statistics', description: 'Usage by prompt, category, tag and date.', label: 'Explore',
|
||||
scope, onClick() {} });
|
||||
assert.equal(plain.children[1].children[0].className, '');
|
||||
assert.throws(() => operationRow({ title: 'x', description: 'y', label: 'z', onClick() {} }), TypeError);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('sectionNav marks the active section and reports the chosen one', async () => {
|
||||
const { sectionNav } = await controlsModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const chosen = [];
|
||||
const nav = sectionNav({ label: 'System sections', sections: [['overview', 'Overview'], ['appearance', 'Appearance']],
|
||||
active: 'appearance', scope, onSelect: id => chosen.push(id) });
|
||||
assert.equal(nav.className, 'section-nav');
|
||||
assert.equal(nav.getAttribute('role'), 'group');
|
||||
assert.equal(nav.getAttribute('aria-label'), 'System sections');
|
||||
assert.deepEqual(nav.children.map(button => [button.textContent, button.getAttribute('aria-pressed'), button.dataset.value]),
|
||||
[['Overview', 'false', 'overview'], ['Appearance', 'true', 'appearance']]);
|
||||
nav.children[0].click();
|
||||
assert.deepEqual(chosen, ['overview']);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('disposing the scope leaves no listener behind', async () => {
|
||||
const { settingRow, operationRow, sectionNav } = await controlsModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
settingRow({ id: 'a', label: 'A', description: 'a', control: { type: 'toggle' }, scope, onChange() {} });
|
||||
settingRow({ id: 'b', label: 'B', description: 'b', control: { type: 'select', options: ['Grid', 'List'] }, scope, onChange() {} });
|
||||
operationRow({ title: 'C', description: 'c', label: 'Go', scope, onClick() {} });
|
||||
sectionNav({ label: 'System sections', sections: [['overview', 'Overview']], active: 'overview', scope, onSelect() {} });
|
||||
assert.equal(dom.listenerCount() > 0, true);
|
||||
scope.dispose();
|
||||
assert.equal(dom.listenerCount(), 0);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
// System › Overview (#249 System S2 Task 6): the concept's Overview copy on real values (D93), the Health report rows
|
||||
// (D95), the Statistics tables (D96) and the health check run that waits for its read job.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const overviewModule = import('../../web/js/workspace/system-overview.mjs');
|
||||
|
||||
const OVERVIEW = Object.freeze({
|
||||
library: { positive_prompts: 1234, negative_variants: 7 },
|
||||
images: { indexed: 24, rated: 20 },
|
||||
health: null,
|
||||
backup: null,
|
||||
needs_review: { pending: null },
|
||||
sources: { configured: 2, effective: 2, auto_detected: false },
|
||||
});
|
||||
|
||||
test('metrics are the server counts and Not checked before any health check', async () => {
|
||||
const { metricValues } = await overviewModule;
|
||||
assert.deepEqual(metricValues(OVERVIEW), [
|
||||
[(1234).toLocaleString(), 'Positive prompts'], ['7', 'Negative variants'], ['24', 'Images'],
|
||||
['Not checked', 'Database integrity'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('metric 4 is the last health result', async () => {
|
||||
const { integrityText } = await overviewModule;
|
||||
assert.equal(integrityText(null), 'Not checked');
|
||||
assert.equal(integrityText({ status: 'passed', issues: 0 }), 'Passed');
|
||||
assert.equal(integrityText({ status: 'issues', issues: 1 }), '1 issue');
|
||||
assert.equal(integrityText({ status: 'issues', issues: 3 }), '3 issues');
|
||||
assert.equal(integrityText({ status: 'incomplete', issues: 0 }), 'Incomplete');
|
||||
// Issues found beside a check that could not run (Needs review, Q6) are still named.
|
||||
assert.equal(integrityText({ status: 'incomplete', issues: 2 }), '2 issues');
|
||||
});
|
||||
|
||||
test('library health rows never invent a review count, a backup or thumbnails', async () => {
|
||||
const { ambiguousTitle, backupText } = await overviewModule;
|
||||
assert.equal(ambiguousTitle({ pending: null }), 'Ambiguous image links');
|
||||
assert.equal(ambiguousTitle(undefined), 'Ambiguous image links');
|
||||
assert.equal(ambiguousTitle({ pending: 1 }), '1 ambiguous image link');
|
||||
assert.equal(ambiguousTitle({ pending: 3 }), '3 ambiguous image links');
|
||||
assert.equal(backupText(null), 'No retained backup record.');
|
||||
assert.equal(backupText({ verified_at: 'not a time' }), 'No retained backup record.');
|
||||
assert.match(backupText({ verified_at: '2026-10-22T09:30:00Z' }), /^Last verified backup .+\.$/);
|
||||
});
|
||||
|
||||
test('the thumbnail row is its own read of the thumbnail status', async () => {
|
||||
const { thumbnailTitle, THUMBNAILS_LOADING } = await overviewModule;
|
||||
// Until `/images/thumbnails/status` answers the row says it is checking; a failed read is unavailable, never 0.
|
||||
assert.equal(THUMBNAILS_LOADING, 'Checking thumbnails…');
|
||||
assert.equal(thumbnailTitle(undefined), THUMBNAILS_LOADING);
|
||||
assert.equal(thumbnailTitle({ pending: 0 }), 'No missing thumbnails');
|
||||
assert.equal(thumbnailTitle({ pending: 1 }), '1 missing thumbnail');
|
||||
assert.equal(thumbnailTitle({ pending: 12 }), '12 missing thumbnails');
|
||||
assert.equal(thumbnailTitle(null), 'Thumbnail status unavailable');
|
||||
assert.equal(thumbnailTitle({ pending: 'x' }), 'Thumbnail status unavailable');
|
||||
// With no image source there is nothing to count.
|
||||
assert.equal(thumbnailTitle({ pending: 0 }, { effective: 0 }), 'Thumbnail status unavailable');
|
||||
assert.equal(thumbnailTitle({ pending: 2 }, { effective: 1 }), '2 missing thumbnails');
|
||||
});
|
||||
|
||||
test('an integrity count SQLite stopped at reads as a lower bound', async () => {
|
||||
const { checkChip } = await overviewModule;
|
||||
assert.equal(checkChip({ id: 'integrity', status: 'issues', count: 100, capped: true }), '100+ problems found');
|
||||
assert.equal(checkChip({ id: 'integrity', status: 'issues', count: 37, capped: false }), '37 problems found');
|
||||
assert.equal(checkChip({ id: 'integrity', status: 'issues', count: 1 }), '1 problem found');
|
||||
});
|
||||
|
||||
test('the sources row tells configured, available and detected sources apart', async () => {
|
||||
const { sourcesCopy } = await overviewModule;
|
||||
const shared = 'Monitoring and enrichment share one source list.';
|
||||
assert.deepEqual(sourcesCopy({ configured: 2, effective: 2, auto_detected: false }), ['2 output sources', shared]);
|
||||
assert.deepEqual(sourcesCopy({ configured: 2, effective: 1, auto_detected: false }),
|
||||
['1 output source', `1 of 2 configured sources available. ${shared}`]);
|
||||
assert.deepEqual(sourcesCopy({ configured: 0, effective: 1, auto_detected: true }),
|
||||
['1 output source', `None configured is available; ComfyUI's output folder is used. ${shared}`]);
|
||||
});
|
||||
|
||||
const REPORT = Object.freeze({
|
||||
checked_at: '2026-10-23T08:00:00Z',
|
||||
status: 'issues',
|
||||
checks: [
|
||||
{ id: 'integrity', label: 'Integrity check', status: 'passed', count: 0 },
|
||||
{ id: 'foreign_keys', label: 'Foreign keys & pair references', status: 'issues', count: 2 },
|
||||
{ id: 'canonical_hashes', label: 'Canonical hashes', status: 'issues', count: 1 },
|
||||
{ id: 'pair_uniqueness', label: 'Pair uniqueness', status: 'failed', count: null },
|
||||
{ id: 'missing_files', label: 'Missing files', status: 'issues', count: 2,
|
||||
sources: [{ name: 'output', status: 'checked', count: 2 }] },
|
||||
{ id: 'ambiguous_metadata', label: 'Ambiguous metadata', status: 'unavailable', count: null },
|
||||
{ id: 'consistency', label: 'Consistency', status: 'issues', count: null },
|
||||
],
|
||||
});
|
||||
|
||||
test('health rows give each check a chip from its real status, in the server order', async () => {
|
||||
const { healthRows } = await overviewModule;
|
||||
assert.deepEqual(healthRows(REPORT), [
|
||||
['Integrity check', 'Passed'],
|
||||
['Foreign keys & pair references', '2 broken references'],
|
||||
['Canonical hashes', '1 hash does not match its text'],
|
||||
['Pair uniqueness', 'Could not check'],
|
||||
['Missing files', '2 links need review'],
|
||||
['Ambiguous metadata', 'Not available yet'],
|
||||
['Consistency', 'Issues found'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('missing files is reported per image source when there are several', async () => {
|
||||
const { healthRows } = await overviewModule;
|
||||
const report = { ...REPORT, checks: [{ id: 'missing_files', label: 'Missing files', status: 'issues', count: 1, partial: true,
|
||||
sources: [{ name: 'output', status: 'checked', count: 1 }, { name: 'input', status: 'checked', count: 0 },
|
||||
{ name: 'archive', status: 'unavailable', count: null }] }] };
|
||||
assert.deepEqual(healthRows(report), [
|
||||
['Missing files · output', '1 link needs review'],
|
||||
['Missing files · input', 'Passed'],
|
||||
['Missing files · archive', 'Not available'],
|
||||
]);
|
||||
const unavailable = { ...REPORT, checks: [{ id: 'missing_files', label: 'Missing files', status: 'unavailable', count: null, sources: [] }] };
|
||||
assert.deepEqual(healthRows(unavailable), [['Missing files', 'Not available']]);
|
||||
});
|
||||
|
||||
test('the report summary says when it was checked and whether it is complete', async () => {
|
||||
const { healthSummary } = await overviewModule;
|
||||
const when = new Date(REPORT.checked_at).toLocaleString();
|
||||
assert.equal(healthSummary({ ...REPORT, status: 'passed' }), `Checked ${when}. Every check passed.`);
|
||||
assert.equal(healthSummary({ ...REPORT, status: 'incomplete' }), `Checked ${when}. Some checks could not run, so this report is incomplete.`);
|
||||
assert.equal(healthSummary({ ...REPORT, checked_at: 'x' }), 'Nothing was changed; review the checks with issues.');
|
||||
});
|
||||
|
||||
test('statistics rows are the real runs and ratings, then the library totals', async () => {
|
||||
const { categoryRows, totalsRows } = await overviewModule;
|
||||
const statistics = {
|
||||
by_category: [{ category: 'Landscapes', runs: 1200, average_rating: 4.5 },
|
||||
{ category: null, runs: 3, average_rating: null }],
|
||||
totals: { total_prompts: 12, total_tags: 15, total_images: 24 },
|
||||
rated_images: 20,
|
||||
};
|
||||
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString(), '4.5 / 5'], ['Uncategorized', '3', 'Not rated']]);
|
||||
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24'], ['Rated images', '20']]);
|
||||
});
|
||||
|
||||
/** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */
|
||||
function fakeApi(answers) {
|
||||
const calls = [];
|
||||
const answer = (method, url) => {
|
||||
calls.push(`${method} ${url}`);
|
||||
const queue = answers[`${method} ${url}`];
|
||||
if (!queue?.length) throw new Error(`unexpected ${method} ${url}`);
|
||||
const next = queue.shift();
|
||||
return next instanceof Error ? Promise.reject(next) : Promise.resolve(next);
|
||||
};
|
||||
return { calls, get: url => answer('GET', url), post: url => answer('POST', url) };
|
||||
}
|
||||
|
||||
const JOB = '0123456789abcdef0123456789abcdef';
|
||||
const job = (state, id = JOB, kind = 'health') => ({ id, kind, state });
|
||||
|
||||
/**
|
||||
* A fake Activity store holding `jobs`: `track(job)` lists a started job, `load()` applies the next of `reads` (the
|
||||
* server's job lists) and `push(job)` is a stream update; each notifies as the real store does. It never polls.
|
||||
*/
|
||||
function fakeActivity({ jobs = [], reads = [] } = {}) {
|
||||
const listeners = new Set();
|
||||
const emit = () => { for (const listener of [...listeners]) listener(); };
|
||||
const store = {
|
||||
loads: 0,
|
||||
find: id => jobs.find(item => item.id === id),
|
||||
running: () => jobs.filter(item => item.state === 'running'),
|
||||
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
||||
listeners: () => listeners.size,
|
||||
async load() {
|
||||
store.loads += 1;
|
||||
if (reads.length) jobs = reads.shift();
|
||||
emit();
|
||||
},
|
||||
track(item) {
|
||||
if (!item?.id) throw new TypeError('Invalid job');
|
||||
store.push(item);
|
||||
return item;
|
||||
},
|
||||
push(item) {
|
||||
jobs = [...jobs.filter(other => other.id !== item.id), item];
|
||||
emit();
|
||||
},
|
||||
};
|
||||
return store;
|
||||
}
|
||||
|
||||
test('a health check tracks its job in the Activity store, then reads the report', async () => {
|
||||
const { runHealthCheck } = await overviewModule;
|
||||
const api = fakeApi({
|
||||
'POST /prompt_manager/system/health': [{ job: job('running') }],
|
||||
'GET /prompt_manager/system/health': [{ report: REPORT }],
|
||||
});
|
||||
const activity = fakeActivity();
|
||||
const checking = runHealthCheck(api, { activity });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
// The store follows the started job's event stream; nothing reads or polls the job here.
|
||||
assert.equal(activity.loads, 0);
|
||||
assert.equal(activity.find(JOB).state, 'running');
|
||||
assert.deepEqual(api.calls, ['POST /prompt_manager/system/health']);
|
||||
activity.push(job('finished'));
|
||||
assert.equal(await checking, REPORT);
|
||||
assert.deepEqual(api.calls, ['POST /prompt_manager/system/health', 'GET /prompt_manager/system/health']);
|
||||
assert.equal(activity.listeners(), 0);
|
||||
});
|
||||
|
||||
test('a check already running elsewhere is waited for, not refused', async () => {
|
||||
const { runHealthCheck } = await overviewModule;
|
||||
const busy = Object.assign(new Error('busy'), { status: 409, code: 'busy' });
|
||||
const api = fakeApi({
|
||||
'POST /prompt_manager/system/health': [busy],
|
||||
'GET /prompt_manager/system/health': [{ report: REPORT }],
|
||||
});
|
||||
const activity = fakeActivity({ reads: [[job('running', 'f'.repeat(32), 'scan'), job('running')]] });
|
||||
const checking = runHealthCheck(api, { activity });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(activity.loads, 1);
|
||||
activity.push(job('finished'));
|
||||
assert.equal(await checking, REPORT);
|
||||
// The other check ended between the refusal and the read: its report is the latest.
|
||||
const ended = fakeApi({ 'POST /prompt_manager/system/health': [busy], 'GET /prompt_manager/system/health': [{ report: REPORT }] });
|
||||
assert.equal(await runHealthCheck(ended, { activity: fakeActivity({ reads: [[]] }) }), REPORT);
|
||||
});
|
||||
|
||||
test('a failed, lost or reportless check rejects; other errors pass through', async () => {
|
||||
const { runHealthCheck, waitForJob, HEALTH_FAILED_TEXT } = await overviewModule;
|
||||
const failed = fakeApi({ 'POST /prompt_manager/system/health': [{ job: job('failed') }] });
|
||||
await assert.rejects(runHealthCheck(failed, { activity: fakeActivity() }), { message: HEALTH_FAILED_TEXT });
|
||||
// The store marks a job lost when the server no longer lists it (ComfyUI restarted).
|
||||
const restarted = fakeActivity({ jobs: [job('running')] });
|
||||
const waiting = waitForJob(restarted, JOB);
|
||||
restarted.push(job('lost'));
|
||||
assert.equal(await waiting, 'lost');
|
||||
const empty = fakeApi({ 'POST /prompt_manager/system/health': [{ job: job('finished') }],
|
||||
'GET /prompt_manager/system/health': [{ report: null }] });
|
||||
await assert.rejects(runHealthCheck(empty, { activity: fakeActivity() }), { message: HEALTH_FAILED_TEXT });
|
||||
const broken = fakeApi({ 'POST /prompt_manager/system/health': [Object.assign(new Error('server'), { status: 500 })] });
|
||||
await assert.rejects(runHealthCheck(broken, { activity: fakeActivity() }), { message: 'server' });
|
||||
const invalid = fakeApi({ 'POST /prompt_manager/system/health': [{ job: null }] });
|
||||
await assert.rejects(runHealthCheck(invalid, { activity: fakeActivity() }), { message: 'Invalid job' });
|
||||
});
|
||||
|
||||
test('waiting stops when its signal aborts', async () => {
|
||||
const { waitForJob } = await overviewModule;
|
||||
const controller = new AbortController();
|
||||
const activity = fakeActivity({ jobs: [job('running')] });
|
||||
const waiting = waitForJob(activity, JOB, { signal: controller.signal });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
controller.abort(new Error('left'));
|
||||
await assert.rejects(waiting, { message: 'left' });
|
||||
assert.equal(activity.listeners(), 0);
|
||||
const gone = new AbortController();
|
||||
gone.abort(new Error('already'));
|
||||
await assert.rejects(waitForJob(fakeActivity(), JOB, { signal: gone.signal }), { message: 'already' });
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
// System draft store (#249 System S2 Task 5): one Save row for browser, host and runtime fields (concept app.js
|
||||
// renderSystem `.save-row` 705, `save-settings` 1320). It saves browser, then host with the GET revision, then runtime;
|
||||
// a domain that fails keeps its fields dirty, and the status copy (D92) says what really happened.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const source = import('../../web/js/workspace/system-state.mjs');
|
||||
|
||||
/** Adapters that record every call; `host` answers like POST /prompt_manager/settings. */
|
||||
function adapters({ browser = {}, host = {}, runtime = {}, revision = 'sha256:one' } = {}) {
|
||||
const calls = [];
|
||||
const outcome = { browser: null, host: null, runtime: null };
|
||||
return {
|
||||
calls,
|
||||
outcome,
|
||||
readBrowser: () => { calls.push(['readBrowser']); return { ...browser }; },
|
||||
readHost: async () => { calls.push(['readHost']); return { values: { ...host }, revision }; },
|
||||
readRuntime: async () => { calls.push(['readRuntime']); return { ...runtime }; },
|
||||
writeBrowser: async changes => {
|
||||
calls.push(['writeBrowser', changes]);
|
||||
if (outcome.browser) return outcome.browser(changes);
|
||||
return { persisted: true };
|
||||
},
|
||||
writeHost: async (changes, options) => {
|
||||
calls.push(['writeHost', changes, options]);
|
||||
if (outcome.host) return outcome.host(changes, options);
|
||||
return { success: true, persisted: true, revision: 'sha256:two', runtime_applied: true };
|
||||
},
|
||||
writeRuntime: async changes => {
|
||||
calls.push(['writeRuntime', changes]);
|
||||
if (outcome.runtime) return outcome.runtime(changes);
|
||||
return { success: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loaded(domains) {
|
||||
const { createSystemDrafts } = await source;
|
||||
const drafts = createSystemDrafts(domains);
|
||||
await Promise.all(['browser', 'host', 'runtime'].map(domain => drafts.load(domain)));
|
||||
domains.calls.length = 0;
|
||||
return drafts;
|
||||
}
|
||||
|
||||
test('sections follow the concept order and the production hashes', async () => {
|
||||
const { SYSTEM_SECTIONS, isSystemSection } = await source;
|
||||
assert.deepEqual(SYSTEM_SECTIONS.map(([id, label]) => [id, label]), [
|
||||
['overview', 'Overview'], ['appearance', 'Appearance'], ['sources', 'Sources & scans'],
|
||||
['integrations', 'Integrations'], ['tagging', 'Auto-tagging'], ['maintenance', 'Maintenance'],
|
||||
['backups', 'Backup & export'], ['diagnostics', 'Diagnostics & logs'],
|
||||
]);
|
||||
assert.equal(isSystemSection('backups'), true);
|
||||
for (const value of ['general', '', 'constructor', '__proto__', undefined]) assert.equal(isSystemSection(value), false, value);
|
||||
});
|
||||
|
||||
test('drafts read baselines and report dirty only for changed values', async () => {
|
||||
const domains = adapters({ browser: { layout: 'grid' }, host: { result_timeout: 30 } });
|
||||
const drafts = await loaded(domains);
|
||||
assert.equal(drafts.get('browser', 'layout'), 'grid');
|
||||
assert.equal(drafts.get('host', 'result_timeout'), 30);
|
||||
assert.equal(drafts.dirty(), false);
|
||||
drafts.set('host', 'result_timeout', 45);
|
||||
assert.equal(drafts.get('host', 'result_timeout'), 45);
|
||||
assert.equal(drafts.dirty(), true);
|
||||
assert.equal(drafts.dirty('host'), true);
|
||||
assert.equal(drafts.dirty('browser'), false);
|
||||
// Setting the saved value back is clean again.
|
||||
drafts.set('host', 'result_timeout', 30);
|
||||
assert.equal(drafts.dirty(), false);
|
||||
assert.throws(() => drafts.set('elsewhere', 'x', 1), TypeError);
|
||||
assert.throws(() => drafts.get('elsewhere', 'x'), TypeError);
|
||||
});
|
||||
|
||||
test('save commits browser then host then runtime', async () => {
|
||||
const domains = adapters({ browser: { layout: 'grid' }, host: { result_timeout: 30 }, runtime: { level: 'INFO' } });
|
||||
const drafts = await loaded(domains);
|
||||
drafts.set('runtime', 'level', 'DEBUG');
|
||||
drafts.set('host', 'result_timeout', 45);
|
||||
drafts.set('browser', 'layout', 'list');
|
||||
const outcome = await drafts.save();
|
||||
assert.deepEqual(outcome, { browser: 'saved', host: 'saved', runtime: 'applied' });
|
||||
assert.deepEqual(domains.calls, [
|
||||
['writeBrowser', { layout: 'list' }],
|
||||
['writeHost', { result_timeout: 45 }, { expected_revision: 'sha256:one' }],
|
||||
['writeRuntime', { level: 'DEBUG' }],
|
||||
]);
|
||||
assert.equal(drafts.dirty(), false);
|
||||
// The next host save sends the revision the last one returned.
|
||||
drafts.set('host', 'result_timeout', 60);
|
||||
await drafts.save();
|
||||
assert.deepEqual(domains.calls.at(-1), ['writeHost', { result_timeout: 60 }, { expected_revision: 'sha256:two' }]);
|
||||
});
|
||||
|
||||
test('host failure keeps host fields dirty and browser saved', async () => {
|
||||
const domains = adapters({ browser: { layout: 'grid' }, host: { result_timeout: 30 } });
|
||||
const drafts = await loaded(domains);
|
||||
domains.outcome.host = () => { throw Object.assign(new Error('Settings could not be written to config.json.'), { code: 'config-write-failed' }); };
|
||||
drafts.set('browser', 'layout', 'list');
|
||||
drafts.set('host', 'result_timeout', 45);
|
||||
const outcome = await drafts.save();
|
||||
assert.deepEqual(outcome, { browser: 'saved', host: 'failed', runtime: 'clean' });
|
||||
assert.equal(drafts.dirty('browser'), false);
|
||||
assert.equal(drafts.dirty('host'), true);
|
||||
assert.equal(drafts.get('host', 'result_timeout'), 45);
|
||||
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
|
||||
assert.deepEqual([...drafts.failed('browser')], []);
|
||||
assert.equal(drafts.statusText(), 'Some changes were not saved. Review the highlighted settings.');
|
||||
// A save answered `persisted: false` without an error is not saved either.
|
||||
domains.outcome.host = () => ({ success: false, persisted: false });
|
||||
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'failed', runtime: 'clean' });
|
||||
// Editing the failed field clears its highlight and reads as unsaved again.
|
||||
drafts.set('host', 'result_timeout', 50);
|
||||
assert.deepEqual([...drafts.failed('host')], []);
|
||||
assert.equal(drafts.statusText(), 'Unsaved changes.');
|
||||
});
|
||||
|
||||
test('stale revision reports conflict and keeps the draft', async () => {
|
||||
const domains = adapters({ host: { result_timeout: 30 } });
|
||||
const drafts = await loaded(domains);
|
||||
domains.outcome.host = () => { throw Object.assign(new Error('Settings changed since they were loaded.'), { code: 'settings-changed' }); };
|
||||
drafts.set('host', 'result_timeout', 45);
|
||||
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'conflict', runtime: 'clean' });
|
||||
assert.equal(drafts.dirty('host'), true);
|
||||
assert.equal(drafts.get('host', 'result_timeout'), 45);
|
||||
assert.equal(drafts.statusText(), 'Settings changed elsewhere. Reload this section to see them.');
|
||||
// The conflict read the host values again (their new revision), keeping the draft.
|
||||
assert.deepEqual(domains.calls.slice(-2).map(([name]) => name), ['writeHost', 'readHost']);
|
||||
// Reloading the section reads the new revision and keeps the draft (Q10).
|
||||
domains.outcome.host = null;
|
||||
await drafts.load('host');
|
||||
assert.equal(drafts.get('host', 'result_timeout'), 45);
|
||||
await drafts.save();
|
||||
assert.deepEqual(domains.calls.at(-1)[2], { expected_revision: 'sha256:one' });
|
||||
});
|
||||
|
||||
test('host save without a read revision never posts', async () => {
|
||||
const { createSystemDrafts } = await source;
|
||||
// Never read: an edit made before the host values (and their revision) arrived must not merge blindly.
|
||||
const unread = adapters({ host: { result_timeout: 30 } });
|
||||
const drafts = createSystemDrafts(unread);
|
||||
drafts.set('host', 'result_timeout', 45);
|
||||
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'failed', runtime: 'clean' });
|
||||
assert.deepEqual(unread.calls, []);
|
||||
assert.equal(drafts.dirty('host'), true);
|
||||
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
|
||||
// A read of an unreadable configuration has no revision either.
|
||||
const unreadable = adapters({ host: { result_timeout: 30 }, revision: null });
|
||||
const blind = createSystemDrafts(unreadable);
|
||||
await blind.load('host');
|
||||
blind.set('host', 'result_timeout', 45);
|
||||
assert.equal((await blind.save()).host, 'failed');
|
||||
assert.equal(unreadable.calls.filter(([name]) => name === 'writeHost').length, 0);
|
||||
// Once read, the same draft saves with the revision.
|
||||
await drafts.load('host');
|
||||
assert.equal((await drafts.save()).host, 'saved');
|
||||
assert.deepEqual(unread.calls.at(-1), ['writeHost', { result_timeout: 45 }, { expected_revision: 'sha256:one' }]);
|
||||
});
|
||||
|
||||
test('reloadHost reads the host values and says whether it could', async () => {
|
||||
const { createSystemDrafts } = await source;
|
||||
const domains = adapters({ host: { result_timeout: 30 } });
|
||||
const drafts = createSystemDrafts(domains);
|
||||
assert.equal(await drafts.reloadHost(), true);
|
||||
assert.equal(drafts.get('host', 'result_timeout'), 30);
|
||||
const broken = createSystemDrafts({ ...domains, readHost: async () => { throw new Error('offline'); } });
|
||||
assert.equal(await broken.reloadHost(), false);
|
||||
broken.set('host', 'result_timeout', 45);
|
||||
assert.equal((await broken.save()).host, 'failed');
|
||||
});
|
||||
|
||||
test('no domain called when clean', async () => {
|
||||
const domains = adapters({ browser: { layout: 'grid' } });
|
||||
const drafts = await loaded(domains);
|
||||
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'clean', runtime: 'clean' });
|
||||
assert.deepEqual(domains.calls, []);
|
||||
drafts.set('browser', 'layout', 'list');
|
||||
drafts.discard();
|
||||
assert.equal(drafts.dirty(), false);
|
||||
assert.equal(drafts.get('browser', 'layout'), 'grid');
|
||||
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'clean', runtime: 'clean' });
|
||||
assert.deepEqual(domains.calls, []);
|
||||
});
|
||||
|
||||
test('status text per outcome', async () => {
|
||||
const { statusText, SETTINGS_STATUS } = await source;
|
||||
assert.deepEqual(SETTINGS_STATUS, {
|
||||
clean: 'No unsaved changes.',
|
||||
dirty: 'Unsaved changes.',
|
||||
browser: 'Saved in this browser.',
|
||||
host: 'Saved to the server configuration.',
|
||||
runtime: 'Applied until the server restarts.',
|
||||
partial: 'Some changes were not saved. Review the highlighted settings.',
|
||||
tabOnly: 'Saved for this tab only; browser storage is unavailable.',
|
||||
conflict: 'Settings changed elsewhere. Reload this section to see them.',
|
||||
});
|
||||
const clean = { browser: 'clean', host: 'clean', runtime: 'clean' };
|
||||
assert.equal(statusText(clean), 'No unsaved changes.');
|
||||
assert.equal(statusText({ ...clean, browser: 'saved' }), 'Saved in this browser.');
|
||||
assert.equal(statusText({ ...clean, host: 'saved' }), 'Saved to the server configuration.');
|
||||
assert.equal(statusText({ ...clean, runtime: 'applied' }), 'Applied until the server restarts.');
|
||||
assert.equal(statusText({ ...clean, browser: 'tab-only' }), 'Saved for this tab only; browser storage is unavailable.');
|
||||
assert.equal(statusText({ browser: 'saved', host: 'saved', runtime: 'applied' }),
|
||||
'Saved in this browser. Saved to the server configuration. Applied until the server restarts.');
|
||||
assert.equal(statusText({ ...clean, browser: 'saved', runtime: 'failed' }),
|
||||
'Some changes were not saved. Review the highlighted settings.');
|
||||
assert.equal(statusText({ browser: 'failed', host: 'conflict', runtime: 'clean' }),
|
||||
'Settings changed elsewhere. Reload this section to see them.');
|
||||
});
|
||||
|
||||
test('the store says unsaved, then the save outcome, and notifies subscribers', async () => {
|
||||
const domains = adapters({ browser: { layout: 'grid' } });
|
||||
const drafts = await loaded(domains);
|
||||
const seen = [];
|
||||
const unsubscribe = drafts.subscribe(() => seen.push(drafts.statusText()));
|
||||
assert.equal(drafts.statusText(), 'No unsaved changes.');
|
||||
drafts.set('browser', 'layout', 'list');
|
||||
domains.outcome.browser = () => ({ persisted: false });
|
||||
await drafts.save();
|
||||
assert.deepEqual(seen, ['Unsaved changes.', 'Saved for this tab only; browser storage is unavailable.']);
|
||||
unsubscribe();
|
||||
drafts.set('browser', 'layout', 'masonry');
|
||||
assert.equal(seen.length, 2);
|
||||
// The tab-only value is this tab's baseline now.
|
||||
drafts.set('browser', 'layout', 'list');
|
||||
assert.equal(drafts.dirty(), false);
|
||||
});
|
||||
|
||||
test('an edit made while a save runs stays dirty', async () => {
|
||||
const domains = adapters({ browser: { layout: 'grid' } });
|
||||
const drafts = await loaded(domains);
|
||||
let release;
|
||||
domains.outcome.browser = () => new Promise(resolve => { release = () => resolve({ persisted: true }); });
|
||||
drafts.set('browser', 'layout', 'list');
|
||||
const saving = drafts.save();
|
||||
// A second Save while one runs joins it instead of writing twice.
|
||||
assert.equal(drafts.save(), saving);
|
||||
assert.equal(drafts.saving(), true);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
drafts.set('browser', 'layout', 'masonry');
|
||||
release();
|
||||
assert.deepEqual(await saving, { browser: 'saved', host: 'clean', runtime: 'clean' });
|
||||
assert.equal(drafts.saving(), false);
|
||||
assert.equal(drafts.dirty('browser'), true);
|
||||
assert.equal(drafts.get('browser', 'layout'), 'masonry');
|
||||
assert.equal(domains.calls.filter(([name]) => name === 'writeBrowser').length, 1);
|
||||
});
|
||||
|
||||
test('a failed read keeps the store usable and drafts survive a reload (Q10)', async () => {
|
||||
const { createSystemDrafts } = await source;
|
||||
const domains = adapters({ browser: { layout: 'grid' } });
|
||||
const drafts = createSystemDrafts(domains);
|
||||
await drafts.load('browser');
|
||||
drafts.set('browser', 'layout', 'list');
|
||||
await drafts.load('browser');
|
||||
assert.equal(drafts.get('browser', 'layout'), 'list');
|
||||
assert.equal(drafts.dirty(), true);
|
||||
const failing = createSystemDrafts({ ...domains, readHost: async () => { throw new Error('offline'); } });
|
||||
await assert.rejects(failing.load('host'), /offline/);
|
||||
assert.equal(failing.dirty(), false);
|
||||
assert.throws(() => createSystemDrafts({ ...domains, writeHost: null }), TypeError);
|
||||
});
|
||||
@@ -76,6 +76,7 @@ function fakeDOM() {
|
||||
get activeElement() { return active ?? body; },
|
||||
createElement: tag => new Element(tag),
|
||||
createElementNS: (namespace, tag) => new Element(tag),
|
||||
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
|
||||
};
|
||||
const all = (root, predicate, found = []) => {
|
||||
if (predicate(root)) found.push(root);
|
||||
@@ -790,3 +791,145 @@ test('the Filters button shows the active refinement count', async () => {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// #249 System S2 Task 5: the scan review's dialog and backup box (D55) generalised for every record-changing review.
|
||||
const scanModule = import('../../web/js/workspace/activity-scan.mjs');
|
||||
const bodyShape = dom => byId(dom, 'modal-body').children.map(node => [node.tagName.toLowerCase(), node.className].filter(Boolean).join('.'));
|
||||
|
||||
test('backupNotice is the concept .notice with its copy and one Back up now action', async () => {
|
||||
const { backupNotice, SCAN_BACKUP_COPY } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
try {
|
||||
let calls = 0;
|
||||
const notice = backupNotice({ copy: SCAN_BACKUP_COPY, onBackup: () => { calls++; } });
|
||||
assert.equal(notice.className, 'notice');
|
||||
assert.deepEqual(tags(notice), ['div', 'div']);
|
||||
assert.deepEqual(tags(notice.children[0]), ['h3', 'p']);
|
||||
assert.equal(notice.children[0].children[0].textContent, 'Back up the database first (recommended)');
|
||||
assert.equal(notice.children[0].children[1].textContent,
|
||||
'Downloads a copy of the prompts database before the scan changes any records.');
|
||||
assert.equal(notice.children[1].className, 'row-actions');
|
||||
const action = button(notice, 'Back up now');
|
||||
action.click();
|
||||
assert.equal(calls, 1);
|
||||
assert.throws(() => backupNotice({ copy: { heading: '', text: 'x' }, onBackup() {} }), TypeError);
|
||||
assert.throws(() => backupNotice({ copy: SCAN_BACKUP_COPY }), TypeError);
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('openReviewDialog lays out heading, grid, options, sentence, backup box and actions', async () => {
|
||||
const { openReviewDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const trigger = new dom.Element('button');
|
||||
dom.body.append(trigger);
|
||||
const option = new dom.Element('div');
|
||||
option.className = 'setting-row';
|
||||
const started = [];
|
||||
const review = openReviewDialog({ scope, title: 'Review maintenance plan', heading: 'Missing-file links',
|
||||
grid: [['Scope', '2 stale image links'], ['Media on disk', 'No source files removed']], options: [option],
|
||||
sentence: 'Cancelling leaves all data untouched.', backup: { copy: { heading: 'Back up first', text: 'A copy.' }, onBackup() {} },
|
||||
cancelLabel: 'Cancel', primary: { label: 'Apply reviewed operation' }, onStart: () => started.push('start'), trigger });
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Review maintenance plan');
|
||||
assert.deepEqual(bodyShape(dom), ['h3', 'dl.meta-grid', 'div.setting-row', 'p.small', 'div.notice', 'div.dialog-actions']);
|
||||
const grid = byId(dom, 'modal-body').children[1];
|
||||
assert.deepEqual(grid.children.map(entry => [tags(entry), entry.children[0].textContent, entry.children[1].textContent]), [
|
||||
[['dt', 'dd'], 'Scope', '2 stale image links'], [['dt', 'dd'], 'Media on disk', 'No source files removed']]);
|
||||
review.setValue('Scope', '3 stale image links');
|
||||
assert.equal(grid.children[0].children[1].textContent, '3 stale image links');
|
||||
const actions = byId(dom, 'modal-body').children.at(-1);
|
||||
assert.deepEqual(actions.children.map(node => [node.textContent, node.className]),
|
||||
[['Cancel', ''], ['Apply reviewed operation', 'ca-primary']]);
|
||||
actions.children[1].click();
|
||||
assert.deepEqual(started, ['start']);
|
||||
assert.equal(modal(dom).open, false);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
|
||||
// A danger primary (the concept's Restore) has no sentence, heading, options or backup box when none is given.
|
||||
openReviewDialog({ scope, title: 'Restore preview', grid: [['Integrity', 'Verified']], backup: null,
|
||||
cancelLabel: 'Cancel', primary: { label: 'Restore', danger: true }, onStart() {} });
|
||||
assert.deepEqual(bodyShape(dom), ['dl.meta-grid', 'div.dialog-actions']);
|
||||
assert.equal(button(byId(dom, 'modal-body'), 'Restore').className, 'danger');
|
||||
button(byId(dom, 'modal-body'), 'Cancel').click();
|
||||
assert.equal(modal(dom).open, false);
|
||||
assert.throws(() => openReviewDialog({ scope, title: 'x', grid: [['a']], cancelLabel: 'Cancel', primary: { label: 'Go' },
|
||||
onStart() {} }), TypeError);
|
||||
assert.throws(() => openReviewDialog({ scope, title: 'x', grid: [], cancelLabel: 'Cancel', primary: {}, onStart() {} }), TypeError);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
closeWorkspaceDialog();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('review dialog backup box keeps the dialog open and toasts the outcome', async () => {
|
||||
const { openReviewDialog, closeWorkspaceDialog, SCAN_BACKUP_COPY } = await dialogsModule;
|
||||
const { downloadDatabaseBackup, BACKUP_DONE, BACKUP_FAILED } = await scanModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
const created = URL.createObjectURL;
|
||||
const revoked = URL.revokeObjectURL;
|
||||
URL.createObjectURL = () => 'blob:backup';
|
||||
URL.revokeObjectURL = () => {};
|
||||
try {
|
||||
const toasts = [];
|
||||
let answer;
|
||||
const fetchUrl = () => new Promise(resolve => { answer = resolve; });
|
||||
openReviewDialog({ scope, title: 'Review metadata scan', grid: [['Sources', '2 output folders']],
|
||||
backup: { copy: SCAN_BACKUP_COPY, onBackup: () => downloadDatabaseBackup({ fetchUrl, notify: text => toasts.push(text) }) },
|
||||
cancelLabel: 'Cancel', primary: { label: 'Start scan' }, onStart() { throw new Error('not started'); } });
|
||||
const action = button(byId(dom, 'modal-body'), 'Back up now');
|
||||
action.click();
|
||||
assert.equal(action.textContent, 'Backing up…');
|
||||
assert.equal(action.getAttribute('aria-disabled'), 'true');
|
||||
// A second press while the copy downloads is ignored.
|
||||
action.click();
|
||||
answer({ ok: true, blob: async () => new Blob(['db']), headers: { get: () => 'attachment; filename="prompts_backup_1.db"' } });
|
||||
await tick();
|
||||
assert.deepEqual(toasts, [BACKUP_DONE]);
|
||||
assert.equal(modal(dom).open, true);
|
||||
assert.equal(action.textContent, 'Back up now');
|
||||
assert.equal(action.getAttribute('aria-disabled'), null);
|
||||
action.click();
|
||||
answer({ ok: false, status: 500 });
|
||||
await tick();
|
||||
assert.deepEqual(toasts, [BACKUP_DONE, BACKUP_FAILED]);
|
||||
assert.equal(modal(dom).open, true);
|
||||
} finally {
|
||||
URL.createObjectURL = created;
|
||||
URL.revokeObjectURL = revoked;
|
||||
scope.dispose();
|
||||
closeWorkspaceDialog();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the scan review is a thin openReviewDialog caller with its scope switch', async () => {
|
||||
const { openScanReviewDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const starts = [];
|
||||
openScanReviewDialog({ scope, sources: 2, onBackup() {}, onStart: value => starts.push(value) });
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Review metadata scan');
|
||||
assert.deepEqual(bodyShape(dom), ['dl.meta-grid', 'div.setting-row', 'p.small', 'div.notice', 'div.dialog-actions']);
|
||||
const grid = byId(dom, 'modal-body').children[0];
|
||||
assert.deepEqual(grid.children.map(entry => entry.textContent), ['Sources2 output folders', 'ScopeNew and unresolved files']);
|
||||
const allFiles = byId(dom, 'scan-all-files');
|
||||
allFiles.checked = true;
|
||||
allFiles.dispatchEvent(new Event('change'));
|
||||
assert.equal(grid.children[1].children[1].textContent, 'All files');
|
||||
button(byId(dom, 'modal-body'), 'Start scan').click();
|
||||
assert.deepEqual(starts, [{ scope: 'all' }]);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
closeWorkspaceDialog();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
"""The read-only health check (System › Overview and Maintenance).
|
||||
|
||||
``run_health_checks`` reads one snapshot on its own ``mode=ro`` connection:
|
||||
SQLite's integrity and foreign-key checks, stored prompt hashes against
|
||||
``utils.hashing``, duplicate pair rows, the legacy consistency queries, image
|
||||
links whose file is gone (only under readable sources) and the Needs-review
|
||||
count when one is supplied. It never writes, and it never turns a check it
|
||||
could not run into a count.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import ntpath
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database import health # noqa: E402
|
||||
from database.operations import PromptDatabase # noqa: E402
|
||||
from utils.hashing import generate_prompt_hash # noqa: E402
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed # noqa: E402
|
||||
|
||||
CHECK_IDS = [
|
||||
"integrity",
|
||||
"foreign_keys",
|
||||
"canonical_hashes",
|
||||
"pair_uniqueness",
|
||||
"missing_files",
|
||||
"ambiguous_metadata",
|
||||
"consistency",
|
||||
]
|
||||
|
||||
|
||||
def _digest(path):
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path, "rb") as handle:
|
||||
return hashlib.sha256(handle.read()).hexdigest()
|
||||
|
||||
|
||||
class HealthTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(os.path.realpath(self.temp.name))
|
||||
self.output = self.root / "output"
|
||||
self.output.mkdir()
|
||||
self.db_path = str(self.root / "prompts.db")
|
||||
self.db = PromptDatabase(self.db_path)
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
assert_closed(self, self.root)
|
||||
self.temp.cleanup()
|
||||
|
||||
def save(self, text, **metadata):
|
||||
return self.db.save_prompt(
|
||||
text, prompt_hash=generate_prompt_hash(text), **metadata
|
||||
)
|
||||
|
||||
def image(self, prompt_id, name, *, exists=True, folder=None):
|
||||
folder = Path(folder or self.output)
|
||||
path = folder / name
|
||||
if exists:
|
||||
path.write_bytes(b"png")
|
||||
return self.db.link_image_to_prompt(prompt_id, str(path))
|
||||
|
||||
def run_checks(self, roots=None, **kwargs):
|
||||
roots = [str(self.output)] if roots is None else roots
|
||||
return self.db.run_health_checks(roots, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def check(report, check_id):
|
||||
return next(c for c in report["checks"] if c["id"] == check_id)
|
||||
|
||||
def raw(self):
|
||||
"""A plain connection for planting damage the app itself never writes."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute("PRAGMA foreign_keys=OFF")
|
||||
return conn
|
||||
|
||||
|
||||
class TestHealthReport(HealthTestCase):
|
||||
def test_clean_library_passes_except_unavailable_review(self):
|
||||
prompt = self.save("A clean prompt")
|
||||
self.db.save_prompt_pair("Cat", "Blur")
|
||||
self.image(prompt, "a.png")
|
||||
report = self.run_checks(review_count=0)
|
||||
self.assertTrue(report["success"])
|
||||
self.assertEqual([c["id"] for c in report["checks"]], CHECK_IDS)
|
||||
self.assertEqual(
|
||||
[c["label"] for c in report["checks"]],
|
||||
[
|
||||
"Integrity check",
|
||||
"Foreign keys & pair references",
|
||||
"Canonical hashes",
|
||||
"Pair uniqueness",
|
||||
"Missing files",
|
||||
"Ambiguous metadata",
|
||||
"Consistency",
|
||||
],
|
||||
)
|
||||
for check in report["checks"]:
|
||||
self.assertEqual(check["status"], "passed", check)
|
||||
self.assertEqual(check["count"], 0, check)
|
||||
self.assertEqual(report["status"], "passed")
|
||||
self.assertRegex(report["checked_at"], r"^\d{4}-\d\d-\d\dT.*\+00:00$")
|
||||
|
||||
def test_health_check_never_writes(self):
|
||||
prompt = self.save("Untouched")
|
||||
self.image(prompt, "kept.png")
|
||||
self.image(prompt, "gone.png", exists=False)
|
||||
with self.raw() as conn:
|
||||
conn.execute("UPDATE prompts SET hash='stale' WHERE id=?", (prompt,))
|
||||
conn.close()
|
||||
# The app's own connection stays open, as in the running server.
|
||||
self.db.get_statistics()
|
||||
files = [self.db_path, self.db_path + "-wal"]
|
||||
before = [_digest(path) for path in files]
|
||||
shm_size = os.path.getsize(self.db_path + "-shm")
|
||||
media = sorted(os.listdir(self.output))
|
||||
opened = []
|
||||
real_connect = sqlite3.connect
|
||||
|
||||
class Recording(sqlite3.Connection):
|
||||
changes_at_close = None
|
||||
|
||||
def close(self):
|
||||
Recording.changes_at_close = self.total_changes
|
||||
super().close()
|
||||
|
||||
def connect(*args, **kwargs):
|
||||
conn = real_connect(*args, factory=Recording, **kwargs)
|
||||
opened.append((args, kwargs, conn))
|
||||
return conn
|
||||
|
||||
with patch.object(health.sqlite3, "connect", side_effect=connect):
|
||||
report = self.run_checks()
|
||||
self.assertEqual(report["status"], "incomplete")
|
||||
self.assertEqual(len(opened), 1)
|
||||
args, kwargs, conn = opened[0]
|
||||
self.assertTrue(str(args[0]).endswith("?mode=ro"), args)
|
||||
self.assertTrue(kwargs.get("uri"))
|
||||
self.assertEqual(Recording.changes_at_close, 0)
|
||||
with self.assertRaises(sqlite3.ProgrammingError): # closed in finally
|
||||
conn.execute("SELECT 1")
|
||||
self.assertEqual([_digest(path) for path in files], before)
|
||||
# The -shm holds WAL read marks every reader updates; it never grows.
|
||||
self.assertEqual(os.path.getsize(self.db_path + "-shm"), shm_size)
|
||||
self.assertEqual(sorted(os.listdir(self.output)), media)
|
||||
with self.db.model.get_connection() as live:
|
||||
stored = live.execute(
|
||||
"SELECT hash FROM prompts WHERE id=?", (prompt,)
|
||||
).fetchone()[0]
|
||||
self.assertEqual(stored, "stale")
|
||||
|
||||
def test_connection_closed_when_a_check_raises(self):
|
||||
self.save("Any")
|
||||
with (
|
||||
patch.object(
|
||||
health,
|
||||
"_canonical_hashes",
|
||||
side_effect=sqlite3.OperationalError("boom"),
|
||||
),
|
||||
self.assertLogs("prompt_manager.database.health", "ERROR"),
|
||||
):
|
||||
report = self.run_checks(review_count=0)
|
||||
canonical = self.check(report, "canonical_hashes")
|
||||
self.assertEqual(canonical["status"], "failed")
|
||||
self.assertIsNone(canonical["count"])
|
||||
self.assertEqual(canonical["detail"], ["The check could not finish."])
|
||||
self.assertEqual(self.check(report, "integrity")["status"], "passed")
|
||||
self.assertEqual(report["status"], "incomplete")
|
||||
self.db.close_all()
|
||||
assert_closed(self, self.root)
|
||||
|
||||
def test_unopenable_database_fails_every_database_check(self):
|
||||
missing = PromptDatabase.__new__(PromptDatabase)
|
||||
missing.model = type("M", (), {"db_path": str(self.root / "nope.db")})()
|
||||
with self.assertLogs("prompt_manager.database.health", "ERROR"):
|
||||
report = missing.run_health_checks([str(self.output)], review_count=0)
|
||||
statuses = {c["id"]: c["status"] for c in report["checks"]}
|
||||
self.assertEqual(statuses["integrity"], "failed")
|
||||
self.assertEqual(statuses["missing_files"], "failed")
|
||||
self.assertEqual(statuses["ambiguous_metadata"], "passed")
|
||||
self.assertEqual(report["status"], "incomplete")
|
||||
self.assertFalse(os.path.exists(self.root / "nope.db"))
|
||||
|
||||
def test_integrity_and_foreign_key_issues_reported(self):
|
||||
prompt = self.save("Linked")
|
||||
with self.raw() as conn:
|
||||
conn.execute("CREATE TABLE probe(a)")
|
||||
conn.execute("CREATE INDEX probe_a ON probe(a)")
|
||||
conn.executemany("INSERT INTO probe VALUES(?)", [(i,) for i in range(16)])
|
||||
conn.execute(
|
||||
"INSERT INTO generated_images(prompt_id,image_path,filename,file_path)"
|
||||
" VALUES(?,?,?,?)",
|
||||
(9999, str(self.output / "orphan.png"), "orphan.png", "orphan"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_tags(prompt_id,tag_id) VALUES(?,?)", (prompt, 777)
|
||||
)
|
||||
conn.close()
|
||||
with self.raw() as conn:
|
||||
conn.execute("PRAGMA writable_schema=ON")
|
||||
conn.execute(
|
||||
"UPDATE sqlite_master SET sql='CREATE INDEX probe_a ON probe(a DESC)'"
|
||||
" WHERE name='probe_a'"
|
||||
)
|
||||
conn.close()
|
||||
self.db.close_all() # the next read sees the edited schema
|
||||
(self.output / "orphan.png").write_bytes(b"png")
|
||||
with self.raw() as conn:
|
||||
expected = [r[0] for r in conn.execute("PRAGMA integrity_check(100)")]
|
||||
conn.close()
|
||||
self.assertGreater(len(expected), 10)
|
||||
report = self.run_checks(review_count=0)
|
||||
integrity = self.check(report, "integrity")
|
||||
self.assertEqual(integrity["status"], "issues")
|
||||
self.assertEqual(integrity["count"], len(expected))
|
||||
self.assertEqual(integrity["detail"], expected[:10])
|
||||
self.assertIn("missing from index probe_a", integrity["detail"][0])
|
||||
foreign = self.check(report, "foreign_keys")
|
||||
self.assertEqual(foreign["status"], "issues")
|
||||
self.assertEqual(foreign["count"], 2)
|
||||
self.assertEqual(
|
||||
sorted(foreign["detail"]),
|
||||
[
|
||||
"generated_images row 1 references a missing prompts row",
|
||||
"prompt_tags row 1 references a missing tags row",
|
||||
],
|
||||
)
|
||||
consistency = self.check(report, "consistency")
|
||||
self.assertEqual(consistency["status"], "issues")
|
||||
self.assertEqual(consistency["count"], 1)
|
||||
self.assertEqual(
|
||||
consistency["detail"], ["Image 1 references non-existent prompt 9999"]
|
||||
)
|
||||
self.assertEqual(report["status"], "issues")
|
||||
|
||||
def test_canonical_hash_mismatch_counts_rows(self):
|
||||
ids = [self.save(f"Prompt {n}") for n in range(14)]
|
||||
with self.raw() as conn:
|
||||
for n, prompt_id in enumerate(ids[:12]):
|
||||
conn.execute(
|
||||
"UPDATE prompts SET hash=? WHERE id=?", (f"bad{n}", prompt_id)
|
||||
)
|
||||
conn.execute("UPDATE prompts SET hash=NULL WHERE id=?", (ids[12],))
|
||||
conn.close()
|
||||
# Normalised spellings hash alike: this one is not a mismatch.
|
||||
self.save(" MIXED Case ")
|
||||
report = self.run_checks(review_count=0)
|
||||
canonical = self.check(report, "canonical_hashes")
|
||||
self.assertEqual(canonical["status"], "issues")
|
||||
self.assertEqual(canonical["count"], 13)
|
||||
self.assertEqual(len(canonical["detail"]), 10)
|
||||
self.assertEqual(
|
||||
canonical["detail"][0],
|
||||
f"Prompt {ids[0]}: the stored hash does not match its text",
|
||||
)
|
||||
|
||||
def test_duplicate_pair_rows_reported(self):
|
||||
pair = self.db.save_prompt_pair("Cat", "")
|
||||
positive = self.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
self.db.save_prompt_pair("Dog", "Blur")
|
||||
with self.raw() as conn:
|
||||
# Only the partial index keeps positive-only pairs unique; a restored
|
||||
# or hand-edited database may have lost it.
|
||||
conn.execute("DROP INDEX idx_prompt_pairs_positive_only")
|
||||
conn.executemany(
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id)"
|
||||
" VALUES(?,NULL)",
|
||||
[(positive,), (positive,)],
|
||||
)
|
||||
conn.close()
|
||||
report = self.run_checks(review_count=0)
|
||||
pairs = self.check(report, "pair_uniqueness")
|
||||
self.assertEqual(pairs["status"], "issues")
|
||||
self.assertEqual(pairs["count"], 2)
|
||||
self.assertEqual(
|
||||
pairs["detail"],
|
||||
[f"Positive prompt {positive} with no negative: 3 pair rows"],
|
||||
)
|
||||
|
||||
def test_integrity_count_at_the_limit_is_flagged_capped(self):
|
||||
class Rows:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
def execute(self, _sql):
|
||||
return iter([(row,) for row in self.rows])
|
||||
|
||||
capped = health._integrity(Rows(["bad"] * health.INTEGRITY_LIMIT), [])
|
||||
self.assertEqual(capped["status"], "issues")
|
||||
self.assertEqual(capped["count"], health.INTEGRITY_LIMIT)
|
||||
self.assertTrue(capped["capped"])
|
||||
exact = health._integrity(Rows(["bad"] * 3), [])
|
||||
self.assertEqual(exact["count"], 3)
|
||||
self.assertFalse(exact["capped"])
|
||||
self.assertFalse(health._integrity(Rows(["ok"]), [])["capped"])
|
||||
|
||||
def test_an_unexpected_error_fails_only_its_own_check(self):
|
||||
self.save("Any")
|
||||
with (
|
||||
patch.object(health, "_pair_uniqueness", side_effect=KeyError("boom")),
|
||||
self.assertLogs("prompt_manager.database.health", "ERROR"),
|
||||
):
|
||||
report = self.run_checks(review_count=0)
|
||||
statuses = {c["id"]: c["status"] for c in report["checks"]}
|
||||
self.assertEqual(statuses.pop("pair_uniqueness"), "failed")
|
||||
self.assertEqual(set(statuses.values()), {"passed"})
|
||||
|
||||
|
||||
class TestDatabaseUri(unittest.TestCase):
|
||||
"""The ``mode=ro`` URI of the database file, for every path form SQLite opens."""
|
||||
|
||||
def test_posix_path_is_a_local_file_uri(self):
|
||||
self.assertEqual(
|
||||
health.readonly_uri("/data/my db#1?.db", windows=False),
|
||||
"file:///data/my%20db%231%3F.db?mode=ro",
|
||||
)
|
||||
|
||||
def test_windows_drive_path(self):
|
||||
self.assertEqual(
|
||||
health.readonly_uri(r"C:\Users\Me\prompts.db", windows=True),
|
||||
"file:///C:/Users/Me/prompts.db?mode=ro",
|
||||
)
|
||||
|
||||
def test_windows_unc_path_has_an_empty_authority(self):
|
||||
# pathlib spells \\server\share as file://server/share, which SQLite
|
||||
# rejects (a non-local authority); an empty one keeps the UNC path.
|
||||
uri = health.readonly_uri(r"\\server\share\Comfy UI\prompts.db", windows=True)
|
||||
self.assertEqual(uri, "file:////server/share/Comfy%20UI/prompts.db?mode=ro")
|
||||
self.assertTrue(ntpath.isabs(r"\\server\share\Comfy UI\prompts.db"))
|
||||
|
||||
def test_uri_opens_a_path_with_uri_characters(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
# "?" is not a valid Windows file name character; "#", "%" and spaces are.
|
||||
name = "a #b%20 c.db" if os.name == "nt" else "a #b?c%20.db"
|
||||
path = os.path.join(os.path.realpath(temp), name)
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
conn.execute("CREATE TABLE t(a)")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
ro = sqlite3.connect(health.readonly_uri(path), uri=True)
|
||||
try:
|
||||
self.assertEqual(ro.execute("SELECT COUNT(*) FROM t").fetchone(), (0,))
|
||||
with self.assertRaises(sqlite3.OperationalError):
|
||||
ro.execute("INSERT INTO t VALUES(1)")
|
||||
finally:
|
||||
ro.close()
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "a native UNC path exists on Windows only")
|
||||
def test_native_unc_path_opens(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = os.path.join(os.path.realpath(temp), "prompts.db")
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
conn.execute("CREATE TABLE t(a)")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
drive, rest = ntpath.splitdrive(path)
|
||||
unc = "\\\\localhost\\" + drive[0] + "$" + rest
|
||||
if not os.path.exists(unc):
|
||||
self.skipTest("the administrative share is not reachable here")
|
||||
ro = sqlite3.connect(health.readonly_uri(unc), uri=True)
|
||||
try:
|
||||
self.assertEqual(ro.execute("SELECT COUNT(*) FROM t").fetchone(), (0,))
|
||||
finally:
|
||||
ro.close()
|
||||
|
||||
|
||||
class TestMissingFiles(HealthTestCase):
|
||||
def test_missing_files_counted_under_readable_sources_only(self):
|
||||
prompt = self.save("Images")
|
||||
self.image(prompt, "here.png")
|
||||
for n in range(12):
|
||||
self.image(prompt, f"gone-{n}.png", exists=False)
|
||||
elsewhere = self.root / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
self.image(prompt, "outside.png", exists=False, folder=elsewhere)
|
||||
report = self.run_checks(review_count=0)
|
||||
missing = self.check(report, "missing_files")
|
||||
self.assertEqual(missing["status"], "issues")
|
||||
self.assertEqual(missing["count"], 12)
|
||||
self.assertEqual(len(missing["detail"]), 10)
|
||||
self.assertRegex(missing["detail"][0], r"^Image \d+: gone-0\.png is missing$")
|
||||
self.assertNotIn(str(self.root), " ".join(missing["detail"]))
|
||||
with self.db.model.get_connection() as conn:
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT COUNT(*) FROM generated_images").fetchone()[0],
|
||||
14,
|
||||
)
|
||||
|
||||
def test_links_outside_every_source_are_named_not_counted(self):
|
||||
prompt = self.save("Images")
|
||||
elsewhere = self.root / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
self.image(prompt, "outside.png", exists=False, folder=elsewhere)
|
||||
missing = self.check(self.run_checks(review_count=0), "missing_files")
|
||||
self.assertEqual(missing["status"], "passed")
|
||||
self.assertEqual(missing["count"], 0)
|
||||
self.assertEqual(
|
||||
missing["detail"],
|
||||
["1 link outside the image sources was not checked."],
|
||||
)
|
||||
|
||||
def test_missing_file_vs_unavailable_root(self):
|
||||
# Per source (S2 ruling): the readable source is counted, the absent
|
||||
# one reports "unavailable" with no count, and the report is incomplete.
|
||||
prompt = self.save("Images")
|
||||
absent = self.root / "unmounted"
|
||||
absent.mkdir()
|
||||
self.image(prompt, "on-nas.png", folder=absent)
|
||||
self.image(prompt, "gone.png", exists=False)
|
||||
os.unlink(absent / "on-nas.png")
|
||||
absent.rmdir()
|
||||
report = self.run_checks([str(self.output), str(absent)], review_count=0)
|
||||
missing = self.check(report, "missing_files")
|
||||
self.assertEqual(missing["status"], "issues")
|
||||
self.assertEqual(missing["count"], 1)
|
||||
self.assertTrue(missing["partial"])
|
||||
self.assertEqual(
|
||||
missing["sources"],
|
||||
[
|
||||
{"name": "output", "status": "checked", "count": 1},
|
||||
{"name": "unmounted", "status": "unavailable", "count": None},
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
missing["detail"][0],
|
||||
"1 image source is unavailable, so its files were not counted.",
|
||||
)
|
||||
self.assertRegex(missing["detail"][1], r"^Image \d+: gone\.png is missing$")
|
||||
# The link under the unavailable source is neither counted nor "outside".
|
||||
self.assertEqual(len(missing["detail"]), 2)
|
||||
self.assertNotIn(str(self.root), " ".join(missing["detail"]))
|
||||
self.assertEqual(report["status"], "incomplete")
|
||||
|
||||
def test_partial_sources_with_no_missing_file_are_not_passed(self):
|
||||
prompt = self.save("Images")
|
||||
self.image(prompt, "here.png")
|
||||
absent = self.root / "unmounted"
|
||||
missing = self.check(
|
||||
self.run_checks([str(self.output), str(absent)], review_count=0),
|
||||
"missing_files",
|
||||
)
|
||||
self.assertEqual(missing["status"], "unavailable")
|
||||
self.assertEqual(missing["count"], 0)
|
||||
self.assertTrue(missing["partial"])
|
||||
self.assertEqual(
|
||||
[s["status"] for s in missing["sources"]], ["checked", "unavailable"]
|
||||
)
|
||||
|
||||
def test_every_source_unavailable_counts_nothing(self):
|
||||
prompt = self.save("Images")
|
||||
absent = self.root / "unmounted"
|
||||
self.image(prompt, "gone.png", exists=False, folder=self.output)
|
||||
report = self.run_checks([str(absent)], review_count=0)
|
||||
missing = self.check(report, "missing_files")
|
||||
self.assertEqual(missing["status"], "unavailable")
|
||||
self.assertIsNone(missing["count"])
|
||||
self.assertFalse(missing["partial"])
|
||||
self.assertEqual(
|
||||
missing["sources"],
|
||||
[{"name": "unmounted", "status": "unavailable", "count": None}],
|
||||
)
|
||||
self.assertEqual(
|
||||
missing["detail"],
|
||||
["1 image source is unavailable, so missing files were not counted."],
|
||||
)
|
||||
|
||||
def test_each_source_counts_its_own_missing_files(self):
|
||||
prompt = self.save("Images")
|
||||
archive = self.root / "archive"
|
||||
archive.mkdir()
|
||||
self.image(prompt, "a.png", exists=False)
|
||||
self.image(prompt, "b.png", exists=False, folder=archive)
|
||||
self.image(prompt, "c.png", exists=False, folder=archive)
|
||||
missing = self.check(
|
||||
self.run_checks([str(self.output), str(archive)], review_count=0),
|
||||
"missing_files",
|
||||
)
|
||||
self.assertEqual(missing["count"], 3)
|
||||
self.assertFalse(missing["partial"])
|
||||
self.assertEqual(
|
||||
[(s["name"], s["count"]) for s in missing["sources"]],
|
||||
[("output", 1), ("archive", 2)],
|
||||
)
|
||||
|
||||
def test_no_sources_is_unavailable(self):
|
||||
self.save("Images")
|
||||
missing = self.check(self.run_checks([], review_count=0), "missing_files")
|
||||
self.assertEqual(missing["status"], "unavailable")
|
||||
self.assertIsNone(missing["count"])
|
||||
self.assertEqual(missing["detail"], ["No image source is available."])
|
||||
self.assertEqual(missing["sources"], [])
|
||||
self.assertFalse(missing["partial"])
|
||||
|
||||
def test_a_bad_stored_path_fails_only_missing_files(self):
|
||||
prompt = self.save("Images")
|
||||
self.image(prompt, "here.png")
|
||||
with self.raw() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO generated_images(prompt_id,image_path,filename)"
|
||||
" VALUES(?,?,?)",
|
||||
(prompt, str(self.root / "else\x00where.png"), "bad.png"),
|
||||
)
|
||||
conn.close()
|
||||
real_realpath = os.path.realpath
|
||||
|
||||
def realpath(path, *args, **kwargs):
|
||||
# POSIX (and Windows before Python 3.11) raise for the NUL byte;
|
||||
# newer ntpath returns it unchanged. Raise everywhere, as POSIX does.
|
||||
if "\x00" in str(path):
|
||||
raise ValueError("embedded null byte")
|
||||
return real_realpath(path, *args, **kwargs)
|
||||
|
||||
with (
|
||||
patch.object(health.os.path, "realpath", side_effect=realpath),
|
||||
self.assertLogs("prompt_manager.database.health", "ERROR"),
|
||||
):
|
||||
report = self.run_checks(review_count=0)
|
||||
statuses = {c["id"]: c["status"] for c in report["checks"]}
|
||||
self.assertEqual(statuses.pop("missing_files"), "failed")
|
||||
self.assertEqual(set(statuses.values()), {"passed"})
|
||||
|
||||
def test_files_are_checked_after_the_read_snapshot_ends(self):
|
||||
prompt = self.save("Images")
|
||||
self.image(prompt, "here.png")
|
||||
self.image(prompt, "gone.png", exists=False)
|
||||
opened = []
|
||||
real_connect = sqlite3.connect
|
||||
|
||||
def connect(*args, **kwargs):
|
||||
conn = real_connect(*args, **kwargs)
|
||||
opened.append(conn)
|
||||
return conn
|
||||
|
||||
real_exists = os.path.exists
|
||||
in_snapshot = []
|
||||
|
||||
def exists(path):
|
||||
for conn in opened:
|
||||
try:
|
||||
in_snapshot.append(conn.in_transaction)
|
||||
except sqlite3.ProgrammingError: # closed: no snapshot
|
||||
in_snapshot.append(False)
|
||||
return real_exists(path)
|
||||
|
||||
with (
|
||||
patch.object(health.sqlite3, "connect", side_effect=connect),
|
||||
patch.object(health.os.path, "exists", side_effect=exists),
|
||||
):
|
||||
missing = self.check(self.run_checks(review_count=0), "missing_files")
|
||||
self.assertEqual((missing["status"], missing["count"]), ("issues", 1))
|
||||
self.assertEqual(len(opened), 1)
|
||||
self.assertTrue(in_snapshot)
|
||||
self.assertNotIn(True, in_snapshot)
|
||||
|
||||
|
||||
class TestAmbiguousMetadata(HealthTestCase):
|
||||
def test_ambiguous_metadata_unavailable_without_review_queue(self):
|
||||
self.save("Any")
|
||||
report = self.run_checks()
|
||||
ambiguous = self.check(report, "ambiguous_metadata")
|
||||
self.assertEqual(ambiguous["status"], "unavailable")
|
||||
self.assertIsNone(ambiguous["count"])
|
||||
self.assertEqual(ambiguous["detail"], ["Needs review is not available yet."])
|
||||
self.assertEqual(report["status"], "incomplete")
|
||||
|
||||
def test_review_count_reports_images_needing_review(self):
|
||||
report = self.run_checks(review_count=3)
|
||||
ambiguous = self.check(report, "ambiguous_metadata")
|
||||
self.assertEqual(ambiguous["status"], "issues")
|
||||
self.assertEqual(ambiguous["count"], 3)
|
||||
self.assertEqual(ambiguous["detail"], [])
|
||||
self.assertEqual(report["status"], "issues")
|
||||
|
||||
def test_invalid_review_count_is_rejected(self):
|
||||
for value in (-1, True, "3", 1.5):
|
||||
with self.assertRaises(ValueError):
|
||||
self.run_checks(review_count=value)
|
||||
|
||||
|
||||
class TestOverallStatus(unittest.TestCase):
|
||||
def test_overall_status_precedence(self):
|
||||
def checks(*statuses):
|
||||
return [{"status": status} for status in statuses]
|
||||
|
||||
self.assertEqual(health.overall_status(checks("passed", "passed")), "passed")
|
||||
self.assertEqual(health.overall_status(checks("passed", "issues")), "issues")
|
||||
self.assertEqual(
|
||||
health.overall_status(checks("issues", "unavailable")), "incomplete"
|
||||
)
|
||||
self.assertEqual(health.overall_status(checks("failed")), "incomplete")
|
||||
# A check that found issues under only some sources is incomplete too.
|
||||
partial = [{"status": "issues", "partial": True}, {"status": "passed"}]
|
||||
self.assertEqual(health.overall_status(partial), "incomplete")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1706,6 +1706,103 @@ class TestLibrary(unittest.TestCase):
|
||||
self.db.list_library_images(positive, pair_id=foreign, archive="all")
|
||||
|
||||
|
||||
class LibraryCounts(unittest.TestCase):
|
||||
"""System › Overview counts and usage statistics follow Library eligibility."""
|
||||
|
||||
setUp = TestLibrary.setUp
|
||||
tearDown = TestLibrary.tearDown
|
||||
save = TestLibrary.save
|
||||
state = TestLibrary.state
|
||||
|
||||
def test_counts_use_library_eligibility(self):
|
||||
self.db.save_prompt_pair("Cat", "Blur")
|
||||
archived = self.db.save_prompt_pair("Cat", "Noise")
|
||||
self.db.archive_prompt_pair(archived)
|
||||
titled_pair = self.db.save_prompt_pair("Dog", "Titled negative")
|
||||
titled = self.db.get_prompt_pair(titled_pair)["negative_prompt_id"]
|
||||
self.state(titled, "A titled parent") # zero pairs of its own: counted
|
||||
legacy = self.save("Legacy")
|
||||
self.db.link_image_to_prompt(legacy, str(Path(self.temp.name) / "a.png"))
|
||||
self.db.link_image_to_prompt(legacy, str(Path(self.temp.name) / "b.png"))
|
||||
counts = self.db.library_counts()
|
||||
# Cat, Dog, the titled negative and Legacy; "Blur" and "Noise" are
|
||||
# negative-only.
|
||||
self.assertEqual(counts["positive_prompts"], 4)
|
||||
# Cat+Blur and Dog+Titled negative; the archived pair is excluded.
|
||||
self.assertEqual(counts["negative_variants"], 2)
|
||||
self.assertEqual(counts["images"], 2)
|
||||
self.assertEqual(counts["rated_images"], 0)
|
||||
listed = self.db.list_library()
|
||||
self.assertEqual(counts["positive_prompts"], listed["total"])
|
||||
self.assertEqual(counts["negative_variants"], listed["variant_total"])
|
||||
|
||||
def test_counts_of_an_empty_library_are_zero(self):
|
||||
self.assertEqual(
|
||||
self.db.library_counts(),
|
||||
{
|
||||
"positive_prompts": 0,
|
||||
"negative_variants": 0,
|
||||
"images": 0,
|
||||
"rated_images": 0,
|
||||
},
|
||||
)
|
||||
|
||||
def test_rated_images_follow_their_linked_prompt_rating(self):
|
||||
# An image carries no rating of its own: it shows its linked prompt's
|
||||
# (the Images "Highest rated" sort), so "rated" counts those images.
|
||||
folder = Path(self.temp.name)
|
||||
rated = self.save("Rated", rating=4)
|
||||
unrated = self.save("Unrated")
|
||||
for name in ("a.png", "b.png", "c.png"):
|
||||
self.db.link_image_to_prompt(rated, str(folder / name))
|
||||
self.db.link_image_to_prompt(unrated, str(folder / "d.png"))
|
||||
counts = self.db.library_counts()
|
||||
self.assertEqual(counts["images"], 4)
|
||||
self.assertEqual(counts["rated_images"], 3)
|
||||
self.db.update_prompt_rating(unrated, 2)
|
||||
self.assertEqual(self.db.library_counts()["rated_images"], 4)
|
||||
|
||||
def test_statistics_sum_real_run_counts(self):
|
||||
a = self.save("Alpha", category="Landscapes", rating=5)
|
||||
b = self.save("Beta", category="Landscapes", rating=4)
|
||||
self.save("Gamma", category="Landscapes") # unrated: not in the average
|
||||
c = self.save("Delta", category="Portraits", rating=3)
|
||||
blank = self.save("Epsilon", category=" ")
|
||||
uncategorised = self.save("Zeta")
|
||||
self.db.record_prompt_use(a, times=3)
|
||||
self.db.record_prompt_use(b, times=2)
|
||||
self.db.record_prompt_use(c)
|
||||
self.db.record_prompt_use(blank)
|
||||
self.db.record_prompt_use(uncategorised, times=4)
|
||||
# A negative-only prompt's runs are the pair's runs: never counted twice.
|
||||
pair = self.db.save_prompt_pair("Alpha", "Only negative")
|
||||
negative = self.db.get_prompt_pair(pair)["negative_prompt_id"]
|
||||
with self.db.model.get_connection() as connection:
|
||||
connection.execute(
|
||||
"UPDATE prompts SET category='Landscapes',run_count=50 WHERE id=?",
|
||||
(negative,),
|
||||
)
|
||||
rows = self.db.usage_by_category()
|
||||
self.assertEqual(
|
||||
rows,
|
||||
[
|
||||
{"category": "Landscapes", "runs": 5, "average_rating": 4.5},
|
||||
{"category": None, "runs": 5, "average_rating": None},
|
||||
{"category": "Portraits", "runs": 1, "average_rating": 3.0},
|
||||
],
|
||||
)
|
||||
|
||||
def test_counts_and_usage_write_nothing(self):
|
||||
self.save("Alpha", category="A")
|
||||
with self.db.model.get_connection() as connection:
|
||||
before = connection.total_changes
|
||||
self.db.library_counts()
|
||||
self.db.usage_by_category()
|
||||
with self.db.model.get_connection() as connection:
|
||||
self.assertEqual(connection.total_changes, before)
|
||||
self.assertFalse(connection.in_transaction)
|
||||
|
||||
|
||||
class DisplayTitleUnits(unittest.TestCase):
|
||||
"""The first-line title is cut like the Library's JavaScript title()."""
|
||||
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
"""System routes (py/api/system.py): health check job, Overview and statistics.
|
||||
|
||||
The health check runs off-loop as a ``read`` registry job of kind ``health``
|
||||
(one at a time, beside a scan), and the API keeps its last report. The
|
||||
Overview reads only real values: Library-eligible counts, indexed images, the
|
||||
last health result or null, no backup until one exists, and the effective
|
||||
sources and whether they were auto-detected. The missing-thumbnail count is not
|
||||
part of it: the Overview reads ``/images/thumbnails/status`` on its own (its
|
||||
walk of every source must not hold the other values back).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
_mock_server = MagicMock()
|
||||
_mock_server.PromptServer.instance.routes = MagicMock()
|
||||
sys.modules.setdefault("server", _mock_server)
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from py.api.jobs import HEALTH_JOB_KIND # noqa: E402
|
||||
from py.config import GalleryConfig # noqa: E402
|
||||
from utils.hashing import generate_prompt_hash # noqa: E402
|
||||
|
||||
try:
|
||||
from tests.test_api_admin import AdminAPITestCase # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from test_api_admin import AdminAPITestCase # noqa: E402
|
||||
|
||||
WAIT = 5
|
||||
|
||||
|
||||
class SystemAPITestCase(AdminAPITestCase):
|
||||
async def get_application(self):
|
||||
app = await super().get_application()
|
||||
# Auto-detection searches the disk; here it finds the fake ComfyUI only.
|
||||
self.api._find_comfyui_output_dir = lambda: (
|
||||
str(self.output_dir) if self.output_dir.is_dir() else None
|
||||
)
|
||||
return app
|
||||
|
||||
def save(self, text, **metadata):
|
||||
return self.api.db.save_prompt(
|
||||
text, prompt_hash=generate_prompt_hash(text), **metadata
|
||||
)
|
||||
|
||||
def image(self, prompt_id, name, *, exists=True, folder=None):
|
||||
path = Path(folder or self.output_dir) / name
|
||||
if exists:
|
||||
path.write_bytes(b"png")
|
||||
return self.api.db.link_image_to_prompt(prompt_id, str(path))
|
||||
|
||||
async def json(self, method, url, status=200):
|
||||
response = await self.client.request(method, url)
|
||||
self.assertEqual(response.status, status, await response.text())
|
||||
return await response.json()
|
||||
|
||||
async def restore_snapshot(self):
|
||||
"""Restore the live database from a copy of itself through the legacy route."""
|
||||
from contextlib import closing
|
||||
|
||||
from aiohttp import FormData
|
||||
|
||||
snapshot = os.path.join(self.tmpdir, "snapshot.db")
|
||||
with closing(sqlite3.connect(self.api.db.model.db_path)) as src:
|
||||
with closing(sqlite3.connect(snapshot)) as dst:
|
||||
src.backup(dst)
|
||||
with open(snapshot, "rb") as handle:
|
||||
upload = handle.read()
|
||||
form = FormData()
|
||||
form.add_field("database_file", upload, filename="upload.db")
|
||||
response = await self.client.request(
|
||||
"POST", "/prompt_manager/restore", data=form
|
||||
)
|
||||
self.assertEqual(response.status, 200, await response.text())
|
||||
|
||||
async def run_health(self):
|
||||
body = await self.json("POST", "/prompt_manager/system/health", 202)
|
||||
job = self.api._jobs.get(body["job"]["id"])
|
||||
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
|
||||
return body["job"], job
|
||||
|
||||
|
||||
class TestHealthRoute(SystemAPITestCase):
|
||||
async def test_no_report_before_a_check(self):
|
||||
body = await self.json("GET", "/prompt_manager/system/health")
|
||||
self.assertEqual(body, {"success": True, "report": None})
|
||||
|
||||
async def test_health_check_runs_as_a_read_job_and_keeps_its_report(self):
|
||||
prompt = self.save("Images")
|
||||
self.image(prompt, "kept.png")
|
||||
self.image(prompt, "gone.png", exists=False)
|
||||
summary, job = await self.run_health()
|
||||
self.assertEqual(summary["kind"], HEALTH_JOB_KIND)
|
||||
self.assertEqual(summary["title"], "Health check")
|
||||
self.assertEqual(summary["admission"], "read")
|
||||
self.assertFalse(summary["cancellable"])
|
||||
self.assertEqual(summary["counters"], {})
|
||||
self.assertEqual(job.state, "finished")
|
||||
self.assertEqual(job.progress, 100)
|
||||
self.assertEqual(job.status_line, "Health check incomplete")
|
||||
report = (await self.json("GET", "/prompt_manager/system/health"))["report"]
|
||||
self.assertEqual(report["status"], "incomplete")
|
||||
missing = next(c for c in report["checks"] if c["id"] == "missing_files")
|
||||
self.assertEqual((missing["status"], missing["count"]), ("issues", 1))
|
||||
ambiguous = next(c for c in report["checks"] if c["id"] == "ambiguous_metadata")
|
||||
self.assertEqual(ambiguous["status"], "unavailable")
|
||||
self.assertNotIn(self.tmpdir, str(report))
|
||||
|
||||
async def test_health_check_is_single_flight_and_runs_beside_a_scan(self):
|
||||
release = threading.Event()
|
||||
entered = threading.Event()
|
||||
real = self.api.db.run_health_checks
|
||||
|
||||
def gated(*args, **kwargs):
|
||||
entered.set()
|
||||
release.wait(WAIT)
|
||||
return real(*args, **kwargs)
|
||||
|
||||
scan_gate = asyncio.Event()
|
||||
|
||||
async def scanning(job):
|
||||
await scan_gate.wait()
|
||||
|
||||
scan = self.api._jobs.start(
|
||||
"scan", "Metadata scan", admission="records", runner=scanning
|
||||
)
|
||||
with patch.object(self.api.db, "run_health_checks", side_effect=gated):
|
||||
first = await self.json("POST", "/prompt_manager/system/health", 202)
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, entered.wait, WAIT)
|
||||
busy = await self.json("POST", "/prompt_manager/system/health", 409)
|
||||
self.assertEqual(busy["code"], "busy")
|
||||
self.assertEqual(
|
||||
busy["running"], {"id": first["job"]["id"], "title": "Health check"}
|
||||
)
|
||||
release.set()
|
||||
await asyncio.wait_for(self.api._jobs.get(first["job"]["id"]).task, WAIT)
|
||||
scan_gate.set()
|
||||
await asyncio.wait_for(scan.task, WAIT)
|
||||
|
||||
async def test_failed_check_keeps_the_previous_report(self):
|
||||
await self.run_health()
|
||||
before = (await self.json("GET", "/prompt_manager/system/health"))["report"]
|
||||
with (
|
||||
patch.object(
|
||||
self.api.db, "run_health_checks", side_effect=RuntimeError("boom")
|
||||
),
|
||||
self.assertLogs("prompt_manager.api", "ERROR"),
|
||||
):
|
||||
body = await self.json("POST", "/prompt_manager/system/health", 202)
|
||||
job = self.api._jobs.get(body["job"]["id"])
|
||||
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
|
||||
self.assertEqual(job.state, "failed")
|
||||
after = (await self.json("GET", "/prompt_manager/system/health"))["report"]
|
||||
self.assertEqual(after, before)
|
||||
|
||||
async def test_absent_configured_source_is_reported_per_source(self):
|
||||
prompt = self.save("Images")
|
||||
self.image(prompt, "gone.png", exists=False)
|
||||
absent = self.comfy_dir / "output" / "unmounted"
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [
|
||||
self._root(self.output_dir),
|
||||
str(absent),
|
||||
]
|
||||
await self.run_health()
|
||||
report = (await self.json("GET", "/prompt_manager/system/health"))["report"]
|
||||
missing = next(c for c in report["checks"] if c["id"] == "missing_files")
|
||||
# Per source: the readable output is counted, the absent one is not.
|
||||
self.assertEqual(missing["status"], "issues")
|
||||
self.assertEqual(missing["count"], 1)
|
||||
self.assertTrue(missing["partial"])
|
||||
self.assertEqual(
|
||||
[(s["status"], s["count"]) for s in missing["sources"]],
|
||||
[("checked", 1), ("unavailable", None)],
|
||||
)
|
||||
self.assertEqual(report["status"], "incomplete")
|
||||
|
||||
async def test_restore_clears_health_report(self):
|
||||
self.save("Any")
|
||||
await self.run_health()
|
||||
self.assertIsNotNone(
|
||||
(await self.json("GET", "/prompt_manager/system/health"))["report"]
|
||||
)
|
||||
await self.restore_snapshot()
|
||||
body = await self.json("GET", "/prompt_manager/system/health")
|
||||
self.assertEqual(body, {"success": True, "report": None})
|
||||
overview = await self.json("GET", "/prompt_manager/system/overview")
|
||||
self.assertIsNone(overview["health"])
|
||||
|
||||
async def test_check_finishing_after_a_restore_is_not_kept(self):
|
||||
# A check that started on the old database describes it, not the new one.
|
||||
self.save("Any")
|
||||
release = threading.Event()
|
||||
entered = threading.Event()
|
||||
real = self.api.db.run_health_checks
|
||||
|
||||
def gated(*args, **kwargs):
|
||||
entered.set()
|
||||
release.wait(WAIT)
|
||||
return real(*args, **kwargs)
|
||||
|
||||
with patch.object(self.api.db, "run_health_checks", side_effect=gated):
|
||||
body = await self.json("POST", "/prompt_manager/system/health", 202)
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, entered.wait, WAIT)
|
||||
await self.restore_snapshot()
|
||||
release.set()
|
||||
job = self.api._jobs.get(body["job"]["id"])
|
||||
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
|
||||
self.assertEqual(job.state, "finished")
|
||||
body = await self.json("GET", "/prompt_manager/system/health")
|
||||
self.assertEqual(body, {"success": True, "report": None})
|
||||
# A check started on the restored database is kept again.
|
||||
await self.run_health()
|
||||
self.assertIsNotNone(
|
||||
(await self.json("GET", "/prompt_manager/system/health"))["report"]
|
||||
)
|
||||
|
||||
async def test_health_sources_resolve_off_the_event_loop(self):
|
||||
loop_thread = threading.get_ident()
|
||||
threads = []
|
||||
real = self.api._health_roots
|
||||
|
||||
def recording():
|
||||
threads.append(threading.get_ident())
|
||||
return real()
|
||||
|
||||
with patch.object(self.api, "_health_roots", side_effect=recording):
|
||||
await self.run_health()
|
||||
self.assertEqual(len(threads), 1)
|
||||
self.assertNotEqual(threads[0], loop_thread)
|
||||
|
||||
async def test_health_start_failure_is_an_error_envelope(self):
|
||||
with (
|
||||
patch.object(self.api, "_health_roots", side_effect=RuntimeError("secret")),
|
||||
self.assertLogs("prompt_manager.api", "ERROR"),
|
||||
):
|
||||
body = await self.json("POST", "/prompt_manager/system/health", 500)
|
||||
self.assertEqual(
|
||||
body,
|
||||
{
|
||||
"success": False,
|
||||
"code": None,
|
||||
"error": "Failed to start the health check",
|
||||
},
|
||||
)
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
|
||||
|
||||
class TestOverviewRoute(SystemAPITestCase):
|
||||
async def test_overview_reports_real_counts(self):
|
||||
self.api.db.save_prompt_pair("Cat", "Blur")
|
||||
archived = self.api.db.save_prompt_pair("Cat", "Noise")
|
||||
self.api.db.archive_prompt_pair(archived)
|
||||
legacy = self.save("Legacy")
|
||||
self.image(legacy, "a.png")
|
||||
self.image(legacy, "b.png")
|
||||
body = await self.json("GET", "/prompt_manager/system/overview")
|
||||
self.assertTrue(body["success"])
|
||||
self.assertEqual(
|
||||
body["library"], {"positive_prompts": 2, "negative_variants": 1}
|
||||
)
|
||||
self.assertEqual(body["images"], {"indexed": 2, "rated": 0})
|
||||
self.assertEqual(
|
||||
set(body),
|
||||
{
|
||||
"success",
|
||||
"library",
|
||||
"images",
|
||||
"health",
|
||||
"backup",
|
||||
"needs_review",
|
||||
"sources",
|
||||
},
|
||||
)
|
||||
|
||||
async def test_overview_rated_counts_images_of_rated_prompts(self):
|
||||
rated = self.save("Rated", rating=5)
|
||||
self.image(rated, "a.png")
|
||||
self.image(rated, "b.png")
|
||||
self.image(self.save("Unrated"), "c.png")
|
||||
body = await self.json("GET", "/prompt_manager/system/overview")
|
||||
self.assertEqual(body["images"], {"indexed": 3, "rated": 2})
|
||||
|
||||
async def test_overview_never_fabricates_backup_or_health(self):
|
||||
body = await self.json("GET", "/prompt_manager/system/overview")
|
||||
self.assertIsNone(body["health"])
|
||||
self.assertIsNone(body["backup"])
|
||||
self.assertEqual(body["needs_review"], {"pending": None})
|
||||
self.save("Any")
|
||||
await self.run_health()
|
||||
report = (await self.json("GET", "/prompt_manager/system/health"))["report"]
|
||||
body = await self.json("GET", "/prompt_manager/system/overview")
|
||||
self.assertEqual(
|
||||
body["health"],
|
||||
{"checked_at": report["checked_at"], "status": "incomplete", "issues": 0},
|
||||
)
|
||||
self.assertIsNone(body["backup"])
|
||||
|
||||
async def test_overview_health_counts_checks_with_issues(self):
|
||||
prompt = self.save("Images")
|
||||
self.image(prompt, "gone.png", exists=False)
|
||||
self.image(prompt, "gone-too.png", exists=False)
|
||||
await self.run_health()
|
||||
body = await self.json("GET", "/prompt_manager/system/overview")
|
||||
self.assertEqual(body["health"]["issues"], 1)
|
||||
|
||||
async def test_overview_sources_distinguish_auto_detect(self):
|
||||
body = await self.json("GET", "/prompt_manager/system/overview")
|
||||
self.assertEqual(
|
||||
body["sources"], {"configured": 0, "effective": 1, "auto_detected": True}
|
||||
)
|
||||
second = self.output_dir / "archive"
|
||||
second.mkdir()
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [
|
||||
self._root(self.output_dir),
|
||||
self._root(second),
|
||||
]
|
||||
body = await self.json("GET", "/prompt_manager/system/overview")
|
||||
self.assertEqual(
|
||||
body["sources"], {"configured": 2, "effective": 2, "auto_detected": False}
|
||||
)
|
||||
# Every configured source unavailable: ComfyUI's output is used instead.
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [str(self.comfy_dir / "absent")]
|
||||
body = await self.json("GET", "/prompt_manager/system/overview")
|
||||
self.assertEqual(
|
||||
body["sources"], {"configured": 1, "effective": 1, "auto_detected": True}
|
||||
)
|
||||
|
||||
async def test_overview_never_walks_the_sources_for_thumbnails(self):
|
||||
(self.output_dir / "one.png").write_bytes(b"png")
|
||||
with patch.object(
|
||||
self.api,
|
||||
"_plan_gallery_thumbnails",
|
||||
side_effect=AssertionError("walked"),
|
||||
):
|
||||
body = await self.json("GET", "/prompt_manager/system/overview")
|
||||
self.assertTrue(body["success"])
|
||||
self.assertNotIn("thumbnails", body)
|
||||
|
||||
async def test_overview_sources_resolve_off_the_event_loop(self):
|
||||
loop_thread = threading.get_ident()
|
||||
threads = []
|
||||
real = self.api._output_dirs_with_origin
|
||||
|
||||
def recording():
|
||||
threads.append(threading.get_ident())
|
||||
return real()
|
||||
|
||||
with patch.object(self.api, "_output_dirs_with_origin", side_effect=recording):
|
||||
body = await self.json("GET", "/prompt_manager/system/overview")
|
||||
self.assertEqual(body["sources"]["effective"], 1)
|
||||
self.assertEqual(len(threads), 1)
|
||||
self.assertNotEqual(threads[0], loop_thread)
|
||||
|
||||
async def test_overview_failure_is_an_error_envelope(self):
|
||||
with (
|
||||
patch.object(
|
||||
self.api.db, "library_counts", side_effect=RuntimeError("secret")
|
||||
),
|
||||
self.assertLogs("prompt_manager.api", "ERROR"),
|
||||
):
|
||||
body = await self.json("GET", "/prompt_manager/system/overview", 500)
|
||||
self.assertEqual(
|
||||
body,
|
||||
{"success": False, "code": None, "error": "Failed to read the overview"},
|
||||
)
|
||||
|
||||
|
||||
class TestStatisticsRoute(SystemAPITestCase):
|
||||
async def test_statistics_sum_real_run_counts(self):
|
||||
a = self.save("Alpha", category="Landscapes", rating=5)
|
||||
b = self.save("Beta", category="Portraits", rating=2)
|
||||
self.api.db.record_prompt_use(a, times=3)
|
||||
self.api.db.record_prompt_use(b)
|
||||
body = await self.json("GET", "/prompt_manager/system/statistics")
|
||||
self.assertEqual(
|
||||
body["by_category"],
|
||||
[
|
||||
{"category": "Landscapes", "runs": 3, "average_rating": 5.0},
|
||||
{"category": "Portraits", "runs": 1, "average_rating": 2.0},
|
||||
],
|
||||
)
|
||||
self.assertEqual(body["totals"], self.api.db.get_statistics())
|
||||
self.assertTrue(body["success"])
|
||||
|
||||
async def test_statistics_lists_rated_images_for_library_totals(self):
|
||||
# D96: the Statistics dialog's "Library totals" reads the rated count
|
||||
# with the runs (one read), from the same images the Overview counts.
|
||||
rated = self.save("Rated", rating=4)
|
||||
self.image(rated, "a.png")
|
||||
self.image(self.save("Unrated"), "b.png")
|
||||
body = await self.json("GET", "/prompt_manager/system/statistics")
|
||||
self.assertEqual(body["rated_images"], 1)
|
||||
self.assertEqual(
|
||||
set(body), {"success", "by_category", "totals", "rated_images"}
|
||||
)
|
||||
|
||||
async def test_statistics_failure_is_an_error_envelope(self):
|
||||
with (
|
||||
patch.object(
|
||||
self.api.db, "usage_by_category", side_effect=RuntimeError("secret")
|
||||
),
|
||||
self.assertLogs("prompt_manager.api", "ERROR"),
|
||||
):
|
||||
body = await self.json("GET", "/prompt_manager/system/statistics", 500)
|
||||
self.assertEqual(
|
||||
body,
|
||||
{"success": False, "code": None, "error": "Failed to read statistics"},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import unittest
|
||||
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user