n8n-nodes-teamorouter/test/run-node-tests.mjs
ed0ss 11619aaa7b Send and return images in the TeamoRouter action node
The action node now speaks the whole TeamoRouter surface instead of a
single prompt: chat messages carry text plus input images (binary or URL)
and can hand the answer to a Chat Trigger as an output field, images can
be generated or edited with the full parameter set (size, quality, n,
output format and compression, background, fidelity, mask) and every
result comes back as binary, as markdown for the chat, or both.
2026-10-22 16:40:04 +03:00

541 lines
18 KiB
JavaScript

// Exercises the compiled nodes against the mock router: real HTTP, real LangChain model.
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createMockServer, parseMultipart, TINY_PNG_BASE64 } from './mock-server.mjs';
const require = createRequire(import.meta.url);
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const { LmChatTeamRouter } = require(join(root, 'dist/nodes/LmChatTeamRouter/LmChatTeamRouter.node.js'));
const { Teamorouter } = require(join(root, 'dist/nodes/Teamorouter/Teamorouter.node.js'));
const { TeamorouterApi } = require(join(root, 'dist/credentials/TeamorouterApi.credentials.js'));
const apiKey = 'test-key';
const mock = createMockServer({ apiKey });
const baseUrl = await mock.listen();
/** Mirrors n8n's httpRequestWithAuthentication closely enough for the code paths under test. */
async function httpRequest(_credentialName, options) {
const url = `${options.baseURL ?? baseUrl}${options.url}`;
const timeout = typeof options.timeout === 'number' && options.timeout > 0 ? options.timeout : undefined;
const headers = {
'content-type': 'application/json',
authorization: `Bearer ${apiKey}`,
...(options.headers ?? {}),
};
const body =
options.body === undefined
? undefined
: Buffer.isBuffer(options.body)
? options.body
: JSON.stringify(options.body);
let response;
try {
response = await fetch(url, {
method: options.method ?? 'GET',
headers,
body,
signal: timeout === undefined ? undefined : AbortSignal.timeout(timeout),
});
} catch (error) {
const wrapped = new Error(error?.cause?.message ?? error.message);
wrapped.httpCode = 504;
throw wrapped;
}
const text = await response.text();
let parsed = text;
try {
parsed = JSON.parse(text);
} catch {
// keep the raw text
}
if (!response.ok) {
const error = new Error(`Request failed with status code ${response.status}`);
error.httpCode = response.status;
error.response = { status: response.status, data: parsed };
throw error;
}
return parsed;
}
function makeContext(parameters, { continueOnFail = false, items = [{ json: {} }] } = {}) {
return {
getCredentials: async () => ({ apiKey, baseUrl: `${baseUrl}/v1` }),
getNodeParameter: (name, _itemIndex, fallback) =>
parameters[name] === undefined ? fallback : parameters[name],
getNode: () => ({
name: 'TeamoRouter',
type: 'n8n-nodes-teamorouter',
typeVersion: 1,
position: [0, 0],
parameters,
}),
continueOnFail: () => continueOnFail,
getInputData: () => items,
logger: { debug() {}, info() {}, warn() {}, error() {} },
helpers: {
httpRequestWithAuthentication: httpRequest,
getBinaryDataBuffer: async (itemIndex, propertyName) =>
Buffer.from(items[itemIndex].binary?.[propertyName]?.data ?? '', 'base64'),
prepareBinaryData: async (data, fileName, mimeType) => {
const buffer = Buffer.from(data);
return {
data: buffer.toString('base64'),
fileName,
mimeType,
fileExtension: String(fileName ?? '').split('.').pop(),
fileSize: buffer.length,
};
},
returnJsonArray: (data) => (Array.isArray(data) ? data : [data]).map((json) => ({ json })),
constructExecutionMetaData: (items) => items,
},
};
}
/** An input item carrying a binary picture, as an earlier node would provide it. */
const itemWithImage = () => ({ json: {}, binary: { data: { data: TINY_PNG_BASE64, mimeType: 'image/png', fileName: 'cat.png' } } });
const lastRequest = () => mock.requests[mock.requests.length - 1];
const chatModel = new LmChatTeamRouter();
const actionNode = new Teamorouter();
let failures = 0;
async function test(name, fn) {
try {
await fn();
console.log(`ok ${name}`);
} catch (error) {
failures += 1;
console.log(`FAIL ${name}\n ${error.message}`);
}
}
await test('credential points at GET /v1/models and the endpoint enforces the key', async () => {
const test = new TeamorouterApi().test;
assert.equal(test.request.url, '/models');
assert.match(test.request.baseURL, /\$credentials\.baseUrl/);
const ok = await fetch(`${baseUrl}/v1/models`, { headers: { authorization: `Bearer ${apiKey}` } });
assert.equal(ok.status, 200);
const denied = await fetch(`${baseUrl}/v1/models`, { headers: { authorization: 'Bearer wrong' } });
assert.equal(denied.status, 401);
});
await test('chat model dropdown lists chat models with prices and hides image models', async () => {
const options = await chatModel.methods.loadOptions.getChatModels.call(makeContext({}));
const ids = options.map((option) => option.value);
assert.ok(ids.includes('gpt-5.6-sol'), 'gpt-5.6-sol missing');
assert.ok(ids.includes('gemini-3.1-pro-preview'), 'gemini missing');
assert.ok(!ids.includes('gpt-image-2'), 'image model leaked into the chat list');
assert.ok(!ids.includes('nano-banana-2.1'), 'image model leaked into the chat list');
const sol = options.find((option) => option.value === 'gpt-5.6-sol');
assert.match(sol.description, /openai/);
assert.match(sol.description, /\$0\.53 in \/ \$3\.18 out per 1M tokens/);
const gemini = options.find((option) => option.value === 'gemini-3.1-pro-preview');
assert.match(gemini.description, /\$0\.29 in \/ \$1\.73 out per 1M tokens/);
assert.deepEqual(ids, [...ids].sort(), 'options must be sorted');
});
await test('image dropdown lists only image models', async () => {
const options = await actionNode.methods.loadOptions.getImageModels.call(makeContext({}));
assert.deepEqual(
options.map((option) => option.value),
['gpt-image-2', 'nano-banana-2.1'],
);
});
await test('supplyData returns a working LangChain chat model', async () => {
const { response: model } = await chatModel.supplyData.call(
makeContext({ model: 'claude-sonnet-5', options: {} }),
0,
);
const answer = await model.invoke('ping');
assert.equal(answer.content, 'echo: ping');
const request = lastRequest();
assert.equal(request.url, '/v1/chat/completions');
assert.equal(request.headers.authorization, `Bearer ${apiKey}`);
assert.equal(request.body.model, 'claude-sonnet-5');
assert.equal(request.body.messages.at(-1).content, 'ping');
assert.equal(request.body.max_tokens, undefined, 'maxTokens -1 must not be sent');
});
await test('supplyData works for GPT models served through the Responses API', async () => {
const { response: model } = await chatModel.supplyData.call(
makeContext({ model: 'gpt-5.6-sol', options: {} }),
0,
);
const answer = await model.invoke('ping');
// The Responses API path returns content blocks instead of a plain string.
const text = Array.isArray(answer.content)
? answer.content.map((block) => block.text).join('')
: answer.content;
assert.equal(text, 'echo: ping');
const request = lastRequest();
// LangChain routes gpt-5.6* to /v1/responses on its own; TeamoRouter serves that endpoint for GPT models.
assert.ok(
['/v1/chat/completions', '/v1/responses'].includes(request.url),
`unexpected endpoint ${request.url}`,
);
assert.equal(request.body.model, 'gpt-5.6-sol');
// The router answers both endpoints, so the model must not care which one was used.
assert.match(request.url, /^\/v1\/(chat\/completions|responses)$/);
});
await test('options reach the request body (service tier, extra body, limits)', async () => {
const { response: model } = await chatModel.supplyData.call(
makeContext({
model: 'claude-sonnet-5',
options: {
temperature: 0.2,
maxTokens: 128,
topP: 0.5,
serviceTier: 'fast',
forceJsonResponse: true,
extraBody: '{"top_k":7}',
},
}),
0,
);
await model.invoke('json please');
const body = lastRequest().body;
assert.equal(body.model, 'claude-sonnet-5');
assert.equal(body.temperature, 0.2);
assert.equal(body.top_p, 0.5);
assert.equal(body.max_tokens, 128);
assert.equal(body.service_tier, 'fast');
assert.deepEqual(body.response_format, { type: 'json_object' });
assert.equal(body.top_k, 7);
});
await test('chat model rejects a broken Extra Body JSON', async () => {
await assert.rejects(
async () => {
await chatModel.supplyData.call(
makeContext({ model: 'gpt-5.6-sol', options: { extraBody: 'not-json' } }),
0,
);
},
(error) => {
assert.match(error.message, /Extra Body must be a JSON object/);
return true;
},
);
});
await test('chat model rejects an empty model selection', async () => {
await assert.rejects(
async () => {
await chatModel.supplyData.call(makeContext({ model: '', options: {} }), 0);
},
(error) => {
assert.match(error.message, /No model selected/);
return true;
},
);
});
await test('action node: message a model returns the completion with usage', async () => {
const context = makeContext({
resource: 'chat',
operation: 'message',
model: 'claude-sonnet-5',
prompt: 'привет',
options: { systemPrompt: 'be brief' },
});
const [items] = await actionNode.execute.call(context);
assert.equal(items.length, 1);
assert.equal(items[0].json.text, 'echo: привет');
assert.equal(items[0].json.usage.total_tokens, 15);
assert.equal(items[0].json.finishReason, 'stop');
assert.equal(items[0].json.output, undefined, 'chatReply must stay off by default');
const body = lastRequest().body;
assert.deepEqual(body.messages[0], { role: 'system', content: 'be brief' });
assert.deepEqual(body.messages[1], { role: 'user', content: 'привет' });
assert.equal(lastRequest().url, '/v1/chat/completions');
});
await test('action node: input images are sent as multimodal content and answered in the chat output', async () => {
const [items] = await actionNode.execute.call(
makeContext(
{
resource: 'chat',
operation: 'message',
model: 'claude-sonnet-5',
prompt: 'что на фото?',
images: { image: [{ source: 'binary', binaryProperty: 'data' }] },
options: {},
chatReply: true,
},
{ items: [itemWithImage()] },
),
);
const content = lastRequest().body.messages[0].content;
assert.deepEqual(content[0], { type: 'text', text: 'что на фото?' });
assert.equal(content[1].type, 'image_url');
assert.match(content[1].image_url.url, /^data:image\/png;base64,/);
assert.equal(items[0].json.text, 'echo: что на фото? (+1 image)');
assert.equal(items[0].json.output, 'echo: что на фото? (+1 image)');
});
await test('action node: an image URL passes through untouched and a chat message needs prompt or image', async () => {
await actionNode.execute.call(
makeContext({
resource: 'chat',
operation: 'message',
model: 'claude-sonnet-5',
prompt: '',
images: { image: [{ source: 'url', url: 'https://example.com/cat.png' }] },
options: {},
}),
);
assert.deepEqual(lastRequest().body.messages[0].content, [
{ type: 'image_url', image_url: { url: 'https://example.com/cat.png' } },
]);
await assert.rejects(
async () => {
await actionNode.execute.call(
makeContext({ resource: 'chat', operation: 'message', model: 'claude-sonnet-5', prompt: '', options: {} }),
);
},
(error) => {
assert.match(error.message, /Enter a Prompt or attach at least one input image/);
return true;
},
);
});
await test('action node: an image returned by a chat model becomes binary', async () => {
const [items] = await actionNode.execute.call(
makeContext({
resource: 'chat',
operation: 'message',
model: 'image-answering-model',
prompt: 'нарисуй кота',
options: {},
chatReply: true,
}),
);
assert.equal(items[0].binary.data.data, TINY_PNG_BASE64);
assert.equal(items[0].binary.data.mimeType, 'image/png');
assert.deepEqual(items[0].json.images, [`data:image/png;base64,${TINY_PNG_BASE64}`]);
assert.equal(items[0].json.output, `echo: нарисуй кота\n\n![image](data:image/png;base64,${TINY_PNG_BASE64})`);
});
await test('action node: list models returns the router payload', async () => {
const [items] = await actionNode.execute.call(
makeContext({ resource: 'model', operation: 'list' }),
);
assert.equal(items.length, 6);
assert.equal(items[0].json.id, 'gpt-5.6-sol');
assert.equal(lastRequest().url, '/v1/models');
});
await test('action node: generate image writes a PNG binary', async () => {
const [items] = await actionNode.execute.call(
makeContext({
resource: 'image',
operation: 'generate',
imageModel: 'gpt-image-2',
imagePrompt: 'orange cat',
binaryPropertyName: 'data',
}),
);
const binary = items[0].binary.data;
assert.equal(binary.mimeType, 'image/png');
assert.equal(binary.fileExtension, 'png');
assert.equal(binary.data, TINY_PNG_BASE64);
assert.equal(Buffer.from(binary.data, 'base64').subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
assert.deepEqual(lastRequest().body, { model: 'gpt-image-2', prompt: 'orange cat' });
});
await test('action node: image options reach the body and every generated image becomes an item', async () => {
const [items] = await actionNode.execute.call(
makeContext({
resource: 'image',
operation: 'generate',
imageModel: 'gpt-image-2.5-sunburst',
imagePrompt: 'orange cat',
binaryPropertyName: 'data',
options: {
size: '2048x2048',
quality: 'high',
numberOfImages: 3,
outputFormat: 'webp',
outputCompression: 80,
background: 'transparent',
moderation: 'low',
},
}),
);
assert.deepEqual(lastRequest().body, {
model: 'gpt-image-2.5-sunburst',
prompt: 'orange cat',
size: '2048x2048',
quality: 'high',
n: 3,
output_format: 'webp',
output_compression: 80,
background: 'transparent',
moderation: 'low',
});
assert.equal(items.length, 3);
assert.deepEqual(
items.map((item) => item.json.index),
[0, 1, 2],
);
assert.equal(items[0].binary.data.mimeType, 'image/webp');
assert.equal(items[0].binary.data.fileExtension, 'webp');
assert.equal(items[2].binary.data2.data, TINY_PNG_BASE64);
});
await test('action node: chat reply turns a generated image into markdown', async () => {
const [items] = await actionNode.execute.call(
makeContext({
resource: 'image',
operation: 'generate',
imageModel: 'gpt-image-2',
imagePrompt: 'orange cat',
binaryPropertyName: 'data',
options: {},
chatReply: true,
}),
);
assert.equal(items.length, 1);
assert.equal(items[0].json.output, `![image](data:image/png;base64,${TINY_PNG_BASE64})`);
assert.equal(items[0].binary.data.data, TINY_PNG_BASE64);
});
await test('action node: an image returned as a URL is passed through', async () => {
const [items] = await actionNode.execute.call(
makeContext({
resource: 'image',
operation: 'generate',
imageModel: 'nano-banana-2.1',
imagePrompt: 'urlonly cat',
binaryPropertyName: 'data',
options: {},
chatReply: true,
}),
);
assert.equal(items[0].json.output, '![image](https://cdn.example.com/cat.png)');
assert.equal(items[0].binary, undefined);
});
await test('action node: edit an image uploads the picture as multipart', async () => {
const [items] = await actionNode.execute.call(
makeContext(
{
resource: 'image',
operation: 'edit',
imageModel: 'gpt-image-2.5-sunburst',
imagePrompt: 'add a hat',
inputBinaryProperty: 'data',
maskBinaryProperty: '',
binaryPropertyName: 'data',
options: { size: '1024x1024', inputFidelity: 'high' },
},
{ items: [itemWithImage()] },
),
);
const request = lastRequest();
assert.equal(request.url, '/v1/images/edits');
assert.match(request.headers['content-type'], /^multipart\/form-data; boundary=/);
const parts = parseMultipart(request.rawBuffer, request.headers['content-type']);
const field = (name) => parts.find((part) => part.name === name);
assert.equal(field('model').body, 'gpt-image-2.5-sunburst');
assert.equal(field('prompt').body, 'add a hat');
assert.equal(field('size').body, '1024x1024');
assert.equal(field('input_fidelity').body, 'high');
assert.match(field('image').header, /filename="cat.png"/);
assert.equal(field('image').body, Buffer.from(TINY_PNG_BASE64, 'base64').toString('latin1'));
assert.equal(items[0].binary.data.data, TINY_PNG_BASE64);
});
await test('action node: router errors become actionable messages', async () => {
const cases = [
['fail-401', /API key/],
['fail-402', /insufficient balance/],
['fail-404', /not found/],
];
for (const [model, expected] of cases) {
await assert.rejects(
async () => {
await actionNode.execute.call(
makeContext({ resource: 'chat', operation: 'message', model, prompt: 'hi', options: {} }),
);
},
(error) => {
assert.match(error.message, expected);
return true;
},
`${model} did not produce a hint`,
);
}
});
await test('action node: image without b64_json fails clearly', async () => {
await assert.rejects(
async () => {
await actionNode.execute.call(
makeContext({
resource: 'image',
operation: 'generate',
imageModel: 'gpt-image-2',
imagePrompt: 'noimage please',
binaryPropertyName: 'data',
}),
);
},
// 400 from the router: no hint, but the request must fail instead of returning empty binary.
(error) => {
assert.match(error.message, /400/);
return true;
},
);
});
await test('action node: continueOnFail returns the error as item data', async () => {
const [items] = await actionNode.execute.call(
makeContext(
{ resource: 'chat', operation: 'message', model: 'fail-500', prompt: 'hi', options: {} },
{ continueOnFail: true },
),
);
assert.equal(items.length, 1);
assert.match(items[0].json.error, /500/);
});
await test('action node: timeout is applied to the request', async () => {
await assert.rejects(async () => {
await actionNode.execute.call(
makeContext({
resource: 'chat',
operation: 'message',
model: 'slow-model',
prompt: 'hi',
options: { timeout: 300 },
}),
);
});
});
await mock.close();
console.log(`\n${failures === 0 ? 'All node tests passed' : `${failures} test(s) failed`}`);
process.exitCode = failures === 0 ? 0 : 1;