fix(workspace): Overview cards show the sidebar's counts (#549)

Closes #547
This commit is contained in:
Vito Sansevero
2026-10-31 02:01:06 +00:00
committed by Vito
parent 3789d85e58
commit f924d7195d
8 changed files with 143 additions and 61 deletions
+2 -1
View File
@@ -602,7 +602,8 @@ async function staleSourceRecovery(page, request, roots) {
const pages = [], inspections = [], options = [], mutations = [];
page.on('request', request => {
const url = new URL(request.url());
if (url.pathname.endsWith('/images/output')) pages.push(url);
// limit=1 is a sidebar count read (Overview re-reads it on a visit, #547), not a page of images.
if (url.pathname.endsWith('/images/output') && url.searchParams.get('limit') !== '1') pages.push(url);
if (url.pathname.includes('/images/inspect/')) inspections.push(url);
if (request.method() !== 'GET') mutations.push(url.pathname);
});
+1 -1
View File
@@ -176,7 +176,7 @@ test('failed library reads retry while late search responses cannot overwrite ne
// D153 (#447): a Browse visit that still names the retired "Highest rated" sort opens the shelf on Recently added.
test('a Library visit with the retired rating sort opens on Recently added',async({page})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
const sorts=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library')sorts.push(url.searchParams.get('sort'));});
const sorts=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library'&&url.searchParams.get('limit')!=='1')sorts.push(url.searchParams.get('sort'));});// limit=1 is a sidebar count read, not the shelf
await page.goto('/prompt_manager/workspace#/overview');await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(async()=>{const {browseLibrary}=await import('/prompt_manager/js/workspace/library.mjs');browseLibrary({sort:'rating'});});
await expect(cards(page)).toHaveCount(3);
+59 -16
View File
@@ -60,6 +60,10 @@ test.afterEach(async ({ page, browserName }) => {
test('workspace starts with real persisted statistics without mutating data', async ({ page, request }) => {
const before = await (await request.get('/prompt_manager/stats')).json();
const total = async path => (await (await request.get(`/prompt_manager/${path}`)).json()).total;
const counts = [['Prompts', await total('library?limit=1')],
['Images', await total('images/output?mode=workspace&limit=1')],
['Tags', await total('vocabulary/tags?limit=1')]];
const mutations = [];
page.on('request', request => {
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) mutations.push(request.url());
@@ -69,10 +73,11 @@ test('workspace starts with real persisted statistics without mutating data', as
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })).toBeVisible();
const main = page.locator('#workspace-main');
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
for (const [label, key] of [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]) {
await expect(statistic(page, label)).toHaveText(String(before.stats[key]));
for (const [label, count] of counts) await expect(statistic(page, label)).toHaveText(String(count));
// #547: a card and the sidebar badge of the page it names show one number.
for (const [route, count] of [['library', counts[0][1]], ['images', counts[1][1]], ['tags', counts[2][1]]]) {
await expect(page.locator(`aside.sidebar nav [data-route="${route}"] .nav-count`)).toHaveText(String(count));
}
expect(before.stats.total_prompts).toBe(2);
expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(before.stats);
expect(mutations).toEqual([]);
await expect(page.locator('iframe')).toHaveCount(0);
@@ -373,25 +378,65 @@ test('unavailable host palette falls back to the system theme and tracks system
});
test('overview reports loading, offers retry after failure and renders real empty statistics', async ({ page }) => {
const sources = ['**/prompt_manager/library?limit=1', '**/prompt_manager/images/output?mode=workspace&limit=1',
'**/prompt_manager/vocabulary/tags?limit=1'];
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/stats', async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
for (const source of sources) {
await page.route(source, async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
}
await page.goto(`${workspace}#/overview`);
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
release();
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
await page.unroute('**/prompt_manager/stats');
await page.route('**/prompt_manager/stats', route => route.fulfill({
json: { success: true, stats: { total_prompts: 0, total_images: 0, total_tags: 0 } },
}));
for (const source of sources) {
await page.unroute(source);
await page.route(source, route => route.fulfill({ json: { success: true, total: 0, items: [], images: [], tags: [] } }));
}
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.locator('#workspace-main').getByRole('alert')).toHaveCount(0);
for (const label of ['Prompts', 'Images', 'Tags']) {
await expect(statistic(page, label)).toHaveText('0');
}
await expect(page.locator('#workspace-main .empty-state')).toBeVisible();
});
test('a slow or failed Images read leaves the other Overview cards and retries only itself', async ({ page }) => {
const images = '**/prompt_manager/images/output?mode=workspace&limit=1';
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route(images, async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
await page.goto(`${workspace}#/overview`);
await expect(statistic(page, 'Prompts')).toHaveText('2');
await expect(statistic(page, 'Images')).toHaveText('–');
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
release();
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
await expect(statistic(page, 'Prompts')).toHaveText('2');
await page.unroute(images);
let libraryReads = 0;
page.on('request', request => { if (new URL(request.url()).pathname === '/prompt_manager/library') libraryReads++; });
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.locator('#workspace-main').getByRole('alert')).toHaveCount(0);
await expect(statistic(page, 'Images')).toHaveText(/^\d+$/);
expect(libraryReads).toBe(0);
});
test('opening Overview reads each count once beside the sidebar', async ({ page }) => {
let reads = 0;
page.on('request', request => {
const url = new URL(request.url());
if (url.pathname === '/prompt_manager/images/output' && url.searchParams.get('favorites') === null) reads++;
});
await page.goto(`${workspace}#/overview`);
await expect(statistic(page, 'Images')).toHaveText(/^\d+$/);
expect(reads).toBe(1);
});
test('small screens keep navigation and the theme accessible without horizontal page overflow', async ({ page }) => {
@@ -410,11 +455,9 @@ test('leaving a loading overview prevents its delayed result from replacing the
let completed;
const pending = new Promise(resolve => { release = resolve; });
const finished = new Promise(resolve => { completed = resolve; });
await page.route('**/prompt_manager/stats', async route => {
await page.route('**/prompt_manager/library?limit=1', async route => {
await pending;
await route.fulfill({ json: {
success: true, stats: { total_prompts: 999, total_images: 888, total_tags: 777 },
} }).catch(() => {});
await route.fulfill({ json: { success: true, total: 999, items: [] } }).catch(() => {});
completed();
});
await page.goto(`${workspace}#/overview`);
@@ -437,7 +480,7 @@ test('leaving a loading overview prevents its delayed result from replacing the
test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => {
let reads = 0;
page.on('request', request => {
if (new URL(request.url()).pathname === '/prompt_manager/stats') reads++;
if (new URL(request.url()).pathname === '/prompt_manager/vocabulary/tags') reads++;
});
await page.goto(`${workspace}#/overview`);
await expect(statistic(page, 'Prompts')).toHaveText('2');
+2 -2
View File
@@ -8,7 +8,7 @@ test('workspace serves real library data beside either ComfyUI renderer', async
text: `Workspace host smoke ${testInfo.project.name}`, tags: ['workspace-host'],
} });
expect(saved.ok()).toBe(true);
const stats = (await (await request.get('/prompt_manager/stats')).json()).stats;
const library = await (await request.get('/prompt_manager/library?limit=1')).json();
const workspace = await context.newPage();
const errors = [];
workspace.on('pageerror', error => errors.push(error.message));
@@ -16,7 +16,7 @@ test('workspace serves real library data beside either ComfyUI renderer', async
await workspace.goto('/prompt_manager/workspace#/overview');
await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview');
await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd'))
.toHaveText(stats.total_prompts.toLocaleString());
.toHaveText(library.total.toLocaleString());
await workspace.getByRole('combobox', { name: 'Theme' }).selectOption('light');
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
const imageResponse = workspace.waitForResponse(response => {
+23 -11
View File
@@ -123,17 +123,29 @@ test('a job of the Activity page is its own deep link', async () => {
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}), [
['Prompts', 0], ['Images', 12], ['Tags', 3],
]);
for (const stats of [undefined, null, {}, {total_prompts:0, total_images:-1, total_tags:0},
{total_prompts:'2', total_images:0, total_tags:0},
{total_prompts:Infinity, total_images:0, total_tags:0},
{total_prompts:0, total_images:1.5, total_tags:0}]) {
assert.throws(() => summarizeStats(stats), /statistics/i);
}
test('Overview cards show the count of the page they name', async () => {
const { summarizeOverviewCounts, OVERVIEW_COUNT_ROUTES } = await source;
assert.deepEqual(OVERVIEW_COUNT_ROUTES, ['library', 'images', 'tags']);
const counts = new Map([['library', 64], ['images', 122], ['tags', 306], ['favorites', 8]]);
assert.deepEqual(summarizeOverviewCounts(counts), {
cards: [['Prompts', 64], ['Images', 122], ['Tags', 306]], pending: false, failed: false, empty: false,
});
});
test('Overview tells loading, failed and really empty counts apart', async () => {
const { summarizeOverviewCounts } = await source;
const nothing = summarizeOverviewCounts(new Map());
assert.deepEqual([nothing.pending, nothing.failed, nothing.empty], [true, false, false]);
assert.deepEqual(nothing.cards, [['Prompts', null], ['Images', null], ['Tags', null]]);
// A slow Images read does not hold back the cards already read.
const partial = summarizeOverviewCounts(new Map([['library', 0], ['tags', 0]]));
assert.deepEqual(partial.cards, [['Prompts', 0], ['Images', null], ['Tags', 0]]);
assert.deepEqual([partial.pending, partial.failed, partial.empty], [true, false, false]);
const failed = summarizeOverviewCounts(new Map([['library', 2], ['images', null], ['tags', 3]]));
assert.deepEqual([failed.pending, failed.failed, failed.empty], [false, true, false]);
assert.deepEqual(failed.cards[1], ['Images', null]);
const empty = summarizeOverviewCounts(new Map([['library', 0], ['images', 0], ['tags', 0]]));
assert.deepEqual([empty.pending, empty.failed, empty.empty], [false, false, true]);
});
test('router normalizes unknown routes, avoids duplicate renders, and disposes its listener', async () => {