Skip to content

Commit

Permalink
fix(editor): Fix workflow initilisation for test definition routes & …
Browse files Browse the repository at this point in the history
…add unit tests (#12507)
  • Loading branch information
OlegIvaniv authored Jan 8, 2025
1 parent c28f302 commit 2775f61
Show file tree
Hide file tree
Showing 11 changed files with 302 additions and 83 deletions.
4 changes: 2 additions & 2 deletions packages/editor-ui/src/api/testDefinition.ee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export interface TestDefinitionRecord {
updatedAt?: string;
createdAt?: string;
annotationTag?: string | null;
mockedNodes?: Array<{ name: string }>;
mockedNodes?: Array<{ name: string; id: string }>;
}

interface CreateTestDefinitionParams {
Expand All @@ -25,7 +25,7 @@ export interface UpdateTestDefinitionParams {
evaluationWorkflowId?: string | null;
annotationTagId?: string | null;
description?: string | null;
mockedNodes?: Array<{ name: string }>;
mockedNodes?: Array<{ name: string; id: string }>;
}

export interface UpdateTestResponse {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,26 @@
import { useI18n } from '@/composables/useI18n';
import { ElCollapseTransition } from 'element-plus';
import { ref, nextTick } from 'vue';
import N8nTooltip from 'n8n-design-system/components/N8nTooltip';
interface EvaluationStep {
title: string;
warning?: boolean;
small?: boolean;
expanded?: boolean;
tooltip?: string;
description?: string;
}
const props = withDefaults(defineProps<EvaluationStep>(), {
description: '',
warning: false,
small: false,
expanded: true,
tooltip: '',
});
const locale = useI18n();
const isExpanded = ref(props.expanded);
const contentRef = ref<HTMLElement | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const isTooltipVisible = ref(false);
const toggleExpand = async () => {
isExpanded.value = !isExpanded.value;
Expand All @@ -35,14 +32,6 @@ const toggleExpand = async () => {
}
}
};
const showTooltip = () => {
isTooltipVisible.value = true;
};
const hideTooltip = () => {
isTooltipVisible.value = false;
};
</script>

<template>
Expand All @@ -51,16 +40,7 @@ const hideTooltip = () => {
:class="[$style.evaluationStep, small && $style.small]"
data-test-id="evaluation-step"
>
<N8nTooltip :disabled="!tooltip" placement="right" :offset="25" :visible="isTooltipVisible">
<template #content>
{{ tooltip }}
</template>
<!-- This empty div is needed to ensure the tooltip trigger area spans the full width of the step.
Without it, the tooltip would only show when hovering over the content div, which is narrower.
The contentPlaceholder creates an invisible full-width area that can trigger the tooltip. -->
<div :class="$style.contentPlaceholder"></div>
</N8nTooltip>
<div :class="$style.content" @mouseenter="showTooltip" @mouseleave="hideTooltip">
<div :class="$style.content">
<div :class="$style.header">
<div :class="[$style.icon, warning && $style.warning]">
<slot name="icon" />
Expand All @@ -83,6 +63,7 @@ const hideTooltip = () => {
<font-awesome-icon :icon="isExpanded ? 'angle-down' : 'angle-right'" size="lg" />
</button>
</div>
<div v-if="description" :class="$style.description">{{ description }}</div>
<ElCollapseTransition v-if="$slots.cardContent">
<div v-show="isExpanded" :class="$style.cardContentWrapper">
<div ref="contentRef" :class="$style.cardContent" data-test-id="evaluation-step-content">
Expand Down Expand Up @@ -111,14 +92,6 @@ const hideTooltip = () => {
width: 80%;
}
}
.contentPlaceholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
}
.icon {
display: flex;
align-items: center;
Expand Down Expand Up @@ -173,4 +146,10 @@ const hideTooltip = () => {
.cardContentWrapper {
height: max-content;
}
.description {
font-size: var(--font-size-2xs);
color: var(--color-text-light);
line-height: 1rem;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ const eventBus = createEventBus<CanvasEventBusEvents>();
const style = useCssModule();
const uuid = crypto.randomUUID();
const props = defineProps<{
modelValue: Array<{ name: string }>;
modelValue: Array<{ name: string; id: string }>;
}>();
const emit = defineEmits<{
'update:modelValue': [value: Array<{ name: string }>];
'update:modelValue': [value: Array<{ name: string; id: string }>];
}>();
const isLoading = ref(true);
Expand Down Expand Up @@ -84,14 +84,7 @@ function disableAllNodes() {
const ids = mappedNodes.value.map((node) => node.id);
updateNodeClasses(ids, false);
const pinnedNodes = props.modelValue
.map((node) => {
const matchedNode = mappedNodes.value.find(
(mappedNode) => mappedNode?.data?.name === node.name,
);
return matchedNode?.id ?? null;
})
.filter((n) => n !== null);
const pinnedNodes = props.modelValue.map((node) => node.id).filter((id) => id !== null);
if (pinnedNodes.length > 0) {
updateNodeClasses(pinnedNodes, true);
Expand All @@ -101,10 +94,10 @@ function onPinButtonClick(data: CanvasNodeData) {
const nodeName = getNodeNameById(data.id);
if (!nodeName) return;
const isPinned = props.modelValue.some((node) => node.name === nodeName);
const isPinned = props.modelValue.some((node) => node.id === data.id);
const updatedNodes = isPinned
? props.modelValue.filter((node) => node.name !== nodeName)
: [...props.modelValue, { name: nodeName }];
? props.modelValue.filter((node) => node.id !== data.id)
: [...props.modelValue, { name: nodeName, id: data.id }];
emit('update:modelValue', updatedNodes);
updateNodeClasses([data.id], !isPinned);
Expand Down Expand Up @@ -145,6 +138,7 @@ onMounted(loadData);
size="large"
icon="thumbtack"
:class="$style.pinButton"
data-test-id="node-pin-button"
@click="onPinButtonClick(data)"
/>
</N8nTooltip>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { waitFor } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import NodesPinning from '../NodesPinning.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';

import {
createTestNode,
createTestWorkflow,
createTestWorkflowObject,
mockNodeTypeDescription,
} from '@/__tests__/mocks';
import { mockedStore } from '@/__tests__/utils';
import { NodeConnectionType } from 'n8n-workflow';
import { SET_NODE_TYPE } from '@/constants';

vi.mock('vue-router', () => {
const push = vi.fn();
return {
useRouter: () => ({
push,
}),
useRoute: () => ({
params: {
name: 'test-workflow',
testId: 'test-123',
},
}),
RouterLink: {
template: '<a><slot /></a>',
},
};
});

const renderComponent = createComponentRenderer(NodesPinning, {
props: {
modelValue: [{ id: '1', name: 'Node 1' }],
},
global: {
plugins: [createTestingPinia()],
},
});

describe('NodesPinning', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const nodes = [
createTestNode({ id: '1', name: 'Node 1', type: SET_NODE_TYPE }),
createTestNode({ id: '2', name: 'Node 2', type: SET_NODE_TYPE }),
];

const nodeTypesStore = mockedStore(useNodeTypesStore);
const nodeTypeDescription = mockNodeTypeDescription({
name: SET_NODE_TYPE,
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
});
nodeTypesStore.nodeTypes = {
node: { 1: nodeTypeDescription },
};

nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription);
const workflow = createTestWorkflow({
id: 'test-workflow',
name: 'Test Workflow',
nodes,
connections: {},
});

const workflowObject = createTestWorkflowObject(workflow);

workflowsStore.getWorkflowById = vi.fn().mockReturnValue(workflow);
workflowsStore.getCurrentWorkflow = vi.fn().mockReturnValue(workflowObject);
beforeEach(() => {
const pinia = createPinia();
setActivePinia(pinia);

nodeTypesStore.setNodeTypes([nodeTypeDescription]);
});

afterEach(() => {
vi.clearAllMocks();
});

it('should render workflow nodes', async () => {
const { container } = renderComponent();

await waitFor(() => {
expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(2);
});

expect(container.querySelector('[data-node-name="Node 1"]')).toBeInTheDocument();
expect(container.querySelector('[data-node-name="Node 2"]')).toBeInTheDocument();
});

it('should update node classes when pinning/unpinning nodes', async () => {
const { container } = renderComponent();

await waitFor(() => {
expect(container.querySelector('[data-node-name="Node 1"]')).toBeInTheDocument();
});

await waitFor(() => {
expect(container.querySelector('[data-node-name="Node 1"]')).toHaveClass(
'canvasNode pinnedNode',
);
expect(container.querySelector('[data-node-name="Node 2"]')).toHaveClass(
'canvasNode notPinnedNode',
);
});
});

it('should emit update:modelValue when pinning nodes', async () => {
const { container, emitted, getAllByTestId } = renderComponent();

await waitFor(() => {
expect(container.querySelector('[data-node-name="Node 1"]')).toBeInTheDocument();
});
const pinButton = getAllByTestId('node-pin-button')[1];
pinButton?.click();

expect(emitted('update:modelValue')).toBeTruthy();
expect(emitted('update:modelValue')[0]).toEqual([
[
{ id: '1', name: 'Node 1' },
{ id: '2', name: 'Node 2' },
],
]);
});
it('should emit update:modelValue when unpinning nodes', async () => {
const { container, emitted, getAllByTestId } = renderComponent();

await waitFor(() => {
expect(container.querySelector('[data-node-name="Node 1"]')).toBeInTheDocument();
});
const pinButton = getAllByTestId('node-pin-button')[0];
pinButton?.click();

expect(emitted('update:modelValue')).toBeTruthy();
expect(emitted('update:modelValue')[0]).toEqual([[]]);
});
});
2 changes: 1 addition & 1 deletion packages/editor-ui/src/components/TestDefinition/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface EvaluationFormState extends EditableFormState {
description: string;
evaluationWorkflow: INodeParameterResourceLocator;
metrics: TestMetricRecord[];
mockedNodes: Array<{ name: string }>;
mockedNodes: Array<{ name: string; id: string }>;
}

export interface TestExecution {
Expand Down
23 changes: 11 additions & 12 deletions packages/editor-ui/src/plugins/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2786,20 +2786,19 @@
"testDefinition.edit.testSaved": "Test saved",
"testDefinition.edit.testSaveFailed": "Failed to save test",
"testDefinition.edit.description": "Description",
"testDefinition.edit.description.tooltip": "Add details about what this test evaluates and what success looks like",
"testDefinition.edit.description.description": "Add details about what this test evaluates and what success looks like",
"testDefinition.edit.tagName": "Tag name",
"testDefinition.edit.step.intro": "When running a test",
"testDefinition.edit.step.executions": "Fetch past executions | Fetch {count} past execution | Fetch {count} past executions",
"testDefinition.edit.step.executions.tooltip": "Select which tagged executions to use as test cases. Each execution will be replayed to compare performance",
"testDefinition.edit.step.nodes": "Mock nodes",
"testDefinition.edit.step.mockedNodes": "No nodes mocked | {count} node mocked | {count} nodes mocked",
"testDefinition.edit.step.nodes.tooltip": "Replace specific nodes with test data to isolate what you're testing",
"testDefinition.edit.step.reRunExecutions": "Re-run executions",
"testDefinition.edit.step.reRunExecutions.tooltip": "Each test case will be re-run using the current workflow version",
"testDefinition.edit.step.compareExecutions": "Compare each past and new execution",
"testDefinition.edit.step.compareExecutions.tooltip": "Select which workflow to use for running the comparison tests",
"testDefinition.edit.step.metrics": "Summarise metrics",
"testDefinition.edit.step.metrics.tooltip": "Define which output fields to track and compare between test runs",
"testDefinition.edit.step.executions": "1. Fetch N past executions tagge | Fetch {count} past execution tagged | Fetch {count} past executions tagged",
"testDefinition.edit.step.executions.description": "Use a tag to select past executions for use as test cases in evaluation. The trigger data from each of these past executions will be used as input to run your workflow. The outputs of past executions are used as benchmark and compared against to check whether performance has changed based on logic and metrics that you define below.",
"testDefinition.edit.step.mockedNodes": "2. Mock N nodes |2. Mock {count} node |2. Mock {count} nodes",
"testDefinition.edit.step.nodes.description": "Mocked nodes have their data replayed rather than being re-executed. Do this to avoid calling external services, save time executing, and isolate what you are evaluating. If a node is mocked, the tagged past execution's output data for that node is used in the evaluation instead.",
"testDefinition.edit.step.reRunExecutions": "3. Simulation",
"testDefinition.edit.step.reRunExecutions.description": "Each past execution is re-run using the latest version of the workflow being tested. Outputs from both the evaluated and comparison data will be passed to evaluation logic.",
"testDefinition.edit.step.compareExecutions": "4. Compare each past and new execution",
"testDefinition.edit.step.compareExecutions.description": "Each past execution is compared with its new equivalent to check for changes in performance. This is done using a separate evaluation workflow: it receives the two execution versions as input, and outputs metrics based on logic that you define.",
"testDefinition.edit.step.metrics": "5. Summarise metrics",
"testDefinition.edit.step.metrics.description": "The names of metrics fields returned by the evaluation workflow (defined above). If included in this section, they are displayed in the test run results and averaged to give a score for the entire test run.",
"testDefinition.edit.step.collapse": "Collapse",
"testDefinition.edit.step.expand": "Expand",
"testDefinition.edit.selectNodes": "Select nodes",
Expand Down
Loading

0 comments on commit 2775f61

Please sign in to comment.