Files
ComfyAssets-ComfyUI_PromptM…/tests/browser/workspace-activity.spec.mjs
Vito Sansevero 357c0f09e7 test(browser): one test server per Playwright worker
The page workflows started one global webServer and ran with one worker.
Each worker now owns its own tests/browser/server.py on an OS-chosen port,
with its own temp root and database, through a shared `test` in
tests/browser/fixtures.mjs. baseURL, and so page, request and
browser.newContext, point at that worker's server. A server that dies
fails the running test with its stderr tail and the next test gets a
fresh one. The worker count comes from PM_TEST_WORKERS, else 1 under CI,
else a quarter of the CPUs up to 8.

Chromium workflows in the pinned image: 510 tests in 606 s with one
worker on main, 511 (one new isolation spec) in 119 s with eight.
2026-10-21 18:30:22 -07:00

802 lines
50 KiB
JavaScript
Raw Permalink 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, openConcept, openProduction } from './concept-inventory.mjs';
// Activity (#249 Activity-1): the concept's Activity page (app.js renderActivity 732–738), its top-bar button
// (index.html 51–52) and sidebar row (app.js ~341), with the recorded D52 (counts of running jobs), D53 (the progress
// pill) and D54 (real job items). Fake jobs come from tests/browser/workspace_activity_fixture.py.
const workspace = '/prompt_manager/workspace';
const main = page => page.locator('#workspace-main');
const pill = page => page.locator('.top-actions #activity-pill');
const activityButton = page => page.locator('.top-actions button#activity-open');
const topCount = page => page.locator('#activity-count');
const navRow = page => page.locator('#system-nav a[data-route="activity"]');
const toast = page => page.locator('#workspace-toast');
const items = page => main(page).locator('section.surface > .activity-item');
const LOST = 'This job ended when ComfyUI restarted.';
// The job view (Task 3, D56): the page heading, then the Progress and Results surfaces and the Scan log.
const heading = page => main(page).locator(':scope > .heading');
const progressSection = page => main(page).getByRole('region', { name: 'Progress', exact: true });
const resultsSection = page => main(page).getByRole('region', { name: 'Results', exact: true });
const tiles = page => progressSection(page).locator('dl.stat-row > div.stat');
const outcomeButtons = page => resultsSection(page).locator('.outcome-list > button');
const fileRows = page => resultsSection(page).locator('.outcome-files > .file-row');
const resultsNote = page => resultsSection(page).locator('.outcome-files > p.small');
const filter = page => resultsSection(page).getByRole('searchbox', { name: 'Filter results', exact: true });
const scanLog = page => main(page).locator('details.term');
const TILE_LABELS = ['Added to Library', 'Already known', 'Needs review', 'No usable metadata', 'Errors'];
const VIEWER_HINT = 'each row opens the image in the viewer';
async function tileValues(page) {
return tiles(page).evaluateAll(nodes => nodes.map(node => [node.className, node.querySelector('dt').textContent,
node.querySelector('dd').textContent]));
}
async function openJob(page, id) {
await page.goto(`${workspace}#/activity/${id}`);
await expect(progressSection(page)).toBeVisible();
}
async function startJob(request, data = {}) {
const response = await request.post('/__test__/jobs/start', { data: { total: 19622, ...data } });
await expect(response).toBeOK();
return (await response.json()).job;
}
async function step(request, id, data) {
await expect(await request.post(`/__test__/jobs/${id}/step`, { data })).toBeOK();
}
async function finish(request, id, state = 'finished') {
const response = await request.post(`/__test__/jobs/${id}/finish`, { data: { state } });
await expect(response).toBeOK();
return (await response.json()).job;
}
async function listed(request) {
const response = await request.get('/prompt_manager/jobs');
await expect(response).toBeOK();
return (await response.json()).jobs;
}
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
await expect(await request.post('/__test__/reset')).toBeOK();
page.activityErrors = [];
page.on('pageerror', error => page.activityErrors.push(error.message));
});
test.afterEach(async ({ page, request, browserName }) => {
// The store's own reads can meet a route handler at teardown; that must not fail the test it outlived.
await page.unrouteAll({ behavior: 'ignoreErrors' });
await saveCoverage(page, browserName);
expect(page.activityErrors).toEqual([]);
await request.post('/__test__/jobs/restart');
});
/** The concept's item structure and the computed styles its CSS gives it, measured the same way in both documents. */
function measureItems(selector) {
return [...document.querySelectorAll(selector)].map(item => {
const css = (node, ...keys) => node && Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]]));
const tree = node => ({ tag: node.localName, className: [...node.classList].sort().join(' '), children: [...node.children].map(tree) });
const progress = item.querySelector('progress');
return {
tree: tree(item),
title: item.querySelector('h3').textContent,
chip: item.querySelector('.chip').textContent,
buttons: [...item.querySelectorAll('.row-actions button')].map(button => button.textContent),
styles: {
item: css(item, 'paddingTop', 'paddingBottom', 'borderBottomWidth', 'borderBottomStyle', 'borderBottomColor'),
heading: css(item.querySelector('.panel-heading'), 'display', 'justifyContent', 'alignItems', 'columnGap'),
h3: css(item.querySelector('h3'), 'fontSize', 'fontWeight', 'color', 'marginTop', 'marginBottom'),
chip: css(item.querySelector('.chip'), 'fontSize', 'color', 'backgroundColor', 'paddingTop', 'paddingLeft', 'borderTopLeftRadius'),
p: css(item.querySelector('p'), 'fontSize', 'color', 'marginTop', 'lineHeight'),
progress: progress && { ...css(progress, 'height', 'marginTop', 'marginBottom', 'accentColor'),
fills: Math.abs(progress.getBoundingClientRect().width - item.getBoundingClientRect().width) < 1,
max: progress.max, label: Boolean(progress.getAttribute('aria-label')) },
actions: css(item.querySelector('.row-actions'), 'display', 'columnGap', 'marginTop'),
button: css(item.querySelector('.row-actions button'), 'fontSize', 'paddingTop', 'paddingLeft', 'minHeight', 'borderTopLeftRadius'),
},
};
});
}
/** D56: an item tree whose `h3` holds the link to the job's view. */
function withTitleLink(tree) {
const [heading, ...rest] = tree.children;
const link = { tag: 'a', className: '', children: [] };
return { ...tree, children: [{ ...heading, children: heading.children.map(node => node.tag === 'h3' ? { ...node, children: [link] } : node) }, ...rest] };
}
function measurePage(mainSelector) {
const root = document.querySelector(mainSelector);
const section = root.querySelector(':scope > section.surface');
return {
h1: root.querySelector('.heading h1').textContent,
description: root.querySelector('.heading p.muted').textContent,
actions: [...root.querySelector('.heading .heading-actions').children].map(node => node.textContent),
h2: section.querySelector(':scope > h2').textContent,
surface: Object.fromEntries(['paddingTop', 'borderTopWidth', 'backgroundColor', 'borderTopLeftRadius']
.map(key => [key, getComputedStyle(section)[key]])),
};
}
for (const theme of ['dark', 'light']) test(`activity page matches the concept list markup ${theme}`, async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await seedConcept(request);
// The concept: its finished sample, then a running job started from System (startJob, app.js 940–952).
await openConcept(page, 'activity', theme);
const conceptPage = await page.evaluate(measurePage, '#main');
const [conceptDone] = await page.evaluate(measureItems, '#main .activity-item');
await page.goto('/__test__/canonical/workspace#system');
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
await page.getByRole('button', { name: 'Review thumbnails', exact: true }).click();
await expect(page.locator('#main .activity-item progress')).toHaveCount(1);
const [conceptRunning] = await page.evaluate(measureItems, '#main .activity-item');
const done = await startJob(request);
await step(request, done.id, { processed: 19622, counters: { added: 1420, errors: 18 } });
await finish(request, done.id);
const running = await startJob(request);
await step(request, running.id, { processed: 8240 });
await openProduction(page, 'activity', theme);
await expect(items(page)).toHaveCount(2);
const productionPage = await page.evaluate(measurePage, '#workspace-main');
const [productionRunning, productionDone] = await page.evaluate(measureItems, '#workspace-main .activity-item');
// D52: no "Unified activity · proposed" chip (prototype copy); the rest of the heading and the section are the concept's.
expect(conceptPage.actions).toEqual(['Unified activity · proposed']);
expect(productionPage).toEqual({ ...conceptPage, actions: [] });
// A completed job is the concept's completed item; D56: its title is a link to the job's view, in the title's colour.
expect(conceptDone.title).toBe('✓ Library metadata enriched');
expect(productionDone).toEqual({ ...conceptDone, tree: withTitleLink(conceptDone.tree), title: '✓ Scan for metadata' });
const links = await items(page).locator('h3 > a').evaluateAll(nodes => nodes.map(node => [node.getAttribute('href'),
getComputedStyle(node).color === getComputedStyle(node.parentElement).color, getComputedStyle(node).textDecorationLine]));
expect(links).toEqual([[`#/activity/${running.id}`, true, 'none'], [`#/activity/${done.id}`, true, 'none']]);
// D54: a running job's chip names its state ("Running", where the concept's sample reads "Needs attention"), and
// its actions are Cancel job only (no "Simulate completion").
expect(conceptRunning.buttons).toEqual(['Simulate completion', 'Cancel job']);
const [, ...cancelOnly] = conceptRunning.tree.children.at(-1).children;
const conceptActions = { ...conceptRunning.tree.children.at(-1), children: cancelOnly };
expect(productionRunning).toEqual({ ...conceptRunning,
tree: withTitleLink({ ...conceptRunning.tree, children: [...conceptRunning.tree.children.slice(0, -1), conceptActions] }),
title: '○ Scan for metadata', chip: 'Running', buttons: ['Cancel job'] });
await expect(items(page).nth(1).locator('p')).toHaveText(/^Finished \d\d:\d\d · 1,420 added · 18 errors$/);
await expect(items(page).first().locator('p')).toHaveText(/^Started \d\d:\d\d · 2 output folders · closing this view does not cancel it$/);
await expect(items(page).first().locator('progress')).toHaveAttribute('aria-label', 'Scan for metadata progress');
expect(await items(page).first().locator('progress').evaluate(node => node.value)).toBe(41);
});
test('pill shows while a job runs and opens its view', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/images`);
// D52: no running job, so neither count shows; the button is the concept's.
await expect(activityButton(page)).toHaveAccessibleName('Activity');
await expect(topCount(page)).toBeHidden();
await expect(navRow(page)).toHaveText('Activity');
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
await expect(pill(page)).toBeHidden();
const job = await startJob(request);
// The list is read again on its own (15 s), or at once on a reload or when Activity opens.
await page.reload();
await expect(pill(page)).toBeVisible();
await expect(pill(page)).toHaveText('Scanning · 0% · 0 / 19,622');
await step(request, job.id, { processed: 8240 });
const text = 'Scanning · 41% · 8,240 / 19,622';
await expect(pill(page)).toHaveText(text);
await expect(pill(page)).toHaveAttribute('aria-label', text);
await expect(pill(page)).toHaveAttribute('href', `#/activity/${job.id}`);
expect(await pill(page).locator('.activity-pill-bar i').evaluate(node => node.style.width)).toBe('41%');
// The pill sits before the button, in the mockup's place, and both counts read 1.
expect(await page.locator('.top-actions > *').evaluateAll(nodes => nodes.map(node => node.id || node.className)))
.toEqual(['activity-pill', 'activity-open', 'theme-label', 'canvas-link']);
await expect(topCount(page)).toBeVisible();
await expect(topCount(page)).toHaveText('1');
await expect(navRow(page).locator('.nav-count')).toHaveText('1');
// The pill opens the job's own view (Task 3, D56), named by the job.
await pill(page).click();
await expect(page).toHaveURL(new RegExp(`#/activity/${job.id}$`));
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Scan for metadata');
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Activity');
await expect(page).toHaveTitle('Scan for metadata · Prompt Manager');
await expect(navRow(page)).toHaveAttribute('aria-current', 'page');
await expect(progressSection(page).locator('progress')).toHaveJSProperty('value', 41);
// The Activity button opens the list itself from any page.
await page.locator('nav [data-route="library"]').click();
await activityButton(page).click();
await expect(page).toHaveURL(/#\/activity$/);
await expect(items(page)).toHaveCount(1);
await finish(request, job.id);
await expect(pill(page)).toBeHidden();
await expect(topCount(page)).toBeHidden();
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
await expect(items(page).first().locator('.chip.green')).toHaveText('Completed');
await expect(items(page).first().locator('progress, .row-actions')).toHaveCount(0);
// A finished job's title still opens its view (D56).
await items(page).first().locator('h3 > a').click();
await expect(page).toHaveURL(new RegExp(`#/activity/${job.id}$`));
await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · 0 added · 0 errors$/);
});
test('a phone keeps the pill and its controls inside the topbar', async ({ page, request }) => {
await page.setViewportSize({ width: 390, height: 844 });
const job = await startJob(request);
await step(request, job.id, { processed: 8240 });
await page.goto(`${workspace}#/images`);
await expect(pill(page)).toHaveText('Scanning · 41% · 8,240 / 19,622');
await expect(pill(page)).toHaveAttribute('aria-label', 'Scanning · 41% · 8,240 / 19,622');
await expect(pill(page).locator('.activity-pill-files, .activity-pill-bar')).toHaveCount(2);
await expect(pill(page).locator('.activity-pill-files')).toBeHidden();
const layout = await page.locator('.top-actions').evaluate(row => ({
scroll: document.documentElement.scrollWidth <= document.documentElement.clientWidth,
inside: [...row.children].filter(node => node.getBoundingClientRect().width)
.every(node => node.getBoundingClientRect().right <= row.getBoundingClientRect().right + 0.5
&& node.getBoundingClientRect().left >= row.getBoundingClientRect().left - 0.5),
}));
expect(layout).toEqual({ scroll: true, inside: true });
});
test('reload during a scan reattaches to the running job', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 1000 });
await page.goto(`${workspace}#/activity`);
await expect(items(page)).toHaveCount(1);
await expect(pill(page)).toHaveText('Scanning · 5% · 1,000 / 19,622');
await page.reload();
await expect(pill(page)).toHaveText('Scanning · 5% · 1,000 / 19,622');
// The reloaded page follows the same job live and started nothing.
await step(request, job.id, { processed: 9811 });
await expect(pill(page)).toHaveText('Scanning · 50% · 9,811 / 19,622');
expect(await items(page).first().locator('progress').evaluate(node => node.value)).toBe(50);
await expect(items(page)).toHaveCount(1);
expect((await listed(request)).map(item => [item.id, item.state])).toEqual([[job.id, 'running']]);
// A second tab attaches to the same job and sees the same progress.
const other = await page.context().newPage();
await other.goto(`${workspace}#/activity`);
await expect(pill(other)).toHaveText('Scanning · 50% · 9,811 / 19,622');
await expect(other.locator('#workspace-main .activity-item')).toHaveCount(1);
await other.close();
expect((await listed(request)).length).toBe(1);
// Cancel job asks the server to stop at a file boundary; the job ends cancelled with its partial counts.
await step(request, job.id, { counters: { added: 7 } });
await items(page).first().getByRole('button', { name: 'Cancel job', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(toast(page)).toHaveText('Scan cancelled · 7 added');
// The removed Cancel job hands its focus to the item's title link (review M4).
await expect(items(page).first().locator('h3 > a')).toBeFocused();
await expect(items(page).first().locator('.chip.blue')).toHaveText('Cancelled');
await expect(items(page).first().locator('p')).toHaveText(/^Cancelled \d\d:\d\d at a file boundary · 7 added · completed work is kept$/);
await expect(pill(page)).toBeHidden();
});
test('lost server clears the pill and marks jobs ended', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 4000 });
await page.goto(`${workspace}#/activity`);
await expect(pill(page)).toBeVisible();
await expect(topCount(page)).toHaveText('1');
const reads = [];
page.on('request', item => { if (new URL(item.url()).pathname === '/prompt_manager/jobs') reads.push(Date.now()); });
// ComfyUI restarts: the stream drops without a final event and the new registry is empty.
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
await expect(pill(page)).toBeHidden();
await expect(topCount(page)).toBeHidden();
await expect(items(page).first().locator('.chip.blue')).toHaveText('Ended');
await expect(items(page).first().locator('p')).toHaveText(LOST);
await expect(items(page).first().locator('progress, .row-actions')).toHaveCount(0);
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
// One re-read found the restart; there is no retry storm and no finish toast for a job that only vanished.
await page.waitForTimeout(2500);
expect(reads.length).toBeLessThanOrEqual(2);
await expect(toast(page)).toHaveText('');
// A server that cannot be read at all also ends the running job, then is read again with a growing backoff.
const second = await startJob(request);
await page.reload();
await expect(pill(page)).toBeVisible();
const failed = [];
await page.route('**/prompt_manager/jobs**', route => { failed.push(Date.now()); return route.abort(); });
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
await expect(pill(page)).toBeHidden();
await expect(items(page).filter({ hasText: LOST })).toHaveCount(1);
await page.waitForTimeout(3500);
// 1 s, then 2 s: at most two re-reads in 3.5 s.
expect(failed.length).toBeLessThanOrEqual(2);
await page.unroute('**/prompt_manager/jobs**');
expect(second.id).not.toBe(job.id);
});
test('start and finish toasts', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await page.goto(`${workspace}#/images`);
await expect(pill(page)).toBeVisible();
await step(request, job.id, { processed: 19622, counters: { added: 1420, errors: 18 } });
await finish(request, job.id);
await expect(toast(page)).toHaveText('Scan finished · 1,420 added · 18 errors');
await expect(pill(page)).toBeHidden();
const single = await startJob(request);
await page.reload();
await expect(pill(page)).toBeVisible();
await step(request, single.id, { processed: 19622, counters: { added: 1, errors: 1 } });
await finish(request, single.id, 'failed');
await expect(toast(page)).toHaveText('Scan failed · 1 added · 1 error');
// The store's own start: the real metadata scan over this server's outputs, announced, followed and finished.
await page.goto(`${workspace}#/activity`);
const started = await page.evaluate(async () => {
const { createActivityStore } = await import('/prompt_manager/js/workspace/activity-store.mjs');
const controller = new AbortController();
const store = createActivityStore({ api: window.ApiClient.createApiClient(), pollMs: 0,
scope: { signal: controller.signal, cleanup: () => {} } });
const result = await store.start('scan');
// Read before the busy attempt: its list read may already see the small scan finished and toast that.
const toast = document.getElementById('workspace-toast').textContent;
const busy = await store.start('scan').catch(error => ({ error: error.message }));
return { id: result.job.id, title: result.job.title, toast, busy: busy.busy?.id ?? null, message: busy.message ?? null };
});
expect(started.title).toBe('Scan for metadata');
expect(started.toast).toBe('Scan started.');
// A second start while the scan runs is refused and names it (unless the small fixture scan already ended).
if (started.busy !== null) expect(started.busy).toBe(started.id);
await expect(toast(page)).toHaveText(/^Scan (started\.|finished · \d+ added · \d+ errors?)$/);
await expect.poll(async () => (await listed(request)).find(item => item.id === started.id)?.state, { timeout: 15000 }).toBe('finished');
});
// ── Task 3: Scan for metadata, its review dialog and the job view ─────────────────────────────
async function startFromImport(page) {
await page.goto(`${workspace}#/images`);
await page.getByRole('button', { name: 'Import', exact: true }).click();
const importDialog = page.getByRole('dialog', { name: 'Import & inspect', exact: true });
const row = importDialog.locator('.operation').filter({ hasText: 'Scan for metadata' });
await row.getByRole('button', { name: 'Review scan', exact: true }).click();
const review = page.getByRole('dialog', { name: 'Review metadata scan', exact: true });
await expect(review).toBeVisible();
await review.getByRole('button', { name: 'Start scan', exact: true }).click();
await expect(review).toHaveCount(0);
}
test('start scan creates a job, toasts, and opens its view', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const posted = [];
page.on('request', item => { if (item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/jobs/scan') posted.push(item); });
await startFromImport(page);
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
const id = page.url().split('/').at(-1);
expect(posted).toHaveLength(1);
// The fixture scan is small and may already have finished; the start toast came first either way.
await expect(toast(page)).toHaveText(/^Scan (started\.|finished · \d+ added · \d+ errors?)$/);
await expect(heading(page).locator('h1')).toHaveText('Scan for metadata');
await expect(heading(page).locator('h1')).toBeFocused();
expect((await listed(request)).map(item => item.id)).toEqual([id]);
await expect.poll(async () => (await listed(request))[0].state, { timeout: 15000 }).toBe('finished');
await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · \d+ added · \d+ errors?$/);
// The finished scan's tiles add up to the files it processed, and its log is there to open.
const job = (await listed(request))[0];
const values = await tileValues(page);
expect(values.map(([, label]) => label)).toEqual(TILE_LABELS);
expect(values.reduce((sum, [, , value]) => sum + Number(value.replaceAll(',', '')), 0)).toBe(job.processed);
// Opening the log reads its last lines (review M12).
await scanLog(page).locator('summary').click();
await expect(scanLog(page).locator('pre')).toContainText('Scan finished');
});
// ── Task 4 (Activity-2): scan scope, new and unresolved files by default ─────────────────────
test('all files checkbox changes the scope line and the request', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const bodies = [];
page.on('request', item => {
if (item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/jobs/scan') bodies.push(item.postDataJSON());
});
const review = page.getByRole('dialog', { name: 'Review metadata scan', exact: true });
const scopeValue = review.locator('.meta-grid > div').filter({ has: page.locator('dt', { hasText: 'Scope' }) }).locator('dd');
// The concept's switch: a checkbox with role=switch.
const allFiles = review.getByRole('switch', { name: 'All files', exact: true });
const openReview = async () => {
await page.goto(`${workspace}#/images`);
await page.getByRole('button', { name: 'Import', exact: true }).click();
await page.getByRole('dialog', { name: 'Import & inspect', exact: true }).locator('.operation')
.filter({ hasText: 'Scan for metadata' }).getByRole('button', { name: 'Review scan', exact: true }).click();
await expect(review).toBeVisible();
};
// The default: the concept's "New and unresolved files", the box unchecked; the request asks for "new".
await openReview();
await expect(scopeValue).toHaveText('New and unresolved files');
await expect(allFiles).not.toBeChecked();
await expect(review.locator('.setting-row p')).toHaveText('Also read the files already linked to their prompt again.');
await expect(allFiles).toHaveAccessibleDescription('Also read the files already linked to their prompt again.');
// Checking and unchecking switches the Scope value both ways.
await allFiles.check();
await expect(scopeValue).toHaveText('All files');
await allFiles.uncheck();
await expect(scopeValue).toHaveText('New and unresolved files');
await review.getByRole('button', { name: 'Start scan', exact: true }).click();
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
await expect.poll(() => bodies.length).toBe(1);
expect(bodies[0]).toEqual({ scope: 'new' });
await expect(heading(page).locator('h1')).toHaveText('Scan for metadata');
// Wait for the first scan to end so the second is not refused as busy.
await scanLog(page).locator('summary').click();
await expect(scanLog(page).locator('pre')).toContainText('Scan finished', { timeout: 15000 });
await expect(scanLog(page).locator('pre')).toContainText('new and unresolved files');
// Checked: "All files", and the request asks for "all".
await openReview();
await allFiles.check();
await expect(scopeValue).toHaveText('All files');
await review.getByRole('button', { name: 'Start scan', exact: true }).click();
await expect.poll(() => bodies.length).toBe(2);
expect(bodies[1]).toEqual({ scope: 'all' });
await scanLog(page).locator('summary').click();
await expect(scanLog(page).locator('pre')).toContainText('Scan started · ', { timeout: 15000 });
await expect(scanLog(page).locator('pre')).toContainText('all files');
});
test('job view counters follow the mapping and update live', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 8240, counters: { added: 1420, known: 6511, review: 37, no_metadata: 254, errors: 18 } });
await openJob(page, job.id);
// The header: the title, the detail line, then Download report (enabled in Task 5) and Cancel scan (danger).
await expect(heading(page).locator('h1')).toHaveText('Scan for metadata');
await expect(heading(page).locator('p.muted')).toHaveText(/^Started \d\d:\d\d · 2 output folders · closing this view does not cancel it$/);
await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report', 'Cancel scan']);
await expect(heading(page).getByRole('button', { name: 'Download report', exact: true })).toBeEnabled();
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).toHaveClass(/danger/);
// Progress: the bar, the server's line and the five tiles in order and colour.
await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Processing file 8,240 of 19,622');
await expect(progressSection(page).locator('progress')).toHaveAttribute('aria-label', 'Scan progress');
await expect(progressSection(page).locator('progress')).toHaveJSProperty('value', 41);
expect(await tileValues(page)).toEqual([
['stat added', 'Added to Library', '1,420'], ['stat', 'Already known', '6,511'], ['stat warn', 'Needs review', '37'],
['stat', 'No usable metadata', '254'], ['stat bad', 'Errors', '18'],
]);
const colours = await tiles(page).evaluateAll(nodes => nodes.map(node => getComputedStyle(node.querySelector('dd')).color));
const expected = await page.evaluate(() => ['--green', '--ca-text', '--warning', '--ca-text', '--red'].map(name => {
const probe = document.createElement('span');
probe.style.color = `var(${name})`;
document.querySelector('#workspace-main').append(probe);
const colour = getComputedStyle(probe).color;
probe.remove();
return colour;
}));
expect([colours[0], colours[2], colours[4]]).toEqual([expected[0], expected[2], expected[4]]);
expect(colours[1]).toBe(colours[3]);
// The outcome list follows the mockup's order with the same counts.
await expect(outcomeButtons(page)).toHaveText(['Added to Library1,420', 'Needs review37', 'Errors18', 'No usable metadata254', 'Already known6,511']);
// Live: the next step updates the bar, the line and the tiles without a reload.
await step(request, job.id, { processed: 9811, counters: { added: 1500, errors: 19 }, records: [
{ group: 'added', path: 'live/new.png', detail: 'Added to the Library as “Live”' },
] });
await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Processing file 9,811 of 19,622');
await expect(progressSection(page).locator('progress')).toHaveJSProperty('value', 50);
await expect(tiles(page).first().locator('dd')).toHaveText('1,500');
await expect(tiles(page).last().locator('dd')).toHaveText('19');
await expect(fileRows(page)).toHaveCount(1);
// A focused row keeps its focus while later reads append rows.
await fileRows(page).first().focus();
await step(request, job.id, { records: [{ group: 'added', path: 'live/next.png', detail: 'Added' }] });
await expect(fileRows(page)).toHaveCount(2);
await expect(fileRows(page).first()).toBeFocused();
// The log is collapsed by default and holds the raw timestamped lines.
await expect(scanLog(page)).not.toHaveAttribute('open', '');
await expect(scanLog(page).locator('summary')).toContainText('Scan log');
await scanLog(page).locator('summary').click();
await expect(scanLog(page).locator('pre')).toHaveAttribute('tabindex', '0');
await expect(scanLog(page).locator('pre')).toHaveText(/^\d\d:\d\d:\d\d \+ live\/new\.png · Added to the Library as “Live”\n\d\d:\d\d:\d\d \+ live\/next\.png · Added$/);
// Finished: the header says so, Cancel scan is gone and the counters stay.
await finish(request, job.id);
await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · 1,501 added · 19 errors$/);
await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report']);
await expect(tiles(page).first().locator('dd')).toHaveText('1,501');
});
test('added rows open the viewer, error rows are plain', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
// The fixture's two listed files share a name in the output and input roots; the row's root label tells them apart.
await step(request, job.id, { processed: 3, records: [
{ group: 'added', path: 'shared/same.png', root: 'output', detail: 'Added to the Library as “Cat portrait”' },
{ group: 'known', path: 'shared/same.png', root: 'input', detail: 'Already linked to “Dog portrait”' },
{ group: 'errors', path: 'broken/truncated.png', root: 'output', detail: 'Error: the file could not be processed (see the server log)' },
] });
await openJob(page, job.id);
await expect(outcomeButtons(page).first()).toHaveAttribute('aria-pressed', 'true');
await expect(fileRows(page)).toHaveCount(1);
const added = fileRows(page).first();
expect(await added.evaluate(node => node.localName)).toBe('button');
await expect(added.locator('.path')).toHaveText('shared/same.png');
await expect(added.locator('.why')).toHaveText('Added to the Library as “Cat portrait”');
await expect(resultsNote(page)).toHaveText('Each row opens the image in the viewer.');
await added.click();
const viewer = page.locator('dialog#viewer-dialog[open]');
await expect(viewer).toBeVisible();
await expect(viewer.locator('#viewer-title')).toContainText('same.png');
await expect(viewer.locator('#viewer-stage img').first()).toHaveAttribute('src', /[?&]root=0(&|$)/);
await page.keyboard.press('Escape');
await expect(viewer).toHaveCount(0);
await expect(added).toBeFocused();
// Already known opens the input root's file.
await outcomeButtons(page).filter({ hasText: 'Already known' }).click();
await expect(outcomeButtons(page).filter({ hasText: 'Already known' })).toHaveAttribute('aria-pressed', 'true');
await fileRows(page).first().click();
await expect(page.locator('dialog#viewer-dialog[open] #viewer-stage img').first()).toHaveAttribute('src', /[?&]root=1(&|$)/);
await page.keyboard.press('Escape');
// Errors: plain text rows, no viewer hint.
await outcomeButtons(page).filter({ hasText: 'Errors' }).click();
await expect(fileRows(page)).toHaveCount(1);
expect(await fileRows(page).first().evaluate(node => node.localName)).toBe('div');
await expect(fileRows(page).first().locator('.path')).toHaveText('broken/truncated.png');
await expect(resultsNote(page)).toHaveCount(0);
// An empty group says so.
await outcomeButtons(page).filter({ hasText: 'Needs review' }).click();
await expect(resultsSection(page).locator('.outcome-files > p.empty-state')).toHaveText('No files in this group.');
// A listed row whose file has left the listing toasts instead of opening an empty viewer.
await step(request, job.id, { records: [{ group: 'added', path: 'gone/missing.png', root: 'output', detail: 'Added' }] });
await outcomeButtons(page).first().click();
await expect(fileRows(page)).toHaveCount(2);
await fileRows(page).nth(1).click();
await expect(toast(page)).toHaveText('This file is no longer in Images. Rescan from Import and try again.');
await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
// A row opens only its own root's file: a root whose label no longer matches (a replaced source) never opens the
// other root's same path, and a row without a root index opens nothing (review M8, M9).
const lookups = [];
page.on('request', item => { const url = new URL(item.url()); if (url.pathname === '/prompt_manager/images/output' && url.searchParams.get('path')) lookups.push(url.search); });
await step(request, job.id, { records: [
{ group: 'added', path: 'shared/same.png', root: 'output', root_index: 1, detail: 'Added' },
{ group: 'added', path: 'shared/same.png', root: 'output', root_index: null, detail: 'Added' },
] });
await expect(fileRows(page)).toHaveCount(4);
for (const index of [2, 3]) {
await fileRows(page).nth(index).click();
await expect(toast(page)).toHaveText('This file is no longer in Images. Rescan from Import and try again.');
await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
}
expect(lookups).toEqual([]);
// The lookup is the exact identity, not a name search.
await fileRows(page).first().click();
await expect(page.locator('dialog#viewer-dialog[open]')).toBeVisible();
expect(lookups).toEqual(['?mode=workspace&root=0&path=shared%2Fsame.png&limit=5']);
await page.keyboard.press('Escape');
});
test('filter narrows the selected outcome', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 4, records: [
{ group: 'added', path: 'CCC/portrait_0001.png', detail: 'Added' },
{ group: 'added', path: 'CCC/portrait_0002.png', detail: 'Added' },
{ group: 'added', path: 'forest/pines_0008.png', detail: 'Added' },
{ group: 'errors', path: 'CCC/broken.png', detail: 'Error' },
] });
await openJob(page, job.id);
await expect(fileRows(page)).toHaveCount(3);
await expect(filter(page)).toHaveAttribute('placeholder', 'Filter by file path…');
await filter(page).fill('ccc/');
await expect(fileRows(page).locator('.path')).toHaveText(['CCC/portrait_0001.png', 'CCC/portrait_0002.png']);
await expect(resultsNote(page)).toHaveText(`Showing 2 of 3 · ${VIEWER_HINT}`);
// The filter stays while another outcome is chosen.
await outcomeButtons(page).filter({ hasText: 'Errors' }).click();
await expect(fileRows(page).locator('.path')).toHaveText(['CCC/broken.png']);
await filter(page).fill('pines');
await expect(fileRows(page)).toHaveCount(0);
await expect(resultsSection(page).locator('.outcome-files > p.empty-state')).toHaveText('No listed file matches this filter.');
await outcomeButtons(page).first().click();
await expect(fileRows(page).locator('.path')).toHaveText(['forest/pines_0008.png']);
await filter(page).fill('');
await expect(fileRows(page)).toHaveCount(3);
});
test('job view renders 200-row cap note', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 260, bulk: { group: 'added', count: 250 }, records: [
...Array.from({ length: 10 }, (_, index) => ({ group: 'no_metadata', path: `misc/plain_${index}.png`, detail: 'No prompt metadata in the file' })),
] });
// Every detail read of this job: in flight, and whether one has returned the full listing.
const detailUrl = `/prompt_manager/jobs/${job.id}`;
const isDetail = item => new URL(item.url()).pathname === detailUrl;
const detailReads = { inFlight: 0, full: false };
page.on('request', item => { if (isDetail(item)) detailReads.inFlight += 1; });
page.on('requestfailed', item => { if (isDetail(item)) detailReads.inFlight -= 1; });
page.on('response', async response => {
if (!isDetail(response)) return;
const body = await response.json().catch(() => null);
const outcomes = body?.job?.outcomes ?? {};
if (outcomes.added?.length === 200 && outcomes.no_metadata?.length === 10) detailReads.full = true;
detailReads.inFlight -= 1;
});
await openJob(page, job.id);
await expect(tiles(page).first().locator('dd')).toHaveText('250');
await expect(fileRows(page)).toHaveCount(200);
await expect(resultsNote(page)).toHaveText(`Showing 200 of 250 · ${VIEWER_HINT}`);
await outcomeButtons(page).filter({ hasText: 'No usable metadata' }).click();
await expect(fileRows(page)).toHaveCount(10);
await expect(resultsNote(page)).toHaveCount(0);
// Every listed row is in the page and it stays responsive: a filter answers at once.
await outcomeButtons(page).first().click();
await filter(page).fill('file_00199');
await expect(fileRows(page)).toHaveCount(1);
await expect(resultsNote(page)).toHaveText(`Showing 1 of 250 · ${VIEWER_HINT}`);
// Review M12: with the list capped and the log closed, more files only update the counters; the detail is read
// again only when the log opens with new lines.
// The reads from opening the view have settled: one returned the full listing and none is in flight. The view
// re-reads only while what the summary lists differs from the last read, so no further read is due.
await expect.poll(() => detailReads.full && detailReads.inFlight === 0).toBe(true);
const reads = [];
page.on('request', item => { if (new URL(item.url()).pathname === `/prompt_manager/jobs/${job.id}`) reads.push(Date.now()); });
await step(request, job.id, { processed: 300, bulk: { group: 'added', count: 40 } });
await expect(tiles(page).first().locator('dd')).toHaveText('290');
await page.waitForTimeout(1500);
expect(reads).toEqual([]);
await scanLog(page).locator('summary').click();
await expect.poll(() => reads.length).toBe(1);
await expect(scanLog(page).locator('pre')).toContainText('bulk/file_00039.png');
});
test('cancel scan ends cancelled with partial counts', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 9, counters: { added: 7, errors: 2 } });
await openJob(page, job.id);
// From the keyboard: when the job ends and Cancel scan goes away, focus moves to the job's heading (review M4).
await heading(page).getByRole('button', { name: 'Cancel scan', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(toast(page)).toHaveText('Scan cancelled · 7 added');
await expect(heading(page).locator('h1')).toBeFocused();
await expect(heading(page).locator('p.muted')).toHaveText(/^Cancelled \d\d:\d\d at a file boundary · 7 added · completed work is kept$/);
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).toHaveCount(0);
await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Cancelled after 9 of 19,622 files');
expect((await tileValues(page)).map(([, , value]) => value)).toEqual(['7', '0', '0', '0', '2']);
expect((await listed(request))[0].state).toBe('cancelled');
// A failed cancel request keeps the button and says so.
const second = await startJob(request);
await openJob(page, second.id);
await page.route('**/cancel', route => route.fulfill({ status: 500, json: { success: false, error: 'nope' } }));
await heading(page).getByRole('button', { name: 'Cancel scan', exact: true }).click();
await expect(toast(page)).toHaveText('The job could not be cancelled. Try again.');
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).not.toHaveAttribute('aria-disabled', 'true');
});
test('busy start names the running job', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const running = await startJob(request, { title: 'Database maintenance', kind: 'maintenance' });
await startFromImport(page);
await expect(toast(page)).toHaveText('Another job is running: Database maintenance.');
await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toHaveAttribute('href', `#/activity/${running.id}`);
await expect(page).toHaveURL(/#\/images$/);
expect((await listed(request)).map(item => item.id)).toEqual([running.id]);
// Review M14: a toast with a link outlasts the plain 6.5 s, so the link stays reachable.
await page.waitForTimeout(7000);
await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toBeVisible();
await toast(page).getByRole('link', { name: 'Database maintenance', exact: true }).click();
await expect(heading(page).locator('h1')).toHaveText('Database maintenance');
// A job of another kind cancels as "Cancel job".
await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report', 'Cancel job']);
});
test('an unknown or restarted job view says so', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/activity/${'0'.repeat(32)}`);
await expect(main(page).locator('section.surface p.empty-state')).toHaveText('This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts.');
await expect(main(page).getByRole('link', { name: 'All activity', exact: true })).toHaveAttribute('href', '#/activity');
const job = await startJob(request);
await step(request, job.id, { processed: 5, records: [{ group: 'added', path: 'kept/row.png', detail: 'Added' }] });
await openJob(page, job.id);
await expect(fileRows(page)).toHaveCount(1);
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
await expect(heading(page).locator('p.muted')).toHaveText(LOST);
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).toHaveCount(0);
// A lost job has no report on the server (review M9): Download report is disabled rather than failing.
await expect(heading(page).getByRole('button', { name: 'Download report', exact: true })).toBeDisabled();
// What was read before the restart stays on the page.
await expect(fileRows(page)).toHaveCount(1);
await expect(pill(page)).toBeHidden();
});
// Review M3: a finished job the server no longer lists is gone from this tab too, and its open view says so.
test('a finished job the server pruned leaves its open view honest', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 1, records: [{ group: 'added', path: 'kept/row.png', detail: 'Added' }] });
await finish(request, job.id);
await openJob(page, job.id);
await expect(fileRows(page)).toHaveCount(1);
await filter(page).focus();
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
// The next periodic list read (15 s) no longer has the finished job.
test.slow();
await expect(main(page).locator('section.surface p.empty-state')).toHaveText('This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts.', { timeout: 20000 });
await expect(progressSection(page)).toHaveCount(0);
await expect(heading(page).locator('h1')).toHaveText('Activity');
await expect(heading(page).locator('h1')).toBeFocused();
await expect(page).toHaveTitle('Activity · Prompt Manager');
});
// Activity-2 Task 5 (D59): Download report and the log's scroll position.
test('download report saves the job JSON', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 2, records: [
{ group: 'added', path: 'sub/a.png', root: 'output', detail: 'Added' },
{ group: 'errors', path: 'broken/b.png', root: 'output', detail: 'Error' },
] });
await finish(request, job.id);
await openJob(page, job.id);
const button = heading(page).getByRole('button', { name: 'Download report', exact: true });
await expect(button).toBeEnabled();
const [download] = await Promise.all([page.waitForEvent('download'), button.click()]);
expect(download.suggestedFilename()).toMatch(/^prompt-manager-scan-\d{8}-\d{4}\.json$/);
const path = await download.path();
const report = JSON.parse(await (await import('node:fs/promises')).readFile(path, 'utf8'));
expect(report.job.id).toBe(job.id);
expect(report.outcomes.added.rows.map(row => row.path)).toEqual(['sub/a.png']);
expect(report.outcomes.errors.truncated).toBe(false);
expect(report.log.length).toBeGreaterThan(0);
await expect(toast(page)).toHaveText('Job report downloaded.');
});
test('log keeps the reader\'s scroll position', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
const more = (from, count) => Array.from({ length: count }, (_, index) => ({ group: 'known', path: `log/f_${from + index}.png`,
root: 'output', detail: 'Known' }));
await step(request, job.id, { processed: 60, records: more(0, 60) });
await openJob(page, job.id);
await scanLog(page).locator('summary').click();
const pre = scanLog(page).locator('pre');
await expect(pre).toContainText('log/f_59.png');
const metrics = () => pre.evaluate(node => ({ top: node.scrollTop, max: node.scrollHeight - node.clientHeight }));
// Opening starts at the newest line.
await expect.poll(async () => { const m = await metrics(); return m.max > 0 && m.top >= m.max - 2; }).toBe(true);
// Pinned to the bottom: new lines are followed.
await step(request, job.id, { processed: 90, records: more(60, 30) });
await expect(pre).toContainText('log/f_89.png');
await expect.poll(async () => { const m = await metrics(); return m.top >= m.max - 2; }).toBe(true);
// Scrolled up: the position stays while more lines arrive.
await pre.evaluate(node => { node.scrollTop = 40; });
await step(request, job.id, { processed: 120, records: more(90, 30) });
await expect(pre).toContainText('log/f_119.png');
expect((await metrics()).top).toBe(40);
// Closed and opened again (review M6): every open starts at the newest line, also after lines arrived meanwhile.
await scanLog(page).locator('summary').click();
await expect(scanLog(page)).not.toHaveAttribute('open', '');
await step(request, job.id, { processed: 150, records: more(120, 30) });
await scanLog(page).locator('summary').click();
await expect(pre).toContainText('log/f_149.png');
await expect.poll(async () => { const m = await metrics(); return m.max > 0 && m.top >= m.max - 2; }).toBe(true);
});
test('a failed report download says so and leaves the button usable', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await finish(request, job.id);
await openJob(page, job.id);
await page.route(`**/prompt_manager/jobs/${job.id}/report`, route => route.fulfill({ status: 500, body: 'no' }));
const button = heading(page).getByRole('button', { name: 'Download report', exact: true });
await button.click();
await expect(toast(page)).toHaveText('The report could not be downloaded. Try again.');
await expect(button).not.toHaveAttribute('aria-disabled', 'true');
});