mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
refactor(editor): Use typed-mocks to speed up tests and type-checking (no-changelog) (#9796)
This commit is contained in:
parent
41e06be6fd
commit
e3e20b48eb
|
@ -1,59 +1,4 @@
|
||||||
import type { INodeTypeData, INodeTypeDescription, IN8nUISettings } from 'n8n-workflow';
|
import type { IN8nUISettings } from 'n8n-workflow';
|
||||||
import {
|
|
||||||
AGENT_NODE_TYPE,
|
|
||||||
SET_NODE_TYPE,
|
|
||||||
CHAT_TRIGGER_NODE_TYPE,
|
|
||||||
MANUAL_TRIGGER_NODE_TYPE,
|
|
||||||
} from '@/constants';
|
|
||||||
import nodeTypesJson from '../../../nodes-base/dist/types/nodes.json';
|
|
||||||
import aiNodeTypesJson from '../../../@n8n/nodes-langchain/dist/types/nodes.json';
|
|
||||||
|
|
||||||
const allNodeTypes = [...nodeTypesJson, ...aiNodeTypesJson];
|
|
||||||
|
|
||||||
export function findNodeTypeDescriptionByName(name: string): INodeTypeDescription {
|
|
||||||
return allNodeTypes.find((node) => node.name === name) as INodeTypeDescription;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const testingNodeTypes: INodeTypeData = {
|
|
||||||
[MANUAL_TRIGGER_NODE_TYPE]: {
|
|
||||||
sourcePath: '',
|
|
||||||
type: {
|
|
||||||
description: findNodeTypeDescriptionByName(MANUAL_TRIGGER_NODE_TYPE),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[SET_NODE_TYPE]: {
|
|
||||||
sourcePath: '',
|
|
||||||
type: {
|
|
||||||
description: findNodeTypeDescriptionByName(SET_NODE_TYPE),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[CHAT_TRIGGER_NODE_TYPE]: {
|
|
||||||
sourcePath: '',
|
|
||||||
type: {
|
|
||||||
description: findNodeTypeDescriptionByName(CHAT_TRIGGER_NODE_TYPE),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[AGENT_NODE_TYPE]: {
|
|
||||||
sourcePath: '',
|
|
||||||
type: {
|
|
||||||
description: findNodeTypeDescriptionByName(AGENT_NODE_TYPE),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultMockNodeTypes: INodeTypeData = {
|
|
||||||
[MANUAL_TRIGGER_NODE_TYPE]: testingNodeTypes[MANUAL_TRIGGER_NODE_TYPE],
|
|
||||||
[SET_NODE_TYPE]: testingNodeTypes[SET_NODE_TYPE],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function mockNodeTypesToArray(nodeTypes: INodeTypeData): INodeTypeDescription[] {
|
|
||||||
return Object.values(nodeTypes).map(
|
|
||||||
(nodeType) => nodeType.type.description as INodeTypeDescription,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultMockNodeTypesArray: INodeTypeDescription[] =
|
|
||||||
mockNodeTypesToArray(defaultMockNodeTypes);
|
|
||||||
|
|
||||||
export const defaultSettings: IN8nUISettings = {
|
export const defaultSettings: IN8nUISettings = {
|
||||||
allowedModules: {},
|
allowedModules: {},
|
||||||
|
|
|
@ -2,48 +2,76 @@ import type {
|
||||||
INodeType,
|
INodeType,
|
||||||
INodeTypeData,
|
INodeTypeData,
|
||||||
INodeTypes,
|
INodeTypes,
|
||||||
IVersionedNodeType,
|
|
||||||
IConnections,
|
IConnections,
|
||||||
IDataObject,
|
IDataObject,
|
||||||
INode,
|
INode,
|
||||||
IPinData,
|
IPinData,
|
||||||
IWorkflowSettings,
|
IWorkflowSettings,
|
||||||
|
LoadedClass,
|
||||||
|
INodeTypeDescription,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeHelpers, Workflow } from 'n8n-workflow';
|
import { NodeHelpers, Workflow } from 'n8n-workflow';
|
||||||
import { uuid } from '@jsplumb/util';
|
import { uuid } from '@jsplumb/util';
|
||||||
import { defaultMockNodeTypes } from '@/__tests__/defaults';
|
import { mock } from 'vitest-mock-extended';
|
||||||
import type { INodeUi, ITag, IUsedCredential, IWorkflowDb, WorkflowMetadata } from '@/Interface';
|
|
||||||
import type { ProjectSharingData } from '@/types/projects.types';
|
|
||||||
import type { RouteLocationNormalized } from 'vue-router';
|
|
||||||
|
|
||||||
export function createTestNodeTypes(data: INodeTypeData = {}): INodeTypes {
|
import {
|
||||||
const nodeTypes = {
|
AGENT_NODE_TYPE,
|
||||||
...defaultMockNodeTypes,
|
CHAT_TRIGGER_NODE_TYPE,
|
||||||
...Object.keys(data).reduce<INodeTypeData>((acc, key) => {
|
CODE_NODE_TYPE,
|
||||||
acc[key] = data[key];
|
EXECUTABLE_TRIGGER_NODE_TYPES,
|
||||||
|
MANUAL_TRIGGER_NODE_TYPE,
|
||||||
|
NO_OP_NODE_TYPE,
|
||||||
|
SET_NODE_TYPE,
|
||||||
|
} from '@/constants';
|
||||||
|
|
||||||
|
const mockNode = (name: string, type: string, props: Partial<INode> = {}) =>
|
||||||
|
mock<INode>({ name, type, ...props });
|
||||||
|
|
||||||
|
const mockLoadedClass = (name: string) =>
|
||||||
|
mock<LoadedClass<INodeType>>({
|
||||||
|
type: mock<INodeType>({
|
||||||
|
// @ts-expect-error
|
||||||
|
description: mock<INodeTypeDescription>({
|
||||||
|
name,
|
||||||
|
displayName: name,
|
||||||
|
version: 1,
|
||||||
|
properties: [],
|
||||||
|
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
|
||||||
|
inputs: ['main'],
|
||||||
|
outputs: ['main'],
|
||||||
|
documentationUrl: 'https://docs',
|
||||||
|
webhooks: undefined,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mockNodes = [
|
||||||
|
mockNode('Manual Trigger', MANUAL_TRIGGER_NODE_TYPE),
|
||||||
|
mockNode('Set', SET_NODE_TYPE),
|
||||||
|
mockNode('Code', CODE_NODE_TYPE),
|
||||||
|
mockNode('Rename', SET_NODE_TYPE),
|
||||||
|
mockNode('Chat Trigger', CHAT_TRIGGER_NODE_TYPE),
|
||||||
|
mockNode('Agent', AGENT_NODE_TYPE),
|
||||||
|
mockNode('End', NO_OP_NODE_TYPE),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const defaultNodeTypes = mockNodes.reduce<INodeTypeData>((acc, { type }) => {
|
||||||
|
acc[type] = mockLoadedClass(type);
|
||||||
return acc;
|
return acc;
|
||||||
}, {}),
|
}, {});
|
||||||
};
|
|
||||||
|
|
||||||
function getKnownTypes(): IDataObject {
|
export const defaultNodeDescriptions = Object.values(defaultNodeTypes).map(
|
||||||
return {};
|
({ type }) => type.description,
|
||||||
}
|
) as INodeTypeDescription[];
|
||||||
|
|
||||||
function getByName(nodeType: string): INodeType | IVersionedNodeType {
|
const nodeTypes = mock<INodeTypes>({
|
||||||
return nodeTypes[nodeType].type;
|
getByName(nodeType) {
|
||||||
}
|
return defaultNodeTypes[nodeType].type;
|
||||||
|
},
|
||||||
function getByNameAndVersion(nodeType: string, version?: number): INodeType {
|
getByNameAndVersion(nodeType: string, version?: number): INodeType {
|
||||||
return NodeHelpers.getVersionedNodeType(getByName(nodeType), version);
|
return NodeHelpers.getVersionedNodeType(defaultNodeTypes[nodeType].type, version);
|
||||||
}
|
},
|
||||||
|
});
|
||||||
return {
|
|
||||||
getKnownTypes,
|
|
||||||
getByName,
|
|
||||||
getByNameAndVersion,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTestWorkflowObject({
|
export function createTestWorkflowObject({
|
||||||
id = uuid(),
|
id = uuid(),
|
||||||
|
@ -51,7 +79,6 @@ export function createTestWorkflowObject({
|
||||||
nodes = [],
|
nodes = [],
|
||||||
connections = {},
|
connections = {},
|
||||||
active = false,
|
active = false,
|
||||||
nodeTypes = {},
|
|
||||||
staticData = {},
|
staticData = {},
|
||||||
settings = {},
|
settings = {},
|
||||||
pinData = {},
|
pinData = {},
|
||||||
|
@ -61,7 +88,6 @@ export function createTestWorkflowObject({
|
||||||
nodes?: INode[];
|
nodes?: INode[];
|
||||||
connections?: IConnections;
|
connections?: IConnections;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
nodeTypes?: INodeTypeData;
|
|
||||||
staticData?: IDataObject;
|
staticData?: IDataObject;
|
||||||
settings?: IWorkflowSettings;
|
settings?: IWorkflowSettings;
|
||||||
pinData?: IPinData;
|
pinData?: IPinData;
|
||||||
|
@ -75,38 +101,10 @@ export function createTestWorkflowObject({
|
||||||
staticData,
|
staticData,
|
||||||
settings,
|
settings,
|
||||||
pinData,
|
pinData,
|
||||||
nodeTypes: createTestNodeTypes(nodeTypes),
|
nodeTypes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTestWorkflow(options: {
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
active?: boolean;
|
|
||||||
createdAt?: number | string;
|
|
||||||
updatedAt?: number | string;
|
|
||||||
nodes?: INodeUi[];
|
|
||||||
connections?: IConnections;
|
|
||||||
settings?: IWorkflowSettings;
|
|
||||||
tags?: ITag[] | string[];
|
|
||||||
pinData?: IPinData;
|
|
||||||
sharedWithProjects?: ProjectSharingData[];
|
|
||||||
homeProject?: ProjectSharingData;
|
|
||||||
versionId?: string;
|
|
||||||
usedCredentials?: IUsedCredential[];
|
|
||||||
meta?: WorkflowMetadata;
|
|
||||||
}): IWorkflowDb {
|
|
||||||
return {
|
|
||||||
...options,
|
|
||||||
createdAt: options.createdAt ?? '',
|
|
||||||
updatedAt: options.updatedAt ?? '',
|
|
||||||
versionId: options.versionId ?? '',
|
|
||||||
id: options.id ?? uuid(),
|
|
||||||
active: options.active ?? false,
|
|
||||||
connections: options.connections ?? {},
|
|
||||||
} as IWorkflowDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTestNode(node: Partial<INode> = {}): INode {
|
export function createTestNode(node: Partial<INode> = {}): INode {
|
||||||
return {
|
return {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
|
@ -118,27 +116,3 @@ export function createTestNode(node: Partial<INode> = {}): INode {
|
||||||
...node,
|
...node,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTestRouteLocation({
|
|
||||||
path = '',
|
|
||||||
params = {},
|
|
||||||
fullPath = path,
|
|
||||||
hash = '',
|
|
||||||
matched = [],
|
|
||||||
redirectedFrom = undefined,
|
|
||||||
name = path,
|
|
||||||
meta = {},
|
|
||||||
query = {},
|
|
||||||
}: Partial<RouteLocationNormalized> = {}): RouteLocationNormalized {
|
|
||||||
return {
|
|
||||||
path,
|
|
||||||
params,
|
|
||||||
fullPath,
|
|
||||||
hash,
|
|
||||||
matched,
|
|
||||||
redirectedFrom,
|
|
||||||
name,
|
|
||||||
meta,
|
|
||||||
query,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,20 +1,33 @@
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||||
|
|
||||||
import CredentialIcon from '@/components/CredentialIcon.vue';
|
import CredentialIcon from '@/components/CredentialIcon.vue';
|
||||||
import { STORES } from '@/constants';
|
import { STORES } from '@/constants';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
|
||||||
import * as testNodeTypes from './testData/nodeTypesTestData';
|
|
||||||
import merge from 'lodash-es/merge';
|
|
||||||
import { groupNodeTypesByNameAndType } from '@/utils/nodeTypes/nodeTypeTransforms';
|
import { groupNodeTypesByNameAndType } from '@/utils/nodeTypes/nodeTypeTransforms';
|
||||||
|
|
||||||
const defaultState = {
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
|
||||||
|
const twitterV1 = mock<INodeTypeDescription>({
|
||||||
|
version: 1,
|
||||||
|
credentials: [{ name: 'twitterOAuth1Api', required: true }],
|
||||||
|
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
|
||||||
|
});
|
||||||
|
|
||||||
|
const twitterV2 = mock<INodeTypeDescription>({
|
||||||
|
version: 2,
|
||||||
|
credentials: [{ name: 'twitterOAuth2Api', required: true }],
|
||||||
|
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeTypes = groupNodeTypesByNameAndType([twitterV1, twitterV2]);
|
||||||
|
const initialState = {
|
||||||
[STORES.CREDENTIALS]: {},
|
[STORES.CREDENTIALS]: {},
|
||||||
[STORES.NODE_TYPES]: {},
|
[STORES.NODE_TYPES]: { nodeTypes },
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(CredentialIcon, {
|
const renderComponent = createComponentRenderer(CredentialIcon, {
|
||||||
pinia: createTestingPinia({
|
pinia: createTestingPinia({ initialState }),
|
||||||
initialState: defaultState,
|
|
||||||
}),
|
|
||||||
global: {
|
global: {
|
||||||
stubs: ['n8n-tooltip'],
|
stubs: ['n8n-tooltip'],
|
||||||
},
|
},
|
||||||
|
@ -25,17 +38,7 @@ describe('CredentialIcon', () => {
|
||||||
|
|
||||||
it('shows correct icon for credential type that is for the latest node type version', () => {
|
it('shows correct icon for credential type that is for the latest node type version', () => {
|
||||||
const { baseElement } = renderComponent({
|
const { baseElement } = renderComponent({
|
||||||
pinia: createTestingPinia({
|
pinia: createTestingPinia({ initialState }),
|
||||||
initialState: merge(defaultState, {
|
|
||||||
[STORES.CREDENTIALS]: {},
|
|
||||||
[STORES.NODE_TYPES]: {
|
|
||||||
nodeTypes: groupNodeTypesByNameAndType([
|
|
||||||
testNodeTypes.twitterV1,
|
|
||||||
testNodeTypes.twitterV2,
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
props: {
|
props: {
|
||||||
credentialTypeName: 'twitterOAuth2Api',
|
credentialTypeName: 'twitterOAuth2Api',
|
||||||
},
|
},
|
||||||
|
@ -49,17 +52,7 @@ describe('CredentialIcon', () => {
|
||||||
|
|
||||||
it('shows correct icon for credential type that is for an older node type version', () => {
|
it('shows correct icon for credential type that is for an older node type version', () => {
|
||||||
const { baseElement } = renderComponent({
|
const { baseElement } = renderComponent({
|
||||||
pinia: createTestingPinia({
|
pinia: createTestingPinia({ initialState }),
|
||||||
initialState: merge(defaultState, {
|
|
||||||
[STORES.CREDENTIALS]: {},
|
|
||||||
[STORES.NODE_TYPES]: {
|
|
||||||
nodeTypes: groupNodeTypesByNameAndType([
|
|
||||||
testNodeTypes.twitterV1,
|
|
||||||
testNodeTypes.twitterV2,
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
props: {
|
props: {
|
||||||
credentialTypeName: 'twitterOAuth1Api',
|
credentialTypeName: 'twitterOAuth1Api',
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,24 +1,23 @@
|
||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
import NodeDetailsView from '@/components/NodeDetailsView.vue';
|
import NodeDetailsView from '@/components/NodeDetailsView.vue';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import type { IWorkflowDb } from '@/Interface';
|
||||||
import { waitFor } from '@testing-library/vue';
|
|
||||||
import { uuid } from '@jsplumb/util';
|
|
||||||
import type { INode } from 'n8n-workflow';
|
|
||||||
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
|
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
|
||||||
import { defaultMockNodeTypesArray } from '@/__tests__/defaults';
|
|
||||||
import { setupServer } from '@/__tests__/server';
|
|
||||||
|
|
||||||
async function createPiniaWithActiveNode(node: INode) {
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
const workflowId = uuid();
|
import { setupServer } from '@/__tests__/server';
|
||||||
const workflow = createTestWorkflow({
|
import { defaultNodeDescriptions, mockNodes } from '@/__tests__/mocks';
|
||||||
id: workflowId,
|
|
||||||
name: 'Test Workflow',
|
async function createPiniaWithActiveNode() {
|
||||||
|
const node = mockNodes[0];
|
||||||
|
const workflow = mock<IWorkflowDb>({
|
||||||
connections: {},
|
connections: {},
|
||||||
active: true,
|
active: true,
|
||||||
nodes: [node],
|
nodes: [node],
|
||||||
|
@ -31,7 +30,7 @@ async function createPiniaWithActiveNode(node: INode) {
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
const ndvStore = useNDVStore();
|
const ndvStore = useNDVStore();
|
||||||
|
|
||||||
nodeTypesStore.setNodeTypes(defaultMockNodeTypesArray);
|
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
|
||||||
workflowsStore.workflow = workflow;
|
workflowsStore.workflow = workflow;
|
||||||
ndvStore.activeNodeName = node.name;
|
ndvStore.activeNodeName = node.name;
|
||||||
|
|
||||||
|
@ -72,12 +71,7 @@ describe('NodeDetailsView', () => {
|
||||||
|
|
||||||
it('should render correctly', async () => {
|
it('should render correctly', async () => {
|
||||||
const wrapper = renderComponent({
|
const wrapper = renderComponent({
|
||||||
pinia: await createPiniaWithActiveNode(
|
pinia: await createPiniaWithActiveNode(),
|
||||||
createTestNode({
|
|
||||||
name: 'Manual Trigger',
|
|
||||||
type: 'manualTrigger',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
|
|
|
@ -1,31 +1,23 @@
|
||||||
import WorkflowLMChatModal from '@/components/WorkflowLMChat.vue';
|
|
||||||
import { AGENT_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE, WORKFLOW_LM_CHAT_MODAL_KEY } from '@/constants';
|
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
|
||||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
|
||||||
import { uuid } from '@jsplumb/util';
|
|
||||||
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
|
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
import type { IConnections, INode } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import WorkflowLMChatModal from '@/components/WorkflowLMChat.vue';
|
||||||
|
import { WORKFLOW_LM_CHAT_MODAL_KEY } from '@/constants';
|
||||||
|
import type { IWorkflowDb } from '@/Interface';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { testingNodeTypes, mockNodeTypesToArray } from '@/__tests__/defaults';
|
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { setupServer } from '@/__tests__/server';
|
import { setupServer } from '@/__tests__/server';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { defaultNodeDescriptions, mockNodes } from '@/__tests__/mocks';
|
||||||
import type { IConnections } from 'n8n-workflow';
|
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(WorkflowLMChatModal, {
|
const connections: IConnections = {
|
||||||
props: {
|
|
||||||
teleported: false,
|
|
||||||
appendToBody: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createPiniaWithAINodes(options = { withConnections: true, withAgentNode: true }) {
|
|
||||||
const { withConnections, withAgentNode } = options;
|
|
||||||
const workflowId = uuid();
|
|
||||||
const connections: IConnections = {
|
|
||||||
'Chat Trigger': {
|
'Chat Trigger': {
|
||||||
main: [
|
main: [
|
||||||
[
|
[
|
||||||
|
@ -37,27 +29,25 @@ async function createPiniaWithAINodes(options = { withConnections: true, withAge
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const workflow = createTestWorkflow({
|
const renderComponent = createComponentRenderer(WorkflowLMChatModal, {
|
||||||
id: workflowId,
|
props: {
|
||||||
name: 'Test Workflow',
|
teleported: false,
|
||||||
active: true,
|
appendToBody: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createPiniaWithAINodes(options = { withConnections: true, withAgentNode: true }) {
|
||||||
|
const { withConnections, withAgentNode } = options;
|
||||||
|
|
||||||
|
const chatTriggerNode = mockNodes[4];
|
||||||
|
const agentNode = mockNodes[5];
|
||||||
|
const nodes: INode[] = [chatTriggerNode];
|
||||||
|
if (withAgentNode) nodes.push(agentNode);
|
||||||
|
const workflow = mock<IWorkflowDb>({
|
||||||
|
nodes,
|
||||||
...(withConnections ? { connections } : {}),
|
...(withConnections ? { connections } : {}),
|
||||||
nodes: [
|
|
||||||
createTestNode({
|
|
||||||
name: 'Chat Trigger',
|
|
||||||
type: CHAT_TRIGGER_NODE_TYPE,
|
|
||||||
}),
|
|
||||||
...(withAgentNode
|
|
||||||
? [
|
|
||||||
createTestNode({
|
|
||||||
name: 'Agent',
|
|
||||||
type: AGENT_NODE_TYPE,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
|
@ -67,12 +57,7 @@ async function createPiniaWithAINodes(options = { withConnections: true, withAge
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
nodeTypesStore.setNodeTypes(
|
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
|
||||||
mockNodeTypesToArray({
|
|
||||||
[CHAT_TRIGGER_NODE_TYPE]: testingNodeTypes[CHAT_TRIGGER_NODE_TYPE],
|
|
||||||
[AGENT_NODE_TYPE]: testingNodeTypes[AGENT_NODE_TYPE],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
workflowsStore.workflow = workflow;
|
workflowsStore.workflow = workflow;
|
||||||
|
|
||||||
await useSettingsStore().getSettings();
|
await useSettingsStore().getSettings();
|
||||||
|
|
|
@ -1,964 +0,0 @@
|
||||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
|
||||||
|
|
||||||
export const twitterV2: INodeTypeDescription = {
|
|
||||||
displayName: 'X (Formerly Twitter)',
|
|
||||||
name: 'n8n-nodes-base.twitter',
|
|
||||||
group: ['output'],
|
|
||||||
subtitle: '={{$parameter["operation"] + ":" + $parameter["resource"]}}',
|
|
||||||
description: 'Post, like, and search tweets, send messages, search users, and add users to lists',
|
|
||||||
defaultVersion: 2,
|
|
||||||
version: 2,
|
|
||||||
defaults: { name: 'X' },
|
|
||||||
inputs: ['main'],
|
|
||||||
outputs: ['main'],
|
|
||||||
credentials: [{ name: 'twitterOAuth2Api', required: true }],
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
displayName: 'Resource',
|
|
||||||
name: 'resource',
|
|
||||||
type: 'options',
|
|
||||||
noDataExpression: true,
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: 'Direct Message',
|
|
||||||
value: 'directMessage',
|
|
||||||
description: 'Send a direct message to a user',
|
|
||||||
},
|
|
||||||
{ name: 'List', value: 'list', description: 'Add a user to a list' },
|
|
||||||
{ name: 'Tweet', value: 'tweet', description: 'Create, like, search, or delete a tweet' },
|
|
||||||
{ name: 'User', value: 'user', description: 'Search users by username' },
|
|
||||||
{ name: 'Custom API Call', value: '__CUSTOM_API_CALL__' },
|
|
||||||
],
|
|
||||||
default: 'tweet',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Operation',
|
|
||||||
name: 'operation',
|
|
||||||
type: 'options',
|
|
||||||
noDataExpression: true,
|
|
||||||
displayOptions: { show: { resource: ['directMessage'] } },
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: 'Create',
|
|
||||||
value: 'create',
|
|
||||||
description: 'Send a direct message to a user',
|
|
||||||
action: 'Create Direct Message',
|
|
||||||
},
|
|
||||||
{ name: 'Custom API Call', value: '__CUSTOM_API_CALL__' },
|
|
||||||
],
|
|
||||||
default: 'create',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'User',
|
|
||||||
name: 'user',
|
|
||||||
type: 'resourceLocator',
|
|
||||||
default: { mode: 'username', value: '' },
|
|
||||||
required: true,
|
|
||||||
description: 'The user you want to send the message to',
|
|
||||||
displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } },
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
displayName: 'By Username',
|
|
||||||
name: 'username',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. n8n',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'By ID',
|
|
||||||
name: 'id',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. 1068479892537384960',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Text',
|
|
||||||
name: 'text',
|
|
||||||
type: 'string',
|
|
||||||
required: true,
|
|
||||||
default: '',
|
|
||||||
typeOptions: { rows: 2 },
|
|
||||||
displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } },
|
|
||||||
description:
|
|
||||||
'The text of the direct message. URL encoding is required. Max length of 10,000 characters.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Additional Fields',
|
|
||||||
name: 'additionalFields',
|
|
||||||
type: 'collection',
|
|
||||||
placeholder: 'Add Field',
|
|
||||||
default: {},
|
|
||||||
displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } },
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
displayName: 'Attachment ID',
|
|
||||||
name: 'attachments',
|
|
||||||
type: 'string',
|
|
||||||
default: '',
|
|
||||||
placeholder: '1664279886239010824',
|
|
||||||
description: 'The attachment ID to associate with the message',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Operation',
|
|
||||||
name: 'operation',
|
|
||||||
type: 'options',
|
|
||||||
noDataExpression: true,
|
|
||||||
displayOptions: { show: { resource: ['list'] } },
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: 'Add Member',
|
|
||||||
value: 'add',
|
|
||||||
description: 'Add a member to a list',
|
|
||||||
action: 'Add Member to List',
|
|
||||||
},
|
|
||||||
{ name: 'Custom API Call', value: '__CUSTOM_API_CALL__' },
|
|
||||||
],
|
|
||||||
default: 'add',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'List',
|
|
||||||
name: 'list',
|
|
||||||
type: 'resourceLocator',
|
|
||||||
default: { mode: 'id', value: '' },
|
|
||||||
required: true,
|
|
||||||
description: 'The list you want to add the user to',
|
|
||||||
displayOptions: { show: { operation: ['add'], resource: ['list'] } },
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
displayName: 'By ID',
|
|
||||||
name: 'id',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. 99923132',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'By URL',
|
|
||||||
name: 'url',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. https://twitter.com/i/lists/99923132',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'User',
|
|
||||||
name: 'user',
|
|
||||||
type: 'resourceLocator',
|
|
||||||
default: { mode: 'username', value: '' },
|
|
||||||
required: true,
|
|
||||||
description: 'The user you want to add to the list',
|
|
||||||
displayOptions: { show: { operation: ['add'], resource: ['list'] } },
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
displayName: 'By Username',
|
|
||||||
name: 'username',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. n8n',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'By ID',
|
|
||||||
name: 'id',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. 1068479892537384960',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Operation',
|
|
||||||
name: 'operation',
|
|
||||||
type: 'options',
|
|
||||||
noDataExpression: true,
|
|
||||||
displayOptions: { show: { resource: ['tweet'] } },
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: 'Create',
|
|
||||||
value: 'create',
|
|
||||||
description: 'Create, quote, or reply to a tweet',
|
|
||||||
action: 'Create Tweet',
|
|
||||||
},
|
|
||||||
{ name: 'Delete', value: 'delete', description: 'Delete a tweet', action: 'Delete Tweet' },
|
|
||||||
{ name: 'Like', value: 'like', description: 'Like a tweet', action: 'Like Tweet' },
|
|
||||||
{
|
|
||||||
name: 'Retweet',
|
|
||||||
value: 'retweet',
|
|
||||||
description: 'Retweet a tweet',
|
|
||||||
action: 'Retweet Tweet',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Search',
|
|
||||||
value: 'search',
|
|
||||||
description: 'Search for tweets from the last seven days',
|
|
||||||
action: 'Search Tweets',
|
|
||||||
},
|
|
||||||
{ name: 'Custom API Call', value: '__CUSTOM_API_CALL__' },
|
|
||||||
],
|
|
||||||
default: 'create',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Text',
|
|
||||||
name: 'text',
|
|
||||||
type: 'string',
|
|
||||||
typeOptions: { rows: 2 },
|
|
||||||
default: '',
|
|
||||||
required: true,
|
|
||||||
displayOptions: { show: { operation: ['create'], resource: ['tweet'] } },
|
|
||||||
description:
|
|
||||||
'The text of the status update. URLs must be encoded. Links wrapped with the t.co shortener will affect character count',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Options',
|
|
||||||
name: 'additionalFields',
|
|
||||||
type: 'collection',
|
|
||||||
placeholder: 'Add Field',
|
|
||||||
default: {},
|
|
||||||
displayOptions: { show: { operation: ['create'], resource: ['tweet'] } },
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
displayName: 'Location ID',
|
|
||||||
name: 'location',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '4e696bef7e24d378',
|
|
||||||
default: '',
|
|
||||||
description: 'Location information for the tweet',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Media ID',
|
|
||||||
name: 'attachments',
|
|
||||||
type: 'string',
|
|
||||||
default: '',
|
|
||||||
placeholder: '1664279886239010824',
|
|
||||||
description: 'The attachment ID to associate with the message',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Quote a Tweet',
|
|
||||||
name: 'inQuoteToStatusId',
|
|
||||||
type: 'resourceLocator',
|
|
||||||
default: { mode: 'id', value: '' },
|
|
||||||
description: 'The tweet being quoted',
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
displayName: 'By ID',
|
|
||||||
name: 'id',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. 1187836157394112513',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'By URL',
|
|
||||||
name: 'url',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Reply to Tweet',
|
|
||||||
name: 'inReplyToStatusId',
|
|
||||||
type: 'resourceLocator',
|
|
||||||
default: { mode: 'id', value: '' },
|
|
||||||
description: 'The tweet being replied to',
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
displayName: 'By ID',
|
|
||||||
name: 'id',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. 1187836157394112513',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'By URL',
|
|
||||||
name: 'url',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Locations are not supported due to Twitter V2 API limitations',
|
|
||||||
name: 'noticeLocation',
|
|
||||||
type: 'notice',
|
|
||||||
displayOptions: { show: { '/additionalFields.location': [''] } },
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Attachements are not supported due to Twitter V2 API limitations',
|
|
||||||
name: 'noticeAttachments',
|
|
||||||
type: 'notice',
|
|
||||||
displayOptions: { show: { '/additionalFields.attachments': [''] } },
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Tweet',
|
|
||||||
name: 'tweetDeleteId',
|
|
||||||
type: 'resourceLocator',
|
|
||||||
default: { mode: 'id', value: '' },
|
|
||||||
required: true,
|
|
||||||
description: 'The tweet to delete',
|
|
||||||
displayOptions: { show: { resource: ['tweet'], operation: ['delete'] } },
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
displayName: 'By ID',
|
|
||||||
name: 'id',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. 1187836157394112513',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'By URL',
|
|
||||||
name: 'url',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Tweet',
|
|
||||||
name: 'tweetId',
|
|
||||||
type: 'resourceLocator',
|
|
||||||
default: { mode: 'id', value: '' },
|
|
||||||
required: true,
|
|
||||||
description: 'The tweet to like',
|
|
||||||
displayOptions: { show: { operation: ['like'], resource: ['tweet'] } },
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
displayName: 'By ID',
|
|
||||||
name: 'id',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. 1187836157394112513',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'By URL',
|
|
||||||
name: 'url',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Search Term',
|
|
||||||
name: 'searchText',
|
|
||||||
type: 'string',
|
|
||||||
required: true,
|
|
||||||
default: '',
|
|
||||||
placeholder: 'e.g. automation',
|
|
||||||
displayOptions: { show: { operation: ['search'], resource: ['tweet'] } },
|
|
||||||
description:
|
|
||||||
'A UTF-8, URL-encoded search query of 500 characters maximum, including operators. Queries may additionally be limited by complexity. Check the searching examples <a href="https://developer.twitter.com/en/docs/tweets/search/guides/standard-operators">here</a>.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Return All',
|
|
||||||
name: 'returnAll',
|
|
||||||
type: 'boolean',
|
|
||||||
default: false,
|
|
||||||
description: 'Whether to return all results or only up to a given limit',
|
|
||||||
displayOptions: { show: { resource: ['tweet'], operation: ['search'] } },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Limit',
|
|
||||||
name: 'limit',
|
|
||||||
type: 'number',
|
|
||||||
default: 50,
|
|
||||||
description: 'Max number of results to return',
|
|
||||||
typeOptions: { minValue: 1 },
|
|
||||||
displayOptions: { show: { resource: ['tweet'], operation: ['search'], returnAll: [false] } },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Options',
|
|
||||||
name: 'additionalFields',
|
|
||||||
type: 'collection',
|
|
||||||
placeholder: 'Add Field',
|
|
||||||
default: {},
|
|
||||||
displayOptions: { show: { operation: ['search'], resource: ['tweet'] } },
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
displayName: 'Sort Order',
|
|
||||||
name: 'sortOrder',
|
|
||||||
type: 'options',
|
|
||||||
options: [
|
|
||||||
{ name: 'Recent', value: 'recency' },
|
|
||||||
{ name: 'Relevant', value: 'relevancy' },
|
|
||||||
],
|
|
||||||
description: 'The order in which to return results',
|
|
||||||
default: 'recency',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'After',
|
|
||||||
name: 'startTime',
|
|
||||||
type: 'dateTime',
|
|
||||||
default: '',
|
|
||||||
description:
|
|
||||||
"Tweets before this date will not be returned. This date must be within the last 7 days if you don't have Academic Research access.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Before',
|
|
||||||
name: 'endTime',
|
|
||||||
type: 'dateTime',
|
|
||||||
default: '',
|
|
||||||
description:
|
|
||||||
"Tweets after this date will not be returned. This date must be within the last 7 days if you don't have Academic Research access.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Tweet Fields',
|
|
||||||
name: 'tweetFieldsObject',
|
|
||||||
type: 'multiOptions',
|
|
||||||
options: [
|
|
||||||
{ name: 'Attachments', value: 'attachments' },
|
|
||||||
{ name: 'Author ID', value: 'author_id' },
|
|
||||||
{ name: 'Context Annotations', value: 'context_annotations' },
|
|
||||||
{ name: 'Conversation ID', value: 'conversation_id' },
|
|
||||||
{ name: 'Created At', value: 'created_at' },
|
|
||||||
{ name: 'Edit Controls', value: 'edit_controls' },
|
|
||||||
{ name: 'Entities', value: 'entities' },
|
|
||||||
{ name: 'Geo', value: 'geo' },
|
|
||||||
{ name: 'ID', value: 'id' },
|
|
||||||
{ name: 'In Reply To User ID', value: 'in_reply_to_user_id' },
|
|
||||||
{ name: 'Lang', value: 'lang' },
|
|
||||||
{ name: 'Non Public Metrics', value: 'non_public_metrics' },
|
|
||||||
{ name: 'Public Metrics', value: 'public_metrics' },
|
|
||||||
{ name: 'Organic Metrics', value: 'organic_metrics' },
|
|
||||||
{ name: 'Promoted Metrics', value: 'promoted_metrics' },
|
|
||||||
{ name: 'Possibly Sensitive', value: 'possibly_sensitive' },
|
|
||||||
{ name: 'Referenced Tweets', value: 'referenced_tweets' },
|
|
||||||
{ name: 'Reply Settings', value: 'reply_settings' },
|
|
||||||
{ name: 'Source', value: 'source' },
|
|
||||||
{ name: 'Text', value: 'text' },
|
|
||||||
{ name: 'Withheld', value: 'withheld' },
|
|
||||||
],
|
|
||||||
default: [],
|
|
||||||
description:
|
|
||||||
'The fields to add to each returned tweet object. Default fields are: ID, text, edit_history_tweet_ids.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Tweet',
|
|
||||||
name: 'tweetId',
|
|
||||||
type: 'resourceLocator',
|
|
||||||
default: { mode: 'id', value: '' },
|
|
||||||
required: true,
|
|
||||||
description: 'The tweet to retweet',
|
|
||||||
displayOptions: { show: { operation: ['retweet'], resource: ['tweet'] } },
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
displayName: 'By ID',
|
|
||||||
name: 'id',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. 1187836157394112513',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'By URL',
|
|
||||||
name: 'url',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Operation',
|
|
||||||
name: 'operation',
|
|
||||||
type: 'options',
|
|
||||||
noDataExpression: true,
|
|
||||||
displayOptions: { show: { resource: ['user'] } },
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: 'Get',
|
|
||||||
value: 'searchUser',
|
|
||||||
description: 'Retrieve a user by username',
|
|
||||||
action: 'Get User',
|
|
||||||
},
|
|
||||||
{ name: 'Custom API Call', value: '__CUSTOM_API_CALL__' },
|
|
||||||
],
|
|
||||||
default: 'searchUser',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'User',
|
|
||||||
name: 'user',
|
|
||||||
type: 'resourceLocator',
|
|
||||||
default: { mode: 'username', value: '' },
|
|
||||||
required: true,
|
|
||||||
description: 'The user you want to search',
|
|
||||||
displayOptions: {
|
|
||||||
show: { operation: ['searchUser'], resource: ['user'] },
|
|
||||||
hide: { me: [true] },
|
|
||||||
},
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
displayName: 'By Username',
|
|
||||||
name: 'username',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. n8n',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'By ID',
|
|
||||||
name: 'id',
|
|
||||||
type: 'string',
|
|
||||||
validation: [],
|
|
||||||
placeholder: 'e.g. 1068479892537384960',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Me',
|
|
||||||
name: 'me',
|
|
||||||
type: 'boolean',
|
|
||||||
displayOptions: { show: { operation: ['searchUser'], resource: ['user'] } },
|
|
||||||
default: false,
|
|
||||||
description: 'Whether you want to search the authenticated user',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
|
|
||||||
codex: {
|
|
||||||
categories: ['Marketing & Content'],
|
|
||||||
resources: {
|
|
||||||
primaryDocumentation: [
|
|
||||||
{ url: 'https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.twitter/' },
|
|
||||||
],
|
|
||||||
credentialDocumentation: [{ url: 'https://docs.n8n.io/credentials/twitter' }],
|
|
||||||
},
|
|
||||||
alias: ['Tweet', 'Twitter', 'X', 'X API'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const twitterV1: INodeTypeDescription = {
|
|
||||||
displayName: 'X (Formerly Twitter)',
|
|
||||||
name: 'n8n-nodes-base.twitter',
|
|
||||||
group: ['output'],
|
|
||||||
subtitle: '={{$parameter["operation"] + ":" + $parameter["resource"]}}',
|
|
||||||
description: 'Consume Twitter API',
|
|
||||||
defaultVersion: 2,
|
|
||||||
version: 1,
|
|
||||||
defaults: { name: 'Twitter' },
|
|
||||||
inputs: ['main'],
|
|
||||||
outputs: ['main'],
|
|
||||||
credentials: [{ name: 'twitterOAuth1Api', required: true }],
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
displayName: 'Resource',
|
|
||||||
name: 'resource',
|
|
||||||
type: 'options',
|
|
||||||
noDataExpression: true,
|
|
||||||
options: [
|
|
||||||
{ name: 'Direct Message', value: 'directMessage' },
|
|
||||||
{ name: 'Tweet', value: 'tweet' },
|
|
||||||
],
|
|
||||||
default: 'tweet',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Operation',
|
|
||||||
name: 'operation',
|
|
||||||
type: 'options',
|
|
||||||
noDataExpression: true,
|
|
||||||
displayOptions: { show: { resource: ['directMessage'] } },
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: 'Create',
|
|
||||||
value: 'create',
|
|
||||||
description: 'Create a direct message',
|
|
||||||
action: 'Create a direct message',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
default: 'create',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'User ID',
|
|
||||||
name: 'userId',
|
|
||||||
type: 'string',
|
|
||||||
required: true,
|
|
||||||
default: '',
|
|
||||||
displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } },
|
|
||||||
description: 'The ID of the user who should receive the direct message',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Text',
|
|
||||||
name: 'text',
|
|
||||||
type: 'string',
|
|
||||||
required: true,
|
|
||||||
default: '',
|
|
||||||
displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } },
|
|
||||||
description:
|
|
||||||
'The text of your Direct Message. URL encode as necessary. Max length of 10,000 characters.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Additional Fields',
|
|
||||||
name: 'additionalFields',
|
|
||||||
type: 'collection',
|
|
||||||
placeholder: 'Add Field',
|
|
||||||
default: {},
|
|
||||||
displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } },
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
displayName: 'Attachment',
|
|
||||||
name: 'attachment',
|
|
||||||
type: 'string',
|
|
||||||
default: 'data',
|
|
||||||
description:
|
|
||||||
'Name of the binary property which contain data that should be added to the direct message as attachment',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Operation',
|
|
||||||
name: 'operation',
|
|
||||||
type: 'options',
|
|
||||||
noDataExpression: true,
|
|
||||||
displayOptions: { show: { resource: ['tweet'] } },
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: 'Create',
|
|
||||||
value: 'create',
|
|
||||||
description: 'Create or reply a tweet',
|
|
||||||
action: 'Create a tweet',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Delete',
|
|
||||||
value: 'delete',
|
|
||||||
description: 'Delete a tweet',
|
|
||||||
action: 'Delete a tweet',
|
|
||||||
},
|
|
||||||
{ name: 'Like', value: 'like', description: 'Like a tweet', action: 'Like a tweet' },
|
|
||||||
{
|
|
||||||
name: 'Retweet',
|
|
||||||
value: 'retweet',
|
|
||||||
description: 'Retweet a tweet',
|
|
||||||
action: 'Retweet a tweet',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Search',
|
|
||||||
value: 'search',
|
|
||||||
description: 'Search tweets',
|
|
||||||
action: 'Search for tweets',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
default: 'create',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Text',
|
|
||||||
name: 'text',
|
|
||||||
type: 'string',
|
|
||||||
required: true,
|
|
||||||
default: '',
|
|
||||||
displayOptions: { show: { operation: ['create'], resource: ['tweet'] } },
|
|
||||||
description:
|
|
||||||
'The text of the status update. URL encode as necessary. t.co link wrapping will affect character counts.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Additional Fields',
|
|
||||||
name: 'additionalFields',
|
|
||||||
type: 'collection',
|
|
||||||
placeholder: 'Add Field',
|
|
||||||
default: {},
|
|
||||||
displayOptions: { show: { operation: ['create'], resource: ['tweet'] } },
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
displayName: 'Attachments',
|
|
||||||
name: 'attachments',
|
|
||||||
type: 'string',
|
|
||||||
default: 'data',
|
|
||||||
description:
|
|
||||||
'Name of the binary properties which contain data which should be added to tweet as attachment. Multiple ones can be comma-separated.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Display Coordinates',
|
|
||||||
name: 'displayCoordinates',
|
|
||||||
type: 'boolean',
|
|
||||||
default: false,
|
|
||||||
description:
|
|
||||||
'Whether or not to put a pin on the exact coordinates a Tweet has been sent from',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'In Reply to Tweet',
|
|
||||||
name: 'inReplyToStatusId',
|
|
||||||
type: 'string',
|
|
||||||
default: '',
|
|
||||||
description: 'The ID of an existing status that the update is in reply to',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Location',
|
|
||||||
name: 'locationFieldsUi',
|
|
||||||
type: 'fixedCollection',
|
|
||||||
placeholder: 'Add Location',
|
|
||||||
default: {},
|
|
||||||
description: 'Subscriber location information.n',
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: 'locationFieldsValues',
|
|
||||||
displayName: 'Location',
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
displayName: 'Latitude',
|
|
||||||
name: 'latitude',
|
|
||||||
type: 'string',
|
|
||||||
required: true,
|
|
||||||
description: 'The location latitude',
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Longitude',
|
|
||||||
name: 'longitude',
|
|
||||||
type: 'string',
|
|
||||||
required: true,
|
|
||||||
description: 'The location longitude',
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Possibly Sensitive',
|
|
||||||
name: 'possiblySensitive',
|
|
||||||
type: 'boolean',
|
|
||||||
default: false,
|
|
||||||
description:
|
|
||||||
'Whether you are uploading Tweet media that might be considered sensitive content such as nudity, or medical procedures',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Tweet ID',
|
|
||||||
name: 'tweetId',
|
|
||||||
type: 'string',
|
|
||||||
required: true,
|
|
||||||
default: '',
|
|
||||||
displayOptions: { show: { operation: ['delete'], resource: ['tweet'] } },
|
|
||||||
description: 'The ID of the tweet to delete',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Search Text',
|
|
||||||
name: 'searchText',
|
|
||||||
type: 'string',
|
|
||||||
required: true,
|
|
||||||
default: '',
|
|
||||||
displayOptions: { show: { operation: ['search'], resource: ['tweet'] } },
|
|
||||||
description:
|
|
||||||
'A UTF-8, URL-encoded search query of 500 characters maximum, including operators. Queries may additionally be limited by complexity. Check the searching examples <a href="https://developer.twitter.com/en/docs/tweets/search/guides/standard-operators">here</a>.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Return All',
|
|
||||||
name: 'returnAll',
|
|
||||||
type: 'boolean',
|
|
||||||
displayOptions: { show: { operation: ['search'], resource: ['tweet'] } },
|
|
||||||
default: false,
|
|
||||||
description: 'Whether to return all results or only up to a given limit',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Limit',
|
|
||||||
name: 'limit',
|
|
||||||
type: 'number',
|
|
||||||
displayOptions: { show: { operation: ['search'], resource: ['tweet'], returnAll: [false] } },
|
|
||||||
typeOptions: { minValue: 1 },
|
|
||||||
default: 50,
|
|
||||||
description: 'Max number of results to return',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Additional Fields',
|
|
||||||
name: 'additionalFields',
|
|
||||||
type: 'collection',
|
|
||||||
placeholder: 'Add Field',
|
|
||||||
default: {},
|
|
||||||
displayOptions: { show: { operation: ['search'], resource: ['tweet'] } },
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
displayName: 'Include Entities',
|
|
||||||
name: 'includeEntities',
|
|
||||||
type: 'boolean',
|
|
||||||
default: false,
|
|
||||||
description: 'Whether the entities node will be included',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Language Name or ID',
|
|
||||||
name: 'lang',
|
|
||||||
type: 'options',
|
|
||||||
typeOptions: { loadOptionsMethod: 'getLanguages' },
|
|
||||||
default: '',
|
|
||||||
description:
|
|
||||||
'Restricts tweets to the given language, given by an ISO 639-1 code. Language detection is best-effort. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Location',
|
|
||||||
name: 'locationFieldsUi',
|
|
||||||
type: 'fixedCollection',
|
|
||||||
placeholder: 'Add Location',
|
|
||||||
default: {},
|
|
||||||
description: 'Subscriber location information.n',
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: 'locationFieldsValues',
|
|
||||||
displayName: 'Location',
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
displayName: 'Latitude',
|
|
||||||
name: 'latitude',
|
|
||||||
type: 'string',
|
|
||||||
required: true,
|
|
||||||
description: 'The location latitude',
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Longitude',
|
|
||||||
name: 'longitude',
|
|
||||||
type: 'string',
|
|
||||||
required: true,
|
|
||||||
description: 'The location longitude',
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Radius',
|
|
||||||
name: 'radius',
|
|
||||||
type: 'options',
|
|
||||||
options: [
|
|
||||||
{ name: 'Milles', value: 'mi' },
|
|
||||||
{ name: 'Kilometers', value: 'km' },
|
|
||||||
],
|
|
||||||
required: true,
|
|
||||||
description:
|
|
||||||
'Returns tweets by users located within a given radius of the given latitude/longitude',
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Distance',
|
|
||||||
name: 'distance',
|
|
||||||
type: 'number',
|
|
||||||
typeOptions: { minValue: 0 },
|
|
||||||
required: true,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Result Type',
|
|
||||||
name: 'resultType',
|
|
||||||
type: 'options',
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: 'Mixed',
|
|
||||||
value: 'mixed',
|
|
||||||
description: 'Include both popular and real time results in the response',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Recent',
|
|
||||||
value: 'recent',
|
|
||||||
description: 'Return only the most recent results in the response',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Popular',
|
|
||||||
value: 'popular',
|
|
||||||
description: 'Return only the most popular results in the response',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
default: 'mixed',
|
|
||||||
description: 'Specifies what type of search results you would prefer to receive',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Tweet Mode',
|
|
||||||
name: 'tweetMode',
|
|
||||||
type: 'options',
|
|
||||||
options: [
|
|
||||||
{ name: 'Compatibility', value: 'compat' },
|
|
||||||
{ name: 'Extended', value: 'extended' },
|
|
||||||
],
|
|
||||||
default: 'compat',
|
|
||||||
description:
|
|
||||||
'When the extended mode is selected, the response contains the entire untruncated text of the Tweet',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Until',
|
|
||||||
name: 'until',
|
|
||||||
type: 'dateTime',
|
|
||||||
default: '',
|
|
||||||
description: 'Returns tweets created before the given date',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Tweet ID',
|
|
||||||
name: 'tweetId',
|
|
||||||
type: 'string',
|
|
||||||
required: true,
|
|
||||||
default: '',
|
|
||||||
displayOptions: { show: { operation: ['like'], resource: ['tweet'] } },
|
|
||||||
description: 'The ID of the tweet',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Additional Fields',
|
|
||||||
name: 'additionalFields',
|
|
||||||
type: 'collection',
|
|
||||||
placeholder: 'Add Field',
|
|
||||||
default: {},
|
|
||||||
displayOptions: { show: { operation: ['like'], resource: ['tweet'] } },
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
displayName: 'Include Entities',
|
|
||||||
name: 'includeEntities',
|
|
||||||
type: 'boolean',
|
|
||||||
default: false,
|
|
||||||
description: 'Whether the entities will be omitted',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Tweet ID',
|
|
||||||
name: 'tweetId',
|
|
||||||
type: 'string',
|
|
||||||
required: true,
|
|
||||||
default: '',
|
|
||||||
displayOptions: { show: { operation: ['retweet'], resource: ['tweet'] } },
|
|
||||||
description: 'The ID of the tweet',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Additional Fields',
|
|
||||||
name: 'additionalFields',
|
|
||||||
type: 'collection',
|
|
||||||
placeholder: 'Add Field',
|
|
||||||
default: {},
|
|
||||||
displayOptions: { show: { operation: ['retweet'], resource: ['tweet'] } },
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
displayName: 'Trim User',
|
|
||||||
name: 'trimUser',
|
|
||||||
type: 'boolean',
|
|
||||||
default: false,
|
|
||||||
description:
|
|
||||||
'Whether each tweet returned in a timeline will include a user object including only the status authors numerical ID',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
|
|
||||||
};
|
|
|
@ -1,13 +1,12 @@
|
||||||
|
import { computed } from 'vue';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
import { useActiveNode } from '@/composables/useActiveNode';
|
import { useActiveNode } from '@/composables/useActiveNode';
|
||||||
import { useNodeType } from '@/composables/useNodeType';
|
import { useNodeType } from '@/composables/useNodeType';
|
||||||
import { createTestNode } from '@/__tests__/mocks';
|
|
||||||
import { MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { defaultMockNodeTypes } from '@/__tests__/defaults';
|
|
||||||
|
|
||||||
const node = computed(() => createTestNode({ name: 'Node', type: MANUAL_TRIGGER_NODE_TYPE }));
|
const node = computed(() => mock());
|
||||||
const nodeType = computed(() => defaultMockNodeTypes[MANUAL_TRIGGER_NODE_TYPE]);
|
const nodeType = computed(() => mock());
|
||||||
|
|
||||||
vi.mock('@/stores/ndv.store', () => ({
|
vi.mock('@/stores/ndv.store', () => ({
|
||||||
useNDVStore: vi.fn(() => ({
|
useNDVStore: vi.fn(() => ({
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import type { Ref } from 'vue';
|
import type { Ref } from 'vue';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useCanvasMapping } from '@/composables/useCanvasMapping';
|
|
||||||
import { createTestNode, createTestWorkflow, createTestWorkflowObject } from '@/__tests__/mocks';
|
|
||||||
import type { IConnections, Workflow } from 'n8n-workflow';
|
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
|
||||||
import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
|
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
import type { Workflow } from 'n8n-workflow';
|
||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
import { useCanvasMapping } from '@/composables/useCanvasMapping';
|
||||||
|
import type { IWorkflowDb } from '@/Interface';
|
||||||
|
import { createTestWorkflowObject, mockNodes } from '@/__tests__/mocks';
|
||||||
|
|
||||||
vi.mock('@/stores/nodeTypes.store', () => ({
|
vi.mock('@/stores/nodeTypes.store', () => ({
|
||||||
useNodeTypesStore: vi.fn(() => ({
|
useNodeTypesStore: vi.fn(() => ({
|
||||||
|
@ -30,11 +32,8 @@ afterEach(() => {
|
||||||
|
|
||||||
describe('useCanvasMapping', () => {
|
describe('useCanvasMapping', () => {
|
||||||
it('should initialize with default props', () => {
|
it('should initialize with default props', () => {
|
||||||
const workflow = createTestWorkflow({
|
const workflow = mock<IWorkflowDb>({
|
||||||
id: '1',
|
|
||||||
name: 'Test Workflow',
|
|
||||||
nodes: [],
|
nodes: [],
|
||||||
connections: {},
|
|
||||||
});
|
});
|
||||||
const workflowObject = createTestWorkflowObject(workflow);
|
const workflowObject = createTestWorkflowObject(workflow);
|
||||||
|
|
||||||
|
@ -49,14 +48,9 @@ describe('useCanvasMapping', () => {
|
||||||
|
|
||||||
describe('elements', () => {
|
describe('elements', () => {
|
||||||
it('should map nodes to canvas elements', () => {
|
it('should map nodes to canvas elements', () => {
|
||||||
const node = createTestNode({
|
const manualTriggerNode = mockNodes[0];
|
||||||
name: 'Node',
|
const workflow = mock<IWorkflowDb>({
|
||||||
type: MANUAL_TRIGGER_NODE_TYPE,
|
nodes: [manualTriggerNode],
|
||||||
});
|
|
||||||
const workflow = createTestWorkflow({
|
|
||||||
name: 'Test Workflow',
|
|
||||||
nodes: [node],
|
|
||||||
connections: {},
|
|
||||||
});
|
});
|
||||||
const workflowObject = createTestWorkflowObject(workflow);
|
const workflowObject = createTestWorkflowObject(workflow);
|
||||||
|
|
||||||
|
@ -67,14 +61,14 @@ describe('useCanvasMapping', () => {
|
||||||
|
|
||||||
expect(elements.value).toEqual([
|
expect(elements.value).toEqual([
|
||||||
{
|
{
|
||||||
id: node.id,
|
id: manualTriggerNode.id,
|
||||||
label: node.name,
|
label: manualTriggerNode.name,
|
||||||
type: 'canvas-node',
|
type: 'canvas-node',
|
||||||
position: { x: 0, y: 0 },
|
position: expect.anything(),
|
||||||
data: {
|
data: {
|
||||||
id: node.id,
|
id: manualTriggerNode.id,
|
||||||
type: node.type,
|
type: manualTriggerNode.type,
|
||||||
typeVersion: 1,
|
typeVersion: expect.anything(),
|
||||||
inputs: [],
|
inputs: [],
|
||||||
outputs: [],
|
outputs: [],
|
||||||
renderType: 'default',
|
renderType: 'default',
|
||||||
|
@ -86,24 +80,16 @@ describe('useCanvasMapping', () => {
|
||||||
|
|
||||||
describe('connections', () => {
|
describe('connections', () => {
|
||||||
it('should map connections to canvas connections', () => {
|
it('should map connections to canvas connections', () => {
|
||||||
const nodeA = createTestNode({
|
const [manualTriggerNode, setNode] = mockNodes.slice(0, 2);
|
||||||
name: 'Node A',
|
const workflow = mock<IWorkflowDb>({
|
||||||
type: MANUAL_TRIGGER_NODE_TYPE,
|
nodes: [manualTriggerNode, setNode],
|
||||||
});
|
|
||||||
const nodeB = createTestNode({
|
|
||||||
name: 'Node B',
|
|
||||||
type: SET_NODE_TYPE,
|
|
||||||
});
|
|
||||||
const workflow = createTestWorkflow({
|
|
||||||
name: 'Test Workflow',
|
|
||||||
nodes: [nodeA, nodeB],
|
|
||||||
connections: {
|
connections: {
|
||||||
[nodeA.name]: {
|
[manualTriggerNode.name]: {
|
||||||
[NodeConnectionType.Main]: [
|
[NodeConnectionType.Main]: [
|
||||||
[{ node: nodeB.name, type: NodeConnectionType.Main, index: 0 }],
|
[{ node: setNode.name, type: NodeConnectionType.Main, index: 0 }],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
} as IConnections,
|
},
|
||||||
});
|
});
|
||||||
const workflowObject = createTestWorkflowObject(workflow);
|
const workflowObject = createTestWorkflowObject(workflow);
|
||||||
|
|
||||||
|
@ -115,7 +101,7 @@ describe('useCanvasMapping', () => {
|
||||||
expect(connections.value).toEqual([
|
expect(connections.value).toEqual([
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
fromNodeName: nodeA.name,
|
fromNodeName: manualTriggerNode.name,
|
||||||
source: {
|
source: {
|
||||||
index: 0,
|
index: 0,
|
||||||
type: NodeConnectionType.Main,
|
type: NodeConnectionType.Main,
|
||||||
|
@ -125,11 +111,11 @@ describe('useCanvasMapping', () => {
|
||||||
type: NodeConnectionType.Main,
|
type: NodeConnectionType.Main,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
id: `[${nodeA.id}/${NodeConnectionType.Main}/0][${nodeB.id}/${NodeConnectionType.Main}/0]`,
|
id: `[${manualTriggerNode.id}/${NodeConnectionType.Main}/0][${setNode.id}/${NodeConnectionType.Main}/0]`,
|
||||||
label: '',
|
label: '',
|
||||||
source: nodeA.id,
|
source: manualTriggerNode.id,
|
||||||
sourceHandle: `outputs/${NodeConnectionType.Main}/0`,
|
sourceHandle: `outputs/${NodeConnectionType.Main}/0`,
|
||||||
target: nodeB.id,
|
target: setNode.id,
|
||||||
targetHandle: `inputs/${NodeConnectionType.Main}/0`,
|
targetHandle: `inputs/${NodeConnectionType.Main}/0`,
|
||||||
type: 'canvas-edge',
|
type: 'canvas-edge',
|
||||||
},
|
},
|
||||||
|
@ -137,24 +123,16 @@ describe('useCanvasMapping', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should map multiple input types to canvas connections', () => {
|
it('should map multiple input types to canvas connections', () => {
|
||||||
const nodeA = createTestNode({
|
const [manualTriggerNode, setNode] = mockNodes.slice(0, 2);
|
||||||
name: 'Node A',
|
const workflow = mock<IWorkflowDb>({
|
||||||
type: MANUAL_TRIGGER_NODE_TYPE,
|
nodes: [manualTriggerNode, setNode],
|
||||||
});
|
|
||||||
const nodeB = createTestNode({
|
|
||||||
name: 'Node B',
|
|
||||||
type: SET_NODE_TYPE,
|
|
||||||
});
|
|
||||||
const workflow = createTestWorkflow({
|
|
||||||
name: 'Test Workflow',
|
|
||||||
nodes: [nodeA, nodeB],
|
|
||||||
connections: {
|
connections: {
|
||||||
'Node A': {
|
[manualTriggerNode.name]: {
|
||||||
[NodeConnectionType.AiTool]: [
|
[NodeConnectionType.AiTool]: [
|
||||||
[{ node: nodeB.name, type: NodeConnectionType.AiTool, index: 0 }],
|
[{ node: setNode.name, type: NodeConnectionType.AiTool, index: 0 }],
|
||||||
],
|
],
|
||||||
[NodeConnectionType.AiDocument]: [
|
[NodeConnectionType.AiDocument]: [
|
||||||
[{ node: nodeB.name, type: NodeConnectionType.AiDocument, index: 1 }],
|
[{ node: setNode.name, type: NodeConnectionType.AiDocument, index: 1 }],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -169,7 +147,7 @@ describe('useCanvasMapping', () => {
|
||||||
expect(connections.value).toEqual([
|
expect(connections.value).toEqual([
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
fromNodeName: nodeA.name,
|
fromNodeName: manualTriggerNode.name,
|
||||||
source: {
|
source: {
|
||||||
index: 0,
|
index: 0,
|
||||||
type: NodeConnectionType.AiTool,
|
type: NodeConnectionType.AiTool,
|
||||||
|
@ -179,17 +157,17 @@ describe('useCanvasMapping', () => {
|
||||||
type: NodeConnectionType.AiTool,
|
type: NodeConnectionType.AiTool,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
id: `[${nodeA.id}/${NodeConnectionType.AiTool}/0][${nodeB.id}/${NodeConnectionType.AiTool}/0]`,
|
id: `[${manualTriggerNode.id}/${NodeConnectionType.AiTool}/0][${setNode.id}/${NodeConnectionType.AiTool}/0]`,
|
||||||
label: '',
|
label: '',
|
||||||
source: nodeA.id,
|
source: manualTriggerNode.id,
|
||||||
sourceHandle: `outputs/${NodeConnectionType.AiTool}/0`,
|
sourceHandle: `outputs/${NodeConnectionType.AiTool}/0`,
|
||||||
target: nodeB.id,
|
target: setNode.id,
|
||||||
targetHandle: `inputs/${NodeConnectionType.AiTool}/0`,
|
targetHandle: `inputs/${NodeConnectionType.AiTool}/0`,
|
||||||
type: 'canvas-edge',
|
type: 'canvas-edge',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
fromNodeName: nodeA.name,
|
fromNodeName: manualTriggerNode.name,
|
||||||
source: {
|
source: {
|
||||||
index: 0,
|
index: 0,
|
||||||
type: NodeConnectionType.AiDocument,
|
type: NodeConnectionType.AiDocument,
|
||||||
|
@ -199,11 +177,11 @@ describe('useCanvasMapping', () => {
|
||||||
type: NodeConnectionType.AiDocument,
|
type: NodeConnectionType.AiDocument,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
id: `[${nodeA.id}/${NodeConnectionType.AiDocument}/0][${nodeB.id}/${NodeConnectionType.AiDocument}/1]`,
|
id: `[${manualTriggerNode.id}/${NodeConnectionType.AiDocument}/0][${setNode.id}/${NodeConnectionType.AiDocument}/1]`,
|
||||||
label: '',
|
label: '',
|
||||||
source: nodeA.id,
|
source: manualTriggerNode.id,
|
||||||
sourceHandle: `outputs/${NodeConnectionType.AiDocument}/0`,
|
sourceHandle: `outputs/${NodeConnectionType.AiDocument}/0`,
|
||||||
target: nodeB.id,
|
target: setNode.id,
|
||||||
targetHandle: `inputs/${NodeConnectionType.AiDocument}/1`,
|
targetHandle: `inputs/${NodeConnectionType.AiDocument}/1`,
|
||||||
type: 'canvas-edge',
|
type: 'canvas-edge',
|
||||||
},
|
},
|
|
@ -1,3 +1,8 @@
|
||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import type { Connection } from '@vue-flow/core';
|
||||||
|
import type { IConnection } from 'n8n-workflow';
|
||||||
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
|
||||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||||
import type { CanvasElement } from '@/types';
|
import type { CanvasElement } from '@/types';
|
||||||
import type { INodeUi } from '@/Interface';
|
import type { INodeUi } from '@/Interface';
|
||||||
|
@ -5,12 +10,8 @@ import { RemoveNodeCommand } from '@/models/history';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useHistoryStore } from '@/stores/history.store';
|
import { useHistoryStore } from '@/stores/history.store';
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
|
||||||
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
|
|
||||||
import type { Connection } from '@vue-flow/core';
|
|
||||||
import type { IConnection } from 'n8n-workflow';
|
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
|
||||||
|
|
||||||
describe('useCanvasOperations', () => {
|
describe('useCanvasOperations', () => {
|
||||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||||
|
@ -255,23 +256,6 @@ describe('useCanvasOperations', () => {
|
||||||
expect(uiStore.stateIsDirty).toBe(false);
|
expect(uiStore.stateIsDirty).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// @TODO Implement once the isConnectionAllowed method is implemented
|
|
||||||
it.skip('should not create a connection if connection is not allowed', () => {
|
|
||||||
const addConnectionSpy = vi
|
|
||||||
.spyOn(workflowsStore, 'addConnection')
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
const connection: Connection = { source: 'sourceNode', target: 'targetNode' };
|
|
||||||
|
|
||||||
vi.spyOn(workflowsStore, 'getNodeById')
|
|
||||||
.mockReturnValueOnce(createTestNode())
|
|
||||||
.mockReturnValueOnce(createTestNode());
|
|
||||||
|
|
||||||
canvasOperations.createConnection(connection);
|
|
||||||
|
|
||||||
expect(addConnectionSpy).not.toHaveBeenCalled();
|
|
||||||
expect(uiStore.stateIsDirty).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a connection if source and target nodes exist and connection is allowed', () => {
|
it('should create a connection if source and target nodes exist and connection is allowed', () => {
|
||||||
const addConnectionSpy = vi
|
const addConnectionSpy = vi
|
||||||
.spyOn(workflowsStore, 'addConnection')
|
.spyOn(workflowsStore, 'addConnection')
|
|
@ -1,43 +1,39 @@
|
||||||
import { useNodeBase } from '@/composables/useNodeBase';
|
|
||||||
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
|
|
||||||
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
|
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { mock, mockClear } from 'vitest-mock-extended';
|
||||||
|
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
|
||||||
|
import type { INode, INodeTypeDescription, Workflow } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { useNodeBase } from '@/composables/useNodeBase';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { findNodeTypeDescriptionByName } from '@/__tests__/defaults';
|
|
||||||
import { SET_NODE_TYPE } from '@/constants';
|
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import type { INodeTypeDescription, Workflow } from 'n8n-workflow';
|
|
||||||
import type { INodeUi } from '@/Interface';
|
|
||||||
import type { Mock } from '@vitest/spy';
|
|
||||||
|
|
||||||
describe('useNodeBase', () => {
|
describe('useNodeBase', () => {
|
||||||
let pinia: ReturnType<typeof createPinia>;
|
let pinia: ReturnType<typeof createPinia>;
|
||||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||||
let uiStore: ReturnType<typeof useUIStore>;
|
let uiStore: ReturnType<typeof useUIStore>;
|
||||||
let instance: Record<string, Mock>;
|
|
||||||
let workflowObject: Workflow;
|
|
||||||
let emit: (event: string, ...args: unknown[]) => void;
|
let emit: (event: string, ...args: unknown[]) => void;
|
||||||
let node: INodeUi;
|
|
||||||
let nodeTypeDescription: INodeTypeDescription;
|
|
||||||
let nodeBase: ReturnType<typeof useNodeBase>;
|
let nodeBase: ReturnType<typeof useNodeBase>;
|
||||||
|
|
||||||
|
const jsPlumbInstance = mock<BrowserJsPlumbInstance>();
|
||||||
|
const nodeTypeDescription = mock<INodeTypeDescription>({
|
||||||
|
inputs: ['main'],
|
||||||
|
outputs: ['main'],
|
||||||
|
});
|
||||||
|
const workflowObject = mock<Workflow>();
|
||||||
|
const node = mock<INode>();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mockClear(jsPlumbInstance);
|
||||||
|
|
||||||
pinia = createPinia();
|
pinia = createPinia();
|
||||||
setActivePinia(pinia);
|
setActivePinia(pinia);
|
||||||
|
|
||||||
workflowsStore = useWorkflowsStore();
|
workflowsStore = useWorkflowsStore();
|
||||||
uiStore = useUIStore();
|
uiStore = useUIStore();
|
||||||
|
|
||||||
instance = {
|
|
||||||
addEndpoint: vi.fn().mockReturnValue({}),
|
|
||||||
};
|
|
||||||
workflowObject = createTestWorkflowObject({ nodes: [], connections: {} });
|
|
||||||
node = createTestNode();
|
|
||||||
nodeTypeDescription = findNodeTypeDescriptionByName(SET_NODE_TYPE);
|
|
||||||
emit = vi.fn();
|
emit = vi.fn();
|
||||||
|
|
||||||
nodeBase = useNodeBase({
|
nodeBase = useNodeBase({
|
||||||
instance: instance as unknown as BrowserJsPlumbInstance,
|
instance: jsPlumbInstance,
|
||||||
name: node.name,
|
name: node.name,
|
||||||
workflowObject,
|
workflowObject,
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
|
@ -54,83 +50,89 @@ describe('useNodeBase', () => {
|
||||||
|
|
||||||
describe('addInputEndpoints', () => {
|
describe('addInputEndpoints', () => {
|
||||||
it('should add input endpoints correctly', () => {
|
it('should add input endpoints correctly', () => {
|
||||||
const { addInputEndpoints } = nodeBase;
|
jsPlumbInstance.addEndpoint.mockReturnValue(mock());
|
||||||
|
|
||||||
vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(node);
|
vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(node);
|
||||||
|
|
||||||
addInputEndpoints(node, nodeTypeDescription);
|
nodeBase.addInputEndpoints(node, nodeTypeDescription);
|
||||||
|
|
||||||
const addEndpointCall = instance.addEndpoint.mock.calls[0][1];
|
|
||||||
|
|
||||||
expect(workflowsStore.getNodeByName).toHaveBeenCalledWith(node.name);
|
expect(workflowsStore.getNodeByName).toHaveBeenCalledWith(node.name);
|
||||||
expect(addEndpointCall.anchor).toEqual([0.01, 0.5, -1, 0]);
|
expect(jsPlumbInstance.addEndpoint).toHaveBeenCalledWith(undefined, {
|
||||||
expect(addEndpointCall.cssClass).toEqual('rect-input-endpoint');
|
anchor: [0.01, 0.5, -1, 0],
|
||||||
expect(addEndpointCall.dragAllowedWhenFull).toEqual(true);
|
maxConnections: -1,
|
||||||
expect(addEndpointCall.enabled).toEqual(true);
|
endpoint: 'Rectangle',
|
||||||
expect(addEndpointCall.endpoint).toEqual('Rectangle');
|
paintStyle: {
|
||||||
expect(addEndpointCall.hoverClass).toEqual('rect-input-endpoint-hover');
|
|
||||||
expect(addEndpointCall.hoverPaintStyle).toMatchObject({
|
|
||||||
fill: 'var(--color-primary)',
|
|
||||||
height: 20,
|
|
||||||
lineWidth: 0,
|
|
||||||
stroke: 'var(--color-primary)',
|
|
||||||
width: 8,
|
width: 8,
|
||||||
});
|
height: 20,
|
||||||
expect(addEndpointCall.maxConnections).toEqual(-1);
|
|
||||||
expect(addEndpointCall.paintStyle).toMatchObject({
|
|
||||||
fill: 'var(--node-type-main-color)',
|
fill: 'var(--node-type-main-color)',
|
||||||
height: 20,
|
|
||||||
lineWidth: 0,
|
|
||||||
stroke: 'var(--node-type-main-color)',
|
stroke: 'var(--node-type-main-color)',
|
||||||
|
lineWidth: 0,
|
||||||
|
},
|
||||||
|
hoverPaintStyle: {
|
||||||
width: 8,
|
width: 8,
|
||||||
});
|
height: 20,
|
||||||
expect(addEndpointCall.parameters).toMatchObject({
|
fill: 'var(--color-primary)',
|
||||||
|
stroke: 'var(--color-primary)',
|
||||||
|
lineWidth: 0,
|
||||||
|
},
|
||||||
|
source: false,
|
||||||
|
target: false,
|
||||||
|
parameters: {
|
||||||
connection: 'target',
|
connection: 'target',
|
||||||
index: 0,
|
nodeId: node.id,
|
||||||
type: 'main',
|
type: 'main',
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
cssClass: 'rect-input-endpoint',
|
||||||
|
dragAllowedWhenFull: true,
|
||||||
|
hoverClass: 'rect-input-endpoint-hover',
|
||||||
|
uuid: `${node.id}-input0`,
|
||||||
});
|
});
|
||||||
expect(addEndpointCall.scope).toBeUndefined();
|
|
||||||
expect(addEndpointCall.source).toBeFalsy();
|
|
||||||
expect(addEndpointCall.target).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('addOutputEndpoints', () => {
|
describe('addOutputEndpoints', () => {
|
||||||
it('should add output endpoints correctly', () => {
|
it('should add output endpoints correctly', () => {
|
||||||
const { addOutputEndpoints } = nodeBase;
|
|
||||||
const getNodeByNameSpy = vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(node);
|
const getNodeByNameSpy = vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(node);
|
||||||
|
|
||||||
addOutputEndpoints(node, nodeTypeDescription);
|
nodeBase.addOutputEndpoints(node, nodeTypeDescription);
|
||||||
|
|
||||||
const addEndpointCall = instance.addEndpoint.mock.calls[0][1];
|
|
||||||
|
|
||||||
expect(getNodeByNameSpy).toHaveBeenCalledWith(node.name);
|
expect(getNodeByNameSpy).toHaveBeenCalledWith(node.name);
|
||||||
expect(addEndpointCall.anchor).toEqual([0.99, 0.5, 1, 0]);
|
expect(jsPlumbInstance.addEndpoint).toHaveBeenCalledWith(undefined, {
|
||||||
expect(addEndpointCall.cssClass).toEqual('dot-output-endpoint');
|
anchor: [0.99, 0.5, 1, 0],
|
||||||
expect(addEndpointCall.dragAllowedWhenFull).toEqual(false);
|
connectionsDirected: true,
|
||||||
expect(addEndpointCall.enabled).toEqual(true);
|
cssClass: 'dot-output-endpoint',
|
||||||
expect(addEndpointCall.endpoint).toEqual({
|
dragAllowedWhenFull: false,
|
||||||
|
enabled: true,
|
||||||
|
endpoint: {
|
||||||
options: {
|
options: {
|
||||||
radius: 9,
|
radius: 9,
|
||||||
},
|
},
|
||||||
type: 'Dot',
|
type: 'Dot',
|
||||||
});
|
},
|
||||||
expect(addEndpointCall.hoverClass).toEqual('dot-output-endpoint-hover');
|
hoverClass: 'dot-output-endpoint-hover',
|
||||||
expect(addEndpointCall.hoverPaintStyle).toMatchObject({
|
hoverPaintStyle: {
|
||||||
fill: 'var(--color-primary)',
|
fill: 'var(--color-primary)',
|
||||||
});
|
outlineStroke: 'none',
|
||||||
expect(addEndpointCall.maxConnections).toEqual(-1);
|
strokeWidth: 9,
|
||||||
expect(addEndpointCall.paintStyle).toMatchObject({
|
},
|
||||||
|
maxConnections: -1,
|
||||||
|
paintStyle: {
|
||||||
fill: 'var(--node-type-main-color)',
|
fill: 'var(--node-type-main-color)',
|
||||||
});
|
outlineStroke: 'none',
|
||||||
expect(addEndpointCall.parameters).toMatchObject({
|
strokeWidth: 9,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
connection: 'source',
|
connection: 'source',
|
||||||
index: 0,
|
index: 0,
|
||||||
|
nodeId: node.id,
|
||||||
type: 'main',
|
type: 'main',
|
||||||
|
},
|
||||||
|
scope: undefined,
|
||||||
|
source: true,
|
||||||
|
target: false,
|
||||||
|
uuid: `${node.id}-output0`,
|
||||||
});
|
});
|
||||||
expect(addEndpointCall.scope).toBeUndefined();
|
|
||||||
expect(addEndpointCall.source).toBeTruthy();
|
|
||||||
expect(addEndpointCall.target).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import type router from 'vue-router';
|
||||||
|
import { ExpressionError, type IPinData, type IRunData, type Workflow } from 'n8n-workflow';
|
||||||
|
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
|
||||||
import { setActivePinia } from 'pinia';
|
|
||||||
import type { IStartRunData, IWorkflowData } from '@/Interface';
|
import type { IStartRunData, IWorkflowData } from '@/Interface';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { ExpressionError, type IPinData, type IRunData, type Workflow } from 'n8n-workflow';
|
|
||||||
import type * as router from 'vue-router';
|
|
||||||
|
|
||||||
vi.mock('@/stores/workflows.store', () => ({
|
vi.mock('@/stores/workflows.store', () => ({
|
||||||
useWorkflowsStore: vi.fn().mockReturnValue({
|
useWorkflowsStore: vi.fn().mockReturnValue({
|
|
@ -231,7 +231,7 @@ export function useNodeBase({
|
||||||
const endpoint = instance?.addEndpoint(
|
const endpoint = instance?.addEndpoint(
|
||||||
refs.value[data.value?.name ?? ''] as Element,
|
refs.value[data.value?.name ?? ''] as Element,
|
||||||
newEndpointData,
|
newEndpointData,
|
||||||
) as Endpoint;
|
);
|
||||||
addEndpointTestingData(endpoint, 'input', typeIndex);
|
addEndpointTestingData(endpoint, 'input', typeIndex);
|
||||||
if (inputConfiguration.displayName ?? nodeTypeData.inputNames?.[i]) {
|
if (inputConfiguration.displayName ?? nodeTypeData.inputNames?.[i]) {
|
||||||
// Apply input names if they got set
|
// Apply input names if they got set
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
natives,
|
natives,
|
||||||
} from '@/plugins/codemirror/completions/datatype.completions';
|
} from '@/plugins/codemirror/completions/datatype.completions';
|
||||||
|
|
||||||
import { mockNodes, mockProxy } from './mock';
|
import { mockProxy } from './mock';
|
||||||
import type { CompletionSource, CompletionResult } from '@codemirror/autocomplete';
|
import type { CompletionSource, CompletionResult } from '@codemirror/autocomplete';
|
||||||
import { CompletionContext } from '@codemirror/autocomplete';
|
import { CompletionContext } from '@codemirror/autocomplete';
|
||||||
import { EditorState } from '@codemirror/state';
|
import { EditorState } from '@codemirror/state';
|
||||||
|
@ -30,6 +30,7 @@ import {
|
||||||
STRING_RECOMMENDED_OPTIONS,
|
STRING_RECOMMENDED_OPTIONS,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { set, uniqBy } from 'lodash-es';
|
import { set, uniqBy } from 'lodash-es';
|
||||||
|
import { mockNodes } from '@/__tests__/mocks';
|
||||||
|
|
||||||
let externalSecretsStore: ReturnType<typeof useExternalSecretsStore>;
|
let externalSecretsStore: ReturnType<typeof useExternalSecretsStore>;
|
||||||
let uiStore: ReturnType<typeof useUIStore>;
|
let uiStore: ReturnType<typeof useUIStore>;
|
||||||
|
|
|
@ -1,126 +1,7 @@
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import type { INode, IRunExecutionData, IExecuteData } from 'n8n-workflow';
|
||||||
import type {
|
|
||||||
INode,
|
|
||||||
IConnections,
|
|
||||||
IRunExecutionData,
|
|
||||||
IExecuteData,
|
|
||||||
INodeTypeData,
|
|
||||||
} from 'n8n-workflow';
|
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
|
||||||
import { WorkflowDataProxy } from 'n8n-workflow';
|
import { WorkflowDataProxy } from 'n8n-workflow';
|
||||||
import { createTestWorkflowObject } from '@/__tests__/mocks';
|
import { createTestWorkflowObject, mockNodes } from '@/__tests__/mocks';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
const nodeTypes: INodeTypeData = {
|
|
||||||
'n8n-nodes-base.set': {
|
|
||||||
sourcePath: '',
|
|
||||||
type: {
|
|
||||||
description: {
|
|
||||||
displayName: 'Set',
|
|
||||||
name: 'n8n-nodes-base.set',
|
|
||||||
group: ['input'],
|
|
||||||
version: 1,
|
|
||||||
description: 'Sets a value',
|
|
||||||
defaults: {
|
|
||||||
name: 'Set',
|
|
||||||
color: '#0000FF',
|
|
||||||
},
|
|
||||||
inputs: ['main'],
|
|
||||||
outputs: ['main'],
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
displayName: 'Value1',
|
|
||||||
name: 'value1',
|
|
||||||
type: 'string',
|
|
||||||
default: 'default-value1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Value2',
|
|
||||||
name: 'value2',
|
|
||||||
type: 'string',
|
|
||||||
default: 'default-value2',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const nodes: INode[] = [
|
|
||||||
{
|
|
||||||
name: 'Start',
|
|
||||||
type: 'n8n-nodes-base.set',
|
|
||||||
parameters: {},
|
|
||||||
typeVersion: 1,
|
|
||||||
id: 'uuid-1',
|
|
||||||
position: [100, 200],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Function',
|
|
||||||
type: 'n8n-nodes-base.set',
|
|
||||||
parameters: {
|
|
||||||
functionCode:
|
|
||||||
'// Code here will run only once, no matter how many input items there are.\n// More info and help: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.function/\nconst { DateTime, Duration, Interval } = require("luxon");\n\nconst data = [\n {\n "length": 105\n },\n {\n "length": 160\n },\n {\n "length": 121\n },\n {\n "length": 275\n },\n {\n "length": 950\n },\n];\n\nreturn data.map(fact => ({json: fact}));',
|
|
||||||
},
|
|
||||||
typeVersion: 1,
|
|
||||||
id: 'uuid-2',
|
|
||||||
position: [280, 200],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Rename',
|
|
||||||
type: 'n8n-nodes-base.set',
|
|
||||||
parameters: {
|
|
||||||
value1: 'data',
|
|
||||||
value2: 'initialName',
|
|
||||||
},
|
|
||||||
typeVersion: 1,
|
|
||||||
id: 'uuid-3',
|
|
||||||
position: [460, 200],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'End',
|
|
||||||
type: 'n8n-nodes-base.set',
|
|
||||||
parameters: {},
|
|
||||||
typeVersion: 1,
|
|
||||||
id: 'uuid-4',
|
|
||||||
position: [640, 200],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const connections: IConnections = {
|
|
||||||
Start: {
|
|
||||||
main: [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
node: 'Function',
|
|
||||||
type: NodeConnectionType.Main,
|
|
||||||
index: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Function: {
|
|
||||||
main: [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
node: 'Rename',
|
|
||||||
type: NodeConnectionType.Main,
|
|
||||||
index: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Rename: {
|
|
||||||
main: [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
node: 'End',
|
|
||||||
type: NodeConnectionType.Main,
|
|
||||||
index: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const runExecutionData: IRunExecutionData = {
|
const runExecutionData: IRunExecutionData = {
|
||||||
resultData: {
|
resultData: {
|
||||||
|
@ -265,20 +146,19 @@ const runExecutionData: IRunExecutionData = {
|
||||||
const workflow = createTestWorkflowObject({
|
const workflow = createTestWorkflowObject({
|
||||||
id: '123',
|
id: '123',
|
||||||
name: 'test workflow',
|
name: 'test workflow',
|
||||||
nodes,
|
nodes: mockNodes,
|
||||||
connections,
|
connections: mock(),
|
||||||
active: false,
|
active: false,
|
||||||
nodeTypes,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastNodeName = 'End';
|
const lastNodeName = mockNodes[mockNodes.length - 1].name;
|
||||||
|
|
||||||
const lastNodeConnectionInputData =
|
const lastNodeConnectionInputData =
|
||||||
runExecutionData.resultData.runData[lastNodeName][0].data!.main[0];
|
runExecutionData.resultData.runData[lastNodeName][0].data!.main[0];
|
||||||
|
|
||||||
const executeData: IExecuteData = {
|
const executeData: IExecuteData = {
|
||||||
data: runExecutionData.resultData.runData[lastNodeName][0].data!,
|
data: runExecutionData.resultData.runData[lastNodeName][0].data!,
|
||||||
node: nodes.find((node) => node.name === lastNodeName) as INode,
|
node: mockNodes.find((node) => node.name === lastNodeName) as INode,
|
||||||
source: {
|
source: {
|
||||||
main: runExecutionData.resultData.runData[lastNodeName][0].source,
|
main: runExecutionData.resultData.runData[lastNodeName][0].source,
|
||||||
},
|
},
|
||||||
|
@ -298,20 +178,3 @@ const dataProxy = new WorkflowDataProxy(
|
||||||
);
|
);
|
||||||
|
|
||||||
export const mockProxy = dataProxy.getDataProxy();
|
export const mockProxy = dataProxy.getDataProxy();
|
||||||
|
|
||||||
export const mockNodes = [
|
|
||||||
{
|
|
||||||
id: uuidv4(),
|
|
||||||
name: 'Manual',
|
|
||||||
position: [0, 0],
|
|
||||||
type: 'n8n-nodes-base.manualTrigger',
|
|
||||||
typeVersion: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: uuidv4(),
|
|
||||||
name: 'Set',
|
|
||||||
position: [0, 0],
|
|
||||||
type: 'n8n-nodes-base.set',
|
|
||||||
typeVersion: 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
|
@ -1,20 +1,21 @@
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import type { RouteLocationNormalized } from 'vue-router';
|
||||||
import {
|
import {
|
||||||
inferProjectIdFromRoute,
|
inferProjectIdFromRoute,
|
||||||
inferResourceTypeFromRoute,
|
inferResourceTypeFromRoute,
|
||||||
inferResourceIdFromRoute,
|
inferResourceIdFromRoute,
|
||||||
} from '../rbacUtils';
|
} from '@/utils/rbacUtils';
|
||||||
import { createTestRouteLocation } from '@/__tests__/mocks';
|
|
||||||
|
|
||||||
describe('rbacUtils', () => {
|
describe('rbacUtils', () => {
|
||||||
describe('inferProjectIdFromRoute()', () => {
|
describe('inferProjectIdFromRoute()', () => {
|
||||||
it('should infer project ID from route correctly', () => {
|
it('should infer project ID from route correctly', () => {
|
||||||
const route = createTestRouteLocation({ path: '/dashboard/projects/123/settings' });
|
const route = mock<RouteLocationNormalized>({ path: '/dashboard/projects/123/settings' });
|
||||||
const projectId = inferProjectIdFromRoute(route);
|
const projectId = inferProjectIdFromRoute(route);
|
||||||
expect(projectId).toBe('123');
|
expect(projectId).toBe('123');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return undefined for project ID if not found', () => {
|
it('should return undefined for project ID if not found', () => {
|
||||||
const route = createTestRouteLocation({ path: '/dashboard/settings' });
|
const route = mock<RouteLocationNormalized>({ path: '/dashboard/settings' });
|
||||||
const projectId = inferProjectIdFromRoute(route);
|
const projectId = inferProjectIdFromRoute(route);
|
||||||
expect(projectId).toBeUndefined();
|
expect(projectId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
@ -31,13 +32,13 @@ describe('rbacUtils', () => {
|
||||||
['/source-control', 'sourceControl'],
|
['/source-control', 'sourceControl'],
|
||||||
['/external-secrets', 'externalSecret'],
|
['/external-secrets', 'externalSecret'],
|
||||||
])('should infer resource type from %s correctly to %s', (path, type) => {
|
])('should infer resource type from %s correctly to %s', (path, type) => {
|
||||||
const route = createTestRouteLocation({ path });
|
const route = mock<RouteLocationNormalized>({ path });
|
||||||
const resourceType = inferResourceTypeFromRoute(route);
|
const resourceType = inferResourceTypeFromRoute(route);
|
||||||
expect(resourceType).toBe(type);
|
expect(resourceType).toBe(type);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return undefined for resource type if not found', () => {
|
it('should return undefined for resource type if not found', () => {
|
||||||
const route = createTestRouteLocation({ path: '/dashboard/settings' });
|
const route = mock<RouteLocationNormalized>({ path: '/dashboard/settings' });
|
||||||
const resourceType = inferResourceTypeFromRoute(route);
|
const resourceType = inferResourceTypeFromRoute(route);
|
||||||
expect(resourceType).toBeUndefined();
|
expect(resourceType).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
@ -45,19 +46,21 @@ describe('rbacUtils', () => {
|
||||||
|
|
||||||
describe('inferResourceIdFromRoute()', () => {
|
describe('inferResourceIdFromRoute()', () => {
|
||||||
it('should infer resource ID from params.id', () => {
|
it('should infer resource ID from params.id', () => {
|
||||||
const route = createTestRouteLocation({ params: { id: 'abc123' } });
|
const route = mock<RouteLocationNormalized>({ params: { id: 'abc123' } });
|
||||||
const resourceId = inferResourceIdFromRoute(route);
|
const resourceId = inferResourceIdFromRoute(route);
|
||||||
expect(resourceId).toBe('abc123');
|
expect(resourceId).toBe('abc123');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should infer resource ID from params.name if id is not present', () => {
|
it('should infer resource ID from params.name if id is not present', () => {
|
||||||
const route = createTestRouteLocation({ params: { name: 'my-resource' } });
|
const route = mock<RouteLocationNormalized>();
|
||||||
|
route.params = { name: 'my-resource' };
|
||||||
const resourceId = inferResourceIdFromRoute(route);
|
const resourceId = inferResourceIdFromRoute(route);
|
||||||
expect(resourceId).toBe('my-resource');
|
expect(resourceId).toBe('my-resource');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return undefined for resource ID if neither id nor name is present', () => {
|
it('should return undefined for resource ID if neither id nor name is present', () => {
|
||||||
const route = createTestRouteLocation({ params: {} });
|
const route = mock<RouteLocationNormalized>();
|
||||||
|
route.params = {};
|
||||||
const resourceId = inferResourceIdFromRoute(route);
|
const resourceId = inferResourceIdFromRoute(route);
|
||||||
expect(resourceId).toBeUndefined();
|
expect(resourceId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,11 @@
|
||||||
|
import type { Router } from 'vue-router';
|
||||||
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
|
import type { ITemplatesWorkflowFull } from '@/Interface';
|
||||||
import { Telemetry } from '@/plugins/telemetry';
|
import { Telemetry } from '@/plugins/telemetry';
|
||||||
import type { NodeTypesStore } from '@/stores/nodeTypes.store';
|
import type { NodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
@ -7,23 +14,33 @@ import { usePostHog } from '@/stores/posthog.store';
|
||||||
import type { TemplatesStore } from '@/stores/templates.store';
|
import type { TemplatesStore } from '@/stores/templates.store';
|
||||||
import { useTemplatesStore } from '@/stores/templates.store';
|
import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
import { useTemplateWorkflow } from '@/utils/templates/templateActions';
|
import { useTemplateWorkflow } from '@/utils/templates/templateActions';
|
||||||
import {
|
import { nodeTypeTelegram } from '@/utils/testData/nodeTypeTestData';
|
||||||
nodeTypeRespondToWebhookV1,
|
|
||||||
nodeTypeShopifyTriggerV1,
|
const testTemplate1 = mock<ITemplatesWorkflowFull>({
|
||||||
nodeTypeTelegramV1,
|
id: 1,
|
||||||
nodeTypeTwitterV1,
|
workflow: {
|
||||||
nodeTypeWebhookV1,
|
nodes: [],
|
||||||
nodeTypeWebhookV1_1,
|
},
|
||||||
nodeTypesSet,
|
full: true,
|
||||||
} from '@/utils/testData/nodeTypeTestData';
|
});
|
||||||
import {
|
|
||||||
fullCreateApiEndpointTemplate,
|
export const testTemplate2 = mock<ITemplatesWorkflowFull>({
|
||||||
fullShopifyTelegramTwitterTemplate,
|
id: 2,
|
||||||
} from '@/utils/testData/templateTestData';
|
workflow: {
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
nodes: [
|
||||||
import { setActivePinia } from 'pinia';
|
{
|
||||||
import { vi } from 'vitest';
|
name: 'Telegram',
|
||||||
import type { Router } from 'vue-router';
|
type: 'n8n-nodes-base.telegram',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
credentials: {
|
||||||
|
telegramApi: 'telegram_habot',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
full: true,
|
||||||
|
});
|
||||||
|
|
||||||
describe('templateActions', () => {
|
describe('templateActions', () => {
|
||||||
describe('useTemplateWorkflow', () => {
|
describe('useTemplateWorkflow', () => {
|
||||||
|
@ -80,16 +97,12 @@ describe('templateActions', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When feature flag is enabled and template has nodes requiring credentials', () => {
|
describe('When feature flag is enabled and template has nodes requiring credentials', () => {
|
||||||
const templateId = fullShopifyTelegramTwitterTemplate.id.toString();
|
const templateId = testTemplate2.id.toString();
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
posthogStore.isFeatureEnabled = vi.fn().mockReturnValue(true);
|
posthogStore.isFeatureEnabled = vi.fn().mockReturnValue(true);
|
||||||
templatesStore.addWorkflows([fullShopifyTelegramTwitterTemplate]);
|
templatesStore.addWorkflows([testTemplate2]);
|
||||||
nodeTypesStore.setNodeTypes([
|
nodeTypesStore.setNodeTypes([nodeTypeTelegram]);
|
||||||
nodeTypeTelegramV1,
|
|
||||||
nodeTypeTwitterV1,
|
|
||||||
nodeTypeShopifyTriggerV1,
|
|
||||||
]);
|
|
||||||
vi.spyOn(nodeTypesStore, 'loadNodeTypesIfNotLoaded').mockResolvedValue();
|
vi.spyOn(nodeTypesStore, 'loadNodeTypesIfNotLoaded').mockResolvedValue();
|
||||||
|
|
||||||
await useTemplateWorkflow({
|
await useTemplateWorkflow({
|
||||||
|
@ -113,17 +126,11 @@ describe('templateActions', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("When feature flag is enabled and template doesn't have nodes requiring credentials", () => {
|
describe("When feature flag is enabled and template doesn't have nodes requiring credentials", () => {
|
||||||
const templateId = fullCreateApiEndpointTemplate.id.toString();
|
const templateId = testTemplate1.id.toString();
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
posthogStore.isFeatureEnabled = vi.fn().mockReturnValue(true);
|
posthogStore.isFeatureEnabled = vi.fn().mockReturnValue(true);
|
||||||
templatesStore.addWorkflows([fullCreateApiEndpointTemplate]);
|
templatesStore.addWorkflows([testTemplate1]);
|
||||||
nodeTypesStore.setNodeTypes([
|
|
||||||
nodeTypeWebhookV1,
|
|
||||||
nodeTypeWebhookV1_1,
|
|
||||||
nodeTypeRespondToWebhookV1,
|
|
||||||
...Object.values(nodeTypesSet),
|
|
||||||
]);
|
|
||||||
vi.spyOn(nodeTypesStore, 'loadNodeTypesIfNotLoaded').mockResolvedValue();
|
vi.spyOn(nodeTypesStore, 'loadNodeTypesIfNotLoaded').mockResolvedValue();
|
||||||
|
|
||||||
await useTemplateWorkflow({
|
await useTemplateWorkflow({
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import type { IWorkflowTemplateNode } from '@/Interface';
|
||||||
import {
|
import {
|
||||||
keyFromCredentialTypeAndName,
|
keyFromCredentialTypeAndName,
|
||||||
replaceAllTemplateNodeCredentials,
|
replaceAllTemplateNodeCredentials,
|
||||||
} from '@/utils/templates/templateTransforms';
|
} from '@/utils/templates/templateTransforms';
|
||||||
import { newWorkflowTemplateNode } from '@/utils/testData/templateTestData';
|
|
||||||
|
|
||||||
describe('templateTransforms', () => {
|
describe('templateTransforms', () => {
|
||||||
describe('replaceAllTemplateNodeCredentials', () => {
|
describe('replaceAllTemplateNodeCredentials', () => {
|
||||||
|
@ -10,7 +11,7 @@ describe('templateTransforms', () => {
|
||||||
const nodeTypeProvider = {
|
const nodeTypeProvider = {
|
||||||
getNodeType: vitest.fn(),
|
getNodeType: vitest.fn(),
|
||||||
};
|
};
|
||||||
const node = newWorkflowTemplateNode({
|
const node = mock<IWorkflowTemplateNode>({
|
||||||
id: 'twitter',
|
id: 'twitter',
|
||||||
type: 'n8n-nodes-base.twitter',
|
type: 'n8n-nodes-base.twitter',
|
||||||
credentials: {
|
credentials: {
|
||||||
|
@ -40,7 +41,7 @@ describe('templateTransforms', () => {
|
||||||
const nodeTypeProvider = {
|
const nodeTypeProvider = {
|
||||||
getNodeType: vitest.fn(),
|
getNodeType: vitest.fn(),
|
||||||
};
|
};
|
||||||
const node = newWorkflowTemplateNode({
|
const node = mock<IWorkflowTemplateNode>({
|
||||||
id: 'twitter',
|
id: 'twitter',
|
||||||
type: 'n8n-nodes-base.twitter',
|
type: 'n8n-nodes-base.twitter',
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,43 +0,0 @@
|
||||||
/**
|
|
||||||
* Credential type test data
|
|
||||||
*/
|
|
||||||
import type { ICredentialType } from 'n8n-workflow';
|
|
||||||
|
|
||||||
export const newCredentialType = (name: string): ICredentialType => ({
|
|
||||||
name,
|
|
||||||
displayName: name,
|
|
||||||
documentationUrl: name,
|
|
||||||
properties: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const credentialTypeTelegram = {
|
|
||||||
name: 'telegramApi',
|
|
||||||
displayName: 'Telegram API',
|
|
||||||
documentationUrl: 'telegram',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
displayName: 'Access Token',
|
|
||||||
name: 'accessToken',
|
|
||||||
type: 'string',
|
|
||||||
typeOptions: {
|
|
||||||
password: true,
|
|
||||||
},
|
|
||||||
default: '',
|
|
||||||
description:
|
|
||||||
'Chat with the <a href="https://telegram.me/botfather">bot father</a> to obtain the access token',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Base URL',
|
|
||||||
name: 'baseUrl',
|
|
||||||
type: 'string',
|
|
||||||
default: 'https://api.telegram.org',
|
|
||||||
description: 'Base URL for Telegram Bot API',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
test: {
|
|
||||||
request: {
|
|
||||||
baseURL: '={{$credentials.baseUrl}}/bot{{$credentials.accessToken}}',
|
|
||||||
url: '/getMe',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies ICredentialType;
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
|
@ -1,12 +1,71 @@
|
||||||
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import type { ICredentialType } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ICredentialsResponse,
|
||||||
|
ITemplatesWorkflowFull,
|
||||||
|
IWorkflowTemplateNode,
|
||||||
|
} from '@/Interface';
|
||||||
import { useTemplatesStore } from '@/stores/templates.store';
|
import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
import { keyFromCredentialTypeAndName } from '@/utils/templates/templateTransforms';
|
import { keyFromCredentialTypeAndName } from '@/utils/templates/templateTransforms';
|
||||||
import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
||||||
import { setActivePinia } from 'pinia';
|
|
||||||
import * as testData from './setupTemplate.store.testData';
|
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
|
||||||
|
import {
|
||||||
|
nodeTypeHttpRequest,
|
||||||
|
nodeTypeNextCloud,
|
||||||
|
nodeTypeReadImap,
|
||||||
|
nodeTypeTelegram,
|
||||||
|
nodeTypeTwitter,
|
||||||
|
} from '@/utils/testData/nodeTypeTestData';
|
||||||
|
import * as testData from './setupTemplate.store.testData';
|
||||||
|
|
||||||
|
const mockCredentialsResponse = (id: string) =>
|
||||||
|
mock<ICredentialsResponse>({
|
||||||
|
id,
|
||||||
|
name: 'Telegram account',
|
||||||
|
type: 'telegramApi',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockCredentialType = (name: string) => mock<ICredentialType>({ name });
|
||||||
|
|
||||||
|
const mockTemplateNode = (name: string, type: string) =>
|
||||||
|
mock<IWorkflowTemplateNode>({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
const testTemplate1 = mock<ITemplatesWorkflowFull>({
|
||||||
|
id: 1,
|
||||||
|
workflow: {
|
||||||
|
nodes: [
|
||||||
|
mockTemplateNode('IMAP Email', 'n8n-nodes-base.emailReadImap'),
|
||||||
|
mockTemplateNode('Nextcloud', 'n8n-nodes-base.nextCloud'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
full: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const testTemplate2 = mock<ITemplatesWorkflowFull>({
|
||||||
|
id: 2,
|
||||||
|
workflow: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
...mockTemplateNode('Telegram', 'n8n-nodes-base.telegram'),
|
||||||
|
credentials: {
|
||||||
|
telegramApi: 'telegram_habot',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
full: true,
|
||||||
|
});
|
||||||
|
|
||||||
describe('SetupWorkflowFromTemplateView store', () => {
|
describe('SetupWorkflowFromTemplateView store', () => {
|
||||||
describe('setInitialCredentialsSelection', () => {
|
describe('setInitialCredentialsSelection', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -17,18 +76,13 @@ describe('SetupWorkflowFromTemplateView store', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const credentialsStore = useCredentialsStore();
|
const credentialsStore = useCredentialsStore();
|
||||||
credentialsStore.setCredentialTypes([testData.credentialTypeTelegram]);
|
credentialsStore.setCredentialTypes([mockCredentialType('telegramApi')]);
|
||||||
const templatesStore = useTemplatesStore();
|
const templatesStore = useTemplatesStore();
|
||||||
templatesStore.addWorkflows([testData.fullShopifyTelegramTwitterTemplate]);
|
templatesStore.addWorkflows([testTemplate2]);
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
nodeTypesStore.setNodeTypes([
|
nodeTypesStore.setNodeTypes([nodeTypeTelegram, nodeTypeTwitter, nodeTypeHttpRequest]);
|
||||||
testData.nodeTypeTelegramV1,
|
|
||||||
testData.nodeTypeTwitterV1,
|
|
||||||
testData.nodeTypeShopifyTriggerV1,
|
|
||||||
testData.nodeTypeHttpRequestV1,
|
|
||||||
]);
|
|
||||||
const setupTemplateStore = useSetupTemplateStore();
|
const setupTemplateStore = useSetupTemplateStore();
|
||||||
setupTemplateStore.setTemplateId(testData.fullShopifyTelegramTwitterTemplate.id.toString());
|
setupTemplateStore.setTemplateId(testTemplate2.id.toString());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should select no credentials when there isn't any available", () => {
|
it("should select no credentials when there isn't any available", () => {
|
||||||
|
@ -43,8 +97,9 @@ describe('SetupWorkflowFromTemplateView store', () => {
|
||||||
|
|
||||||
it("should select credential when there's only one", () => {
|
it("should select credential when there's only one", () => {
|
||||||
// Setup
|
// Setup
|
||||||
|
const credentialId = 'YaSKdvEcT1TSFrrr1';
|
||||||
const credentialsStore = useCredentialsStore();
|
const credentialsStore = useCredentialsStore();
|
||||||
credentialsStore.setCredentials([testData.credentialsTelegram1]);
|
credentialsStore.setCredentials([mockCredentialsResponse(credentialId)]);
|
||||||
|
|
||||||
const setupTemplateStore = useSetupTemplateStore();
|
const setupTemplateStore = useSetupTemplateStore();
|
||||||
|
|
||||||
|
@ -52,17 +107,14 @@ describe('SetupWorkflowFromTemplateView store', () => {
|
||||||
setupTemplateStore.setInitialCredentialSelection();
|
setupTemplateStore.setInitialCredentialSelection();
|
||||||
|
|
||||||
expect(setupTemplateStore.selectedCredentialIdByKey).toEqual({
|
expect(setupTemplateStore.selectedCredentialIdByKey).toEqual({
|
||||||
[keyFromCredentialTypeAndName('telegramApi', 'telegram_habot')]: 'YaSKdvEcT1TSFrrr1',
|
[keyFromCredentialTypeAndName('telegramApi', 'telegram_habot')]: credentialId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should select no credentials when there are more than 1 available', () => {
|
it('should select no credentials when there are more than 1 available', () => {
|
||||||
// Setup
|
// Setup
|
||||||
const credentialsStore = useCredentialsStore();
|
const credentialsStore = useCredentialsStore();
|
||||||
credentialsStore.setCredentials([
|
credentialsStore.setCredentials([mockCredentialsResponse('1'), mockCredentialsResponse('2')]);
|
||||||
testData.credentialsTelegram1,
|
|
||||||
testData.credentialsTelegram2,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const setupTemplateStore = useSetupTemplateStore();
|
const setupTemplateStore = useSetupTemplateStore();
|
||||||
|
|
||||||
|
@ -83,7 +135,7 @@ describe('SetupWorkflowFromTemplateView store', () => {
|
||||||
])('should not auto-select credentials for %s', (credentialType, auth) => {
|
])('should not auto-select credentials for %s', (credentialType, auth) => {
|
||||||
// Setup
|
// Setup
|
||||||
const credentialsStore = useCredentialsStore();
|
const credentialsStore = useCredentialsStore();
|
||||||
credentialsStore.setCredentialTypes([testData.newCredentialType(credentialType)]);
|
credentialsStore.setCredentialTypes([mockCredentialType(credentialType)]);
|
||||||
credentialsStore.setCredentials([
|
credentialsStore.setCredentials([
|
||||||
testData.newCredential({
|
testData.newCredential({
|
||||||
name: `${credentialType}Credential`,
|
name: `${credentialType}Credential`,
|
||||||
|
@ -128,24 +180,17 @@ describe('SetupWorkflowFromTemplateView store', () => {
|
||||||
|
|
||||||
// Setup
|
// Setup
|
||||||
const credentialsStore = useCredentialsStore();
|
const credentialsStore = useCredentialsStore();
|
||||||
credentialsStore.setCredentialTypes([testData.credentialTypeTelegram]);
|
credentialsStore.setCredentialTypes([mockCredentialType('telegramApi')]);
|
||||||
const templatesStore = useTemplatesStore();
|
const templatesStore = useTemplatesStore();
|
||||||
templatesStore.addWorkflows([testData.fullSaveEmailAttachmentsToNextCloudTemplate]);
|
templatesStore.addWorkflows([testTemplate1]);
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
nodeTypesStore.setNodeTypes([
|
nodeTypesStore.setNodeTypes([nodeTypeReadImap, nodeTypeNextCloud]);
|
||||||
testData.nodeTypeReadImapV1,
|
|
||||||
testData.nodeTypeReadImapV2,
|
|
||||||
testData.nodeTypeNextCloudV1,
|
|
||||||
]);
|
|
||||||
const setupTemplateStore = useSetupTemplateStore();
|
const setupTemplateStore = useSetupTemplateStore();
|
||||||
setupTemplateStore.setTemplateId(
|
setupTemplateStore.setTemplateId(testTemplate1.id.toString());
|
||||||
testData.fullSaveEmailAttachmentsToNextCloudTemplate.id.toString(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const templateImapNode = testData.fullSaveEmailAttachmentsToNextCloudTemplate.workflow.nodes[0];
|
const templateImapNode = testTemplate1.workflow.nodes[0];
|
||||||
const templateNextcloudNode =
|
const templateNextcloudNode = testTemplate1.workflow.nodes[1];
|
||||||
testData.fullSaveEmailAttachmentsToNextCloudTemplate.workflow.nodes[1];
|
|
||||||
|
|
||||||
it('should return correct credential usages', () => {
|
it('should return correct credential usages', () => {
|
||||||
const setupTemplateStore = useSetupTemplateStore();
|
const setupTemplateStore = useSetupTemplateStore();
|
||||||
|
|
|
@ -53,52 +53,3 @@ export const newCredential = (
|
||||||
name: faker.commerce.productName(),
|
name: faker.commerce.productName(),
|
||||||
...opts,
|
...opts,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const credentialsTelegram1: ICredentialsResponse = {
|
|
||||||
createdAt: '2023-11-23T14:26:07.969Z',
|
|
||||||
updatedAt: '2023-11-23T14:26:07.964Z',
|
|
||||||
id: 'YaSKdvEcT1TSFrrr1',
|
|
||||||
name: 'Telegram account',
|
|
||||||
type: 'telegramApi',
|
|
||||||
ownedBy: {
|
|
||||||
id: '713ef3e7-9e65-4b0a-893c-8a653cbb2c4f',
|
|
||||||
email: 'user@n8n.io',
|
|
||||||
firstName: 'Player',
|
|
||||||
lastName: 'One',
|
|
||||||
},
|
|
||||||
sharedWithProjects: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const credentialsTelegram2: ICredentialsResponse = {
|
|
||||||
createdAt: '2023-11-23T14:26:07.969Z',
|
|
||||||
updatedAt: '2023-11-23T14:26:07.964Z',
|
|
||||||
id: 'YaSKdvEcT1TSFrrr2',
|
|
||||||
name: 'Telegram account',
|
|
||||||
type: 'telegramApi',
|
|
||||||
ownedBy: {
|
|
||||||
id: '713ef3e7-9e65-4b0a-893c-8a653cbb2c4f',
|
|
||||||
email: 'user@n8n.io',
|
|
||||||
firstName: 'Player',
|
|
||||||
lastName: 'One',
|
|
||||||
},
|
|
||||||
sharedWithProjects: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export {
|
|
||||||
fullSaveEmailAttachmentsToNextCloudTemplate,
|
|
||||||
fullShopifyTelegramTwitterTemplate,
|
|
||||||
} from '@/utils/testData/templateTestData';
|
|
||||||
|
|
||||||
export { credentialTypeTelegram, newCredentialType } from '@/utils/testData/credentialTypeTestData';
|
|
||||||
|
|
||||||
export {
|
|
||||||
nodeTypeHttpRequestV1,
|
|
||||||
nodeTypeNextCloudV1,
|
|
||||||
nodeTypeReadImapV1,
|
|
||||||
nodeTypeReadImapV2,
|
|
||||||
nodeTypeShopifyTriggerV1,
|
|
||||||
nodeTypeTelegramV1,
|
|
||||||
nodeTypeTelegramV1_1,
|
|
||||||
nodeTypeTwitterV1,
|
|
||||||
nodeTypeTwitterV2,
|
|
||||||
} from '@/utils/testData/nodeTypeTestData';
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import type { IWorkflowTemplateNode } from '@/Interface';
|
||||||
import { keyFromCredentialTypeAndName } from '@/utils/templates/templateTransforms';
|
import { keyFromCredentialTypeAndName } from '@/utils/templates/templateTransforms';
|
||||||
import type { IWorkflowTemplateNodeWithCredentials } from '@/utils/templates/templateTransforms';
|
import type { IWorkflowTemplateNodeWithCredentials } from '@/utils/templates/templateTransforms';
|
||||||
import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
|
import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
|
||||||
|
@ -5,8 +7,7 @@ import {
|
||||||
getAppCredentials,
|
getAppCredentials,
|
||||||
groupNodeCredentialsByKey,
|
groupNodeCredentialsByKey,
|
||||||
} from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
|
} from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
|
||||||
import * as testData from './setupTemplate.store.testData';
|
import { nodeTypeTelegram, nodeTypeTwitter } from '@/utils/testData/nodeTypeTestData';
|
||||||
import { newWorkflowTemplateNode } from '@/utils/testData/templateTestData';
|
|
||||||
|
|
||||||
const objToMap = <TKey extends string, T>(obj: Record<TKey, T>) => {
|
const objToMap = <TKey extends string, T>(obj: Record<TKey, T>) => {
|
||||||
return new Map(Object.entries(obj)) as Map<TKey, T>;
|
return new Map(Object.entries(obj)) as Map<TKey, T>;
|
||||||
|
@ -40,7 +41,7 @@ describe('useCredentialSetupState', () => {
|
||||||
groupNodeCredentialsByKey([
|
groupNodeCredentialsByKey([
|
||||||
{
|
{
|
||||||
node: nodesByName.Twitter,
|
node: nodesByName.Twitter,
|
||||||
requiredCredentials: testData.nodeTypeTwitterV1.credentials,
|
requiredCredentials: nodeTypeTwitter.credentials!,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
).toEqual(
|
).toEqual(
|
||||||
|
@ -58,14 +59,14 @@ describe('useCredentialSetupState', () => {
|
||||||
|
|
||||||
it('returns credentials grouped when the credential names are the same', () => {
|
it('returns credentials grouped when the credential names are the same', () => {
|
||||||
const [node1, node2] = [
|
const [node1, node2] = [
|
||||||
newWorkflowTemplateNode({
|
mock<IWorkflowTemplateNode>({
|
||||||
id: 'twitter',
|
id: 'twitter',
|
||||||
type: 'n8n-nodes-base.twitter',
|
type: 'n8n-nodes-base.twitter',
|
||||||
credentials: {
|
credentials: {
|
||||||
twitterOAuth1Api: 'credential',
|
twitterOAuth1Api: 'credential',
|
||||||
},
|
},
|
||||||
}) as IWorkflowTemplateNodeWithCredentials,
|
}) as IWorkflowTemplateNodeWithCredentials,
|
||||||
newWorkflowTemplateNode({
|
mock<IWorkflowTemplateNode>({
|
||||||
id: 'telegram',
|
id: 'telegram',
|
||||||
type: 'n8n-nodes-base.telegram',
|
type: 'n8n-nodes-base.telegram',
|
||||||
credentials: {
|
credentials: {
|
||||||
|
@ -78,11 +79,11 @@ describe('useCredentialSetupState', () => {
|
||||||
groupNodeCredentialsByKey([
|
groupNodeCredentialsByKey([
|
||||||
{
|
{
|
||||||
node: node1,
|
node: node1,
|
||||||
requiredCredentials: testData.nodeTypeTwitterV1.credentials,
|
requiredCredentials: nodeTypeTwitter.credentials!,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
node: node2,
|
node: node2,
|
||||||
requiredCredentials: testData.nodeTypeTelegramV1.credentials,
|
requiredCredentials: nodeTypeTelegram.credentials!,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
).toEqual(
|
).toEqual(
|
||||||
|
|
Loading…
Reference in a new issue