diff --git a/TESTING.md b/TESTING.md index 98a075a..69ced42 100644 --- a/TESTING.md +++ b/TESTING.md @@ -430,10 +430,11 @@ the first nav link and heading button. Recorded shell deviations: - **D4**: a "Theme" select with Dark, Light and System. System follows the ComfyUI palette, then the OS. -- **D5**: no Activity button or Activity row until an activity center exists. +- **D5**: retired by Activity (#249 Activity-1). The concept's Activity button and + Activity row are shown; their differences are D52 to D54. - **D28**: no COLLECTIONS, and no Design review. The bottom nav is - System plus the production Overview hub (its own outline home glyph, not the concept's - `activity` pulse, and no count). + the concept's Activity and System, plus the production Overview hub (its own + outline home glyph, not the concept's `activity` pulse, and no count). The connection reads "Your library, on your server" with a neutral muted dot. - **D49** (saved views, #249 D16): the SAVED VIEWS group label has no "PROPOSED" badge, because saved views are persisted (`/prompt_manager/saved-views`). Its rows are @@ -484,6 +485,55 @@ Recorded shell deviations: as deleted. `tests/browser/workspace-saved-views.spec.mjs` covers the row, count, route, star updates, Save view on Favorites, Edit, and the failure paths; `tests/test_image_favorites.py` covers `favorites=1`. +- **D52** (Activity, #249 Activity-1): the top-bar `button#activity-open` and the + sidebar's Activity row (the concept's `activity` pulse, first in `system-nav`) count + this session's *running* jobs. The concept counts + every sample job. `span.count#activity-count` is `hidden` at 0 (its text stays "0"), + and the row has no `.nav-count` then. The Activity heading has no "Unified activity · + proposed" chip (prototype copy). At `max-width: 700px` the full-width `.top-actions` + row may wrap (`flex-wrap: wrap`), so the button, Theme and ComfyUI stay inside a + zoomed phone. The shell comparison hides the concept's count at runtime + (`#activity-count { display: none }`), so the concept's own CSS lays out its button + without it, and maps the row's count to none, the button's count to "0" and the + phone row's `flexWrap` to `wrap`. +- **D53**: while a job runs, the approved Activity mockup's progress pill sits before + the Activity button on every page: `a#activity-pill.activity-pill`, "Scanning · 41% · + 8,240 / 19,622" (the verb by job kind, the server's floored percentage, and the file + counts once the total is known), a 70px mini bar, `href="#/activity/"` and the same + text as its `aria-label`. With no running job it is `hidden`, which the shell + comparison maps as a hidden item before the button. At `max-width: 700px` it keeps the + verb and percentage only; on coarse pointers it is 40px tall like the concept's + buttons. Its rules are in `workspace.css` under D53. +- **D54**: each `.activity-item` is a real job of this session (`GET + /prompt_manager/jobs`), running first, then finished newest first. The concept's + structure and CSS stay: `.panel-heading` > `h3` "✓ " for a completed job and + "○ <title>" otherwise, plus `span.chip`, then `p`, and for a running job `<progress + max=100>` ("<title> progress") and `.row-actions`. The chip is green "Completed" or + blue "Running", "Cancelled", "Failed" or "Ended". The line reads "Started HH:MM · K + output folders · closing this view does not cancel it" while running, "Finished HH:MM + · N added · M errors", "Cancelled HH:MM at a file boundary · N added · completed work + is kept", "Stopped on an error at HH:MM · N added · M errors", or "This job ended when + ComfyUI restarted." The running actions are "Cancel job" only (no "Simulate + completion"); a failed cancel toasts "The job could not be cancelled. Try again.". With + no job the section says "No jobs have run since ComfyUI started. Scans and other long + operations appear here." `#/activity/<id>` shows the list with that job's item marked + `aria-current="true"` until the job view (Activity-1 Task 3). + + The Activity store reads the list once, then follows each running job over + `/prompt_manager/jobs/<id>/events`. A dropped stream re-reads the list after 1 s, + doubling to at most 30 s; a received event resets the wait. A running job the server + no longer lists, or one whose server cannot be read, is shown as ended, so no pill + stays. While the tab is visible the list is read again every 15 s (and whenever + Activity opens), so a job started in another tab or on the legacy admin page appears + without a reload. Toasts: "Scan started." after this tab's own start; "Scan finished + · N added · M errors", "Scan cancelled · N added" or "Scan failed · N added · M + errors" when a job this tab saw running ends (plural helper for "error"). + `tests/js/workspace-activity-store.test.js` covers the store, its backoff and the + copy; `tests/browser/workspace-activity.spec.mjs` covers the list against the + concept's own items (`renderActivity` and `startJob`), the pill and counts, a phone, + reload and a second tab, Cancel job, a restart and an unreachable server, and the + toasts, with fake jobs from `tests/browser/workspace_activity_fixture.py` + (`/__test__/jobs/*`, including a simulated restart that drops the job streams). - **D29**: no prototype copy. There is no "Interactive concept" badge and no "What changes & why" button. The footer reads "ComfyAssets · Local tools. Your library.", and the title has no " concept" suffix. Save view's dialog action reads @@ -570,7 +620,8 @@ Recorded deviations on this page (spec §8): footer, masonry tops may differ by sub-pixel snapping (at most one layout unit per card above in the column), and a List row's info takes its natural height, centred beside the 160×95 art. -- **D5**, **D29**: no Activity button and no prototype copy (shell). +- **D29**: no prototype copy (shell). The Activity button and pill are the shell's + (D52, D53). - **D7**: the star is a real persisted favorite with "Added to favorites." / "Removed from favorites." and a rollback toast on failure. - **D8**: every Images card has the concept's select checkbox before the star; the diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index 4cd8583..362774a 100644 --- a/tests/browser/images-concept.spec.mjs +++ b/tests/browser/images-concept.spec.mjs @@ -7,7 +7,7 @@ import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf, // images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid, // the first six cards, the continuation and the panel box are compared at 1600x1100 and 390x844 in both themes, and the // page's behaviors under normal and reduced motion; grid, cards and panel also at 1200x900 and 1800x1100 (2 and 4 -// columns, the 275px and 310px panel). The topbar and sidebar (D5, D28, D29) are the shell spec's. +// columns, the 275px and 310px panel). The topbar and sidebar (D28, D29, D52, D53) are the shell spec's. // Paired captures are artifacts only; there are no PNG baselines. test.use({locale:'en-US',timezoneId:'UTC'}); diff --git a/tests/browser/server.py b/tests/browser/server.py index 91895c9..5e2888d 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -14,6 +14,7 @@ from aiohttp import web from PIL import Image from PIL.PngImagePlugin import PngInfo +from tests.browser.workspace_activity_fixture import register_activity_commands from tests.browser.workspace_concept_fixture import ( MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES, MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES, @@ -159,6 +160,7 @@ def create_app(root: Path) -> web.Application: library_editor_commands = register_library_editor_commands(routes, api, root) register_vocabulary_commands(routes, api, root) reveal_stub = register_reveal_stub(routes, api, root) + register_activity_commands(app, routes, api) async def canonical_asset(request: web.Request) -> web.Response: filename, content_type = CANONICAL_ASSETS[request.path] diff --git a/tests/browser/shell-concept.spec.mjs b/tests/browser/shell-concept.spec.mjs index 995f4a6..509bdc4 100644 --- a/tests/browser/shell-concept.spec.mjs +++ b/tests/browser/shell-concept.spec.mjs @@ -1,7 +1,7 @@ import {writeFile} from 'node:fs/promises'; import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; -import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs'; +import {SIZES,THEMES,MOTIONS,seedConcept,openConcept as openConceptPage,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs'; // Task A6 of #249: the production shell (sidebar, topbar, heading, footer) against the immutable concept, with the // concept-shaped real fixture so the sidebar counts are the concept's own (24 images, 6 prompts, 15 tags). Both themes, @@ -16,7 +16,16 @@ const FULL_HEADING=new Set(['tags']); const rowNamed=(nav,text)=>nav.rows.find(row=>row.text===text); -const removedItem=item=>String(item.className).includes('concept-badge')||(item.tag==='button'&&item.text.startsWith('Activity')); +const removedItem=item=>String(item.className).includes('concept-badge'); + +/** + * The concept with its Activity count hidden (D52): production counts running jobs only and hides the count at 0, as + * the comparison fixture has none. The concept's own CSS then lays out its button without the count. + */ +async function openConcept(page,route,theme,motion) { + await openConceptPage(page,route,theme,motion); + await mutate(page,{css:'#activity-count { display: none }'}); +} /** Left offsets of flex items packed along a row of `width` by its `justify-content` and `gap`. */ function packedLefts(items,width,justify,gap) { @@ -37,10 +46,12 @@ function remainingTopActions(actions) { const gap=parseFloat(actions.columnGap); // A full-width row (<=700px) keeps its width; otherwise it shrinks to its remaining items. const width=actions.fullWidth?actions.width:shown.reduce((sum,item)=>sum+item.width,0)+gap*(shown.length-1); - // At <=700px the concept spreads three items with `space-between`, so its Theme select sits in the middle; with - // Activity removed (D5) the same rule puts Theme at the row's start. Offsets come from the concept's own sizes. + // At <=700px the concept spreads its shown items with `space-between`; the hidden pill (D53) takes no place. + // Offsets come from the concept's own sizes. const lefts=new Map(shown.map((item,index)=>[item,packedLefts(shown,width,actions.justifyContent,gap)[index]])); return {...actions,height,width, + // D52: the full-width row (<=700px) wraps its items when a zoomed phone is narrower than they are. + flexWrap:actions.fullWidth?'wrap':actions.flexWrap, items:kept.map(item=>item.shown?{...item,top:Math.round((height-item.height)/2),left:lefts.get(item)}:item)}; } @@ -53,7 +64,7 @@ function remainingFooter(footer,spans) { } // D28 ruling (final review A, Minor 1): Overview gets a distinct outline home glyph. The concept has no home icon, and -// its `activity` pulse stays reserved for the Activity row D5 omits, so the two entries never share one glyph. +// its `activity` pulse belongs to the Activity row, so the two entries never share one glyph. const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6'; /** @@ -76,7 +87,9 @@ function mapShellConcept(reference) { if(nav.id!=='system-nav')return nav; const system=rowNamed(nav,'System'); return {...nav,rows:[ - // D5: no Activity row until an activity center exists. D28: no Design review (prototype page). + // D52: the Activity row counts running jobs only, so with none it has no count. + {...rowNamed(nav,'Activity'),count:null}, + // D28: no Design review (prototype page). system, // D28: the production Overview hub follows System, as a concept nav row without a count. Its icon is // the ruling's own outline home glyph (OVERVIEW_GLYPH), never the concept's `activity` pulse. @@ -95,9 +108,11 @@ function mapShellConcept(reference) { small:'Your library, on your server', // D28: no fake connection state, so the dot is the neutral muted token (the concept's label color). dot:{...chrome.connection.dot,backgroundColor:chrome.labels[0].color}}, - // D29: no "Interactive concept" badge. D5: no Activity button. The row's size and the remaining items' - // centered offsets follow from the concept's own item sizes. - topActions:remainingTopActions(chrome.topActions), + // D29: no "Interactive concept" badge. D53: the progress pill before the Activity button, hidden with no running + // job. D52: the button's count is hidden at 0. The row's size and the remaining items' centered offsets follow + // from the concept's own item sizes. + topActions:remainingTopActions({...chrome.topActions,items:chrome.topActions.items.flatMap(item=> + item.tag==='button'&&item.text==='Activity'?[{tag:'a',className:'activity-pill',shown:false},{...item,count:'0'}]:[item])}), footer:(()=>{ // D29: production footer copy (one line, as the concept's own copy is), and no "What changes & why" // (a prototype review page); the footer then holds only its span. diff --git a/tests/browser/workspace-activity.spec.mjs b/tests/browser/workspace-activity.spec.mjs new file mode 100644 index 0000000..b0e307a --- /dev/null +++ b/tests/browser/workspace-activity.spec.mjs @@ -0,0 +1,312 @@ +import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; +import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.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.'; + +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 }) => { + 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'), + }, + }; + }); +} + +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. + expect(conceptDone.title).toBe('✓ Library metadata enriched'); + expect(productionDone).toEqual({ ...conceptDone, title: '✓ Scan for metadata' }); + // 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: { ...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'); + + await pill(page).click(); + await expect(page).toHaveURL(new RegExp(`#/activity/${job.id}$`)); + await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Activity'); + await expect(page.locator('#workspace-breadcrumb')).toHaveText('Activity'); + await expect(page).toHaveTitle('Activity · Prompt Manager'); + await expect(navRow(page)).toHaveAttribute('aria-current', 'page'); + await expect(items(page).first()).toHaveAttribute('aria-current', 'true'); + await expect(items(page).first().locator('h3')).toHaveText('○ Scan for metadata'); + + // 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); +}); + +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 }).click(); + await expect(toast(page)).toHaveText('Scan cancelled · 7 added'); + 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'); + const busy = await store.start('scan').catch(error => ({ error: error.message })); + return { id: result.job.id, title: result.job.title, toast: document.getElementById('workspace-toast').textContent, + 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'); +}); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index cf5f65d..a9e2e28 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -8,7 +8,7 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' }); const workspace = '/prompt_manager/workspace'; const primary = ['images', 'library', 'tags', 'inspect']; -const tools = ['system', 'overview']; +const tools = ['activity', 'system', 'overview']; async function noOverflow(page) { expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1); @@ -556,7 +556,7 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as await page.goto(`${workspace}#/images`); const icons = await page.evaluate(async () => { const { createWorkspaceIcon } = await import('/prompt_manager/js/workspace/icons.mjs'); - const names = ['images', 'library', 'tags', 'upload', 'overview', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch', 'star', 'tagImage', 'folder', 'history', 'review']; + const names = ['images', 'library', 'tags', 'upload', 'overview', 'activity', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch', 'star', 'tagImage', 'folder', 'history', 'review']; const otherDocument = document.implementation.createHTMLDocument(); const rendered = names.map(name => { const icon = createWorkspaceIcon(name, otherDocument); @@ -575,7 +575,8 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as expect(icon).toMatchObject({ svg: true, className: 'icon', owner: true, viewBox: '0 0 24 24', hidden: 'true', focusable: 'false', fill: 'none', stroke: 'currentColor', width: '1.65', children: 1 }); expect(icon.path.length).toBeGreaterThan(10); } - // D28 ruling: Overview's own outline home glyph, not the concept's `activity` pulse ('M3 12h4l3-8 4 16 3-8h4'). + // D28 ruling: Overview's own outline home glyph; the concept's `activity` pulse is the Activity row's. + expect(icons.rendered.find(icon => icon.name === 'activity').path).toBe('M3 12h4l3-8 4 16 3-8h4'); expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6'); expect(new Set(icons.rendered.map(icon => icon.path)).size).toBe(icons.rendered.length); }); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index 9290025..3a71a3d 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -20,6 +20,8 @@ const routes = [ ['system', 'System', '/prompt_manager/admin'], // #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites. ['favorites', 'Favorites', null], + // #249 Activity-1: the concept's Activity page in the tools-and-system group. + ['activity', 'Activity', null], ]; async function countThemeWrites(page) { @@ -176,7 +178,8 @@ test('theme System follows ComfyUI and persists an explicit light or dark prefer await expect(theme).toHaveAttribute('data-ca-theme', 'light'); }); -// Task A4 (D4, D5): the concept topbar (index.html 45-63, style.css 225-277) without the prototype badge or Activity. +// Task A4 (D4, D29): the concept topbar (index.html 45-63, style.css 225-277) without the prototype badge; Activity +// (D52, D53) shows its button, and its pill only while a job runs. test('topbar matches the concept actions', async ({ page }) => { await page.setViewportSize({ width: 1600, height: 1100 }); await page.goto(`${workspace}#/images`); @@ -204,7 +207,7 @@ test('topbar matches the concept actions', async ({ page }) => { expect(shape).toEqual({ children: ['div.breadcrumbs', 'div.top-actions'], crumbs: ['ComfyAssets', 'span:/', 'span#workspace-breadcrumb:Images'], - actions: ['label.theme-label', 'a.canvas-link'], + actions: ['a.activity-pill', 'button.', 'label.theme-label', 'a.canvas-link'], label: 'Theme', labelFor: 'theme', options: [['dark', 'Dark'], ['light', 'Light'], ['system', 'System']], arrow: [['↗', 'true']], @@ -218,11 +221,12 @@ test('topbar matches the concept actions', async ({ page }) => { await expect(link).toHaveAttribute('href', '/'); expect(await link.evaluate(node => node.href === `${location.origin}/`)).toBe(true); await expect(page.getByRole('combobox', { name: 'Appearance' })).toHaveCount(0); - await expect(page.getByRole('button', { name: /^Activity/ })).toHaveCount(0); - await expect(page.locator('.concept-badge, #activity-open')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Activity', exact: true })).toHaveCount(1); + await expect(page.locator('#activity-pill')).toBeHidden(); + await expect(page.locator('.concept-badge')).toHaveCount(0); await page.locator('nav [data-route="library"]').click(); await expect(page.locator('#workspace-breadcrumb')).toHaveText('Prompt library'); - await expect(topbar.locator('.top-actions > *')).toHaveCount(2); + await expect(topbar.locator('.top-actions > *')).toHaveCount(4); }); // Concept style.css 1375-1391: at <=700px the breadcrumbs hide, the actions spread across the bar and the Theme @@ -242,13 +246,18 @@ test('mobile topbar hides breadcrumbs and spreads actions', async ({ page }) => expected: bar.width - 28, label: getComputedStyle(node.querySelector('.theme-label')).fontSize, select: getComputedStyle(node.querySelector('#theme')).fontSize, - labelLeft: box(node.querySelector('.theme-label')).left - bar.left, + activityLeft: box(node.querySelector('#activity-open')).left - bar.left, + actionsGap: [box(node.querySelector('#activity-open')).right, box(node.querySelector('.theme-label')).left, + box(node.querySelector('.theme-label')).right, box(node.querySelector('.canvas-link')).left], linkRight: bar.right - box(node.querySelector('.canvas-link')).right, }; }); expect(layout).toMatchObject({ padding: '14px', actions: { justify: 'space-between', gap: '8px' }, label: '0px', select: '11px' }); expect(Math.abs(layout.actions.width - layout.expected)).toBeLessThanOrEqual(1); - expect(Math.abs(layout.labelLeft - 14)).toBeLessThanOrEqual(1); + // The concept's three items spread with space-between: Activity first, Theme between equal gaps, ComfyUI last. + expect(Math.abs(layout.activityLeft - 14)).toBeLessThanOrEqual(1); + const [activityRight, themeLeft, themeRight, linkLeft] = layout.actionsGap; + expect(Math.abs((themeLeft - activityRight) - (linkLeft - themeRight))).toBeLessThanOrEqual(1); expect(Math.abs(layout.linkRight - 14)).toBeLessThanOrEqual(1); await expect(page.getByRole('link', { name: 'ComfyUI', exact: true })).toBeVisible(); await theme.selectOption('light'); @@ -506,18 +515,18 @@ test('a slow Images count does not delay the Library and Tags badges', async ({ await expect(navCount(page, 'images')).toHaveText(String(totals.images)); }); -test('sidebar bottom holds System and Overview with a neutral connection', async ({ page }) => { +test('sidebar bottom holds Activity, System and Overview with a neutral connection', async ({ page }) => { await page.goto(`${workspace}#/system`); const bottom = page.locator('aside.sidebar > .sidebar-bottom'); expect(await childShape(bottom)).toEqual(['nav#system-nav', 'div.connection']); const system = page.locator('nav#system-nav'); await expect(system).toHaveAttribute('aria-label', 'Tools and system'); expect(await system.locator(':scope > a').evaluateAll(nodes => nodes.map(node => [node.getAttribute('href'), node.textContent]))).toEqual([ - ['#/system', 'System'], ['#/overview', 'Overview'], + ['#/activity', 'Activity'], ['#/system', 'System'], ['#/overview', 'Overview'], ]); await expect(system.locator('.nav-count')).toHaveCount(0); await expect(system.locator('a[href="#/system"]')).toHaveAttribute('aria-current', 'page'); - await expect(system.locator('svg.icon')).toHaveCount(2); + await expect(system.locator('svg.icon')).toHaveCount(3); const connection = bottom.locator('.connection'); expect(await childShape(connection)).toEqual(['i', 'div']); await expect(connection.locator(':scope > div')).toHaveText('ComfyUI workspaceYour library, on your server'); diff --git a/tests/browser/workspace_activity_fixture.py b/tests/browser/workspace_activity_fixture.py new file mode 100644 index 0000000..8e7a581 --- /dev/null +++ b/tests/browser/workspace_activity_fixture.py @@ -0,0 +1,141 @@ +"""Fake Activity jobs for the browser tests (#249 Activity-1). + +A fake job is a real registry job whose runner waits for commands instead of +reading files, so a test sets its progress and counters exactly: + +- ``POST /__test__/jobs/start`` starts one (``kind``, ``title``, ``total``, + ``output_folders``) and returns its summary, or 409 while one runs; +- ``POST /__test__/jobs/{id}/step`` sets ``processed`` (and ``total``) and + merges ``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``; +- ``POST /__test__/jobs/restart`` acts out a ComfyUI restart: the open job + streams drop without a final event and the registry starts empty. +""" + +import asyncio +import re + +from aiohttp import web + +EVENTS_PATH = re.compile(r"^/prompt_manager/jobs/[0-9a-f]{32}/events$") +FINAL_STATES = {"finished", "cancelled", "failed"} +POLL_SECONDS = 0.02 + + +def register_activity_commands(app, routes, api): + from py.api.jobs import SCAN_GROUP_NAMES, JobBusy, JobRegistry + + streams = set() + endings = {} + + @web.middleware + async def track_streams(request, handler): + if not EVENTS_PATH.match(request.path): + return await handler(request) + transport = request.transport + streams.add(transport) + try: + return await handler(request) + finally: + streams.discard(transport) + + app.middlewares.append(track_streams) + + def fake_runner(ending): + async def run(job): + while not ending.done() and not job.cancelled: + await asyncio.sleep(POLL_SECONDS) + state = ending.result() if ending.done() else "cancelled" + job.publish({"type": "complete", "progress": job.progress}) + job.finish(state) + + return run + + def job_or_404(request): + job = api._jobs.get(request.match_info["job_id"]) + if job is None or job.id not in endings: + raise web.HTTPNotFound() + return job + + async def body(request): + data = await request.json() if request.can_read_body else {} + if not isinstance(data, dict): + raise web.HTTPBadRequest() + return data + + def count(value, default=0): + if value is None: + return default + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise web.HTTPBadRequest() + return value + + @routes.post("/__test__/jobs/start") + async def start_job(request): + data = await body(request) + ending = asyncio.get_running_loop().create_future() + try: + job = api._jobs.start( + str(data.get("kind", "scan")), + str(data.get("title", "Scan for metadata")), + record_changing=True, + scope={ + "mode": "all", + "output_folders": count(data.get("output_folders"), 2), + }, + runner=fake_runner(ending), + groups=SCAN_GROUP_NAMES, + ) + except JobBusy: + raise web.HTTPConflict() + job.update(total=count(data.get("total"))) + endings[job.id] = ending + return web.json_response({"success": True, "job": job.summary()}) + + @routes.post("/__test__/jobs/{job_id}/step") + async def step_job(request): + job = job_or_404(request) + data = await body(request) + total = count(data.get("total"), job.total) + processed = count(data.get("processed"), job.processed) + counters = data.get("counters", {}) + if not isinstance(counters, dict) or set(counters) - set(job.counters): + raise web.HTTPBadRequest() + for key, value in counters.items(): + job.counters[key] = count(value) + progress = processed * 100 // total if total else 0 + job.update( + progress=progress, + processed=processed, + total=total, + status_line=f"Processing file {processed:,} of {total:,}", + ) + job.publish({"type": "progress", "progress": progress}) + return web.json_response({"success": True, "job": job.summary()}) + + @routes.post("/__test__/jobs/{job_id}/finish") + async def finish_job(request): + job = job_or_404(request) + state = (await body(request)).get("state", "finished") + if state not in FINAL_STATES: + raise web.HTTPBadRequest() + ending = endings[job.id] + if not ending.done(): + ending.set_result(state) + while job.running: + await asyncio.sleep(POLL_SECONDS) + return web.json_response({"success": True, "job": job.summary()}) + + @routes.post("/__test__/jobs/restart") + async def restart(request): + old = api._jobs + api._jobs = JobRegistry(api.logger) + endings.clear() + for transport in list(streams): + transport.close() + tasks = [job.task for job in old.list() if job.task and not job.task.done()] + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + return web.json_response({"success": True}) diff --git a/tests/js/workspace-activity-store.test.js b/tests/js/workspace-activity-store.test.js new file mode 100644 index 0000000..5ded899 --- /dev/null +++ b/tests/js/workspace-activity-store.test.js @@ -0,0 +1,283 @@ +// Activity (#249 Activity-1): the session job store behind the top-bar button and pill and the Activity page. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const source = import('../../web/js/workspace/activity-store.mjs'); + +const ID_A = 'a'.repeat(32); +const ID_B = 'b'.repeat(32); +const ID_C = 'c'.repeat(32); + +function summary(overrides = {}) { + return { id: ID_A, kind: 'scan', title: 'Scan for metadata', state: 'running', started_at: 1000, finished_at: null, + progress: 0, status_line: '', processed: 0, total: 0, + counters: { added: 0, known: 0, review: 0, no_metadata: 0, errors: 0 }, scope: { mode: 'all', output_folders: 2 }, + ...overrides }; +} + +function apiError(status, message, code) { + return Object.assign(new Error(message), { status, code }); +} + +/** A fake world: the API's answers, every opened stream, the pending timers and the toasts. */ +function harness() { + const world = { list: [], listError: null, posts: [], postResult: null, postError: null, streams: [], timers: [], + toasts: [], gets: 0 }; + const cleanups = []; + const controller = new AbortController(); + const scope = { signal: controller.signal, cleanup: fn => cleanups.push(fn) }; + const api = { + async get(url, options) { + assert.equal(url, '/prompt_manager/jobs'); + assert.equal(options.cache, 'no-store'); + world.gets += 1; + if (world.listError) throw world.listError; + return { success: true, jobs: world.list }; + }, + async post(url, body) { + world.posts.push([url, body]); + if (world.postError) throw world.postError; + return world.postResult; + }, + }; + const connect = url => { + const stream = { url, closed: false, onmessage: null, onerror: null, close() { this.closed = true; } }; + world.streams.push(stream); + return stream; + }; + const timers = { + setTimeout(fn, delay) { const timer = { fn, delay, cleared: false }; world.timers.push(timer); return timer; }, + clearTimeout(timer) { timer.cleared = true; }, + }; + world.dispose = () => { controller.abort(); cleanups.forEach(fn => fn()); }; + world.fire = async () => { + const timer = world.timers.find(item => !item.cleared && !item.fired); + timer.fired = true; + timer.fn(); + await new Promise(resolve => setImmediate(resolve)); + return timer.delay; + }; + world.open = () => world.streams.filter(stream => !stream.closed); + return { world, api, scope, connect, timers, pollMs: 0, notify: message => world.toasts.push(message) }; +} + +const send = (stream, job) => stream.onmessage({ data: JSON.stringify(job) }); + +test('store merges list and SSE updates and caps backoff', async () => { + const { createActivityStore, BACKOFF_MAX_MS, LOST_DETAIL } = await source; + const { world, ...deps } = harness(); + world.list = [summary(), summary({ id: ID_B, state: 'finished', finished_at: 900, counters: { added: 3, errors: 0 } })]; + const store = createActivityStore(deps); + let changes = 0; + store.subscribe(() => { changes += 1; }); + await store.load(); + assert.equal(store.status, 'ready'); + assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_A, 'running'], [ID_B, 'finished']]); + assert.deepEqual(store.running().map(job => job.id), [ID_A]); + // One stream per running job only; a finished job is not followed. + assert.deepEqual(world.streams.map(stream => stream.url), [`/prompt_manager/jobs/${ID_A}/events`]); + assert.ok(changes >= 1); + + // An SSE summary replaces the listed one; garbage and another job's summary are ignored. + const [stream] = world.streams; + send(stream, summary({ progress: 42, processed: 8240, total: 19622 })); + stream.onmessage({ data: 'not json' }); + send(stream, summary({ id: ID_B, progress: 99 })); + assert.equal(store.find(ID_A).progress, 42); + assert.equal(store.find(ID_B).state, 'finished'); + assert.deepEqual(world.toasts, []); + + // The end of the job: the finish toast, and the stream is closed. + send(stream, summary({ state: 'finished', progress: 100, finished_at: 2000, counters: { added: 1420, errors: 18 } })); + assert.deepEqual(world.toasts, ['Scan finished · 1,420 added · 18 errors']); + assert.equal(stream.closed, true); + assert.deepEqual(store.running(), []); + + // A new running job from another tab, then the server goes away: every re-read waits longer, up to 30 s. + world.list = [summary({ id: ID_C, state: 'running' })]; + await store.load(); + const second = world.open()[0]; + assert.equal(second.url, `/prompt_manager/jobs/${ID_C}/events`); + world.listError = new Error('connection refused'); + second.onerror(); + assert.equal(second.closed, true); + const delays = []; + for (let index = 0; index < 8; index += 1) delays.push(await world.fire()); + assert.deepEqual(delays, [1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]); + assert.ok(delays.every(delay => delay <= BACKOFF_MAX_MS)); + // While the server cannot be read the running job shows as ended, so no pill stays. + assert.equal(store.status, 'error'); + assert.equal(store.find(ID_C).state, 'lost'); + assert.deepEqual(store.running(), []); + assert.equal(typeof LOST_DETAIL, 'string'); + + // The server is back without the job (a restart): it stays ended, earlier finished jobs stay listed. + world.listError = null; + world.list = []; + await world.fire(); + assert.equal(store.status, 'ready'); + assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_C, 'lost'], [ID_A, 'finished'], [ID_B, 'finished']]); + assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 0, 'no retry after a good read'); + assert.equal(world.toasts.length, 1, 'an ended job has no finish toast'); + world.dispose(); +}); + +test('a dropped stream of a still running job reattaches after the backoff, then resets it', async () => { + const { createActivityStore } = await source; + const { world, ...deps } = harness(); + world.list = [summary()]; + const store = createActivityStore(deps); + await store.load(); + world.streams[0].onerror(); + assert.equal(await world.fire(), 1000); + const again = world.open(); + assert.equal(again.length, 1); + assert.equal(store.find(ID_A).state, 'running'); + again[0].onerror(); + assert.equal(await world.fire(), 2000); + send(world.open()[0], summary({ progress: 5 })); + world.open()[0].onerror(); + assert.equal(await world.fire(), 1000, 'a received message resets the backoff'); + // A second drop while a re-read is already scheduled does not schedule another. + world.open()[0].onerror(); + store.load(); + assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 1); + await new Promise(resolve => setImmediate(resolve)); + world.dispose(); + assert.ok(world.streams.every(stream => stream.closed), 'dispose closes every stream'); + assert.ok(world.timers.every(timer => timer.cleared || timer.fired), 'dispose clears the pending re-read'); +}); + +test('start returns busy with the running job', async () => { + const { createActivityStore } = await source; + const { world, ...deps } = harness(); + const store = createActivityStore(deps); + world.postResult = { success: true, job: summary() }; + const started = await store.start('scan'); + assert.equal(started.job.id, ID_A); + assert.deepEqual(world.posts, [['/prompt_manager/jobs/scan', {}]]); + assert.deepEqual(world.toasts, ['Scan started.']); + assert.equal(world.open().length, 1, 'the started job is followed'); + + world.postError = apiError(409, 'Scan for metadata is already running', 'busy'); + world.list = [summary()]; + const busy = await store.start('scan'); + assert.equal(busy.busy.id, ID_A); + assert.equal(busy.busy.title, 'Scan for metadata'); + assert.equal(busy.message, 'Scan for metadata is already running'); + assert.equal(world.toasts.length, 1, 'a refused start has no start toast'); + + world.postError = apiError(500, 'boom', null); + await assert.rejects(store.start('scan'), /boom/); + world.postError = null; + world.postResult = { success: true, job: { id: 'nope' } }; + await assert.rejects(store.start('scan'), /Invalid job/); + + world.postResult = { success: true, cancellation_requested: true }; + assert.equal(await store.cancel(ID_A), true); + assert.deepEqual(world.posts.at(-1), [`/prompt_manager/jobs/${ID_A}/cancel`, {}]); + world.postResult = { success: true, cancellation_requested: false }; + assert.equal(await store.cancel(ID_A), false); + world.dispose(); +}); + +test('a list that is not a job list counts as a failed read', async () => { + const { createActivityStore } = await source; + const { world, api, ...deps } = harness(); + const store = createActivityStore({ ...deps, api: { ...api, get: async () => ({ success: true }) } }); + await store.load(); + assert.equal(store.status, 'error'); + assert.equal(world.timers.length, 1); + world.dispose(); + await store.load(); + assert.equal(store.status, 'error', 'a disposed store reads nothing'); +}); + +test('a visible tab reads the list again every 15 s; a hidden one skips its turn', async () => { + const { createActivityStore, POLL_MS } = await source; + const { world, ...deps } = harness(); + let visible = true; + const store = createActivityStore({ ...deps, pollMs: POLL_MS, isVisible: () => visible }); + await store.load(); + assert.equal(world.gets, 1); + // A job started in another tab appears on the next read. + world.list = [summary()]; + assert.equal(await world.fire(), 15000); + assert.equal(world.gets, 2); + assert.deepEqual(store.running().map(job => job.id), [ID_A]); + visible = false; + assert.equal(await world.fire(), 15000); + assert.equal(world.gets, 2, 'a hidden tab does not read'); + visible = true; + await world.fire(); + assert.equal(world.gets, 3); + // A manual read replaces the pending poll rather than adding one. + await store.load(); + assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 1); + world.dispose(); + assert.ok(world.timers.every(timer => timer.cleared || timer.fired)); +}); + +test('a late or older answer never brings an ended job back, so it is announced once', async () => { + const { createActivityStore } = await source; + const { world, api, ...deps } = harness(); + const answers = []; + const slowApi = { ...api, get: () => new Promise(resolve => answers.push(resolve)) }; + const store = createActivityStore({ ...deps, api: slowApi }); + const first = store.load(); + const second = store.load(); + // The newer read answers first; the older one, answering later with stale data, is dropped. + answers[1]({ success: true, jobs: [summary()] }); + await second; + answers[0]({ success: true, jobs: [] }); + await first; + assert.deepEqual(store.jobs().map(job => job.state), ['running']); + send(world.open()[0], summary({ state: 'finished', counters: { added: 2, errors: 0 } })); + assert.deepEqual(world.toasts, ['Scan finished · 2 added · 0 errors']); + // A list read sent before the end still says running: the job stays finished and no stream reopens. + const third = store.load(); + answers[2]({ success: true, jobs: [summary()] }); + await third; + assert.equal(store.find(ID_A).state, 'finished'); + assert.equal(world.open().length, 0); + assert.equal(world.toasts.length, 1); + // A stale failure is dropped too. + const failing = store.load(); + const newest = store.load(); + answers[4]({ success: true, jobs: [] }); + await newest; + answers[3](Promise.reject(new Error('late failure'))); + await failing; + assert.equal(store.status, 'ready'); + world.dispose(); +}); + +test('summaries are validated and the copy follows the spec', async () => { + const { parseJob, finishToast, startToast, pillParts, outcomeLine, backoffDelay } = await source; + for (const raw of [null, [], { id: 'x', state: 'running' }, summary({ state: 'lost' }), summary({ id: ID_A.toUpperCase() })]) { + assert.equal(parseJob(raw), null); + } + const loose = parseJob({ id: ID_A, state: 'running', progress: 140.4, processed: -1, total: 1.5, counters: { added: 'x', errors: 2 }, + title: ' ', started_at: 'x', scope: [] }); + assert.deepEqual(loose, { id: ID_A, kind: '', title: 'Background job', state: 'running', started_at: null, finished_at: null, + progress: 100, status_line: '', processed: 0, total: 0, counters: { added: 0, errors: 2 }, scope: {} }); + assert.equal(parseJob(summary({ progress: -3 })).progress, 0); + + const scan = parseJob(summary({ progress: 42, processed: 8240, total: 19622, counters: { added: 1, errors: 1 } })); + assert.deepEqual(pillParts(scan), { label: 'Scanning · 42%', files: ' · 8,240 / 19,622', text: 'Scanning · 42% · 8,240 / 19,622' }); + assert.equal(pillParts({ ...scan, total: 0 }).text, 'Scanning · 42%'); + assert.equal(pillParts({ ...scan, kind: 'thumbnails', title: 'Thumbnails' }).text, 'Thumbnails · 42% · 8,240 / 19,622'); + assert.equal(finishToast({ ...scan, state: 'finished' }), 'Scan finished · 1 added · 1 error'); + assert.equal(finishToast({ ...scan, state: 'cancelled' }), 'Scan cancelled · 1 added'); + assert.equal(finishToast({ ...scan, state: 'failed' }), 'Scan failed · 1 added · 1 error'); + assert.equal(finishToast({ ...scan, counters: {}, state: 'finished' }), 'Scan finished · 0 added · 0 errors'); + const other = { ...scan, kind: 'backup', title: 'Backup' }; + assert.equal(finishToast({ ...other, state: 'finished' }), 'Backup finished.'); + assert.equal(finishToast({ ...other, state: 'cancelled' }), 'Backup was cancelled.'); + assert.equal(finishToast({ ...other, state: 'failed' }), 'Backup failed.'); + assert.equal(startToast(scan), 'Scan started.'); + assert.equal(startToast(other), 'Backup started.'); + assert.equal(outcomeLine(scan), '1 added · 1 error'); + assert.equal(outcomeLine(other), ''); + assert.deepEqual([-1, 0, 1, 4, 5, 6, 1e9, 0.5].map(backoffDelay), [1000, 1000, 2000, 16000, 30000, 30000, 30000, 1000]); +}); diff --git a/tests/js/workspace-state.test.js b/tests/js/workspace-state.test.js index 6556a55..5706dea 100644 --- a/tests/js/workspace-state.test.js +++ b/tests/js/workspace-state.test.js @@ -4,10 +4,10 @@ const source = import('../../web/js/workspace/state.mjs'); test('workspace routes resolve only supported deep links', async () => { const { resolveRoute } = await source; - for (const route of ['overview', 'images', 'library', 'tags', 'inspect', 'system']) { + for (const route of ['overview', 'images', 'library', 'tags', 'inspect', 'system', 'activity']) { assert.equal(resolveRoute(`#/${route}`).id, route); } - for (const hash of ['', '#', '#/unknown', '#/images/other', '#/<script>']) { + for (const hash of ['', '#', '#/unknown', '#/images/other', '#/<script>', '#/activity/abc', `#/activity/${'A'.repeat(32)}`]) { assert.equal(resolveRoute(hash).id, 'images'); } }); @@ -23,10 +23,33 @@ test('tags is a native route titled Tags; only unbuilt routes hand off to legacy assert.equal(routeTitle(route), route.label); } assert.equal(routeTitle(resolveRoute('#/library/7')), 'Prompt library'); - for (const id of ['images', 'library', 'tags', 'overview']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id); + for (const id of ['images', 'library', 'tags', 'overview', 'activity']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id); for (const id of ['inspect', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), true, id); }); +test('a job of the Activity page is its own deep link', async () => { + const { resolveRoute, routeTitle, isHandoff } = await source; + const id = '0123456789abcdef'.repeat(2); + const route = resolveRoute(`#/activity/${id}`); + assert.deepEqual([route.id, route.jobId, route.hash, routeTitle(route), isHandoff(route)], + ['activity', id, `#/activity/${id}`, 'Activity', false]); + assert.equal(resolveRoute('#/activity').jobId, undefined); + assert.equal(route.href, undefined, 'Activity has no legacy card on Overview'); + // The router renders again when only the job changes, and not for the same job. + const { mountRouter } = await source; + const other = 'f'.repeat(32); + const win = new EventTarget(); + win.location = { hash: `#/activity/${id}` }; + win.history = { state: null, replaceState(state, unused, hash) { win.location.hash = hash; } }; + const seen = []; + const dispose = mountRouter(win, next => seen.push([next.id, next.jobId])); + win.dispatchEvent(new Event('hashchange')); + win.location.hash = `#/activity/${other}`; win.dispatchEvent(new Event('hashchange')); + win.location.hash = '#/activity'; win.dispatchEvent(new Event('hashchange')); + assert.deepEqual(seen, [['activity', id], ['activity', other], ['activity', undefined]]); + dispose(); +}); + test('statistics preserve zero and reject incomplete or invalid counts', async () => { const { summarizeStats } = await source; assert.deepEqual(summarizeStats({total_prompts:0, total_images:12, total_tags:3}), [ diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 06b44e5..24fce0b 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -155,12 +155,24 @@ "functions": 98, "statements": 98 }, - "workspace/file-inspector.mjs": { + "workspace/activity-store.mjs": { + "lines": 98, + "branches": 94, + "functions": 95, + "statements": 98 + }, + "workspace/activity.mjs": { "lines": 95, - "branches": 92, - "functions": 88, + "branches": 86, + "functions": 98, "statements": 95 }, + "workspace/file-inspector.mjs": { + "lines": 96, + "branches": 92, + "functions": 92, + "statements": 96 + }, "workspace/heading.mjs": { "lines": 98, "branches": 98, @@ -229,7 +241,7 @@ }, "workspace/index.mjs": { "lines": 98, - "branches": 96, + "branches": 97, "functions": 98, "statements": 98 }, @@ -264,10 +276,10 @@ "statements": 98 }, "workspace/library.mjs": { - "lines": 95, + "lines": 96, "branches": 85, "functions": 96, - "statements": 95 + "statements": 96 }, "workspace/media-viewer.mjs": { "lines": 98, @@ -282,10 +294,10 @@ "statements": 98 }, "workspace/prompt-blocks.mjs": { - "lines": 89, - "branches": 89, + "lines": 90, + "branches": 92, "functions": 98, - "statements": 89 + "statements": 90 }, "workspace/prompt-inspector.mjs": { "lines": 98, @@ -302,7 +314,7 @@ "workspace/recorded-run.mjs": { "lines": 95, "branches": 83, - "functions": 77, + "functions": 80, "statements": 95 }, "workspace/saved-views.mjs": { @@ -325,15 +337,15 @@ }, "workspace/toast.mjs": { "lines": 98, - "branches": 64, + "branches": 73, "functions": 98, "statements": 98 }, "workspace/toolbar.mjs": { - "lines": 94, - "branches": 93, + "lines": 96, + "branches": 96, "functions": 98, - "statements": 94 + "statements": 96 }, "workspace/topbar.mjs": { "lines": 98, diff --git a/web/js/workspace/activity-store.mjs b/web/js/workspace/activity-store.mjs new file mode 100644 index 0000000..259cc8d --- /dev/null +++ b/web/js/workspace/activity-store.mjs @@ -0,0 +1,267 @@ +/** + * The Activity store (#249 Activity-1): this server session's jobs, for the top-bar button and pill and the Activity + * page. One `GET /prompt_manager/jobs` read, then one SSE stream (`/jobs/<id>/events`) per running job. + * + * - A dropped stream re-reads the list after a backoff that doubles from 1 s and is capped at 30 s, so a stopped + * server never sees a retry storm. A successful stream message resets the backoff. + * - While the tab is visible the list is read again every 15 s, so a job started elsewhere (another tab, the legacy + * admin page) appears without a reload. + * - Jobs live only in server memory. A running job the server no longer lists (ComfyUI restarted), or one whose + * server cannot be read at all, is shown as ended (`lost`), so no pill stays stuck. + * - Toasts announce what happened; Activity is where progress lives. "Scan started." follows this tab's own start; + * the finish toast follows any job this store saw running when it ends. + */ +import { showWorkspaceToast } from './toast.mjs'; +import { noun } from './plural.mjs'; + +export const JOBS_URL = '/prompt_manager/jobs'; +export const BACKOFF_FIRST_MS = 1000; +export const BACKOFF_MAX_MS = 30000; +export const POLL_MS = 15000; +export const LOST_DETAIL = 'This job ended when ComfyUI restarted.'; +const SERVER_STATES = new Set(['running', 'finished', 'cancelled', 'failed']); +const FINAL_STATES = new Set(['finished', 'cancelled', 'failed']); +const JOB_ID = /^[0-9a-f]{32}$/; +// The pill's verb for each job kind; a kind without one shows its title. +const PILL_VERBS = Object.freeze({ scan: 'Scanning' }); +const START_TOASTS = Object.freeze({ scan: 'Scan started.' }); + +/** The wait before the `attempt`-th re-read (0-based): 1 s, 2 s, 4 s … never more than 30 s. */ +export function backoffDelay(attempt) { + const step = Number.isSafeInteger(attempt) && attempt > 0 ? Math.min(attempt, 16) : 0; + return Math.min(BACKOFF_MAX_MS, BACKOFF_FIRST_MS * 2 ** step); +} + +const count = value => Number.isSafeInteger(value) && value >= 0 ? value : 0; +const time = value => typeof value === 'number' && Number.isFinite(value) ? value : null; +const plainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value); + +/** A job summary from the API, or null when it is not one (unknown state, bad id). */ +export function parseJob(raw) { + if (!plainObject(raw) || typeof raw.id !== 'string' || !JOB_ID.test(raw.id) || !SERVER_STATES.has(raw.state)) return null; + const counters = {}; + if (plainObject(raw.counters)) for (const [key, value] of Object.entries(raw.counters)) counters[key] = count(value); + const progress = typeof raw.progress === 'number' && Number.isFinite(raw.progress) ? Math.round(raw.progress) : 0; + return { + id: raw.id, + kind: typeof raw.kind === 'string' ? raw.kind : '', + title: typeof raw.title === 'string' && raw.title.trim() ? raw.title : 'Background job', + state: raw.state, + started_at: time(raw.started_at), + finished_at: time(raw.finished_at), + progress: Math.max(0, Math.min(100, progress)), + status_line: typeof raw.status_line === 'string' ? raw.status_line : '', + processed: count(raw.processed), + total: count(raw.total), + counters, + scope: plainObject(raw.scope) ? { ...raw.scope } : {}, + }; +} + +const number = value => value.toLocaleString(); +const errorsOf = job => `${number(job.counters.errors ?? 0)} ${noun(job.counters.errors ?? 0, 'error')}`; +/** "1,420 added". */ +export const addedOf = job => `${number(job.counters.added ?? 0)} added`; + +/** The toast when a job ends: "Scan finished · N added · M errors", "Scan cancelled · N added". */ +export function finishToast(job) { + if (job.kind === 'scan') { + if (job.state === 'cancelled') return `Scan cancelled · ${addedOf(job)}`; + if (job.state === 'failed') return `Scan failed · ${addedOf(job)} · ${errorsOf(job)}`; + return `Scan finished · ${addedOf(job)} · ${errorsOf(job)}`; + } + if (job.state === 'cancelled') return `${job.title} was cancelled.`; + if (job.state === 'failed') return `${job.title} failed.`; + return `${job.title} finished.`; +} + +/** The toast after this tab starts a job. */ +export function startToast(job) { + return START_TOASTS[job.kind] ?? `${job.title} started.`; +} + +/** The pill's text: "Scanning · 42% · 8,240 / 19,622"; the file counts only once the total is known. */ +export function pillParts(job) { + const label = `${PILL_VERBS[job.kind] ?? job.title} · ${job.progress}%`; + const files = job.total > 0 ? ` · ${number(job.processed)} / ${number(job.total)}` : ''; + return { label, files, text: label + files }; +} + +/** The scan's counters as one line: "N added · M errors"; other kinds have none yet. */ +export function outcomeLine(job) { + return job.kind === 'scan' ? `${addedOf(job)} · ${errorsOf(job)}` : ''; +} + +function defaultConnect(url) { + const resolve = globalThis.ApiClient?.resolveUrl ?? (value => value); + return new EventSource(resolve(url)); +} + +/** + * @param {{api: {get: Function, post: Function}, scope: {signal: AbortSignal, cleanup: Function}, + * connect?: (url: string) => {onmessage: Function|null, onerror: Function|null, close: Function}, + * notify?: (message: string) => void, timers?: {setTimeout: Function, clearTimeout: Function}, + * pollMs?: number, isVisible?: () => boolean}} options + */ +export function createActivityStore({ api, scope, connect = defaultConnect, notify = showWorkspaceToast, + timers = globalThis, pollMs = POLL_MS, isVisible = () => globalThis.document?.visibilityState !== 'hidden' } = {}) { + let jobs = []; + let status = 'idle'; + let attempt = 0; + let retryTimer = null; + let pollTimer = null; + let reads = 0; + let disposed = false; + const streams = new Map(); + const listeners = new Set(); + + const emit = () => { for (const listener of [...listeners]) listener(store); }; + const find = id => jobs.find(job => job.id === id); + + function announce(previous, next) { + if (previous?.state === 'running' && FINAL_STATES.has(next.state)) notify(finishToast(next)); + } + + function closeStream(id) { + const source = streams.get(id); + if (!source) return; + streams.delete(id); + source.onmessage = null; + source.onerror = null; + source.close(); + } + + function follow() { + for (const id of [...streams.keys()]) if (find(id)?.state !== 'running') closeStream(id); + for (const job of jobs) if (job.state === 'running' && !streams.has(job.id)) openStream(job.id); + } + + function openStream(id) { + const source = connect(`${JOBS_URL}/${id}/events`); + streams.set(id, source); + source.onmessage = event => { + let job; + try { job = parseJob(JSON.parse(event.data)); } catch { job = null; } + if (!job || job.id !== id) return; + attempt = 0; + upsert(job); + }; + source.onerror = () => { + closeStream(id); + if (find(id)?.state === 'running') scheduleResync(); + }; + } + + /** A job never runs again once it has ended, whatever an older read in flight still says. */ + const settled = (previous, next) => FINAL_STATES.has(previous?.state) && next.state === 'running' ? previous : next; + + function upsert(job) { + const previous = find(job.id); + job = settled(previous, job); + jobs = previous ? jobs.map(item => item.id === job.id ? job : item) : [job, ...jobs]; + announce(previous, job); + follow(); + emit(); + } + + /** Replace the jobs with the server's list; a running job it no longer has has ended with the server. */ + function merge(list) { + const next = list.map(parseJob).filter(Boolean).map(job => settled(find(job.id), job)); + const ids = new Set(next.map(job => job.id)); + const carried = jobs.filter(job => !ids.has(job.id)) + .map(job => job.state === 'running' ? { ...job, state: 'lost' } : job); + for (const job of next) announce(find(job.id), job); + jobs = [...next, ...carried]; + follow(); + } + + function markLost() { + jobs = jobs.map(job => job.state === 'running' ? { ...job, state: 'lost' } : job); + follow(); + } + + function scheduleResync() { + if (disposed || retryTimer !== null) return; + const delay = backoffDelay(attempt); + attempt += 1; + retryTimer = timers.setTimeout(() => { + retryTimer = null; + load(); + }, delay); + } + + /** The next periodic read; a hidden tab skips its turn. */ + function schedulePoll() { + if (disposed || pollMs <= 0) return; + if (pollTimer !== null) timers.clearTimeout(pollTimer); + pollTimer = timers.setTimeout(() => { + pollTimer = null; + if (isVisible()) load(); + else schedulePoll(); + }, pollMs); + } + + /** Read this session's jobs. A failed read marks running jobs ended and tries again after the backoff. */ + async function load() { + if (disposed) return; + const read = ++reads; + try { + const result = await api.get(JOBS_URL, { signal: scope.signal, cache: 'no-store' }); + // Only the newest read applies; an older one answering late is dropped. + if (disposed || read !== reads) return; + if (!Array.isArray(result?.jobs)) throw new TypeError('Invalid job list'); + merge(result.jobs); + status = 'ready'; + schedulePoll(); + } catch (error) { + if (disposed || scope.signal?.aborted || read !== reads) return; + markLost(); + status = 'error'; + scheduleResync(); + } + emit(); + } + + const store = { + get status() { return status; }, + jobs: () => [...jobs], + running: () => jobs.filter(job => job.state === 'running'), + find, + load, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + /** Start a job of `kind`: {job}, or {busy, message} naming the running job that holds its slot. */ + async start(kind, body = {}) { + try { + const result = await api.post(`${JOBS_URL}/${encodeURIComponent(kind)}`, body, { signal: scope.signal }); + const job = parseJob(result?.job); + if (!job) throw new TypeError('Invalid job'); + upsert(job); + notify(startToast(job)); + return { job }; + } catch (error) { + if (error?.status !== 409 || error?.code !== 'busy') throw error; + await load(); + return { busy: store.running()[0] ?? null, message: error.message }; + } + }, + /** Ask a running job to stop at its next file boundary; false once it has ended. */ + async cancel(id) { + const result = await api.post(`${JOBS_URL}/${encodeURIComponent(id)}/cancel`, {}, { signal: scope.signal }); + return result?.cancellation_requested === true; + }, + }; + + scope.cleanup(() => { + disposed = true; + if (retryTimer !== null) timers.clearTimeout(retryTimer); + if (pollTimer !== null) timers.clearTimeout(pollTimer); + retryTimer = null; + pollTimer = null; + for (const id of [...streams.keys()]) closeStream(id); + listeners.clear(); + }); + return store; +} diff --git a/web/js/workspace/activity.mjs b/web/js/workspace/activity.mjs new file mode 100644 index 0000000..9da11ce --- /dev/null +++ b/web/js/workspace/activity.mjs @@ -0,0 +1,192 @@ +/** + * Activity (#249 Activity-1): the concept's Activity page (app.js renderActivity 732–738), its top-bar button + * (`button#activity-open` with `span.count#activity-count`, index.html 51–52) and its sidebar row (app.js ~341), fed + * by the session job store (activity-store.mjs). + * + * - D52: both counts show the running jobs only and are hidden at 0; the heading has no "Unified activity · proposed" + * chip (prototype copy). + * - D53: while a job runs, the progress pill (`a.activity-pill`, the approved mockup's) sits before the button on every + * page: "Scanning · 42% · 8,240 / 19,622" and a mini bar, linking `#/activity/<id>`. + * - D54: each `.activity-item` is a real job. Its chip names the state (green "Completed", or blue "Running", + * "Cancelled", "Failed", "Ended"), its line says when and what, and a running job shows its `<progress>` and + * "Cancel job" (no "Simulate completion"). + */ +import { createButton } from '../shared/toolkit/core.js'; +import { navigateTo } from './state.mjs'; +import { pageHeading } from './heading.mjs'; +import { showWorkspaceToast } from './toast.mjs'; +import { countOf } from './plural.mjs'; +import { pillParts, outcomeLine, addedOf, LOST_DETAIL } from './activity-store.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.'; +export const LOAD_FAILED = 'Activity could not be read. It is tried again shortly.'; +export const CANCEL_FAILED_TOAST = 'The job could not be cancelled. Try again.'; +const CHIPS = Object.freeze({ + running: ['blue', 'Running'], finished: ['green', 'Completed'], cancelled: ['blue', 'Cancelled'], + failed: ['blue', 'Failed'], lost: ['blue', 'Ended'], +}); + +function element(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +/** "09:41" in the browser's local time; an unknown time is empty. */ +export function clockTime(seconds) { + if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return ''; + const date = new Date(seconds * 1000); + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; +} + +const join = parts => parts.filter(Boolean).join(' · '); + +/** What a job covers: "2 output folders" for a scan. */ +function scopeLine(job) { + const folders = job.scope?.output_folders; + return Number.isSafeInteger(folders) && folders >= 0 ? countOf(folders, 'output folder') : ''; +} + +/** The item's line under its title, by state. */ +export function jobDetail(job) { + const at = clockTime(job.finished_at); + if (job.state === 'running') { + return join([`Started ${clockTime(job.started_at)}`, scopeLine(job), 'closing this view does not cancel it']); + } + if (job.state === 'lost') return LOST_DETAIL; + if (job.state === 'cancelled') { + return join([`Cancelled ${at} at a file boundary`, job.kind === 'scan' ? addedOf(job) : '', + 'completed work is kept']); + } + if (job.state === 'failed') return join([`Stopped on an error at ${at}`, outcomeLine(job)]); + return join([`Finished ${at}`, outcomeLine(job)]); +} + +/** The shell's Activity indicators: the top-bar count and pill, and the sidebar row's count. */ +export function mountActivityTopbar({ store, scope, doc = document }) { + const button = doc.getElementById('activity-open'); + const count = doc.getElementById('activity-count'); + const pill = doc.getElementById('activity-pill'); + scope.listen(button, 'click', () => navigateTo('#/activity')); + const label = element('span', '', 'activity-pill-label'); + const files = element('span', '', 'activity-pill-files'); + const bar = element('span', undefined, 'activity-pill-bar'); + const fill = element('i'); + bar.setAttribute('aria-hidden', 'true'); + bar.append(fill); + pill.replaceChildren(label, files, bar); + const render = () => { + const running = store.running(); + count.textContent = String(running.length); + count.hidden = running.length === 0; + const row = doc.querySelector('#system-nav a[data-route="activity"]'); + row?.querySelector('.nav-count')?.remove(); + if (row && running.length) row.append(element('span', running.length.toLocaleString(), 'nav-count')); + const [job] = running; + pill.hidden = !job; + if (!job) { + pill.removeAttribute('href'); + pill.removeAttribute('aria-label'); + return; + } + const parts = pillParts(job); + label.textContent = parts.label; + files.textContent = parts.files; + fill.style.width = `${job.progress}%`; + pill.href = `#/activity/${job.id}`; + pill.setAttribute('aria-label', parts.text); + }; + scope.cleanup(store.subscribe(render)); + render(); +} + +function itemFor(job, scope) { + const item = element('div', undefined, 'activity-item'); + item.dataset.jobId = job.id; + const heading = element('div', undefined, 'panel-heading'); + const title = element('h3'); + const chip = element('span'); + heading.append(title, chip); + const detail = element('p'); + const progress = element('progress'); + progress.max = 100; + const actions = element('div', undefined, 'row-actions'); + const cancel = createButton('Cancel job'); + actions.append(cancel); + item.append(heading, detail, progress, actions); + scope.listen(cancel, 'click', async () => { + if (cancel.getAttribute('aria-disabled') === 'true') return; + cancel.setAttribute('aria-disabled', 'true'); + try { + await item.cancelJob(); + } catch (error) { + if (scope.signal.aborted) return; + cancel.removeAttribute('aria-disabled'); + showWorkspaceToast(CANCEL_FAILED_TOAST); + } + }); + item.update = next => { + const [tone, state] = CHIPS[next.state]; + title.textContent = `${next.state === 'finished' ? '✓' : '○'} ${next.title}`; + chip.className = `chip ${tone}`; + chip.textContent = state; + detail.textContent = jobDetail(next); + const running = next.state === 'running'; + if (running) { + progress.value = next.progress; + progress.textContent = `${next.progress}%`; + progress.setAttribute('aria-label', `${next.title} progress`); + if (!progress.isConnected) item.insertBefore(progress, actions.isConnected ? actions : null); + if (!actions.isConnected) item.append(actions); + } else { + progress.remove(); + actions.remove(); + } + }; + return item; +} + +/** The Activity page: the concept's "Recent work" list of this session's jobs. `jobId` marks one job's item. */ +export function mountActivityPage(main, { store, scope, jobId = null }) { + pageHeading(main).description.textContent = ACTIVITY_DESCRIPTION; + const section = element('section', undefined, 'surface'); + const status = element('p', undefined, 'empty-state'); + section.append(element('h2', 'Recent work')); + main.append(section); + const items = new Map(); + let revealed = false; + const render = () => { + const jobs = store.jobs(); + for (const [id, item] of items) if (!jobs.some(job => job.id === id)) { item.remove(); items.delete(id); } + jobs.forEach((job, index) => { + let item = items.get(job.id); + if (!item) { + item = itemFor(job, scope); + item.cancelJob = () => store.cancel(job.id); + items.set(job.id, item); + } + item.update(job); + if (job.id === jobId) item.setAttribute('aria-current', 'true'); + // Only a node out of place moves, so a focused Cancel job keeps its focus across updates. + const at = section.children[index + 1]; + if (at !== item) section.insertBefore(item, at ?? null); + }); + const failed = store.status === 'error' && !jobs.length; + status.textContent = failed ? LOAD_FAILED : EMPTY_ACTIVITY; + if (failed) status.setAttribute('role', 'alert'); + else status.removeAttribute('role'); + if (jobs.length || store.status === 'idle') status.remove(); + else if (!status.isConnected) section.append(status); + const marked = jobId && items.get(jobId); + if (marked && !revealed) { + revealed = true; + marked.scrollIntoView({ block: 'nearest' }); + } + }; + scope.cleanup(store.subscribe(render)); + render(); + // Opening Activity reads the list at once instead of waiting for the next periodic read. + store.load(); +} diff --git a/web/js/workspace/icons.mjs b/web/js/workspace/icons.mjs index ef24a92..5ce605a 100644 --- a/web/js/workspace/icons.mjs +++ b/web/js/workspace/icons.mjs @@ -3,8 +3,9 @@ const paths = Object.freeze({ "library": "M4 4h5v16H4z M11 4h4v16h-4z M17 5l3-1 3 15-3 1z", "tags": "M3 3h8l10 10-8 8L3 11V3ZM7 7h.01", "upload": "M12 16V3m-5 5 5-5 5 5M3 15v6h18v-6", - // D28 ruling: an outline home glyph of its own; the concept's `activity` pulse stays with the Activity row D5 omits. + // D28 ruling: an outline home glyph of its own; the concept's `activity` pulse belongs to the Activity row. "overview": "M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6", + "activity": "M3 12h4l3-8 4 16 3-8h4", "settings": "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10 2 2M5 19l2-2M17 7l2-2", "grid": "M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z", "list": "M9 5h12M9 12h12M9 19h12M3 5h.01M3 12h.01M3 19h.01", diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 64d7456..2ad930b 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -11,6 +11,8 @@ import { bindCanvasLink, canvasHref } from './topbar.mjs'; import { renderHeading, pageHeading } from './heading.mjs'; import { closeWorkspaceDialog } from './workspace-dialogs.mjs'; import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs'; +import { createActivityStore } from './activity-store.mjs'; +import { mountActivityPage, mountActivityTopbar } from './activity.mjs'; const { createApiClient, resolveUrl } = window.ApiClient; @@ -43,7 +45,7 @@ function mountWorkspace() { const preference = document.getElementById('theme'); preference.value = theme.preference; scope.listen(preference, 'change', () => theme.setPreference(preference.value)); - // D6: ComfyUI ↗ opens the canvas; on Images with a resolved selection it sends that pair (D5: no Activity). + // D6: ComfyUI ↗ opens the canvas; on Images with a resolved selection it sends that pair (a send is not an Activity job). let images = null; scope.cleanup(bindCanvasLink(document.querySelector('.topbar .canvas-link'), { href: canvasHref(resolveUrl, window.location.pathname), @@ -60,8 +62,13 @@ function mountWorkspace() { return link; }); primaryNav.replaceChildren(...navLinks([['images', 'images'], ['library', 'library'], ['tags', 'tags'], ['inspect', 'upload']])); - // D28: System, then the production Overview hub, in the concept's tools-and-system group. - systemNav.replaceChildren(...navLinks([['system', 'settings'], ['overview', 'overview']])); + // The concept's tools-and-system group: Activity, System, then (D28) the production Overview hub. + systemNav.replaceChildren(...navLinks([['activity', 'activity'], ['system', 'settings'], ['overview', 'overview']])); + // Activity (#249 Activity-1): one store of this session's jobs feeds the top-bar button and pill, the sidebar + // row's count and the Activity page, on every route. + const activity = createActivityStore({ api, scope, connect: url => new EventSource(resolveUrl(url)) }); + mountActivityTopbar({ store: activity, scope }); + activity.load(); const sidebar = document.querySelector('aside.sidebar'); // The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`. const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` : route.id; @@ -221,6 +228,10 @@ function mountWorkspace() { mountVocabulary(main, api, view); return; } + if (route.id === 'activity') { + mountActivityPage(main, { store: activity, scope: view, jobId: route.jobId }); + return; + } if (isHandoff(route)) { description.textContent = route.description; const panel = element('section', undefined, 'surface handoff'); diff --git a/web/js/workspace/state.mjs b/web/js/workspace/state.mjs index 1cf43cb..b0a3a52 100644 --- a/web/js/workspace/state.mjs +++ b/web/js/workspace/state.mjs @@ -11,11 +11,13 @@ export const routes = [ { id: 'overview', label: 'Overview' }, // SAVED VIEWS' first row (#249 D51): Images filtered to favorites. No href, so Overview lists no card for it. { id: 'favorites', label: 'Favorites' }, + // The concept's Activity (app.js renderActivity 732–738): this server session's jobs. No href: no Overview card. + { id: 'activity', label: 'Activity' }, { id: 'system', label: 'System', href: '/prompt_manager/admin', description: 'Open the current dashboard, then choose Settings or Maintenance for configuration, scans, backups and diagnostics.', action: 'Open existing administration' }, ]; -const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview', 'saved', 'favorites']); +const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview', 'saved', 'favorites', 'activity']); /** A saved view (#249 D16): the shell resolves its id to the view's page, name and state. */ const SAVED_ROUTE = Object.freeze({ id: 'saved', label: 'Saved view' }); @@ -33,6 +35,9 @@ export function isHandoff(route) { export function resolveRoute(hash) { const saved = /^#\/saved\/([1-9]\d*)$/.exec(hash); if (saved && Number.isSafeInteger(Number(saved[1]))) return {...SAVED_ROUTE, savedId:Number(saved[1]), hash}; + // One job of the Activity page (its id is the registry's uuid4 hex). + const job = /^#\/activity\/([0-9a-f]{32})$/.exec(hash); + if (job) return {...routes.find(route => route.id === 'activity'), jobId:job[1], hash}; const match = /^#\/library\/([1-9]\d*)$/.exec(hash); if (match && Number.isSafeInteger(Number(match[1]))) { return {...routes.find(route => route.id === 'library'), positiveId:Number(match[1]), hash}; @@ -110,7 +115,8 @@ export function mountRouter(win, onRoute) { const route = resolveRoute(win.location.hash); const hash = route.hash || `#/${route.id}`; if (win.location.hash !== hash) win.history.replaceState(win.history.state, '', hash); - if (current?.id === route.id && current?.positiveId === route.positiveId && current?.savedId === route.savedId) return; + if (current?.id === route.id && current?.positiveId === route.positiveId && current?.savedId === route.savedId && + current?.jobId === route.jobId) return; current = route; onRoute(route); }; diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 5baece7..79b1a2d 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -17,6 +17,21 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; } } +/* D53: the running job's progress pill before the Activity button (the approved Activity mockup's `.pill`), in the + concept's tokens. At <=700px it keeps its verb and percentage; the file counts and bar stay in its accessible name. */ +.activity-pill:not([hidden]) { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border: 1px solid var(--line); + border-radius: 999px; font-size: 12px; white-space: nowrap; color: var(--ca-text); background: var(--ca-surface); /* D53 */ } +.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 */ } +@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. */ + .top-actions { flex-wrap: wrap; /* D52 */ } + .activity-pill:not([hidden]) { gap: 0; padding-inline: 10px; /* D53 */ } + .activity-pill-files, .activity-pill-bar { display: none; /* D53 */ } +} + /* Overview: real library statistics and the legacy tool cards inside a concept `.surface`. */ .stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 0 0 26px; } .stat { padding: 22px; background: var(--ca-surface); border: 1px solid var(--line); border-radius: 7px; } diff --git a/web/workspace.html b/web/workspace.html index adf88fd..03bd56f 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -32,7 +32,7 @@ <div class="workspace"> <header class="topbar"> <div class="breadcrumbs">ComfyAssets <span>/</span> <span id="workspace-breadcrumb">Images</span></div> - <div class="top-actions"><label class="theme-label" for="theme">Theme + <div class="top-actions"><!-- D53: the progress pill, shown while a job runs. D52: the count shows running jobs, hidden at 0. --><a id="activity-pill" class="activity-pill" hidden></a><button id="activity-open" type="button">Activity <span class="count" id="activity-count" hidden>0</span></button><label class="theme-label" for="theme">Theme <select id="theme" aria-label="Theme"> <option value="dark">Dark</option> <option value="light">Light</option>