feat(nodes): flag missing local models at queue and load time (#1057)

This commit is contained in:
Will Miao
2026-08-10 12:31:36 +08:00
parent 41e1fd1e1f
commit 6a259a14fa
16 changed files with 1200 additions and 6 deletions

View File

@@ -0,0 +1,228 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
const {
APP_MODULE,
API_MODULE,
UTILS_MODULE,
} = vi.hoisted(() => ({
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
UTILS_MODULE: new URL('../../../web/comfyui/loras_widget_utils.js', import.meta.url).pathname,
}));
vi.mock(APP_MODULE, () => ({
app: { graph: {} },
}));
const { fetchApiMock } = vi.hoisted(() => ({ fetchApiMock: vi.fn() }));
vi.mock(API_MODULE, () => ({
api: { fetchApi: fetchApiMock },
}));
import {
normalizeLoraNameKey,
buildAvailableLoraSet,
isLoraNameAvailable,
getAvailableLoras,
getAvailableLorasSync,
resetAvailableLorasCache,
onLibraryChanged,
handleLibraryChangeMessage,
} from '../../../web/comfyui/loras_widget_utils.js';
describe('normalizeLoraNameKey', () => {
it('normalizes backslashes to forward slashes', () => {
expect(normalizeLoraNameKey('sub\\folder\\lora.safetensors')).toBe(
'sub/folder/lora'
);
});
it('strips known model extensions case-insensitively', () => {
expect(normalizeLoraNameKey('lora.safetensors')).toBe('lora');
expect(normalizeLoraNameKey('lora.CKPT')).toBe('lora');
expect(normalizeLoraNameKey('lora.pt')).toBe('lora');
expect(normalizeLoraNameKey('lora.bin')).toBe('lora');
});
it('keeps extensions the backend does not strip', () => {
// Matches backend _strip_lora_extension: .gguf is not a LoRA extension.
expect(normalizeLoraNameKey('lora.gguf')).toBe('lora.gguf');
});
it('leaves extension-less names unchanged', () => {
expect(normalizeLoraNameKey('lora')).toBe('lora');
});
});
describe('buildAvailableLoraSet', () => {
it('registers both path and basename forms without extension', () => {
const set = buildAvailableLoraSet(['sub/a.safetensors', 'b.ckpt']);
expect(set.has('sub/a')).toBe(true);
expect(set.has('a')).toBe(true);
expect(set.has('b')).toBe(true);
});
it('ignores empty entries', () => {
const set = buildAvailableLoraSet([null, '', 'sub/c.safetensors']);
expect(set.has('sub/c')).toBe(true);
expect(set.has('')).toBe(false);
});
});
describe('isLoraNameAvailable', () => {
const set = buildAvailableLoraSet(['sub/a.safetensors', 'b.ckpt']);
it('treats everything as available while the set is not loaded', () => {
expect(isLoraNameAvailable('anything.safetensors', null)).toBe(true);
});
it('matches by basename with or without extension', () => {
expect(isLoraNameAvailable('a', set)).toBe(true);
expect(isLoraNameAvailable('a.safetensors', set)).toBe(true);
expect(isLoraNameAvailable('b.ckpt', set)).toBe(true);
});
it('matches by full folder path', () => {
expect(isLoraNameAvailable('sub/a.safetensors', set)).toBe(true);
});
it('reports names not in the library', () => {
expect(isLoraNameAvailable('missing.safetensors', set)).toBe(false);
});
it('falls back to the basename for folder-qualified names', () => {
// Mirror of the backend basename fallback: a folder prefix that does not
// match a stored path still resolves when the basename exists.
expect(isLoraNameAvailable('sub/b.ckpt', set)).toBe(true);
expect(isLoraNameAvailable('any/folder/a.safetensors', set)).toBe(true);
expect(isLoraNameAvailable('sub/missing.safetensors', set)).toBe(false);
});
it('treats absolute paths as available without verification', () => {
expect(isLoraNameAvailable('/abs/path/x.safetensors', set)).toBe(true);
expect(isLoraNameAvailable('C:/abs/path/x.safetensors', set)).toBe(true);
});
});
describe('getAvailableLoras caching', () => {
beforeEach(() => {
fetchApiMock.mockReset();
resetAvailableLorasCache();
});
it('fetches the cycler list and builds the availability set', async () => {
fetchApiMock.mockResolvedValue({
ok: true,
json: async () => ({
success: true,
loras: [{ file_name: 'sub/a.safetensors' }, { file_name: 'b.ckpt' }],
}),
});
const set = await getAvailableLoras();
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/cycler-list', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
expect(set.has('sub/a')).toBe(true);
expect(set.has('b')).toBe(true);
expect(getAvailableLorasSync().has('sub/a')).toBe(true);
});
it('shares a single in-flight request between concurrent callers', async () => {
fetchApiMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true, loras: [] }),
});
await Promise.all([getAvailableLoras(), getAvailableLoras()]);
expect(fetchApiMock).toHaveBeenCalledTimes(1);
});
it('does not refetch while the cache is fresh', async () => {
fetchApiMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true, loras: [] }),
});
await getAvailableLoras();
await getAvailableLoras();
expect(fetchApiMock).toHaveBeenCalledTimes(1);
});
it('resolves to null and does not cache on fetch failure', async () => {
fetchApiMock.mockRejectedValue(new Error('network down'));
const result = await getAvailableLoras();
expect(result).toBeNull();
expect(getAvailableLorasSync()).toBeNull();
});
it('resolves to null on non-ok response', async () => {
fetchApiMock.mockResolvedValue({ ok: false });
const result = await getAvailableLoras();
expect(result).toBeNull();
});
});
describe('library change invalidation', () => {
beforeEach(() => {
fetchApiMock.mockReset();
resetAvailableLorasCache();
});
it('invalidates the availability cache and notifies listeners on models_changed', async () => {
fetchApiMock.mockResolvedValue({
ok: true,
json: async () => ({
success: true,
loras: [{ file_name: 'old.safetensors' }],
}),
});
const listener = vi.fn();
const unsubscribe = onLibraryChanged(listener);
try {
await getAvailableLoras();
expect(getAvailableLorasSync().has('old')).toBe(true);
// Simulate a deletion in the Lora Manager UI: the cache is dropped and
// listeners are notified so widgets re-render with fresh data.
handleLibraryChangeMessage({ type: 'models_changed' });
expect(listener).toHaveBeenCalledTimes(1);
expect(getAvailableLorasSync()).toBeNull();
fetchApiMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true, loras: [] }),
});
await getAvailableLoras();
expect(getAvailableLorasSync().has('old')).toBe(false);
} finally {
unsubscribe();
}
});
it('ignores unrelated messages', () => {
const listener = vi.fn();
const unsubscribe = onLibraryChanged(listener);
try {
handleLibraryChangeMessage({ type: 'download_progress' });
handleLibraryChangeMessage({ type: 'init_progress' });
handleLibraryChangeMessage(null);
expect(listener).not.toHaveBeenCalled();
} finally {
unsubscribe();
}
});
it('unsubscribes listeners', () => {
const listener = vi.fn();
const unsubscribe = onLibraryChanged(listener);
unsubscribe();
handleLibraryChangeMessage({ type: 'models_changed' });
expect(listener).not.toHaveBeenCalled();
});
});