- /logs/files rows carry an identity string (size:mtime_ns:ino); the workspace sends back the active and rotated rows its dialog was built from. Under the files and handler locks every listed copy is re-checked with _matches_review and the listed active log's inode; any difference refuses with nothing changed (409 logs-changed). A copy that appeared after the confirmation is kept. - A second writer of the active log (our flushed stream is behind the file's size, or our file is now a rotated copy) refuses first (409 logs-foreign-writer); each unlink is preceded by a fresh identity check and the active log is emptied last. - An active log with a hard link (st_nlink > 1) is never truncated (409 logs-hard-link), also on the file-logging-off path. - The legacy admin page's POST without a body keeps clearing what is there, with the foreign-writer and hard-link refusals. - The browser fixture grows the active log through the handler's stream.
612 lines
39 KiB
JavaScript
612 lines
39 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.postDataJSON()); });
|
||
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);
|
||
// #389: Clear sends back exactly the files the dialog was built from, each with its identity string.
|
||
expect(truncates[0].expected.map(row => row.name).sort()).toEqual(['prompt_manager.log', 'prompt_manager.log.1',
|
||
'prompt_manager.log.2', 'prompt_manager.log.3']);
|
||
for (const row of truncates[0].expected) expect(row.identity).toMatch(/^\d+:\d+:\d+$/);
|
||
// 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('a rollover while Clear all logs is open deletes nothing and says why (#389)', async ({ page, request }) => {
|
||
await seedLogs(request, { rotated: [1024, 1024], active: 100 });
|
||
await openDiagnostics(page);
|
||
await openFiles(page);
|
||
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 2 rotated copies /);
|
||
// The log rolls over after the confirmation was built: the copies it listed are not the ones there now.
|
||
await expect(await request.post('/__test__/logs/rotate')).toBeOK();
|
||
const rolled = await names(request);
|
||
const answer = page.waitForResponse(response => response.url().endsWith('/prompt_manager/logs/truncate'));
|
||
await dialog(page).getByRole('button', { name: 'Clear', exact: true }).click();
|
||
expect((await answer).status()).toBe(409);
|
||
await expect(toast(page)).toHaveText('The log files changed since you confirmed; nothing was cleared. '
|
||
+ 'Read the list again and clear again.');
|
||
expect(await names(request)).toEqual(rolled);
|
||
});
|
||
|
||
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);
|
||
}
|
||
});
|