* 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 * 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
591 lines
38 KiB
JavaScript
591 lines
38 KiB
JavaScript
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||
import { test, expect } from './fixtures.mjs';
|
||
import { seedConcept } from './concept-inventory.mjs';
|
||
|
||
// System › Diagnostics & logs (#249 System S12, #329) on the concept-shaped fixture, with the logger writing into this
|
||
// server's own root (workspace_diagnostics_fixture.py): the meta-grid's live values, Run checks, the report, Inspect and
|
||
// the test link (D110); the log viewer, statistics, downloads and runtime drafts (D109); and the reviewed rotated-log
|
||
// cleanup (D100) and Clear all logs, which delete only inside that temporary log folder.
|
||
const workspace = '/prompt_manager/workspace';
|
||
const REPORT_URL = '/prompt_manager/system/diagnostics';
|
||
const LOGS_URL = '/prompt_manager/logs';
|
||
const CONFIG_URL = '/prompt_manager/logs/config';
|
||
const PREVIEW_URL = '/prompt_manager/maintenance/preview';
|
||
const APPLY_URL = '/prompt_manager/maintenance/apply';
|
||
const TEST_LINK_URL = '/prompt_manager/diagnostics/test-link';
|
||
const CHANGED = 'The log files changed since the review; nothing was deleted. Review again.';
|
||
const KEY = 'sk-s12-browser-SECRET-0123456789';
|
||
const LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
|
||
|
||
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__/integrations/reset')).toBeOK();
|
||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||
await expect(await request.post('/__test__/logs/reset')).toBeOK();
|
||
page.systemErrors = [];
|
||
page.on('pageerror', error => page.systemErrors.push(error.message));
|
||
});
|
||
|
||
test.afterEach(async ({ page, request, browserName }) => {
|
||
await saveCoverage(page, browserName);
|
||
await request.post('/__test__/release');
|
||
await request.post('/__test__/jobs/restart');
|
||
await request.post('/__test__/logs/reset');
|
||
await request.post('/__test__/integrations/reset');
|
||
await request.post('/__test__/system/reset');
|
||
expect(page.systemErrors).toEqual([]);
|
||
});
|
||
|
||
const toast = page => page.locator('#workspace-toast');
|
||
const layoutOf = page => page.locator('#workspace-main .settings-layout');
|
||
const dialog = page => page.locator('dialog#modal[open]');
|
||
const surfaceOf = (page, title) => layoutOf(page).locator(':scope > section.surface').filter({
|
||
has: page.getByRole('heading', { level: 2, name: title, exact: true }) });
|
||
const cell = (page, term) => surfaceOf(page, 'Diagnostics').locator('.meta-grid > div').filter({
|
||
has: page.locator(`dt:text-is("${term}")`) }).locator('dd');
|
||
const lines = page => page.locator('#log-lines > code');
|
||
const operation = (page, title) => layoutOf(page).locator('.operation').filter({ has: page.locator(`h3:text-is("${title}")`) });
|
||
const settingOf = (page, label) => layoutOf(page).locator('.setting-row').filter({ has: page.locator(`label:text-is("${label}")`) });
|
||
const status = page => page.locator('#settings-status');
|
||
const save = page => page.getByRole('button', { name: 'Save preferences', exact: true });
|
||
|
||
async function serverJson(request, url) {
|
||
const response = await request.get(url);
|
||
await expect(response).toBeOK();
|
||
return response.json();
|
||
}
|
||
|
||
async function seedLogs(request, data = {}) {
|
||
await expect(await request.post('/__test__/logs/seed', { data })).toBeOK();
|
||
}
|
||
|
||
const names = async request => (await serverJson(request, '/__test__/logs/state')).entries.map(entry => entry.name);
|
||
|
||
async function openDiagnostics(page) {
|
||
if (page.url().endsWith('#/system/diagnostics')) await page.reload();
|
||
else await page.goto(`${workspace}#/system/diagnostics`);
|
||
await expect(layoutOf(page).locator(':scope > section.surface')).toHaveCount(2);
|
||
await expect(lines(page)).not.toHaveText('Loading…');
|
||
await expect(cell(page, 'Database')).not.toHaveText('Loading…');
|
||
// The renderer line settles first: its text can move the rows below.
|
||
await expect(cell(page, 'Host renderers')).not.toHaveText('Loading…');
|
||
await expect(page.locator('#log-stats')).not.toHaveText('Loading…');
|
||
return layoutOf(page);
|
||
}
|
||
|
||
async function openFiles(page) {
|
||
const details = surfaceOf(page, 'Logs').locator('details');
|
||
if (!(await details.evaluate(node => node.open))) await details.locator('summary').click();
|
||
await expect(details.locator(':scope > p.small').first()).not.toHaveText('Loading…');
|
||
return details;
|
||
}
|
||
|
||
/** The levels of the viewer's lines, in order. */
|
||
const shownLevels = async page => (await lines(page).textContent()).split('\n').filter(Boolean)
|
||
.map(line => line.split(/ +/)[1]);
|
||
|
||
async function ended(request, id) {
|
||
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).not.toBe('running');
|
||
return (await serverJson(request, `/prompt_manager/jobs/${id}`)).job;
|
||
}
|
||
|
||
/** A second page of this origin that serves a fake ComfyUI window on the send bridge. */
|
||
async function fakeComfyWindow(context, renderer) {
|
||
const host = await context.newPage();
|
||
await host.goto(`${workspace}#/overview`);
|
||
await host.evaluate(async drawn => {
|
||
const { createBridgeHost } = await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||
window.fakeHost = createBridgeHost({ namespace: '/', origin: location.origin, label: 'PromptManager — ComfyUI',
|
||
adapter: { listTargets: () => [{ id: '7', label: 'Prompt Manager', type: 'PromptManager', roles: ['positive'],
|
||
renderer: drawn }], preview() { throw new Error('unused'); }, apply() { throw new Error('unused'); } } });
|
||
}, renderer);
|
||
return host;
|
||
}
|
||
|
||
async function readBlob(download) {
|
||
const chunks = await (await download.createReadStream()).toArray();
|
||
return Buffer.concat(chunks).toString('utf8');
|
||
}
|
||
|
||
// ── Task 4: the Diagnostics surface ─────────────────────────────────────────────────────────────────────────────────
|
||
|
||
test('meta-grid shows real database and source status, the pinned Viewer.js version and the host renderers',
|
||
async ({ page, request, context }) => {
|
||
const mutations = [];
|
||
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); });
|
||
await openDiagnostics(page);
|
||
const report = await serverJson(request, REPORT_URL);
|
||
const surface = surfaceOf(page, 'Diagnostics');
|
||
await expect(surface.locator(':scope > .panel-heading > h2')).toHaveText('Diagnostics');
|
||
await expect(surface.locator(':scope > .panel-heading > button')).toHaveText('Run checks');
|
||
await expect(surface.locator('.meta-grid dt')).toHaveText(['Database', 'Sources', 'Viewer.js', 'Host renderers']);
|
||
const images = report.checks.database.image_count;
|
||
await expect(cell(page, 'Database')).toHaveText(new RegExp(`^Readable · [\\d.]+ (bytes|KB|MB|GB) · ${images.toLocaleString('en-US')} images?$`));
|
||
// The size is the database's real size (`database_size_bytes`), never a missing key read as 0.
|
||
expect(report.database_info.database_size_bytes).toBeGreaterThan(0);
|
||
await expect(cell(page, 'Database')).not.toContainText('· 0 bytes ·');
|
||
const { configured, watching } = report.sources;
|
||
await expect(cell(page, 'Sources')).toHaveText(configured
|
||
? `${configured} source${configured === 1 ? '' : 's'} · ${watching} watching`
|
||
: `ComfyUI output folder · ${watching ? 'watching' : 'manual scan'}`);
|
||
await expect(cell(page, 'Viewer.js')).toHaveText('1.15.2 · pinned vendor');
|
||
await expect(cell(page, 'Host renderers')).toHaveText('No ComfyUI window is open in this browser, so its renderer is not known.');
|
||
// A ComfyUI window with a Classic node: the line Integrations shows (D106).
|
||
const host = await fakeComfyWindow(context, 'classic');
|
||
await openDiagnostics(page);
|
||
await expect(cell(page, 'Host renderers')).toHaveText('Detected: Classic (1 ComfyUI window).');
|
||
await host.close();
|
||
// A refused read names the running job (D57's naming).
|
||
await page.route(`**${REPORT_URL}`, route => route.fulfill({ status: 409, contentType: 'application/json',
|
||
body: JSON.stringify({ success: false, code: 'busy', error: 'busy', running: { id: 'a'.repeat(32), title: 'Restore' } }) }));
|
||
await openDiagnostics(page);
|
||
await expect(cell(page, 'Database')).toHaveText('Busy: Restore is running.');
|
||
await page.unroute(`**${REPORT_URL}`);
|
||
await expect(await request.post('/__test__/system/fail', { data: { path: REPORT_URL, times: 1 } })).toBeOK();
|
||
await openDiagnostics(page);
|
||
await expect(cell(page, 'Sources')).toHaveText('Could not be read.');
|
||
expect(mutations).toEqual([]);
|
||
});
|
||
|
||
test('Run checks opens the shared Health report', async ({ page, request }) => {
|
||
const before = await serverJson(request, '/__test__/system/digest');
|
||
await openDiagnostics(page);
|
||
const run = surfaceOf(page, 'Diagnostics').getByRole('button', { name: 'Run checks', exact: true });
|
||
await run.click();
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Health report');
|
||
await expect(run).toHaveText('Run checks');
|
||
await dialog(page).getByRole('button', { name: 'Close', exact: true }).click();
|
||
await expect(dialog(page)).toHaveCount(0);
|
||
await expect(run).toBeFocused();
|
||
// The same report Overview reads: Overview's metric names it.
|
||
expect((await serverJson(request, '/prompt_manager/system/health')).report).toBeTruthy();
|
||
expect((await serverJson(request, '/__test__/system/digest')).rows).toEqual(before.rows);
|
||
});
|
||
|
||
test('report download goes through the api client and holds no secret or absolute path', async ({ page, request }) => {
|
||
const { revision } = await serverJson(request, '/prompt_manager/settings');
|
||
await expect(await request.post('/prompt_manager/lora/enable', { data: { enabled: false, trigger_words_enabled: false, path: '',
|
||
civitai_api_key: KEY, expected_revision: revision } })).toBeOK();
|
||
await openDiagnostics(page);
|
||
const reads = [];
|
||
page.on('request', item => { if (item.url().endsWith(REPORT_URL)) reads.push(item.url()); });
|
||
const download = page.waitForEvent('download');
|
||
await surfaceOf(page, 'Diagnostics').getByRole('button', { name: 'Download diagnostic report', exact: true }).click();
|
||
const file = await download;
|
||
expect(file.suggestedFilename()).toMatch(/^prompt-manager-diagnostics-\d{8}-\d{4}\.json$/);
|
||
await expect(toast(page)).toHaveText('Diagnostic report downloaded.');
|
||
expect(reads).toHaveLength(1);
|
||
const text = await readBlob(file);
|
||
const report = JSON.parse(text);
|
||
expect(report.host_renderers).toBe('No ComfyUI window is open in this browser, so its renderer is not known.');
|
||
expect(report.config.integrations.has_civitai_api_key).toBe(true);
|
||
expect(report.redacted).toBe(0);
|
||
expect(report.viewerjs).toBe('1.15.2');
|
||
expect(Object.keys(report.checks)).toEqual(['database', 'dependencies', 'comfyui_output', 'image_monitor']);
|
||
expect(text).not.toContain(KEY);
|
||
expect(text).not.toContain('pm-browser-');
|
||
const strings = [];
|
||
const walk = value => {
|
||
if (typeof value === 'string') strings.push(value);
|
||
else if (value && typeof value === 'object') Object.values(value).forEach(walk);
|
||
};
|
||
walk(report);
|
||
expect(strings.filter(value => /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(value))).toEqual([]);
|
||
// A refused report names the job and saves nothing.
|
||
await page.route(`**${REPORT_URL}`, route => route.fulfill({ status: 409, contentType: 'application/json',
|
||
body: JSON.stringify({ success: false, code: 'busy', error: 'busy', running: { id: 'a'.repeat(32), title: 'Restore' } }) }));
|
||
let saved = false;
|
||
page.on('download', () => { saved = true; });
|
||
await surfaceOf(page, 'Diagnostics').getByRole('button', { name: 'Download diagnostic report', exact: true }).click();
|
||
await expect(toast(page)).toHaveText('Busy: Restore is running. Try again when it ends.');
|
||
await page.unroute(`**${REPORT_URL}`);
|
||
await page.route(`**${REPORT_URL}`, route => route.fulfill({ status: 500, contentType: 'application/json',
|
||
body: JSON.stringify({ success: false, error: 'The diagnostic report could not be built.' }) }));
|
||
await surfaceOf(page, 'Diagnostics').getByRole('button', { name: 'Download diagnostic report', exact: true }).click();
|
||
await expect(toast(page)).toHaveText('The diagnostic report could not be downloaded. Nothing was saved to this device.');
|
||
expect(saved).toBe(false);
|
||
});
|
||
|
||
test('Inspect opens the Inspect a file page and writes nothing', async ({ page, request }) => {
|
||
const before = await serverJson(request, '/__test__/system/digest');
|
||
const mutations = [];
|
||
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); });
|
||
await openDiagnostics(page);
|
||
const row = operation(page, 'Image-link diagnostics');
|
||
await expect(row.locator('p')).toHaveText('Check a file-to-prompt association without changing it.');
|
||
await row.getByRole('button', { name: 'Inspect', exact: true }).click();
|
||
await expect(page).toHaveURL(`${workspace}#/inspect`);
|
||
await expect(page.locator('#workspace-main h1')).toHaveText('Inspect a file');
|
||
expect(mutations).toEqual([]);
|
||
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
|
||
});
|
||
|
||
test('Create test link confirms first and names the prompt', async ({ page, request }) => {
|
||
const [prompt] = (await serverJson(request, '/prompt_manager/recent?limit=1')).results;
|
||
const posts = [];
|
||
page.on('request', item => { if (item.url().endsWith(TEST_LINK_URL)) posts.push(item.postDataJSON()); });
|
||
await openDiagnostics(page);
|
||
const row = operation(page, 'Test image link');
|
||
await expect(row.locator('p')).toHaveText('Write a placeholder image record to check that linking works.');
|
||
const create = row.getByRole('button', { name: 'Create test link…', exact: true });
|
||
await expect(create).not.toHaveAttribute('aria-disabled', 'true');
|
||
await create.click();
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Create test link');
|
||
await expect(dialog(page).locator('#modal-body > p')).toHaveText(new RegExp('^Writes a placeholder image record '
|
||
+ `\\(prompt_manager_test_link\\.png\\) linked to prompt #${prompt.id} “.+”\\. Maintenance › Missing-file links removes it later\\.$`));
|
||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||
await expect(dialog(page)).toHaveCount(0);
|
||
expect(posts).toEqual([]);
|
||
await create.click();
|
||
await dialog(page).getByRole('button', { name: 'Create', exact: true }).click();
|
||
await expect(toast(page)).toHaveText(/^Test link created \(image record \d+\)\.$/);
|
||
expect(posts).toEqual([{ prompt_id: prompt.id }]);
|
||
// A failed write says nothing was changed.
|
||
await page.route(`**${TEST_LINK_URL}`, route => route.fulfill({ status: 500, contentType: 'application/json',
|
||
body: JSON.stringify({ success: false, error: 'Test link failed' }) }));
|
||
await create.click();
|
||
await dialog(page).getByRole('button', { name: 'Create', exact: true }).click();
|
||
await expect(toast(page)).toHaveText('The test link could not be created. Nothing was changed.');
|
||
// With no prompt to link, the button is unavailable and says why.
|
||
await page.route('**/prompt_manager/recent?limit=1', route => route.fulfill({ status: 200, contentType: 'application/json',
|
||
body: JSON.stringify({ success: true, results: [] }) }));
|
||
await openDiagnostics(page);
|
||
await expect(operation(page, 'Test image link').locator('p'))
|
||
.toHaveText('Write a placeholder image record to check that linking works. Add a prompt first.');
|
||
await expect(operation(page, 'Test image link').getByRole('button')).toHaveAttribute('aria-disabled', 'true');
|
||
});
|
||
|
||
// ── Task 5: the log viewer, statistics and runtime settings ─────────────────────────────────────────────────────────
|
||
|
||
test('level filter is minimum-level over all six choices', async ({ page, request }) => {
|
||
await seedLogs(request);
|
||
const urls = [];
|
||
page.on('request', item => { if (new URL(item.url()).pathname === LOGS_URL) urls.push(new URL(item.url()).search); });
|
||
await openDiagnostics(page);
|
||
const level = page.locator('#log-level');
|
||
await expect(level.locator('option')).toHaveText(['All levels', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']);
|
||
await expect(page.locator('label[for="log-level"]')).toHaveText('Level');
|
||
// All levels: the concept's four lines first, newest first, in its format.
|
||
await expect(lines(page)).toContainText('09:42:18 INFO Scan completed: 12 enriched, 0 duplicate links\n'
|
||
+ '09:42:18 WARNING 3 images have ambiguous prompt metadata\n09:39:04 INFO Database backup verified\n'
|
||
+ '09:38:52 INFO Source monitor started: output');
|
||
for (const line of (await lines(page).textContent()).split('\n')) expect(line).toMatch(/^\d\d:\d\d:\d\d [A-Z]+ +\S/);
|
||
expect(urls.at(-1)).toBe('?limit=100');
|
||
for (const minimum of LEVELS) {
|
||
await level.selectOption(minimum);
|
||
await expect.poll(() => urls.at(-1)).toBe(`?limit=100&level=${minimum}`);
|
||
await expect(lines(page)).toContainText('CRITICAL The database could not be opened');
|
||
const shown = await shownLevels(page);
|
||
expect(shown.every(name => LEVELS.indexOf(name) >= LEVELS.indexOf(minimum)), `${minimum}: ${shown}`).toBe(true);
|
||
if (minimum === 'DEBUG') await expect(lines(page)).toContainText('DEBUG Thumbnail cache lookup');
|
||
if (minimum === 'WARNING') {
|
||
await expect(lines(page)).not.toContainText('Database backup verified');
|
||
await expect(lines(page)).toContainText('ERROR A source folder could not be read');
|
||
}
|
||
}
|
||
await level.selectOption('');
|
||
await expect.poll(() => urls.at(-1)).toBe('?limit=100');
|
||
});
|
||
|
||
test('Lines accepts 1 to 500 and sends nothing when invalid', async ({ page, request }) => {
|
||
await seedLogs(request, { filler: 30 });
|
||
const urls = [];
|
||
page.on('request', item => { if (new URL(item.url()).pathname === LOGS_URL) urls.push(new URL(item.url()).search); });
|
||
await openDiagnostics(page);
|
||
const limit = page.locator('#log-limit');
|
||
await expect(page.locator('label[for="log-limit"]')).toHaveText('Lines');
|
||
await expect(limit).toHaveValue('100');
|
||
expect(await limit.evaluate(node => [node.type, node.min, node.max, node.title]))
|
||
.toEqual(['number', '1', '500', 'Lines to show: a whole number from 1 to 500.']);
|
||
await limit.fill('20');
|
||
await limit.dispatchEvent('change');
|
||
await expect.poll(() => urls.at(-1)).toBe('?limit=20');
|
||
await expect.poll(async () => (await lines(page).textContent()).split('\n').length).toBe(20);
|
||
const sent = urls.length;
|
||
for (const value of ['0', '501', '2.5', '']) {
|
||
await limit.fill(value);
|
||
await limit.dispatchEvent('change');
|
||
await expect(limit).toHaveAttribute('aria-invalid', 'true');
|
||
}
|
||
// A Level change while Lines is invalid sends nothing either.
|
||
await page.locator('#log-level').selectOption('WARNING');
|
||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||
expect(urls.length).toBe(sent);
|
||
await limit.fill('500');
|
||
await limit.dispatchEvent('change');
|
||
await expect(limit).not.toHaveAttribute('aria-invalid');
|
||
await expect.poll(() => urls.at(-1)).toBe('?limit=500&level=WARNING');
|
||
});
|
||
|
||
test('logs refresh handles empty, error and stale answers', async ({ page, request }) => {
|
||
await seedLogs(request);
|
||
await openDiagnostics(page);
|
||
const level = page.locator('#log-level');
|
||
const refresh = surfaceOf(page, 'Logs').getByRole('button', { name: 'Refresh', exact: true });
|
||
// Empty: nothing at this level once the buffer is cleared.
|
||
await expect(await request.post('/__test__/logs/reset')).toBeOK();
|
||
await level.selectOption('CRITICAL');
|
||
await expect(lines(page)).toHaveText('No log entries at this level.');
|
||
// Error.
|
||
await page.route(`**${LOGS_URL}?*`, route => route.fulfill({ status: 500, contentType: 'application/json',
|
||
body: JSON.stringify({ success: false, error: 'boom', logs: [] }) }));
|
||
await refresh.click();
|
||
await expect(lines(page)).toHaveText('The log could not be read.');
|
||
await page.unroute(`**${LOGS_URL}?*`);
|
||
// Stale: an older answer that arrives after a newer request never paints.
|
||
await seedLogs(request);
|
||
await expect(await request.post('/__test__/hold', { data: { path: LOGS_URL } })).toBeOK();
|
||
await level.selectOption('DEBUG');
|
||
await expect.poll(async () => (await serverJson(request, '/__test__/hold')).held[LOGS_URL] ?? 0).toBe(1);
|
||
await level.selectOption('ERROR');
|
||
await expect.poll(async () => (await serverJson(request, '/__test__/hold')).held[LOGS_URL] ?? 0).toBe(2);
|
||
await expect(await request.post('/__test__/release')).toBeOK();
|
||
await expect(lines(page)).toContainText('CRITICAL The database could not be opened');
|
||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||
expect((await shownLevels(page)).every(name => ['ERROR', 'CRITICAL'].includes(name))).toBe(true);
|
||
});
|
||
|
||
test('log statistics show entries, errors, warnings, files and size', async ({ page, request }) => {
|
||
await seedLogs(request, { rotated: [2048, 1024], other: true, link: true, active: 512 });
|
||
await openDiagnostics(page);
|
||
const stats = page.getByRole('status', { name: 'Log statistics', exact: true });
|
||
await expect(stats).toHaveAttribute('id', 'log-stats');
|
||
const text = await stats.textContent();
|
||
const match = /^([\d,]+) entr(?:y|ies) in memory · (\d+) errors? · (\d+) warnings? · (\d+) files? · (.+) on disk$/.exec(text);
|
||
expect(match, text).not.toBeNull();
|
||
const [, entries, errors, warnings, files, size] = match;
|
||
expect(Number(entries.replace(/,/g, ''))).toBeGreaterThanOrEqual(7);
|
||
expect(Number(errors)).toBeGreaterThanOrEqual(2);
|
||
expect(Number(warnings)).toBeGreaterThanOrEqual(1);
|
||
// The active log, two rotated copies and the .bak; the link is not counted (Rule R).
|
||
expect(Number(files)).toBe(4);
|
||
expect(size).toMatch(/^[\d.]+ (bytes|KB|MB)$/);
|
||
});
|
||
|
||
test('Download log saves the active log', async ({ page, request }) => {
|
||
await seedLogs(request, { active: 300 });
|
||
await openDiagnostics(page);
|
||
const download = page.waitForEvent('download');
|
||
await surfaceOf(page, 'Logs').getByRole('button', { name: 'Download log', exact: true }).click();
|
||
const file = await download;
|
||
expect(file.suggestedFilename()).toBe('prompt_manager.log');
|
||
expect((await readBlob(file)).length).toBeGreaterThanOrEqual(300);
|
||
await page.route('**/prompt_manager/logs/download/**', route => route.fulfill({ status: 404, contentType: 'application/json',
|
||
body: JSON.stringify({ success: false, error: 'Log file not found' }) }));
|
||
await surfaceOf(page, 'Logs').getByRole('button', { name: 'Download log', exact: true }).click();
|
||
await expect(toast(page)).toHaveText('The log could not be downloaded.');
|
||
});
|
||
|
||
test('runtime level (including CRITICAL) and console logging save and say they apply until the server restarts',
|
||
async ({ page, request }) => {
|
||
const posts = [];
|
||
page.on('request', item => { if (item.method() === 'POST' && item.url().endsWith(CONFIG_URL)) posts.push(item.postDataJSON()); });
|
||
await openDiagnostics(page);
|
||
const level = settingOf(page, 'Runtime log level');
|
||
await expect(level.locator('p')).toHaveText('How much the server writes to the log. Applied until the server restarts.');
|
||
await expect(level.locator('option')).toHaveText(['INFO', 'WARNING', 'DEBUG', 'ERROR', 'CRITICAL']);
|
||
await expect(level.locator('select')).toHaveValue('INFO');
|
||
const consoleRow = settingOf(page, 'Console logging');
|
||
await expect(consoleRow.locator('p')).toHaveText('Print Prompt Manager’s warnings and errors in the ComfyUI console '
|
||
+ '(everything at DEBUG). Applied until the server restarts.');
|
||
const toggle = consoleRow.locator('input[type="checkbox"]');
|
||
await expect(toggle).toBeChecked();
|
||
await level.locator('select').selectOption('CRITICAL');
|
||
await toggle.uncheck();
|
||
await expect(status(page)).toHaveText('Unsaved changes.');
|
||
await save(page).click();
|
||
await expect(status(page)).toHaveText('Applied until the server restarts.');
|
||
expect(posts).toEqual([{ level: 'CRITICAL', console_logging: false }]);
|
||
const { config } = await serverJson(request, CONFIG_URL);
|
||
expect([config.level, config.console_logging]).toEqual(['CRITICAL', false]);
|
||
// Read again on a fresh page: the server's values.
|
||
await openDiagnostics(page);
|
||
await expect(settingOf(page, 'Runtime log level').locator('select')).toHaveValue('CRITICAL');
|
||
await expect(settingOf(page, 'Console logging').locator('input[type="checkbox"]')).not.toBeChecked();
|
||
// Only the changed key is sent.
|
||
await settingOf(page, 'Runtime log level').locator('select').selectOption('DEBUG');
|
||
await save(page).click();
|
||
await expect(status(page)).toHaveText('Applied until the server restarts.');
|
||
expect(posts.at(-1)).toEqual({ level: 'DEBUG' });
|
||
});
|
||
|
||
test('a refused runtime value is marked invalid', async ({ page }) => {
|
||
await page.route(`**${CONFIG_URL}`, route => (route.request().method() === 'POST'
|
||
? route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ success: false, error: 'Invalid log level' }) })
|
||
: route.continue()));
|
||
await openDiagnostics(page);
|
||
const select = settingOf(page, 'Runtime log level').locator('select');
|
||
await select.selectOption('WARNING');
|
||
await save(page).click();
|
||
await expect(status(page)).toHaveText('Some changes were not saved. Review the highlighted settings.');
|
||
await expect(select).toHaveAttribute('aria-invalid', 'true');
|
||
await expect(settingOf(page, 'Console logging').locator('input[type="checkbox"]')).not.toHaveAttribute('aria-invalid');
|
||
// The draft stays: an edit clears the mark.
|
||
await expect(select).toHaveValue('WARNING');
|
||
await select.selectOption('ERROR');
|
||
await expect(select).not.toHaveAttribute('aria-invalid');
|
||
});
|
||
|
||
test('runtime values that cannot be read stay disabled and say so', async ({ page }) => {
|
||
await page.route(`**${CONFIG_URL}`, route => route.fulfill({ status: 500, contentType: 'application/json',
|
||
body: JSON.stringify({ success: false, error: 'boom' }) }));
|
||
await openDiagnostics(page);
|
||
for (const label of ['Runtime log level', 'Console logging']) {
|
||
const row = settingOf(page, label);
|
||
await expect(row.locator('p.small')).toHaveText('The current value could not be read.');
|
||
await expect(row.locator('select, input[type="checkbox"]')).toBeDisabled();
|
||
}
|
||
});
|
||
|
||
// ── Task 6: log files, the reviewed cleanup and Clear all logs ─────────────────────────────────────────────────────
|
||
|
||
const LOG_FOLDER = { rotated: [3000, 2048, 1024], other: true, link: true, active: 100 };
|
||
const LINKS = process.platform === 'win32' ? 0 : 1;
|
||
|
||
test('Review log cleanup lists only rotated copies and keeps the rest', async ({ page, request }) => {
|
||
await seedLogs(request, LOG_FOLDER);
|
||
const before = await names(request);
|
||
const previews = [];
|
||
page.on('request', item => { if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON()); });
|
||
await openDiagnostics(page);
|
||
const details = await openFiles(page);
|
||
await expect(details.locator(':scope > p.small').first()).toHaveText(/^prompt_manager\.log · [\d.]+ (bytes|KB) · rotated copies: 3$/);
|
||
await details.getByRole('button', { name: 'Review log cleanup', exact: true }).click();
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Review maintenance plan');
|
||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Rotated log copies');
|
||
expect(previews).toEqual([{ operation: 'rotated_logs', options: {} }]);
|
||
const grid = await dialog(page).locator('dl.meta-grid > div').evaluateAll(items => items.map(item =>
|
||
[item.querySelector('dt').textContent, item.querySelector('dd').textContent]));
|
||
expect(grid.map(([term]) => term)).toEqual(['Scope', 'Protected', 'Backup', 'Media on disk']);
|
||
expect(grid[0][1]).toMatch(/^3 rotated log copies · 5\.9 KB$/);
|
||
expect(grid[1][1]).toBe(['Kept: the active log', ...(LINKS ? ['Kept: 1 link'] : []), 'Kept: 1 other file'].join(' · '));
|
||
expect(grid[2][1]).toBe('Not needed: no library record changes');
|
||
expect(grid[3][1]).toBe('Deletes only the listed log files. No image, video or library record changes.');
|
||
await expect(dialog(page).locator('details > summary')).toHaveText('Listed files (3)');
|
||
await expect(dialog(page).locator('details pre')).toHaveText(/^prompt_manager\.log\.1 · 2\.9 KB · .+\nprompt_manager\.log\.2 · 2\.0 KB · .+\nprompt_manager\.log\.3 · 1\.0 KB · .+$/);
|
||
// No D55 backup box: nothing in the library changes.
|
||
await expect(dialog(page).locator('.notice')).toHaveCount(0);
|
||
await expect(dialog(page).getByRole('button', { name: 'Back up now' })).toHaveCount(0);
|
||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||
await expect(dialog(page)).toHaveCount(0);
|
||
expect(await names(request)).toEqual(before);
|
||
});
|
||
|
||
test('Apply deletes exactly the listed files and Activity lists them', async ({ page, request }) => {
|
||
await seedLogs(request, LOG_FOLDER);
|
||
await openDiagnostics(page);
|
||
const details = await openFiles(page);
|
||
await details.getByRole('button', { name: 'Review log cleanup', exact: true }).click();
|
||
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
|
||
await expect(dialog(page)).toHaveCount(0);
|
||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||
const job = await ended(request, page.url().split('/').pop());
|
||
expect([job.kind, job.state, job.title, job.counters.changed]).toEqual(['maintenance', 'finished', 'Rotated log copies', 3]);
|
||
await expect(toast(page)).toHaveText(/Rotated log copies (started|finished · 3 changed)/);
|
||
const main = page.locator('#workspace-main');
|
||
await expect(main.locator('h1')).toHaveText('Rotated log copies');
|
||
for (const name of ['prompt_manager.log.1', 'prompt_manager.log.2', 'prompt_manager.log.3']) await expect(main).toContainText(name);
|
||
// Only those three are gone: the active log, the user's .bak and the link are kept.
|
||
expect(await names(request)).toEqual(['prompt_manager.log', ...(LINKS ? ['prompt_manager.log.4'] : []), 'prompt_manager.log.bak']);
|
||
});
|
||
|
||
test('a rollover after the review deletes nothing and Activity offers Review again', async ({ page, request }) => {
|
||
await seedLogs(request, LOG_FOLDER);
|
||
await openDiagnostics(page);
|
||
const details = await openFiles(page);
|
||
await details.getByRole('button', { name: 'Review log cleanup', exact: true }).click();
|
||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Rotated log copies');
|
||
await expect(await request.post('/__test__/logs/rotate')).toBeOK();
|
||
const rolled = await serverJson(request, '/__test__/logs/state');
|
||
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
|
||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||
const job = await ended(request, page.url().split('/').pop());
|
||
expect([job.state, job.scope.failure]).toEqual(['failed', 'plan-changed']);
|
||
await expect(page.locator('#workspace-main')).toContainText(CHANGED);
|
||
expect((await serverJson(request, '/__test__/logs/state')).entries.map(entry => entry.name))
|
||
.toEqual(rolled.entries.map(entry => entry.name));
|
||
const previews = [];
|
||
page.on('request', item => { if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON()); });
|
||
await page.locator('#workspace-main').getByRole('button', { name: 'Review again', exact: true }).click();
|
||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Rotated log copies');
|
||
expect(previews).toEqual([{ operation: 'rotated_logs', options: {} }]);
|
||
});
|
||
|
||
test('Clear all logs states both counts, confirms first and reads everything again', async ({ page, request }) => {
|
||
await seedLogs(request, LOG_FOLDER);
|
||
const truncates = [];
|
||
page.on('request', item => { if (item.url().endsWith('/prompt_manager/logs/truncate')) truncates.push(item.url()); });
|
||
await openDiagnostics(page);
|
||
await expect(lines(page)).toContainText('Database backup verified');
|
||
const details = await openFiles(page);
|
||
const clearRow = operation(page, 'Clear all logs');
|
||
await expect(clearRow.locator('p')).toHaveText('Empty the active log and delete its rotated copies.');
|
||
const clear = clearRow.getByRole('button', { name: 'Clear all logs…', exact: true });
|
||
await expect(clear).toHaveClass(/\bdanger\b/);
|
||
await clear.click();
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Clear all logs');
|
||
await expect(dialog(page).locator('#modal-body > p')).toHaveText('Empties prompt_manager.log, deletes 3 rotated copies (5.9 KB) '
|
||
+ 'and clears the lines shown here. The other files in the log folder are kept. This cannot be undone.');
|
||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||
await expect(dialog(page)).toHaveCount(0);
|
||
expect(truncates).toEqual([]);
|
||
await clear.click();
|
||
await dialog(page).getByRole('button', { name: 'Clear', exact: true }).click();
|
||
await expect(toast(page)).toHaveText('Logs cleared · 3 rotated copies deleted.');
|
||
expect(truncates).toHaveLength(1);
|
||
// Read again: the viewer, the file line and the list.
|
||
await expect(lines(page)).not.toContainText('Database backup verified');
|
||
await expect(details.locator(':scope > p.small').first()).toHaveText(/rotated copies: 0$/);
|
||
const kept = ['prompt_manager.log', 'prompt_manager.log.bak', ...(LINKS ? ['prompt_manager.log.4'] : [])];
|
||
// The server lists newest first: compare as a set.
|
||
await expect.poll(async () => (await details.locator('.operation-list h3').allTextContents()).sort()).toEqual([...kept].sort());
|
||
expect(await names(request)).toEqual(['prompt_manager.log', ...(LINKS ? ['prompt_manager.log.4'] : []), 'prompt_manager.log.bak']);
|
||
});
|
||
|
||
test('Clear all logs reads the files again before it states what it deletes', async ({ page, request }) => {
|
||
await seedLogs(request, { rotated: [1024], active: 100 });
|
||
await openDiagnostics(page);
|
||
const details = await openFiles(page);
|
||
await expect(details.locator(':scope > p.small').first()).toHaveText(/rotated copies: 1$/);
|
||
// The log rolls over while the page stays open: Clear names what it deletes now, not the count shown.
|
||
await seedLogs(request, { records: false, rotated: [1024, 1024, 1024] });
|
||
await operation(page, 'Clear all logs').getByRole('button', { name: 'Clear all logs…', exact: true }).click();
|
||
await expect(dialog(page).locator('#modal-body > p')).toHaveText(/^Empties prompt_manager\.log, deletes 3 rotated copies \(3\.0 KB\) /);
|
||
await expect(details.locator(':scope > p.small').first()).toHaveText(/rotated copies: 3$/);
|
||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||
// A list that cannot be read gives the general sentence, never an old count.
|
||
await page.route('**/prompt_manager/logs/files', route => route.fulfill({ status: 500, contentType: 'application/json',
|
||
body: JSON.stringify({ success: false, error: 'boom', files: [] }) }));
|
||
await operation(page, 'Clear all logs').getByRole('button', { name: 'Clear all logs…', exact: true }).click();
|
||
await expect(dialog(page).locator('#modal-body > p')).toHaveText(/^Empties prompt_manager\.log, deletes its rotated copies and clears/);
|
||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||
expect(await names(request)).toEqual(['prompt_manager.log', 'prompt_manager.log.1', 'prompt_manager.log.2', 'prompt_manager.log.3']);
|
||
});
|
||
|
||
test('each listed file downloads by its returned name and a link has no Download', async ({ page, request }) => {
|
||
await seedLogs(request, LOG_FOLDER);
|
||
await openDiagnostics(page);
|
||
const details = await openFiles(page);
|
||
await expect(details.locator(':scope > p.small').nth(1))
|
||
.toHaveText('Rotates at 10 MB · keeps 5 rotated copies · file logging on · memory buffer 1,000 entries');
|
||
const list = details.locator('.operation-list');
|
||
expect((await list.locator('h3').allTextContents()).sort()).toEqual(['prompt_manager.log', 'prompt_manager.log.1',
|
||
'prompt_manager.log.2', 'prompt_manager.log.3', ...(LINKS ? ['prompt_manager.log.4'] : []), 'prompt_manager.log.bak'].sort());
|
||
const item = name => list.locator('.operation').filter({ has: page.locator(`h3:text-is("${name}")`) });
|
||
await expect(item('prompt_manager.log.1').locator('p')).toHaveText(/^2\.9 KB · \d{4}-\d\d-\d\d \d\d:\d\d · Rotated copy$/);
|
||
await expect(item('prompt_manager.log.bak').locator('p')).toHaveText(/^0 bytes · .+ · Other file$/);
|
||
if (LINKS) {
|
||
await expect(item('prompt_manager.log.4').locator('p')).toHaveText(/ · Link, not downloaded$/);
|
||
await expect(item('prompt_manager.log.4').getByRole('button')).toHaveCount(0);
|
||
}
|
||
for (const name of ['prompt_manager.log.1', 'prompt_manager.log.bak']) {
|
||
const download = page.waitForEvent('download');
|
||
await item(name).getByRole('button', { name: `Download ${name}`, exact: true }).click();
|
||
const file = await download;
|
||
expect(file.suggestedFilename()).toBe(name);
|
||
if (name === 'prompt_manager.log.1') expect((await readBlob(file)).length).toBe(3000);
|
||
}
|
||
});
|