* feat(system): back up, restore and export natively System > Backup & export replaces its hand-off with the concept's section on the S8 backend: Back up downloads the verified copy and the status line shows only what GET /backup/status reports (D103); Review restore stages a chosen file and shows the server's review in the shared review dialog, Cancel discards the stage and Restore runs the Database restore job, after which the open views reload (D105); Export JSON plus the components and saved-pairs exports (D103); Import / inspect opens the shared Import & inspect dialog; the safety-backup policy saves through the Save row and scheduled backups offer Manual only (D104). Overview's Backup status row reads the same status. Refs #326 * test(system): compare Backup & export with the concept system-backups-concept.spec compares the section, the Restore preview and the Import & inspect dialog with the concept in both themes at 1600 and 390 wide, mapping only D103, D104, D105 (and D15/D55 for the shared Import dialog), and proves a renamed label, a removed row, a lost danger mark and swapped tokens are noticed. A review's grid values now wrap anywhere (D105), so a long backup file name keeps the concept's two equal columns at phone width. Refs #326 * docs(testing): Backup & export contract (D103-D105) Refs #326 * test(coverage): raise the frontend floors S9 measured system-backups.mjs gets its measured floor in place of the provisional one; admin.js, activity-scan.mjs and maintenance-review.mjs branches go up. Refs #326 * fix(system): keep a refused or handed-back restore review usable A Restore refused as busy now opens the same review again with Discard / Restore, and a restore job whose leases did not drain hands its review back (scope.review_kept) to the page that started it, which reopens it; every other path that abandons a token after Restore sends its DELETE, so held reviews can no longer lock restore out. The kept-review line says where the review is, and a full review store says what to do (I1). applyPlan resolves with its outcome and its job tracking now has failing-before proof (N5). Also: the safety-backup toggle says what the policy does (D104, M2); the restore picker has no type filter so safety backups can be chosen (M3); "Export all prompts JSON" names what /export holds (N1); small snapshots show KB (N3); the duplicated holding and save helpers are shared (N4); a dismissal test covers Cancel, x, Escape, a replacing dialog and leaving (M1). Refs #326 * test(coverage): raise the system-backups branch floor Refs #326
1017 lines
68 KiB
JavaScript
1017 lines
68 KiB
JavaScript
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: the real pending count; Review metadata opens Needs review (the concept's go-review).
|
||
'3 ambiguous image links': row => ({ ...row,
|
||
title: `${number(overview.needs_review.pending)} ambiguous image link${overview.needs_review.pending === 1 ? '' : 's'}` }),
|
||
// D93 (S9): what GET /backup/status reports; the fixture has no backup yet.
|
||
'Backup status': row => ({ ...row, description: 'No backup recorded.' }),
|
||
// 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.' }),
|
||
};
|
||
const surfaces = reference.columns.surfaces.map(surface => ({ ...surface,
|
||
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) }));
|
||
// D129: "Library location" after Backup status, in the backup row's own markup; the fixture's database is configured
|
||
// explicitly (config.json), so it does not move and its action is Details.
|
||
const [health] = surfaces;
|
||
const backup = health.rows.find(row => row.title === 'Backup status');
|
||
const location = { ...backup, title: 'Library location',
|
||
description: `${overview.location.path} · The database path set in config.json.`,
|
||
action: { ...backup.action, text: 'Details' } };
|
||
surfaces[0] = { ...health, tree: [...health.tree, 'div.operation'],
|
||
rows: [...health.rows.slice(0, 2), location, ...health.rows.slice(2)] };
|
||
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 },
|
||
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');
|
||
// So is the backup row (S9).
|
||
await expect(page.locator('#workspace-main .operation').filter({ has: page.locator('h3', { hasText: 'Backup status' }) })
|
||
.locator('p')).toHaveText('No backup recorded.');
|
||
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 (its real count), 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; D129 the inserted "Library location" row, which shifts the thumbnail row to the fourth place
|
||
// (row 2 takes its title, text and action; row 3 and the surface's fifth child are new). (The fixture's first three
|
||
// metrics are the concept's own counts.)
|
||
const D129_ROW_ACTION = ['backgroundColor', 'borderTopColor', 'borderTopLeftRadius', 'borderTopWidth', 'className', 'color',
|
||
'cursor', 'fontSize', 'opacity', 'paddingLeft', 'paddingTop', 'text'].map(key => `columns.surfaces.0.rows.3.action.${key}`);
|
||
const UNMAPPED_PATHS = [
|
||
'columns.surfaces.0.rows.0.title', 'columns.surfaces.0.rows.1.description',
|
||
'columns.surfaces.0.rows.2.action.text', 'columns.surfaces.0.rows.2.description', 'columns.surfaces.0.rows.2.title',
|
||
...D129_ROW_ACTION, 'columns.surfaces.0.rows.3.description', 'columns.surfaces.0.rows.3.title',
|
||
'columns.surfaces.0.rows.3.tree.0', 'columns.surfaces.0.rows.3.tree.1', 'columns.surfaces.0.rows.3.tree.length',
|
||
'columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.4', 'columns.surfaces.0.tree.length',
|
||
'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',
|
||
].sort();
|
||
|
||
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']);
|
||
});
|
||
|
||
/** Runs in the page: Appearance's surface, every row's text and control, and the concept's row geometry and tokens. */
|
||
function measureAppearance(mainSelector) {
|
||
const layout = document.querySelector(`${mainSelector} .settings-layout`);
|
||
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 text = node => node?.textContent.trim() ?? null;
|
||
const surface = layout.querySelector(':scope > section.surface');
|
||
const control = row => {
|
||
const select = row.querySelector(':scope > select');
|
||
if (select) return { kind: 'select', id: select.id, options: [...select.options].map(option => option.textContent),
|
||
selected: select.selectedOptions[0]?.textContent ?? null };
|
||
const input = row.querySelector(':scope > input');
|
||
if (input) return { kind: `input.${input.type}`, id: input.id, value: input.value };
|
||
const toggle = row.querySelector(':scope > * input[type="checkbox"]');
|
||
if (toggle) return { kind: 'switch', id: toggle.id, wrapper: name(toggle.parentElement), checked: toggle.checked,
|
||
...css(toggle.parentElement, 'width', 'height') };
|
||
const chip = row.querySelector(':scope > .chip');
|
||
return chip ? { kind: name(chip), text: text(chip), ...css(chip, 'color', 'backgroundColor', 'fontSize') } : null;
|
||
};
|
||
const rows = [...surface.querySelectorAll(':scope > .setting-row')];
|
||
const operations = [...surface.querySelectorAll(':scope > .operation')];
|
||
const firstRow = rows[0];
|
||
return {
|
||
layout: [...layout.children].map(name),
|
||
surface: { tree: [...surface.children].map(name), heading: text(surface.querySelector(':scope > h2')),
|
||
intro: text(surface.querySelector(':scope > p')),
|
||
...css(surface, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius') },
|
||
rows: rows.map(row => ({ tree: [...row.children].map(name), text: [...row.firstElementChild.children].map(node => [node.localName, text(node),
|
||
node.localName === 'label' ? node.htmlFor : null]), control: control(row) })),
|
||
operations: operations.map(row => ({ tree: [...row.children].map(name), title: text(row.querySelector('h3')),
|
||
description: text(row.querySelector('p')), action: text(row.querySelector('.row-actions > button')) })),
|
||
row: css(firstRow, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', 'borderTopWidth',
|
||
'borderTopColor', 'marginTop'),
|
||
label: css(firstRow.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
|
||
description: css(firstRow.querySelector('p'), 'fontSize', 'color', 'marginTop', 'maxWidth'),
|
||
select: css(firstRow.querySelector('select'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor', 'color', 'borderTopColor'),
|
||
heading3: css(surface.querySelector('.setting-row h3'), 'fontSize', 'fontWeight', 'color'),
|
||
number: css(surface.querySelector('.setting-row input[type="number"]'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor'),
|
||
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||
};
|
||
}
|
||
|
||
// D97: the rows Appearance adds for the legacy gallery settings, after the concept's own, as production renders them.
|
||
const ADDED_APPEARANCE_ROWS = [
|
||
['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', true],
|
||
['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', false],
|
||
];
|
||
|
||
/** The concept's Appearance with the recorded deviations applied, given the server's real settings. */
|
||
function mapAppearanceConcept(reference, settings) {
|
||
const toggleRow = reference.rows.find(row => row.control?.kind === 'switch');
|
||
const added = ADDED_APPEARANCE_ROWS.map(([id, label, description, checked]) => ({
|
||
tree: toggleRow.tree, text: [['label', label, id], ['p', description, null]],
|
||
control: { ...toggleRow.control, id, checked } }));
|
||
return {
|
||
...reference,
|
||
// #317: the server's thumbnail settings follow in their own surface ("Thumbnails", README: retained in Appearance).
|
||
layout: [reference.layout[0], 'section.surface', ...reference.layout.slice(1)],
|
||
// D97: two added toggles and the "Reset display defaults" operation close the surface.
|
||
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.setting-row', 'div.operation'] },
|
||
rows: [...reference.rows.map(row => row.control?.id === 'timeout'
|
||
// The node search timeout is the server's real `result_timeout`, not the concept's sample 30.
|
||
? { ...row, control: { ...row.control, value: String(settings.result_timeout) } } : row), ...added],
|
||
operations: [{ tree: ['div', 'div.row-actions'], title: 'Reset display defaults',
|
||
description: 'Restore this browser’s display defaults, then save. Other settings are kept.', action: 'Reset' }],
|
||
};
|
||
}
|
||
|
||
async function conceptAppearance(page, theme) {
|
||
await openConcept(page, 'system', theme);
|
||
await page.locator('#main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
|
||
await expect(page.locator('#main .settings-layout > section.surface > h2')).toHaveText('Browsing & display');
|
||
await page.mouse.move(0, 0);
|
||
return page.evaluate(measureAppearance, '#main');
|
||
}
|
||
|
||
async function productionAppearance(page, theme) {
|
||
await openProduction(page, 'system/appearance', theme);
|
||
await expect(page.locator('#workspace-main #timeout')).toBeEnabled();
|
||
await page.mouse.move(0, 0);
|
||
return page.evaluate(measureAppearance, '#workspace-main');
|
||
}
|
||
|
||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||
test(`appearance matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||
await page.setViewportSize({ width, height });
|
||
const { settings } = await serverJson(request, '/prompt_manager/settings');
|
||
const concept = await conceptAppearance(page, theme);
|
||
await page.screenshot({ path: testInfo.outputPath(`concept-appearance-${theme}-${width}.png`), fullPage: true });
|
||
const production = await productionAppearance(page, theme);
|
||
await page.screenshot({ path: testInfo.outputPath(`production-appearance-${theme}-${width}.png`), fullPage: true });
|
||
expect(diffInventory(mapAppearanceConcept(concept, settings), production)).toEqual([]);
|
||
expect(production.pageScroll).toBe(false);
|
||
});
|
||
}
|
||
|
||
test('appearance comparison detects mutations', async ({ page, request }) => {
|
||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||
const { settings } = await serverJson(request, '/prompt_manager/settings');
|
||
const concept = await conceptAppearance(page, 'dark');
|
||
const expected = mapAppearanceConcept(concept, settings);
|
||
const production = await productionAppearance(page, 'dark');
|
||
expect(diffInventory(expected, production)).toEqual([]);
|
||
// Unmapped, the concept differs only where D97 and the real timeout are recorded.
|
||
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
|
||
.filter((path, index, all) => all.indexOf(path) === index).sort())
|
||
.toEqual(['layout.1', 'layout.2', 'layout.length', 'operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.13',
|
||
'rows.length', 'surface.tree'].sort());
|
||
const measure = () => page.evaluate(measureAppearance, '#workspace-main');
|
||
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
|
||
// A removed row.
|
||
const undoRow = await mutate(page, { remove: ['#workspace-main .setting-row:nth-of-type(3)'] });
|
||
expect(await paths()).toEqual(expect.arrayContaining(['rows.length', 'surface.tree.length']));
|
||
await undoRow();
|
||
// A changed label and a changed option.
|
||
await page.evaluate(() => { document.querySelector('#workspace-main label[for="density"]').textContent = 'Thumbnail sizes'; });
|
||
expect(await paths()).toEqual(['rows.1.text.0.1']);
|
||
await page.evaluate(() => { document.querySelector('#workspace-main label[for="density"]').textContent = 'Thumbnail size'; });
|
||
await page.evaluate(() => { document.querySelector('#workspace-main #image-quality option').textContent = 'Medium'; });
|
||
expect(await paths()).toEqual(expect.arrayContaining(['rows.5.control.options.0']));
|
||
await page.evaluate(() => { document.querySelector('#workspace-main #image-quality option').textContent = 'Optimized'; });
|
||
// A swapped token.
|
||
const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' });
|
||
expect(await paths()).toEqual(expect.arrayContaining(['row.borderTopColor']));
|
||
await undoToken();
|
||
expect(diffInventory(expected, await measure())).toEqual([]);
|
||
});
|
||
|
||
/**
|
||
* Runs in the page: Sources & scans (app.js 679–681): the optional notice, then the "Image sources" surface with its
|
||
* panel heading, the source list, the two setting rows and the operations, with the concept's geometry and tokens.
|
||
*/
|
||
function measureSources(mainSelector) {
|
||
const layout = document.querySelector(`${mainSelector} .settings-layout`);
|
||
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 text = node => node?.textContent.trim() ?? null;
|
||
const button = node => ({ text: text(node), className: node.className,
|
||
...css(node, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') });
|
||
const operation = row => ({ tree: [...row.children].map(name), title: text(row.querySelector('h3')),
|
||
description: text(row.querySelector('p')), actions: [...row.querySelectorAll('.row-actions > button')].map(button) });
|
||
const notice = layout.querySelector(':scope > .notice');
|
||
const surface = layout.querySelector(':scope > section.surface');
|
||
const heading = surface.querySelector(':scope > .panel-heading');
|
||
const list = surface.querySelector(':scope > #source-list');
|
||
const control = row => {
|
||
const toggle = row.querySelector(':scope > * input[type="checkbox"]');
|
||
if (toggle) return { kind: 'switch', id: toggle.id, wrapper: name(toggle.parentElement), checked: toggle.checked,
|
||
...css(toggle.parentElement, 'width', 'height') };
|
||
const input = row.querySelector(':scope > input');
|
||
return { kind: `input.${input.type}`, id: input.id, min: input.min, max: input.max, value: input.value,
|
||
...css(input, 'width', 'accentColor') };
|
||
};
|
||
const rows = [...surface.querySelectorAll(':scope > .setting-row')];
|
||
return {
|
||
layout: [...layout.children].map(name),
|
||
notice: notice && { tree: [...notice.children].map(name),
|
||
text: [...notice.querySelectorAll(':scope > div > *')].map(node => [node.localName, text(node)]),
|
||
actions: [...notice.querySelectorAll(':scope > .row-actions > button')].map(button),
|
||
...css(notice, 'display', 'alignItems', 'columnGap', 'borderTopColor', 'backgroundColor', 'paddingTop', 'marginBottom') },
|
||
surface: { tree: [...surface.children].map(name), intro: text(surface.querySelector(':scope > p')),
|
||
...css(surface, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopColor', 'borderTopLeftRadius') },
|
||
heading: { tree: [...heading.children].map(name), title: text(heading.querySelector('h2')),
|
||
action: button(heading.querySelector('button')), ...css(heading, 'display', 'alignItems', 'justifyContent', 'marginBottom'),
|
||
h2: css(heading.querySelector('h2'), 'fontSize', 'fontWeight', 'color') },
|
||
sources: [...list.children].map(operation),
|
||
rows: rows.map(row => ({ tree: [...row.children].map(name), text: [...row.firstElementChild.children].map(node => [node.localName, text(node),
|
||
node.localName === 'label' ? node.htmlFor : null]), control: control(row) })),
|
||
operations: [...surface.querySelectorAll(':scope > .operation')].map(operation),
|
||
operation: css(list.querySelector('.operation'), 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop',
|
||
'paddingBottom', 'borderTopWidth', 'borderTopColor'),
|
||
h3: css(list.querySelector('.operation h3'), 'fontSize', 'fontWeight', 'color'),
|
||
p: css(list.querySelector('.operation p'), 'fontSize', 'color', 'marginTop'),
|
||
row: css(rows[0], 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', 'borderTopColor'),
|
||
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||
};
|
||
}
|
||
|
||
// D128: "watching until restart" after monitoring was switched off while the observer still runs.
|
||
const WATCH_TEXT = { watching: 'watching', stopping: 'watching until restart', manual: 'manual scan', restart_pending: 'restart pending' };
|
||
const WATCHED = new Set(['watching', 'stopping']);
|
||
// D98: the operation Sources adds after the concept's own, as production renders it.
|
||
const REFRESH_FILES = { title: 'Refresh files', description: 'Read every source again to find files added or removed outside ComfyUI.',
|
||
label: 'Refresh' };
|
||
|
||
/** The concept's Sources with the recorded deviations applied, given the server's real settings and sources. */
|
||
function mapSourcesConcept(reference, { settings, sources }) {
|
||
const [sample] = reference.sources;
|
||
const scan = sample.actions[0];
|
||
return {
|
||
...reference,
|
||
// The real sources replace the two samples (without the "sample path" note). D98: each configured one has Remove,
|
||
// a plain button after its scan.
|
||
sources: sources.effective.map(source => ({ ...sample, title: source.label,
|
||
description: `${source.path} · ${WATCH_TEXT[source.watch]}`,
|
||
actions: [{ ...scan, text: WATCHED.has(source.watch) ? 'Scan changes' : 'Scan folder' }, { ...scan, text: 'Remove' }] })),
|
||
rows: reference.rows.map(row => {
|
||
if (row.control.id === 'monitoring') return { ...row, control: { ...row.control, checked: settings.monitoring_enabled } };
|
||
// D113: the range ends at the server's core count (the concept's fixed 16) and shows the real worker count.
|
||
return { ...row, control: { ...row.control, max: String(settings.cpu_count), value: String(settings.worker_threads) } };
|
||
}),
|
||
// D98: "Refresh files" closes the surface.
|
||
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.operation'] },
|
||
operations: [...reference.operations, { ...reference.operations[0], title: REFRESH_FILES.title,
|
||
description: REFRESH_FILES.description, actions: [{ ...reference.operations[0].actions[0], text: REFRESH_FILES.label }] }],
|
||
};
|
||
}
|
||
|
||
async function conceptSources(page, theme, { notice }) {
|
||
await openConcept(page, 'system', theme);
|
||
await page.locator('#main .section-nav').getByRole('button', { name: 'Sources & scans', exact: true }).click();
|
||
await expect(page.locator('#main .panel-heading > h2')).toHaveText('Image sources');
|
||
if (!notice) await page.locator('#main .notice').getByRole('button', { name: 'Later', exact: true }).click();
|
||
await expect(page.locator('#main .settings-layout > .notice')).toHaveCount(notice ? 1 : 0);
|
||
await page.mouse.move(0, 0);
|
||
return page.evaluate(measureSources, '#main');
|
||
}
|
||
|
||
async function productionSources(page, theme, { notice }) {
|
||
// about:blank has no storage; the workspace page reads the dismissal.
|
||
if (!notice) await page.addInitScript(() => { if (location.protocol.startsWith('http')) localStorage.setItem('pm_update_notice_dismissed', '3.2.0'); });
|
||
await openProduction(page, 'system/sources', theme);
|
||
await expect(page.locator('#workspace-main #source-list .operation')).toHaveCount(2);
|
||
await expect(page.locator('#workspace-main #workers')).toBeEnabled();
|
||
await expect(page.locator('#workspace-main .settings-layout > .notice')).toHaveCount(notice ? 1 : 0);
|
||
await page.mouse.move(0, 0);
|
||
return page.evaluate(measureSources, '#workspace-main');
|
||
}
|
||
|
||
/** The fixture's sources as the concept shows its samples: the first one watched, the second not. */
|
||
async function watchedSources(request) {
|
||
await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output'] } })).toBeOK();
|
||
return serverJson(request, '/prompt_manager/settings');
|
||
}
|
||
|
||
for (const theme of THEMES) for (const [width, height] of SIZES) for (const notice of [true, false]) {
|
||
const variant = notice ? 'with' : 'without';
|
||
test(`sources matches the concept ${variant} the notice ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||
await page.setViewportSize({ width, height });
|
||
const server = await watchedSources(request);
|
||
const concept = await conceptSources(page, theme, { notice });
|
||
await page.screenshot({ path: testInfo.outputPath(`concept-sources-${variant}-notice-${theme}-${width}.png`), fullPage: true });
|
||
const production = await productionSources(page, theme, { notice });
|
||
await page.screenshot({ path: testInfo.outputPath(`production-sources-${variant}-notice-${theme}-${width}.png`), fullPage: true });
|
||
expect(diffInventory(mapSourcesConcept(concept, server), production)).toEqual([]);
|
||
expect(production.pageScroll).toBe(false);
|
||
});
|
||
}
|
||
|
||
test('sources comparison detects mutations', async ({ page, request }) => {
|
||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||
const server = await watchedSources(request);
|
||
const concept = await conceptSources(page, 'dark', { notice: true });
|
||
const expected = mapSourcesConcept(concept, server);
|
||
const production = await productionSources(page, 'dark', { notice: true });
|
||
expect(diffInventory(expected, production)).toEqual([]);
|
||
// Unmapped, the concept differs only in the real sources, the D98 operation and the D113 range.
|
||
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
|
||
.filter((path, index, all) => all.indexOf(path) === index).sort())
|
||
.toEqual(['operations.2', 'operations.length', 'rows.1', 'sources.0', 'sources.1', 'surface.tree'].sort());
|
||
const measure = () => page.evaluate(measureSources, '#workspace-main');
|
||
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
|
||
// A removed notice and a removed source row.
|
||
const undoNotice = await mutate(page, { remove: ['#workspace-main .settings-layout > .notice'] });
|
||
expect(await paths()).toEqual(expect.arrayContaining(['layout.length', 'notice.tree.length']));
|
||
await undoNotice();
|
||
const undoRow = await mutate(page, { remove: ['#workspace-main #source-list > .operation:nth-child(2)'] });
|
||
expect(await paths()).toEqual(expect.arrayContaining(['sources.length']));
|
||
await undoRow();
|
||
// A changed label and a changed button.
|
||
await page.evaluate(() => { document.querySelector('#workspace-main label[for="monitoring"]').textContent = 'Watch for outputs'; });
|
||
expect(await paths()).toEqual(['rows.0.text.0.1']);
|
||
await page.evaluate(() => { document.querySelector('#workspace-main label[for="monitoring"]').textContent = 'Watch for new outputs'; });
|
||
await page.evaluate(() => { document.querySelector('#workspace-main .panel-heading button').textContent = 'Add source'; });
|
||
expect(await paths()).toEqual(['heading.action.text']);
|
||
await page.evaluate(() => { document.querySelector('#workspace-main .panel-heading button').textContent = '+ Add source'; });
|
||
// A swapped token.
|
||
const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' });
|
||
expect(await paths()).toEqual(expect.arrayContaining(['operation.borderTopColor']));
|
||
await undoToken();
|
||
expect(diffInventory(expected, await measure())).toEqual([]);
|
||
});
|
||
|
||
/**
|
||
* Runs in the page: the open "Add image source" dialog (the concept's `simpleInput`, app.js 1138 and 1497): its title,
|
||
* the body's tree, the field's label and input, and the actions, with the concept's geometry and tokens.
|
||
*/
|
||
function measureAddSource() {
|
||
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 text = node => node?.textContent.trim() ?? null;
|
||
const body = dialog.querySelector('#modal-body');
|
||
const field = body.querySelector(':scope > .field');
|
||
const label = field?.querySelector(':scope > label');
|
||
const input = field?.querySelector(':scope > input');
|
||
const actions = body.querySelector(':scope > .dialog-actions');
|
||
const rect = dialog.getBoundingClientRect();
|
||
return {
|
||
title: text(dialog.querySelector('#modal-title')),
|
||
tree: [...body.children].map(name),
|
||
field: field && { tree: [...field.children].map(name), ...css(field, 'display', 'flexDirection', 'rowGap', 'marginBottom') },
|
||
label: label && { text: text(label), for: label.htmlFor, ...css(label, 'fontSize', 'fontWeight', 'color') },
|
||
input: input && { id: input.id, type: input.type, value: input.value,
|
||
...css(input, 'height', 'paddingLeft', 'fontSize', 'color', 'backgroundColor', 'borderTopColor', 'borderTopLeftRadius') },
|
||
actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
|
||
buttons: [...actions.children].map(button => ({ text: text(button), className: button.className,
|
||
...css(button, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
|
||
width: Math.round(rect.width),
|
||
};
|
||
}
|
||
|
||
/** D127: the field is "Folder path", starts empty (no sample folder) and its action is "Add". */
|
||
function mapAddSourceConcept(reference) {
|
||
const [cancel, apply] = reference.actions.buttons;
|
||
return { ...reference, label: { ...reference.label, text: 'Folder path' }, input: { ...reference.input, value: '' },
|
||
actions: { ...reference.actions, buttons: [cancel, { ...apply, text: 'Add' }] } };
|
||
}
|
||
|
||
async function conceptAddSource(page, theme) {
|
||
await conceptSources(page, theme, { notice: false });
|
||
await page.locator('#main .panel-heading').getByRole('button', { name: '+ Add source', exact: true }).click();
|
||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Add image source');
|
||
await page.mouse.move(0, 0);
|
||
return page.evaluate(measureAddSource);
|
||
}
|
||
|
||
async function productionAddSource(page, theme) {
|
||
await productionSources(page, theme, { notice: false });
|
||
await page.locator('#workspace-main .panel-heading').getByRole('button', { name: '+ Add source', exact: true }).click();
|
||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Add image source');
|
||
await page.mouse.move(0, 0);
|
||
return page.evaluate(measureAddSource);
|
||
}
|
||
|
||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||
test(`add source dialog matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||
await page.setViewportSize({ width, height });
|
||
await watchedSources(request);
|
||
const concept = await conceptAddSource(page, theme);
|
||
await page.screenshot({ path: testInfo.outputPath(`concept-add-source-${theme}-${width}.png`) });
|
||
const production = await productionAddSource(page, theme);
|
||
await page.screenshot({ path: testInfo.outputPath(`production-add-source-${theme}-${width}.png`) });
|
||
expect(diffInventory(mapAddSourceConcept(concept), production)).toEqual([]);
|
||
});
|
||
}
|
||
|
||
test('add source comparison detects mutations', async ({ page, request }) => {
|
||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||
await watchedSources(request);
|
||
const concept = await conceptAddSource(page, 'dark');
|
||
const expected = mapAddSourceConcept(concept);
|
||
const production = await productionAddSource(page, 'dark');
|
||
expect(diffInventory(expected, production)).toEqual([]);
|
||
// Unmapped, the concept differs only where D127 is recorded.
|
||
expect(diffInventory(concept, production).map(item => item.path).sort())
|
||
.toEqual(['actions.buttons.1.text', 'input.value', 'label.text']);
|
||
const paths = async () => diffInventory(expected, await page.evaluate(measureAddSource)).map(item => item.path);
|
||
// A changed label, a swapped action order and a changed style.
|
||
await page.evaluate(() => { document.querySelector('dialog#modal[open] label[for="action-input"]').textContent = 'Folder'; });
|
||
expect(await paths()).toEqual(['label.text']);
|
||
await page.evaluate(() => { document.querySelector('dialog#modal[open] label[for="action-input"]').textContent = 'Folder path'; });
|
||
await page.evaluate(() => { const actions = document.querySelector('dialog#modal[open] .dialog-actions'); actions.prepend(actions.lastElementChild); });
|
||
expect(await paths()).toEqual(expect.arrayContaining(['actions.buttons.0.text', 'actions.buttons.1.text']));
|
||
await page.evaluate(() => { const actions = document.querySelector('dialog#modal[open] .dialog-actions'); actions.prepend(actions.lastElementChild); });
|
||
const undoStyle = await mutate(page, { css: 'dialog#modal #action-input { border-color: rgb(255, 0, 0); } dialog#modal .dialog-actions { gap: 31px; }' });
|
||
expect(await paths()).toEqual(expect.arrayContaining(['input.borderTopColor', 'actions.columnGap']));
|
||
await undoStyle();
|
||
expect(diffInventory(expected, await page.evaluate(measureAddSource))).toEqual([]);
|
||
});
|
||
|
||
/**
|
||
* Runs in the page: the open "Thumbnail maintenance" dialog (the concept's `thumbnail-review`, app.js 1433–1437): its
|
||
* title, the body's tree, each `.field` (label, control, options, selection, limits), the `p.small` line and the
|
||
* actions, with the concept's geometry and tokens. The concept has no `p.small`, so a probe one stands in for its style.
|
||
*/
|
||
function measureThumbnailReview() {
|
||
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 text = node => node?.textContent.trim() ?? null;
|
||
const body = dialog.querySelector('#modal-body');
|
||
const fields = [...body.querySelectorAll(':scope > .field')].map(field => {
|
||
const label = field.querySelector(':scope > label');
|
||
const control = field.querySelector(':scope > select, :scope > input');
|
||
return {
|
||
tree: [...field.children].map(name),
|
||
...css(field, 'display', 'flexDirection', 'rowGap', 'marginBottom'),
|
||
label: { text: text(label), for: label.htmlFor, ...css(label, 'fontSize', 'fontWeight', 'color') },
|
||
control: { id: control.id, tag: control.localName, type: control.type,
|
||
options: control.options ? [...control.options].map(option => option.textContent) : null,
|
||
selected: control.options ? control.selectedIndex : null,
|
||
value: control.localName === 'input' ? control.value : null,
|
||
min: control.getAttribute('min'), max: control.getAttribute('max'),
|
||
...css(control, 'height', 'paddingLeft', 'fontSize', 'color', 'backgroundColor', 'borderTopColor', 'borderTopLeftRadius') },
|
||
};
|
||
});
|
||
let line = body.querySelector(':scope > p.small');
|
||
const probe = !line;
|
||
if (probe) { line = document.createElement('p'); line.className = 'small'; body.append(line); }
|
||
const plan = { text: probe ? null : text(line), role: probe ? null : line.getAttribute('role'),
|
||
...css(line, 'fontSize', 'color', 'marginTop', 'marginBottom') };
|
||
if (probe) line.remove();
|
||
const actions = body.querySelector(':scope > .dialog-actions');
|
||
const rect = dialog.getBoundingClientRect();
|
||
return {
|
||
title: text(dialog.querySelector('#modal-title')),
|
||
tree: [...body.children].map(name),
|
||
fields,
|
||
plan,
|
||
actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
|
||
buttons: [...actions.children].map(button => ({ text: text(button), className: button.className,
|
||
...css(button, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
|
||
width: Math.round(rect.width),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* D99: the planned counts line (`p.small[role=status]`) before the actions, from the server's plan for the chosen
|
||
* options, and the primary "Start" (the concept's "Simulate thumbnail job"). D131 (#317): the Quality and Preview size
|
||
* limit fields give way to a second `p.small` naming the server's thumbnail settings, and Scope gains "Convert
|
||
* outdated (removes older formats)".
|
||
*/
|
||
function mapThumbnailConcept(reference, plan) {
|
||
const [cancel, start] = reference.actions.buttons;
|
||
const [scope] = reference.fields;
|
||
const tree = ['div.field', 'p.small', 'p.small', 'div.dialog-actions'];
|
||
const fields = [{ ...scope, control: { ...scope.control, options: [...scope.control.options, 'Convert outdated (removes older formats)'] } }];
|
||
return { ...reference, tree, fields,
|
||
plan: { ...reference.plan, role: 'status',
|
||
text: `${number(plan.targets)} ${plan.targets === 1 ? 'preview' : 'previews'} to generate · ${number(plan.fresh)} already up to date.` },
|
||
actions: { ...reference.actions, buttons: [cancel, { ...start, text: 'Start' }] } };
|
||
}
|
||
|
||
async function conceptThumbnailReview(page, theme) {
|
||
await conceptSources(page, theme, { notice: false });
|
||
const row = page.locator('#main .operation').filter({ has: page.getByRole('heading', { name: 'Thumbnail generation', exact: true }) });
|
||
await row.getByRole('button', { name: 'Review options', exact: true }).click();
|
||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Thumbnail maintenance');
|
||
await page.mouse.move(0, 0);
|
||
return page.evaluate(measureThumbnailReview);
|
||
}
|
||
|
||
/** Production's dialog from Sources' Review options or Overview's Review thumbnails, once its plan line answered. */
|
||
async function productionThumbnailReview(page, theme, entry) {
|
||
if (entry === 'sources') {
|
||
await productionSources(page, theme, { notice: false });
|
||
const row = page.locator('#workspace-main .operation').filter({ has: page.getByRole('heading', { name: 'Thumbnail generation', exact: true }) });
|
||
await row.getByRole('button', { name: 'Review options', exact: true }).click();
|
||
} else {
|
||
await openProduction(page, 'system/overview', theme);
|
||
await page.locator('#workspace-main').getByRole('button', { name: 'Review thumbnails', exact: true }).click();
|
||
}
|
||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Thumbnail maintenance');
|
||
await expect(page.locator('dialog#modal[open] #modal-body > p.small').first()).toHaveText(/ already up to date\.$/);
|
||
await page.mouse.move(0, 0);
|
||
return page.evaluate(measureThumbnailReview);
|
||
}
|
||
|
||
const thumbnailPlan = request => serverJson(request, '/prompt_manager/images/thumbnails/plan?mode=missing');
|
||
|
||
for (const entry of ['sources', 'overview']) for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||
test(`thumbnail dialog matches the concept from ${entry} ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||
await page.setViewportSize({ width, height });
|
||
await watchedSources(request);
|
||
const plan = await thumbnailPlan(request);
|
||
const concept = await conceptThumbnailReview(page, theme);
|
||
await page.screenshot({ path: testInfo.outputPath(`concept-thumbnail-review-${theme}-${width}.png`) });
|
||
const production = await productionThumbnailReview(page, theme, entry);
|
||
await page.screenshot({ path: testInfo.outputPath(`production-thumbnail-review-${entry}-${theme}-${width}.png`) });
|
||
expect(diffInventory(mapThumbnailConcept(concept, plan), production)).toEqual([]);
|
||
});
|
||
}
|
||
|
||
test('thumbnail dialog comparison detects mutations', async ({ page, request }) => {
|
||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||
await watchedSources(request);
|
||
const plan = await thumbnailPlan(request);
|
||
const concept = await conceptThumbnailReview(page, 'dark');
|
||
const expected = mapThumbnailConcept(concept, plan);
|
||
const production = await productionThumbnailReview(page, 'dark', 'sources');
|
||
expect(diffInventory(expected, production)).toEqual([]);
|
||
// Unmapped, the concept differs only where D99 and D131 are recorded.
|
||
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
|
||
.filter((path, index, all) => all.indexOf(path) === index).sort())
|
||
.toEqual(['actions.buttons', 'fields.0', 'fields.1', 'fields.2', 'fields.length', 'plan.role', 'plan.text', 'tree.1', 'tree.2']
|
||
.sort());
|
||
const paths = async () => diffInventory(expected, await page.evaluate(measureThumbnailReview)).map(item => item.path);
|
||
// A changed option label, a swapped option order, a swapped action order and a changed style.
|
||
await page.evaluate(() => { document.querySelector('#thumb-mode option').textContent = 'Missing only'; });
|
||
expect(await paths()).toEqual(['fields.0.control.options.0']);
|
||
await page.evaluate(() => { document.querySelector('#thumb-mode option').textContent = 'Missing thumbnails only'; });
|
||
await page.evaluate(() => { const select = document.querySelector('#thumb-mode'); select.prepend(select.lastElementChild); });
|
||
expect(await paths()).toEqual(expect.arrayContaining(['fields.0.control.options.0', 'fields.0.control.options.1']));
|
||
await page.evaluate(() => { const select = document.querySelector('#thumb-mode'); select.append(select.firstElementChild); select.selectedIndex = 0; });
|
||
await page.evaluate(() => { const actions = document.querySelector('dialog#modal[open] .dialog-actions'); actions.prepend(actions.lastElementChild); });
|
||
expect(await paths()).toEqual(expect.arrayContaining(['actions.buttons.0.text', 'actions.buttons.1.text']));
|
||
await page.evaluate(() => { const actions = document.querySelector('dialog#modal[open] .dialog-actions'); actions.prepend(actions.lastElementChild); });
|
||
const undoStyle = await mutate(page, { css: 'dialog#modal #thumb-mode { border-color: rgb(255, 0, 0); } dialog#modal #modal-body > p.small { color: rgb(255, 0, 0); }' });
|
||
expect(await paths()).toEqual(expect.arrayContaining(['fields.0.control.borderTopColor', 'plan.color']));
|
||
await undoStyle();
|
||
expect(diffInventory(expected, await page.evaluate(measureThumbnailReview))).toEqual([]);
|
||
});
|
||
|
||
/**
|
||
* Runs in the page: one `.notice` (the concept's markup: `div > h2 + p`, then `.row-actions` buttons) with its tokens and
|
||
* geometry, and its left edge against `edgeSelector` (the content it lines up with).
|
||
*/
|
||
function measureNoticeOf([selector, edgeSelector]) {
|
||
const notice = document.querySelector(selector);
|
||
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 text = node => node?.textContent.trim() ?? null;
|
||
const button = node => ({ text: text(node), className: node.className,
|
||
...css(node, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor', 'borderTopLeftRadius') });
|
||
const edge = document.querySelector(edgeSelector);
|
||
return {
|
||
tree: [...notice.children].map(name),
|
||
text: [...notice.querySelectorAll(':scope > div:not(.row-actions) > *')].map(node => [node.localName, text(node)]),
|
||
actions: [...notice.querySelectorAll(':scope > .row-actions > button')].map(button),
|
||
frame: css(notice, 'display', 'alignItems', 'justifyContent', 'columnGap', 'borderTopWidth', 'borderTopColor',
|
||
'borderTopLeftRadius', 'backgroundColor', 'paddingTop', 'paddingLeft', 'marginBottom', 'flexDirection'),
|
||
h2: css(notice.querySelector('h2'), 'fontSize', 'fontWeight', 'color'),
|
||
p: css(notice.querySelector('p'), 'fontSize', 'color', 'marginTop'),
|
||
rowActions: css(notice.querySelector(':scope > .row-actions'), 'display', 'columnGap', 'flexShrink'),
|
||
alignedWith: Math.round(notice.getBoundingClientRect().left - edge.getBoundingClientRect().left),
|
||
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||
};
|
||
}
|
||
|
||
/** D129: the concept's Sources notice as the library notice: its own copy and actions, no bottom margin (main's padding). */
|
||
function mapLibraryNotice(reference) {
|
||
return {
|
||
...reference,
|
||
text: [['h2', 'Your library is inside the node folder'], ['p', 'Your library is stored inside the node folder, where an '
|
||
+ 'update or reinstall can delete it. Move it to ComfyUI\'s user folder: it is copied and verified first, and nothing '
|
||
+ 'is deleted.']],
|
||
actions: reference.actions.map((action, index) => ({ ...action, text: ['Keep it here', 'Move library'][index] })),
|
||
frame: { ...reference.frame, marginBottom: '0px' },
|
||
};
|
||
}
|
||
|
||
async function conceptNotice(page, theme) {
|
||
await openConcept(page, 'system', theme);
|
||
await page.locator('#main .section-nav').getByRole('button', { name: 'Sources & scans', exact: true }).click();
|
||
await expect(page.locator('#main .settings-layout > .notice')).toBeVisible();
|
||
await page.mouse.move(0, 0);
|
||
return page.evaluate(measureNoticeOf, ['#main .settings-layout > .notice', '#main .heading']);
|
||
}
|
||
|
||
async function productionNotice(page, theme) {
|
||
await openProduction(page, 'system/overview', theme);
|
||
await expect(page.locator('#library-banner > .notice')).toBeVisible();
|
||
await expect(page.locator('#workspace-main .summary-metrics')).toBeVisible();
|
||
await page.mouse.move(0, 0);
|
||
return page.evaluate(measureNoticeOf, ['#library-banner > .notice', '#workspace-main .heading']);
|
||
}
|
||
|
||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||
test(`library notice matches the concept notice ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||
await page.setViewportSize({ width, height });
|
||
await expect(await request.post('/__test__/system/library', { data: { state: 'node_folder' } })).toBeOK();
|
||
const concept = await conceptNotice(page, theme);
|
||
const production = await productionNotice(page, theme);
|
||
await page.screenshot({ path: testInfo.outputPath(`production-library-notice-${theme}-${width}.png`), fullPage: true });
|
||
expect(diffInventory(mapLibraryNotice(concept), production)).toEqual([]);
|
||
expect(production.alignedWith).toBe(0);
|
||
expect(production.pageScroll).toBe(false);
|
||
});
|
||
}
|
||
|
||
test('library notice comparison detects mutations', async ({ page, request }) => {
|
||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||
await expect(await request.post('/__test__/system/library', { data: { state: 'node_folder' } })).toBeOK();
|
||
const expected = mapLibraryNotice(await conceptNotice(page, 'dark'));
|
||
await productionNotice(page, 'dark');
|
||
const paths = async () => diffInventory(expected,
|
||
await page.evaluate(measureNoticeOf, ['#library-banner > .notice', '#workspace-main .heading'])).map(item => item.path);
|
||
expect(await paths()).toEqual([]);
|
||
const undoToken = await mutate(page, { css: '#library-banner { --soft-blue: rgb(255, 0, 0); }' });
|
||
expect(await paths()).toEqual(['frame.backgroundColor']);
|
||
await undoToken();
|
||
await page.evaluate(() => { document.querySelector('#library-banner .notice h2').textContent = 'Library'; });
|
||
expect(await paths()).toEqual(['text.0.1']);
|
||
});
|