Files
ComfyAssets-ComfyUI_PromptM…/tests/browser/workspace-system-maintenance.spec.mjs
T
Vito 3295ff8b20 Pair category repair (#550) and saved instructions + dialog picker (#431) (#590)
* feat(maintenance): repair old Pair prompts' empty category and tags (#550)

Squashed from PR #579. Closes #550.

* feat(tagging): saved instructions with a default per engine (#431)

Squashed from PR #587.

* feat(tagging): pick saved instructions in the tagging dialogs (#431)

Squashed from PR #588. Closes #431.
2026-11-01 03:01:07 +00:00

550 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept } from './concept-inventory.mjs';
// System › Maintenance (#249 System S7 Task 19) on the concept-shaped real fixture and the S6 backend: the concept's
// rows, the read-only health check, the D100 plan review applied as an Activity job, the D101 duplicate search and
// review, the S5 thumbnail dialog and the D102 metadata cache row. The hooks are workspace_system_fixture.py's.
const workspace = '/prompt_manager/workspace';
const PREVIEW_URL = '/prompt_manager/maintenance/preview';
const APPLY_URL = '/prompt_manager/maintenance/apply';
const CHANGED = 'The library changed since the review; nothing was changed. Review the plan again.';
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await seedConcept(request);
await expect(await request.post('/__test__/system/reset')).toBeOK();
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
page.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__/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 row = (page, title) => layoutOf(page).locator('.operation').filter({ has: page.locator('h3', { hasText: title }) });
async function serverJson(request, url) {
const response = await request.get(url);
await expect(response).toBeOK();
return response.json();
}
async function openMaintenance(page) {
await page.goto(`${workspace}#/system/maintenance`);
await expect(layoutOf(page).locator(':scope > section.surface')).toHaveCount(2);
return layoutOf(page);
}
/** The open plan dialog's `[dt, dd]` rows. */
const gridOf = page => dialog(page).locator('dl.meta-grid > div').evaluateAll(items => items.map(item =>
[item.querySelector('dt').textContent, item.querySelector('dd').textContent]));
/** Wait until the job `id` ended on the server; answers its summary. */
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;
}
test('the section shows the concept rows; Metadata cache follows Q5 (D102)', async ({ page }) => {
const layout = await openMaintenance(page);
await expect(layout.locator(':scope > .notice h2')).toHaveText('Check first. Review changes. Then apply.');
await expect(layout.locator(':scope > section.handoff')).toHaveCount(0);
expect(await layout.locator(':scope > section.surface').evaluateAll(surfaces => surfaces.map(surface => [
surface.querySelector('h2').textContent,
[...surface.querySelectorAll('.operation')].map(item => [item.querySelector('h3').textContent,
item.querySelector('.row-actions > button').textContent, item.querySelector('.row-actions > button').className]),
]))).toEqual([
['Database', [['Integrity & relationship checks', 'Check', ''], ['Duplicate prompt text', 'Review plan', ''],
['Optimize database', 'Review plan', ''], ['Unused prompt components', 'Review plan', 'danger'],
// D192 (#550): beyond the concept.
['Pair category and tags', 'Review plan', '']]],
['Images & cache', [['Missing-file links', 'Review plan', 'danger'], ['Duplicate image files', 'Find duplicates', ''],
['Thumbnail cache', 'Review options', ''], ['Metadata cache', 'Review plan', '']]],
]);
// Nothing of Prompt Manager's is cached in a browser (#222): the row says so and its review is unavailable.
const cache = row(page, 'Metadata cache');
await expect(cache.locator('p')).toHaveText('Clear only Prompt Manager metadata cached by this browser. Nothing is cached in this browser.');
const review = cache.getByRole('button', { name: 'Review plan', exact: true });
await expect(review).toHaveAttribute('aria-disabled', 'true');
// An aria-disabled button stays focusable and clickable; its press opens nothing.
await review.dispatchEvent('click');
await expect(review).toHaveAttribute('aria-disabled', 'true');
await expect(dialog(page)).toHaveCount(0);
});
test('Metadata cache reviews and clears only owned browser entries when there are some (D102)', async ({ page }) => {
// The shipped inventory is empty (#222); this test names one owned entry through the module's inventory seam.
await page.addInitScript(() => {
localStorage.setItem('pm-owned-test-entry', 'cached');
localStorage.setItem('pm_workspace_theme', 'dark');
let wrapped;
Object.defineProperty(window, 'CacheOwnership', { configurable: true, get: () => wrapped, set(api) {
const owned = { storageKeys: ['pm-owned-test-entry'], cacheNames: [] };
wrapped = { ...api, ownedEntries: options => api.ownedEntries({ ...options, owned }),
clearOwned: options => api.clearOwned({ ...options, owned }) };
} });
});
await openMaintenance(page);
const cache = row(page, 'Metadata cache');
const review = cache.getByRole('button', { name: 'Review plan', exact: true });
await expect(review).not.toHaveAttribute('aria-disabled', 'true');
await expect(cache.locator('p')).toHaveText('Clear only Prompt Manager metadata cached by this browser.');
await review.click();
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Metadata cache');
expect(await gridOf(page)).toEqual([['Scope', '1 cached entry in this browser'],
['Protected', 'Preferences and other tools’ data in this browser'], ['Backup', 'Not needed: the database is not changed'],
['Media on disk', 'No files are removed']]);
await dialog(page).getByRole('button', { name: 'Clear browser cache', exact: true }).click();
await expect(toast(page)).toHaveText('Cleared 1 cached entry from this browser.');
await expect(review).toHaveAttribute('aria-disabled', 'true');
expect(await page.evaluate(() => [localStorage.getItem('pm-owned-test-entry'), localStorage.getItem('pm_workspace_theme')]))
.toEqual([null, 'dark']);
});
test('Run health checks and Check open the Health report; Review missing links starts a fresh preview, never a cleanup',
async ({ page, request }) => {
await expect(await request.post('/__test__/system/missing-file')).toBeOK();
const before = await serverJson(request, '/__test__/system/digest');
const previews = [];
page.on('request', item => { if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON()); });
await openMaintenance(page);
const check = row(page, 'Integrity & relationship checks').getByRole('button', { name: 'Check', exact: true });
await check.click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Health report');
await expect(dialog(page).locator('.operation').filter({ hasText: 'Missing files · output' }).locator('.chip'))
.toHaveText('1 link needs review');
await expect(check).toHaveText('Check');
await dialog(page).getByRole('button', { name: 'Review missing links', exact: true }).click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Review maintenance plan');
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Missing-file links');
expect(previews).toEqual([{ operation: 'missing_links', options: {} }]);
expect((await gridOf(page))[0]).toEqual(['Scope', '1 link to a missing file']);
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
await expect(check).toBeFocused();
// The notice's Run health checks opens the same report.
await layoutOf(page).getByRole('button', { name: 'Run health checks', exact: true }).click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Health report');
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
});
test("plan dialog shows the server's scope, protected, backup policy and media lines with the backup box (D100); Cancel writes nothing",
async ({ page, request }) => {
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
const before = await serverJson(request, '/__test__/system/digest');
const answers = [];
page.on('response', response => { if (response.url().endsWith(PREVIEW_URL)) answers.push(response.json()); });
await openMaintenance(page);
const reviewButton = row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', exact: true });
await reviewButton.click();
const plan = dialog(page);
await expect(plan.locator('#modal-title')).toHaveText('Review maintenance plan');
const answer = await answers[0];
expect(answer.scope.count).toBe(1);
await expect(plan.locator('#modal-body > h3')).toHaveText('Duplicate prompt text');
expect(await gridOf(page)).toEqual([
['Scope', answer.scope.label],
['Protected', answer.protected.reasons.length ? answer.protected.reasons.join(' · ') : 'Nothing is protected'],
['Backup', `Create and verify before changes (${answer.backup.destination_label})`],
['Media on disk', answer.media.label],
]);
expect(await plan.locator('#modal-body').evaluate(body => [...body.children].map(node =>
[node.localName, ...node.classList].join('.')))).toEqual(['h3', 'dl.meta-grid', 'details', 'p.small', 'div.notice',
'div.dialog-actions']);
await expect(plan.locator('details > summary')).toHaveText('Listed records (1)');
await expect(plan.locator('#modal-body > p.small')).toHaveText(/^Nothing changes until you apply\. This review holds for 1[45] minutes\.$/);
await expect(plan.locator('.notice h3')).toHaveText('Back up the database first (recommended)');
await expect(plan.locator('.dialog-actions button')).toHaveText(['Cancel', 'Apply reviewed operation']);
await plan.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
await expect(reviewButton).toBeFocused();
// Optimize and Unused prompt components are reviewed the same way.
for (const [title, scope] of [['Optimize database', 'The whole database'], ['Unused prompt components', null]]) {
await row(page, title).getByRole('button', { name: 'Review plan', exact: true }).click();
await expect(dialog(page).locator('#modal-body > h3')).toHaveText(title);
const value = (await answers.at(-1)).scope.label;
expect((await gridOf(page))[0]).toEqual(['Scope', scope ?? value]);
await page.keyboard.press('Escape');
await expect(dialog(page)).toHaveCount(0);
}
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
});
test('D192: Pair category and tags reviews old Pair prompts, keeps an edited one and fills the other (#550)',
async ({ page, request }) => {
await expect(await request.post('/__test__/system/pair-metadata')).toBeOK();
const answers = [];
page.on('response', response => { if (response.url().endsWith(PREVIEW_URL)) answers.push(response.json()); });
await openMaintenance(page);
const pairRow = row(page, 'Pair category and tags');
await expect(pairRow.locator('p')).toHaveText('Fill an empty category or tags on prompts older Pair node runs saved, ' +
'from their saved pairs. Only empty fields of never-edited prompts.');
await pairRow.getByRole('button', { name: 'Review plan', exact: true }).click();
const plan = dialog(page);
await expect(plan.locator('#modal-body > h3')).toHaveText('Pair category and tags');
const answer = await answers[0];
expect(answer.operation).toBe('pair_metadata');
expect(await gridOf(page)).toEqual([
['Scope', '1 prompt to fill: 1 category, 1 tag set'],
['Protected', '1 edited after it was saved'],
['Backup', `Create and verify before changes (${answer.backup.destination_label})`],
['Media on disk', 'No image file or saved pair is changed.'],
]);
await expect(plan.locator('details > summary')).toHaveText('Listed records (3)');
await plan.locator('details > summary').click();
// Protected rows come first, the Record row last.
const lines = (await plan.locator('details > pre').textContent()).split('\n');
expect(lines).toHaveLength(3);
expect(lines[0]).toBe('S550 harbor · Kept: edited after it was saved; its empty category or tags may be intentional');
expect(lines[1]).toMatch(/^S550 lantern · Fills category “night” and 2 tags: s550-glow, s550-warm \(pair \d+\)$/);
expect(lines[2]).toMatch(/^Record · The job's report lists every filled field\. Download it to keep it/);
await plan.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.title, job.state, job.counters.changed, job.counters.protected])
.toEqual(['Pair category and tags', 'finished', 1, 1]);
const main = page.locator('#workspace-main');
await expect(main.locator('h1')).toHaveText('Pair category and tags');
await expect(main.getByRole('button', { name: 'Download report', exact: true })).toBeVisible();
// #550 live test: the Changed group lists the path-less record rows as plain text, never an open action.
const results = main.getByRole('region', { name: 'Results', exact: true });
await results.locator('.outcome-list > button').filter({ hasText: 'Changed' }).click();
const records = results.locator('.outcome-files > .file-row');
await expect(records).toHaveCount(1);
await expect(records.first()).toHaveText(/^Prompt \d+: category “night”; tags s550-glow, s550-warm$/);
expect(await records.first().evaluate(node => node.localName)).toBe('div');
await expect(results.locator('.outcome-files button')).toHaveCount(0);
await expect(results.getByRole('searchbox', { name: 'Filter results' })).toHaveAttribute('placeholder', 'Filter records…');
// Protected lists each kept prompt with the review's reason (#550 live re-check).
await results.locator('.outcome-list > button').filter({ hasText: 'Protected' }).click();
await expect(records).toHaveCount(1);
await expect(records.first()).toHaveText(
/^Prompt \d+: kept, edited after it was saved; its empty category or tags may be intentional$/);
// An empty group says records, not files.
await results.locator('.outcome-list > button').filter({ hasText: 'Errors' }).click();
await expect(results.locator('.outcome-files > p.empty-state')).toHaveText('No records in this group.');
const library = await serverJson(request, '/prompt_manager/library');
const lantern = library.items.find(item => item.text === 'S550 lantern');
const harbor = library.items.find(item => item.text === 'S550 harbor');
const filled = (await serverJson(request, `/prompt_manager/library/${lantern.id}?archive=all`)).positive;
expect([filled.category, [...filled.tags].sort()]).toEqual(['night', ['s550-glow', 's550-warm']]);
expect((await serverJson(request, `/prompt_manager/library/${harbor.id}?archive=all`)).positive.category).toBeFalsy();
await page.goto(`${workspace}#/library`);
await expect(page.locator(`article[data-prompt-id="${lantern.id}"] .card-title > .chip`)).toHaveText('night');
});
test('a plan with nothing to do shows only Close, never Apply', async ({ page, request }) => {
const applies = [];
page.on('request', item => { if (item.url().endsWith(APPLY_URL)) applies.push(item.url()); });
await openMaintenance(page);
// The concept fixture has no duplicate prompt text.
const reviewButton = row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', exact: true });
await reviewButton.click();
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Duplicate prompt text');
expect((await gridOf(page))[0]).toEqual(['Scope', 'No duplicate prompt text']);
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('There is nothing to apply; nothing was changed.');
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Close']);
await expect(dialog(page).locator('.notice')).toHaveCount(0);
await dialog(page).getByRole('button', { name: 'Close', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
await expect(reviewButton).toBeFocused();
expect(applies).toEqual([]);
});
test('Apply reviewed operation closes, toasts, opens the job in Activity', async ({ page, request }) => {
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
const applies = [];
page.on('request', item => { if (item.url().endsWith(APPLY_URL)) applies.push(item.postDataJSON()); });
await openMaintenance(page);
await row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', 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}$/);
await expect(toast(page)).toHaveText(/Duplicate prompt text (started|finished · 1 changed)/);
expect(applies).toHaveLength(1);
expect(Object.keys(applies[0])).toEqual(['token']);
const job = await ended(request, page.url().split('/').pop());
expect([job.kind, job.state, job.counters.changed]).toEqual(['maintenance', 'finished', 1]);
const main = page.locator('#workspace-main');
await expect(main.locator('h1')).toHaveText('Duplicate prompt text');
await expect(main.locator('.stat-row .stat dt')).toHaveText(['Changed', 'Protected', 'Errors']);
await expect(main.locator('.stat-row .stat dd').first()).toHaveText('1');
// Not cancellable: no Cancel, a report like every job.
await expect(main.getByRole('button', { name: 'Cancel job', exact: true })).toHaveCount(0);
await expect(main.getByRole('button', { name: 'Download report', exact: true })).toBeVisible();
});
test('plan-changed asks for a new review', async ({ page, request }) => {
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
await openMaintenance(page);
await row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', exact: true }).click();
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Duplicate prompt text');
// The library changes after the review: the apply's recheck refuses it and nothing is changed.
await expect(await request.post('/__test__/system/duplicate-prompts', { data: { again: true } })).toBeOK();
const before = await serverJson(request, '/__test__/system/digest');
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
await expect(toast(page)).toHaveText(`Duplicate prompt text failed · ${CHANGED}`);
await expect(page.locator('#workspace-main')).toContainText(CHANGED);
expect((await serverJson(request, '/__test__/system/digest')).rows).toEqual(before.rows);
// S7 review M2: the failed job offers Review again, which reviews the same operation afresh.
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-title')).toHaveText('Review maintenance plan');
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Duplicate prompt text');
expect(previews).toEqual([{ operation: 'duplicate_prompts', options: {} }]);
});
test('a review button pressed twice sends one preview (S7 review M4)', async ({ page, request }) => {
await expect(await request.post('/__test__/system/missing-file')).toBeOK();
await expect(await request.post('/__test__/system/duplicate-files')).toBeOK();
const previews = [];
page.on('request', item => { if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON().operation); });
await openMaintenance(page);
await row(page, 'Integrity & relationship checks').getByRole('button', { name: 'Check', exact: true }).click();
// Hold the preview at the server so the second press lands while it is read.
await expect(await request.post('/__test__/hold', { data: { path: PREVIEW_URL } })).toBeOK();
// The pressed button reads "Reviewing…" while held, so it is found by its place, not its name.
const missing = dialog(page).locator('.dialog-actions > button').last();
await expect(missing).toHaveText('Review missing links');
await missing.click();
await expect(missing).toHaveAttribute('aria-disabled', 'true');
await expect(missing).toHaveText('Reviewing…');
await missing.dispatchEvent('click');
await expect.poll(async () => (await serverJson(request, '/__test__/hold')).held[PREVIEW_URL]).toBe(1);
await expect(await request.post('/__test__/release')).toBeOK();
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Missing-file links');
expect(previews).toEqual(['missing_links']);
await page.keyboard.press('Escape');
await row(page, 'Duplicate image files').getByRole('button', { name: 'Find duplicates', exact: true }).click();
await page.locator('#workspace-main').getByRole('button', { name: 'Review groups', exact: true }).click();
await expect(await request.post('/__test__/hold', { data: { path: PREVIEW_URL } })).toBeOK();
const cleanup = dialog(page).locator('.dialog-actions > button').last();
await expect(cleanup).toHaveText('Review duplicate cleanup');
await cleanup.click();
await expect(cleanup).toHaveAttribute('aria-disabled', 'true');
await cleanup.dispatchEvent('click');
await expect.poll(async () => (await serverJson(request, '/__test__/hold')).held[PREVIEW_URL]).toBe(1);
await expect(await request.post('/__test__/release')).toBeOK();
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Duplicate image files');
expect(previews).toEqual(['missing_links', 'duplicate_files']);
});
test('plan-used links the job that applied it', async ({ page, request }) => {
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
// A double-submitted apply: the first request applies the plan, the second one's answer reaches the page.
await page.route(`**${APPLY_URL}`, async route => {
await route.fetch();
await route.fulfill({ response: await route.fetch() });
});
await openMaintenance(page);
await row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', exact: true }).click();
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
await expect(toast(page)).toHaveText('This plan was already applied: Duplicate prompt text.');
const link = toast(page).getByRole('link', { name: 'Duplicate prompt text', exact: true });
const { jobs } = await serverJson(request, '/prompt_manager/jobs');
const applied = jobs.find(job => job.kind === 'maintenance');
await expect(link).toHaveAttribute('href', `#/activity/${applied.id}`);
await expect(page).toHaveURL(`${workspace}#/system/maintenance`);
});
/**
* Record every toast the page shows (`[text, [link hrefs]]`): a job's finish toast may replace an earlier one before a
* test reads it.
*/
async function recordToasts(page) {
await page.addInitScript(() => {
window.toastLog = [];
const record = node => window.toastLog.push([node.textContent, [...node.querySelectorAll('a')].map(a => a.getAttribute('href'))]);
new MutationObserver(() => {
const node = document.getElementById('workspace-toast');
const last = window.toastLog.at(-1);
if (node && node.textContent && last?.[0] !== node.textContent) record(node);
}).observe(document, { subtree: true, childList: true, characterData: true });
});
return () => page.evaluate(() => window.toastLog);
}
const NOT_CONFIRMED = ['Outcome not confirmed. Check Activity before applying it again.', ['#/activity']];
/** The maintenance jobs the server lists (the apply ran even though its answer was lost). */
async function maintenanceJobs(request) {
return (await serverJson(request, '/prompt_manager/jobs')).jobs.filter(job => job.kind === 'maintenance');
}
test('lost apply response says Outcome not confirmed and points to Activity', async ({ page, request }) => {
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
// The server applies the plan; its answer never reaches the page.
const reads = [];
await page.route(`**${APPLY_URL}`, async route => {
await route.fetch();
page.on('request', item => { if (item.url().endsWith('/prompt_manager/jobs')) reads.push(item.url()); });
await route.abort('failed');
});
const toasts = await recordToasts(page);
await openMaintenance(page);
await row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', exact: true }).click();
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
await expect.poll(toasts).toContainEqual(NOT_CONFIRMED);
await expect(page).toHaveURL(`${workspace}#/system/maintenance`);
// The Activity list is read again at once, and the job the lost answer started is in it.
await expect.poll(() => reads.length).toBeGreaterThan(0);
expect((await maintenanceJobs(request)).map(job => job.title)).toEqual(['Duplicate prompt text']);
await page.evaluate(() => { location.hash = '#/activity'; });
await expect(page.locator('#workspace-main')).toContainText('Duplicate prompt text');
});
test('a proxy error or an unreadable answer is not a refusal: Outcome not confirmed (S7 review I1)', async ({ page, request }) => {
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
const reads = [];
// A reverse proxy times out after the server started the job.
await page.route(`**${APPLY_URL}`, async route => {
await route.fetch();
page.on('request', item => { if (item.url().endsWith('/prompt_manager/jobs')) reads.push(item.url()); });
await route.fulfill({ status: 504, contentType: 'text/html', body: '<html><body>Gateway Timeout</body></html>' });
});
const toasts = await recordToasts(page);
await openMaintenance(page);
await row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', exact: true }).click();
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
await expect.poll(toasts).toContainEqual(NOT_CONFIRMED);
await expect.poll(() => reads.length).toBeGreaterThan(0);
expect(await maintenanceJobs(request)).toHaveLength(1);
});
test('a refused apply names the running job', async ({ page, request }) => {
await expect(await request.post('/__test__/system/duplicate-prompts')).toBeOK();
await openMaintenance(page);
await row(page, 'Duplicate prompt text').getByRole('button', { name: 'Review plan', exact: true }).click();
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Duplicate prompt text');
const hold = await (await request.post('/__test__/jobs/hold', { data: { title: 'Database restore (admin page)' } })).json();
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
await expect(toast(page)).toHaveText('Another job is running: Database restore (admin page).');
await expect(toast(page).getByRole('link')).toHaveAttribute('href', `#/activity/${hold.job.id}`);
await expect(page).toHaveURL(`${workspace}#/system/maintenance`);
});
test('Find duplicates starts a job; its view links Review groups; Keep radios build a duplicate_files plan (D101)',
async ({ page, request }) => {
await expect(await request.post('/__test__/system/duplicate-files')).toBeOK();
const previews = [];
page.on('request', item => { if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON()); });
await openMaintenance(page);
await row(page, 'Duplicate image files').getByRole('button', { name: 'Find duplicates', exact: true }).click();
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
const jobId = page.url().split('/').pop();
const main = page.locator('#workspace-main');
await expect(main.locator('h1')).toHaveText('Find duplicate image files');
const reviewGroups = main.getByRole('button', { name: 'Review groups', exact: true });
await expect(reviewGroups).toBeVisible();
await expect(main.locator('.stat-row .stat dt')).toHaveText(['Groups found']);
await reviewGroups.click();
const review = dialog(page);
await expect(review.locator('#modal-title')).toHaveText('Review duplicate image files');
const group = review.locator('.compare-grid').filter({ hasText: 's7-dupes/copy.png' });
await expect(group.locator('.compare-item img')).toHaveCount(3);
const labels = (await group.locator('.compare-item label').allTextContents()).map(text => text.trim());
expect([...labels].sort()).toEqual(['Keep input/s7-dupes/same.png', 'Keep output/s7-dupes/copy.png',
'Keep output/s7-dupes/same.png']);
await expect(review.locator('#modal-body > p.small').last())
.toHaveText(/History keeps the removed paths in its run records\./);
// Keep the input copy: the same relative path as the output copy, told apart by its source index.
await group.locator('.compare-item').filter({ hasText: 'input/s7-dupes/same.png' }).getByRole('radio').check();
await review.getByRole('button', { name: 'Review duplicate cleanup', exact: true }).click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Review maintenance plan');
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Duplicate image files');
expect(previews).toHaveLength(1);
expect(previews[0].operation).toBe('duplicate_files');
expect(previews[0].options.job_id).toBe(jobId);
expect(previews[0].options.keep).toContainEqual(expect.objectContaining({ root_index: 1, path: 's7-dupes/same.png' }));
for (const entry of previews[0].options.keep) expect(Object.keys(entry).sort()).toEqual(['group_id', 'path', 'root_index']);
expect((await gridOf(page))[3][1]).toMatch(/^Deletes 2 files \(.+\) from disk; the kept copies stay\./);
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
await expect(page).not.toHaveURL(new RegExp(jobId));
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
const cleanup = await ended(request, page.url().split('/').pop());
expect([cleanup.kind, cleanup.state, cleanup.counters.removed]).toEqual(['duplicate_cleanup', 'finished', 2]);
await expect(main.locator('.stat-row .stat dt')).toHaveText(['Removed', 'Kept', 'Errors']);
expect((await request.get('/prompt_manager/images/serve/s7-dupes/same.png?root=1')).status()).toBe(200);
expect((await request.get('/prompt_manager/images/serve/s7-dupes/same.png?root=0')).status()).toBe(404);
expect((await request.get('/prompt_manager/images/serve/s7-dupes/copy.png?root=0')).status()).toBe(404);
});
test('the duplicates review pages ten groups at a time and keeps each choice (D101)', async ({ page, request }) => {
await expect(await request.post('/__test__/system/duplicate-files', { data: { groups: 11 } })).toBeOK();
const groupReads = [];
page.on('request', item => { if (item.url().includes('/maintenance/duplicates/')) groupReads.push(new URL(item.url()).search); });
const previews = [];
page.on('request', item => { if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON()); });
await openMaintenance(page);
await row(page, 'Duplicate image files').getByRole('button', { name: 'Find duplicates', exact: true }).click();
const main = page.locator('#workspace-main');
await main.getByRole('button', { name: 'Review groups', exact: true }).click();
const review = dialog(page);
const status = review.locator('#modal-body > p.small[role=status]');
await expect(status).toHaveText('Groups 1–10 of 12');
await expect(review.locator('.compare-grid')).toHaveCount(10);
await expect(review.getByRole('button', { name: 'Previous groups', exact: true })).toBeDisabled();
// The three-copy group sorts first (the most bytes to free): keep its input copy.
const first = review.locator('.compare-grid').first();
await first.locator('.compare-item').filter({ hasText: 'input/s7-dupes/same.png' }).getByRole('radio').check();
await review.getByRole('button', { name: 'Next groups', exact: true }).click();
await expect(status).toHaveText('Groups 11–12 of 12');
await expect(review.locator('.compare-grid')).toHaveCount(2);
await expect(review.getByRole('button', { name: 'Next groups', exact: true })).toBeDisabled();
await review.getByRole('button', { name: 'Previous groups', exact: true }).click();
await expect(status).toHaveText('Groups 1–10 of 12');
await expect(first.locator('.compare-item').filter({ hasText: 'input/s7-dupes/same.png' }).getByRole('radio')).toBeChecked();
expect(groupReads).toEqual(['?offset=0&limit=10', '?offset=10&limit=10', '?offset=0&limit=10']);
await review.getByRole('button', { name: 'Review duplicate cleanup', exact: true }).click();
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Duplicate image files');
// Every group shown so far is reviewed, each with its chosen copy.
expect(previews[0].options.keep).toHaveLength(12);
expect(previews[0].options.keep).toContainEqual(expect.objectContaining({ root_index: 1, path: 's7-dupes/same.png' }));
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
});
for (const [width, height] of [[1600, 1100], [390, 844]]) {
test(`long copy paths wrap; the duplicates review never scrolls sideways ${width}x${height}`, async ({ page, request }) => {
await page.setViewportSize({ width, height });
await expect(await request.post('/__test__/system/duplicate-files', { data: { long: true, trio: false } })).toBeOK();
await openMaintenance(page);
await row(page, 'Duplicate image files').getByRole('button', { name: 'Find duplicates', exact: true }).click();
await page.locator('#workspace-main').getByRole('button', { name: 'Review groups', exact: true }).click();
const review = dialog(page);
await expect(review.locator('.compare-item')).toHaveCount(2);
const overflow = await review.evaluate(node => {
const body = node.querySelector('#modal-body');
// An item wider than its box, or a label (inline: measured by its boxes) past its item's right edge.
const wide = [...node.querySelectorAll('.compare-item')].filter(item => item.scrollWidth > item.clientWidth + 1
|| [...item.querySelector('label').getClientRects()].some(rect => rect.right > item.getBoundingClientRect().right + 1)).length;
return { dialog: node.scrollWidth > node.clientWidth + 1, body: body.scrollWidth > body.clientWidth + 1, wide,
page: document.documentElement.scrollWidth > document.documentElement.clientWidth };
});
expect(overflow).toEqual({ dialog: false, body: false, wide: 0, page: false });
});
}
test('Thumbnail cache opens the S5 dialog', async ({ page }) => {
await openMaintenance(page);
const options = row(page, 'Thumbnail cache').getByRole('button', { name: 'Review options', exact: true });
await options.click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Thumbnail maintenance');
await expect(dialog(page).locator('.field > label')).toHaveText(['Scope']);
await page.keyboard.press('Escape');
await expect(options).toBeFocused();
});