Files
ComfyUI-Lora-Manager/tests/frontend/utils/uiHelpers.dom.test.js
T
Will Miao 94e3f54571 feat(workflow): exclude text-capable nodes with connected text from send targets
CLIP Text Encode and friends whose text widget is backed by a connected
input cannot have their text changed via the widget (execution reads the
linked input), so sending to them was a silent no-op.

- Registry: compute text_widget_connected capability from the widget's
  backing input link state; has_text_widget drops to false when wired;
  include the flag in the registration fingerprint so link changes
  re-register the affected nodes
- Registry: hook link connect/disconnect (graph events on new litegraph,
  onAfterChange fallback for classic) on root and subgraphs, plus
  subgraph-created for future subgraphs
- applyWidgetUpdate: skip inject_text when the target widget is connected
  and self-heal the registry instead of writing a value that is ignored
- Web UI: drop text_widget_connected nodes from prompt/embedding send
  candidates; show a Mark as -> Send Prompt Target hint toast when no
  candidates remain (new uiHelpers.workflow.noPromptTargets key, synced
  to all locales; zh-CN/zh-TW translated)
- Extract shared resolveTextWidget() used by both the candidate-set
  logic and the write path so the two cannot drift apart
- Tests: workflow registry connection-state registration, subgraph
  handling, fingerprint re-registration, inject_text write/skip paths,
  setup link-change hooks; uiHelpers candidate filtering and hint toast
2026-08-12 16:50:07 +08:00

649 lines
20 KiB
JavaScript

import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
I18N_MODULE,
STATE_MODULE,
STORAGE_MODULE,
CONSTANTS_MODULE,
EVENT_MANAGER_MODULE,
BANNER_SERVICE_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
} = vi.hoisted(() => ({
I18N_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,
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname,
EVENT_MANAGER_MODULE: new URL('../../../static/js/utils/EventManager.js', import.meta.url).pathname,
BANNER_SERVICE_MODULE: new URL('../../../static/js/managers/BannerService.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
}));
const translateMock = vi.fn((key, _params, fallback) => fallback || key);
const getStorageItemMock = vi.fn();
const setStorageItemMock = vi.fn();
const registerBannerMock = vi.fn();
const showModalMock = vi.fn();
vi.mock(I18N_MODULE, () => ({
translate: translateMock,
}));
vi.mock(STATE_MODULE, () => ({
state: {},
getCurrentPageState: vi.fn(),
}));
vi.mock(STORAGE_MODULE, () => ({
getStorageItem: getStorageItemMock,
setStorageItem: setStorageItemMock,
}));
vi.mock(CONSTANTS_MODULE, () => ({
NODE_TYPE_ICONS: {},
DEFAULT_NODE_COLOR: '#ffffff',
}));
vi.mock(EVENT_MANAGER_MODULE, () => ({
eventManager: {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
addHandler: vi.fn(),
removeHandler: vi.fn(),
setState: vi.fn(),
},
}));
vi.mock(BANNER_SERVICE_MODULE, () => ({
bannerService: {
registerBanner: registerBannerMock,
},
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: showModalMock,
},
}));
describe('UI helper DOM utilities', () => {
beforeEach(() => {
document.body.innerHTML = '';
document.body.removeAttribute('data-theme');
document.documentElement.removeAttribute('data-theme');
getStorageItemMock.mockReset();
setStorageItemMock.mockReset();
registerBannerMock.mockReset();
showModalMock.mockReset();
translateMock.mockReset();
globalThis.requestAnimationFrame = (cb) => cb();
});
afterEach(() => {
vi.useRealTimers();
delete global.fetch;
delete navigator.clipboard;
delete window.open;
});
it('creates toast elements and cleans them up after timeout', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Toast message');
const { showToast } = await import(UI_HELPERS_MODULE);
showToast('uiHelpers.clipboard.copied', {}, 'success');
const container = document.querySelector('.toast-container');
expect(container).not.toBeNull();
expect(container.querySelectorAll('.toast')).toHaveLength(1);
await Promise.resolve();
vi.advanceTimersByTime(2000);
const toast = container.querySelector('.toast');
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
await Promise.resolve();
expect(toast.classList.contains('show')).toBe(false);
});
it('renders an action button and countdown span for action toasts', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
const { showActionToast } = await import(UI_HELPERS_MODULE);
const onAction = vi.fn();
showActionToast('toast.undo.deleted', { name: 'Demo Model' }, 'success', {
actionText: 'Undo',
onAction,
});
const toast = document.querySelector('.toast-container .toast');
expect(toast).not.toBeNull();
expect(toast.classList.contains('toast-success')).toBe(true);
expect(translateMock).toHaveBeenCalledWith('toast.undo.deleted', { name: 'Demo Model' });
const button = toast.querySelector('.toast-action-btn');
expect(button).not.toBeNull();
expect(button.textContent).toBe('Undo');
const countdown = toast.querySelector('.toast-countdown');
expect(countdown).not.toBeNull();
expect(countdown.textContent).toBe('(30s)');
// Ticking one second updates the countdown text
vi.advanceTimersByTime(1000);
expect(countdown.textContent).toBe('(29s)');
// Drain remaining timers so no state leaks into other tests
vi.advanceTimersByTime(30000);
});
it('invokes onAction once and dismisses immediately when the button is clicked', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
const { showActionToast } = await import(UI_HELPERS_MODULE);
const onAction = vi.fn();
showActionToast('toast.undo.deleted', {}, 'success', {
actionText: 'Undo',
onAction,
});
const toast = document.querySelector('.toast-container .toast');
toast.querySelector('.toast-action-btn').click();
expect(onAction).toHaveBeenCalledTimes(1);
expect(toast.classList.contains('show')).toBe(false);
// Dismissal removes the element after the transition ends
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
expect(document.querySelector('.toast-container .toast')).toBeNull();
expect(document.querySelector('.toast-container')).toBeNull();
});
it('calls onAction exactly once when the button is double-clicked', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
const { showActionToast } = await import(UI_HELPERS_MODULE);
const onAction = vi.fn();
showActionToast('toast.undo.deleted', {}, 'success', {
actionText: 'Undo',
onAction,
});
const button = document.querySelector('.toast-action-btn');
button.click();
button.click();
expect(onAction).toHaveBeenCalledTimes(1);
});
it('dismisses the toast when the countdown reaches zero', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
// Async RAF mirrors production ordering: the countdown interval is
// registered before the dismiss timeout, so the final tick displays (0s)
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
const { showActionToast } = await import(UI_HELPERS_MODULE);
showActionToast('toast.undo.deleted', {}, 'success', {
actionText: 'Undo',
onAction: vi.fn(),
durationMs: 3000,
});
vi.advanceTimersByTime(0); // Flush the RAF callback
const toast = document.querySelector('.toast-container .toast');
const countdown = toast.querySelector('.toast-countdown');
expect(countdown.textContent).toBe('(3s)');
vi.advanceTimersByTime(2000);
expect(countdown.textContent).toBe('(1s)');
expect(toast.classList.contains('show')).toBe(true);
vi.advanceTimersByTime(1000);
expect(countdown.textContent).toBe('(0s)');
expect(toast.classList.contains('show')).toBe(false);
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
expect(document.querySelector('.toast-container .toast')).toBeNull();
});
it('clears the countdown interval when dismissed via the action button', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
const { showActionToast } = await import(UI_HELPERS_MODULE);
const onAction = vi.fn();
showActionToast('toast.undo.deleted', {}, 'success', {
actionText: 'Undo',
onAction,
durationMs: 30000,
});
const toast = document.querySelector('.toast-container .toast');
const countdown = toast.querySelector('.toast-countdown');
toast.querySelector('.toast-action-btn').click();
// Advancing past the full duration must not tick the countdown further,
// throw, or re-dismiss the already-dismissed toast
vi.advanceTimersByTime(60000);
expect(countdown.textContent).toBe('(30s)');
expect(onAction).toHaveBeenCalledTimes(1);
expect(toast.classList.contains('show')).toBe(false);
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
expect(document.querySelector('.toast-container')).toBeNull();
});
it('toggles the persisted theme and updates DOM attributes', async () => {
getStorageItemMock.mockReturnValue('light');
document.body.innerHTML = '<button class="theme-toggle"></button>';
globalThis.matchMedia = vi.fn(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
const { toggleTheme } = await import(UI_HELPERS_MODULE);
const nextTheme = toggleTheme();
expect(nextTheme).toBe('dark');
expect(setStorageItemMock).toHaveBeenCalledWith('theme', 'dark');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
expect(document.body.dataset.theme).toBe('dark');
expect(document.querySelector('.theme-toggle').classList.contains('theme-dark')).toBe(true);
});
it('renders subgraph names in the node selector list', async () => {
const registryResponse = {
success: true,
data: {
node_count: 2,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Root Loader',
type: 1,
bgcolor: '#123456',
},
'subgraph-uuid:2': {
id: 2,
graph_id: 'subgraph-uuid',
graph_name: 'Character Subgraph',
title: 'Nested Loader',
type: 1,
bgcolor: '#654321',
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
const { sendLoraToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendLoraToWorkflow('<lora:test:1>');
expect(result).toBe(true);
expect(global.fetch).toHaveBeenCalledWith('/api/lm/get-registry');
const nodeLabels = Array.from(
document.querySelectorAll('#nodeSelector .node-item[data-node-id] span')
).map((span) => span.textContent.trim());
expect(nodeLabels).toEqual([
'#1 Root Loader',
'#2 (Character Subgraph) Nested Loader',
]);
});
it('excludes prompt targets whose text widget is connected to an input', async () => {
const registryResponse = {
success: true,
data: {
node_count: 4,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Free Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: false,
widget_names: ['text', 'clip'],
},
},
'root:2': {
id: 2,
graph_id: 'root',
graph_name: null,
title: 'Wired Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: true,
widget_names: ['text', 'clip'],
},
},
'root:3': {
id: 3,
graph_id: 'root',
graph_name: null,
title: 'Marked But Wired',
type: 'KSampler',
mode: 0,
marker_role: 'send_prompt_target',
capabilities: {
has_text_widget: false,
text_widget_connected: true,
widget_names: ['seed'],
},
},
'root:4': {
id: 4,
graph_id: 'root',
graph_name: null,
title: 'Free Text 2',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: false,
widget_names: ['text', 'clip'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendPromptToWorkflow('a cat');
expect(result).toBe(true);
const nodeLabels = Array.from(
document.querySelectorAll('#nodeSelector .node-item[data-node-id] span')
).map((span) => span.textContent.trim());
expect(nodeLabels).toEqual(['#1 Free Text', '#4 Free Text 2']);
});
it('returns false when the only prompt target has its text widget connected', async () => {
const registryResponse = {
success: true,
data: {
node_count: 1,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Wired Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: true,
widget_names: ['text', 'clip'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
translateMock.mockReturnValue(
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
);
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendPromptToWorkflow('a cat');
expect(result).toBe(false);
expect(document.querySelectorAll('#nodeSelector .node-item').length).toBe(0);
const toast = document.querySelector('.toast-container .toast');
expect(toast).not.toBeNull();
expect(toast.textContent).toContain('Mark as');
expect(toast.textContent).toContain('Send Prompt Target');
});
it('shows the mark-as hint when no embedding target is available', async () => {
const registryResponse = {
success: true,
data: {
node_count: 1,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Wired Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: true,
widget_names: ['text', 'clip'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
translateMock.mockReturnValue(
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
);
const { sendEmbeddingToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendEmbeddingToWorkflow('embeddingcode');
expect(result).toBe(false);
const toast = document.querySelector('.toast-container .toast');
expect(toast).not.toBeNull();
expect(toast.textContent).toContain('Send Prompt Target');
});
it('keeps unconnected marker targets in the prompt candidate list', async () => {
const registryResponse = {
success: true,
data: {
node_count: 2,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Marked Target',
type: 'KSampler',
mode: 0,
marker_role: 'send_prompt_target',
capabilities: {
has_text_widget: false,
text_widget_connected: false,
widget_names: ['seed'],
},
},
'root:2': {
id: 2,
graph_id: 'root',
graph_name: null,
title: 'Marked Target 2',
type: 'KSampler',
mode: 0,
marker_role: 'send_prompt_target',
capabilities: {
has_text_widget: false,
text_widget_connected: false,
widget_names: ['seed'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendPromptToWorkflow('a cat');
expect(result).toBe(true);
const nodeLabels = Array.from(
document.querySelectorAll('#nodeSelector .node-item[data-node-id] span')
).map((span) => span.textContent.trim());
expect(nodeLabels).toEqual(['#1 Marked Target', '#2 Marked Target 2']);
});
it('opens Civitai links using the preferred host and registers the first-use banner once', async () => {
const openSpy = vi.fn();
globalThis.window.open = openSpy;
getStorageItemMock.mockImplementation((key, defaultValue) => {
if (key === 'civitai_host_info_banner_seen') {
return false;
}
return defaultValue;
});
const { openCivitaiByMetadata } = await import(UI_HELPERS_MODULE);
openCivitaiByMetadata(123, 456, 'Demo Model');
expect(setStorageItemMock).toHaveBeenCalledWith('civitai_host_info_banner_seen', true);
expect(registerBannerMock).toHaveBeenCalledTimes(1);
expect(openSpy).toHaveBeenCalledWith(
'https://civitai.com/models/123?modelVersionId=456',
'_blank',
'noopener,noreferrer'
);
});
it('uses the configured red host for fallback searches', async () => {
const openSpy = vi.fn();
globalThis.window.open = openSpy;
getStorageItemMock.mockImplementation((key, defaultValue) => {
if (key === 'civitai_host_info_banner_seen') {
return true;
}
return defaultValue;
});
const stateModule = await import(STATE_MODULE);
stateModule.state.global = {
settings: {
civitai_host: 'civitai.red',
},
};
const { openCivitaiByMetadata } = await import(UI_HELPERS_MODULE);
openCivitaiByMetadata(null, null, 'Demo Model');
expect(registerBannerMock).not.toHaveBeenCalled();
expect(openSpy).toHaveBeenCalledWith(
'https://civitai.red/models?query=Demo%20Model',
'_blank',
'noopener,noreferrer'
);
});
it('copies mapped local example-image paths when the backend requests clipboard mode', async () => {
global.fetch = vi.fn().mockResolvedValue({
json: async () => ({
success: true,
mode: 'clipboard',
path: '/Volumes/ComfyUI/examples/demo',
}),
});
navigator.clipboard = {
writeText: vi.fn().mockResolvedValue(),
};
const { openExampleImagesFolder } = await import(UI_HELPERS_MODULE);
const result = await openExampleImagesFolder('abc123');
expect(result).toBe(true);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('/Volumes/ComfyUI/examples/demo');
expect(global.fetch).toHaveBeenCalledWith('/api/lm/open-example-images-folder', expect.objectContaining({
method: 'POST',
}));
});
it('opens custom URIs for example-image folders when requested by the backend', async () => {
global.fetch = vi.fn().mockResolvedValue({
json: async () => ({
success: true,
mode: 'uri',
uri: 'shortcuts://run-shortcut?name=OpenFinder',
}),
});
window.open = vi.fn(() => ({}));
const { openExampleImagesFolder } = await import(UI_HELPERS_MODULE);
const result = await openExampleImagesFolder('abc123');
expect(result).toBe(true);
expect(window.open).toHaveBeenCalledWith(
'shortcuts://run-shortcut?name=OpenFinder',
'_blank',
'noopener,noreferrer'
);
});
});