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')}`; }); }