diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs index f5f9e977..346452bf 100644 --- a/tests/browser/system-concept.spec.mjs +++ b/tests/browser/system-concept.spec.mjs @@ -181,6 +181,17 @@ function mapOverviewConcept(reference, overview) { // D96: the dialog shows runs and ratings by category and library totals, not prompt, tag or date usage. Statistics: row => ({ ...row, description: 'Runs and ratings by category, with library totals.' }), }; + const surfaces = reference.columns.surfaces.map(surface => ({ ...surface, + rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) })); + // D129: "Library location" after Backup status, in the backup row's own markup; the fixture's database is configured + // explicitly (config.json), so it does not move and its action is Details. + const [health] = surfaces; + const backup = health.rows.find(row => row.title === 'Backup status'); + const location = { ...backup, title: 'Library location', + description: `${overview.location.path} · The database path set in config.json.`, + action: { ...backup.action, text: 'Details' } }; + surfaces[0] = { ...health, tree: [...health.tree, 'div.operation'], + rows: [...health.rows.slice(0, 2), location, ...health.rows.slice(2)] }; return { ...reference, // D92: no "Sample configuration" chip. @@ -188,8 +199,7 @@ function mapOverviewConcept(reference, overview) { // D122: the concept's literal label is its prototype action name ("system-tab sections"). nav: { ...reference.nav, label: 'System sections' }, metrics: { ...reference.metrics, items: reference.metrics.items.map(item => ({ ...item, value: relabel[item.label] })) }, - columns: { ...reference.columns, surfaces: reference.columns.surfaces.map(surface => ({ ...surface, - rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) })) }, + columns: { ...reference.columns, surfaces }, save: { ...reference.save, // D92: truthful status copy, and "Save preferences" is aria-disabled (dimmed) while nothing is unsaved. status: { ...reference.save.status, text: 'No unsaved changes.' }, @@ -232,13 +242,21 @@ for (const theme of THEMES) for (const [width, height] of SIZES) { // The recorded differences, by path: D93 the review row (its real count), the backup row, the // preferences row and the sources row, and metric 4; D96 the statistics row; D92 the chip, the status and the dimmed -// Save; D122 the nav label. (The fixture's first three metrics are the concept's own counts.) +// Save; D122 the nav label; D129 the inserted "Library location" row, which shifts the thumbnail row to the fourth place +// (row 2 takes its title, text and action; row 3 and the surface's fifth child are new). (The fixture's first three +// metrics are the concept's own counts.) +const D129_ROW_ACTION = ['backgroundColor', 'borderTopColor', 'borderTopLeftRadius', 'borderTopWidth', 'className', 'color', + 'cursor', 'fontSize', 'opacity', 'paddingLeft', 'paddingTop', 'text'].map(key => `columns.surfaces.0.rows.3.action.${key}`); const UNMAPPED_PATHS = [ 'columns.surfaces.0.rows.0.title', 'columns.surfaces.0.rows.1.description', + 'columns.surfaces.0.rows.2.action.text', 'columns.surfaces.0.rows.2.description', 'columns.surfaces.0.rows.2.title', + ...D129_ROW_ACTION, 'columns.surfaces.0.rows.3.description', 'columns.surfaces.0.rows.3.title', + 'columns.surfaces.0.rows.3.tree.0', 'columns.surfaces.0.rows.3.tree.1', 'columns.surfaces.0.rows.3.tree.length', + 'columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.4', 'columns.surfaces.0.tree.length', 'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description', 'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label', 'save.button.cursor', 'save.button.opacity', 'save.status.text', -]; +].sort(); test('system comparison detects mutations', async ({ page, request }) => { await page.setViewportSize({ width: 1600, height: 1100 }); @@ -903,3 +921,85 @@ test('thumbnail dialog comparison detects mutations', async ({ page, request }) await undoStyle(); expect(diffInventory(expected, await page.evaluate(measureThumbnailReview))).toEqual([]); }); + +/** + * Runs in the page: one `.notice` (the concept's markup: `div > h2 + p`, then `.row-actions` buttons) with its tokens and + * geometry, and its left edge against `edgeSelector` (the content it lines up with). + */ +function measureNoticeOf([selector, edgeSelector]) { + const notice = document.querySelector(selector); + const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null; + const name = node => [node.localName, ...[...node.classList].sort()].join('.'); + const text = node => node?.textContent.trim() ?? null; + const button = node => ({ text: text(node), className: node.className, + ...css(node, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor', 'borderTopLeftRadius') }); + const edge = document.querySelector(edgeSelector); + return { + tree: [...notice.children].map(name), + text: [...notice.querySelectorAll(':scope > div:not(.row-actions) > *')].map(node => [node.localName, text(node)]), + actions: [...notice.querySelectorAll(':scope > .row-actions > button')].map(button), + frame: css(notice, 'display', 'alignItems', 'justifyContent', 'columnGap', 'borderTopWidth', 'borderTopColor', + 'borderTopLeftRadius', 'backgroundColor', 'paddingTop', 'paddingLeft', 'marginBottom', 'flexDirection'), + h2: css(notice.querySelector('h2'), 'fontSize', 'fontWeight', 'color'), + p: css(notice.querySelector('p'), 'fontSize', 'color', 'marginTop'), + rowActions: css(notice.querySelector(':scope > .row-actions'), 'display', 'columnGap', 'flexShrink'), + alignedWith: Math.round(notice.getBoundingClientRect().left - edge.getBoundingClientRect().left), + pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth, + }; +} + +/** D129: the concept's Sources notice as the library notice: its own copy and actions, no bottom margin (main's padding). */ +function mapLibraryNotice(reference) { + return { + ...reference, + text: [['h2', 'Your library is inside the node folder'], ['p', 'Your library is stored inside the node folder, where an ' + + 'update or reinstall can delete it. Move it to ComfyUI\'s user folder: it is copied and verified first, and nothing ' + + 'is deleted.']], + actions: reference.actions.map((action, index) => ({ ...action, text: ['Keep it here', 'Move library'][index] })), + frame: { ...reference.frame, marginBottom: '0px' }, + }; +} + +async function conceptNotice(page, theme) { + await openConcept(page, 'system', theme); + await page.locator('#main .section-nav').getByRole('button', { name: 'Sources & scans', exact: true }).click(); + await expect(page.locator('#main .settings-layout > .notice')).toBeVisible(); + await page.mouse.move(0, 0); + return page.evaluate(measureNoticeOf, ['#main .settings-layout > .notice', '#main .heading']); +} + +async function productionNotice(page, theme) { + await openProduction(page, 'system/overview', theme); + await expect(page.locator('#library-banner > .notice')).toBeVisible(); + await expect(page.locator('#workspace-main .summary-metrics')).toBeVisible(); + await page.mouse.move(0, 0); + return page.evaluate(measureNoticeOf, ['#library-banner > .notice', '#workspace-main .heading']); +} + +for (const theme of THEMES) for (const [width, height] of SIZES) { + test(`library notice matches the concept notice ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => { + await page.setViewportSize({ width, height }); + await expect(await request.post('/__test__/system/library', { data: { state: 'node_folder' } })).toBeOK(); + const concept = await conceptNotice(page, theme); + const production = await productionNotice(page, theme); + await page.screenshot({ path: testInfo.outputPath(`production-library-notice-${theme}-${width}.png`), fullPage: true }); + expect(diffInventory(mapLibraryNotice(concept), production)).toEqual([]); + expect(production.alignedWith).toBe(0); + expect(production.pageScroll).toBe(false); + }); +} + +test('library notice comparison detects mutations', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + await expect(await request.post('/__test__/system/library', { data: { state: 'node_folder' } })).toBeOK(); + const expected = mapLibraryNotice(await conceptNotice(page, 'dark')); + await productionNotice(page, 'dark'); + const paths = async () => diffInventory(expected, + await page.evaluate(measureNoticeOf, ['#library-banner > .notice', '#workspace-main .heading'])).map(item => item.path); + expect(await paths()).toEqual([]); + const undoToken = await mutate(page, { css: '#library-banner { --soft-blue: rgb(255, 0, 0); }' }); + expect(await paths()).toEqual(['frame.backgroundColor']); + await undoToken(); + await page.evaluate(() => { document.querySelector('#library-banner .notice h2').textContent = 'Library'; }); + expect(await paths()).toEqual(['text.0.1']); +}); diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs index 0080f282..5e09a776 100644 --- a/tests/browser/workspace-system.spec.mjs +++ b/tests/browser/workspace-system.spec.mjs @@ -68,6 +68,8 @@ test('overview shows real counts and Not checked', async ({ page, request }) => expect(await rowsOf(layout, 'Library health')).toEqual([ ['0 ambiguous image links', 'Review metadata before assigning a positive / negative pair.', 'Review metadata'], ['Backup status', 'No retained backup record.', 'View backups'], + // D129: the fixture's database is configured explicitly, so it reads as config.json's choice and does not move. + ['Library location', 'browser.db · The database path set in config.json.', 'Details'], ['No missing thumbnails', 'Generate only missing previews when necessary.', 'Review thumbnails'], ]); expect(await rowsOf(layout, 'Workspace preferences')).toEqual([ @@ -78,6 +80,95 @@ test('overview shows real counts and Not checked', async ({ page, request }) => expect(writes).toEqual([]); }); +// D129 (#249 S6b): where the library lives. The hook makes the fixture's database read as a node-folder library; its +// Move library is a stand-in that touches no file (workspace_system_fixture.py). +const banner = page => page.locator('#library-banner'); +const LIBRARY_URL = '/prompt_manager/system/library'; + +test('a node-folder library is offered a move above every page; Keep it here stops the offer', async ({ page, request }) => { + await expect(await request.post('/__test__/system/library', { data: { state: 'node_folder' } })).toBeOK(); + await page.goto(`${workspace}#/images`); + await expect(banner(page).locator('.notice h2')).toHaveText('Your library is inside the node folder'); + await expect(banner(page).locator('.notice p')).toHaveText(/^Your library is stored inside the node folder, where an update or reinstall can delete it\./); + await expect(banner(page).locator('.notice .row-actions button')).toHaveText(['Keep it here', 'Move library']); + await expect(banner(page).getByRole('button', { name: 'Move library', exact: true })).toHaveClass(/ca-primary/); + await page.goto(`${workspace}#/library`); + await expect(banner(page).locator('.notice')).toBeVisible(); + await banner(page).getByRole('button', { name: 'Keep it here', exact: true }).click(); + await expect(banner(page)).toBeHidden(); + const { location } = await serverJson(request, LIBRARY_URL); + expect([location.keep, location.offer, location.movable]).toEqual([true, false, true]); + await page.reload(); + const layout = await openOverview(page); + await expect(banner(page)).toBeHidden(); + const row = layout.locator('.operation').filter({ hasText: 'Library location' }); + await expect(row.locator('p')).toHaveText('browser.db · The node folder, where an update or reinstall can delete it. ' + + 'You chose to keep it here.'); + // The row stays: the move is still one press away. + await expect(row.locator('.row-actions button')).toHaveText('Move library'); +}); + +test('Move library is reviewed, runs as an Activity job and ends the offer', async ({ page, request }) => { + await expect(await request.post('/__test__/system/library', { data: { state: 'node_folder' } })).toBeOK(); + const layout = await openOverview(page); + const row = layout.locator('.operation').filter({ hasText: 'Library location' }); + await expect(row.locator('p')).toHaveText('browser.db · The node folder, where an update or reinstall can delete it.'); + const move = banner(page).getByRole('button', { name: 'Move library', exact: true }); + await move.click(); + const dialog = page.locator('dialog#modal[open]'); + await expect(dialog.locator('#modal-title')).toHaveText('Move library'); + expect(await dialog.locator('dl.meta-grid > div').evaluateAll(items => items.map(item => + [item.querySelector('dt').textContent, item.querySelector('dd').textContent]))).toEqual([ + ['From', 'browser.db'], ['To', 'user/__promptmanager/prompts.db'], ['Size', expect.stringMatching(/ (KB|MB)$/)], + ['Moves with it', 'config.json and the database backups'], ['Old files', 'Renamed *.moved-to-…, never deleted'], + ]); + await expect(dialog.locator('.dialog-actions button')).toHaveText(['Cancel', 'Move library']); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.locator('dialog#modal[open]')).toHaveCount(0); + await expect(move).toBeFocused(); + await row.getByRole('button', { name: 'Move library', exact: true }).click(); + await dialog.getByRole('button', { name: 'Move library', exact: true }).click(); + await expect(page).toHaveURL(/#\/activity\/[^/]+$/); + await expect(page.locator('#workspace-main')).toContainText( + /Moved the library to user\/__promptmanager: \d+ prompts? and 0 images verified\. The old files were renamed browser\.db\.moved-to-__promptmanager; nothing was deleted\./); + // The offer ends with the job; the Overview says where the library lives now. + await expect(banner(page)).toBeHidden(); + const after = await openOverview(page); + await expect(after.locator('.operation').filter({ hasText: 'Library location' }).locator('p')) + .toHaveText('browser.db · ComfyUI\'s user folder, which updates and reinstalls of the node leave alone.'); +}); + +test('a move that fails says so and keeps the offer; a refused start says why', async ({ page, request }) => { + await expect(await request.post('/__test__/system/library', { data: { state: 'failing' } })).toBeOK(); + await page.goto(`${workspace}#/images`); + await banner(page).getByRole('button', { name: 'Move library', exact: true }).click(); + await page.locator('dialog#modal[open]').getByRole('button', { name: 'Move library', exact: true }).click(); + await expect(page).toHaveURL(/#\/activity\/[^/]+$/); + await expect(page.locator('#workspace-main')).toContainText( + 'The copy did not pass verification. Nothing was moved. The library is still in the node folder, unchanged.'); + await expect(banner(page).locator('.notice')).toBeVisible(); + await expect(await request.post('/__test__/system/fail', { data: { path: `${LIBRARY_URL}/move` } })).toBeOK(); + await banner(page).getByRole('button', { name: 'Move library', exact: true }).click(); + await page.locator('dialog#modal[open]').getByRole('button', { name: 'Move library', exact: true }).click(); + await expect(page.locator('#workspace-toast')).toHaveText('Move library could not start. Try again.'); +}); + +test('a library that does not move shows its details', async ({ page }) => { + const layout = await openOverview(page); + await expect(banner(page)).toBeHidden(); + const details = layout.locator('.operation').filter({ hasText: 'Library location' }).getByRole('button', { name: 'Details', exact: true }); + await details.click(); + const dialog = page.locator('dialog#modal[open]'); + await expect(dialog.locator('#modal-title')).toHaveText('Library location'); + expect(await dialog.locator('dl.meta-grid > div').evaluateAll(items => items.map(item => + [item.querySelector('dt').textContent, item.querySelector('dd').textContent.replace(/[\d.,]+ (KB|MB)$/, 'N')]))).toEqual([ + ['Database', 'browser.db'], ['Why here', 'The database path set in config.json'], + ['Beside it', 'config.json, the database backups and the logs'], ['Size', 'N'], + ]); + await dialog.getByRole('button', { name: 'Close', exact: true }).click(); + await expect(details).toBeFocused(); +}); + test('Run health checks opens Health report and updates metric 4', async ({ page, request }) => { await page.goto(`${workspace}#/system/maintenance`); const layout = layoutOf(page); diff --git a/tests/browser/workspace_system_fixture.py b/tests/browser/workspace_system_fixture.py index 95f32189..fbd45578 100644 --- a/tests/browser/workspace_system_fixture.py +++ b/tests/browser/workspace_system_fixture.py @@ -23,6 +23,12 @@ read. These per-server hooks only change what a System test needs on top: image monitor whose observer is ``alive`` (default true) and schedules the named folders of this server's root (``["output"]``), so Sources reads watching, manual scan and restart pending states (S4). +- ``POST /__test__/system/library`` ``{state}`` (S6b): ``node_folder`` makes + the live database read as a node-folder library that can move to + ``user/__promptmanager``; Move library then runs a stand-in move that + touches no file and ends with the library in the default folder. + ``failing`` is the same library whose move fails verification. Reset + restores the real resolver, the real move and "Keep it here". Delays use the shared request holds (``request_hold.py``). """ @@ -54,7 +60,12 @@ def register_system_commands(app, routes, api, root: Path): from py.config import GalleryConfig, PromptManagerConfig from utils import image_monitor + from database import library_move + from utils import library_location + configured = list(GalleryConfig.MONITORING_DIRECTORIES) + real_move = library_move.move_library + keep = PromptManagerConfig.KEEP_LIBRARY_IN_NODE_FOLDER config_bytes = config_path.read_bytes() if config_path.exists() else None host_values = ( PromptManagerConfig.RESULT_TIMEOUT, @@ -92,6 +103,9 @@ def register_system_commands(app, routes, api, root: Path): GalleryConfig.MONITORING_ENABLED, ) = host_values image_monitor._monitor_instance = monitor + api.__dict__.pop("_library_resolver", None) + library_move.move_library = real_move + PromptManagerConfig.KEEP_LIBRARY_IN_NODE_FOLDER = keep if config_bytes is None: config_path.unlink(missing_ok=True) else: @@ -187,6 +201,43 @@ def register_system_commands(app, routes, api, root: Path): ) return web.json_response({"success": True}) + @routes.post("/__test__/system/library") + async def library_state(request): + data = await request.json() + state = data.get("state") + if state not in ("node_folder", "failing"): + raise web.HTTPBadRequest() + target = str(root / "user" / "__promptmanager") + + def located(source): + return library_location.LibraryLocation( + source, + api.db.model.db_path, + str(config_path), + str(root), + target if source == library_location.SOURCE_NODE_FOLDER else None, + target_source=library_location.SOURCE_DEFAULT, + ) + + current = {"source": library_location.SOURCE_NODE_FOLDER} + api._library_resolver = lambda: located(current["source"]) + + def stand_in_move(plan, *, progress, **_kwargs): + if state == "failing": + raise library_move.MoveFailed("verify-failed") + progress("Copying the database") + counts = api.db.library_counts() + current["source"] = library_location.SOURCE_DEFAULT + return library_move.MoveResult( + located(library_location.SOURCE_DEFAULT), + {"prompts": counts["positive_prompts"], "generated_images": 0}, + ["prompts.db"], + ["browser.db.moved-to-__promptmanager"], + ) + + library_move.move_library = stand_in_move + return web.json_response({"success": True}) + return reset diff --git a/tests/js/workspace-library-location.test.js b/tests/js/workspace-library-location.test.js new file mode 100644 index 00000000..ce668e95 --- /dev/null +++ b/tests/js/workspace-library-location.test.js @@ -0,0 +1,145 @@ +// Where the library lives (#249 System S6b, D129): the Overview row's copy, the Move library review and its start, +// "Keep it here" and the Move library job's end. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const locationModule = import('../../web/js/workspace/library-location.mjs'); + +const NODE_FOLDER = Object.freeze({ + path: 'custom_nodes/ComfyUI_PromptManager/prompts.db', source: 'node_folder', movable: true, keep: false, offer: true, + target: 'user/__promptmanager', problem: null, size_bytes: 1_700_000_000, +}); + +function scope() { + const controller = new AbortController(); + return { signal: controller.signal, abort: () => controller.abort(), listen() {}, cleanup() {} }; +} + +function activityStore({ running = [] } = {}) { + const listeners = new Set(); + let jobs = []; + return { + tracked: [], loads: 0, + track(job) { this.tracked.push(job); jobs = [...jobs, job]; return job; }, + async load() { this.loads += 1; }, + running() { return running; }, + jobs() { return jobs; }, + set(next) { jobs = next; for (const listener of listeners) listener(); }, + subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, + }; +} + +class ApiError extends Error { + constructor(status, message, code) { super(message); this.status = status; this.code = code; } +} + +test('the row says where the library is and why, the choice and any problem', async () => { + const { locationDescription, SOURCE_TEXT } = await locationModule; + assert.equal(locationDescription(NODE_FOLDER), + 'custom_nodes/ComfyUI_PromptManager/prompts.db · The node folder, where an update or reinstall can delete it.'); + assert.equal(locationDescription({ ...NODE_FOLDER, keep: true }), + `${NODE_FOLDER.path} · ${SOURCE_TEXT.node_folder}. You chose to keep it here.`); + assert.equal(locationDescription({ path: 'user/__promptmanager/prompts.db', source: 'default', movable: false, keep: true }), + 'user/__promptmanager/prompts.db · ComfyUI\'s user folder, which updates and reinstalls of the node leave alone.'); + assert.match(locationDescription({ ...NODE_FOLDER, problem: 'data-dir-empty' }), /PROMPT_MANAGER_DATA_DIR has no library yet/); + assert.match(locationDescription({ path: 'prompts.db', source: 'environment', movable: false }), /PROMPT_MANAGER_DATA_DIR names/); + assert.match(locationDescription({ path: 'prompts.db', source: 'config', movable: false }), /config\.json/); +}); + +test('only a well-formed location is shown', async () => { + const { isLocation } = await locationModule; + assert.equal(isLocation(NODE_FOLDER), true); + for (const bad of [null, undefined, 'x', {}, { ...NODE_FOLDER, source: 'elsewhere' }, { ...NODE_FOLDER, path: 3 }, + { ...NODE_FOLDER, movable: 'yes' }]) assert.equal(isLocation(bad), false); +}); + +test('sizes read as the server would write them', async () => { + const { sizeText } = await locationModule; + assert.equal(sizeText(1_700_000_000), '1.6 GB'); + assert.equal(sizeText(120 * 2 ** 20), '120.0 MB'); + assert.equal(sizeText(1500), '2 KB'); + assert.equal(sizeText(-1), 'Unknown'); + assert.equal(sizeText(undefined), 'Unknown'); +}); + +test('the review names both folders, the size and what happens to the old files', async () => { + const { reviewGrid } = await locationModule; + assert.deepEqual(reviewGrid(NODE_FOLDER), [ + ['From', 'custom_nodes/ComfyUI_PromptManager/prompts.db'], + ['To', 'user/__promptmanager/prompts.db'], + ['Size', '1.6 GB'], + ['Moves with it', 'config.json and the database backups'], + ['Old files', 'Renamed *.moved-to-…, never deleted'], + ]); +}); + +test('Move library starts the job, tracks it and opens its view', async () => { + const { startLibraryMove, MOVE_URL } = await locationModule; + const posts = []; + const api = { post: async (url, body) => { posts.push([url, body]); return { job: { id: 'j1', kind: 'library_move' } }; } }; + const activity = activityStore(); + const routes = []; + const job = await startLibraryMove({ api, activity, scope: scope(), notify: () => assert.fail('no toast'), + navigate: hash => routes.push(hash) }); + assert.equal(job.id, 'j1'); + assert.deepEqual(posts, [[MOVE_URL, {}]]); + assert.deepEqual(activity.tracked.map(item => item.id), ['j1']); + assert.deepEqual(routes, ['#/activity/j1']); +}); + +test('a busy server names the running job; another refusal says the server reason', async () => { + const { startLibraryMove, START_FAILED } = await locationModule; + const running = { id: 'scan1', title: 'Metadata scan' }; + const activity = activityStore({ running: [running] }); + const toasts = []; + const busy = { post: async () => { throw new ApiError(409, 'busy', 'busy'); } }; + assert.equal(await startLibraryMove({ api: busy, activity, scope: scope(), notify: t => toasts.push(t), + navigate: () => assert.fail('nothing opens') }), null); + assert.equal(activity.loads, 1); + assert.deepEqual(toasts.at(-1), ['Another job is running: ', { text: 'Metadata scan', href: '#/activity/scan1' }, '.']); + const short = { post: async () => { throw new ApiError(507, 'There is not enough free space for the copy.', 'insufficient-space'); } }; + await startLibraryMove({ api: short, activity, scope: scope(), notify: t => toasts.push(t), navigate: () => {} }); + assert.equal(toasts.at(-1), 'There is not enough free space for the copy.'); + const broken = { post: async () => { throw new TypeError('network'); } }; + await startLibraryMove({ api: broken, activity, scope: scope(), notify: t => toasts.push(t), navigate: () => {} }); + assert.equal(toasts.at(-1), START_FAILED); + const gone = scope(); + gone.abort(); + const before = toasts.length; + await startLibraryMove({ api: broken, activity, scope: gone, notify: t => toasts.push(t), navigate: () => {} }); + assert.equal(toasts.length, before); +}); + +test('Keep it here posts the choice and resolves the new location, or null with a toast', async () => { + const { keepLibrary, KEEP_URL, KEEP_FAILED } = await locationModule; + const posts = []; + const kept = { ...NODE_FOLDER, keep: true, offer: false }; + const api = { post: async (url, body) => { posts.push([url, body]); return { location: kept }; } }; + assert.deepEqual(await keepLibrary({ api, scope: scope() }), kept); + assert.deepEqual(posts, [[KEEP_URL, { keep: true }]]); + const toasts = []; + const failing = { post: async () => { throw new ApiError(500, 'no', 'config-write-failed'); } }; + assert.equal(await keepLibrary({ api: failing, scope: scope(), notify: t => toasts.push(t) }), null); + assert.deepEqual(toasts, [KEEP_FAILED]); + const odd = { post: async () => ({ location: { path: 1 } }) }; + assert.equal(await keepLibrary({ api: odd, scope: scope() }), null); +}); + +test('the end of a Move library job is reported once; other jobs are ignored', async () => { + const { onMoveEnded } = await locationModule; + const activity = activityStore(); + let ended = 0; + const unsubscribe = onMoveEnded(activity, () => { ended += 1; }); + activity.set([{ id: 'm', kind: 'library_move', state: 'running' }, { id: 's', kind: 'scan', state: 'running' }]); + assert.equal(ended, 0); + activity.set([{ id: 'm', kind: 'library_move', state: 'running' }, { id: 's', kind: 'scan', state: 'finished' }]); + assert.equal(ended, 0); + activity.set([{ id: 'm', kind: 'library_move', state: 'failed' }]); + assert.equal(ended, 1); + activity.set([{ id: 'm', kind: 'library_move', state: 'failed' }]); + assert.equal(ended, 1); + unsubscribe(); + activity.set([{ id: 'n', kind: 'library_move', state: 'running' }]); + activity.set([{ id: 'n', kind: 'library_move', state: 'finished' }]); + assert.equal(ended, 1); +}); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 9942d6bb..49f91a58 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -335,6 +335,12 @@ "functions": 98, "statements": 98 }, + "workspace/library-location.mjs": { + "lines": 94, + "branches": 91, + "functions": 92, + "statements": 94 + }, "workspace/library-state.mjs": { "lines": 98, "branches": 95, diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 219b026f..c730f24b 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -18,6 +18,7 @@ import { mountInspect } from './inspect.mjs'; import { mountReview } from './review.mjs'; import { createPromptSender } from './prompt-sender.mjs'; import { mountSystem } from './system.mjs'; +import { mountLibraryBanner } from './library-location.mjs'; const { createApiClient, resolveUrl } = window.ApiClient; @@ -75,6 +76,8 @@ function mountWorkspace() { const activity = createActivityStore({ api, scope, connect: url => new EventSource(resolveUrl(url)) }); mountActivityTopbar({ store: activity, scope }); activity.load(); + // D129: a node-folder library is offered a move (or "Keep it here") above every page. + mountLibraryBanner(document.getElementById('library-banner'), { api, activity, scope }); const sidebar = document.querySelector('aside.sidebar'); // The route key a nav link carries in `data-route`: a saved view's is `saved/`. const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` : route.id; diff --git a/web/js/workspace/library-location.mjs b/web/js/workspace/library-location.mjs new file mode 100644 index 00000000..4be47a9b --- /dev/null +++ b/web/js/workspace/library-location.mjs @@ -0,0 +1,241 @@ +/** + * Where the library lives (#249 System S6b, D129): the workspace banner, System › Overview's "Library location" row, + * the Move library review and its start. + * + * - The server says where the database is and why (`GET /prompt_manager/system/library` and the Overview's + * `location`): `default` (ComfyUI's user folder), `environment` (PROMPT_MANAGER_DATA_DIR), `config` + * (config.json's database path) or `node_folder`, where an update or reinstall can delete it. + * - D129: while a node-folder library is not kept there, the shell shows the concept's `.notice` (`h2` + `p` + + * `.row-actions`) above every page: "Keep it here" saves the choice on the server (the banner stops; the Overview row + * stays), "Move library" opens the review. The concept has no such notice; its markup and classes are the Sources + * notice's (app.js 680). + * - The review is the shared record-changing review (`openReviewDialog`): From, To, Size, what moves with it and what + * happens to the old files. Move library starts the server's exclusive "Move library" job, tracks it in Activity and + * opens its view; a refusal (another job running, no room, a library already there) says why and nothing starts. + * - When a Move library job ends, the banner and the Overview read the location again. + */ +import { createButton } from '../shared/toolkit/core.js'; +import { busyToast } from './activity-scan.mjs'; +import { closeWorkspaceDialog, openContentDialog, openReviewDialog } from './workspace-dialogs.mjs'; +import { navigateTo } from './state.mjs'; +import { showWorkspaceToast } from './toast.mjs'; + +export const LIBRARY_URL = '/prompt_manager/system/library'; +export const MOVE_URL = `${LIBRARY_URL}/move`; +export const KEEP_URL = `${LIBRARY_URL}/keep`; +export const MOVE_KIND = 'library_move'; + +export const BANNER_TITLE = 'Your library is inside the node folder'; +export const BANNER_TEXT = 'Your library is stored inside the node folder, where an update or reinstall can delete it. ' + + 'Move it to ComfyUI\'s user folder: it is copied and verified first, and nothing is deleted.'; +export const KEEP_LABEL = 'Keep it here'; +export const MOVE_LABEL = 'Move library'; +export const ROW_TITLE = 'Library location'; +export const START_FAILED = 'Move library could not start. Try again.'; +export const KEEP_FAILED = 'Your choice could not be saved. Try again.'; + +/** Why the library is where it is, by `source`. */ +export const SOURCE_TEXT = Object.freeze({ + default: 'ComfyUI\'s user folder, which updates and reinstalls of the node leave alone', + environment: 'The folder PROMPT_MANAGER_DATA_DIR names', + config: 'The database path set in config.json', + node_folder: 'The node folder, where an update or reinstall can delete it', +}); + +/** A `problem` code as a sentence. */ +export const PROBLEM_TEXT = Object.freeze({ + 'data-dir-not-absolute': 'PROMPT_MANAGER_DATA_DIR is not an absolute folder, so it was ignored.', + 'data-dir-served': 'PROMPT_MANAGER_DATA_DIR names a folder whose files are served over the web, so it was ignored.', + 'data-dir-empty': 'PROMPT_MANAGER_DATA_DIR has no library yet; Move library puts this one there.', + 'move-unfinished': 'An earlier move did not finish; this library is still the one in use.', +}); + +const number = value => Number(value).toLocaleString('en-US'); + +/** "1.6 GB" or "120.0 MB" or "512 KB". */ +export function sizeText(bytes) { + if (!Number.isFinite(bytes) || bytes < 0) return 'Unknown'; + if (bytes >= 2 ** 30) return `${(bytes / 2 ** 30).toLocaleString('en-US', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} GB`; + if (bytes >= 2 ** 20) return `${(bytes / 2 ** 20).toLocaleString('en-US', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} MB`; + return `${number(Math.ceil(bytes / 1024))} KB`; +} + +/** Whether `location` is a well-formed server answer. */ +export function isLocation(location) { + return Boolean(location) && typeof location === 'object' && typeof location.path === 'string' + && Object.hasOwn(SOURCE_TEXT, location.source) && typeof location.movable === 'boolean'; +} + +/** The Overview row's description: the path, why it is there, the user's choice and any problem. */ +export function locationDescription(location) { + const parts = [`${location.path} · ${SOURCE_TEXT[location.source]}.`]; + if (location.movable && location.keep) parts.push('You chose to keep it here.'); + const problem = PROBLEM_TEXT[location.problem]; + if (problem) parts.push(problem); + return parts.join(' '); +} + +/** The review's `[term, value]` rows. */ +export function reviewGrid(location) { + return [ + ['From', location.path], + ['To', `${location.target}/prompts.db`], + ['Size', sizeText(location.size_bytes)], + ['Moves with it', 'config.json and the database backups'], + ['Old files', 'Renamed *.moved-to-…, never deleted'], + ]; +} + +export const REVIEW_SENTENCE = 'The copy is verified before Prompt Manager switches to it. No other job can run until it ' + + 'finishes, and closing this page does not stop it.'; + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +/** + * Start the Move library job, follow it in Activity and open its view. A busy server toasts the D57 link to the running + * job; any other refusal says the server's reason. Nothing is said once `scope` ended. + */ +export async function startLibraryMove({ api, activity, scope, notify = showWorkspaceToast, navigate = navigateTo }) { + try { + const { job } = await api.post(MOVE_URL, {}, { signal: scope.signal }); + if (scope.signal.aborted) return null; + const tracked = activity.track(job); + navigate(`#/activity/${tracked.id}`); + return tracked; + } catch (error) { + if (scope.signal.aborted) return null; + if (error?.status === 409 && error?.code === 'busy') { + await activity.load(); + if (!scope.signal.aborted) notify(busyToast(activity.running()[0] ?? null)); + return null; + } + notify(typeof error?.code === 'string' && error.message ? error.message : START_FAILED); + return null; + } +} + +/** The Move library review over `location`; Move library starts the job. */ +export function openMoveReview({ api, activity, scope, location, trigger = null, notify = showWorkspaceToast, + navigate = navigateTo }) { + return openReviewDialog({ scope, trigger, title: 'Move library', grid: reviewGrid(location), sentence: REVIEW_SENTENCE, + cancelLabel: 'Cancel', primary: { label: MOVE_LABEL }, + onStart: () => { startLibraryMove({ api, activity, scope, notify, navigate }); } }); +} + +/** Save "Keep it here" (`keep` true) or take it back; resolves the new location, or null when it was not saved. */ +export async function keepLibrary({ api, scope, keep = true, notify = showWorkspaceToast }) { + try { + const { location } = await api.post(KEEP_URL, { keep }, { signal: scope.signal }); + return isLocation(location) ? location : null; + } catch (error) { + if (!scope.signal.aborted) notify(KEEP_FAILED); + return null; + } +} + +/** The details of a library that does not move: where it is, why, and what lives beside it. */ +export function openLocationDetails({ scope, location, trigger = null }) { + const list = el('dl', undefined, 'meta-grid'); + for (const [term, value] of [['Database', location.path], ['Why here', SOURCE_TEXT[location.source]], + ['Beside it', 'config.json, the database backups and the logs'], ['Size', sizeText(location.size_bytes)]]) { + const entry = el('div'); + entry.append(el('dt', term), el('dd', value)); + list.append(entry); + } + const close = createButton('Close'); + scope.listen(close, 'click', () => closeWorkspaceDialog()); + const actions = el('div', undefined, 'dialog-actions'); + actions.append(close); + const nodes = [list]; + const problem = PROBLEM_TEXT[location.problem]; + if (problem) nodes.push(el('p', problem, 'small')); + nodes.push(actions); + openContentDialog({ scope, title: ROW_TITLE, trigger, nodes }); +} + +/** D129: the Overview's "Library location" `.operation` row; Move library when it can move, else Details. */ +export function locationRow(location, { api, activity, scope, notify = showWorkspaceToast, navigate = navigateTo }) { + const row = el('div', undefined, 'operation'); + const text = el('div'); + text.append(el('h3', ROW_TITLE), el('p', locationDescription(location))); + const actions = el('div', undefined, 'row-actions'); + const action = createButton(location.movable ? MOVE_LABEL : 'Details'); + scope.listen(action, 'click', () => { + if (location.movable) openMoveReview({ api, activity, scope, location, trigger: action, notify, navigate }); + else openLocationDetails({ scope, location, trigger: action }); + }); + actions.append(action); + row.append(text, actions); + return row; +} + +/** D129: the concept's `.notice` for a node-folder library: Keep it here and Move library. */ +export function libraryNotice({ scope, onKeep, onMove }) { + const notice = el('div', undefined, 'notice'); + const text = el('div'); + text.append(el('h2', BANNER_TITLE), el('p', BANNER_TEXT)); + const keep = createButton(KEEP_LABEL); + const move = createButton(MOVE_LABEL, 'ca-primary'); + scope.listen(keep, 'click', () => onKeep(keep)); + scope.listen(move, 'click', () => onMove(move)); + const actions = el('div', undefined, 'row-actions'); + actions.append(keep, move); + notice.append(text, actions); + return notice; +} + +/** Calls `onEnded()` whenever a Move library job this page sees stops running. Returns the unsubscribe. */ +export function onMoveEnded(activity, onEnded) { + const running = new Set(); + return activity.subscribe(() => { + let ended = false; + for (const job of activity.jobs()) { + if (job.kind !== MOVE_KIND) continue; + if (job.state === 'running') running.add(job.id); + else if (running.delete(job.id)) ended = true; + } + if (ended) onEnded(); + }); +} + +/** + * Mount the banner in `container` (hidden until the server offers the move). It reads the location once, again when a + * Move library job ends, and hides for good this page once the user keeps the library where it is. + */ +export function mountLibraryBanner(container, { api, activity, scope, notify = showWorkspaceToast, navigate = navigateTo, + latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) { + const read = latestOnly({ lifetime: scope.signal }); + const show = location => { + container.replaceChildren(); + container.hidden = !(isLocation(location) && location.offer === true); + if (container.hidden) return; + container.append(libraryNotice({ scope, + onKeep: async button => { + button.setAttribute('aria-disabled', 'true'); + const saved = await keepLibrary({ api, scope, notify }); + if (scope.signal.aborted) return; + button.removeAttribute('aria-disabled'); + if (saved) show(saved); + }, + onMove: button => openMoveReview({ api, activity, scope, location, trigger: button, notify, navigate }) })); + }; + async function load() { + try { + const answer = await read(signal => api.get(LIBRARY_URL, { signal, cache: 'no-store' })); + if (answer === STALE || scope.signal.aborted) return; + show(answer.location); + } catch { + // The banner is advisory: an unreadable location shows nothing (the Overview row reports it). + if (!scope.signal.aborted) show(null); + } + } + container.hidden = true; + scope.cleanup(onMoveEnded(activity, load)); + load(); + return { reload: load }; +} diff --git a/web/js/workspace/system-overview.mjs b/web/js/workspace/system-overview.mjs index 8178be2f..70c50638 100644 --- a/web/js/workspace/system-overview.mjs +++ b/web/js/workspace/system-overview.mjs @@ -22,6 +22,7 @@ import { showWorkspaceToast } from './toast.mjs'; import { noun } from './plural.mjs'; import { operationRow } from './system-controls.mjs'; import { openThumbnailReview } from './thumbnail-review.mjs'; +import { isLocation, locationRow, onMoveEnded } from './library-location.mjs'; export const OVERVIEW_URL = '/prompt_manager/system/overview'; export const STATISTICS_URL = '/prompt_manager/system/statistics'; @@ -319,7 +320,7 @@ function surface(heading, rows) { } /** The Overview's nodes, and its thumbnail row (painted by its own read). */ -function overviewNodes(overview, { scope, navigate, openStatistics, openThumbnails }) { +function overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics, openThumbnails }) { const go = section => () => navigate(`#/system/${section}`); const review = operationRow({ scope, title: ambiguousTitle(overview.needs_review), description: Number.isSafeInteger(overview.needs_review?.pending) @@ -332,6 +333,9 @@ function overviewNodes(overview, { scope, navigate, openStatistics, openThumbnai review, operationRow({ scope, title: 'Backup status', description: backupText(overview.backup), label: 'View backups', onClick: go('backups') }), + // D129: where the library lives and why; Move library when it is in the node folder. + ...(isLocation(overview.location) + ? [locationRow(overview.location, { api, activity, scope, notify, navigate })] : []), // D99: Review thumbnails opens the thumbnail dialog (the concept's row starts a sample job directly). thumbnails, ]); @@ -394,7 +398,7 @@ export function mountOverview(container, { api, activity, scope, navigate = navi try { const overview = await overviewRead(signal => api.get(OVERVIEW_URL, { signal, cache: 'no-store' })); if (overview === STALE || scope.signal.aborted) return; - const { nodes, thumbnails } = overviewNodes(overview, { scope, navigate, openStatistics, + const { nodes, thumbnails } = overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics, openThumbnails: trigger => openThumbnailReview({ api, store: activity, scope, trigger, navigate }) }); thumbnailRow = thumbnails; sources = overview.sources; @@ -427,6 +431,8 @@ export function mountOverview(container, { api, activity, scope, navigate = navi paintThumbnails(); } const first = swap(loading()); + // D129: a finished Move library changes where the library lives. + scope.cleanup(onMoveEnded(activity, load)); load(); loadThumbnails(); return first; diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 7c3a5aff..7d4b06b8 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -414,3 +414,9 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc field) beside their label, as the concept's own radio rows (s1160 `.compare-item label`). */ .field[role="radiogroup"] > label { display: flex; align-items: center; gap: 8px; /* D90 */ } .field[role="radiogroup"] input[type="radio"] { width: auto; margin: 0; /* D90 */ } +/* D129: the library-location notice sits above every page, in main's own padding and width, so its `.notice` lines + up with the page below; main's top padding then spaces it from the page. */ +.library-banner { max-width: 1900px; margin: auto; padding: 30px 30px 0; /* D129 */ } +.library-banner > .notice { margin-bottom: 0; /* D129 */ } +@media (max-width: 1250px) { .library-banner { padding: 24px 24px 0; /* D129 */ } } +@media (max-width: 700px) { .library-banner { padding: 20px 14px 0; /* D129 */ } } diff --git a/web/workspace.html b/web/workspace.html index e912c2c9..c0532f66 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -40,6 +40,8 @@ ComfyUI + +