* fix(lora): read LoraManager's settings and storage layout the way it does (#355) Add py/lora_manager_layout.py: the settings file in LoraManager's lookup order (environment folder, portable plugin file, user config folder), only the seven keys the import needs, the LoRA roots, the centralized sidecar root, the example-images root and the active library, each folder through one guard. Sidecar discovery for both storage modes and the local preview and example-image lookup live there too. The test runner and the browser test server point LoraManager's settings lookup at a disposable folder, so no test reads the real settings. * fix(lora): import the sidecars and local images LoraManager really uses (#355) The import, the legacy scan, the trigger-word cache and the image server now share LoraManager's layout: sidecars beside the models (alongside) or under the sidecar root (centralized), kept only when their model exists inside a LoRA root. Each LoRA links its LoraManager preview and example images where they are; CivitAI downloads run only for a LoRA without any local image. The review adds skipped, with_local_media and the layout, and offers no token when LoraManager's settings cannot be used; the legacy scan reports the same reason without replacing anything. find_lora_directories becomes default_lora_roots (no scan of the LoraManager install); the preview and example-folder helpers it replaced are removed. The trigger cache never raises into a running node. * fix(lora): never serve a LoraManager folder that holds the home folder (#355) The folder guard also rejects ancestors of the home folder (/home, C:\Users), compared in the realpath + normcase form, so a LoraManager setting cannot widen the image server's allow-list to every user's files. * fix(lora): show linked LoraManager videos as videos (#355) _enrich_images marks every image without is_video by its extension, so an .mp4 preview outside the output folders shows the Video preview label and plays in the inspector. Tests cover serving the sidecar and example-image folders only while the integration is on, and a filesystem root as the sidecar folder adding nothing. * 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. * docs(lora): describe how the import finds LoraManager's files (#355) * test(lora): set the settings folder relative to the process folder (#355) os.path.relpath fails across Windows drives (the temp folder on C:, the checkout on D:); the test now changes into its temp folder instead. * fix(lora): review findings for the LoraManager layout (#355) - The example-images library subfolder goes through the folder guard like its root, so its canonical (normcased) form matches the media check on Windows for a library name with capitals. - A failed trigger-word load is retried by a running node only after TRIGGER_RETRY_SECONDS, not on every prompt run. - A review whose discovery cannot list a folder says so (folder-unreadable, the folder's name only) instead of a generic error. * test(images): hold the listing clock in the deferred-walk test test_a_deferred_walk_waits_at_least_half_a_second set the previous walk 29.99 s back against a 30 s interval and then read the real clock again: a runner slower than 10 ms between the two lines passed the interval and the walk started at once (failed on macOS 3.13 and Windows 3.11). The module's clock is now held for the mark, so the deferred walk is always the one measured. * test(lora): write the unkept-settings fixture as raw text CodeQL read the dict with a credential-named key flowing into write_text as clear-text storage of a secret. The value is a marker, so the fixture now writes the JSON text directly and names the value MARKER. * fix(lora): link each LoRA's cover as its newest image (#355) Workspace lists show a prompt's media newest link first, so the import links a record's images last to first: the card shows the cover (a still in front of a leading video) the import put first. * fix(lora): show LoRA-linked media on the Library page and in the inspector (#355) Library previews, the positive detail and the per-prompt images page list a prompt's linked media under the output folders plus, while the LoRA integration is on, LoraManager's guarded folders and the download cache (_media_roots; output root indices keep their meaning). That media keeps its rooted identity, so Info and the media viewer open it; the path-based serve route answers for those roots only with a file the database records (either path spelling), never serves or makes a preview there, and output requests never read LoraManager's layout. Such media is marked linked_only: no preview file, no favorite star, no Tag action. Show in folder accepts those roots; the download cache is named, not created. * fix(lora): expose only linked files under LoraManager's folders (#355) Serve, inspect and Show in folder share one gate for a LoRA-linked root (_media_roots past the output folders): a file the database does not record as a linked image is refused with 403, checked by its given and its real path. Before, inspect and reveal read any file under those folders by path. * fix(lora): check the file Show in folder would open under a LoRA root (#355) reveal_image resolved the requested path through the client-path resolver, which can fall back to a path anchor; under a LoRA-linked root the file that would open now passes the same recorded-media gate, so only a linked file is ever revealed there.
483 lines
31 KiB
JavaScript
483 lines
31 KiB
JavaScript
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||
import { test, expect } from './fixtures.mjs';
|
||
import { seedConcept } from './concept-inventory.mjs';
|
||
|
||
// System › Integrations (#249 System S10 Task 27) on the concept-shaped fixture with a fake LoraManager install
|
||
// (workspace_integrations_fixture.py): the D106 ComfyUI rows and renderer status, the LoRA rows and the D114 key, saved
|
||
// through the one Save row, and the D107 import review run as an Activity job. No test reaches the network: CivitAI
|
||
// downloads are answered by the fixture's stub opener.
|
||
const workspace = '/prompt_manager/workspace';
|
||
const PREVIEW_URL = '/prompt_manager/lora/import/preview';
|
||
const APPLY_URL = '/prompt_manager/lora/import/apply';
|
||
const ENABLE_URL = '/prompt_manager/lora/enable';
|
||
const KEPT = 'The import failed; the previous LoRA imports were kept. Images already downloaded stay in the image cache.';
|
||
|
||
test.beforeEach(async ({ page, request, browserName }) => {
|
||
await startCoverage(page, browserName);
|
||
await seedConcept(request);
|
||
await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||
await expect(await request.post('/__test__/integrations/reset')).toBeOK();
|
||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||
page.systemErrors = [];
|
||
page.on('pageerror', error => page.systemErrors.push(error.message));
|
||
});
|
||
|
||
test.afterEach(async ({ page, request, browserName }) => {
|
||
await saveCoverage(page, browserName);
|
||
await request.post('/__test__/integrations/gate', { data: { hold: false } });
|
||
await request.post('/__test__/release');
|
||
await request.post('/__test__/jobs/restart');
|
||
await request.post('/__test__/integrations/reset');
|
||
await request.post('/__test__/system/reset');
|
||
expect(page.systemErrors).toEqual([]);
|
||
});
|
||
|
||
const toast = page => page.locator('#workspace-toast');
|
||
const layoutOf = page => page.locator('#workspace-main .settings-layout');
|
||
const dialog = page => page.locator('dialog#modal[open]');
|
||
const status = page => page.locator('#settings-status');
|
||
const save = page => page.getByRole('button', { name: 'Save preferences', exact: true });
|
||
const settingOf = (page, label) => layoutOf(page).locator('.setting-row').filter({ has: page.locator(`label:text-is("${label}")`) });
|
||
|
||
async function serverJson(request, url) {
|
||
const response = await request.get(url);
|
||
await expect(response).toBeOK();
|
||
return response.json();
|
||
}
|
||
|
||
async function lora(request, data = {}) {
|
||
await expect(await request.post('/__test__/integrations/lora-manager', { data })).toBeOK();
|
||
}
|
||
|
||
async function openIntegrations(page) {
|
||
// A fresh page each time: the drafts live for the page session (Q10), and a hash-only goto would not reload.
|
||
if (page.url().endsWith('#/system/integrations')) await page.reload();
|
||
else await page.goto(`${workspace}#/system/integrations`);
|
||
await expect(layoutOf(page).locator(':scope > section.surface')).toHaveCount(2);
|
||
// The LoRA rows are painted once the server's LoRA state was read.
|
||
await expect(settingOf(page, 'LoraManager integration').locator('input')).toBeEnabled();
|
||
// The renderer line settles first: its new text moves the rows below, so no click lands mid-shift.
|
||
await expect(settingOf(page, 'Host compatibility').getByRole('status')).not.toHaveText('Looking for open ComfyUI windows…');
|
||
return layoutOf(page);
|
||
}
|
||
|
||
/** Enable the integration (and optionally its trigger words) through the Save row. */
|
||
async function enable(page, { triggers = false } = {}) {
|
||
await settingOf(page, 'LoraManager integration').locator('input').check();
|
||
if (triggers) await settingOf(page, 'Automatically include trigger words').locator('input').check();
|
||
await save(page).click();
|
||
await expect(status(page)).toHaveText('Saved to the server configuration.');
|
||
}
|
||
|
||
/** The open review's `[dt, dd]` rows. */
|
||
const gridOf = page => dialog(page).locator('dl.meta-grid > div').evaluateAll(items => items.map(item =>
|
||
[item.querySelector('dt').textContent, item.querySelector('dd').textContent]));
|
||
|
||
async function ended(request, id) {
|
||
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).not.toBe('running');
|
||
return (await serverJson(request, `/prompt_manager/jobs/${id}`)).job;
|
||
}
|
||
|
||
/** A second page of this origin that serves a fake ComfyUI window on the send bridge. */
|
||
async function fakeComfyWindow(context, renderer) {
|
||
const host = await context.newPage();
|
||
await host.goto(`${workspace}#/overview`);
|
||
await host.evaluate(async drawn => {
|
||
const { createBridgeHost } = await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||
window.fakeHost = createBridgeHost({ namespace: '/', origin: location.origin, label: 'PromptManager — ComfyUI',
|
||
adapter: { listTargets: () => (drawn ? [{ id: '7', label: 'Prompt Manager', type: 'PromptManager', roles: ['positive'],
|
||
renderer: drawn }] : []), preview() { throw new Error('unused'); }, apply() { throw new Error('unused'); } } });
|
||
}, renderer);
|
||
return host;
|
||
}
|
||
|
||
test('send destination offers Ask each time; unsupported options disabled with the reason (D106)', async ({ page }) => {
|
||
const layout = await openIntegrations(page);
|
||
expect(await layout.locator(':scope > section.surface > h2').allTextContents()).toEqual(['ComfyUI', 'LoRA & resources']);
|
||
const send = settingOf(page, 'Default send destination');
|
||
expect(await send.locator('option').evaluateAll(options => options.map(option => [option.textContent, option.disabled])))
|
||
.toEqual([['Ask each time', false], ['Selected Prompt Manager node', true], ['Create a Prompt Manager (+/-) node', true]]);
|
||
await expect(send.locator('select')).toHaveValue('Ask each time');
|
||
await expect(send.locator('p.small')).toHaveText(
|
||
'Sending always asks for the ComfyUI window and node, so “Ask each time” is the only option.');
|
||
const renderer = settingOf(page, 'Host compatibility');
|
||
expect(await renderer.locator('option').evaluateAll(options => options.map(option => [option.textContent, option.disabled])))
|
||
.toEqual([['Follow host renderer', false], ['Classic', true], ['Nodes 2.0', true]]);
|
||
await expect(renderer.locator('p.small').first()).toHaveText('Prompt Manager nodes always follow ComfyUI’s own renderer setting.');
|
||
const back = settingOf(page, 'Return to canvas after send');
|
||
await expect(back.locator('input')).toBeDisabled();
|
||
await expect(back.locator('input')).not.toBeChecked();
|
||
await expect(back.locator('p.small')).toHaveText('Not available: sending applies the text in ComfyUI and keeps this page open.');
|
||
// Nothing on this surface is ever drafted: the Save row stays clean.
|
||
await expect(status(page)).toHaveText('No unsaved changes.');
|
||
await expect(save(page)).toHaveAttribute('aria-disabled', 'true');
|
||
});
|
||
|
||
test('host compatibility shows the detected renderer as status (D106)', async ({ page, context }) => {
|
||
await openIntegrations(page);
|
||
const line = settingOf(page, 'Host compatibility').getByRole('status');
|
||
await expect(line).toHaveText('No ComfyUI window is open in this browser, so its renderer is not known.');
|
||
const host = await fakeComfyWindow(context, 'nodes2');
|
||
try {
|
||
await page.reload();
|
||
await expect(line).toHaveText('Detected: Nodes 2.0 (1 ComfyUI window).');
|
||
await host.evaluate(() => window.fakeHost.dispose());
|
||
await host.evaluate(async () => {
|
||
const { createBridgeHost } = await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||
window.fakeHost = createBridgeHost({ namespace: '/', origin: location.origin, label: 'PromptManager — ComfyUI',
|
||
adapter: { listTargets: () => [], preview() {}, apply() {} } });
|
||
});
|
||
await page.reload();
|
||
await expect(line).toHaveText('ComfyUI is open; add a Prompt Manager node to see which renderer it uses.');
|
||
} finally {
|
||
await host.close();
|
||
}
|
||
});
|
||
|
||
test('LoRA rows show detected / enabled / cache-loaded separately', async ({ page, request }) => {
|
||
await openIntegrations(page);
|
||
const integration = settingOf(page, 'LoraManager integration');
|
||
const triggers = settingOf(page, 'Automatically include trigger words');
|
||
await expect(integration.locator('p').first()).toHaveText('Not detected in ComfyUI’s custom_nodes folders.');
|
||
await expect(integration.locator('input')).not.toBeChecked();
|
||
await expect(triggers.locator('p.small')).toHaveText('Trigger words are not loaded.');
|
||
// Enabling what is not installed is refused by the server: the toggle stays highlighted, nothing was saved.
|
||
await integration.locator('input').check();
|
||
await save(page).click();
|
||
await expect(status(page)).toHaveText('Some changes were not saved. Review the highlighted settings.');
|
||
await expect(integration.locator('input')).toHaveAttribute('aria-invalid', 'true');
|
||
expect((await serverJson(request, '/__test__/integrations/state')).enabled).toBe(false);
|
||
|
||
await lora(request);
|
||
await openIntegrations(page);
|
||
await expect(integration.locator('p').first()).toHaveText('Detected: custom_nodes/ComfyUI-Lora-Manager.');
|
||
await expect(integration.locator('input')).not.toBeChecked();
|
||
await enable(page, { triggers: true });
|
||
await expect(integration.locator('input')).toBeChecked();
|
||
await expect(triggers.locator('p.small')).toHaveText('Trigger words are loaded.');
|
||
expect(await serverJson(request, '/__test__/integrations/state')).toMatchObject({ enabled: true, trigger_words: true });
|
||
// A reload reads the saved state back.
|
||
await openIntegrations(page);
|
||
await expect(integration.locator('input')).toBeChecked();
|
||
await expect(triggers.locator('input')).toBeChecked();
|
||
await expect(status(page)).toHaveText('No unsaved changes.');
|
||
});
|
||
|
||
test('LoraManager path row shows the detected path read-only, as legacy (D106)', async ({ page, request }) => {
|
||
await openIntegrations(page);
|
||
const row = settingOf(page, 'LoraManager path');
|
||
const input = row.locator('input');
|
||
await expect(input).toHaveAttribute('readonly', '');
|
||
await expect(input).toHaveValue('');
|
||
await expect(input).toHaveAttribute('placeholder', 'Not detected');
|
||
await lora(request);
|
||
await openIntegrations(page);
|
||
await expect(input).toHaveValue('custom_nodes/ComfyUI-Lora-Manager');
|
||
await input.focus();
|
||
await page.keyboard.type('elsewhere');
|
||
await expect(input).toHaveValue('custom_nodes/ComfyUI-Lora-Manager');
|
||
await expect(status(page)).toHaveText('No unsaved changes.');
|
||
});
|
||
|
||
test('key never prefilled; blank keeps; Clear saved key clears; saved through Save preferences (D114)', async ({ page, request }) => {
|
||
const enables = [];
|
||
page.on('request', item => { if (item.url().endsWith(ENABLE_URL)) enables.push(item.postDataJSON()); });
|
||
await lora(request);
|
||
await openIntegrations(page);
|
||
const key = settingOf(page, 'CivitAI API key');
|
||
const field = key.locator('input[type="password"]');
|
||
const clear = key.getByRole('button', { name: 'Clear saved key', exact: true });
|
||
await expect(field).toHaveValue('');
|
||
await expect(key.locator('p.small')).toHaveText('No key saved.');
|
||
await expect(clear).toHaveCount(0);
|
||
// A typed key is dropped when the section is left: the field comes back empty with nothing hidden to save (N4).
|
||
await field.fill('sk-dropped');
|
||
await expect(status(page)).toHaveText('Unsaved changes.');
|
||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
|
||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Integrations', exact: true }).click();
|
||
await expect(status(page)).toHaveText('No unsaved changes.');
|
||
await expect(key.locator('p.small')).toHaveText('No key saved.');
|
||
// Typing a key drafts it; Save sends it once through /lora/enable with the settings revision.
|
||
await field.fill('sk-test-key');
|
||
await expect(key.locator('p.small')).toHaveText('The new key replaces the saved one when you save.');
|
||
await expect(status(page)).toHaveText('Unsaved changes.');
|
||
await save(page).click();
|
||
await expect(status(page)).toHaveText('Saved to the server configuration.');
|
||
expect(enables).toHaveLength(1);
|
||
expect(enables[0]).toMatchObject({ civitai_api_key: 'sk-test-key', enabled: false });
|
||
expect(enables[0].expected_revision).toMatch(/^(sha256:|absent)/);
|
||
await expect(field).toHaveValue('');
|
||
await expect(key.locator('p.small')).toHaveText('A key is saved.');
|
||
expect((await serverJson(request, '/__test__/integrations/state')).key_saved).toBe(true);
|
||
// Never prefilled, even after a reload; the page never receives the key.
|
||
const statusBodies = [];
|
||
page.on('response', async response => {
|
||
if (response.url().endsWith('/prompt_manager/lora/status')) statusBodies.push(await response.text());
|
||
});
|
||
await openIntegrations(page);
|
||
await expect(field).toHaveValue('');
|
||
await expect(key.locator('p.small')).toHaveText('A key is saved.');
|
||
expect(statusBodies.join('')).not.toContain('sk-test-key');
|
||
// The announced line is rewritten only when its text changes, not on every edit elsewhere (S10 re-review N8).
|
||
await page.evaluate(() => {
|
||
window.__keyNoteWrites = 0;
|
||
new MutationObserver(records => { window.__keyNoteWrites += records.length; })
|
||
.observe(document.querySelector('#civitai-key-status'), { childList: true, characterData: true, subtree: true });
|
||
});
|
||
const triggers = settingOf(page, 'Automatically include trigger words').locator('input');
|
||
await triggers.check();
|
||
await expect(status(page)).toHaveText('Unsaved changes.');
|
||
await triggers.uncheck();
|
||
await expect(status(page)).toHaveText('No unsaved changes.');
|
||
expect(await page.evaluate(() => window.__keyNoteWrites)).toBe(0);
|
||
// A blank field keeps the saved key: nothing to save.
|
||
await field.fill(' ');
|
||
await expect(status(page)).toHaveText('No unsaved changes.');
|
||
await field.fill('');
|
||
// Clear saved key drafts the removal; Save removes it. The keyboard stays on the key field, and the line that
|
||
// says so is announced (S10 review M2).
|
||
await expect(key.locator('p.small')).toHaveAttribute('role', 'status');
|
||
await expect(field).toHaveAttribute('aria-describedby', 'civitai-key-status');
|
||
await clear.focus();
|
||
await page.keyboard.press('Enter');
|
||
await expect(key.locator('p.small')).toHaveText('The saved key is removed when you save.');
|
||
await expect(clear).toHaveCount(0);
|
||
await expect(field).toBeFocused();
|
||
await save(page).click();
|
||
await expect(status(page)).toHaveText('Saved to the server configuration.');
|
||
expect(enables.at(-1)).toMatchObject({ clear_civitai_api_key: true });
|
||
expect(enables.at(-1)).not.toHaveProperty('civitai_api_key');
|
||
await expect(key.locator('p.small')).toHaveText('No key saved.');
|
||
expect((await serverJson(request, '/__test__/integrations/state')).key_saved).toBe(false);
|
||
});
|
||
|
||
test('a LoRA save over settings changed elsewhere is a conflict, never a blind write', async ({ page, request }) => {
|
||
await lora(request);
|
||
await openIntegrations(page);
|
||
await settingOf(page, 'Automatically include trigger words').locator('input').check();
|
||
// Another page saves the settings meanwhile: the revision this page read is stale.
|
||
await expect(await request.post('/prompt_manager/settings', { data: { result_timeout: 42 } })).toBeOK();
|
||
await save(page).click();
|
||
await expect(status(page)).toHaveText('Settings changed elsewhere. Reload this section to see them.');
|
||
expect((await serverJson(request, '/__test__/integrations/state')).trigger_words).toBe(false);
|
||
// The draft stays; the next Save carries the reread revision and succeeds.
|
||
await save(page).click();
|
||
await expect(status(page)).toHaveText('Saved to the server configuration.');
|
||
expect((await serverJson(request, '/__test__/integrations/state')).trigger_words).toBe(true);
|
||
});
|
||
|
||
test('a save where only the LoRA part is written says so; the next save needs no conflict (S10 review I3)',
|
||
async ({ page, request }) => {
|
||
await lora(request);
|
||
await expect(await request.post('/__test__/system/missing-source', { data: { inside: true } })).toBeOK();
|
||
await openIntegrations(page);
|
||
const triggers = settingOf(page, 'Automatically include trigger words').locator('input');
|
||
await triggers.check();
|
||
// In Sources, the drive is unplugged after the page read the list, and another source is removed.
|
||
const nav = page.locator('#workspace-main .section-nav');
|
||
await nav.getByRole('button', { name: 'Sources & scans', exact: true }).click();
|
||
const sourceRow = title => layoutOf(page).locator('#source-list .operation').filter({ has: page.locator('h3', { hasText: title }) });
|
||
await expect(sourceRow('unplugged')).toBeVisible();
|
||
await expect(await request.post('/__test__/system/unplug')).toBeOK();
|
||
await sourceRow('ComfyUI input').getByRole('button', { name: 'Remove', exact: true }).click();
|
||
await save(page).click();
|
||
// The LoRA part was saved, the sources were refused: the status gives the server's reason for the source, and
|
||
// only the source row is highlighted.
|
||
await expect(status(page)).toHaveText("output/unplugged can't be used: Gallery root does not exist. Remove it or reconnect it, then save.");
|
||
await expect(sourceRow('unplugged')).toHaveAttribute('aria-invalid', 'true');
|
||
expect((await serverJson(request, '/__test__/integrations/state')).trigger_words).toBe(true);
|
||
await nav.getByRole('button', { name: 'Integrations', exact: true }).click();
|
||
await expect(triggers).toBeChecked();
|
||
await expect(triggers).not.toHaveAttribute('aria-invalid', 'true');
|
||
// The next save carries the revision the saved part left: it saves, with no "changed elsewhere".
|
||
await nav.getByRole('button', { name: 'Sources & scans', exact: true }).click();
|
||
await sourceRow('unplugged').getByRole('button', { name: 'Remove', exact: true }).click();
|
||
await save(page).click();
|
||
await expect(status(page)).toHaveText('Saved to the server configuration.');
|
||
expect((await serverJson(request, '/prompt_manager/settings')).sources.configured.map(source => source.path)).toEqual(['output']);
|
||
});
|
||
|
||
test('a partial save made from Integrations keeps its outcome when Sources opens (S10 re-review R1)',
|
||
async ({ page, request }) => {
|
||
await lora(request);
|
||
await expect(await request.post('/__test__/system/missing-source', { data: { inside: true } })).toBeOK();
|
||
await openIntegrations(page);
|
||
const nav = page.locator('#workspace-main .section-nav');
|
||
const sourceRow = title => layoutOf(page).locator('#source-list .operation').filter({ has: page.locator('h3', { hasText: title }) });
|
||
await nav.getByRole('button', { name: 'Sources & scans', exact: true }).click();
|
||
await expect(sourceRow('unplugged')).toBeVisible();
|
||
await expect(await request.post('/__test__/system/unplug')).toBeOK();
|
||
await sourceRow('ComfyUI input').getByRole('button', { name: 'Remove', exact: true }).click();
|
||
// Back in Integrations, a LoRA change, then Save from there.
|
||
await nav.getByRole('button', { name: 'Integrations', exact: true }).click();
|
||
await settingOf(page, 'Automatically include trigger words').locator('input').check();
|
||
await save(page).click();
|
||
const reason = "output/unplugged can't be used: Gallery root does not exist. Remove it or reconnect it, then save.";
|
||
await expect(status(page)).toHaveText(reason);
|
||
// Leaving Integrations (no key was typed) keeps the outcome: Sources shows the reason on the refused row.
|
||
await nav.getByRole('button', { name: 'Sources & scans', exact: true }).click();
|
||
await expect(sourceRow('unplugged')).toHaveAttribute('aria-invalid', 'true');
|
||
await expect(status(page)).toHaveText(reason);
|
||
});
|
||
|
||
test('Review import shows replacement, blocked items and network effects with the backup box; Start opens the job (D107)',
|
||
async ({ page, request }) => {
|
||
await lora(request, { loras: 3, civitai: true, preview: false, previous: 2 });
|
||
const previews = [];
|
||
const applies = [];
|
||
page.on('request', item => {
|
||
if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON());
|
||
if (item.url().endsWith(APPLY_URL)) applies.push(item.postDataJSON());
|
||
});
|
||
await openIntegrations(page);
|
||
await enable(page);
|
||
const before = await serverJson(request, '/__test__/integrations/state');
|
||
const review = layoutOf(page).locator('.operation').filter({ hasText: 'LoRA reference data' })
|
||
.getByRole('button', { name: 'Review import', exact: true });
|
||
await review.click();
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Review LoRA import');
|
||
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 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');
|
||
// The review wrote and downloaded nothing; Cancel neither.
|
||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||
await expect(dialog(page)).toHaveCount(0);
|
||
await expect(review).toBeFocused();
|
||
expect(await serverJson(request, '/__test__/integrations/state')).toEqual(before);
|
||
|
||
await review.click();
|
||
await dialog(page).getByRole('button', { name: 'Start import', exact: true }).click();
|
||
await expect(dialog(page)).toHaveCount(0);
|
||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||
await expect(toast(page)).toHaveText(/LoRA reference import started\.|LoRA import finished · 3 imported · 0 errors/);
|
||
expect(applies).toHaveLength(1);
|
||
expect(Object.keys(applies[0])).toEqual(['token']);
|
||
const job = await ended(request, page.url().split('/').pop());
|
||
expect([job.kind, job.title, job.state]).toEqual(['lora_import', 'LoRA reference import', 'finished']);
|
||
expect(job.counters).toMatchObject({ imported: 3, downloaded: 3, download_errors: 0, errors: 0 });
|
||
const main = page.locator('#workspace-main');
|
||
await expect(main.locator('h1')).toHaveText('LoRA reference import');
|
||
await expect(main.locator('.stat-row .stat dt')).toHaveText(['Imported', 'Linked to existing', 'Skipped', 'Images downloaded',
|
||
'Download errors', 'Errors']);
|
||
await expect(main.getByRole('button', { name: 'Cancel import', exact: true })).toHaveCount(0);
|
||
const after = await serverJson(request, '/__test__/integrations/state');
|
||
expect(after.imports).toEqual(['style_01 example prompt', 'style_02 example prompt', 'style_03 example prompt']);
|
||
expect(after.downloads).toBe(3);
|
||
});
|
||
|
||
test('saved pairs block the import: the review explains it and offers only Close', async ({ page, request }) => {
|
||
await lora(request, { previous: 1 });
|
||
await expect(await request.post('/__test__/integrations/pair')).toBeOK();
|
||
await openIntegrations(page);
|
||
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))[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']);
|
||
await dialog(page).getByRole('button', { name: 'Close', exact: true }).click();
|
||
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('imported LoRA previews show on the Library card and open in the inspector and viewer (#355 G6)', async ({ page, request }) => {
|
||
await lora(request, { loras: 1 });
|
||
await openIntegrations(page);
|
||
await enable(page);
|
||
await layoutOf(page).getByRole('button', { name: 'Review import', exact: true }).click();
|
||
await dialog(page).getByRole('button', { name: 'Start import', exact: true }).click();
|
||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||
expect((await ended(request, page.url().split('/').pop())).state).toBe('finished');
|
||
await page.goto(`${workspace}#/library`);
|
||
const card = page.locator('#workspace-main article.prompt-card').filter({ hasText: 'Style 01' });
|
||
const cover = card.locator('.card-art img');
|
||
await expect(cover).toHaveAttribute('src', /\/prompt_manager\/images\/serve\/style_01\.png\?root=\d+&root_scope=[0-9a-f]{64}$/);
|
||
await expect.poll(() => cover.evaluate(node => node.complete && node.naturalWidth)).toBeGreaterThan(0);
|
||
await expect(card.locator('.card-art')).not.toContainText('No available image previews');
|
||
// The prompt's gallery: Info inspects the file and the viewer opens it; no Tag action or star for LoRA media.
|
||
await card.getByRole('button', { name: 'Explore history', exact: true }).click();
|
||
const item = page.getByRole('region', { name: 'Shared gallery', exact: true }).locator('article[data-image-key]').first();
|
||
await expect(item.getByRole('button', { name: 'Tag or auto-tag style_01.png', exact: true })).toHaveCount(0);
|
||
await expect(item.locator('.star-button')).toHaveCount(0);
|
||
await item.getByRole('button', { name: 'Inspect style_01.png', exact: true }).click();
|
||
const inspector = page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||
await expect(inspector).toContainText('style_01.png');
|
||
await item.getByRole('button', { name: 'Open style_01.png', exact: true }).click();
|
||
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
|
||
await expect.poll(() => page.locator('dialog[open] .viewer-canvas img').evaluate(node => node.complete && node.naturalWidth))
|
||
.toBeGreaterThan(0);
|
||
await page.keyboard.press('Escape');
|
||
});
|
||
|
||
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);
|
||
await enable(page);
|
||
const before = await serverJson(request, '/__test__/integrations/state');
|
||
await expect(await request.post('/__test__/integrations/fail-import')).toBeOK();
|
||
await layoutOf(page).getByRole('button', { name: 'Review import', exact: true }).click();
|
||
await dialog(page).getByRole('button', { name: 'Start import', exact: true }).click();
|
||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||
await expect(toast(page)).toHaveText(`LoRA import failed · ${KEPT}`);
|
||
const job = await ended(request, page.url().split('/').pop());
|
||
expect([job.state, job.scope.failure, job.counters.errors]).toEqual(['failed', 'import-failed', 1]);
|
||
await expect(page.locator('#workspace-main')).toContainText(KEPT);
|
||
expect((await serverJson(request, '/__test__/integrations/state')).imports).toEqual(before.imports);
|
||
expect(before.imports).toEqual(['previous lora import 1', 'previous lora import 2']);
|
||
});
|
||
|
||
test('the import can be cancelled while it downloads; then nothing is written', async ({ page, request }) => {
|
||
await lora(request, { loras: 2, civitai: true, preview: false, previous: 1 });
|
||
await openIntegrations(page);
|
||
await enable(page);
|
||
const before = await serverJson(request, '/__test__/integrations/state');
|
||
await expect(await request.post('/__test__/integrations/gate', { data: { hold: true } })).toBeOK();
|
||
await layoutOf(page).getByRole('button', { name: 'Review import', exact: true }).click();
|
||
await dialog(page).getByRole('button', { name: 'Start import', exact: true }).click();
|
||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||
const id = page.url().split('/').pop();
|
||
await expect.poll(async () => (await serverJson(request, '/__test__/integrations/state')).downloads).toBeGreaterThan(0);
|
||
const cancel = page.locator('#workspace-main').getByRole('button', { name: 'Cancel import', exact: true });
|
||
await expect(cancel).toBeVisible();
|
||
// The cancel must have reached the server before the held download goes on (S10 review M1).
|
||
await Promise.all([
|
||
page.waitForResponse(response => /\/prompt_manager\/jobs\/[0-9a-f]+\/cancel$/.test(response.url()) && response.ok()),
|
||
cancel.click(),
|
||
]);
|
||
await expect(await request.post('/__test__/integrations/gate', { data: { hold: false } })).toBeOK();
|
||
const job = await ended(request, id);
|
||
expect(job.state).toBe('cancelled');
|
||
expect((await serverJson(request, '/__test__/integrations/state')).imports).toEqual(before.imports);
|
||
await expect(cancel).toHaveCount(0);
|
||
});
|