* 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
330 lines
23 KiB
JavaScript
330 lines
23 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 S9 Task 25: System › Backup & export (app.js renderSystem 691–693), its "Restore preview" (`restore`,
|
||
// 1472–1476) and its "Import & inspect" (`import`, 1483–1487) against the immutable concept, on the concept-shaped
|
||
// fixture. Each concept measurement is mapped only by the recorded deviations (D103, D104, D105; the Import dialog's
|
||
// D15 and D55), each commented where it applies; after that the difference must be empty, and a mutated label, row,
|
||
// danger mark 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/restore/preview';
|
||
|
||
/** Runs in the page: the section's surfaces under `mainSelector`, their text, controls, geometry and tokens. */
|
||
function measureBackups(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 base = layout.getBoundingClientRect();
|
||
const box = node => {
|
||
const rect = node.getBoundingClientRect();
|
||
return { left: Math.round(rect.left - base.left), top: Math.round(rect.top - base.top), width: Math.round(rect.width) };
|
||
};
|
||
const control = row => {
|
||
const select = row.querySelector(':scope > select');
|
||
if (select) return { kind: 'select', id: select.id, options: [...select.options].map(option => [option.textContent, option.disabled]),
|
||
selected: select.selectedOptions[0]?.textContent ?? null, disabled: select.disabled };
|
||
const toggle = row.querySelector(':scope > * input[type="checkbox"]');
|
||
return toggle ? { kind: 'switch', id: toggle.id, wrapper: name(toggle.parentElement), checked: toggle.checked,
|
||
disabled: toggle.disabled, ...css(toggle.parentElement, 'width', 'height') } : null;
|
||
};
|
||
const columns = layout.querySelector(':scope > .two-col');
|
||
const surfaces = [...columns.querySelectorAll(':scope > section.surface')];
|
||
const policy = layout.querySelector(':scope > section.surface');
|
||
const chip = policy.querySelector(':scope > h2 > .chip');
|
||
const row = policy.querySelector('.setting-row');
|
||
const status = surfaces[0].querySelector(':scope > p.small');
|
||
const warning = policy.querySelector(':scope > .warning-note');
|
||
return {
|
||
layout: [...layout.children].map(name),
|
||
columns: { ...css(columns, 'display', 'gridTemplateColumns', 'columnGap'), box: box(columns) },
|
||
surfaces: surfaces.map(surface => ({ tree: [...surface.children].map(name), heading: text(surface.querySelector(':scope > h2')),
|
||
intro: text(surface.querySelector(':scope > p:not(.small)')), box: box(surface),
|
||
actions: [...surface.querySelectorAll(':scope > .row-actions')].map(group => ({ tree: [...group.children].map(name),
|
||
...css(group, 'display', 'columnGap', 'flexWrap', 'marginTop'),
|
||
buttons: [...group.children].map(button) })) })),
|
||
status: { text: text(status), ...css(status, 'fontSize', 'color', 'marginTop') },
|
||
surface: css(surfaces[0], 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius'),
|
||
h2: css(surfaces[0].querySelector('h2'), 'fontSize', 'fontWeight', 'color', 'marginBottom'),
|
||
intro: css(surfaces[0].querySelector(':scope > p'), 'fontSize', 'color', 'lineHeight'),
|
||
// Its place below the columns (the concept's `.surface + .surface` 18px), whatever the columns' height.
|
||
policy: { tree: [...policy.children].map(name), heading: text(policy.querySelector(':scope > h2')),
|
||
box: { left: box(policy).left, width: box(policy).width,
|
||
gap: Math.round(policy.getBoundingClientRect().top - columns.getBoundingClientRect().bottom) },
|
||
chip: { text: text(chip), className: chip.className, ...css(chip, 'color', 'fontSize', 'borderTopColor', 'backgroundColor') },
|
||
rows: [...policy.querySelectorAll(':scope > .setting-row')].map(item => ({ tree: [...item.children].map(name),
|
||
text: [...item.firstElementChild.children].map(node => [name(node), text(node), node.localName === 'label' ? node.htmlFor : null]),
|
||
control: control(item) })),
|
||
warning: { text: text(warning), ...css(warning, 'fontSize', 'color', 'marginTop', 'marginBottom') } },
|
||
row: css(row, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', 'borderTopWidth', 'borderTopColor',
|
||
'marginTop'),
|
||
label: css(row.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
|
||
description: css(row.querySelector('p'), 'fontSize', 'color', 'marginTop', 'maxWidth'),
|
||
select: css(policy.querySelector('.setting-row > select'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor', 'color',
|
||
'borderTopColor'),
|
||
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||
};
|
||
}
|
||
|
||
const SCHEDULE_REASON = 'Daily and Weekly are not available yet: back up with Back up whenever you choose.';
|
||
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' +
|
||
'records. A restore always keeps one.';
|
||
|
||
/**
|
||
* D103: "Back up" (the concept's "Simulate backup"), the status line from /backup/status, "Export JSON" (its "Export
|
||
* sample JSON") and the two secondary exports in a second `.row-actions.spaced` with the concept's plain buttons.
|
||
* D104: the toggle's line says what the policy does, the schedule's reason replaces the prototype's proposal line,
|
||
* Daily and Weekly are disabled, and the warning note is truthful. The fixture has no backup yet.
|
||
*/
|
||
function mapBackupsConcept(reference) {
|
||
const [database, portable] = reference.surfaces;
|
||
const [backup, restore] = database.actions[0].buttons;
|
||
const [exportButton, importButton] = portable.actions[0].buttons;
|
||
const secondary = ['Export all prompts JSON', 'Export saved pairs JSON'].map(label => ({ ...exportButton, text: label }));
|
||
return {
|
||
...reference,
|
||
surfaces: [
|
||
{ ...database, actions: [{ ...database.actions[0], buttons: [{ ...backup, text: 'Back up' }, restore] }] },
|
||
{ ...portable, tree: [...portable.tree, 'div.row-actions.spaced'],
|
||
actions: [{ ...portable.actions[0], buttons: [{ ...exportButton, text: 'Export JSON' }, importButton] },
|
||
{ ...portable.actions[0], tree: ['button', 'button'], buttons: secondary }] },
|
||
],
|
||
status: { ...reference.status, text: 'No backup recorded.' },
|
||
policy: { ...reference.policy,
|
||
rows: reference.policy.rows.map(item => (item.control.kind !== 'select'
|
||
// D104 (M2): what the safety-backup policy really does.
|
||
? { ...item, text: item.text.map(([node, value, target]) => [node, node === 'p' ? SAFETY_DESCRIPTION : value, target]) }
|
||
: { ...item,
|
||
text: item.text.map(([node, value, target]) => [node, node === 'p' ? SCHEDULE_REASON : value, target]),
|
||
control: { ...item.control, options: item.control.options.map(([label]) => [label, label !== 'Manual only']) } })),
|
||
warning: { ...reference.policy.warning, text: 'Restores are staged, migrated and validated before replacing live data.' } },
|
||
};
|
||
}
|
||
|
||
async function conceptBackups(page, theme) {
|
||
await openConcept(page, 'system', theme);
|
||
await page.locator('#main .section-nav').getByRole('button', { name: 'Backup & export', exact: true }).click();
|
||
await expect(page.locator('#main .settings-layout > .two-col > section.surface')).toHaveCount(2);
|
||
await page.mouse.move(0, 0);
|
||
return page.evaluate(measureBackups, '#main');
|
||
}
|
||
|
||
async function productionBackups(page, theme, status = 'No backup recorded.') {
|
||
await openProduction(page, 'system/backups', theme);
|
||
await expect(page.locator('#workspace-main .settings-layout > .two-col > section.surface')).toHaveCount(2);
|
||
// The status line and the policy row are their own reads: compare once both answered.
|
||
await expect(page.locator('#workspace-main #backup-status')).toHaveText(status);
|
||
await expect(page.locator('#workspace-main #backup-before')).toBeEnabled();
|
||
await page.mouse.move(0, 0);
|
||
return page.evaluate(measureBackups, '#workspace-main');
|
||
}
|
||
|
||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||
test(`backup & export matches the concept ${theme} ${width}x${height}`, async ({ page }, testInfo) => {
|
||
await page.setViewportSize({ width, height });
|
||
const concept = await conceptBackups(page, theme);
|
||
await page.screenshot({ path: testInfo.outputPath(`concept-backups-${theme}-${width}.png`), fullPage: true });
|
||
const production = await productionBackups(page, theme);
|
||
await page.screenshot({ path: testInfo.outputPath(`production-backups-${theme}-${width}.png`), fullPage: true });
|
||
expect(diffInventory(mapBackupsConcept(concept), production)).toEqual([]);
|
||
expect(production.pageScroll).toBe(false);
|
||
});
|
||
}
|
||
|
||
/** Runs in the page: the open dialog's title, body tree, grid, note, backup box and actions with the concept's tokens. */
|
||
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 text = node => node?.textContent.trim() ?? null;
|
||
const body = dialog.querySelector('#modal-body');
|
||
const grid = body.querySelector(':scope > dl.meta-grid');
|
||
const note = body.querySelector(':scope > p.small');
|
||
const actions = body.querySelector(':scope > .dialog-actions');
|
||
const list = body.querySelector(':scope > .operation-list');
|
||
return {
|
||
title: text(dialog.querySelector('#modal-title')),
|
||
tree: [...body.children].map(name),
|
||
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') },
|
||
note: note && { text: text(note), ...css(note, 'fontSize', 'color') },
|
||
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) },
|
||
list: list && { ...css(list, 'display', 'rowGap'),
|
||
rows: [...list.children].map(row => ({ tree: [...row.children].map(name), title: text(row.querySelector('h3')),
|
||
description: text(row.querySelector('p')), buttons: [...row.querySelectorAll('button')].map(text),
|
||
...css(row, 'display', 'alignItems', 'paddingTop', 'borderTopColor') })) },
|
||
actions: 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') })) },
|
||
};
|
||
}
|
||
|
||
const RESTORE_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 the restore replaces it.']], buttons: ['Back up now'] });
|
||
|
||
/**
|
||
* D105: the server's review in the concept's "Restore preview": no sample note; the four rows with the review's values,
|
||
* then "In the backup" and "In the library now"; the hold line in the concept's dialog `p.small` (its Import dialog's
|
||
* intro, `small`); the D55 backup box before the actions; "Restore" (the concept's "Simulate restore", still danger).
|
||
*/
|
||
function mapRestoreConcept(reference, small, rows) {
|
||
const [cancel, restore] = reference.actions.buttons;
|
||
return {
|
||
...reference,
|
||
tree: ['dl.meta-grid', 'p.small', 'div.notice', 'div.dialog-actions'],
|
||
grid: { ...reference.grid, rows },
|
||
note: { ...small, text: 'Restore replaces the whole library with this backup and runs alone in Activity. ' +
|
||
'Nothing changes until you restore. This review holds for 15 minutes.' },
|
||
backup: RESTORE_BACKUP_BOX,
|
||
actions: { ...reference.actions, buttons: [cancel, { ...restore, text: 'Restore' }] },
|
||
};
|
||
}
|
||
|
||
/** D15 and D55, as on Images: Rescan for "Scan an output folder", then "Scan for metadata"; no portable recipe import. */
|
||
function mapImportConcept(reference) {
|
||
return { ...reference,
|
||
note: { ...reference.note, text: 'Image inspection and output scanning have different effects. Choose the workflow you want.' },
|
||
list: { ...reference.list, rows: reference.list.rows.filter(row => row.title !== 'Import portable recipes').flatMap(row =>
|
||
row.title === 'Scan an output folder'
|
||
? [{ ...row, description: 'Find files added or removed in the output folders.', buttons: ['Rescan'] },
|
||
{ ...row, title: 'Scan for metadata' }] : [row]) } };
|
||
}
|
||
|
||
async function conceptDialog(page, theme, action, title) {
|
||
await conceptBackups(page, theme);
|
||
await page.locator(`#main .settings-layout [data-action="${action}"]`).click();
|
||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText(title);
|
||
await page.mouse.move(0, 0);
|
||
return page.evaluate(measureDialog);
|
||
}
|
||
|
||
/** Production's restore review of a fresh backup of this server's library; the review's own answer too. */
|
||
async function productionRestore(page, request, theme) {
|
||
const copy = await request.get('/prompt_manager/backup');
|
||
await expect(copy).toBeOK();
|
||
// That copy is this session's download now.
|
||
await productionBackups(page, theme, /^Downloaded backup · .+ · verified\.$/);
|
||
const chooser = page.waitForEvent('filechooser');
|
||
const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL));
|
||
await page.locator('#workspace-main').getByRole('button', { name: 'Review restore…', exact: true }).click();
|
||
await (await chooser).setFiles({ name: 'prompts_backup_20261025_093000.db', mimeType: 'application/octet-stream',
|
||
buffer: await copy.body() });
|
||
const preview = await (await answer).json();
|
||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Restore preview');
|
||
await page.mouse.move(0, 0);
|
||
return { preview, production: await page.evaluate(measureDialog) };
|
||
}
|
||
|
||
const counts = summary => `${summary.prompts} ${summary.prompts === 1 ? 'prompt' : 'prompts'} · ${summary.image_links} ` +
|
||
`${summary.image_links === 1 ? 'image link' : 'image links'} · ${summary.pairs} ${summary.pairs === 1 ? 'saved pair' : 'saved pairs'}` +
|
||
` · ${summary.runs} ${summary.runs === 1 ? 'run' : 'runs'}`;
|
||
|
||
function restoreRows(preview, snapshot) {
|
||
return [['Snapshot', snapshot], ['Integrity', 'Verified'], ['Migration', 'Current schema'],
|
||
['Safety backup', `Created before replacing (${preview.safety_backup.destination_label})`],
|
||
['In the backup', counts(preview.summary)], ['In the library now', counts(preview.live)]];
|
||
}
|
||
|
||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||
test(`restore preview and import dialogs match the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||
await page.setViewportSize({ width, height });
|
||
// The concept's own dialog `p.small` (its Import dialog's intro) is the reference for the review's note.
|
||
const conceptImport = await conceptDialog(page, theme, 'import', 'Import & inspect');
|
||
await page.screenshot({ path: testInfo.outputPath(`concept-import-${theme}-${width}.png`) });
|
||
const conceptRestore = await conceptDialog(page, theme, 'restore', 'Restore preview');
|
||
expect(conceptRestore.tree).toEqual(['p.demo-note', 'dl.meta-grid', 'div.dialog-actions']);
|
||
await page.screenshot({ path: testInfo.outputPath(`concept-restore-${theme}-${width}.png`) });
|
||
const { preview, production } = await productionRestore(page, request, theme);
|
||
await page.screenshot({ path: testInfo.outputPath(`production-restore-${theme}-${width}.png`) });
|
||
const snapshot = production.grid.rows[0][1];
|
||
expect(snapshot).toMatch(/^prompts_backup_20261025_093000.db · (\d+ KB|\d+\.\d MB) · .+$/);
|
||
const { text: _intro, ...small } = conceptImport.note;
|
||
expect(diffInventory(mapRestoreConcept(conceptRestore, small, restoreRows(preview, snapshot)), production)).toEqual([]);
|
||
await page.keyboard.press('Escape');
|
||
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
|
||
await page.locator('#workspace-main').getByRole('button', { name: 'Import / inspect file', exact: true }).click();
|
||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Import & inspect');
|
||
await page.mouse.move(0, 0);
|
||
await page.screenshot({ path: testInfo.outputPath(`production-import-${theme}-${width}.png`) });
|
||
expect(diffInventory(mapImportConcept(conceptImport), await page.evaluate(measureDialog))).toEqual([]);
|
||
});
|
||
}
|
||
|
||
test('backup & export comparison detects mutations', async ({ page, request }) => {
|
||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||
const concept = await conceptBackups(page, 'dark');
|
||
const expected = mapBackupsConcept(concept);
|
||
await productionBackups(page, 'dark');
|
||
const measure = () => page.evaluate(measureBackups, '#workspace-main');
|
||
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
|
||
expect(await paths()).toEqual([]);
|
||
// Unmapped, the section differs exactly on the D103 and D104 paths (the added row's own paths, then the rest).
|
||
const unmapped = diffInventory(concept, await measure()).map(item => item.path).sort();
|
||
const added = 'surfaces.1.actions.1.';
|
||
expect(unmapped.some(path => path.startsWith(added))).toBe(true);
|
||
expect(unmapped.filter(path => !path.startsWith(added))).toEqual([
|
||
'policy.rows.0.text.1.1', 'policy.rows.1.control.options.1.1', 'policy.rows.1.control.options.2.1', 'policy.rows.1.text.1.1',
|
||
'policy.warning.text',
|
||
'status.text', 'surfaces.0.actions.0.buttons.0.text', 'surfaces.1.actions.0.buttons.0.text', 'surfaces.1.actions.length',
|
||
'surfaces.1.tree.3', 'surfaces.1.tree.length']);
|
||
// A renamed label, a removed row, a lost danger mark and a swapped token are noticed.
|
||
await page.evaluate(() => { document.querySelector('#workspace-main .setting-row label').textContent = 'Safety backup'; });
|
||
expect(await paths()).toEqual(['policy.rows.0.text.0.1']);
|
||
await page.evaluate(() => { document.querySelector('#workspace-main .setting-row label').textContent = 'Create a safety backup before maintenance'; });
|
||
const undoRow = await mutate(page, { remove: ['#workspace-main .settings-layout > section.surface > .setting-row:last-of-type'] });
|
||
expect(await paths()).toEqual(expect.arrayContaining(['policy.rows.length', 'policy.tree.length']));
|
||
await undoRow();
|
||
await page.evaluate(() => { document.querySelector('#workspace-main button.danger').classList.remove('danger'); });
|
||
expect(await paths()).toEqual(expect.arrayContaining(['surfaces.0.actions.0.buttons.1.className']));
|
||
await page.evaluate(() => { document.querySelector('#workspace-main .two-col .row-actions > button:nth-child(2)').classList.add('danger'); });
|
||
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();
|
||
const undoChip = await mutate(page, { css: '#workspace-main .chip.blue { color: rgb(255, 0, 0); }' });
|
||
expect(await paths()).toEqual(['policy.chip.color']);
|
||
await undoChip();
|
||
expect(await paths()).toEqual([]);
|
||
|
||
// The restore review: a renamed term, a lost danger mark and the grid's token are noticed.
|
||
const conceptImport = await conceptDialog(page, 'dark', 'import', 'Import & inspect');
|
||
const conceptRestore = await conceptDialog(page, 'dark', 'restore', 'Restore preview');
|
||
const { preview, production } = await productionRestore(page, request, 'dark');
|
||
const { text: _intro, ...small } = conceptImport.note;
|
||
const expectedRestore = mapRestoreConcept(conceptRestore, small, restoreRows(preview, production.grid.rows[0][1]));
|
||
const restorePaths = async () => diffInventory(expectedRestore, await page.evaluate(measureDialog)).map(item => item.path);
|
||
expect(await restorePaths()).toEqual([]);
|
||
await page.evaluate(() => { document.querySelector('dialog#modal[open] dl.meta-grid dt').textContent = 'Backup'; });
|
||
expect(await restorePaths()).toEqual(['grid.rows.0.0']);
|
||
await page.evaluate(() => { document.querySelector('dialog#modal[open] dl.meta-grid dt').textContent = 'Snapshot'; });
|
||
await page.evaluate(() => { document.querySelector('dialog#modal[open] .dialog-actions .danger').classList.remove('danger'); });
|
||
expect(await restorePaths()).toEqual(expect.arrayContaining(['actions.buttons.1.className']));
|
||
await page.evaluate(() => { document.querySelector('dialog#modal[open] .dialog-actions button:last-child').classList.add('danger'); });
|
||
const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' });
|
||
expect(await restorePaths()).toEqual(expect.arrayContaining(['grid.columnGap']));
|
||
await undoGrid();
|
||
expect(await restorePaths()).toEqual([]);
|
||
});
|