import { readFileSync } from 'node:fs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { imageMetadataMethods } from '../web/resolver/views/tabs_image_metadata_methods.js'; const modelInfoMethodsSource = readFileSync( new URL('../web/resolver/views/model_info_methods.js', import.meta.url), 'utf8' ); const imageMetadataMethodsSource = readFileSync( new URL('../web/resolver/views/tabs_image_metadata_methods.js', import.meta.url), 'utf8' ); function extractMethod(source, methodName, paramsPattern = '[^)]*') { const signatureRegex = new RegExp(`\\n\\s+(async\\s+)?${methodName}\\s*\\(${paramsPattern}\\)\\s*\\{`); const match = signatureRegex.exec(source); assert.ok(match, `Could not find ${methodName}`); const isAsync = Boolean(match[1]); const parenStart = source.indexOf('(', match.index); const parenEnd = source.indexOf(')', parenStart); const params = source.slice(parenStart + 1, parenEnd); const braceStart = source.indexOf('{', parenEnd); let depth = 0; for (let index = braceStart; index < source.length; index += 1) { const char = source[index]; if (char === '{') depth += 1; if (char === '}') depth -= 1; if (depth === 0) { return `${isAsync ? 'async ' : ''}function ${methodName}(${params}) ${source.slice(braceStart, index + 1)}`; } } throw new Error(`Could not parse ${methodName}`); } function createTransferContext(catalogValues = ['clip.safetensors']) { return { ...imageMetadataMethods, getMissingAcceptedModelFileTypes: ({ node_type: nodeType, widget_name: widgetName }) => ( nodeType === 'CLIPLoader' && widgetName === 'clip_name' ? catalogValues .map(value => { const match = String(value || '').match(/\.([a-z0-9]+)$/i); return match ? { extension: match[1].toLowerCase(), display: `.${match[1].toLowerCase()}` } : null; }) .filter(Boolean) : [] ), getModelFileTypeInfo: value => { const match = String(value || '').match(/\.([a-z0-9]+)$/i); return match ? { extension: match[1].toLowerCase() } : null; }, getComfyWidgetNameByIndex: () => 'clip_name', getCurrentComfyCatalogValues: (nodeType, widgetName) => ( nodeType === 'CLIPLoader' && widgetName === 'clip_name' ? catalogValues : null ), escapeHtml: value => String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''), getFilenameFromPath: value => String(value).split(/[\\/]/).pop(), stripModelExtension: value => String(value).replace(/\.[^.]+$/, ''), getContextMenuAttrs: model => model?.context_scope ? ` data-context-scope="${model.context_scope}"` : '', getModelPreviewTooltipAttrs: model => model?.exists === true && model?.resolved_path ? ` data-tooltip-image="${model.resolved_path}"` : '', }; } function createTransferState(sourceModels) { const targetGroups = [{ category: 'text_encoders', nodes: [{ nodeKey: 'top:1', nodeId: 1, nodeType: 'CLIPLoader', refs: [{ category: 'text_encoders', node_id: 1, node_type: 'CLIPLoader', widget_index: 0, widget_name: 'clip_name', original_path: 'current.safetensors', transferKey: 'text_encoders:top:1:0', }], }], }]; return { loadedModels: { loaded_models: sourceModels }, transfer: { mode: 'replace', targetGroups, selectedNodeKeys: new Set(['top:1']), sourceSelections: {}, }, }; } function createLoraTransferState(sourceModels, activityScope = 'all') { const targetGroups = [{ category: 'loras', nodes: [{ nodeKey: 'top:2', nodeId: 2, nodeType: 'LoraLoaderV2', refs: [ { category: 'loras', node_id: 2, node_type: 'LoraLoaderV2', widget_index: 2, original_path: 'active-current.safetensors', active: true, transferKey: 'loras:top:2:0', }, { category: 'loras', node_id: 2, node_type: 'LoraLoaderV2', widget_index: 2, original_path: 'inactive-current.safetensors', active: false, transferKey: 'loras:top:2:1', }, ], }], }]; return { loadedModels: { loaded_models: sourceModels }, transfer: { mode: 'replace-add', activityScope, targetGroups, selectedNodeKeys: new Set(['top:2']), sourceSelections: {}, }, }; } test('metadata transfer persists only mode and activity scope preferences', () => { const context = createTransferContext(); assert.deepEqual(context.getMetadataTransferPreferences(), { mode: 'replace', activityScope: 'all', }); assert.equal(context.setMetadataTransferMode('merge'), 'merge'); assert.equal(context.setMetadataTransferActivityScope('inactive'), 'inactive'); assert.deepEqual(context.getMetadataTransferPreferences(), { mode: 'merge', activityScope: 'inactive', }); }); test('metadata transfer refresh updates only the transfer panel when it is mounted', () => { const state = createTransferState([ { category: 'text_encoders', original_path: 'imported.safetensors' }, ]); const currentPanel = { outerHTML: '
old
' }; const result = { querySelector(selector) { assert.equal(selector, '.mr-image-transfer'); return currentPanel; }, }; let fullRenderCount = 0; let tooltipBindCount = 0; const context = { ...imageMetadataMethods, imageInspectorState: state, contentElement: { querySelector(selector) { assert.equal(selector, '[data-image-inspector-result]'); return result; }, }, renderMetadataTransferPanel: () => '
updated
', renderImageMetadataResult() { fullRenderCount += 1; }, bindTooltips() { tooltipBindCount += 1; }, }; assert.equal(context.renderMetadataTransferPanelInPlace(), true); assert.equal(currentPanel.outerHTML, '
updated
'); assert.equal(fullRenderCount, 0); assert.equal(tooltipBindCount, 1); }); test('metadata transfer shows a spinner while applying', () => { const context = createTransferContext(); const state = createTransferState([ { category: 'text_encoders', original_path: 'imported.safetensors' }, ]); state.transfer.applying = true; const html = context.renderMetadataTransferPanel(state); assert.match(html, /aria-busy="true"/); assert.match(html, /class="mr-image-transfer-action-spinner" aria-hidden="true"/); assert.match(html, /Applying\.\.\.<\/span>/); }); test('metadata transfer guidance is available through a question tooltip', () => { const context = createTransferContext(); const state = createTransferState([]); state.loadedModels = null; const html = context.renderMetadataTransferPanel(state); assert.match(html, /class="mr-tooltip-badge"[^>]*data-tooltip="[\s\S]*Select a node row to include it in the transfer/); assert.match(html, /Import a workflow with model metadata to enable Apply transfer/); assert.doesNotMatch(html, /class="mr-image-transfer-note"/); }); test('metadata transfer keeps target node checkboxes selectable without an imported workflow', () => { const context = createTransferContext(); const state = createTransferState([]); state.loadedModels = null; state.transfer.selectedNodeKeys = new Set(); let renderCount = 0; context.imageInspectorState = state; context.renderMetadataTransferPanelInPlace = () => { renderCount += 1; return true; }; const html = context.renderMetadataTransferPanel(state); assert.match(html, /data-image-transfer-node="top:1"/); const targetButton = html.match(/]*data-image-transfer-node="top:1"[^>]*>/)?.[0] || ''; assert.doesNotMatch(targetButton, /\sdisabled(?:\s|=|>)/); context.toggleMetadataTransferNode('top:1'); assert.deepEqual(Array.from(state.transfer.selectedNodeKeys), ['top:1']); assert.equal(renderCount, 1); }); test('metadata transfer does not disable a target category missing from the imported workflow', () => { const context = createTransferContext(); const state = createTransferState([ { category: 'checkpoints', original_path: 'imported-checkpoint.safetensors' }, ]); const html = context.renderMetadataTransferPanel(state); assert.match(html, /No source model/); const targetButton = html.match(/]*data-image-transfer-node="top:1"[^>]*>/)?.[0] || ''; assert.doesNotMatch(targetButton, /\sdisabled(?:\s|=|>)/); }); test('metadata transfer exposes Undo transfer while the post-transfer workflow is unchanged', () => { const context = createTransferContext(); const state = createTransferState([ { category: 'text_encoders', original_path: 'imported.safetensors' }, ]); state.lastTransfer = { workflow: { signature: 'before' }, afterSignature: 'after', undoing: false, }; context.imageInspectorState = state; context.getCurrentWorkflow = () => ({ signature: 'after' }); context.getWorkflowSignature = workflow => workflow.signature; const html = context.renderMetadataTransferPanel(state); assert.match(html, /data-image-inspector-action="undo-transfer"/); assert.match(html, />\s*Undo transfer\s*<\/button>/); context.getCurrentWorkflow = () => ({ signature: 'changed' }); const changedHtml = context.renderMetadataTransferPanel(state); assert.match(changedHtml, /data-image-inspector-action="undo-transfer" disabled/); assert.match(changedHtml, /title="The current workflow changed after the last transfer\."/); assert.match(changedHtml, />\s*Undo transfer\s*<\/button>/); }); test('metadata transfer undo restores the previous workflow and consumes its history', async () => { const previousWorkflow = { signature: 'before', nodes: [{ id: 1, widgets_values: ['old-model.safetensors'] }], }; let currentWorkflow = { signature: 'after', nodes: [{ id: 1, widgets_values: ['new-model.safetensors'] }], }; let restoredWorkflow = null; const notifications = []; const state = { transfer: { undoing: false }, lastTransfer: { workflow: previousWorkflow, beforeSignature: 'before', afterSignature: 'after', undoing: false, }, }; const context = { ...imageMetadataMethods, imageInspectorState: state, getCurrentWorkflow: () => currentWorkflow, getWorkflowSignature: workflow => workflow.signature, updateWorkflowInComfyUI: async workflow => { restoredWorkflow = workflow; currentWorkflow = workflow; return true; }, renderImageMetadataResult() {}, openMetadataTransfer() { state.transfer = { undoing: false }; }, showNotification(message, type) { notifications.push({ message, type }); }, cachedLoadedModelsSignature: 'after', cachedLoadedModelsData: { loaded_models: [] }, activeWorkflowSignature: 'after', }; const previousWindow = globalThis.window; globalThis.window = { dispatchEvent() {} }; try { await context.undoMetadataTransfer(); } finally { if (previousWindow === undefined) delete globalThis.window; else globalThis.window = previousWindow; } assert.deepEqual(restoredWorkflow, previousWorkflow); assert.notEqual(restoredWorkflow, previousWorkflow); assert.equal(context.imageInspectorState.lastTransfer, null); assert.equal(context.imageInspectorState.transfer.undoing, false); assert.equal(context.activeWorkflowSignature, 'before'); assert.equal(context.cachedLoadedModelsSignature, null); assert.equal(context.cachedLoadedModelsData, null); assert.deepEqual(notifications, [{ message: 'Undid the last model transfer.', type: 'success', }]); }); test('metadata transfer undo refuses to restore a changed workflow', async () => { const state = { lastTransfer: { workflow: { signature: 'before' }, afterSignature: 'after', undoing: false, }, }; let updateCalled = false; const notifications = []; const context = { ...imageMetadataMethods, imageInspectorState: state, getCurrentWorkflow: () => ({ signature: 'changed' }), getWorkflowSignature: workflow => workflow.signature, updateWorkflowInComfyUI: async () => { updateCalled = true; return true; }, renderImageMetadataResult() {}, showNotification(message, type) { notifications.push({ message, type }); }, }; const result = await context.undoMetadataTransfer(); assert.equal(result, null); assert.equal(updateCalled, false); assert.equal(state.lastTransfer.undoing, false); assert.deepEqual(notifications, [{ message: 'The current workflow changed after the last transfer.', type: 'warning', }]); }); test('metadata tab reuses transfer state for the unchanged workflow', () => { const context = createTransferContext(); context.getWorkflowSignature = workflow => workflow.signature; const state = { transfer: { workflowSignature: 'workflow-a' } }; assert.equal( context.isMetadataTransferCurrentWorkflow(state, { signature: 'workflow-a' }), true ); assert.equal( context.isMetadataTransferCurrentWorkflow(state, { signature: 'workflow-b' }), false ); assert.equal( context.isMetadataTransferCurrentWorkflow({ transfer: { workflowSignature: '' } }, null), true ); assert.equal(context.isMetadataTransferCurrentWorkflow(null, null), false); }); test('metadata transfer reuses the shared Loaded Models cache for the same workflow', async () => { const cachedData = { loaded_models: [{ category: 'text_encoders', node_id: 1, node_type: 'CLIPLoader', widget_index: 0, original_path: 'current.safetensors', active: true, connected: true, is_top_level: true, }], total: 1, }; let fetchCount = 0; const context = { ...imageMetadataMethods, getCurrentWorkflow: () => ({ signature: 'workflow-a' }), getWorkflowSignature: workflow => workflow.signature, cachedLoadedModelsSignature: 'workflow-a', cachedLoadedModelsData: cachedData, getCachedLoadedModelsForSignature(signature, { force = false } = {}) { return !force && signature === this.cachedLoadedModelsSignature ? this.cachedLoadedModelsData : null; }, fetchJson: async () => { fetchCount += 1; throw new Error('The shared cache was not used'); }, renderImageMetadataResult() {}, }; const result = await context.openMetadataTransfer(); assert.equal(fetchCount, 0); assert.equal(result, cachedData); assert.equal(context.imageInspectorState.transfer.targetModels, cachedData); }); test('metadata transfer stores a fresh target scan in the shared Loaded Models cache', async () => { const freshData = { loaded_models: [], total: 0 }; let saveCount = 0; const context = { ...imageMetadataMethods, getCurrentWorkflow: () => ({ signature: 'workflow-b' }), getWorkflowSignature: workflow => workflow.signature, cachedLoadedModelsSignature: null, cachedLoadedModelsData: null, getCachedLoadedModelsForSignature() { return null; }, fetchJson: async () => freshData, saveLoadedModelsCacheForActiveWorkflow() { saveCount += 1; }, renderImageMetadataResult() {}, }; const result = await context.openMetadataTransfer(); assert.equal(result, freshData); assert.equal(context.cachedLoadedModelsSignature, 'workflow-b'); assert.equal(context.cachedLoadedModelsData, freshData); assert.equal(saveCount, 1); }); test('metadata transfer refresh keeps the current panel visible while scanning a changed workflow', async () => { const oldTargetGroup = { category: 'text_encoders', nodes: [{ nodeKey: 'top:1', nodeId: 1, nodeType: 'CLIPLoader', refs: [], }], }; const sourceModel = { category: 'text_encoders', original_path: 'imported.safetensors', }; let resolveFetch; let renderCount = 0; const context = { ...imageMetadataMethods, imageInspectorState: { requestToken: 'source-request', loadedModels: { loaded_models: [sourceModel] }, transfer: { open: true, workflowSignature: 'old-workflow', targetModels: { loaded_models: [] }, targetGroups: [oldTargetGroup], selectedNodeKeys: new Set(['top:1']), collapsedCategories: new Set(['text_encoders']), sourceSelections: {}, }, }, getCurrentWorkflow: () => ({ signature: 'new-workflow' }), getWorkflowSignature: workflow => workflow.signature, syncWorkflowScopedQueue() {}, getCachedLoadedModelsForSignature() { return null; }, getMetadataTransferAcceptedFileTypes() { return []; }, fetchJson: () => new Promise(resolve => { resolveFetch = resolve; }), saveLoadedModelsCacheForActiveWorkflow() {}, renderImageMetadataResult() { renderCount += 1; }, }; const refreshPromise = context.openMetadataTransfer({ preserveContent: true }); await new Promise(resolve => setTimeout(resolve, 0)); assert.equal(renderCount, 0); assert.equal(context.imageInspectorState.transfer.refreshing, true); assert.deepEqual(context.imageInspectorState.transfer.targetGroups, [oldTargetGroup]); assert.deepEqual( Array.from(context.imageInspectorState.transfer.selectedNodeKeys), ['top:1'] ); resolveFetch({ loaded_models: [{ category: 'text_encoders', node_id: 1, node_type: 'CLIPLoader', widget_index: 0, original_path: 'current.safetensors', active: true, connected: true, is_top_level: true, }], total: 1, }); await refreshPromise; assert.equal(renderCount, 1); assert.equal(context.imageInspectorState.transfer.refreshing, false); assert.equal(context.imageInspectorState.transfer.targetGroups[0].nodes[0].nodeKey, 'top:1'); assert.deepEqual( Array.from(context.imageInspectorState.transfer.selectedNodeKeys), ['top:1'] ); }); test('metadata transfer uses the shared canonical category aliases', () => { const context = createTransferContext(); const state = createTransferState([ { category: 'clip_gguf', original_path: 'clip.safetensors' }, ]); state.transfer.targetGroups[0].category = 'clips'; state.transfer.targetGroups[0].nodes[0].refs[0].category = 'clips'; const [row] = context.getMetadataTransferPreviewRows(state); assert.equal(row.sourceUnavailable, false); assert.equal(row.nextValue, 'clip.safetensors'); }); test('metadata transfer hides imported models with an unsupported file extension', () => { const context = createTransferContext(); const state = createTransferState([ { category: 'text_encoders', original_path: 'clip.gguf' }, { category: 'text_encoders', original_path: 'clip.safetensors' }, ]); const [row] = context.getMetadataTransferPreviewRows(state); assert.deepEqual(row.sourceOptions.map(option => option.value), ['clip.safetensors']); assert.equal(row.nextValue, 'clip.safetensors'); assert.equal(row.sourceUnavailable, false); }); test('metadata transfer blocks a selected node when no imported model matches its format', () => { const context = createTransferContext(); const state = createTransferState([ { category: 'text_encoders', original_path: 'clip.gguf' }, ]); const [row] = context.getMetadataTransferPreviewRows(state); assert.equal(row.sourceOptions.length, 0); assert.equal(row.nextLabel, 'No compatible model'); assert.equal(row.sourceUnavailable, true); }); test('replace/add preview replaces existing LoRAs and shows remaining models as additions', () => { const context = createTransferContext(); const state = createLoraTransferState([ { category: 'loras', original_path: 'new-a.safetensors' }, { category: 'loras', original_path: 'new-b.safetensors' }, { category: 'loras', original_path: 'new-c.safetensors' }, ]); const rows = context.getMetadataTransferPreviewRows(state); assert.equal(rows.length, 3); assert.deepEqual(rows.slice(0, 2).map(row => row.operation), ['replace', 'replace']); assert.equal(rows[0].nextValue, 'new-a.safetensors'); assert.equal(rows[1].nextValue, 'new-b.safetensors'); assert.equal(rows[2].operation, 'add'); assert.equal(rows[2].nextValue, 'new-c.safetensors'); }); test('metadata transfer preview adds model actions and previews only for local files', () => { const context = createTransferContext(); const state = createTransferState([ { category: 'text_encoders', original_path: 'imported.safetensors', exists: true, resolved_path: 'C:\\models\\imported.safetensors', }, ]); state.transfer.targetGroups[0].nodes[0].refs[0] = { ...state.transfer.targetGroups[0].nodes[0].refs[0], exists: true, resolved_path: 'C:\\models\\current.safetensors', }; const [row] = context.getMetadataTransferPreviewRows(state); const html = context.renderMetadataTransferPreview([row]); assert.equal(row.nextModel.resolved_path, 'C:\\models\\imported.safetensors'); assert.match(html, /data-context-scope="loaded_model"/); assert.match(html, /data-context-scope="local_model"/); assert.match(html, /data-tooltip-image="C:\\models\\current\.safetensors"/); assert.match(html, /data-tooltip-image="C:\\models\\imported\.safetensors"/); state.loadedModels.loaded_models[0].exists = false; const [missingRow] = context.getMetadataTransferPreviewRows(state); const missingHtml = context.renderMetadataTransferPreview([missingRow]); assert.doesNotMatch(missingHtml, /data-context-scope="local_model"/); assert.doesNotMatch(missingHtml, /data-tooltip-image="C:\\models\\imported\.safetensors"/); }); test('unselected target nodes keep current model actions available', () => { const context = createTransferContext(); const state = createTransferState([ { category: 'text_encoders', original_path: 'imported.safetensors' }, ]); state.transfer.selectedNodeKeys = new Set(); state.transfer.targetGroups[0].nodes[0].refs[0] = { ...state.transfer.targetGroups[0].nodes[0].refs[0], exists: true, resolved_path: 'C:\\models\\current.safetensors', }; const html = context.renderMetadataTransferPanel(state); assert.match(html, /class="mr-image-transfer-change is-unselected"/); assert.match(html, /data-context-scope="loaded_model"/); assert.match(html, /data-tooltip-image="C:\\models\\current\.safetensors"/); }); test('LoRA activity scope filters imported models but keeps all target slots eligible', () => { const context = createTransferContext(); const state = createLoraTransferState([ { category: 'loras', original_path: 'new-active.safetensors', active: true }, { category: 'loras', original_path: 'new-inactive-a.safetensors', active: false }, { category: 'loras', original_path: 'new-inactive-b.safetensors', active: false }, { category: 'loras', original_path: 'new-inactive-c.safetensors', active: false }, ], 'inactive'); const rows = context.getMetadataTransferPreviewRows(state); assert.equal(rows.length, 3); assert.equal(rows[0].currentValue, 'active-current.safetensors'); assert.equal(rows[0].nextValue, 'new-inactive-a.safetensors'); assert.equal(rows[1].currentValue, 'inactive-current.safetensors'); assert.equal(rows[1].nextValue, 'new-inactive-b.safetensors'); assert.equal(rows[0].operation, 'replace'); assert.equal(rows[1].operation, 'replace'); assert.equal(rows[2].operation, 'add'); assert.equal(rows[2].nextValue, 'new-inactive-c.safetensors'); }); test('workflow node context keeps Locate Node but hides model info', () => { const contextMenu = { style: { display: 'none', left: '', top: '' }, querySelector() { return null; }, getBoundingClientRect() { return { right: 0, bottom: 0 }; }, }; const visibility = {}; const showContextMenu = eval(`(${extractMethod(modelInfoMethodsSource, 'showContextMenu')})`); const dialog = { contextMenu, hideTooltip() {}, canShowSourceDetails() { return false; }, getContextMenuSourceLink() { return null; }, getContextMenuSourceLookupModel(value) { return value; }, canSuggestDownloadSubfolderFromContextMenu() { return false; }, setContextMenuItemVisible(action, visible) { visibility[action] = visible; }, setContextMenuDividerVisible() {}, updateContextMenuSourceItem() {}, refreshContextMenuSourceLink() {}, }; const previousWindow = globalThis.window; globalThis.window = { innerWidth: 1000, innerHeight: 1000 }; try { showContextMenu.call(dialog, 10, 20, { context_scope: 'workflow_node', node_id: 42, }); } finally { if (previousWindow === undefined) delete globalThis.window; else globalThis.window = previousWindow; } assert.match(imageMetadataMethodsSource, /context_scope: 'workflow_node'/); assert.equal(visibility.showInfo, false); assert.equal(visibility.locateNode, true); });