feat(lora): the import review names the LoraManager settings it read (#355)

The review gains a first row, LoraManager ("<source> settings · <mode>
storage · library <name>", the settings file as its hover title), and its
Found row counts metadata files, LoRAs and LoRAs with local images. A plan
whose LoraManager settings cannot be used takes the close-only path:
"LoraManager settings: not usable: <reason>. Nothing was changed." The
browser fixture writes each LoRA's model file and can leave out the local
preview, so the download, gate and cancel specs still exercise CivitAI.
This commit is contained in:
Vito Sansevero
2026-10-26 17:54:48 -07:00
parent 145b105b82
commit 316f8673b3
6 changed files with 113 additions and 24 deletions
@@ -270,7 +270,8 @@ function mapImportConcept(reference, plan) {
title: 'Review LoRA import',
tree: ['h3', 'dl.meta-grid', 'details', 'p.small', 'div.notice', 'div.dialog-actions'],
heading: { ...reference.heading, text: 'LoRA reference data' },
grid: { ...reference.grid, rows: [['Scope', plan.scope.label], ['Found', '2 metadata files · 2 readable'],
grid: { ...reference.grid, rows: [['LoraManager', 'environment settings · alongside storage · library default'],
['Scope', plan.scope.label], ['Found', '2 metadata files · 2 LoRAs · 0 with local images'],
['Protected', 'Nothing is protected'], ['Network', 'Downloads up to 2 example images from CivitAI'],
['Backup', `Create and verify before changes (${plan.backup.destination_label})`],
['Media on disk', 'No files are removed; image links are replaced']] },
@@ -312,7 +313,7 @@ async function productionReview(page, request, theme) {
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`LoRA import review matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
await fakeLoraManager(request, { loras: 2, civitai: true });
await fakeLoraManager(request, { loras: 2, civitai: true, preview: false });
const concept = await conceptReview(page, theme);
expect(concept.tree).toEqual(['p.demo-note', 'h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions']);
await page.screenshot({ path: testInfo.outputPath(`concept-plan-review-${theme}-${width}.png`) });
@@ -324,15 +325,15 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
test('LoRA import review comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await fakeLoraManager(request, { loras: 2, civitai: true });
await fakeLoraManager(request, { loras: 2, civitai: true, preview: false });
const concept = await conceptReview(page, 'dark');
const { plan } = await productionReview(page, request, 'dark');
const expected = mapImportConcept(concept, plan);
const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path);
expect(await paths()).toEqual([]);
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[3].textContent = 'Downloads'; });
expect(await paths()).toEqual(['grid.rows.3.0']);
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[3].textContent = 'Network'; });
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[4].textContent = 'Downloads'; });
expect(await paths()).toEqual(['grid.rows.4.0']);
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[4].textContent = 'Network'; });
const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' });
expect(await paths()).toEqual(expect.arrayContaining(['grid.columnGap']));
await undoGrid();
@@ -339,13 +339,17 @@ test('Review import shows replacement, blocked items and network effects with th
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('LoRA reference data');
expect(previews).toEqual([{}]);
expect(await gridOf(page)).toEqual([
['LoraManager', 'environment settings · alongside storage · library default'],
['Scope', 'Replace 2 imported prompts with 3 LoRAs'],
['Found', '3 metadata files · 3 readable'],
['Found', '3 metadata files · 3 LoRAs · 0 with local images'],
['Protected', 'Nothing is protected'],
['Network', 'Downloads up to 3 example images from CivitAI'],
['Backup', 'Create and verify before changes (browser.db.backup_<date and time>)'],
['Media on disk', 'No files are removed; image links are replaced'],
]);
// #355: the settings file the server read is the LoraManager row's hover title.
await expect(dialog(page).locator('dl.meta-grid > div').first().locator('dd'))
.toHaveAttribute('title', /lora-manager-settings[\/\\]settings\.json$/);
await expect(dialog(page).locator('details > summary')).toHaveText('LoRAs found (3)');
await expect(dialog(page).locator('.notice h3')).toHaveText('Back up the database first (recommended)');
await expect(dialog(page).locator('p.small').last()).toContainText('stay in the image cache');
@@ -382,7 +386,7 @@ test('saved pairs block the import: the review explains it and offers only Close
await enable(page);
await layoutOf(page).getByRole('button', { name: 'Review import', exact: true }).click();
// gridOf reads once; poll until the review has rendered its rows.
await expect.poll(async () => (await gridOf(page))[2]).toEqual(['Protected', '1 imported prompt used by saved pairs']);
await expect.poll(async () => (await gridOf(page))[3]).toEqual(['Protected', '1 imported prompt used by saved pairs']);
await expect(dialog(page).locator('p.small')).toHaveText(
'Delete or change the saved pairs that use these prompts, then review the import again. Nothing was changed.');
expect(await dialog(page).locator('.dialog-actions button').allTextContents()).toEqual(['Close']);
@@ -390,6 +394,22 @@ test('saved pairs block the import: the review explains it and offers only Close
await expect(dialog(page)).toHaveCount(0);
});
test('unusable LoraManager settings: the review says why and offers only Close (#355)', async ({ page, request }) => {
await lora(request, { previous: 1, settings: 'invalid' });
await openIntegrations(page);
await enable(page);
const before = await serverJson(request, '/__test__/integrations/state');
await layoutOf(page).getByRole('button', { name: 'Review import', exact: true }).click();
await expect.poll(async () => (await gridOf(page))[0])
.toEqual(['LoraManager', 'Not usable: the settings file is not valid JSON']);
await expect(dialog(page).locator('p.small')).toHaveText(
'LoraManager settings: not usable: the settings file is not valid JSON. Nothing was changed.');
expect(await dialog(page).locator('.dialog-actions button').allTextContents()).toEqual(['Close']);
await dialog(page).getByRole('button', { name: 'Close', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
expect(await serverJson(request, '/__test__/integrations/state')).toEqual(before);
});
test('a failed import keeps old imports and its job says so', async ({ page, request }) => {
await lora(request, { loras: 2, previous: 2 });
await openIntegrations(page);
+31 -4
View File
@@ -108,13 +108,17 @@ test('host compatibility shows the detected renderer as status (D106)', async ()
test('the import review shows replacement, blocked items and network effects (D107)', async () => {
const { importGrid } = await integrations;
const plan = { lora_files: 3, with_metadata: 2, current_imports: 5, blocked_by_pairs: 0, planned_downloads: 4,
const plan = { lora_files: 2221, with_metadata: 615, with_local_media: 476, skipped: 1606, current_imports: 5,
blocked_by_pairs: 0, planned_downloads: 4,
layout: { source: 'portable', settings_file: '~/ComfyUI/custom_nodes/LM/settings.json', error: null, mode: 'centralized',
library: 'comfyui', example_images: true },
scope: { count: 2, label: 'Replace 5 imported prompts with 2 LoRAs' }, protected: { count: 0, reasons: [] },
backup: { policy: 'on', destination_label: 'prompts.db.backup_<date and time>' }, network: { planned_downloads: 4, api_key: true },
media: { deletes_files: false, label: 'No files are removed; image links are replaced' } };
assert.deepEqual(importGrid(plan), [
['LoraManager', 'portable settings · centralized storage · library comfyui'],
['Scope', 'Replace 5 imported prompts with 2 LoRAs'],
['Found', '3 metadata files · 2 readable'],
['Found', '2,221 metadata files · 615 LoRAs · 476 with local images'],
['Protected', 'Nothing is protected'],
['Network', 'Downloads up to 4 example images from CivitAI with your saved key'],
['Backup', 'Create and verify before changes (prompts.db.backup_<date and time>)'],
@@ -122,11 +126,34 @@ test('the import review shows replacement, blocked items and network effects (D1
]);
const blocked = importGrid({ ...plan, planned_downloads: 1, network: { api_key: false }, protected: { count: 2,
reasons: ['2 imported prompts used by saved pairs'] } });
assert.deepEqual(blocked[2], ['Protected', '2 imported prompts used by saved pairs']);
assert.deepEqual(blocked[3], ['Network', 'Downloads up to 1 example image from CivitAI']);
assert.deepEqual(blocked[3], ['Protected', '2 imported prompts used by saved pairs']);
assert.deepEqual(blocked[4], ['Network', 'Downloads up to 1 example image from CivitAI']);
// A plan without a layout (no LoraManager row) and with no counts.
assert.deepEqual(importGrid({})[0], ['Scope', '0 LoRAs']);
assert.deepEqual(importGrid({})[1], ['Found', '0 metadata files · 0 LoRAs · 0 with local images']);
assert.deepEqual(importGrid({})[3], ['Network', 'No downloads']);
assert.deepEqual(importGrid({})[5], ['Media on disk', 'No files are removed']);
assert.deepEqual(importGrid({ ...plan, lora_files: 1, with_metadata: 1, with_local_media: 0 })[2],
['Found', '1 metadata file · 1 LoRA · 0 with local images']);
});
test('the LoraManager row names what the import read, and an unusable layout closes the review (#355)', async () => {
const { layoutText, closeNote, LAYOUT_UNUSABLE, BLOCKED_NOTE, NOTHING_TO_IMPORT } = await integrations;
assert.equal(layoutText({ source: 'environment', mode: 'alongside', library: 'default', error: null }),
'environment settings · alongside storage · library default');
assert.equal(layoutText({ source: 'user config', mode: 'alongside', library: 'x', error: 'the settings file is not valid JSON' }),
'Not usable: the settings file is not valid JSON');
assert.equal(layoutText(null), null);
assert.equal(layoutText('nope'), null);
const error = 'the sidecar folder does not exist';
assert.equal(closeNote({ token: null, layout: { error } }),
'LoraManager settings: not usable: the sidecar folder does not exist. Nothing was changed.');
assert.equal(LAYOUT_UNUSABLE(error), closeNote({ token: null, layout: { error }, blocked_by_pairs: 2 }));
assert.equal(closeNote({ token: null, blocked_by_pairs: 1, layout: { error: null } }), BLOCKED_NOTE);
assert.equal(closeNote({ token: null }), NOTHING_TO_IMPORT);
assert.equal(closeNote({ token: 't', with_metadata: 0, current_imports: 0 }), NOTHING_TO_IMPORT);
assert.equal(closeNote({ token: 't', with_metadata: 1, current_imports: 0 }), null);
assert.equal(closeNote({ token: 't', with_metadata: 0, current_imports: 3 }), null);
});
test('a LoRA import job reads in Activity: tiles, outcomes and the kept collection on failure', async () => {
+5
View File
@@ -841,6 +841,11 @@ test('openReviewDialog lays out heading, grid, options, sentence, backup box and
[['dt', 'dd'], 'Scope', '2 stale image links'], [['dt', 'dd'], 'Media on disk', 'No source files removed']]);
review.setValue('Scope', '3 stale image links');
assert.equal(grid.children[0].children[1].textContent, '3 stale image links');
// #355: a row's hover title (the LoRA review's settings file); an unknown term changes nothing.
review.setTitle('Media on disk', '~/settings.json');
review.setTitle('Absent', 'x');
review.setValue('Absent', 'x');
assert.equal(grid.children[1].children[1].title, '~/settings.json');
const actions = byId(dom, 'modal-body').children.at(-1);
assert.deepEqual(actions.children.map(node => [node.textContent, node.className]),
[['Cancel', ''], ['Apply reviewed operation', 'ca-primary']]);
+43 -11
View File
@@ -16,6 +16,9 @@
* - "LoRA reference data" → "Review import" (D107; the concept's button only simulated a job): the server
* reviews the import (`POST /lora/import/preview`, read-only) and the shared review dialog shows what it replaces,
* what saved pairs block, what it downloads and the backup box; Start runs the "LoRA reference import" Activity job.
* #355: its first row, "LoraManager", names the settings source, storage mode and library the server read (the
* settings file is the row's hover title); "Found" counts metadata files, LoRAs and LoRAs with local images. When
* LoraManager's settings cannot be used, the plan has no token and the review says why and offers only Close.
*/
import { createButton } from '../shared/toolkit/core.js';
import { createBridgeClient } from '../shared/toolkit/prompt-bridge.js';
@@ -55,6 +58,9 @@ export const KEY_COPY = Object.freeze({ saved: 'A key is saved.', none: 'No key
export const NOTHING_TO_IMPORT = 'There is nothing to import; nothing was changed.';
export const BLOCKED_NOTE = 'Delete or change the saved pairs that use these prompts, then review the import again. Nothing was changed.';
export const CACHE_NOTE = 'Example images downloaded before a failure stay in the image cache.';
export const LAYOUT_TERM = 'LoraManager';
/** #355: the close-only note for LoraManager settings the server cannot use. */
export const LAYOUT_UNUSABLE = reason => `LoraManager settings: not usable: ${reason}. Nothing was changed.`;
const number = value => value.toLocaleString('en-US');
@@ -175,17 +181,38 @@ export function keyText(values, changes) {
return values.lora_has_key ? KEY_COPY.saved : KEY_COPY.none;
}
/** #355: the "LoraManager" row: which settings, storage mode and library the server read; null without a layout. */
export function layoutText(layout) {
if (!layout || typeof layout !== 'object') return null;
if (layout.error) return `Not usable: ${layout.error}`;
return `${layout.source} settings · ${layout.mode} storage · library ${layout.library}`;
}
/** The note of a review that offers only Close, or null when the plan can start. */
export function closeNote(plan) {
if (!plan.token) {
if (plan.layout?.error) return LAYOUT_UNUSABLE(plan.layout.error);
return plan.blocked_by_pairs ? BLOCKED_NOTE : NOTHING_TO_IMPORT;
}
return !plan.with_metadata && !plan.current_imports ? NOTHING_TO_IMPORT : null;
}
/** D107: the import review's rows from the server's plan. */
export function importGrid(plan) {
const downloads = Number.isSafeInteger(plan.planned_downloads) ? plan.planned_downloads : 0;
const network = downloads
? `Downloads up to ${number(downloads)} example ${noun(downloads, 'image')} from CivitAI${plan.network?.api_key ? ' with your saved key' : ''}`
: 'No downloads';
const found = Number.isSafeInteger(plan.lora_files) ? plan.lora_files : 0;
const readable = Number.isSafeInteger(plan.with_metadata) ? plan.with_metadata : 0;
const count = value => (Number.isSafeInteger(value) ? value : 0);
const found = count(plan.lora_files);
const readable = count(plan.with_metadata);
const local = count(plan.with_local_media);
const layout = layoutText(plan.layout);
return [
...(layout ? [[LAYOUT_TERM, layout]] : []),
['Scope', plan.scope?.label || `${number(readable)} ${noun(readable, 'LoRA')}`],
['Found', `${number(found)} metadata ${noun(found, 'file')} · ${number(readable)} readable`],
['Found', `${number(found)} metadata ${noun(found, 'file')} · ${number(readable)} ${noun(readable, 'LoRA')} · `
+ `${number(local)} with local images`],
['Protected', protectedText(plan.protected)],
['Network', network],
['Backup', backupText(plan.backup)],
@@ -205,11 +232,18 @@ function namesList(plan) {
return [list];
}
/** The settings file (home shown as `~`) as the LoraManager row's hover title. */
function settingsTitle(dd, plan) {
if (typeof plan.layout?.settings_file === 'string') dd.title = plan.layout.settings_file;
}
function closeOnly({ scope, trigger, plan, note }) {
const grid = el('dl', undefined, 'meta-grid');
for (const [term, value] of importGrid(plan)) {
const entry = el('div');
entry.append(el('dt', term), el('dd', value));
const dd = el('dd', value);
if (term === LAYOUT_TERM) settingsTitle(dd, plan);
entry.append(el('dt', term), dd);
grid.append(entry);
}
const close = createButton('Close');
@@ -223,21 +257,19 @@ function closeOnly({ scope, trigger, plan, note }) {
/** Show the reviewed import; Start applies exactly this token (D107). */
export function showImportPlan(plan, { api, store, scope, trigger = null, reviewAgain, notify = showWorkspaceToast,
navigate = navigateTo, backup = () => downloadDatabaseBackup() }) {
if (!plan.token) {
closeOnly({ scope, trigger, plan, note: plan.blocked_by_pairs ? BLOCKED_NOTE : NOTHING_TO_IMPORT });
return;
}
if (!plan.with_metadata && !plan.current_imports) {
closeOnly({ scope, trigger, plan, note: NOTHING_TO_IMPORT });
const note = closeNote(plan);
if (note) {
closeOnly({ scope, trigger, plan, note });
return;
}
const removesAll = !plan.with_metadata;
const sentence = [planNote(plan), removesAll ? 'No LoRA metadata was found: this removes every imported LoRA prompt.' : '', CACHE_NOTE]
.filter(Boolean).join(' ');
openReviewDialog({ scope, trigger, title: IMPORT_TITLE, heading: 'LoRA reference data', grid: importGrid(plan),
const review = openReviewDialog({ scope, trigger, title: IMPORT_TITLE, heading: 'LoRA reference data', grid: importGrid(plan),
options: namesList(plan), sentence, backup: { copy: BACKUP_COPY, onBackup: backup }, cancelLabel: 'Cancel',
primary: { label: IMPORT_START, danger: removesAll },
onStart: () => applyPlan({ api, store, scope, token: plan.token, reviewAgain, notify, navigate, url: IMPORT_APPLY_URL }) });
if (typeof plan.layout?.settings_file === 'string') review.setTitle(LAYOUT_TERM, plan.layout.settings_file);
}
/** Review the import (read-only) and show its plan; the pressed button reads "Reviewing…" until it answered. */
+5 -1
View File
@@ -429,7 +429,7 @@ function requireGrid(grid) {
* primary (`ca-primary`, or the concept's `danger` when `primary.danger`). The primary closes the dialog, then calls
* `onStart()`; `onDismiss()` runs once when the dialog closes any other way (Cancel, ×, Escape, a replacing dialog, the
* page leaving), so a staged review is discarded (S9). Returns `{setValue(term, text)}` for a grid value that follows
* an option.
* an option, and `{setTitle(term, text)}` for a value's hover title.
*/
export function openReviewDialog({ scope, title, heading = null, grid, options = [], sentence = null, backup = null,
cancelLabel, primary, onStart, trigger = null, onDismiss = null }) {
@@ -475,6 +475,10 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
const dd = values.get(term);
if (dd) dd.textContent = text;
},
setTitle(term, text) {
const dd = values.get(term);
if (dd) dd.title = text;
},
};
}