Files
ComfyAssets-ComfyUI_PromptM…/tests/browser/system-maintenance-concept.spec.mjs
T
Vito 9ab25f9aaa feat(system): Maintenance UI (System S7) (#322)
* fix(gallery): clear only Prompt Manager cache entries (#222)

The legacy gallery's Clear cache deleted every localStorage key starting
pm_ or promptManager_ (the workspace theme and the dismissed update notice
among them) and every CacheStorage cache whose name held gallery or
thumbnail, which on ComfyUI's shared origin can belong to another tool.

web/js/cache-ownership.js names what a clear may remove from an inventory
of the real writers: every key Prompt Manager writes is a preference and no
page opens CacheStorage or IndexedDB, so nothing is removed and the toast
says so. A test fails when a new storage writer is not classified. The
server thumbnail clear stays its own request; a refused or unreachable
server changes nothing and is reported, and refused browser storage is
reported as such.

* feat(system): run reviewed maintenance natively

System › Maintenance is native: the concept's notice and its Database and
Images & cache surfaces replace the hand-off.

- Integrity & relationship checks runs the health check; the report's
  Review missing links reviews a fresh Missing-file links plan.
- Review plan shows the server's read-only plan (D100): scope, protected,
  backup policy and media lines, the listed records, how long the review
  holds and the backup box; Apply reviewed operation sends only its token
  and opens the job in Activity. busy names the running job, plan-used links
  the job that applied it, an expired or replaced review is reviewed again,
  and a lost answer says Outcome not confirmed and points to Activity.
- Find duplicates starts the search; its view offers Review groups (D101):
  ten groups at a time, Keep radios per copy, each keeper sent as
  {group_id, root_index, path}; the dialog says History keeps the removed
  paths in its run records.
- Thumbnail cache opens the thumbnail dialog; Metadata cache is disabled
  with its reason while nothing is owned (D102, Q5).
- Activity descriptors for maintenance, duplicates and duplicate_cleanup.
- The legacy duplicates dialog's View Full names its source (?root=).

* test(system): compare Maintenance with the concept

system-maintenance-concept.spec compares System › Maintenance, the plan
dialog of each operation and the duplicates dialog with the concept in both
themes and both viewports, mapped only by D100–D102, and proves a mutated
row, label, danger mark or token is noticed. The duplicates dialog's Review
duplicate cleanup is the concept's plain button.

* test(system): Maintenance no longer hands off; raise coverage floors

The System shell test expected Maintenance's D94 hand-off; the section is
native now. coverage:update sets the new modules' floors from the
measured run (cache-ownership 94, maintenance-review 94,
system-maintenance 92 lines) and raises the floors other files passed.

* test(admin): close the preview worker after the last job in teardown

A scan publishes its last event, which ends the stream the test reads,
before it hands its missing previews to the automatic worker. That
hand-off can run after tearDownAsync closed the worker, so it starts a new
one whose .part files were still open when the final drain checked for
open handles (py3.10 on ubuntu, test_scan_reads_metadata_on_several_threads).
The drain now closes the worker for good once every job has ended.

* fix(system): address the S7 final review

- I1: an apply answer is a refusal only when our server sent it (a known
  code, or a 4xx whose body says success:false); a proxy's 5xx, an
  unreadable 2xx or a dropped connection says Outcome not confirmed and
  reads Activity again. ApiError gains 'refused'; the server's own
  pre-start failure is coded 'apply-failed'.
- M1: the lost-response test lets the apply run, then drops its answer.
- M2: a failed apply records its code in the job's scope; Activity offers
  Review again for an overtaken plan (Maintenance for a duplicate cleanup
  whose sources changed).
- M3: a failed duplicate cleanup toasts the server's reason.
- M4: a review button holds while its preview is read (one preview per
  double press).
- M5: the storage guard also catches bracket and property assignment and
  CacheStorage/IndexedDB opens; why the Toolkit build is skipped is
  written down.
- M6: a refused storage listing says so on the Metadata cache row.
- M7, M8: D100 text corrected; workflow_storage has its title.
- M9: a busy start names the job the server named.

* test: follow #319 in the Maintenance tests

- The thumbnail dialog has only Scope since #319.
- The admin teardown refuses new preview workers on its own API instead
  of closing with final=True, which since #319 also shuts the process-wide
  encoders that later test modules use.

* fix(system): keep the server's search-again line on a duplicate review

A duplicate cleanup review the server refuses with 409 sources-changed
showed the generic "could not be reviewed" message; it now shows the
server's "The sources changed since the search. Search for duplicates
again."

* fix(system): a plan with nothing to do offers only Close

A reviewed maintenance plan whose scope is 0 records ("No duplicate
prompt text") offered Apply reviewed operation, which started an empty
job. It now shows its rows, "There is nothing to apply; nothing was
changed." and a Close button.

* fix(system): wrap long copy paths in the duplicates review

A Keep label is the copy's source and path, and dataset folders often have
long names with no space to break at, so the review scrolled sideways.
The label now wraps anywhere inside its compare item (D101).
2026-10-25 05:51:57 -07:00

333 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 S7 Task 20: System › Maintenance (app.js renderSystem 688–690), its plan dialog (`maintenancePlan`,
// 953–958) for each operation and its duplicates dialog (`duplicates`, 1457–1461) against the immutable concept, on
// the concept-shaped real fixture. Each concept measurement is mapped only by the recorded deviations (D100, D101,
// D102; the health report is D95, compared in system-concept.spec), each commented where it applies; after that the
// difference must be empty, and a mutated label, row or 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__/jobs/restart');
await request.post('/__test__/system/reset');
expect(page.conceptErrors).toEqual([]);
});
const PREVIEW_URL = '/prompt_manager/maintenance/preview';
/** Runs in the page: Maintenance's notice and surfaces under `mainSelector`, with the concept's geometry and tokens. */
function measureMaintenance(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', 'opacity', 'cursor') });
const box = node => {
const rect = node.getBoundingClientRect(), base = layout.getBoundingClientRect();
return { left: Math.round(rect.left - base.left), width: Math.round(rect.width) };
};
const notice = layout.querySelector(':scope > .notice');
const surfaces = [...layout.querySelectorAll(':scope > section.surface')];
const operation = surfaces[0].querySelector('.operation');
return {
layout: [...layout.children].map(name),
notice: { tree: [...notice.children].map(name), text: [...notice.querySelectorAll(':scope > div > *')].map(node => [node.localName, text(node)]),
run: button(notice.querySelector(':scope > button')),
...css(notice, 'display', 'alignItems', 'justifyContent', 'columnGap', 'borderTopColor', 'backgroundColor', 'paddingTop', 'marginBottom') },
surfaces: surfaces.map(surface => ({ tree: [...surface.children].map(name), heading: text(surface.querySelector(':scope > h2')),
box: box(surface),
rows: [...surface.querySelectorAll(':scope > .operation')].map(row => ({ tree: [...row.children].map(name),
title: text(row.querySelector('h3')), description: text(row.querySelector('p')),
actions: [...row.querySelectorAll('.row-actions > button')].map(button) })) })),
surface: css(surfaces[0], 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius', 'marginTop'),
h2: css(surfaces[0].querySelector('h2'), 'fontSize', 'fontWeight', 'color', 'marginBottom'),
operation: css(operation, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom',
'borderTopWidth', 'borderTopColor'),
h3: css(operation.querySelector('h3'), 'fontSize', 'fontWeight', 'color'),
p: css(operation.querySelector('p'), 'fontSize', 'color', 'marginTop'),
actions: css(operation.querySelector('.row-actions'), 'display', 'columnGap', 'flexShrink'),
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
};
}
/** D102: the Metadata cache row says why its review is unavailable and its button is aria-disabled (D93's dimmed style). */
function mapMaintenanceConcept(reference) {
return {
...reference,
surfaces: reference.surfaces.map(surface => ({ ...surface, rows: surface.rows.map(row => row.title !== 'Metadata cache' ? row : {
...row, description: `${row.description} Nothing is cached in this browser.`,
actions: row.actions.map(action => ({ ...action, opacity: '0.55', cursor: 'not-allowed' })) }) })),
};
}
async function conceptMaintenance(page, theme) {
await openConcept(page, 'system', theme);
await page.locator('#main .section-nav').getByRole('button', { name: 'Maintenance', exact: true }).click();
await expect(page.locator('#main .settings-layout > section.surface')).toHaveCount(2);
await page.mouse.move(0, 0);
return page.evaluate(measureMaintenance, '#main');
}
async function productionMaintenance(page, theme) {
await openProduction(page, 'system/maintenance', theme);
await expect(page.locator('#workspace-main .settings-layout > section.surface')).toHaveCount(2);
// The Metadata cache row reads this browser's storage once: compare after it answered.
await expect(page.locator('#workspace-main .operation').filter({ hasText: 'Metadata cache' }).locator('p'))
.toHaveText(/Nothing is cached in this browser\.$/);
await page.mouse.move(0, 0);
return page.evaluate(measureMaintenance, '#workspace-main');
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`maintenance matches the concept ${theme} ${width}x${height}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height });
const concept = await conceptMaintenance(page, theme);
await page.screenshot({ path: testInfo.outputPath(`concept-maintenance-${theme}-${width}.png`), fullPage: true });
const production = await productionMaintenance(page, theme);
await page.screenshot({ path: testInfo.outputPath(`production-maintenance-${theme}-${width}.png`), fullPage: true });
expect(diffInventory(mapMaintenanceConcept(concept), production)).toEqual([]);
expect(production.pageScroll).toBe(false);
});
}
/** Runs in the page: the open review dialog (plan or duplicates): its title, body and the concept's tokens. */
function measureReview() {
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 grid = body.querySelector(':scope > dl.meta-grid');
const compare = body.querySelector('.compare-grid');
const item = compare?.querySelector('.compare-item');
const notes = [...body.querySelectorAll(':scope > p.small')];
const note = notes.at(-1);
const actions = body.querySelector(':scope > .dialog-actions');
return {
title: text(dialog.querySelector('#modal-title')),
tree: [...body.children].map(name),
heading: body.querySelector(':scope > h3') && { text: text(body.querySelector(':scope > h3')),
...css(body.querySelector(':scope > h3'), 'fontSize', 'fontWeight', 'color', 'marginBottom') },
grid: grid && { rows: [...grid.children].map(row => [text(row.querySelector('dt')), text(row.querySelector('dd'))]),
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'),
dt: css(grid.querySelector('dt'), 'fontSize', 'color', 'textTransform'), dd: css(grid.querySelector('dd'), 'fontSize', 'color') },
details: body.querySelector(':scope > details') && { summary: text(body.querySelector(':scope > details > summary')),
tree: [...body.querySelector(':scope > details').children].map(name) },
backup: body.querySelector(':scope > .notice') && { tree: [...body.querySelector(':scope > .notice').children].map(name),
text: [...body.querySelectorAll(':scope > .notice > div:first-child > *')].map(node => [node.localName, text(node)]),
buttons: [...body.querySelectorAll(':scope > .notice button')].map(text) },
compare: compare && { tree: [...compare.children].map(name), ...css(compare, 'display', 'gridTemplateColumns', 'columnGap'),
items: [...compare.children].map(entry => ({ tree: [...entry.children].map(name), label: text(entry.querySelector('label')),
checked: entry.querySelector('input[type=radio]').checked, image: entry.querySelector('img')?.className ?? null })),
item: css(item, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius'),
image: css(item.querySelector('img'), 'width', 'objectFit'),
label: css(item.querySelector('label'), 'fontSize', 'color'),
radio: { name: item.querySelector('input[type=radio]').name.startsWith('keep-') ? 'keep-*' : 'other',
type: item.querySelector('input').type } },
note: note && { text: text(note), ...css(note, 'fontSize', 'color', 'marginTop') },
actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
buttons: [...actions.children].map(item => ({ text: text(item), className: item.className,
...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
};
}
// The concept's plan names (`data-plan`) and production's operation, row and button for each.
const PLANS = Object.freeze([
['duplicate prompts', 'duplicate_prompts', 'Duplicate prompt text'],
['database optimization', 'optimize', 'Optimize database'],
['unused prompt components', 'unused_components', 'Unused prompt components'],
['missing-file links', 'missing_links', 'Missing-file links'],
]);
const BACKUP_BOX = Object.freeze({ tree: ['div', 'div.row-actions'], text: [['h3', 'Back up the database first (recommended)'],
['p', 'Downloads a copy of the prompts database before this operation changes any records.']], buttons: ['Back up now'] });
/**
* D100: the server's plan in the concept's dialog: the operation's row title as its `h3` (the concept prints its
* `data-plan` name), the four rows with the plan's values, the listed records (`details`), the hold line in the
* concept's `p.small`, the D55 backup box before the actions, and "Apply reviewed operation". No sample note.
*/
function mapPlanConcept(reference, title, plan) {
const backup = plan.backup.policy === 'off' ? 'No safety backup (the policy is off)'
: `Create and verify before changes (${plan.backup.destination_label})`;
const tree = ['h3', 'dl.meta-grid', ...(plan.details.length ? ['details'] : []), 'p.small', 'div.notice', 'div.dialog-actions'];
const [cancel, apply] = reference.actions.buttons;
return {
...reference,
tree,
heading: { ...reference.heading, text: title },
grid: { ...reference.grid, rows: [['Scope', plan.scope.label],
['Protected', plan.protected.reasons.length ? plan.protected.reasons.join(' · ') : 'Nothing is protected'],
['Backup', backup], ['Media on disk', plan.media.label]] },
details: plan.details.length ? { summary: `Listed records (${plan.details.length})`, tree: ['summary', 'pre'] } : null,
backup: BACKUP_BOX,
note: { ...reference.note, text: 'Nothing changes until you apply. This review holds for 15 minutes.' },
actions: { ...reference.actions, buttons: [cancel, { ...apply, text: 'Apply reviewed operation' }] },
};
}
async function conceptPlan(page, theme, name) {
await conceptMaintenance(page, theme);
await page.locator(`#main [data-plan="${name}"]`).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan');
await page.mouse.move(0, 0);
return page.evaluate(measureReview);
}
/** Production's plan dialog for the row `title`, with the server's answer its review read. */
async function productionPlan(page, title) {
const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL));
await page.locator('#workspace-main .operation').filter({ has: page.locator('h3', { hasText: title }) })
.getByRole('button', { name: 'Review plan', exact: true }).click();
const plan = await (await answer).json();
await expect(page.locator('dialog#modal[open] #modal-body > h3')).toHaveText(title);
await page.mouse.move(0, 0);
return { plan, production: await page.evaluate(measureReview) };
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`plan dialogs match the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
// Something to merge, and a missing-file link, so no plan is empty.
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
await expect(await request.post('/__test__/system/missing-file')).toBeOK();
const concepts = [];
for (const [name] of PLANS) {
concepts.push(await conceptPlan(page, theme, name));
await page.screenshot({ path: testInfo.outputPath(`concept-plan-${name.replace(/\W+/g, '-')}-${theme}-${width}.png`) });
}
expect(concepts[0].tree).toEqual(['p.demo-note', 'h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions']);
await productionMaintenance(page, theme);
for (const [index, [, operation, title]] of PLANS.entries()) {
const { plan, production } = await productionPlan(page, title);
expect(plan.operation).toBe(operation);
await page.screenshot({ path: testInfo.outputPath(`production-plan-${operation}-${theme}-${width}.png`) });
expect(diffInventory(mapPlanConcept(concepts[index], title, plan), production)).toEqual([]);
await page.keyboard.press('Escape');
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
}
});
}
/**
* D101: the real group in the concept's markup: the status line (the concept's sample note's place), a group's
* `p.small` and `.compare-grid` in one wrapper, a "Keep <source>/<path>" radio per copy, the oldest checked, and the
* production note in the concept's `p.small`.
*/
function mapDuplicatesConcept(reference) {
const items = reference.compare.items.map((item, index) => ({ ...item,
label: `Keep output/s7-dupes/group_00_${index ? 'b' : 'a'}.png` }));
return {
...reference,
tree: ['p.small', 'div.duplicate-groups', 'p.small', 'div.dialog-actions'],
compare: { ...reference.compare, items },
note: { ...reference.note, text: 'Removing a copy deletes its file from disk; its database links move to the kept copy. '
+ 'History keeps the removed paths in its run records. Nothing is deleted until you review and apply the cleanup.' },
};
}
async function conceptDuplicates(page, theme) {
await conceptMaintenance(page, theme);
await page.locator('#main').getByRole('button', { name: 'Find duplicates', exact: true }).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review duplicate image files');
await page.mouse.move(0, 0);
return page.evaluate(measureReview);
}
async function productionDuplicates(page, theme) {
await productionMaintenance(page, theme);
await page.locator('#workspace-main').getByRole('button', { name: 'Find duplicates', exact: true }).click();
await page.locator('#workspace-main').getByRole('button', { name: 'Review groups', exact: true }).click();
await expect(page.locator('dialog#modal[open] .compare-item img')).toHaveCount(2);
await page.locator('dialog#modal[open] .compare-item img').evaluateAll(images => Promise.all(images.map(image => image.decode())));
await page.mouse.move(0, 0);
return page.evaluate(measureReview);
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`duplicates dialog matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
await expect(await request.post('/__test__/system/duplicate-files', { data: { groups: 1, trio: false } })).toBeOK();
const concept = await conceptDuplicates(page, theme);
expect(concept.tree).toEqual(['p.demo-note', 'div.compare-grid', 'p.small', 'div.dialog-actions']);
await page.screenshot({ path: testInfo.outputPath(`concept-duplicates-${theme}-${width}.png`) });
const production = await productionDuplicates(page, theme);
await page.screenshot({ path: testInfo.outputPath(`production-duplicates-${theme}-${width}.png`) });
expect(diffInventory(mapDuplicatesConcept(concept), production)).toEqual([]);
});
}
test('maintenance comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const concept = await conceptMaintenance(page, 'dark');
const expected = mapMaintenanceConcept(concept);
const production = await productionMaintenance(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the section differs only on the D102 row.
expect(diffInventory(concept, production).map(item => item.path).sort()).toEqual([
'surfaces.1.rows.3.actions.0.cursor', 'surfaces.1.rows.3.actions.0.opacity', 'surfaces.1.rows.3.description']);
const measure = () => page.evaluate(measureMaintenance, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
// A removed row, a changed label, a lost danger mark and a swapped token.
const undoRow = await mutate(page, { remove: ['#workspace-main section.surface:nth-of-type(1) > .operation:nth-of-type(2)'] });
expect(await paths()).toEqual(expect.arrayContaining(['surfaces.0.rows.length', 'surfaces.0.tree.length']));
await undoRow();
await page.evaluate(() => { document.querySelector('#workspace-main section.surface h3').textContent = 'Integrity checks'; });
expect(await paths()).toEqual(['surfaces.0.rows.0.title']);
await page.evaluate(() => { document.querySelector('#workspace-main section.surface h3').textContent = 'Integrity & relationship checks'; });
await page.evaluate(() => { document.querySelector('#workspace-main button.danger').classList.remove('danger'); });
expect(await paths()).toEqual(expect.arrayContaining(['surfaces.0.rows.3.actions.0.className']));
await page.evaluate(() => { document.querySelectorAll('#workspace-main section.surface')[0].querySelectorAll('.row-actions > button')[3].classList.add('danger'); });
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([]);
// The plan dialog: a changed grid term and the duplicates dialog's grid token are noticed.
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
const conceptPlanDialog = await conceptPlan(page, 'dark', 'duplicate prompts');
await productionMaintenance(page, 'dark');
const { plan } = await productionPlan(page, 'Duplicate prompt text');
const expectedPlan = mapPlanConcept(conceptPlanDialog, 'Duplicate prompt text', plan);
const planPaths = async () => diffInventory(expectedPlan, await page.evaluate(measureReview)).map(item => item.path);
expect(await planPaths()).toEqual([]);
await page.evaluate(() => { document.querySelector('dialog#modal[open] dl.meta-grid dt').textContent = 'Impact'; });
expect(await planPaths()).toEqual(['grid.rows.0.0']);
await page.evaluate(() => { document.querySelector('dialog#modal[open] dl.meta-grid dt').textContent = 'Scope'; });
const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' });
expect(await planPaths()).toEqual(expect.arrayContaining(['grid.columnGap']));
await undoGrid();
expect(await planPaths()).toEqual([]);
});
test('duplicates comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await expect(await request.post('/__test__/system/duplicate-files', { data: { groups: 1, trio: false } })).toBeOK();
const expected = mapDuplicatesConcept(await conceptDuplicates(page, 'dark'));
await productionDuplicates(page, 'dark');
const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path);
expect(await paths()).toEqual([]);
const undoToken = await mutate(page, { css: 'dialog#modal .compare-item { background: rgb(255, 0, 0); }' });
expect(await paths()).toEqual(['compare.item.backgroundColor']);
await undoToken();
await page.evaluate(() => { document.querySelector('dialog#modal[open] .dialog-actions button:last-child').classList.add('ca-primary'); });
expect(await paths()).toEqual(expect.arrayContaining(['actions.buttons.1.className']));
await page.evaluate(() => { document.querySelector('dialog#modal[open] .dialog-actions button:last-child').classList.remove('ca-primary'); });
const undoItem = await mutate(page, { remove: ['dialog#modal[open] .compare-item:last-child'] });
expect(await paths()).toEqual(expect.arrayContaining(['compare.items.length']));
await undoItem();
expect(await paths()).toEqual([]);
});