mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(settings): directory picker and live validation for path settings
Add a reusable directory-picker modal backed by a new generic POST /api/lm/browse-directory endpoint (browse logic extracted from the recipe batch-import handler into py/utils/directory_browser.py) and wire a browse button plus advisory validate-path feedback (POST /api/lm/validate-path) into the settings path inputs: recipes path, example images path/local root, and the extra-folder/model-path rows. The browse button insets into the right edge of static inputs so narrow settings rows keep their single-control layout. Translations for the new settings.directoryPicker and settings.pathValidation keys are filled in for all 9 locales.
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: (key, params = {}, fallback = null) => fallback ?? key,
|
||||
}));
|
||||
|
||||
import { directoryPickerModal } from '../../../static/js/components/DirectoryPickerModal.js';
|
||||
|
||||
function buildModalDom() {
|
||||
document.body.innerHTML = `
|
||||
<div id="directoryPickerModal" class="modal directory-picker-modal" style="display: none;">
|
||||
<div class="modal-content directory-picker-content">
|
||||
<button class="close" id="directoryPickerCloseBtn">×</button>
|
||||
<h3>Select folder</h3>
|
||||
<div class="directory-picker-path-row">
|
||||
<input type="text" id="directoryPickerPathInput">
|
||||
<button id="directoryPickerGoBtn">Go</button>
|
||||
</div>
|
||||
<div class="directory-browser" id="directoryPickerBrowser">
|
||||
<div class="browser-header">
|
||||
<button class="back-btn" id="directoryPickerUpBtn"></button>
|
||||
<div class="current-path" id="directoryPickerCurrentPath"></div>
|
||||
</div>
|
||||
<div class="browser-content">
|
||||
<div class="folder-list" id="directoryPickerFolderList"></div>
|
||||
<div class="directory-picker-error" id="directoryPickerError" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="browser-footer">
|
||||
<button class="primary-btn" id="directoryPickerSelectBtn">Select</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function okResponse(payload) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true, ...payload }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('DirectoryPickerModal', () => {
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
buildModalDom();
|
||||
document.body.classList.remove('modal-open');
|
||||
|
||||
fetchMock = vi.fn(async () => okResponse({
|
||||
current_path: '/home/user',
|
||||
parent_path: '/home',
|
||||
directories: [
|
||||
{ name: 'photos', path: '/home/user/photos', is_parent: false },
|
||||
{ name: 'models', path: '/home/user/models', is_parent: false },
|
||||
],
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
directoryPickerModal.close();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function lastRequestBody() {
|
||||
return JSON.parse(fetchMock.mock.calls.at(-1)[1].body);
|
||||
}
|
||||
|
||||
function modalEl() {
|
||||
return document.getElementById('directoryPickerModal');
|
||||
}
|
||||
|
||||
it('open() loads the initial path via POST /api/lm/browse-directory', async () => {
|
||||
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/lm/browse-directory');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(lastRequestBody().path).toBe('/home/user');
|
||||
expect(modalEl().style.display).toBe('block');
|
||||
expect(document.body.classList.contains('modal-open')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the folder list and current path', async () => {
|
||||
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
|
||||
});
|
||||
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
|
||||
const names = [...document.querySelectorAll('#directoryPickerFolderList .item-name')].map((el) => el.textContent);
|
||||
expect(names).toEqual(['photos', 'models']);
|
||||
});
|
||||
|
||||
it('drills down on folder click using the server-provided entry path', async () => {
|
||||
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
|
||||
});
|
||||
fetchMock.mockClear();
|
||||
|
||||
document.querySelectorAll('#directoryPickerFolderList .folder-item')[0].click();
|
||||
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
expect(lastRequestBody().path).toBe('/home/user/photos');
|
||||
});
|
||||
|
||||
it('drills down from a Windows path using the server-provided entry path', async () => {
|
||||
fetchMock.mockImplementation(async () => okResponse({
|
||||
current_path: 'C:\\Users\\miao',
|
||||
parent_path: 'C:\\Users',
|
||||
directories: [
|
||||
{ name: 'models', path: 'C:\\Users\\miao\\models', is_parent: false },
|
||||
],
|
||||
}));
|
||||
directoryPickerModal.open({ initialPath: 'C:\\Users\\miao', onSelect: vi.fn() });
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(1);
|
||||
});
|
||||
fetchMock.mockClear();
|
||||
|
||||
document.querySelector('#directoryPickerFolderList .folder-item').click();
|
||||
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
expect(lastRequestBody().path).toBe('C:\\Users\\miao\\models');
|
||||
});
|
||||
|
||||
it('navigates up via the server-provided parent_path', async () => {
|
||||
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||
await vi.waitFor(() => {
|
||||
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
|
||||
});
|
||||
fetchMock.mockClear();
|
||||
|
||||
document.getElementById('directoryPickerUpBtn').click();
|
||||
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
expect(lastRequestBody().path).toBe('/home');
|
||||
});
|
||||
|
||||
it('disables the Up button when parent_path is null', async () => {
|
||||
fetchMock.mockImplementation(async () => okResponse({
|
||||
current_path: '/',
|
||||
parent_path: null,
|
||||
directories: [],
|
||||
}));
|
||||
directoryPickerModal.open({ initialPath: '/', onSelect: vi.fn() });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/');
|
||||
});
|
||||
const upBtn = document.getElementById('directoryPickerUpBtn');
|
||||
expect(upBtn.disabled).toBe(true);
|
||||
fetchMock.mockClear();
|
||||
|
||||
upBtn.click();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an empty-folder message for a directory without subfolders', async () => {
|
||||
fetchMock.mockImplementation(async () => okResponse({
|
||||
current_path: '/home/user/empty',
|
||||
parent_path: '/home/user',
|
||||
directories: [],
|
||||
}));
|
||||
directoryPickerModal.open({ initialPath: '/home/user/empty', onSelect: vi.fn() });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('#directoryPickerFolderList .directory-picker-empty')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSelect with current_path and closes on Select', async () => {
|
||||
const onSelect = vi.fn();
|
||||
directoryPickerModal.open({ initialPath: '/home/user', onSelect });
|
||||
await vi.waitFor(() => {
|
||||
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
|
||||
});
|
||||
|
||||
document.getElementById('directoryPickerSelectBtn').click();
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('/home/user');
|
||||
expect(modalEl().style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('shows the backend error message and keeps the previous listing', async () => {
|
||||
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
|
||||
});
|
||||
|
||||
fetchMock.mockImplementation(async () => ({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ success: false, error: 'Directory not found' }),
|
||||
}));
|
||||
|
||||
await directoryPickerModal.loadDirectory('/gone');
|
||||
|
||||
const errorEl = document.getElementById('directoryPickerError');
|
||||
expect(errorEl.textContent).toBe('Directory not found');
|
||||
expect(errorEl.style.display).toBe('block');
|
||||
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
|
||||
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('closes on ESC and stops propagation to modals underneath', async () => {
|
||||
const underlyingEscSpy = vi.fn();
|
||||
document.addEventListener('keydown', underlyingEscSpy);
|
||||
|
||||
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
|
||||
document.getElementById('directoryPickerPathInput').dispatchEvent(event);
|
||||
|
||||
expect(modalEl().style.display).toBe('none');
|
||||
expect(underlyingEscSpy).not.toHaveBeenCalled();
|
||||
// The settings modal's body lock must survive the picker closing.
|
||||
expect(document.body.classList.contains('modal-open')).toBe(true);
|
||||
|
||||
document.removeEventListener('keydown', underlyingEscSpy);
|
||||
});
|
||||
|
||||
it('loads the typed path on Go click and on Enter', async () => {
|
||||
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
fetchMock.mockClear();
|
||||
|
||||
const input = document.getElementById('directoryPickerPathInput');
|
||||
input.value = '/var/models';
|
||||
document.getElementById('directoryPickerGoBtn').click();
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
expect(lastRequestBody().path).toBe('/var/models');
|
||||
|
||||
fetchMock.mockClear();
|
||||
input.value = '/tmp/other';
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
expect(lastRequestBody().path).toBe('/tmp/other');
|
||||
});
|
||||
|
||||
it('closes on backdrop click but not on content click', async () => {
|
||||
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
|
||||
modalEl().querySelector('.directory-picker-content').click();
|
||||
expect(modalEl().style.display).toBe('block');
|
||||
|
||||
modalEl().click();
|
||||
expect(modalEl().style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,433 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||
modalManager: {
|
||||
closeModal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
state: {
|
||||
global: {
|
||||
settings: {},
|
||||
},
|
||||
loadingManager: {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
},
|
||||
},
|
||||
createDefaultSettings: () => ({
|
||||
language: 'en',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
resetAndReload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/constants.js', () => ({
|
||||
DOWNLOAD_PATH_TEMPLATES: {},
|
||||
DEFAULT_PATH_TEMPLATES: {},
|
||||
MAPPABLE_BASE_MODELS: [],
|
||||
PATH_TEMPLATE_PLACEHOLDERS: {},
|
||||
DEFAULT_PRIORITY_TAG_CONFIG: {},
|
||||
getMappableBaseModelsDynamic: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: (_key, _params, fallback) => fallback ?? '',
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/i18n/index.js', () => ({
|
||||
i18n: {
|
||||
getCurrentLocale: () => 'en',
|
||||
setLanguage: vi.fn().mockResolvedValue(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
|
||||
configureModelCardVideo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/DirectoryPickerModal.js', () => ({
|
||||
directoryPickerModal: {
|
||||
open: vi.fn(),
|
||||
close: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
|
||||
import { directoryPickerModal } from '../../../static/js/components/DirectoryPickerModal.js';
|
||||
|
||||
const createManager = () => {
|
||||
const initSettingsSpy = vi
|
||||
.spyOn(SettingsManager.prototype, 'initializeSettings')
|
||||
.mockResolvedValue();
|
||||
const initializeSpy = vi
|
||||
.spyOn(SettingsManager.prototype, 'initialize')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const manager = new SettingsManager();
|
||||
|
||||
initSettingsSpy.mockRestore();
|
||||
initializeSpy.mockRestore();
|
||||
|
||||
return manager;
|
||||
};
|
||||
|
||||
const appendPathInput = (id = 'recipesPath') => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'text-input-wrapper';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = id;
|
||||
wrapper.appendChild(input);
|
||||
document.body.appendChild(wrapper);
|
||||
return input;
|
||||
};
|
||||
|
||||
const validResponse = (path) => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
path,
|
||||
exists: true,
|
||||
is_directory: true,
|
||||
readable: true,
|
||||
writable: true,
|
||||
error_code: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const invalidResponse = (errorCode) => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
path: '/missing',
|
||||
exists: false,
|
||||
is_directory: false,
|
||||
readable: false,
|
||||
writable: false,
|
||||
error_code: errorCode,
|
||||
error: `server: ${errorCode}`,
|
||||
}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
describe('SettingsManager.attachPathField', () => {
|
||||
it('keeps the input in its wrapper and injects an inset browse button and a status element', () => {
|
||||
const manager = createManager();
|
||||
const input = appendPathInput();
|
||||
|
||||
manager.attachPathField('recipesPath');
|
||||
|
||||
const wrapper = input.parentElement;
|
||||
expect(wrapper.classList.contains('text-input-wrapper')).toBe(true);
|
||||
const browseBtn = wrapper.querySelector('.browse-path-btn.inset');
|
||||
expect(browseBtn).not.toBeNull();
|
||||
expect(browseBtn.querySelector('i.fas.fa-folder-open')).not.toBeNull();
|
||||
expect(input.classList.contains('has-inset-browse')).toBe(true);
|
||||
expect(wrapper.querySelector('.path-validation')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('is idempotent — a second call does not duplicate the button', () => {
|
||||
const manager = createManager();
|
||||
const input = appendPathInput();
|
||||
|
||||
manager.attachPathField('recipesPath');
|
||||
manager.attachPathField('recipesPath');
|
||||
|
||||
expect(document.querySelectorAll('.browse-path-btn')).toHaveLength(1);
|
||||
expect(document.querySelectorAll('.path-validation')).toHaveLength(1);
|
||||
expect(input.dataset.pathFieldAttached).toBe('1');
|
||||
});
|
||||
|
||||
it('wraps only the input for insetting when inside .path-control, leaving siblings in place', () => {
|
||||
const manager = createManager();
|
||||
const container = document.createElement('div');
|
||||
container.className = 'setting-control path-control';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = 'exampleImagesPath';
|
||||
const downloadBtn = document.createElement('button');
|
||||
downloadBtn.id = 'exampleImagesDownloadBtn';
|
||||
container.appendChild(input);
|
||||
container.appendChild(downloadBtn);
|
||||
document.body.appendChild(container);
|
||||
|
||||
manager.attachPathField('exampleImagesPath');
|
||||
|
||||
const wrapper = input.parentElement;
|
||||
expect(wrapper.classList.contains('text-input-wrapper')).toBe(true);
|
||||
expect(wrapper.parentElement).toBe(container);
|
||||
const browseBtn = wrapper.querySelector('.browse-path-btn.inset');
|
||||
expect(browseBtn).not.toBeNull();
|
||||
expect(wrapper.nextElementSibling).toBe(downloadBtn);
|
||||
expect(container.querySelector('.path-validation')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('warns and no-ops when the input is missing', () => {
|
||||
const manager = createManager();
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
manager.attachPathField('doesNotExist');
|
||||
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsManager.validatePath', () => {
|
||||
it('posts to /api/lm/validate-path on blur with expect directory', async () => {
|
||||
const manager = createManager();
|
||||
const input = appendPathInput();
|
||||
input.value = '/data/recipes';
|
||||
global.fetch = vi.fn().mockResolvedValue(validResponse('/data/recipes'));
|
||||
|
||||
manager.attachPathField('recipesPath');
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
|
||||
|
||||
const [url, options] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe('/api/lm/validate-path');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ path: '/data/recipes', expect: 'directory' });
|
||||
});
|
||||
|
||||
it('debounces rapid input events into a single validation call', async () => {
|
||||
vi.useFakeTimers();
|
||||
const manager = createManager();
|
||||
const input = appendPathInput();
|
||||
global.fetch = vi.fn().mockResolvedValue(validResponse('/data'));
|
||||
|
||||
manager.attachPathField('recipesPath');
|
||||
input.value = '/d';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
input.value = '/da';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
input.value = '/data';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(499);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders a valid status for a valid path', async () => {
|
||||
const manager = createManager();
|
||||
const input = appendPathInput();
|
||||
input.value = '/data/recipes';
|
||||
global.fetch = vi.fn().mockResolvedValue(validResponse('/data/recipes'));
|
||||
|
||||
manager.attachPathField('recipesPath');
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('.path-validation').classList.contains('visible')).toBe(true);
|
||||
});
|
||||
|
||||
const statusEl = document.querySelector('.path-validation');
|
||||
expect(statusEl.classList.contains('valid')).toBe(true);
|
||||
expect(statusEl.textContent).toContain('Path is valid');
|
||||
expect(statusEl.querySelector('i.fas.fa-check-circle')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders an error status mapped from error_code', async () => {
|
||||
const manager = createManager();
|
||||
const input = appendPathInput();
|
||||
input.value = '/missing';
|
||||
global.fetch = vi.fn().mockResolvedValue(invalidResponse('path_not_found'));
|
||||
|
||||
manager.attachPathField('recipesPath');
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('.path-validation').classList.contains('visible')).toBe(true);
|
||||
});
|
||||
|
||||
const statusEl = document.querySelector('.path-validation');
|
||||
expect(statusEl.classList.contains('valid')).toBe(false);
|
||||
expect(statusEl.textContent).toContain('Path does not exist');
|
||||
});
|
||||
|
||||
it('ignores stale responses overtaken by a newer value', async () => {
|
||||
const manager = createManager();
|
||||
const input = appendPathInput();
|
||||
|
||||
const deferreds = [];
|
||||
global.fetch = vi.fn().mockImplementation(() => new Promise((resolve) => {
|
||||
deferreds.push(resolve);
|
||||
}));
|
||||
|
||||
manager.attachPathField('recipesPath');
|
||||
|
||||
input.value = '/old-path';
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
input.value = '/new-path';
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Newer request resolves first and renders valid status.
|
||||
deferreds[1](validResponse('/new-path'));
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('.path-validation').classList.contains('valid')).toBe(true);
|
||||
});
|
||||
|
||||
// Older request resolves late and must not overwrite the status.
|
||||
deferreds[0](invalidResponse('path_not_found'));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const statusEl = document.querySelector('.path-validation');
|
||||
expect(statusEl.classList.contains('valid')).toBe(true);
|
||||
expect(statusEl.textContent).toContain('Path is valid');
|
||||
});
|
||||
|
||||
it('clears the status and skips fetch when the value is empty', async () => {
|
||||
const manager = createManager();
|
||||
const input = appendPathInput();
|
||||
input.value = '/data';
|
||||
global.fetch = vi.fn().mockResolvedValue(validResponse('/data'));
|
||||
|
||||
manager.attachPathField('recipesPath');
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('.path-validation').classList.contains('visible')).toBe(true);
|
||||
});
|
||||
|
||||
global.fetch.mockClear();
|
||||
input.value = '';
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
await Promise.resolve();
|
||||
|
||||
const statusEl = document.querySelector('.path-validation');
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(statusEl.classList.contains('visible')).toBe(false);
|
||||
expect(statusEl.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('clears the status silently on network failure', async () => {
|
||||
const manager = createManager();
|
||||
const input = appendPathInput();
|
||||
input.value = '/data';
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('network down'));
|
||||
|
||||
manager.attachPathField('recipesPath');
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const statusEl = document.querySelector('.path-validation');
|
||||
expect(statusEl.classList.contains('visible')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsManager.browseForPath', () => {
|
||||
it('opens the picker with the current value and applies the selection', async () => {
|
||||
const manager = createManager();
|
||||
const input = appendPathInput();
|
||||
input.value = '/initial';
|
||||
const onAfterSelect = vi.fn();
|
||||
global.fetch = vi.fn().mockResolvedValue(validResponse('/picked'));
|
||||
|
||||
manager.attachPathField('recipesPath', { onAfterSelect });
|
||||
manager.browseForPath('recipesPath');
|
||||
|
||||
expect(directoryPickerModal.open).toHaveBeenCalledTimes(1);
|
||||
const openArgs = directoryPickerModal.open.mock.calls[0][0];
|
||||
expect(openArgs.initialPath).toBe('/initial');
|
||||
|
||||
openArgs.onSelect('/picked');
|
||||
|
||||
expect(input.value).toBe('/picked');
|
||||
expect(onAfterSelect).toHaveBeenCalledWith('/picked');
|
||||
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
|
||||
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
|
||||
path: '/picked',
|
||||
expect: 'directory',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsManager dynamic path rows', () => {
|
||||
const appendExtraFolderContainer = (modelType = 'loras') => {
|
||||
const container = document.createElement('div');
|
||||
container.id = `extraFolderPaths-${modelType}`;
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
};
|
||||
|
||||
it('renders a browse button in extra folder path rows', () => {
|
||||
const manager = createManager();
|
||||
appendExtraFolderContainer('loras');
|
||||
|
||||
manager.addExtraFolderPathRow('loras', '/models/loras', false);
|
||||
|
||||
const row = document.querySelector('.extra-folder-path-row');
|
||||
const browseBtn = row.querySelector('.browse-path-btn');
|
||||
expect(browseBtn).not.toBeNull();
|
||||
expect(browseBtn.querySelector('i.fas.fa-folder-open')).not.toBeNull();
|
||||
// Browse button sits before the remove button.
|
||||
expect(browseBtn.nextElementSibling.classList.contains('remove-path-btn')).toBe(true);
|
||||
});
|
||||
|
||||
it('picker selection routes through updateExtraFolderPaths', () => {
|
||||
const manager = createManager();
|
||||
appendExtraFolderContainer('loras');
|
||||
const updateSpy = vi
|
||||
.spyOn(manager, 'updateExtraFolderPaths')
|
||||
.mockResolvedValue();
|
||||
|
||||
manager.addExtraFolderPathRow('loras', '/models/loras', false);
|
||||
const row = document.querySelector('.extra-folder-path-row');
|
||||
const input = row.querySelector('.extra-folder-path-input');
|
||||
const browseBtn = row.querySelector('.browse-path-btn');
|
||||
|
||||
manager.browseForPathRow(browseBtn, 'loras');
|
||||
|
||||
expect(directoryPickerModal.open).toHaveBeenCalledTimes(1);
|
||||
const openArgs = directoryPickerModal.open.mock.calls[0][0];
|
||||
expect(openArgs.initialPath).toBe('/models/loras');
|
||||
|
||||
openArgs.onSelect('/picked/loras');
|
||||
|
||||
expect(input.value).toBe('/picked/loras');
|
||||
expect(updateSpy).toHaveBeenCalledWith('loras');
|
||||
});
|
||||
|
||||
it('model path rows route through updateModelFolderPaths', () => {
|
||||
const manager = createManager();
|
||||
const container = document.createElement('div');
|
||||
container.id = 'modelFolderPaths-loras';
|
||||
document.body.appendChild(container);
|
||||
const updateSpy = vi
|
||||
.spyOn(manager, 'updateModelFolderPaths')
|
||||
.mockResolvedValue();
|
||||
|
||||
manager.addModelFolderPathRow('loras', '/models/loras', false);
|
||||
const row = container.querySelector('.extra-folder-path-row');
|
||||
const input = row.querySelector('.extra-folder-path-input');
|
||||
const browseBtn = row.querySelector('.browse-path-btn');
|
||||
expect(browseBtn).not.toBeNull();
|
||||
|
||||
manager.browseForPathRow(browseBtn, 'loras', true);
|
||||
const openArgs = directoryPickerModal.open.mock.calls[0][0];
|
||||
openArgs.onSelect('/picked/loras');
|
||||
|
||||
expect(input.value).toBe('/picked/loras');
|
||||
expect(updateSpy).toHaveBeenCalledWith('loras');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user