521 lines
17 KiB
JavaScript
521 lines
17 KiB
JavaScript
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, '"')
|
|
.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 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);
|
|
});
|