feat(system): show where the library lives and offer the move

D129. System > Overview's Library health gains a "Library location" row
(the database path, why it is there, the "Keep it here" choice and any
PROMPT_MANAGER_DATA_DIR problem) with Move library for a node-folder
library and Details otherwise. While a node-folder library is not kept
there, the workspace shows the concept's .notice above every page:
"Keep it here" saves the choice on the server and stops the notice;
"Move library" opens the shared review (From, To, Size, what moves, what
happens to the old files) and starts the exclusive job in Activity. The
notice and the Overview read the location again when the job ends.

The Overview comparison maps the inserted row; a new comparison holds the
notice to the concept's Sources notice (markup, tokens, alignment, both
themes and widths) with a mutation check.
This commit is contained in:
Vito Sansevero
2026-10-24 07:53:18 -07:00
parent edf7811454
commit ea23d5161d
10 changed files with 657 additions and 6 deletions
+104 -4
View File
@@ -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']);
});
+91
View File
@@ -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);
+51
View File
@@ -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
+145
View File
@@ -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);
});