Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
2008 lines
50 KiB
TypeScript
2008 lines
50 KiB
TypeScript
import * as helpers from '@utils/helpers';
|
|
import { mockDeep } from 'jest-mock-extended';
|
|
import type { IExecuteFunctions, IBinaryData, INode } from 'n8n-workflow';
|
|
import { NodeOperationError } from 'n8n-workflow';
|
|
|
|
import * as audio from './actions/audio';
|
|
import * as file from './actions/file';
|
|
import * as image from './actions/image';
|
|
import * as text from './actions/text';
|
|
import * as video from './actions/video';
|
|
import * as utils from './helpers/utils';
|
|
import * as transport from './transport';
|
|
|
|
describe('GoogleGemini Node', () => {
|
|
const executeFunctionsMock = mockDeep<IExecuteFunctions>();
|
|
const apiRequestMock = jest.spyOn(transport, 'apiRequest');
|
|
const getConnectedToolsMock = jest.spyOn(helpers, 'getConnectedTools');
|
|
const downloadFileMock = jest.spyOn(utils, 'downloadFile');
|
|
const uploadFileMock = jest.spyOn(utils, 'uploadFile');
|
|
const transferFileMock = jest.spyOn(utils, 'transferFile');
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
executeFunctionsMock.getNode.mockReturnValue({ typeVersion: 1 } as INode);
|
|
});
|
|
|
|
describe('Text -> Message', () => {
|
|
it('should call the api with the correct parameters', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Hello, world!' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return true;
|
|
case 'options':
|
|
return {
|
|
systemMessage: 'You are a helpful assistant.',
|
|
codeExecution: true,
|
|
frequencyPenalty: 0,
|
|
maxOutputTokens: 100,
|
|
candidateCount: 1,
|
|
presencePenalty: 0,
|
|
temperature: 0.5,
|
|
topP: 0.5,
|
|
topK: 10,
|
|
};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }, { type: 'ai_tool' }]);
|
|
getConnectedToolsMock.mockResolvedValue([]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'Hello, world!' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
json: {
|
|
content: {
|
|
parts: [{ text: 'Hello, world!' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
expect.objectContaining({
|
|
body: {
|
|
contents: [
|
|
{
|
|
parts: [{ text: 'Hello, world!' }],
|
|
role: 'user',
|
|
},
|
|
],
|
|
tools: [
|
|
{
|
|
codeExecution: {},
|
|
},
|
|
],
|
|
generationConfig: {
|
|
candidateCount: 1,
|
|
frequencyPenalty: 0,
|
|
maxOutputTokens: 100,
|
|
presencePenalty: 0,
|
|
temperature: 0.5,
|
|
topP: 0.5,
|
|
topK: 10,
|
|
responseMimeType: 'application/json',
|
|
},
|
|
systemInstruction: {
|
|
parts: [{ text: 'You are a helpful assistant.' }],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should include thinking options when the thinking budget is specified', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Hello, world!' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'options':
|
|
return {
|
|
thinkingBudget: 1024,
|
|
maxOutputTokens: 100,
|
|
temperature: 0.5,
|
|
};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'Hello with thinking!' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
json: {
|
|
content: {
|
|
parts: [{ text: 'Hello with thinking!' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
{
|
|
body: {
|
|
contents: [
|
|
{
|
|
parts: [{ text: 'Hello, world!' }],
|
|
role: 'user',
|
|
},
|
|
],
|
|
tools: [],
|
|
generationConfig: {
|
|
maxOutputTokens: 100,
|
|
temperature: 0.5,
|
|
thinkingConfig: {
|
|
thinkingBudget: 1024,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('Version 1.0', () => {
|
|
it('should use codeExecution from options for version 1.0', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Hello, world!' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'builtInTools':
|
|
return {};
|
|
case 'options':
|
|
return {
|
|
codeExecution: true,
|
|
};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'Hello, world!' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
expect.objectContaining({
|
|
body: expect.objectContaining({
|
|
tools: expect.arrayContaining([
|
|
{
|
|
codeExecution: {},
|
|
},
|
|
]),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Version 1.1', () => {
|
|
beforeEach(() => {
|
|
executeFunctionsMock.getNode.mockReturnValue({ typeVersion: 1.1 } as INode);
|
|
});
|
|
|
|
it('should include Google Search tool when enabled', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'What is the weather today?' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'builtInTools':
|
|
return {
|
|
googleSearch: true,
|
|
};
|
|
case 'options':
|
|
return {};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'The weather is sunny.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
expect.objectContaining({
|
|
body: expect.objectContaining({
|
|
tools: expect.arrayContaining([
|
|
{
|
|
googleSearch: {},
|
|
},
|
|
]),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should include Google Maps tool when enabled', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Find restaurants near me' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'builtInTools':
|
|
return {
|
|
googleMaps: {},
|
|
};
|
|
case 'options':
|
|
return {};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'Here are some restaurants.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
expect.objectContaining({
|
|
body: expect.objectContaining({
|
|
tools: expect.arrayContaining([
|
|
{
|
|
googleMaps: {},
|
|
},
|
|
]),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should include toolConfig with latLng when Google Maps has coordinates', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Find restaurants near me' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'builtInTools':
|
|
return {
|
|
googleMaps: {
|
|
latitude: 34.050481,
|
|
longitude: -118.248526,
|
|
},
|
|
};
|
|
case 'options':
|
|
return {};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'Here are some restaurants.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
expect.objectContaining({
|
|
body: expect.objectContaining({
|
|
tools: expect.arrayContaining([
|
|
{
|
|
googleMaps: {},
|
|
},
|
|
]),
|
|
toolConfig: {
|
|
retrievalConfig: {
|
|
latLng: {
|
|
latitude: 34.050481,
|
|
longitude: -118.248526,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should include URL Context tool when enabled', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Summarize this URL' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'builtInTools':
|
|
return {
|
|
urlContext: true,
|
|
};
|
|
case 'options':
|
|
return {};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'URL summary.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
expect.objectContaining({
|
|
body: expect.objectContaining({
|
|
tools: expect.arrayContaining([
|
|
{
|
|
urlContext: {},
|
|
},
|
|
]),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should include File Search tool with fileSearchStoreNames when enabled', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Search my files' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'builtInTools':
|
|
return {
|
|
fileSearch: {
|
|
fileSearchStoreNames: '["store1", "store2"]',
|
|
},
|
|
};
|
|
case 'options':
|
|
return {};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'File search results.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
expect.objectContaining({
|
|
body: expect.objectContaining({
|
|
tools: expect.arrayContaining([
|
|
{
|
|
fileSearch: {
|
|
fileSearchStoreNames: ['store1', 'store2'],
|
|
},
|
|
},
|
|
]),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should include File Search tool with metadataFilter when provided', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Tell me about the book' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'builtInTools':
|
|
return {
|
|
fileSearch: {
|
|
fileSearchStoreNames: '["store1"]',
|
|
metadataFilter: 'author="Robert Graves"',
|
|
},
|
|
};
|
|
case 'options':
|
|
return {};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'Book information.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
expect.objectContaining({
|
|
body: expect.objectContaining({
|
|
tools: expect.arrayContaining([
|
|
{
|
|
fileSearch: {
|
|
fileSearchStoreNames: ['store1'],
|
|
metadataFilter: 'author="Robert Graves"',
|
|
},
|
|
},
|
|
]),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should include Code Execution tool when enabled', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Calculate 2+2' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'builtInTools':
|
|
return {
|
|
codeExecution: true,
|
|
};
|
|
case 'options':
|
|
return {};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'The result is 4.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
expect.objectContaining({
|
|
body: expect.objectContaining({
|
|
tools: expect.arrayContaining([
|
|
{
|
|
codeExecution: {},
|
|
},
|
|
]),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should include multiple built-in tools when enabled', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Complex query' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'builtInTools':
|
|
return {
|
|
googleSearch: true,
|
|
urlContext: true,
|
|
codeExecution: true,
|
|
};
|
|
case 'options':
|
|
return {};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'Response with multiple tools.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
expect.objectContaining({
|
|
body: expect.objectContaining({
|
|
tools: expect.arrayContaining([
|
|
{
|
|
googleSearch: {},
|
|
},
|
|
{
|
|
urlContext: {},
|
|
},
|
|
{
|
|
codeExecution: {},
|
|
},
|
|
]),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should not include toolConfig when Google Maps coordinates are empty', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Find restaurants' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'builtInTools':
|
|
return {
|
|
googleMaps: {
|
|
latitude: '',
|
|
longitude: '',
|
|
},
|
|
};
|
|
case 'options':
|
|
return {};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'Restaurants found.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
expect.objectContaining({
|
|
body: expect.not.objectContaining({
|
|
toolConfig: expect.anything(),
|
|
}),
|
|
}),
|
|
);
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
expect.objectContaining({
|
|
body: expect.objectContaining({
|
|
tools: expect.arrayContaining([
|
|
{
|
|
googleMaps: {},
|
|
},
|
|
]),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe('includeMergedResponse', () => {
|
|
it('should include mergedResponse per candidate when enabled and simplify is true', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Hello' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'builtInTools':
|
|
return {};
|
|
case 'options':
|
|
return {
|
|
includeMergedResponse: true,
|
|
};
|
|
case 'options.maxToolsIterations':
|
|
return 15;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'Hello' }, { text: ' World' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
json: {
|
|
content: {
|
|
parts: [{ text: 'Hello' }, { text: ' World' }],
|
|
role: 'model',
|
|
},
|
|
mergedResponse: 'Hello World',
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should include mergedResponse per candidate when enabled and simplify is false', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Hello' }];
|
|
case 'simplify':
|
|
return false;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'builtInTools':
|
|
return {};
|
|
case 'options':
|
|
return {
|
|
includeMergedResponse: true,
|
|
};
|
|
case 'options.maxToolsIterations':
|
|
return 15;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'Response' }, { text: ' text' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
json: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'Response' }, { text: ' text' }],
|
|
role: 'model',
|
|
},
|
|
mergedResponse: 'Response text',
|
|
},
|
|
],
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should not include mergedResponse when disabled', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Hello' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'builtInTools':
|
|
return {};
|
|
case 'options':
|
|
return {
|
|
includeMergedResponse: false,
|
|
};
|
|
case 'options.maxToolsIterations':
|
|
return 15;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'Hello' }, { text: ' World' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
json: {
|
|
content: {
|
|
parts: [{ text: 'Hello' }, { text: ' World' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
expect(result[0].json).not.toHaveProperty('mergedResponse');
|
|
});
|
|
|
|
it('should handle multiple candidates with different merged responses', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Hello' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'builtInTools':
|
|
return {};
|
|
case 'options':
|
|
return {
|
|
includeMergedResponse: true,
|
|
candidateCount: 2,
|
|
};
|
|
case 'options.maxToolsIterations':
|
|
return 15;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'First' }, { text: ' candidate' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
{
|
|
content: {
|
|
parts: [{ text: 'Second' }, { text: ' candidate' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].json).toEqual({
|
|
content: {
|
|
parts: [{ text: 'First' }, { text: ' candidate' }],
|
|
role: 'model',
|
|
},
|
|
mergedResponse: 'First candidate',
|
|
});
|
|
expect(result[1].json).toEqual({
|
|
content: {
|
|
parts: [{ text: 'Second' }, { text: ' candidate' }],
|
|
role: 'model',
|
|
},
|
|
mergedResponse: 'Second candidate',
|
|
});
|
|
});
|
|
|
|
it('should only merge text parts and ignore non-text parts', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'messages.values':
|
|
return [{ role: 'user', content: 'Hello' }];
|
|
case 'simplify':
|
|
return true;
|
|
case 'jsonOutput':
|
|
return false;
|
|
case 'builtInTools':
|
|
return {};
|
|
case 'options':
|
|
return {
|
|
includeMergedResponse: true,
|
|
};
|
|
case 'options.maxToolsIterations':
|
|
return 15;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'Text' }, { executableCode: {} }, { text: ' more text' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await text.message.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result[0].json).toEqual({
|
|
content: {
|
|
parts: [{ text: 'Text' }, { executableCode: {} }, { text: ' more text' }],
|
|
role: 'model',
|
|
},
|
|
mergedResponse: 'Text more text',
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Audio -> Analyze', () => {
|
|
it('should analyze audio from URL', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'inputType':
|
|
return 'url';
|
|
case 'audioUrls':
|
|
return 'https://example.com/audio.mp3';
|
|
case 'text':
|
|
return "What's in this audio?";
|
|
case 'simplify':
|
|
return true;
|
|
case 'options':
|
|
return {
|
|
maxOutputTokens: 300,
|
|
};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
downloadFileMock.mockResolvedValue({
|
|
fileContent: Buffer.from('test'),
|
|
mimeType: 'audio/mpeg',
|
|
});
|
|
uploadFileMock.mockResolvedValue({
|
|
fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123',
|
|
mimeType: 'audio/mpeg',
|
|
});
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'This audio contains a person speaking about AI.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await audio.analyze.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
json: {
|
|
content: {
|
|
parts: [{ text: 'This audio contains a person speaking about AI.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
expect(downloadFileMock).toHaveBeenCalledWith('https://example.com/audio.mp3', 'audio/mpeg');
|
|
expect(uploadFileMock).toHaveBeenCalledWith(Buffer.from('test'), 'audio/mpeg');
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
{
|
|
body: {
|
|
contents: [
|
|
{
|
|
parts: [
|
|
{
|
|
fileData: {
|
|
fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123',
|
|
mimeType: 'audio/mpeg',
|
|
},
|
|
},
|
|
{
|
|
text: "What's in this audio?",
|
|
},
|
|
],
|
|
role: 'user',
|
|
},
|
|
],
|
|
generationConfig: {
|
|
maxOutputTokens: 300,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should analyze audio from binary data', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'inputType':
|
|
return 'binary';
|
|
case 'binaryPropertyName':
|
|
return 'data';
|
|
case 'text':
|
|
return "What's in this audio?";
|
|
case 'simplify':
|
|
return true;
|
|
case 'options':
|
|
return {
|
|
maxOutputTokens: 300,
|
|
};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
const mockBinaryData: IBinaryData = {
|
|
mimeType: 'audio/mpeg',
|
|
fileName: 'test.mp3',
|
|
fileSize: '1024',
|
|
fileExtension: 'mp3',
|
|
data: 'test',
|
|
};
|
|
executeFunctionsMock.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
|
|
executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue(Buffer.from('test'));
|
|
uploadFileMock.mockResolvedValue({
|
|
fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123',
|
|
mimeType: 'audio/mpeg',
|
|
});
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'This audio contains a person speaking about AI.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await audio.analyze.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
json: {
|
|
content: {
|
|
parts: [{ text: 'This audio contains a person speaking about AI.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
expect(uploadFileMock).toHaveBeenCalledWith(Buffer.from('test'), 'audio/mpeg');
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
{
|
|
body: {
|
|
contents: [
|
|
{
|
|
parts: [
|
|
{
|
|
fileData: {
|
|
fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123',
|
|
mimeType: 'audio/mpeg',
|
|
},
|
|
},
|
|
{
|
|
text: "What's in this audio?",
|
|
},
|
|
],
|
|
role: 'user',
|
|
},
|
|
],
|
|
generationConfig: {
|
|
maxOutputTokens: 300,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should analyze audio from Google API URL', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'inputType':
|
|
return 'url';
|
|
case 'audioUrls':
|
|
return 'https://generativelanguage.googleapis.com/v1/files/abc123';
|
|
case 'text':
|
|
return "What's in this audio?";
|
|
case 'simplify':
|
|
return true;
|
|
case 'options':
|
|
return {
|
|
maxOutputTokens: 300,
|
|
};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
|
|
apiRequestMock.mockImplementation(async (method: string) => {
|
|
if (method === 'GET') {
|
|
return { mimeType: 'audio/mpeg' };
|
|
}
|
|
return {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'This audio contains a person speaking about AI.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
});
|
|
|
|
const result = await audio.analyze.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
json: {
|
|
content: {
|
|
parts: [{ text: 'This audio contains a person speaking about AI.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
|
|
expect(downloadFileMock).not.toHaveBeenCalled();
|
|
expect(uploadFileMock).not.toHaveBeenCalled();
|
|
expect(apiRequestMock).toHaveBeenCalledWith('GET', '', {
|
|
option: { url: 'https://generativelanguage.googleapis.com/v1/files/abc123' },
|
|
});
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
{
|
|
body: {
|
|
contents: [
|
|
{
|
|
parts: [
|
|
{
|
|
fileData: {
|
|
fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123',
|
|
mimeType: 'audio/mpeg',
|
|
},
|
|
},
|
|
{
|
|
text: "What's in this audio?",
|
|
},
|
|
],
|
|
role: 'user',
|
|
},
|
|
],
|
|
generationConfig: {
|
|
maxOutputTokens: 300,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Audio -> Transcribe', () => {
|
|
it('should transcribe audio from URL', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'inputType':
|
|
return 'url';
|
|
case 'audioUrls':
|
|
return 'https://example.com/audio.mp3';
|
|
case 'simplify':
|
|
return true;
|
|
case 'options':
|
|
return {
|
|
startTime: '00:15',
|
|
endTime: '02:15',
|
|
};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
downloadFileMock.mockResolvedValue({
|
|
fileContent: Buffer.from('test'),
|
|
mimeType: 'audio/mpeg',
|
|
});
|
|
uploadFileMock.mockResolvedValue({
|
|
fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123',
|
|
mimeType: 'audio/mpeg',
|
|
});
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'This is the transcribed text from 00:15 to 02:15.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await audio.transcribe.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
json: {
|
|
content: {
|
|
parts: [{ text: 'This is the transcribed text from 00:15 to 02:15.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
expect(downloadFileMock).toHaveBeenCalledWith('https://example.com/audio.mp3', 'audio/mpeg');
|
|
expect(uploadFileMock).toHaveBeenCalledWith(Buffer.from('test'), 'audio/mpeg');
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
{
|
|
body: {
|
|
contents: [
|
|
{
|
|
parts: [
|
|
{
|
|
fileData: {
|
|
fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123',
|
|
mimeType: 'audio/mpeg',
|
|
},
|
|
},
|
|
{
|
|
text: 'Generate a transcript of the speech from 00:15 to 02:15',
|
|
},
|
|
],
|
|
role: 'user',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should transcribe audio from binary data', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'inputType':
|
|
return 'binary';
|
|
case 'binaryPropertyName':
|
|
return 'data';
|
|
case 'simplify':
|
|
return true;
|
|
case 'options':
|
|
return {};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
const mockBinaryData: IBinaryData = {
|
|
mimeType: 'audio/mpeg',
|
|
fileName: 'test.mp3',
|
|
fileSize: '1024',
|
|
fileExtension: 'mp3',
|
|
data: 'test',
|
|
};
|
|
executeFunctionsMock.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
|
|
executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue(Buffer.from('test'));
|
|
uploadFileMock.mockResolvedValue({
|
|
fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123',
|
|
mimeType: 'audio/mpeg',
|
|
});
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'This is the transcribed text.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await audio.transcribe.execute.call(executeFunctionsMock, 0);
|
|
expect(result).toEqual([
|
|
{
|
|
json: {
|
|
content: {
|
|
parts: [{ text: 'This is the transcribed text.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
expect(uploadFileMock).toHaveBeenCalledWith(Buffer.from('test'), 'audio/mpeg');
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
{
|
|
body: {
|
|
contents: [
|
|
{
|
|
parts: [
|
|
{
|
|
fileData: {
|
|
fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123',
|
|
mimeType: 'audio/mpeg',
|
|
},
|
|
},
|
|
{
|
|
text: 'Generate a transcript of the speech',
|
|
},
|
|
],
|
|
role: 'user',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should transcribe audio from Google API URL', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.5-flash';
|
|
case 'inputType':
|
|
return 'url';
|
|
case 'audioUrls':
|
|
return 'https://generativelanguage.googleapis.com/v1/files/abc123';
|
|
case 'text':
|
|
return "What's in this audio?";
|
|
case 'simplify':
|
|
return true;
|
|
case 'options':
|
|
return {
|
|
startTime: '00:15',
|
|
endTime: '02:15',
|
|
};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
|
|
apiRequestMock.mockImplementation(async (method: string) => {
|
|
if (method === 'GET') {
|
|
return { mimeType: 'audio/mpeg' };
|
|
}
|
|
return {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'This is the transcribed text from 00:15 to 02:15.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
});
|
|
|
|
const result = await audio.transcribe.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
json: {
|
|
content: {
|
|
parts: [{ text: 'This is the transcribed text from 00:15 to 02:15.' }],
|
|
role: 'model',
|
|
},
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
|
|
expect(downloadFileMock).not.toHaveBeenCalled();
|
|
expect(uploadFileMock).not.toHaveBeenCalled();
|
|
expect(apiRequestMock).toHaveBeenCalledWith('GET', '', {
|
|
option: { url: 'https://generativelanguage.googleapis.com/v1/files/abc123' },
|
|
});
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.5-flash:generateContent',
|
|
{
|
|
body: {
|
|
contents: [
|
|
{
|
|
parts: [
|
|
{
|
|
fileData: {
|
|
fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123',
|
|
mimeType: 'audio/mpeg',
|
|
},
|
|
},
|
|
{
|
|
text: 'Generate a transcript of the speech from 00:15 to 02:15',
|
|
},
|
|
],
|
|
role: 'user',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('File -> Upload', () => {
|
|
it('should upload file from URL', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'inputType':
|
|
return 'url';
|
|
case 'fileUrl':
|
|
return 'https://example.com/file.pdf';
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
transferFileMock.mockResolvedValue({
|
|
fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123',
|
|
mimeType: 'application/pdf',
|
|
});
|
|
const result = await file.upload.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
json: {
|
|
fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123',
|
|
mimeType: 'application/pdf',
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
expect(transferFileMock).toHaveBeenCalledWith(
|
|
0,
|
|
'https://example.com/file.pdf',
|
|
'application/octet-stream',
|
|
);
|
|
});
|
|
|
|
it('should upload file from binary data', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'inputType':
|
|
return 'binary';
|
|
case 'binaryPropertyName':
|
|
return 'data';
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
transferFileMock.mockResolvedValue({
|
|
fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123',
|
|
mimeType: 'application/pdf',
|
|
});
|
|
|
|
const result = await file.upload.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
json: {
|
|
fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123',
|
|
mimeType: 'application/pdf',
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
expect(transferFileMock).toHaveBeenCalledWith(0, undefined, 'application/octet-stream');
|
|
});
|
|
});
|
|
|
|
describe('Image -> Generate', () => {
|
|
it('should generate image using Gemini model', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.0-flash-preview-image-generation';
|
|
case 'prompt':
|
|
return 'A cute cat eating a dinosaur';
|
|
case 'options.binaryPropertyOutput':
|
|
return 'data';
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
apiRequestMock.mockResolvedValue({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{
|
|
inlineData: {
|
|
data: 'abcdefgh',
|
|
mimeType: 'image/png',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
executeFunctionsMock.helpers.prepareBinaryData.mockResolvedValue({
|
|
mimeType: 'image/png',
|
|
fileName: 'image.png',
|
|
fileSize: '100',
|
|
data: 'abcdefgh',
|
|
});
|
|
|
|
const result = await image.generate.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
binary: {
|
|
data: {
|
|
mimeType: 'image/png',
|
|
fileName: 'image.png',
|
|
fileSize: '100',
|
|
data: 'abcdefgh',
|
|
},
|
|
},
|
|
json: {
|
|
mimeType: 'image/png',
|
|
fileName: 'image.png',
|
|
fileSize: '100',
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/gemini-2.0-flash-preview-image-generation:generateContent',
|
|
{
|
|
body: {
|
|
contents: [
|
|
{
|
|
role: 'user',
|
|
parts: [{ text: 'A cute cat eating a dinosaur' }],
|
|
},
|
|
],
|
|
generationConfig: {
|
|
responseModalities: ['IMAGE', 'TEXT'],
|
|
},
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should generate multiple images using Imagen model', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/imagen-3.0-generate-002';
|
|
case 'prompt':
|
|
return 'A cute cat eating a dinosaur';
|
|
case 'options.sampleCount':
|
|
return 2;
|
|
case 'options.binaryPropertyOutput':
|
|
return 'data';
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
apiRequestMock.mockResolvedValue({
|
|
predictions: [
|
|
{
|
|
bytesBase64Encoded: 'abcdefgh',
|
|
mimeType: 'image/png',
|
|
},
|
|
{
|
|
bytesBase64Encoded: 'abcdefgh',
|
|
mimeType: 'image/png',
|
|
},
|
|
],
|
|
});
|
|
executeFunctionsMock.helpers.prepareBinaryData.mockResolvedValue({
|
|
mimeType: 'image/png',
|
|
fileName: 'image.png',
|
|
fileSize: '100',
|
|
data: 'abcdefgh',
|
|
});
|
|
|
|
const result = await image.generate.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
binary: {
|
|
data: {
|
|
mimeType: 'image/png',
|
|
fileName: 'image.png',
|
|
fileSize: '100',
|
|
data: 'abcdefgh',
|
|
},
|
|
},
|
|
json: {
|
|
mimeType: 'image/png',
|
|
fileName: 'image.png',
|
|
fileSize: '100',
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
{
|
|
binary: {
|
|
data: {
|
|
mimeType: 'image/png',
|
|
fileName: 'image.png',
|
|
fileSize: '100',
|
|
data: 'abcdefgh',
|
|
},
|
|
},
|
|
json: {
|
|
mimeType: 'image/png',
|
|
fileName: 'image.png',
|
|
fileSize: '100',
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/imagen-3.0-generate-002:predict',
|
|
{
|
|
body: {
|
|
instances: [
|
|
{
|
|
prompt: 'A cute cat eating a dinosaur',
|
|
},
|
|
],
|
|
parameters: {
|
|
sampleCount: 2,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should throw error for unsupported model', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/unsupported-model';
|
|
case 'prompt':
|
|
return 'A cute cat eating a dinosaur';
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getNode.mockReturnValue({
|
|
id: '1',
|
|
name: 'Google Gemini',
|
|
} as INode);
|
|
|
|
await expect(image.generate.execute.call(executeFunctionsMock, 0)).rejects.toThrow(
|
|
new NodeOperationError(
|
|
executeFunctionsMock.getNode(),
|
|
'Model models/unsupported-model is not supported for image generation',
|
|
{
|
|
description: 'Please check the model ID and try again.',
|
|
},
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Video -> Generate', () => {
|
|
beforeEach(() => {
|
|
jest.useFakeTimers({ advanceTimers: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('should generate video using Veo model', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/veo-3.0-generate-002';
|
|
case 'prompt':
|
|
return 'Panning wide shot of a calico kitten sleeping in the sunshine';
|
|
case 'options':
|
|
return {
|
|
aspectRatio: '16:9',
|
|
personGeneration: 'dont_allow',
|
|
sampleCount: 1,
|
|
durationSeconds: 8,
|
|
};
|
|
case 'options.binaryPropertyOutput':
|
|
return 'data';
|
|
case 'returnAs':
|
|
return 'video';
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getCredentials.mockResolvedValue({ apiKey: 'test-api-key' });
|
|
let pollCount = 0;
|
|
apiRequestMock.mockImplementation(async (_method: string, path: string) => {
|
|
if (path.includes(':predictLongRunning')) {
|
|
return {
|
|
name: 'operations/123',
|
|
done: false,
|
|
};
|
|
}
|
|
pollCount++;
|
|
return {
|
|
name: 'operations/123',
|
|
done: pollCount > 1,
|
|
response:
|
|
pollCount > 1
|
|
? {
|
|
generateVideoResponse: {
|
|
generatedSamples: [
|
|
{
|
|
video: {
|
|
uri: 'https://example.com/video.mp4',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}
|
|
: undefined,
|
|
};
|
|
});
|
|
downloadFileMock.mockResolvedValue({
|
|
fileContent: Buffer.from('abcdefgh'),
|
|
mimeType: 'video/mp4',
|
|
});
|
|
executeFunctionsMock.helpers.prepareBinaryData.mockResolvedValue({
|
|
mimeType: 'video/mp4',
|
|
fileName: 'video.mp4',
|
|
fileSize: '1000',
|
|
data: 'abcdefgh',
|
|
});
|
|
|
|
const promise = video.generate.execute.call(executeFunctionsMock, 0);
|
|
await jest.advanceTimersByTimeAsync(5000);
|
|
await jest.advanceTimersByTimeAsync(5000);
|
|
const result = await promise;
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
binary: {
|
|
data: {
|
|
mimeType: 'video/mp4',
|
|
fileName: 'video.mp4',
|
|
fileSize: '1000',
|
|
data: 'abcdefgh',
|
|
},
|
|
},
|
|
json: {
|
|
mimeType: 'video/mp4',
|
|
fileName: 'video.mp4',
|
|
fileSize: '1000',
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/veo-3.0-generate-002:predictLongRunning',
|
|
{
|
|
body: {
|
|
instances: [
|
|
{
|
|
prompt: 'Panning wide shot of a calico kitten sleeping in the sunshine',
|
|
},
|
|
],
|
|
parameters: {
|
|
aspectRatio: '16:9',
|
|
personGeneration: 'dont_allow',
|
|
sampleCount: 1,
|
|
durationSeconds: 8,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
expect(apiRequestMock).toHaveBeenCalledWith('GET', '/v1beta/operations/123');
|
|
expect(pollCount).toBe(2);
|
|
expect(downloadFileMock).toHaveBeenCalledWith('https://example.com/video.mp4', 'video/mp4', {
|
|
key: 'test-api-key',
|
|
});
|
|
});
|
|
|
|
it('should not pass durationSeconds if not provided', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/veo-3.0-generate-002';
|
|
case 'prompt':
|
|
return 'Panning wide shot of a calico kitten sleeping in the sunshine';
|
|
case 'options':
|
|
return {
|
|
aspectRatio: '16:9',
|
|
personGeneration: 'dont_allow',
|
|
sampleCount: 1,
|
|
};
|
|
case 'returnAs':
|
|
return 'url';
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getCredentials.mockResolvedValue({ apiKey: 'test-api-key' });
|
|
apiRequestMock.mockResolvedValue({
|
|
name: 'operations/123',
|
|
done: true,
|
|
response: {
|
|
generateVideoResponse: {
|
|
generatedSamples: [
|
|
{
|
|
video: {
|
|
uri: 'https://example.com/video.mp4',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await video.generate.execute.call(executeFunctionsMock, 0);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
json: {
|
|
url: 'https://example.com/video.mp4',
|
|
},
|
|
pairedItem: { item: 0 },
|
|
},
|
|
]);
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
'POST',
|
|
'/v1beta/models/veo-3.0-generate-002:predictLongRunning',
|
|
{
|
|
body: {
|
|
instances: [
|
|
{
|
|
prompt: 'Panning wide shot of a calico kitten sleeping in the sunshine',
|
|
},
|
|
],
|
|
parameters: {
|
|
aspectRatio: '16:9',
|
|
personGeneration: 'dont_allow',
|
|
sampleCount: 1,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should handle errors from video generation', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/veo-3.0-generate-002';
|
|
case 'prompt':
|
|
return 'Panning wide shot of a calico kitten sleeping in the sunshine';
|
|
case 'options':
|
|
return {};
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
executeFunctionsMock.getCredentials.mockResolvedValue({ apiKey: 'test-api-key' });
|
|
apiRequestMock.mockImplementationOnce(async () => {
|
|
return {
|
|
name: 'operations/123',
|
|
done: true,
|
|
error: {
|
|
message: 'Failed to generate video',
|
|
},
|
|
};
|
|
});
|
|
executeFunctionsMock.getNode.mockReturnValue({ name: 'Google Gemini' } as INode);
|
|
|
|
await expect(video.generate.execute.call(executeFunctionsMock, 0)).rejects.toThrow(
|
|
new NodeOperationError(executeFunctionsMock.getNode(), 'Failed to generate video', {
|
|
description: 'Error generating video',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should throw error for non-Veo model', async () => {
|
|
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
|
switch (parameter) {
|
|
case 'modelId':
|
|
return 'models/gemini-2.0-flash';
|
|
case 'prompt':
|
|
return 'Panning wide shot of a calico kitten sleeping in the sunshine';
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
|
|
executeFunctionsMock.getNode.mockReturnValue({ name: 'Google Gemini' } as INode);
|
|
|
|
await expect(video.generate.execute.call(executeFunctionsMock, 0)).rejects.toThrow(
|
|
new NodeOperationError(
|
|
executeFunctionsMock.getNode(),
|
|
'Model models/gemini-2.0-flash is not supported for video generation. Please use a Veo model',
|
|
{
|
|
description: 'Video generation is only supported by Veo models',
|
|
},
|
|
),
|
|
);
|
|
});
|
|
});
|
|
});
|