Files
DemonGatanjieuandClaude Opus 5.5 7b09909e5b test: publish the plugin's tests, one command to run them
tests/ (Python backend tests, .mjs frontend rule tests) is no longer
ignored. tools/run_tests.mjs runs the structure check, every
tests/*.mjs and the Python tests with $PYTHON, the portable
python_embeded or python. docs/guides/testing.md says how to run and
write them; README links it. Developer notes no longer point at 18 test
files that were removed earlier (AGENTS.md table, backend, frontend and
update-guide notes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:47:35 +08:00

544 lines
24 KiB
JavaScript

import assert from 'node:assert/strict';
import {
extractMaterialPromptEnvelope,
fillPrompt as dispatchPromptInjection,
applyNodeMaterialValues,
isModelFilePath,
sanitizePromptText,
PROMPT_EXTRACTORS,
} from '../web/modules/node_material_actions.js';
import { promptBoxes, typeTakesPrompt } from '../web/modules/prompt_boxes.js';
// A saved block's values into a node of its type (the library's block apply, removed with the
// Material Library page), to exercise applyNodeMaterialValues' value and hash rules.
function applyMaterialBlock(app, node, block, workflowHashes) {
if (node?.type !== block?.type || !Array.isArray(block.widgets_values)
|| block.widgets_values.length > (node.widgets?.length || 0)) throw new Error('materialNoCompatibleValues');
return applyNodeMaterialValues(app, node, block.widgets_values.map((value, index) => ({ index, value })),
{ sourceNodeId: block.node_id, workflowHashes });
}
const multiline = ['STRING', { multiline: true }];
globalThis.LiteGraph = { registered_node_types: {
'easy a1111Loader': { nodeData: { input: { required: { positive: multiline, negative: multiline } } } },
'easy fullLoader': { nodeData: { input: { required: { positive: multiline, negative: multiline } } } },
'easy positive': { nodeData: { input: { required: { positive: multiline } } } },
'easy negative': { nodeData: { input: { required: { negative: multiline } } } },
CLIPTextEncode: { nodeData: { input: { required: { text: multiline, clip: ['CLIP'] } } } },
LoraLoader: { nodeData: { input: { required: { lora_name: [[]], strength_model: ['FLOAT'] } } } },
} };
function createEasyA1111LoaderFixture() {
const widgets = [
{ name: 'ckpt_name', value: 'v1-5-pruned-emaonly.safetensors', type: 'combo' },
{ name: 'vae_name', value: 'vae-ft-mse-840000-ema_pruned.safetensors', type: 'combo' },
{ name: 'clip_skip', value: -1, type: 'number' },
{ name: 'lora_name', value: 'None', type: 'combo' },
{ name: 'lora_model_strength', value: 1.0, type: 'number' },
{ name: 'lora_clip_strength', value: 1.0, type: 'number' },
{ name: 'positive', value: 'initial positive prompt', type: 'customtext', options: { multiline: true } },
{ name: 'negative', value: 'initial negative prompt', type: 'customtext', options: { multiline: true } },
{ name: 'empty_latent_width', value: 512, type: 'number' },
{ name: 'empty_latent_height', value: 768, type: 'number' },
{ name: 'batch_size', value: 1, type: 'number' },
{ name: 'seed', value: 123456789, type: 'number' },
{ name: 'control_after_generate', value: 'fixed', type: 'combo' },
{ name: 'steps', value: 25, type: 'number' },
{ name: 'cfg', value: 7.5, type: 'number' },
{ name: 'sampler_name', value: 'euler_ancestral', type: 'combo' },
{ name: 'scheduler', value: 'karras', type: 'combo' },
];
const node = {
id: 10,
type: 'easy a1111Loader',
title: 'Easy A1111 Loader',
widgets,
widgets_values: widgets.map(w => w.value),
};
const calls = [];
node.onWidgetChanged = (index, value, old, widget) => {
calls.push({ index, value, old, name: widget.name });
};
const graph = {
getNodeById: id => (id === node.id ? node : null),
beforeChange: () => calls.push('before'),
afterChange: () => calls.push('after'),
change() {},
setDirtyCanvas() {},
};
const app = {
graph,
canvas: { selected_nodes: { 10: node }, setDirty() {} },
};
return { node, graph, app, calls };
}
// ============================================================================
// Test 1: promptBoxes on easy a1111Loader (boxes by definition, roles by name)
// ============================================================================
{
const { node } = createEasyA1111LoaderFixture();
assert.deepEqual(promptBoxes(node).map(box => [box.index, box.name, box.role]), [[6, 'positive', 'positive'], [7, 'negative', 'negative']]);
}
// ============================================================================
// Test 2: Strategy ① - Dual-Slot Pair Injection into easy a1111Loader
// ============================================================================
{
const { node, app, calls } = createEasyA1111LoaderFixture();
const envelope = {
hasPrompt: true,
positive: '1girl, cyberpunk city, neon lights, masterpiece',
negative: 'bad hands, low quality, deformed, blurry',
singleText: '1girl, cyberpunk city, neon lights, masterpiece',
primaryRole: 'both',
};
const result = dispatchPromptInjection(app, node, envelope);
assert.equal(result.widgets, 2);
// Verify positive and negative injected into their respective slots
assert.equal(node.widgets[6].value, '1girl, cyberpunk city, neon lights, masterpiece');
assert.equal(node.widgets[7].value, 'bad hands, low quality, deformed, blurry');
assert.equal(node.widgets_values[6], '1girl, cyberpunk city, neon lights, masterpiece');
assert.equal(node.widgets_values[7], 'bad hands, low quality, deformed, blurry');
// Verify other widgets completely untouched
assert.equal(node.widgets[0].value, 'v1-5-pruned-emaonly.safetensors');
assert.equal(node.widgets[11].value, 123456789);
// Verify atomic undo
result.undo();
assert.equal(node.widgets[6].value, 'initial positive prompt');
assert.equal(node.widgets[7].value, 'initial negative prompt');
assert.equal(node.widgets_values[6], 'initial positive prompt');
assert.equal(node.widgets_values[7], 'initial negative prompt');
}
// ============================================================================
// Test 3: Strategy ② - Role-Matched Negative Prompt Injection (Fixes index 0 bug)
// ============================================================================
{
const { node, app } = createEasyA1111LoaderFixture();
const envelope = {
hasPrompt: true,
positive: '',
negative: 'ugly, bad anatomy, bad feet, lowres',
singleText: 'ugly, bad anatomy, bad feet, lowres',
primaryRole: 'negative',
};
const result = dispatchPromptInjection(app, node, envelope);
assert.equal(result.widgets, 1);
// Positive must REMAIN unchanged!
assert.equal(node.widgets[6].value, 'initial positive prompt');
assert.equal(node.widgets_values[6], 'initial positive prompt');
// Negative must be accurately updated!
assert.equal(node.widgets[7].value, 'ugly, bad anatomy, bad feet, lowres');
assert.equal(node.widgets_values[7], 'ugly, bad anatomy, bad feet, lowres');
// Undo restores negative
result.undo();
assert.equal(node.widgets[7].value, 'initial negative prompt');
}
// ============================================================================
// Test 4: Single-Slot Role Nodes (easy positive & easy negative)
// ============================================================================
{
// easy positive
const posWidgets = [{ name: 'positive', value: 'old pos' }];
const posNode = {
id: 20,
type: 'easy positive',
title: 'Easy Positive',
widgets: posWidgets,
widgets_values: ['old pos'],
};
const appPos = {
graph: { getNodeById: id => id === 20 ? posNode : null, beforeChange() {}, afterChange() {}, change() {}, setDirtyCanvas() {} },
canvas: { selected_nodes: { 20: posNode }, setDirty() {} },
};
assert.deepEqual(promptBoxes(posNode).map(box => [box.index, box.role]), [[0, 'positive']]);
const dualEnvelope = {
hasPrompt: true,
positive: 'beautiful scenery, sunset',
negative: 'clouds, rain',
singleText: 'beautiful scenery, sunset',
primaryRole: 'both',
};
dispatchPromptInjection(appPos, posNode, dualEnvelope);
assert.equal(posNode.widgets[0].value, 'beautiful scenery, sunset');
// easy negative
const negWidgets = [{ name: 'negative', value: 'old neg' }];
const negNode = {
id: 21,
type: 'easy negative',
title: 'Easy Negative',
widgets: negWidgets,
widgets_values: ['old neg'],
};
const appNeg = {
graph: { getNodeById: id => id === 21 ? negNode : null, beforeChange() {}, afterChange() {}, change() {}, setDirtyCanvas() {} },
canvas: { selected_nodes: { 21: negNode }, setDirty() {} },
};
assert.deepEqual(promptBoxes(negNode).map(box => [box.index, box.role]), [[0, 'negative']]);
dispatchPromptInjection(appNeg, negNode, dualEnvelope);
assert.equal(negNode.widgets[0].value, 'clouds, rain');
}
// ============================================================================
// Test 5: Standard CLIPTextEncode (General Slot)
// ============================================================================
{
const clipWidgets = [{ name: 'text', value: 'old clip text' }];
const clipNode = {
id: 30,
type: 'CLIPTextEncode',
title: 'CLIP Text Encode (Prompt)',
widgets: clipWidgets,
widgets_values: ['old clip text'],
};
const appClip = {
graph: { getNodeById: id => id === 30 ? clipNode : null, beforeChange() {}, afterChange() {}, change() {}, setDirtyCanvas() {} },
canvas: { selected_nodes: { 30: clipNode }, setDirty() {} },
};
assert.deepEqual(promptBoxes(clipNode).map(box => [box.index, box.role]), [[0, '']]);
// Single positive
dispatchPromptInjection(appClip, clipNode, {
hasPrompt: true,
positive: 'cyberpunk girl',
negative: '',
singleText: 'cyberpunk girl',
primaryRole: 'positive',
});
assert.equal(clipNode.widgets[0].value, 'cyberpunk girl');
// Single negative
dispatchPromptInjection(appClip, clipNode, {
hasPrompt: true,
positive: '',
negative: 'worst quality',
singleText: 'worst quality',
primaryRole: 'negative',
});
assert.equal(clipNode.widgets[0].value, 'worst quality');
}
// ============================================================================
// Test 6: extractMaterialPromptEnvelope Extraction Correctness
// ============================================================================
{
// A: prompt_plan with positive and negative
const planMaterial = {
kind: 'prompt_plan',
plan: {
positive: 'masterpiece, 8k',
negative: 'low quality, worst quality',
parts: [],
},
};
const envPlan = extractMaterialPromptEnvelope(planMaterial);
assert.equal(envPlan.hasPrompt, true);
assert.equal(envPlan.primaryRole, 'both');
assert.equal(envPlan.positive, 'masterpiece, 8k');
assert.equal(envPlan.negative, 'low quality, worst quality');
// B: prompt_text note marked as negative
const noteMaterial = {
kind: 'prompt_text',
name: '反向提示词常用',
note: {
promptEn: 'bad hands, blurry',
promptZh: '坏手,模糊',
},
};
const envNote = extractMaterialPromptEnvelope(noteMaterial);
assert.equal(envNote.hasPrompt, true);
assert.equal(envNote.primaryRole, 'negative');
assert.equal(envNote.negative, 'bad hands, blurry');
// C: image material with node_blocks and prompt_roles
const imagePayload = {
node_blocks: [
{ node_id: 6, type: 'CLIPTextEncode', widgets_values: ['high quality, 1girl'] },
{ node_id: 7, type: 'CLIPTextEncode', widgets_values: ['ugly, deformed'] },
],
prompt_roles: {
'6': { role: 'positive', source: 'automatic' },
'7': { role: 'negative', source: 'automatic' },
},
};
const envImage = extractMaterialPromptEnvelope({}, imagePayload);
assert.equal(envImage.hasPrompt, true);
assert.equal(envImage.primaryRole, 'both');
assert.equal(envImage.positive, 'high quality, 1girl');
assert.equal(envImage.negative, 'ugly, deformed');
}
// ============================================================================
// Test 7: Regression test - Same-type block application remains strictly isolated
// ============================================================================
{
const ksamplerNode = {
id: 50,
type: 'KSampler',
widgets: [
{ name: 'seed', value: 100 },
{ name: 'steps', value: 20 },
{ name: 'cfg', value: 8 },
],
widgets_values: [100, 20, 8],
};
const appKS = {
graph: { getNodeById: id => id === 50 ? ksamplerNode : null, beforeChange() {}, afterChange() {}, change() {}, setDirtyCanvas() {} },
canvas: { selected_nodes: { 50: ksamplerNode }, setDirty() {} },
};
const ksamplerBlock = {
node_id: 3,
type: 'KSampler',
widgets_values: [999, 35, 7.5],
};
// Applying same-type block
const result = applyMaterialBlock(appKS, ksamplerNode, ksamplerBlock);
assert.equal(ksamplerNode.widgets_values[0], 100); // seed is volatile
assert.equal(ksamplerNode.widgets_values[1], 35);
assert.equal(ksamplerNode.widgets_values[2], 7.5);
// Mismatched node type must throw
const otherBlock = {
node_id: 4,
type: 'DifferentNode',
widgets_values: [1, 2, 3],
};
assert.throws(() => applyMaterialBlock(appKS, ksamplerNode, otherBlock), /materialNoCompatibleValues/);
}
// ============================================================================
// Test 8: isModelFilePath & isPromptNodeType filtering rules
// ============================================================================
{
assert.equal(isModelFilePath('Tools\\Aesthetic Quality Modifiers - Masterpiece_v5.0 [anima-preview-3].safetensors'), true);
assert.equal(isModelFilePath('2D_Anime/Anima/Style - Chen bin鬼针草 [Anima]_Anima v4.0.safetensors'), true);
assert.equal(isModelFilePath('Anima\\qwen_3_06b_base.safetensors'), true);
assert.equal(isModelFilePath('model.ckpt'), true);
assert.equal(isModelFilePath('vae.pt'), true);
assert.equal(isModelFilePath('model.gguf'), true);
// Genuine prompts must NOT be flagged as model paths
assert.equal(isModelFilePath('masterpiece, 1girl, cyberpunk city, neon lights'), false);
assert.equal(isModelFilePath('bad hands, lowres, blurry'), false);
assert.equal(isModelFilePath(''), false);
// A type takes a prompt when its definition has a multiline text input.
assert.equal(typeTakesPrompt('CLIPTextEncode'), true);
assert.equal(typeTakesPrompt('easy positive'), true);
assert.equal(typeTakesPrompt('LoraLoader'), false);
assert.equal(typeTakesPrompt('NotInstalledNode'), false);
}
// ============================================================================
// Test 9: Realistic workflow snapshot with multiple LoRA / UNET / VAE blocks
// (Strictly prevents model filenames from leaking into prompt text boxes)
// ============================================================================
{
// Simulating material_1789723104
const workflowPayload = {
node_blocks: [
{ node_id: 27, type: 'LoraLoader', widgets_values: ['Tools\\Aesthetic Quality Modifiers - Masterpiece_v5.0 [anima-preview-3].safetensors', 1.2, 1] },
{ node_id: 32, type: 'LoraLoader', widgets_values: ['2D_Anime\\Anima\\Style - Chen bin鬼针草 [Anima]_Anima v4.0.safetensors', 1, 1] },
{ node_id: 26, type: 'LoraLoader', widgets_values: ['2D_Anime\\Anima\\over-thigh-thighhighs_anima-base-v1.0.safetensors', 1, 1] },
{ node_id: 1, type: 'UNETLoader', widgets_values: ['Anima\\Kirazuri (Anima)_v4.0 [anima-base-1].safetensors', 'default'] },
{ node_id: 8, type: 'VAELoader', widgets_values: ['Anima\\qwenImageGGUF_vae.safetensors'] },
{ node_id: 22, type: 'CLIPTextEncode', widgets_values: ['masterpiece, high quality anime illustration, young adult woman'] },
{ node_id: 6, type: 'CLIPTextEncode', widgets_values: ['mature woman, older woman, onee-san, child, loli, bad hands'] },
],
prompt_roles: {
'22': { role: 'positive', source: 'automatic' },
'6': { role: 'negative', source: 'automatic' },
},
prompt_groups: {
positive: ['masterpiece, high quality anime illustration, young adult woman'],
negative: ['mature woman, older woman, onee-san, child, loli, bad hands'],
},
};
const envelope = extractMaterialPromptEnvelope({}, workflowPayload);
assert.equal(envelope.hasPrompt, true);
assert.equal(envelope.primaryRole, 'both');
// CRITICAL: positive prompt must NOT contain ANY .safetensors file paths!
assert.equal(envelope.positive, 'masterpiece, high quality anime illustration, young adult woman');
assert.equal(envelope.positive.includes('.safetensors'), false);
// Negative prompt must be purely the negative prompt
assert.equal(envelope.negative, 'mature woman, older woman, onee-san, child, loli, bad hands');
assert.equal(envelope.negative.includes('.safetensors'), false);
// Now inject into easy fullLoader fixture
const fullLoaderWidgets = [
{ name: 'ckpt_name', value: 'model.safetensors', type: 'combo' },
{ name: 'config_name', value: 'Default', type: 'combo' },
{ name: 'vae_name', value: 'Baked VAE', type: 'combo' },
{ name: 'clip_skip', value: -2, type: 'number' },
{ name: 'lora_name', value: 'None', type: 'combo' },
{ name: 'resolution', value: '512 x 512', type: 'combo' },
{ name: 'positive', value: 'old positive', type: 'customtext', options: { multiline: true } },
{ name: 'positive_token_normalization', value: 'none', type: 'combo' },
{ name: 'positive_weight_interpretation', value: 'comfy', type: 'combo' },
{ name: 'negative', value: 'old negative', type: 'customtext', options: { multiline: true } },
{ name: 'negative_token_normalization', value: 'none', type: 'combo' },
{ name: 'negative_weight_interpretation', value: 'comfy', type: 'combo' },
{ name: 'batch_size', value: 1, type: 'number' },
{ name: 'a1111_prompt_style', value: false, type: 'combo' },
];
const fullLoaderNode = {
id: 99,
type: 'easy fullLoader',
title: '简易加载器 (完整版)',
widgets: fullLoaderWidgets,
widgets_values: fullLoaderWidgets.map(w => w.value),
};
const appFull = {
graph: { getNodeById: id => id === 99 ? fullLoaderNode : null, beforeChange() {}, afterChange() {}, change() {}, setDirtyCanvas() {} },
canvas: { selected_nodes: { 99: fullLoaderNode }, setDirty() {} },
};
assert.deepEqual(promptBoxes(fullLoaderNode).map(box => [box.index, box.role]), [[6, 'positive'], [9, 'negative']]);
dispatchPromptInjection(appFull, fullLoaderNode, envelope);
// Verify positive textarea (index 6) has ONLY the positive prompt!
assert.equal(fullLoaderNode.widgets[6].value, 'masterpiece, high quality anime illustration, young adult woman');
assert.equal(fullLoaderNode.widgets[6].value.includes('.safetensors'), false);
// Verify negative textarea (index 9) has ONLY the negative prompt!
assert.equal(fullLoaderNode.widgets[9].value, 'mature woman, older woman, onee-san, child, loli, bad hands');
assert.equal(fullLoaderNode.widgets[9].value.includes('.safetensors'), false);
// Verify non-prompt widgets (ckpt_name, resolution, etc.) are untouched!
assert.equal(fullLoaderNode.widgets[0].value, 'model.safetensors');
assert.equal(fullLoaderNode.widgets[4].value, 'None');
assert.equal(fullLoaderNode.widgets[5].value, '512 x 512');
}
// ============================================================================
// Test 10: sanitizePromptText unit guardrails
// ============================================================================
{
assert.equal(sanitizePromptText(null), '');
assert.equal(sanitizePromptText(undefined), '');
assert.equal(sanitizePromptText(12345), '');
assert.equal(sanitizePromptText(' '), '');
assert.equal(sanitizePromptText('model.safetensors'), '');
assert.equal(sanitizePromptText('checkpoints/v1-5-pruned.ckpt'), '');
assert.equal(sanitizePromptText('loras/anime_style.safetensors\r\nsub/model.safetensors'), '');
assert.equal(sanitizePromptText('vae.pt'), '');
assert.equal(sanitizePromptText('onnx_model.onnx'), '');
assert.equal(sanitizePromptText('llm.gguf'), '');
assert.equal(sanitizePromptText(' 1girl, beautiful lighting, cinematic shot '), '1girl, beautiful lighting, cinematic shot');
}
// ============================================================================
// Test 11: prompt envelopes of plans, notes and loader blocks
// ============================================================================
{
assert.equal(Array.isArray(PROMPT_EXTRACTORS), true);
assert.equal(PROMPT_EXTRACTORS.length, 5);
// 11.1 Prompt Plan SSOT
const planMaterial = {
kind: 'prompt_plan',
plan: {
positive: 'cyberpunk street, neon glow',
negative: 'blurry, low quality',
},
};
const envPlan = extractMaterialPromptEnvelope(planMaterial);
assert.equal(envPlan.positive, 'cyberpunk street, neon glow');
assert.equal(envPlan.negative, 'blurry, low quality');
// 11.2 Negative Prompt Note SSOT
const negNoteMaterial = {
kind: 'prompt_note_bundle',
name: 'Negative Base Prompts',
tags: ['negative'],
note: {
promptEn: 'worst quality, bad anatomy, deformed limbs',
},
};
const envNote = extractMaterialPromptEnvelope(negNoteMaterial);
assert.equal(envNote.primaryRole, 'negative');
assert.equal(envNote.negative, 'worst quality, bad anatomy, deformed limbs');
// 11.3 Loader blocks with model file: info must NEVER contain .safetensors
const loaderMaterial = {
kind: 'recipe_parameter_selection',
node_blocks: [
{
node_id: 1,
type: 'easy fullLoader',
widgets_values: ['epicrealism.safetensors', 'baked_vae.safetensors'],
},
{
node_id: 2,
type: 'CLIPTextEncode',
promptRole: 'positive',
widgets_values: ['photorealistic portrait, 8k'],
},
],
};
const envLoader = extractMaterialPromptEnvelope(loaderMaterial);
assert.equal(envLoader.positive, 'photorealistic portrait, 8k');
assert.equal(envLoader.positive.includes('.safetensors'), false);
}
// ============================================================================
// Test 12: Prompt text never crosses roles
// ============================================================================
{
const makeNode = (id, type, name) => {
const node = { id, type, title: type, widgets: [{ name, value: 'keep me' }], widgets_values: ['keep me'] };
const app = {
graph: { getNodeById: nodeId => nodeId === id ? node : null, beforeChange() {}, afterChange() {}, change() {}, setDirtyCanvas() {} },
canvas: { selected_nodes: { [id]: node }, setDirty() {} },
};
return { node, app };
};
const negativeOnly = { hasPrompt: true, positive: '', negative: 'lowres, blurry', singleText: 'lowres, blurry', primaryRole: 'negative' };
const positiveOnly = { hasPrompt: true, positive: '1girl, garden', negative: '', singleText: '1girl, garden', primaryRole: 'positive' };
const pos = makeNode(30, 'easy positive', 'positive');
assert.throws(() => dispatchPromptInjection(pos.app, pos.node, negativeOnly), /materialNoCompatibleValues/);
assert.equal(pos.node.widgets[0].value, 'keep me', 'negative text must not land in a positive-only node');
const neg = makeNode(31, 'easy negative', 'negative');
assert.throws(() => dispatchPromptInjection(neg.app, neg.node, positiveOnly), /materialNoCompatibleValues/);
assert.equal(neg.node.widgets[0].value, 'keep me', 'positive text must not land in a negative-only node');
// Role-neutral CLIPTextEncode still accepts either side.
const clip = makeNode(32, 'CLIPTextEncode', 'text');
dispatchPromptInjection(clip.app, clip.node, negativeOnly);
assert.equal(clip.node.widgets[0].value, 'lowres, blurry');
// Two-slot loader receives a one-sided envelope only in the matching slot.
const { node: loader, app: loaderApp } = createEasyA1111LoaderFixture();
dispatchPromptInjection(loaderApp, loader, negativeOnly);
assert.equal(loader.widgets[6].value, 'initial positive prompt');
assert.equal(loader.widgets[7].value, 'lowres, blurry');
}
console.log('✔ All Cross-Node Prompt Injection Protocol (CNPIP) tests passed!');