fix(activity): indeterminate pill bar and the nothing-to-do line

The pill's mini bar sweeps while a job's total is unknown instead of
showing an empty fill. A finished thumbnail job with nothing to do shows
the server's final line. Coverage floors raised for gallery.js and
activity.mjs.
This commit is contained in:
Vito Sansevero
2026-10-23 16:31:58 -07:00
parent 7f75533a63
commit f6e8977c79
5 changed files with 42 additions and 12 deletions
+22 -3
View File
@@ -54,8 +54,8 @@ 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 } });
async function finish(request, id, state = 'finished', extra = {}) {
const response = await request.post(`/__test__/jobs/${id}/finish`, { data: { state, ...extra } });
await expect(response).toBeOK();
return (await response.json()).job;
}
@@ -1050,8 +1050,13 @@ test('a held legacy job and the health check show no tiles, no Results and no ma
await expect(response).toBeOK();
const held = (await response.json()).job;
await page.goto(`${workspace}#/images`);
// D112: the pill names the held operation and shows no percentage.
// D112: the pill names the held operation and shows no percentage; its bar is indeterminate, not an empty fill
// (S5 review M5), and stays hidden from assistive tech, which reads the label.
await expect(pill(page)).toHaveText('Thumbnail clear (gallery page)');
await expect(pill(page)).toHaveAttribute('aria-label', 'Thumbnail clear (gallery page)');
await expect(pill(page).locator('.activity-pill-bar')).toHaveClass('activity-pill-bar indeterminate');
await expect(pill(page).locator('.activity-pill-bar')).toHaveAttribute('aria-hidden', 'true');
expect(await pill(page).locator('.activity-pill-bar i').evaluate(node => node.style.width)).toBe('');
await page.goto(`${workspace}#/activity`);
const item = items(page).filter({ has: page.getByRole('link', { name: /Thumbnail clear \(gallery page\)$/ }) });
await expect(item.locator('p')).toHaveText(/^Started \d\d:\d\d · closing this view does not cancel it$/);
@@ -1082,3 +1087,17 @@ test('a held legacy job and the health check show no tiles, no Results and no ma
await finish(request, health.id);
await expect(toast(page)).toHaveText('Health check finished.');
});
// S5 review I3: a thumbnail job with nothing to generate ends on the server's final line ("No previews to generate · N
// already up to date"), never on a stale "Processing file 0 of 0" under the Completed chip.
test('a finished thumbnail job with nothing to do says so', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request, { ...THUMB_JOB, total: 0 });
await step(request, job.id, { total: 0, processed: 0 }); // the stale line an older server left
await openJob(page, job.id);
const line = progressSection(page).locator('.panel-heading > span.small');
await finish(request, job.id, 'finished', { status_line: 'No previews to generate · 24 already up to date' });
await expect(line).toHaveText('No previews to generate · 24 already up to date');
await expect(page.getByText('Processing file 0 of 0')).toHaveCount(0);
await expect(progressSection(page).locator('progress')).toHaveAttribute('value', '100');
});
+6 -2
View File
@@ -16,7 +16,8 @@ reading files, so a test sets its progress and counters exactly:
rows), sets ``processed`` (and ``total``) and ``counters``, then publishes
the progress to every subscriber;
- ``POST /__test__/jobs/{id}/finish`` ends it as ``finished``, ``cancelled``
or ``failed``; a cancel through the real route ends it ``cancelled``;
or ``failed``, with the runner's final ``status_line`` when given; a cancel
through the real route ends it ``cancelled``;
- ``POST /__test__/jobs/hold`` opens a legacy-page hold (``kind``, ``title``,
``admission``, default ``records``): a running, non-cancellable job with no
outcome groups, as a legacy restore, maintenance or thumbnail clear request
@@ -259,9 +260,12 @@ def register_activity_commands(app, routes, api):
@routes.post("/__test__/jobs/{job_id}/finish")
async def finish_job(request):
job = job_or_404(request)
state = (await body(request)).get("state", "finished")
data = await body(request)
state = data.get("state", "finished")
if state not in FINAL_STATES:
raise web.HTTPBadRequest()
if isinstance(data.get("status_line"), str): # the runner's final line
job.update(status_line=data["status_line"])
ending = endings[job.id]
if not ending.done():
ending.set_result(state)
+5 -5
View File
@@ -42,10 +42,10 @@
"statements": 98
},
"gallery.js": {
"lines": 41,
"branches": 63,
"functions": 45,
"statements": 41
"lines": 45,
"branches": 64,
"functions": 48,
"statements": 45
},
"html-escape.js": {
"lines": 98,
@@ -181,7 +181,7 @@
},
"workspace/activity.mjs": {
"lines": 96,
"branches": 90,
"branches": 91,
"functions": 98,
"statements": 96
},
+5 -2
View File
@@ -22,7 +22,7 @@ import { navigateTo } from './state.mjs';
import { pageHeading } from './heading.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { pillParts, outcomeLine, LOST_DETAIL } from './activity-store.mjs';
import { viewOf, canCancel, showProgress } from './activity-kinds.mjs';
import { viewOf, canCancel, showProgress, indeterminate } from './activity-kinds.mjs';
export const ACTIVITY_DESCRIPTION = 'Scans, tagging, downloads and maintenance share one history.';
export const EMPTY_ACTIVITY = 'No jobs have run since ComfyUI started. Scans and other long operations appear here.';
@@ -110,7 +110,10 @@ export function mountActivityTopbar({ store, scope, doc = document }) {
const parts = pillParts(job);
label.textContent = parts.label;
files.textContent = parts.files;
fill.style.width = `${job.progress}%`;
// D111 (S5 review M5): no total, no fill: the bar sweeps instead of showing an empty determinate 0%.
const unknown = indeterminate(job);
bar.classList.toggle('indeterminate', unknown);
fill.style.width = unknown ? '' : `${job.progress}%`;
pill.href = `#/activity/${job.id}`;
pill.setAttribute('aria-label', parts.text);
};
+4
View File
@@ -24,6 +24,10 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
.activity-pill:hover { border-color: var(--ca-accent); /* D53 */ }
.activity-pill-bar { display: block; inline-size: 70px; block-size: 5px; border-radius: 3px; background: var(--line); overflow: hidden; /* D53 */ }
.activity-pill-bar i { display: block; block-size: 100%; inline-size: 0; background: var(--ca-accent); /* D53 */ }
/* D111 (S5): a running job without a known total sweeps a short segment; reduced motion shows a dimmed full bar. */
.activity-pill-bar.indeterminate i { inline-size: 40%; animation: activity-pill-sweep 1.4s ease-in-out infinite; /* D111 */ }
@keyframes activity-pill-sweep { from { transform: translateX(-100%); } to { transform: translateX(250%); } }
@media (prefers-reduced-motion: reduce) { .activity-pill-bar.indeterminate i { inline-size: 100%; opacity: .45; animation: none; /* D111 */ } }
@media (pointer: coarse) { .activity-pill:not([hidden]) { min-block-size: 40px; /* D53 */ } }
@media (max-width: 700px) {
/* D52: with the Activity button the full-width row can be wider than a zoomed phone, so its items wrap. */