diff --git a/tests/browser/workspace-activity.spec.mjs b/tests/browser/workspace-activity.spec.mjs index 7dfd2575..5f16414f 100644 --- a/tests/browser/workspace-activity.spec.mjs +++ b/tests/browser/workspace-activity.spec.mjs @@ -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'); +}); diff --git a/tests/browser/workspace_activity_fixture.py b/tests/browser/workspace_activity_fixture.py index 1362e260..843b80fc 100644 --- a/tests/browser/workspace_activity_fixture.py +++ b/tests/browser/workspace_activity_fixture.py @@ -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) diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 3abaca5c..9942d6bb 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -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 }, diff --git a/web/js/workspace/activity.mjs b/web/js/workspace/activity.mjs index db448c51..9595f896 100644 --- a/web/js/workspace/activity.mjs +++ b/web/js/workspace/activity.mjs @@ -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); }; diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 3493342b..7c3a5aff 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -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. */