From c1ada9fa44fdbce2879f996b6f04e9fef1aede61 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 27 Oct 2026 07:00:19 -0700 Subject: [PATCH] test(system): compare Diagnostics & logs with the concept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- TESTING.md | 12 + tests/browser/concept-inventory.mjs | 70 ++++ .../system-diagnostics-concept.spec.mjs | 389 ++++++++++++++++++ .../system-maintenance-concept.spec.mjs | 70 +--- .../browser/workspace_diagnostics_fixture.py | 28 +- tests/js/workspace-system-diagnostics.test.js | 4 +- web/js/workspace/system-diagnostics.mjs | 2 +- 7 files changed, 503 insertions(+), 72 deletions(-) create mode 100644 tests/browser/system-diagnostics-concept.spec.mjs diff --git a/TESTING.md b/TESTING.md index 50f97cf6..498d8415 100644 --- a/TESTING.md +++ b/TESTING.md @@ -3800,6 +3800,18 @@ The browser server writes its logs into `/logs` times, rotated copies, a link (POSIX) and a user's `.bak`, and roll the copies over; no test reads or deletes a real log folder. +`tests/browser/system-diagnostics-concept.spec.mjs` compares the section and its log +cleanup review with the concept (dark and light, 1600x1100 and 390x844, no page scroll; +both selects show their whole option at 320 wide). The fixture's `{mode: "concept"}` seed +reproduces the concept's Logs sample: only its four buffer lines, a 1.4 MB active log with +three rotated copies and console logging off, so those values are compared as the concept +prints them. `mapDiagnosticsConcept` applies only D109 and D110, and `mapCleanupConcept` +D100 (the shared `mapPlanConcept`) with D109's rule for this plan (no backup box, "Not +needed: no library record changes", "Listed files"). The mutation check asserts that the +unmapped difference is exactly the deviated paths. The comparison found that the Database +line read `database_info.size_bytes`, a key the report does not have, so it always said +"0 bytes"; it reads `database_size_bytes`, the key `get_database_info()` returns. + ### Browser cache ownership (#222) The legacy gallery's Clear cache used to delete every localStorage key starting `pm_` or diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 4afd5858..d04d2fd3 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -676,3 +676,73 @@ export function measureInspector(selector) { primary:node.classList.contains('ca-primary'),minHeight:getComputedStyle(node).minHeight})), }; } + +// #249 System S7 Task 20, shared since S12 (#329): the maintenance plan dialog's measurement and its D100 mapping, +// moved unchanged from system-maintenance-concept.spec.mjs so the rotated-log review uses the same ones. +/** Runs in the page: the open review dialog (plan or duplicates): its title, body and the concept's tokens. */ +export 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') })) }, + }; +} + +export 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. + */ +export 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' }] }, + }; +} diff --git a/tests/browser/system-diagnostics-concept.spec.mjs b/tests/browser/system-diagnostics-concept.spec.mjs new file mode 100644 index 00000000..94e296d4 --- /dev/null +++ b/tests/browser/system-diagnostics-concept.spec.mjs @@ -0,0 +1,389 @@ +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 S12 Task 7 (#329): System › Diagnostics & logs (app.js renderSystem 694–695) and its log cleanup review +// (the concept's `maintenancePlan("rotated logs")`, 953–958) against the immutable concept, on the concept-shaped +// fixture with the logger writing into this server's own root and seeded with the concept's sample (four lines, a +// 1.4 MB active log, three rotated copies, console logging off). Each concept measurement is mapped only by the recorded +// deviations (D109, D110, and D100 for the review), each commented where it applies; after that the difference must be +// empty, and a removed term, a renamed button, a reordered option, a primary Refresh, a squeezed select or a swapped +// 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(); + await expect(await request.post('/__test__/logs/reset')).toBeOK(); + await expect(await request.post('/__test__/logs/seed', { data: { mode: 'concept' } })).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__/logs/reset'); + await request.post('/__test__/system/reset'); + expect(page.conceptErrors).toEqual([]); +}); + +const REPORT_URL = '/prompt_manager/system/diagnostics'; +const PREVIEW_URL = '/prompt_manager/maintenance/preview'; +const LINE_FORMAT = /^\d\d:\d\d:\d\d [A-Z]+ +\S/; + +/** Runs in the page: Diagnostics & logs under `mainSelector`: every surface's text, controls, geometry and tokens. */ +function measureDiagnostics([mainSelector, lineFormat]) { + const format = new RegExp(lineFormat); + const layout = document.querySelector(`${mainSelector} .settings-layout`); + const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null; + const tokens = (node, ...names) => Object.fromEntries(names.map(key => [key, getComputedStyle(node).getPropertyValue(key).trim()])); + 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 }); + 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, 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 } : null; + }; + const operations = parent => [...parent.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) })); + const toolbarItem = node => ({ name: name(node), text: node.localName === 'select' || node.localName === 'input' ? null : text(node), + for: node.localName === 'label' ? node.htmlFor : null, id: node.id || null, + options: node.localName === 'select' ? [...node.options].map(option => option.textContent) : null, + input: node.localName === 'input' ? [node.type, node.min, node.max, node.value] : null, + className: node.localName === 'button' ? node.className : null }); + const surfaces = [...layout.querySelectorAll(':scope > section.surface')]; + const pre = layout.querySelector('pre'); + const lines = (pre?.textContent ?? '').split('\n').filter(Boolean); + const details = layout.querySelector('details'); + const firstRow = layout.querySelector('.setting-row'); + const toolbar = layout.querySelector('.toolbar'); + return { + layout: [...layout.children].map(name), + surfaces: surfaces.map(surface => { + const heading = surface.querySelector(':scope > .panel-heading'); + const grid = surface.querySelector(':scope > .meta-grid'); + return { + tree: [...surface.children].map(name), + heading: text(surface.querySelector(':scope > h2, :scope > .panel-heading > h2')), + panelHeading: heading && [...heading.children].map(node => [name(node), text(node)]), + grid: grid && [...grid.children].map(entry => [name(entry), text(entry.querySelector('dt')), text(entry.querySelector('dd'))]), + buttons: [...surface.querySelectorAll(':scope > button')].map(button), + toolbar: surface.querySelector(':scope > .toolbar') && [...surface.querySelector(':scope > .toolbar').children].map(toolbarItem), + pre: surface.querySelector(':scope > pre') && { tabIndex: pre.tabIndex, tree: [...pre.children].map(name), lines, + format: lines.every(line => format.test(line)) }, + notes: [...surface.querySelectorAll(':scope > p')].map(node => [name(node), text(node), node.getAttribute('role'), + node.getAttribute('aria-label')]), + rows: [...surface.querySelectorAll(':scope > .setting-row')].map(row => ({ tree: [...row.children].map(name), + text: [...row.firstElementChild.children].map(node => [name(node), text(node), node.localName === 'label' ? node.htmlFor : null]), + control: control(row) })), + details: surface.querySelector(':scope > details') && { + summary: text(details.querySelector(':scope > summary')), tree: [...details.children].map(name), + notes: [...details.querySelectorAll(':scope > p')].map(node => [name(node), text(node)]), + buttons: [...details.querySelectorAll(':scope > button')].map(button), + files: [...details.querySelectorAll(':scope > .operation-list > .operation')].map(row => ({ + title: text(row.querySelector('h3')), detail: text(row.querySelector('p')), actions: [...row.querySelectorAll('.row-actions > button')].map(item => + [text(item), item.getAttribute('aria-label')]) })), + operations: operations(details) }, + operations: operations(surface), + }; + }), + tokens: tokens(surfaces[0], '--line', '--panel', '--accent'), + surface: css(surfaces[1], 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius', + 'marginTop'), + grid: css(layout.querySelector('.meta-grid'), 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'), + toolbar: css(toolbar, 'display', 'flexWrap', 'alignItems', 'columnGap', 'rowGap', 'marginBottom'), + pre: css(pre, 'fontFamily', 'fontSize', 'backgroundColor', 'borderTopColor', 'borderTopLeftRadius', 'paddingTop', 'overflowX'), + row: css(firstRow, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', 'borderTopWidth', + 'borderTopColor'), + label: css(firstRow.querySelector('label'), 'fontSize', 'fontWeight', 'color'), + description: css(firstRow.querySelector('p'), 'fontSize', 'color', 'marginTop'), + select: css(firstRow.querySelector('select'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor', 'color', 'borderTopColor'), + summary: css(details.querySelector('summary'), 'fontSize', 'fontWeight', 'color', 'cursor'), + small: css(details.querySelector(':scope > p.small'), 'fontSize', 'color'), + // Whether each select is as wide as the browser lays out a copy holding only its chosen option, i.e. it shows + // that option whole (the browser's own select sizing, arrow included). + selectsFit: [...layout.querySelectorAll('select')].map(select => { + const copy = select.cloneNode(false); + copy.removeAttribute('id'); + copy.append(new Option(select.selectedOptions[0]?.textContent ?? '')); + Object.assign(copy.style, { position: 'absolute', visibility: 'hidden', width: 'max-content', minWidth: '0', + maxWidth: 'none', flex: 'none' }); + select.parentElement.append(copy); + const needed = copy.getBoundingClientRect().width; + copy.remove(); + return select.getBoundingClientRect().width + 0.5 >= needed; + }), + pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth, + }; +} + +const plural = (count, one, many = `${one}s`) => `${count.toLocaleString('en-US')} ${count === 1 ? one : many}`; +/** The server's size label (1 KB = 1024 bytes), as the section prints it. */ +function sizeText(bytes) { + if (bytes < 1024) return plural(bytes, 'byte'); + if (bytes < 2 ** 20) return `${(bytes / 2 ** 10).toFixed(1)} KB`; + if (bytes < 2 ** 30) return `${(bytes / 2 ** 20).toFixed(1)} MB`; + return `${(bytes / 2 ** 30).toFixed(1)} GB`; +} + +const KINDS = Object.freeze({ active: 'Active log', rotated: 'Rotated copy', other: 'Other file' }); +const RENDERER_NONE = 'No ComfyUI window is open in this browser, so its renderer is not known.'; +const COPY = Object.freeze({ + testLink: 'Write a placeholder image record to check that linking works.', + runtimeLevel: 'How much the server writes to the log. Applied until the server restarts.', + console: 'Print Prompt Manager’s warnings and errors in the ComfyUI console (everything at DEBUG). Applied until the server restarts.', + clear: 'Empty the active log and delete its rotated copies.', + retention: 'Rotates at 10 MB · keeps 5 rotated copies · file logging on · memory buffer 1,000 entries', +}); + +/** + * The concept's Diagnostics & logs with the recorded deviations applied, for the server's `report` and the seeded + * `files` (`/logs/files`). The concept's own sample values the fixture reproduces stay as they are: the four log lines, + * the Viewer.js line, "prompt_manager.log · 1.4 MB · rotated copies: 3", INFO and console logging off. + */ +function mapDiagnosticsConcept(reference, { report, files, stats }) { + const [diagnostics, logs] = reference.surfaces; + const [levelLabel, level, refresh, download] = logs.toolbar; + const [runtime, consoleRow] = logs.rows; + const database = report.checks.database; + const { configured, watching, unavailable } = report.sources; + const sources = configured === 0 ? `ComfyUI output folder · ${watching ? 'watching' : 'manual scan'}` + : unavailable > 0 ? `${plural(configured, 'source')} · ${unavailable} not available` + : `${plural(configured, 'source')} · ${watching} watching`; + const errors = (stats.level_counts.ERROR ?? 0) + (stats.level_counts.CRITICAL ?? 0); + return { + ...reference, + surfaces: [ + { ...diagnostics, + // D110: the Test image link operation follows the concept's Image-link diagnostics. + tree: [...diagnostics.tree, 'div.operation'], + // D110: live values. Database and Sources from `GET /system/diagnostics`, Host renderers is D106's + // renderer line (no ComfyUI window in this browser); the pinned Viewer.js line is the concept's own. + grid: diagnostics.grid.map(([entry, term, value]) => [entry, term, { + Database: `Readable · ${sizeText(report.database_info.database_size_bytes)} · ${plural(database.image_count, 'image')}`, + Sources: sources, + 'Host renderers': RENDERER_NONE }[term] ?? value]), + // D110: the buttons drop the concept's "sample". + buttons: diagnostics.buttons.map(item => ({ ...item, text: item.text.replace('Download sample diagnostic report', + 'Download diagnostic report') })), + operations: [ + // D110: "Inspect" opens the read-only Inspect a file page. + ...diagnostics.operations.map(row => ({ ...row, actions: row.actions.map(item => ({ ...item, text: 'Inspect' })) })), + // D110: the legacy synthetic link (S12-11), enabled: the fixture has a most recent prompt. + { ...diagnostics.operations[0], title: 'Test image link', description: COPY.testLink, + actions: [{ ...diagnostics.operations[0].actions[0], text: 'Create test link…' }] }, + ] }, + { ...logs, + // D109: the statistics line follows the viewer. + tree: [logs.tree[0], logs.tree[1], logs.tree[2], 'p.small', ...logs.tree.slice(3)], + toolbar: [ + levelLabel, + // D109: all six minimum levels (the concept has three). + { ...level, options: ['All levels', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] }, + // D109: the added Lines control, 1–500, default 100. + { name: 'label.small', text: 'Lines', for: 'log-limit', id: null, options: null, input: null, className: null }, + { name: 'input', text: null, for: null, id: 'log-limit', options: null, input: ['number', '1', '500', '100'], + className: null }, + refresh, + // D109: the active log, not a sample. + { ...download, text: 'Download log' }, + ], + // D109: "Log statistics" (errors are ERROR + CRITICAL). + notes: [['p.small', `${plural(stats.buffer_count, 'entry', 'entries')} in memory · ${plural(errors, 'error')} · ` + + `${plural(stats.level_counts.WARNING ?? 0, 'warning')} · ${plural(stats.log_files_count, 'file')} · ` + + `${sizeText(stats.total_log_size)} on disk`, 'status', 'Log statistics']], + rows: [ + // D109: CRITICAL after the concept's levels; the value lasts until the server restarts. + { ...runtime, text: [runtime.text[0], ['p', COPY.runtimeLevel, null]], + control: { ...runtime.control, options: [...runtime.control.options, 'CRITICAL'] } }, + // D109: what the console setting does and that it lasts until the server restarts (off, as the + // concept's sample, by the fixture's seed). + { ...consoleRow, text: [consoleRow.text[0], ['p', COPY.console, null]] }, + ], + details: { ...logs.details, + // D109: the retention line, the files and Clear all logs follow the concept's line and review. + tree: [...logs.details.tree, 'p.small', 'div.operation-list', 'div.operation'], + notes: [...logs.details.notes, ['p.small', COPY.retention]], + files: files.map(row => ({ title: row.filename, detail: `${sizeText(row.size)} · ${row.modified.slice(0, 16) + .replace('T', ' ')} · ${KINDS[row.kind]}`, actions: [['Download', `Download ${row.filename}`]] })), + operations: [{ tree: ['div', 'div.row-actions'], title: 'Clear all logs', description: COPY.clear, + actions: [{ text: 'Clear all logs…', className: 'danger' }] }] } }, + ], + }; +} + +async function conceptDiagnostics(page, theme) { + await openConcept(page, 'system', theme); + await page.locator('#main .section-nav').getByRole('button', { name: 'Diagnostics & logs', exact: true }).click(); + await expect(page.locator('#main .settings-layout > section.surface')).toHaveCount(2); + await page.mouse.move(0, 0); + return page.evaluate(measureDiagnostics, ['#main', LINE_FORMAT.source]); +} + +/** Production's section once every read answered, with the server's report, files and statistics it shows. */ +async function productionDiagnostics(page, request, theme) { + await openProduction(page, 'system/diagnostics', theme); + const main = page.locator('#workspace-main'); + await expect(main.locator('.settings-layout > section.surface')).toHaveCount(2); + await expect(main.locator('.meta-grid dd')).not.toContainText(['Loading…']); + await expect(main.locator('.meta-grid dd').last()).toHaveText(RENDERER_NONE); + await expect(main.locator('#log-lines')).not.toHaveText('Loading…'); + await expect(main.locator('#log-stats')).not.toHaveText('Loading…'); + await expect(main.locator('details > p.small')).not.toContainText(['Loading…']); + await expect(main.locator('#runtime-level')).toBeEnabled(); + await expect(main.locator('.operation').filter({ hasText: 'Create test link…' }).locator('button')).not.toHaveAttribute('aria-disabled', 'true'); + await page.mouse.move(0, 0); + const production = await page.evaluate(measureDiagnostics, ['#workspace-main', LINE_FORMAT.source]); + const read = async url => (await request.get(url)).json(); + const report = await read(REPORT_URL); + const { files } = await read('/prompt_manager/logs/files'); + const { stats } = await read('/prompt_manager/logs/stats'); + return { production, report, files, stats }; +} + +for (const theme of THEMES) for (const [width, height] of SIZES) { + test(`diagnostics and logs match the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => { + await page.setViewportSize({ width, height }); + const concept = await conceptDiagnostics(page, theme); + await page.screenshot({ path: testInfo.outputPath(`concept-diagnostics-${theme}-${width}.png`), fullPage: true }); + const { production, ...live } = await productionDiagnostics(page, request, theme); + await page.screenshot({ path: testInfo.outputPath(`production-diagnostics-${theme}-${width}.png`), fullPage: true }); + expect(diffInventory(mapDiagnosticsConcept(concept, live), production)).toEqual([]); + expect(production.surfaces[1].pre.format).toBe(true); + expect(production.pageScroll).toBe(false); + }); +} + +// Below the concept's smallest size: on a 320px phone the Level and Runtime log level selects still show their whole +// option, without page scroll. +for (const theme of THEMES) test(`diagnostics selects fit at 320 wide ${theme}`, async ({ page, request }) => { + await page.setViewportSize({ width: 320, height: 700 }); + const { production } = await productionDiagnostics(page, request, theme); + expect(production.selectsFit).toEqual([true, true]); + expect(production.pageScroll).toBe(false); +}); + +/** + * D100 with D109's rule for this plan: the server's `rotated_logs` plan in the concept's dialog, "Rotated log copies" + * as its `h3`, the plan's grid values (its Backup row says none is needed), "Listed files (N)", the hold line, no D55 + * backup box (nothing in the library changes) and "Apply reviewed operation". + */ +function mapCleanupConcept(reference, plan) { + const mapped = mapPlanConcept(reference, 'Rotated log copies', plan); + return { + ...mapped, + // D109: no backup box for a plan that changes no library record. + tree: mapped.tree.filter(item => item !== 'div.notice'), + grid: { ...mapped.grid, rows: mapped.grid.rows.map(([term, value]) => + [term, term === 'Backup' ? 'Not needed: no library record changes' : value]) }, + backup: null, + // D109: the plan lists files, not records. + details: { ...mapped.details, summary: `Listed files (${plan.details.length})` }, + }; +} + +async function conceptCleanup(page, theme) { + await conceptDiagnostics(page, theme); + await page.locator('#main details > summary').click(); + await page.locator('#main [data-plan="rotated logs"]').click(); + await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan'); + await page.mouse.move(0, 0); + return page.evaluate(measureReview); +} + +async function productionCleanup(page, request, theme) { + await productionDiagnostics(page, request, theme); + await page.locator('#workspace-main details > summary').click(); + const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL)); + await page.locator('#workspace-main').getByRole('button', { name: 'Review log cleanup', exact: true }).click(); + const plan = await (await answer).json(); + await expect(page.locator('dialog#modal[open] #modal-body > h3')).toHaveText('Rotated log copies'); + 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(`log cleanup review matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => { + await page.setViewportSize({ width, height }); + const concept = await conceptCleanup(page, theme); + expect(concept.heading.text).toBe('rotated logs'); + expect(concept.tree).toEqual(['p.demo-note', 'h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions']); + await page.screenshot({ path: testInfo.outputPath(`concept-log-cleanup-${theme}-${width}.png`) }); + const { plan, production } = await productionCleanup(page, request, theme); + expect(plan.operation).toBe('rotated_logs'); + await page.screenshot({ path: testInfo.outputPath(`production-log-cleanup-${theme}-${width}.png`) }); + expect(diffInventory(mapCleanupConcept(concept, plan), production)).toEqual([]); + }); +} + +// Every path where production differs from the unmapped concept: D110's live values (Viewer.js is the concept's own), +// renamed buttons and added operation, and D109's toolbar, statistics line, runtime rows and log files. +const DEVIATED_PATHS = [ + 'surfaces.0.buttons.0', 'surfaces.0.grid.0', 'surfaces.0.grid.1', 'surfaces.0.grid.3', 'surfaces.0.operations.0', + 'surfaces.0.operations.1', 'surfaces.0.operations.length', 'surfaces.0.tree.4', 'surfaces.0.tree.length', + 'surfaces.1.details.files', 'surfaces.1.details.notes', 'surfaces.1.details.operations', 'surfaces.1.details.tree', + 'surfaces.1.notes.0', 'surfaces.1.notes.length', 'surfaces.1.rows.0', 'surfaces.1.rows.1', 'surfaces.1.toolbar.1', + 'surfaces.1.toolbar.2', 'surfaces.1.toolbar.3', 'surfaces.1.toolbar.4', 'surfaces.1.toolbar.5', 'surfaces.1.toolbar.length', + 'surfaces.1.tree.3', 'surfaces.1.tree.5', 'surfaces.1.tree.6', 'surfaces.1.tree.length'].sort(); + +test('diagnostics comparison detects mutations', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const concept = await conceptDiagnostics(page, 'dark'); + const { production, ...live } = await productionDiagnostics(page, request, 'dark'); + const expected = mapDiagnosticsConcept(concept, live); + expect(diffInventory(expected, production)).toEqual([]); + // Unmapped, the section differs only where D109 and D110 are recorded. + expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 4).join('.')) + .filter((path, index, all) => all.indexOf(path) === index).sort()).toEqual(DEVIATED_PATHS); + const measure = () => page.evaluate(measureDiagnostics, ['#workspace-main', LINE_FORMAT.source]); + const paths = async () => diffInventory(expected, await measure()).map(item => item.path); + // A removed term. + const undoTerm = await mutate(page, { remove: ['#workspace-main .meta-grid > div:nth-child(2)'] }); + expect(await paths()).toEqual(expect.arrayContaining(['surfaces.0.grid.length'])); + await undoTerm(); + // A renamed button. + await page.evaluate(() => { document.querySelector('#workspace-main section.surface > button').textContent = 'Download report'; }); + expect(await paths()).toEqual(['surfaces.0.buttons.0.text']); + await page.evaluate(() => { document.querySelector('#workspace-main section.surface > button').textContent = 'Download diagnostic report'; }); + // A reordered level option. + await page.evaluate(() => { const select = document.querySelector('#workspace-main #log-level'); select.append(select.options[1]); }); + expect(await paths()).toEqual(expect.arrayContaining(['surfaces.1.toolbar.1.options.1'])); + await page.evaluate(() => { const select = document.querySelector('#workspace-main #log-level'); select.insertBefore(select.options[5], select.options[1]); }); + // A primary Refresh. + await page.evaluate(() => { document.querySelectorAll('#workspace-main .toolbar > button')[0].classList.add('ca-primary'); }); + expect(await paths()).toEqual(expect.arrayContaining(['surfaces.1.toolbar.4.className'])); + await page.evaluate(() => { document.querySelectorAll('#workspace-main .toolbar > button')[0].classList.remove('ca-primary'); }); + // A select squeezed until its chosen option is cut. + const undoSqueeze = await mutate(page, { css: '#workspace-main #runtime-level { max-width: 30px !important; min-width: 0 !important; }' }); + expect(await paths()).toEqual(expect.arrayContaining(['selectsFit.1'])); + await undoSqueeze(); + // A swapped token. + const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' }); + expect(await paths()).toEqual(expect.arrayContaining(['tokens.--line', 'row.borderTopColor'])); + await undoToken(); + expect(diffInventory(expected, await measure())).toEqual([]); + + // The log cleanup review: a changed grid term, a backup box that came back and a grid token are noticed. + const conceptReview = await conceptCleanup(page, 'dark'); + const { plan } = await productionCleanup(page, request, 'dark'); + const expectedReview = mapCleanupConcept(conceptReview, plan); + const reviewPaths = async () => diffInventory(expectedReview, await page.evaluate(measureReview)).map(item => item.path); + expect(await reviewPaths()).toEqual([]); + await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[2].textContent = 'Safety'; }); + expect(await reviewPaths()).toEqual(['grid.rows.2.0']); + await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[2].textContent = 'Backup'; }); + const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' }); + expect(await reviewPaths()).toEqual(expect.arrayContaining(['grid.columnGap'])); + await undoGrid(); + expect(await reviewPaths()).toEqual([]); +}); + diff --git a/tests/browser/system-maintenance-concept.spec.mjs b/tests/browser/system-maintenance-concept.spec.mjs index 89812cd6..cf2007dd 100644 --- a/tests/browser/system-maintenance-concept.spec.mjs +++ b/tests/browser/system-maintenance-concept.spec.mjs @@ -1,6 +1,7 @@ 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'; +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 @@ -103,47 +104,6 @@ for (const theme of THEMES) for (const [width, height] of SIZES) { }); } -/** 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'], @@ -151,32 +111,6 @@ const PLANS = Object.freeze([ ['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); diff --git a/tests/browser/workspace_diagnostics_fixture.py b/tests/browser/workspace_diagnostics_fixture.py index cefecba3..3cbdb505 100644 --- a/tests/browser/workspace_diagnostics_fixture.py +++ b/tests/browser/workspace_diagnostics_fixture.py @@ -14,7 +14,11 @@ real log folder. Every route refuses a log folder outside the server root. more INFO records older still), ``active`` bytes into the active log, rotated copies ``prompt_manager.log.`` of the listed sizes (fixed times), on POSIX a symlink ``prompt_manager.log.`` to a file outside - the folder (``link``) and a user's ``prompt_manager.log.bak`` (``other``); + the folder (``link``) and a user's ``prompt_manager.log.bak`` (``other``). + ``{mode: "concept"}`` reproduces the concept's Logs sample (app.js 695) + for the comparison: the buffer holds only its four lines, the active log + is 1.4 MB with three rotated copies ("prompt_manager.log · 1.4 MB · rotated + copies: 3") and console logging is off, as the concept's toggle; - ``POST /__test__/logs/rotate`` rolls the copies over as the handler does (``.2`` → ``.3``, ``.1`` → ``.2``, a new ``.1``), so a reviewed plan no longer matches; @@ -48,6 +52,10 @@ LEVEL_RECORDS = ( ("09:30:03", logging.CRITICAL, "The database could not be opened"), ) ROTATED_TIME = datetime.datetime(2026, 10, 25, 14, 2) +# The concept's "prompt_manager.log · 1.4 MB · rotated copies: 3": the active +# log stays under 1.45 MB while the server writes its own lines. +CONCEPT_ACTIVE = int(1.4 * 1024 * 1024) +CONCEPT_ROTATED = (3072, 2048, 1024) MAX_SEED_BYTES = 64 * 1024 * 1024 DEFAULT_CONFIG = { "level": "INFO", @@ -135,7 +143,25 @@ def register_diagnostics_commands(app, routes, api, root: Path): with manager._buffer_lock: manager._log_buffer.clear() + def seed_concept() -> None: + manager = _manager() + log_dir = folder() + manager.update_config({"console_logging": False}) + with manager._buffer_lock: + manager._log_buffer.clear() + for index, (time_text, level, message) in enumerate(CONCEPT_RECORDS): + record = _record(time_text, level, message, index) + manager.add_to_buffer(record, record.getMessage()) + with open(log_dir / ACTIVE, "ab") as handle: + handle.write(b"x" * CONCEPT_ACTIVE) + for number, size in enumerate(CONCEPT_ROTATED, start=1): + moment = ROTATED_TIME - datetime.timedelta(hours=number) + _touch(log_dir / f"{ACTIVE}.{number}", size, moment) + def seed(data) -> None: + if data.get("mode") == "concept": + seed_concept() + return manager = _manager() log_dir = folder() with_records = data.get("records", True) is not False diff --git a/tests/js/workspace-system-diagnostics.test.js b/tests/js/workspace-system-diagnostics.test.js index 58348dae..65b0bddb 100644 --- a/tests/js/workspace-system-diagnostics.test.js +++ b/tests/js/workspace-system-diagnostics.test.js @@ -14,7 +14,7 @@ const SYSTEM = fs.readFileSync(path.join(__dirname, '../../web/js/workspace/syst const REPORT = { checks: { database: { status: 'ok', prompt_count: 4210, has_images_table: true, image_count: 19956 } }, - database_info: { database_path: 'user/__promptmanager/prompts.db', size_bytes: 1717986918 }, + database_info: { database_path: 'user/__promptmanager/prompts.db', database_size_bytes: 1717986918 }, sources: { configured: 2, effective: 2, watching: 2, unavailable: 0 }, viewerjs: '1.15.2', }; @@ -45,7 +45,7 @@ test('database text covers readable, not readable, busy and loading', async () = const { databaseText, LOADING, UNREADABLE } = await diagnostics; assert.equal(databaseText({ report: REPORT }), 'Readable · 1.6 GB · 19,956 images'); assert.equal(databaseText({ report: { ...REPORT, checks: { database: { status: 'ok', image_count: 1 } }, - database_info: { size_bytes: 2048 } } }), 'Readable · 2.0 KB · 1 image'); + database_info: { database_size_bytes: 2048 } } }), 'Readable · 2.0 KB · 1 image'); assert.equal(databaseText({ report: { checks: { database: { status: 'error', message: 'Database error: locked' } } } }), 'Not readable: Database error: locked'); assert.equal(databaseText({ loading: true }), LOADING); diff --git a/web/js/workspace/system-diagnostics.mjs b/web/js/workspace/system-diagnostics.mjs index 4e0c33d6..a9bfb52a 100644 --- a/web/js/workspace/system-diagnostics.mjs +++ b/web/js/workspace/system-diagnostics.mjs @@ -112,7 +112,7 @@ export function databaseText(state) { const check = report?.checks?.database; if (check?.status !== 'ok') return `Not readable: ${check?.message || 'no answer from the database'}`; const images = Number.isSafeInteger(check.image_count) ? check.image_count : 0; - return `Readable · ${sizeText(report.database_info?.size_bytes)} · ${number(images)} ${noun(images, 'image')}`; + return `Readable · ${sizeText(report.database_info?.database_size_bytes)} · ${number(images)} ${noun(images, 'image')}`; }); }