mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-10 03:22:12 -03:00
feat(modal): open model metadata sidecar location from the model modal
Centralized sidecar storage moves .metadata.json into a mirror tree, so the Location link in the model modal could no longer reveal a model's sidecar. The Location row now shows an inline metadata icon button (centralized mode only) that calls a new POST /api/lm/models/open-sidecar-location endpoint: the backend resolves the sidecar through py/utils/sidecar_paths, reveals the .metadata.json selected in the file manager (falling back to the sidecar directory when it has not been written yet), and returns the path for clipboard mode on headless/Docker sessions. The platform reveal logic is factored into a shared FileSystemHandler._reveal_file helper. All 9 locales translated.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
MODAL_MODULE,
|
||||
API_FACTORY,
|
||||
UI_HELPERS_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
SHOWCASE_MODULE,
|
||||
MODEL_TAGS_MODULE,
|
||||
UTILS_MODULE,
|
||||
TRIGGER_WORDS_MODULE,
|
||||
PRESET_TAGS_MODULE,
|
||||
MODEL_VERSIONS_MODULE,
|
||||
RECIPE_TAB_MODULE,
|
||||
I18N_HELPERS_MODULE,
|
||||
STATE_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
MODAL_MODULE: new URL('../../../static/js/components/shared/ModelModal.js', import.meta.url).pathname,
|
||||
API_FACTORY: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
|
||||
MODEL_TAGS_MODULE: new URL('../../../static/js/components/shared/ModelTags.js', import.meta.url).pathname,
|
||||
UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname,
|
||||
TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname,
|
||||
PRESET_TAGS_MODULE: new URL('../../../static/js/components/shared/PresetTags.js', import.meta.url).pathname,
|
||||
MODEL_VERSIONS_MODULE: new URL('../../../static/js/components/shared/ModelVersionsTab.js', import.meta.url).pathname,
|
||||
RECIPE_TAB_MODULE: new URL('../../../static/js/components/shared/RecipeTab.js', import.meta.url).pathname,
|
||||
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: vi.fn(),
|
||||
openCivitai: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: {
|
||||
showModal: vi.fn((id, html) => {
|
||||
document.body.innerHTML = `<div id="${id}">${html}</div>`;
|
||||
}),
|
||||
closeModal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(SHOWCASE_MODULE, () => ({
|
||||
scrollToTop: vi.fn(),
|
||||
loadExampleImages: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(MODEL_TAGS_MODULE, () => ({
|
||||
setupTagEditMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(UTILS_MODULE, async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
renderCompactTags: vi.fn(() => ''),
|
||||
setupTagTooltip: vi.fn(),
|
||||
formatFileSize: vi.fn(() => '1 MB'),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(TRIGGER_WORDS_MODULE, () => ({
|
||||
renderTriggerWords: vi.fn(() => ''),
|
||||
setupTriggerWordsEditMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(PRESET_TAGS_MODULE, () => ({
|
||||
parsePresets: vi.fn(() => ({})),
|
||||
renderPresetTags: vi.fn(() => ''),
|
||||
}));
|
||||
|
||||
vi.mock(MODEL_VERSIONS_MODULE, () => ({
|
||||
initVersionsTab: vi.fn(() => ({
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock(RECIPE_TAB_MODULE, () => ({
|
||||
loadRecipesForModel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||
translate: vi.fn((_, __, fallback) => fallback || ''),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
MODEL_TYPES: {
|
||||
LORA: 'loras',
|
||||
CHECKPOINT: 'checkpoints',
|
||||
EMBEDDING: 'embeddings'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY, () => ({
|
||||
getModelApiClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const MODEL_PATH = '/data/loras/flux/model.safetensors';
|
||||
|
||||
function makeModel(overrides = {}) {
|
||||
return {
|
||||
model_name: 'Sidecar Model',
|
||||
file_path: MODEL_PATH,
|
||||
file_name: 'model.safetensors',
|
||||
civitai: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Model modal sidecar location entry', () => {
|
||||
let getModelApiClient;
|
||||
let showToast;
|
||||
let state;
|
||||
let originalMode;
|
||||
|
||||
beforeEach(async () => {
|
||||
document.body.innerHTML = '';
|
||||
({ getModelApiClient } = await import(API_FACTORY));
|
||||
({ showToast } = await import(UI_HELPERS_MODULE));
|
||||
({ state } = await import(STATE_MODULE));
|
||||
getModelApiClient.mockReset();
|
||||
showToast.mockReset();
|
||||
getModelApiClient.mockReturnValue({
|
||||
fetchModelMetadata: vi.fn().mockResolvedValue(null),
|
||||
saveModelMetadata: vi.fn(),
|
||||
});
|
||||
originalMode = state.global.settings.sidecar_storage_mode;
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
state.global.settings.sidecar_storage_mode = originalMode;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function renderModal(model) {
|
||||
const { showModelModal } = await import(MODAL_MODULE);
|
||||
await showModelModal(model, 'loras');
|
||||
}
|
||||
|
||||
it('renders the metadata location button only in centralized sidecar mode', async () => {
|
||||
state.global.settings.sidecar_storage_mode = 'centralized';
|
||||
await renderModal(makeModel());
|
||||
|
||||
const button = document.querySelector('[data-action="open-sidecar-location"]');
|
||||
expect(button).not.toBeNull();
|
||||
expect(button.dataset.filepath).toBe(MODEL_PATH);
|
||||
});
|
||||
|
||||
it.each(['alongside', '', undefined])('hides the button when sidecar mode is %s', async (mode) => {
|
||||
state.global.settings.sidecar_storage_mode = mode;
|
||||
await renderModal(makeModel());
|
||||
|
||||
expect(document.querySelector('[data-action="open-sidecar-location"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('posts the model file path to the sidecar endpoint on click', async () => {
|
||||
state.global.settings.sidecar_storage_mode = 'centralized';
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
await renderModal(makeModel());
|
||||
|
||||
document.querySelector('[data-action="open-sidecar-location"]').click();
|
||||
await vi.waitFor(() => expect(showToast).toHaveBeenCalled());
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/lm/models/open-sidecar-location',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ file_path: MODEL_PATH }),
|
||||
})
|
||||
);
|
||||
expect(showToast).toHaveBeenCalledWith('modals.model.openSidecarLocation.success', {}, 'success');
|
||||
});
|
||||
|
||||
it('copies the resolved sidecar path in clipboard mode', async () => {
|
||||
state.global.settings.sidecar_storage_mode = 'centralized';
|
||||
const sidecarPath = '/sidecars/loras-abcd1234/flux/model.metadata.json';
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, mode: 'clipboard', path: sidecarPath }),
|
||||
});
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||
await renderModal(makeModel());
|
||||
|
||||
document.querySelector('[data-action="open-sidecar-location"]').click();
|
||||
await vi.waitFor(() => expect(showToast).toHaveBeenCalled());
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(sidecarPath);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'modals.model.openSidecarLocation.copied',
|
||||
{ path: sidecarPath },
|
||||
'success'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows an error toast when the endpoint fails', async () => {
|
||||
state.global.settings.sidecar_storage_mode = 'centralized';
|
||||
global.fetch.mockResolvedValue({ ok: false, status: 404 });
|
||||
await renderModal(makeModel());
|
||||
|
||||
document.querySelector('[data-action="open-sidecar-location"]').click();
|
||||
await vi.waitFor(() => expect(showToast).toHaveBeenCalled());
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith('modals.model.openSidecarLocation.failed', {}, 'error');
|
||||
});
|
||||
});
|
||||
@@ -911,6 +911,151 @@ async def test_open_file_location_headless_returns_clipboard_mode(tmp_path, monk
|
||||
assert run_calls == []
|
||||
|
||||
|
||||
def _patch_linux_desktop(monkeypatch):
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
|
||||
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_model_sidecar_location_reveals_metadata_file(tmp_path, monkeypatch):
|
||||
"""Centralized mode: the resolved .metadata.json is revealed selected,
|
||||
even though it lives in the mirror tree rather than next to the model."""
|
||||
model_file = tmp_path / "model.safetensors"
|
||||
model_file.write_text("x", encoding="utf-8")
|
||||
metadata_file = tmp_path / "mirror" / "model.metadata.json"
|
||||
metadata_file.parent.mkdir(parents=True)
|
||||
metadata_file.write_text("{}", encoding="utf-8")
|
||||
|
||||
handler = FileSystemHandler(settings_service=SimpleNamespace())
|
||||
_patch_linux_desktop(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"py.routes.handlers.misc_handlers.get_metadata_path",
|
||||
lambda path: str(metadata_file),
|
||||
)
|
||||
|
||||
run_calls = []
|
||||
popen_calls = []
|
||||
monkeypatch.setattr(
|
||||
subprocess, "run", lambda *args, **kwargs: run_calls.append(args[0]) or SimpleNamespace(returncode=0)
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "Popen", lambda *args, **kwargs: popen_calls.append(args))
|
||||
|
||||
request = FakeRequest(json_data={"file_path": str(model_file)})
|
||||
response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType]
|
||||
payload = _json_payload(response)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["path"] == str(metadata_file)
|
||||
assert len(run_calls) == 1
|
||||
assert any("model.metadata.json" in arg for arg in run_calls[0])
|
||||
assert popen_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_model_sidecar_location_falls_back_to_sidecar_dir(tmp_path, monkeypatch):
|
||||
"""When the sidecar has not been written yet, open the sidecar directory
|
||||
itself instead of reporting an error."""
|
||||
model_file = tmp_path / "model.safetensors"
|
||||
model_file.write_text("x", encoding="utf-8")
|
||||
sidecar_dir = tmp_path / "mirror"
|
||||
sidecar_dir.mkdir()
|
||||
|
||||
handler = FileSystemHandler(settings_service=SimpleNamespace())
|
||||
_patch_linux_desktop(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"py.routes.handlers.misc_handlers.get_metadata_path",
|
||||
lambda path: str(sidecar_dir / "model.metadata.json"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"py.routes.handlers.misc_handlers.get_sidecar_dir",
|
||||
lambda path: str(sidecar_dir),
|
||||
)
|
||||
|
||||
popen_calls = []
|
||||
monkeypatch.setattr(
|
||||
subprocess, "Popen", lambda *args, **kwargs: popen_calls.append(args[0])
|
||||
)
|
||||
|
||||
request = FakeRequest(json_data={"file_path": str(model_file)})
|
||||
response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType]
|
||||
payload = _json_payload(response)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["path"] == str(sidecar_dir)
|
||||
assert popen_calls == [["xdg-open", str(sidecar_dir)]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_model_sidecar_location_missing_sidecar_returns_404(tmp_path, monkeypatch):
|
||||
model_file = tmp_path / "model.safetensors"
|
||||
model_file.write_text("x", encoding="utf-8")
|
||||
missing_dir = tmp_path / "missing"
|
||||
|
||||
handler = FileSystemHandler(settings_service=SimpleNamespace())
|
||||
monkeypatch.setattr(
|
||||
"py.routes.handlers.misc_handlers.get_metadata_path",
|
||||
lambda path: str(missing_dir / "model.metadata.json"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"py.routes.handlers.misc_handlers.get_sidecar_dir",
|
||||
lambda path: str(missing_dir),
|
||||
)
|
||||
|
||||
request = FakeRequest(json_data={"file_path": str(model_file)})
|
||||
response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType]
|
||||
payload = _json_payload(response)
|
||||
|
||||
assert response.status == 404
|
||||
assert payload["success"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_model_sidecar_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
|
||||
model_file = tmp_path / "model.safetensors"
|
||||
model_file.write_text("x", encoding="utf-8")
|
||||
metadata_file = tmp_path / "mirror" / "model.metadata.json"
|
||||
metadata_file.parent.mkdir(parents=True)
|
||||
metadata_file.write_text("{}", encoding="utf-8")
|
||||
|
||||
handler = FileSystemHandler(settings_service=SimpleNamespace())
|
||||
monkeypatch.delenv("DISPLAY", raising=False)
|
||||
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
|
||||
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
|
||||
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
"py.routes.handlers.misc_handlers.get_metadata_path",
|
||||
lambda path: str(metadata_file),
|
||||
)
|
||||
|
||||
popen_calls = []
|
||||
monkeypatch.setattr(subprocess, "Popen", lambda *args, **kwargs: popen_calls.append(args))
|
||||
|
||||
request = FakeRequest(json_data={"file_path": str(model_file)})
|
||||
response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType]
|
||||
payload = _json_payload(response)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["mode"] == "clipboard"
|
||||
assert payload["path"] == str(metadata_file)
|
||||
assert popen_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_model_sidecar_location_validates_input(tmp_path):
|
||||
handler = FileSystemHandler(settings_service=SimpleNamespace())
|
||||
|
||||
response = await handler.open_model_sidecar_location(FakeRequest()) # pyright: ignore[reportArgumentType]
|
||||
assert response.status == 400
|
||||
|
||||
request = FakeRequest(json_data={"file_path": str(tmp_path / "missing.safetensors")})
|
||||
response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType]
|
||||
assert response.status == 404
|
||||
|
||||
|
||||
class RecordingRouter:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
Reference in New Issue
Block a user