* fix(logs): declare the compressed length so a log download finishes The gzip middleware compressed a text body but kept the handler's declared Content-Length (the uncompressed size), so a browser waited for bytes that never came: downloading any log over 1 KB hung, in the workspace and on the admin page. The middleware now drops a declared length before compressing. Part of #329 * feat(system): show Diagnostics & logs natively and retire the hand-off panel System › Diagnostics & logs is native (D109, D110): the concept's Diagnostics surface with the live database, source, Viewer.js and renderer lines, Run checks (the shared Health report), the diagnostic report, Inspect and the confirmed test link; and the Logs surface with the minimum-level viewer, Lines 1-500, statistics, the log download, the runtime level and console setting saved by Save preferences, the log files with their downloads, Review log cleanup (the rotated_logs maintenance plan, no backup box, Listed files) and a confirmed Clear all logs. D94's hand-off panel, SECTION_HANDOFFS and the undelivered runtime adapter are gone. The browser server logs into its own temporary root. Part of #329 * test(system): compare Diagnostics & logs with the concept System › Diagnostics & logs and its log cleanup review are compared with the concept (app.js 694–695 and maintenancePlan("rotated logs")) in both themes at 1600x1100 and 390x844, with D109 and D110 (and D100 for the review) as the only mapped differences, a 320-wide select check and a mutation check. The fixture gains a concept seed mode (the concept's four log lines, a 1.4 MB active log with three rotated copies, console logging off), and the plan dialog's measureReview and mapPlanConcept move unchanged into concept-inventory.mjs for Maintenance and this review. The comparison found that the Database line read a report key that does not exist (database_info.size_bytes) and so always showed "0 bytes"; it now reads database_size_bytes, the key get_database_info() returns. Part of #329 * test(system): prove every legacy System capability has an owner TESTING.md gains "Legacy System capability coverage": every System capability of the legacy admin and gallery pages with its anchor on main, owner, status (covered, kept in legacy or approved removal), native control and the exact title of the test that proves it. tests/vendor/legacy-coverage.test.mjs (with tools/vendor/legacy-coverage.mjs) fails on a row with no owner, an unknown status, a malformed native control, a control on a row that is not covered, or a test title its file does not declare; a title only in a comment does not count. workspace-system.spec's "every covered legacy System capability has its native control" opens each covered row's section and finds the control by role and accessible name. Versus the design's table: the anchors are re-checked against main, "Run health checks" is on Maintenance (D123), not Overview, Diagnostics run is proven by "Run checks opens the shared Health report", and the export rows repeat their test title instead of "(same as above)". Part of #329 * fix(system): confirm Clear all logs on a fresh file list and record coverage Review fixes for the Diagnostics & logs section: - Clear all logs reads the log files again before it confirms, so the dialog names the copies it deletes now (a list it cannot read gives the general sentence, never an old count); Refresh reads the files too. - Runtime values that cannot be read stay disabled and say so. - Create test link holds its button until the write answered, so a second press cannot write a second record. - The Lines field names its range. - The browser server builds its logger in its own root, so no log file is ever opened in another folder; the fixture closes every directory scan and replaces a link instead of writing through it. - system.mjs drops the unused section fallback; frontend coverage floors recorded with coverage:update (system-diagnostics.mjs 97.5% lines). Part of #329 * fix(system): read the database size the report sends The Database line read database_info.size_bytes, a key the report does not have (get_database_info answers database_size_bytes), so it always read "Readable · 0 bytes · N images". The browser test now requires a non-zero size. Found by PR 3's concept comparison. Part of #329 * test(system): require a running test and a control for every covered capability Review fixes for the legacy coverage proof: - A title declared only with test.skip, .fixme or .fail no longer proves a row (.only and .slow still do). - Every covered row names its native control, except the rows in CONTROL_EXEMPTIONS, each with its reason (today only the gallery per-image auto-tag row, owned by the Images selection bar). The browser test asserts that the covered rows without a control are exactly the exemptions. - JavaScript comments are stripped across the whole source, wherever a block comment starts, skipping strings, template literals and regular expressions. A Python def test_ counts only at function or method indentation and outside comments and docstrings. - A control name several rows share ("Review plan", "Review options", "Refresh") is looked up inside the row the table names (· in "<row>"). - The diagnostics comparison records each select's chosen option, so a wrong default level is caught. Part of #329
267 lines
17 KiB
JavaScript
267 lines
17 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, measureReview, mapPlanConcept }
|
||
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);
|
||
});
|
||
}
|
||
|
||
// 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'],
|
||
]);
|
||
|
||
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([]);
|
||
});
|