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: '' };
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: () => '',
renderImageMetadataResult() {
fullRenderCount += 1;
},
bindTooltips() {
tooltipBindCount += 1;
},
};
assert.equal(context.renderMetadataTransferPanelInPlace(), true);
assert.equal(currentPanel.outerHTML, '');
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(/