feat(editor): Evaluation feature Phase one readiness (no-changelog) (#13383)

Co-authored-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
Raúl Gómez Morales 2025-02-21 12:58:28 +01:00 committed by GitHub
parent 7bd83d7d33
commit b2293b7ad5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 2452 additions and 2255 deletions

View file

@ -92,7 +92,10 @@ export class NDV extends BasePage {
resourceLocatorModeSelector: (paramName: string) =>
this.getters.resourceLocator(paramName).find('[data-test-id="rlc-mode-selector"]'),
resourceLocatorSearch: (paramName: string) =>
this.getters.resourceLocator(paramName).findChildByTestId('rlc-search'),
this.getters
.resourceLocator(paramName)
.find('[aria-describedby]')
.then(($el) => cy.get(`#${$el.attr('aria-describedby')}`).findChildByTestId('rlc-search')),
resourceMapperFieldsContainer: () => cy.getByTestId('mapping-fields-container'),
resourceMapperSelectColumn: () => cy.getByTestId('matching-column-select'),
resourceMapperRemoveFieldButton: (fieldName: string) =>

View file

@ -1,5 +1,6 @@
<script lang="ts" setup>
import { ElDropdown, ElDropdownMenu, ElDropdownItem, type Placement } from 'element-plus';
import { ref } from 'vue';
import type { UserAction } from 'n8n-design-system/types';
import type { IconOrientation, IconSize } from 'n8n-design-system/types/icon';
@ -34,17 +35,30 @@ withDefaults(defineProps<ActionToggleProps>(), {
disabled: false,
});
const actionToggleRef = ref<InstanceType<typeof ElDropdown> | null>(null);
const emit = defineEmits<{
action: [value: string];
'visible-change': [value: boolean];
}>();
const onCommand = (value: string) => emit('action', value);
const onVisibleChange = (value: boolean) => emit('visible-change', value);
const openActionToggle = (isOpen: boolean) => {
if (isOpen) {
actionToggleRef.value?.handleOpen();
} else {
actionToggleRef.value?.handleClose();
}
};
defineExpose({
openActionToggle,
});
</script>
<template>
<span :class="$style.container" data-test-id="action-toggle" @click.stop.prevent>
<ElDropdown
ref="actionToggleRef"
:placement="placement"
:size="size"
:disabled="disabled"

View file

@ -11,7 +11,8 @@ withDefaults(defineProps<TagProps>(), {
<template>
<span :class="['n8n-tag', $style.tag, { [$style.clickable]: clickable }]" v-bind="$attrs">
{{ text }}
<slot v-if="$slots['tag']" name="tag" />
<span v-else>{{ text }}</span>
</span>
</template>

View file

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { computed, useCssModule } from 'vue';
import type { TextSize, TextColor, TextAlign } from 'n8n-design-system/types/text';
import type { TextAlign, TextColor, TextSize } from 'n8n-design-system/types/text';
interface TextProps {
bold?: boolean;
@ -121,6 +121,14 @@ const classes = computed(() => {
color: var(--color-warning);
}
.foreground-dark {
color: var(--color-foreground-dark);
}
.foreground-xdark {
color: var(--color-foreground-xdark);
}
.align-left {
text-align: left;
}

View file

@ -3,6 +3,7 @@ export type TextSize = (typeof TEXT_SIZE)[number];
const TEXT_COLOR = [
'primary',
'secondary',
'text-dark',
'text-base',
'text-light',
@ -10,6 +11,8 @@ const TEXT_COLOR = [
'danger',
'success',
'warning',
'foreground-dark',
'foreground-xdark',
] as const;
export type TextColor = (typeof TEXT_COLOR)[number];

View file

@ -9,7 +9,7 @@ export interface TestDefinitionRecord {
annotationTagId?: string | null;
description?: string | null;
updatedAt?: string;
createdAt?: string;
createdAt: string;
annotationTag?: string | null;
mockedNodes?: Array<{ name: string; id: string }>;
}
@ -52,6 +52,9 @@ export interface TestRunRecord {
errorCode?: string;
errorDetails?: Record<string, unknown>;
finalResult?: 'success' | 'error' | 'warning';
failedCases?: number;
passedCases?: number;
totalCases?: number;
}
interface GetTestRunParams {

View file

@ -1,15 +1,29 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue';
import { nextTick, ref, withDefaults } from 'vue';
import { useToast } from '@/composables/useToast';
import { onClickOutside } from '@vueuse/core';
interface Props {
modelValue: string;
subtitle: string;
subtitle?: string;
type: string;
readonly: boolean;
readonly?: boolean;
placeholder?: string;
maxlength?: number;
required?: boolean;
autosize?: boolean | { minRows: number; maxRows: number };
inputType?: string;
maxHeight?: string;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
placeholder: '',
maxlength: 64,
required: true,
autosize: false,
inputType: 'text',
maxHeight: '22px',
subtitle: '',
});
const emit = defineEmits<{
'update:modelValue': [value: string];
@ -25,15 +39,11 @@ const onNameEdit = (value: string) => {
const enableNameEdit = () => {
isNameEdit.value = true;
void nextTick(() => {
if (nameInput.value) {
nameInput.value.focus();
}
});
void nextTick(() => nameInput.value?.focus());
};
const disableNameEdit = () => {
if (!props.modelValue) {
if (!props.modelValue && props.required) {
emit('update:modelValue', `Untitled ${props.type}`);
showToast({
title: 'Error',
@ -54,20 +64,30 @@ onClickOutside(nameInput, disableNameEdit);
</span>
<div
v-else
:class="[$style.headline, $style['headline-editable']]"
:class="{
[$style.headline]: true,
[$style['headline-editable']]: true,
[$style.editing]: isNameEdit,
}"
@keydown.stop
@click="enableNameEdit"
>
<div v-if="!isNameEdit">
<span>{{ modelValue }}</span>
<span>
<n8n-text v-if="!modelValue" size="small" color="text-base">{{ placeholder }}</n8n-text>
<slot v-else>{{ modelValue }}</slot>
</span>
<i><font-awesome-icon icon="pen" /></i>
</div>
<div v-else :class="$style.nameInput">
<div v-else :class="{ [$style.nameInput]: props.inputType !== 'textarea' }">
<n8n-input
ref="nameInput"
:model-value="modelValue"
size="xlarge"
:maxlength="64"
size="large"
:type="inputType"
:maxlength
:placeholder
:autosize
@update:model-value="onNameEdit"
@change="disableNameEdit"
/>
@ -97,15 +117,22 @@ onClickOutside(nameInput, disableNameEdit);
border-radius: var(--border-radius-base);
position: relative;
min-height: 22px;
max-height: 22px;
max-height: v-bind(maxHeight);
font-weight: 400;
&.editing {
width: 100%;
}
i {
display: var(--headline-icon-display, none);
font-size: 0.75em;
margin-left: 8px;
color: var(--color-text-base);
}
:global(textarea) {
resize: none;
}
}
.headline-editable {

View file

@ -1,9 +1,8 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeMount, onBeforeUnmount, onMounted } from 'vue';
import type { RouteLocation, RouteLocationRaw } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue';
import TabBar from '@/components/MainHeader/TabBar.vue';
import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue';
import { useI18n } from '@/composables/useI18n';
import { usePushConnection } from '@/composables/usePushConnection';
import {
LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON,
MAIN_HEADER_TABS,
@ -12,18 +11,19 @@ import {
VIEWS,
WORKFLOW_EVALUATION_EXPERIMENT,
} from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useExecutionsStore } from '@/stores/executions.store';
import { useNDVStore } from '@/stores/ndv.store';
import { usePostHog } from '@/stores/posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useExecutionsStore } from '@/stores/executions.store';
import { useSettingsStore } from '@/stores/settings.store';
import { usePushConnection } from '@/composables/usePushConnection';
import { usePostHog } from '@/stores/posthog.store';
import { computed, onBeforeMount, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { RouteLocation, RouteLocationRaw } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import GithubButton from 'vue-github-button';
import { useLocalStorage } from '@vueuse/core';
import GithubButton from 'vue-github-button';
const router = useRouter();
const route = useRoute();
@ -50,7 +50,6 @@ const githubButtonHidden = useLocalStorage(LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON
const testDefinitionRoutes: VIEWS[] = [
VIEWS.TEST_DEFINITION,
VIEWS.TEST_DEFINITION_EDIT,
VIEWS.TEST_DEFINITION_RUNS,
VIEWS.TEST_DEFINITION_RUNS_DETAIL,
VIEWS.TEST_DEFINITION_RUNS_COMPARE,
];

View file

@ -1,5 +1,7 @@
<script lang="ts" setup>
import type { ButtonType } from 'n8n-design-system';
import { N8nIconButton, N8nActionToggle } from 'n8n-design-system';
import { ref } from 'vue';
type Action = {
label: string;
@ -9,24 +11,37 @@ type Action = {
defineProps<{
actions: Action[];
disabled?: boolean;
type?: ButtonType;
}>();
const emit = defineEmits<{
action: [id: string];
}>();
const actionToggleRef = ref<InstanceType<typeof N8nActionToggle> | null>(null);
defineExpose({
openActionToggle: (isOpen: boolean) => actionToggleRef.value?.openActionToggle(isOpen),
});
</script>
<template>
<div :class="[$style.buttonGroup]">
<slot></slot>
<N8nActionToggle
ref="actionToggleRef"
data-test-id="add-resource"
:actions="actions"
placement="bottom-end"
:teleported="false"
@action="emit('action', $event)"
>
<N8nIconButton :disabled="disabled" :class="[$style.buttonGroupDropdown]" icon="angle-down" />
<N8nIconButton
:disabled="disabled"
:class="[$style.buttonGroupDropdown]"
icon="angle-down"
:type="type ?? 'primary'"
/>
</N8nActionToggle>
</div>
</template>

View file

@ -213,7 +213,7 @@ defineExpose({ isWithinDropdown });
:width="width"
:popper-class="$style.popover"
:visible="show"
:teleported="false"
:teleported="true"
data-test-id="resource-locator-dropdown"
>
<div v-if="errorView" :class="$style.messageContainer">

View file

@ -1,40 +1,125 @@
<script setup lang="ts">
import { computed, useCssModule } from 'vue';
const props = defineProps<{
state?: 'default' | 'error' | 'success';
hoverable?: boolean;
}>();
const css = useCssModule();
const classes = computed(() => ({
[css.arrowConnector]: true,
[css.hoverable]: props.hoverable,
[css.error]: props.state === 'error',
[css.success]: props.state === 'success',
}));
</script>
<template>
<div :class="$style.arrowConnector"></div>
<div :class="classes">
<div :class="$style.stalk"></div>
<div :class="$style.arrowHead"></div>
</div>
</template>
<style module lang="scss">
.arrowConnector {
$arrow-width: 12px;
$arrow-height: 8px;
$stalk-width: 2px;
$color: var(--color-text-dark);
position: relative;
height: var(--arrow-height, 3rem);
margin: 0.5rem 0;
margin: 0.1rem 0;
display: flex;
flex-direction: column;
align-items: center;
}
.stalk {
position: relative;
width: var(--stalk-width, 0.125rem);
height: calc(100% - var(--arrow-tip-height, 0.5rem));
background-color: var(--arrow-color, var(--color-text-dark));
transition: all 0.2s ease;
&::before,
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 1rem;
height: 100%;
cursor: pointer;
}
}
.arrowHead {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
}
&::before {
top: 0;
width: $stalk-width;
height: calc(100% - #{$arrow-height});
background-color: $color;
}
&::after {
bottom: 0;
width: 0;
height: 0;
border-left: calc($arrow-width / 2) solid transparent;
border-right: calc($arrow-width / 2) solid transparent;
border-top: $arrow-height solid $color;
border-left: calc(var(--arrow-tip-width, 0.75rem) / 2) solid transparent;
border-right: calc(var(--arrow-tip-width, 0.75rem) / 2) solid transparent;
border-top: var(--arrow-tip-height, 0.5rem) solid var(--arrow-color, var(--color-text-dark));
transition: all 0.2s ease;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 1.5rem;
height: 1.5rem;
cursor: pointer;
}
}
.hoverable {
--hover-scale: var(--arrow-hover-scale, 1.8);
cursor: pointer;
&:hover {
.stalk {
width: calc(var(--stalk-width, 0.125rem) * var(--hover-scale));
background-color: var(--arrow-hover-color, var(--arrow-color, var(--color-text-dark)));
}
.arrowHead {
border-left-width: calc(var(--arrow-tip-width, 0.75rem) / 2 * var(--hover-scale));
border-right-width: calc(var(--arrow-tip-width, 0.75rem) / 2 * var(--hover-scale));
border-top-width: calc(var(--arrow-tip-height, 0.5rem) * var(--hover-scale));
border-top-color: var(--arrow-hover-color, var(--arrow-color, var(--color-text-dark)));
}
}
}
.error {
--stalk-width: 0.1875rem;
--arrow-color: var(--color-danger);
--arrow-tip-width: 1rem;
--arrow-tip-height: 0.625rem;
}
.success {
--stalk-width: 0.1875rem;
--arrow-color: var(--color-success);
--arrow-tip-width: 1rem;
--arrow-tip-height: 0.625rem;
.stalk {
position: relative;
&::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1rem;
color: var(--arrow-color, var(--color-success));
}
}
}
</style>

View file

@ -1,31 +1,43 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { ElCollapseTransition } from 'element-plus';
import { ref, nextTick } from 'vue';
import { computed, nextTick, ref, useCssModule } from 'vue';
interface EvaluationStep {
title: string;
title?: string;
warning?: boolean;
small?: boolean;
expanded?: boolean;
description?: string;
issues?: Array<{ field: string; message: string }>;
showIssues?: boolean;
tooltip?: string;
}
const props = withDefaults(defineProps<EvaluationStep>(), {
description: '',
warning: false,
small: false,
expanded: true,
expanded: false,
issues: () => [],
showIssues: true,
title: '',
});
const locale = useI18n();
const isExpanded = ref(props.expanded);
const contentRef = ref<HTMLElement | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const showTooltip = ref(false);
const $style = useCssModule();
const containerClass = computed(() => {
return {
[$style.wrap]: true,
[$style.expanded]: isExpanded.value,
[$style.hasIssues]: props.issues.length > 0,
};
});
const toggleExpand = async () => {
isExpanded.value = !isExpanded.value;
@ -36,66 +48,98 @@ const toggleExpand = async () => {
}
}
};
const handleMouseEnter = () => {
if (!props.tooltip) return;
showTooltip.value = true;
};
const handleMouseLeave = () => {
showTooltip.value = false;
};
const renderIssues = computed(() => props.showIssues && props.issues.length);
const issuesList = computed(() => props.issues.map((issue) => issue.message).join(', '));
</script>
<template>
<div :class="containerClass">
<slot name="containerPrefix" />
<div
ref="containerRef"
:class="[$style.evaluationStep, small && $style.small]"
data-test-id="evaluation-step"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div :class="$style.content">
<div :class="$style.header">
<div :class="[$style.icon, warning && $style.warning]">
<slot name="icon" />
</div>
<h3 :class="$style.title">{{ title }}</h3>
<span v-if="issues.length > 0 && showIssues" :class="$style.warningIcon">
<div :class="$style.header" @click="toggleExpand">
<h3 :class="$style.title">
<span :class="$style.label">
<slot v-if="$slots.title" name="title" />
<span v-else>{{ title }}</span>
<N8nInfoTip
v-if="tooltip"
:class="$style.infoTip"
:bold="true"
type="tooltip"
theme="info"
tooltip-placement="top"
>
{{ tooltip }}
</N8nInfoTip>
</span>
</h3>
<span v-if="renderIssues" :class="$style.warningIcon">
<N8nInfoTip :bold="true" type="tooltip" theme="warning" tooltip-placement="right">
{{ issues.map((issue) => issue.message).join(', ') }}
{{ issuesList }}
</N8nInfoTip>
</span>
<button
v-if="$slots.cardContent"
:class="$style.collapseButton"
:aria-expanded="isExpanded"
:aria-controls="'content-' + title.replace(/\s+/g, '-')"
data-test-id="evaluation-step-collapse-button"
@click="toggleExpand"
>
{{
isExpanded
? locale.baseText('testDefinition.edit.step.collapse')
: locale.baseText('testDefinition.edit.step.expand')
: locale.baseText('testDefinition.edit.step.configure')
}}
<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">
<div
ref="contentRef"
:class="$style.cardContent"
data-test-id="evaluation-step-content"
>
<div v-if="description" :class="$style.description">{{ description }}</div>
<slot name="cardContent" />
</div>
</div>
</ElCollapseTransition>
</div>
</div>
</div>
</template>
<style module lang="scss">
.wrap {
position: relative;
}
.evaluationStep {
display: grid;
grid-template-columns: 1fr;
gap: var(--spacing-m);
background: var(--color-background-light);
padding: var(--spacing-s);
border-radius: var(--border-radius-xlarge);
box-shadow: var(--box-shadow-base);
background: var(--color-background-xlight);
border-radius: var(--border-radius-large);
border: var(--border-base);
width: 100%;
color: var(--color-text-dark);
position: relative;
z-index: 1;
&.small {
width: 80%;
margin-left: auto;
@ -117,13 +161,14 @@ const toggleExpand = async () => {
.content {
display: grid;
gap: var(--spacing-2xs);
}
.header {
display: flex;
gap: var(--spacing-2xs);
align-items: center;
cursor: pointer;
padding: var(--spacing-s);
}
.title {
@ -131,17 +176,28 @@ const toggleExpand = async () => {
font-size: var(--font-size-s);
line-height: 1.125rem;
}
.label {
display: flex;
align-items: center;
gap: var(--spacing-4xs);
}
.infoTip {
opacity: 0;
}
.evaluationStep:hover .infoTip {
opacity: 1;
}
.warningIcon {
color: var(--color-warning);
}
.cardContent {
font-size: var(--font-size-s);
margin-top: var(--spacing-xs);
padding: 0 var(--spacing-s);
margin: var(--spacing-s) 0;
}
.collapseButton {
cursor: pointer;
pointer-events: none;
border: none;
background: none;
padding: 0;
@ -151,9 +207,16 @@ const toggleExpand = async () => {
text-wrap: none;
overflow: hidden;
min-width: fit-content;
.hasIssues & {
color: var(--color-danger);
}
}
.cardContentWrapper {
height: max-content;
.expanded & {
border-top: var(--border-base);
}
}
.description {
@ -161,4 +224,15 @@ const toggleExpand = async () => {
color: var(--color-text-light);
line-height: 1rem;
}
.customTooltip {
position: absolute;
left: 0;
background: var(--color-background-dark);
color: var(--color-text-light);
padding: var(--spacing-3xs) var(--spacing-2xs);
border-radius: var(--border-radius-base);
font-size: var(--font-size-2xs);
pointer-events: none;
}
</style>

View file

@ -1,6 +1,8 @@
<script setup lang="ts">
import { useTemplateRef, nextTick } from 'vue';
import type { TestMetricRecord } from '@/api/testDefinition.ee';
import { useI18n } from '@/composables/useI18n';
import { N8nInput } from 'n8n-design-system';
export interface MetricsInputProps {
modelValue: Array<Partial<TestMetricRecord>>;
@ -8,12 +10,14 @@ export interface MetricsInputProps {
const props = defineProps<MetricsInputProps>();
const emit = defineEmits<{
'update:modelValue': [value: MetricsInputProps['modelValue']];
deleteMetric: [metric: Partial<TestMetricRecord>];
deleteMetric: [metric: TestMetricRecord];
}>();
const locale = useI18n();
const metricsRefs = useTemplateRef<Array<InstanceType<typeof N8nInput>>>('metric');
function addNewMetric() {
emit('update:modelValue', [...props.modelValue, { name: '' }]);
void nextTick(() => metricsRefs.value?.at(-1)?.focus());
}
function updateMetric(index: number, name: string) {
@ -22,8 +26,14 @@ function updateMetric(index: number, name: string) {
emit('update:modelValue', newMetrics);
}
function onDeleteMetric(metric: Partial<TestMetricRecord>) {
emit('deleteMetric', metric);
function onDeleteMetric(metric: Partial<TestMetricRecord>, index: number) {
if (!metric.id) {
const newMetrics = [...props.modelValue];
newMetrics.splice(index, 1);
emit('update:modelValue', newMetrics);
} else {
emit('deleteMetric', metric as TestMetricRecord);
}
}
</script>
@ -37,13 +47,13 @@ function onDeleteMetric(metric: Partial<TestMetricRecord>) {
<div :class="$style.metricsContainer">
<div v-for="(metric, index) in modelValue" :key="index" :class="$style.metricItem">
<N8nInput
:ref="`metric_${index}`"
ref="metric"
data-test-id="evaluation-metric-item"
:model-value="metric.name"
:placeholder="locale.baseText('testDefinition.edit.metricsPlaceholder')"
@update:model-value="(value: string) => updateMetric(index, value)"
/>
<n8n-icon-button icon="trash" type="text" @click="onDeleteMetric(metric)" />
<n8n-icon-button icon="trash" type="text" @click="onDeleteMetric(metric, index)" />
</div>
<n8n-button
type="tertiary"

View file

@ -1,15 +1,15 @@
<script setup lang="ts">
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { computed, onMounted, ref, useCssModule } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
import { createEventBus, N8nTooltip } from 'n8n-design-system';
import type { CanvasConnectionPort, CanvasEventBusEvents, CanvasNodeData } from '@/types';
import { useVueFlow } from '@vue-flow/core';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { CanvasConnectionPort, CanvasEventBusEvents, CanvasNodeData } from '@/types';
import { useVueFlow } from '@vue-flow/core';
import { createEventBus, N8nTooltip } from 'n8n-design-system';
import { computed, onMounted, ref, useCssModule } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
@ -111,10 +111,12 @@ function onPinButtonClick(data: CanvasNodeData) {
});
}
}
function isPinButtonVisible(outputs: CanvasConnectionPort[]) {
return outputs.length === 1;
function isPinButtonVisible(outputs: CanvasConnectionPort[], inputs: CanvasConnectionPort[]) {
return outputs.length === 1 && inputs.length >= 1;
}
const isPinned = (data: CanvasNodeData) => props.modelValue.some((node) => node.id === data.id);
onNodesInitialized(async () => {
await fitView();
isLoading.value = false;
@ -142,20 +144,39 @@ onMounted(loadData);
:read-only="true"
:event-bus="eventBus"
>
<template #nodeToolbar="{ data, outputs }">
<div :class="$style.pinButtonContainer">
<N8nTooltip v-if="isPinButtonVisible(outputs)" placement="left">
<template #nodeToolbar="{ data, outputs, inputs }">
<div
v-if="isPinButtonVisible(outputs, inputs)"
:class="{
[$style.pinButtonContainer]: true,
[$style.pinButtonContainerPinned]: isPinned(data),
}"
>
<N8nTooltip placement="left">
<template #content>
{{ locale.baseText('testDefinition.edit.nodesPinning.pinButtonTooltip') }}
</template>
<n8n-icon-button
type="tertiary"
size="large"
<N8nButton
v-if="isPinned(data)"
icon="thumbtack"
:class="$style.pinButton"
block
type="secondary"
:class="$style.customSecondary"
data-test-id="node-pin-button"
@click="onPinButtonClick(data)"
/>
>
Un Mock
</N8nButton>
<N8nButton
v-else
icon="thumbtack"
block
type="secondary"
data-test-id="node-pin-button"
@click="onPinButtonClick(data)"
>
Mock
</N8nButton>
</N8nTooltip>
</div>
</template>
@ -170,30 +191,30 @@ onMounted(loadData);
}
.pinButtonContainer {
position: absolute;
right: 0;
display: flex;
justify-content: flex-end;
bottom: 100%;
right: 50%;
bottom: -5px;
height: calc(100% + 47px);
border: 1px solid transparent;
padding: 5px 5px;
border-radius: 8px;
width: calc(100% + 10px);
transform: translateX(50%);
&.pinButtonContainerPinned {
background-color: hsla(247, 49%, 55%, 1);
}
}
.pinButton {
cursor: pointer;
color: var(--canvas-node--border-color);
border: none;
}
.notPinnedNode,
.pinnedNode {
:global(.n8n-node-icon) > div {
filter: contrast(40%) brightness(1.5) grayscale(100%);
}
}
.pinnedNode {
--canvas-node--border-color: hsla(247, 49%, 55%, 1);
.customSecondary {
--button-background-color: hsla(247, 49%, 55%, 1);
--button-font-color: var(--color-button-primary-font);
--button-border-color: hsla(247, 49%, 55%, 1);
:global(.n8n-node-icon) > div {
filter: contrast(40%) brightness(1.5) grayscale(100%);
}
--button-hover-background-color: hsla(247, 49%, 55%, 1);
--button-hover-border-color: var(--color-button-primary-font);
--button-hover-font-color: var(--color-button-primary-font);
}
.spinner {
position: absolute;
top: 50%;

View file

@ -1,9 +1,14 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { VIEWS } from '@/constants';
import { SAMPLE_EVALUATION_WORKFLOW } from '@/constants.workflows';
import type { IWorkflowDataCreate } from '@/Interface';
import { useProjectsStore } from '@/stores/projects.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { N8nButton, N8nLink } from 'n8n-design-system';
import type { INodeParameterResourceLocator, IPinData } from 'n8n-workflow';
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
interface WorkflowSelectorProps {
modelValue: INodeParameterResourceLocator;
@ -21,12 +26,16 @@ const props = withDefaults(defineProps<WorkflowSelectorProps>(), {
sampleWorkflowName: undefined,
});
defineEmits<{
const emit = defineEmits<{
'update:modelValue': [value: WorkflowSelectorProps['modelValue']];
workflowCreated: [workflowId: string];
}>();
const locale = useI18n();
const projectStore = useProjectsStore();
const workflowsStore = useWorkflowsStore();
const router = useRouter();
const subworkflowName = computed(() => {
if (props.sampleWorkflowName) {
return locale.baseText('testDefinition.workflowInput.subworkflowName', {
@ -43,15 +52,55 @@ const sampleWorkflow = computed<IWorkflowDataCreate>(() => {
pinData: props.examplePinnedData,
};
});
const selectorVisible = ref(false);
const updateModelValue = (value: INodeParameterResourceLocator) => emit('update:modelValue', value);
/**
* copy pasted from WorkflowSelectorParameterInput.vue
* but we should remove it from here
*/
const handleDefineEvaluation = async () => {
const projectId = projectStore.currentProjectId;
const workflowName = sampleWorkflow.value.name ?? 'My Sub-Workflow';
const sampleSubWorkflows = workflowsStore.allWorkflows.filter(
(w) => w.name && new RegExp(workflowName).test(w.name),
);
const workflow: IWorkflowDataCreate = {
...sampleWorkflow.value,
name: `${workflowName} ${sampleSubWorkflows.length + 1}`,
};
if (projectId) {
workflow.projectId = projectId;
}
const newWorkflow = await workflowsStore.createNewWorkflow(workflow);
const { href } = router.resolve({ name: VIEWS.WORKFLOW, params: { name: newWorkflow.id } });
updateModelValue({
...props.modelValue,
value: newWorkflow.id,
cachedResultName: workflow.name,
});
window.open(href, '_blank');
};
</script>
<template>
<div>
<n8n-input-label
:label="locale.baseText('testDefinition.edit.workflowSelectorLabel')"
:bold="false"
>
<div class="mt-xs">
<template v-if="!modelValue.value">
<N8nButton type="secondary" class="mb-xs" @click="handleDefineEvaluation">
{{ locale.baseText('testDefinition.workflow.createNew') }}
</N8nButton>
<N8nLink class="mb-xs" style="display: block" @click="selectorVisible = !selectorVisible">
{{ locale.baseText('testDefinition.workflow.createNew.or') }}
</N8nLink>
</template>
<WorkflowSelectorParameterInput
ref="workflowInput"
v-if="modelValue.value || selectorVisible"
:parameter="{
displayName: locale.baseText('testDefinition.edit.workflowSelectorDisplayName'),
name: 'workflowId',
@ -63,11 +112,11 @@ const sampleWorkflow = computed<IWorkflowDataCreate>(() => {
:is-value-expression="false"
:expression-edit-dialog-visible="false"
:path="'workflows'"
allow-new
:allow-new="false"
:sample-workflow="sampleWorkflow"
@update:model-value="$emit('update:modelValue', $event)"
@workflow-created="$emit('workflowCreated', $event)"
:new-resource-label="locale.baseText('testDefinition.workflow.createNew')"
@update:model-value="updateModelValue"
@workflow-created="emit('workflowCreated', $event)"
/>
</n8n-input-label>
</div>
</template>

View file

@ -1,40 +1,72 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import EvaluationStep from '@/components/TestDefinition/EditDefinition/EvaluationStep.vue';
import TagsInput from '@/components/TestDefinition/EditDefinition/TagsInput.vue';
import WorkflowSelector from '@/components/TestDefinition/EditDefinition/WorkflowSelector.vue';
import MetricsInput from '@/components/TestDefinition/EditDefinition/MetricsInput.vue';
import type { TestMetricRecord } from '@/api/testDefinition.ee';
import BlockArrow from '@/components/TestDefinition/EditDefinition/BlockArrow.vue';
import EvaluationStep from '@/components/TestDefinition/EditDefinition/EvaluationStep.vue';
import MetricsInput from '@/components/TestDefinition/EditDefinition/MetricsInput.vue';
import WorkflowSelector from '@/components/TestDefinition/EditDefinition/WorkflowSelector.vue';
import type { EditableFormState, EvaluationFormState } from '@/components/TestDefinition/types';
import type { ITag, ModalState } from '@/Interface';
import { useI18n } from '@/composables/useI18n';
import { useMessage } from '@/composables/useMessage';
import { NODE_PINNING_MODAL_KEY } from '@/constants';
import { ref } from 'vue';
import type { ITag, ModalState } from '@/Interface';
import type { IPinData } from 'n8n-workflow';
import { computed, ref } from 'vue';
defineProps<{
const props = defineProps<{
showConfig: boolean;
tagUsageCount: number;
allTags: ITag[];
tagsById: Record<string, ITag>;
isLoading: boolean;
examplePinnedData?: IPinData;
sampleWorkflowName?: string;
hasRuns: boolean;
getFieldIssues: (key: string) => Array<{ field: string; message: string }>;
startEditing: (field: keyof EditableFormState) => void;
saveChanges: (field: keyof EditableFormState) => void;
cancelEditing: (field: keyof EditableFormState) => void;
createTag?: (name: string) => Promise<ITag>;
}>();
const emit = defineEmits<{
openPinningModal: [];
deleteMetric: [metric: Partial<TestMetricRecord>];
deleteMetric: [metric: TestMetricRecord];
openExecutionsViewForTag: [];
renameTag: [tag: string];
evaluationWorkflowCreated: [workflowId: string];
}>();
const locale = useI18n();
const changedFieldsKeys = ref<string[]>([]);
const activeTooltip = ref<string | null>(null);
const tooltipPosition = ref<{
x: number;
y: number;
width: number;
height: number;
right: number;
} | null>(null);
const tags = defineModel<EvaluationFormState['tags']>('tags', { required: true });
const renameTag = async () => {
const { prompt } = useMessage();
const result = await prompt(locale.baseText('testDefinition.edit.step.tag.placeholder'), {
inputValue: props.tagsById[tags.value.value[0]]?.name,
inputPlaceholder: locale.baseText('testDefinition.edit.step.tag.placeholder'),
inputValidator: (value) => {
if (!value) {
return locale.baseText('testDefinition.edit.step.tag.validation.required');
}
if (value.length > 21) {
return locale.baseText('testDefinition.edit.step.tag.validation.tooLong');
}
return true;
},
});
if (result?.action === 'confirm') {
emit('renameTag', result.value);
}
};
const evaluationWorkflow = defineModel<EvaluationFormState['evaluationWorkflow']>(
'evaluationWorkflow',
{ required: true },
@ -46,54 +78,86 @@ const mockedNodes = defineModel<EvaluationFormState['mockedNodes']>('mockedNodes
const nodePinningModal = ref<ModalState | null>(null);
function updateChangedFieldsKeys(key: string) {
changedFieldsKeys.value.push(key);
const selectedTag = computed(() => {
return props.tagsById[tags.value.value[0]] ?? {};
});
function openExecutionsView() {
emit('openExecutionsViewForTag');
}
function showFieldIssues(fieldKey: string) {
return changedFieldsKeys.value.includes(fieldKey);
function showTooltip(event: MouseEvent, tooltip: string) {
const container = event.target as HTMLElement;
const containerRect = container.getBoundingClientRect();
activeTooltip.value = tooltip;
tooltipPosition.value = {
x: containerRect.right,
y: containerRect.top,
width: containerRect.width,
height: containerRect.height,
right: window.innerWidth,
};
}
function hideTooltip() {
activeTooltip.value = null;
tooltipPosition.value = null;
}
</script>
<template>
<div :class="[$style.panelBlock, { [$style.hidden]: !showConfig }]">
<div :class="[$style.container, { [$style.hidden]: !showConfig }]">
<div :class="$style.editForm">
<div :class="$style.panelIntro">
{{ locale.baseText('testDefinition.edit.step.intro') }}
</div>
<BlockArrow :class="$style.introArrow" />
<!-- Select Executions -->
<EvaluationStep
:class="$style.step"
:title="
locale.baseText('testDefinition.edit.step.executions', {
adjustToNumber: tagUsageCount,
})
"
:description="locale.baseText('testDefinition.edit.step.executions.description')"
:class="[$style.step, $style.reducedSpacing]"
:issues="getFieldIssues('tags')"
:show-issues="showFieldIssues('tags')"
:tooltip="
hasRuns ? locale.baseText('testDefinition.edit.step.executions.tooltip') : undefined
"
@mouseenter="
showTooltip($event, locale.baseText('testDefinition.edit.step.executions.tooltip'))
"
@mouseleave="hideTooltip"
>
<template #icon><font-awesome-icon icon="history" size="lg" /></template>
<template #containerPrefix>
<BlockArrow :class="[$style.middle, $style.diagramArrow, $style.sm]" />
</template>
<template #title>
{{
locale.baseText('testDefinition.edit.step.executions', {
adjustToNumber: selectedTag?.usageCount ?? 0,
})
}}
</template>
<template #cardContent>
<TagsInput
v-model="tags"
:class="{ 'has-issues': getFieldIssues('tags') }"
:all-tags="allTags"
:tags-by-id="tagsById"
:is-loading="isLoading"
:start-editing="startEditing"
:save-changes="saveChanges"
:cancel-editing="cancelEditing"
:create-tag="createTag"
@update:model-value="updateChangedFieldsKeys('tags')"
<div :class="$style.tagInputContainer">
<div :class="$style.tagInputTag">
<i18n-t keypath="testDefinition.edit.step.tag">
<template #tag>
<N8nTag :text="selectedTag.name" :clickable="true" @click="renameTag">
<template #tag>
{{ selectedTag.name }} <font-awesome-icon icon="pen" size="sm" />
</template>
</N8nTag>
</template>
</i18n-t>
</div>
<div :class="$style.tagInputControls">
<n8n-button
label="Select executions"
type="tertiary"
size="small"
@click="openExecutionsView"
/>
</div>
</div>
</template>
</EvaluationStep>
<div :class="$style.evaluationArrows">
<BlockArrow />
<BlockArrow />
</div>
<!-- Mocked Nodes -->
<EvaluationStep
:class="$style.step"
@ -103,12 +167,14 @@ function showFieldIssues(fieldKey: string) {
})
"
:small="true"
:expanded="true"
:description="locale.baseText('testDefinition.edit.step.nodes.description')"
:issues="getFieldIssues('mockedNodes')"
:show-issues="showFieldIssues('mockedNodes')"
:tooltip="hasRuns ? locale.baseText('testDefinition.edit.step.nodes.tooltip') : undefined"
@mouseenter="showTooltip($event, locale.baseText('testDefinition.edit.step.nodes.tooltip'))"
@mouseleave="hideTooltip"
>
<template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template>
<template #containerPrefix>
<BlockArrow :class="[$style.diagramArrow, $style.right]" />
</template>
<template #cardContent>
<n8n-button
size="small"
@ -125,27 +191,45 @@ function showFieldIssues(fieldKey: string) {
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.reRunExecutions')"
:small="true"
:description="locale.baseText('testDefinition.edit.step.reRunExecutions.description')"
:tooltip="
hasRuns ? locale.baseText('testDefinition.edit.step.reRunExecutions.tooltip') : undefined
"
@mouseenter="
showTooltip($event, locale.baseText('testDefinition.edit.step.reRunExecutions.tooltip'))
"
@mouseleave="hideTooltip"
>
<template #icon><font-awesome-icon icon="redo" size="lg" /></template>
<template #containerPrefix>
<BlockArrow :class="[$style.right, $style.diagramArrow]" />
</template>
</EvaluationStep>
<!-- Compare Executions -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.compareExecutions')"
:description="locale.baseText('testDefinition.edit.step.compareExecutions.description')"
:description="locale.baseText('testDefinition.edit.workflowSelectorLabel')"
:issues="getFieldIssues('evaluationWorkflow')"
:show-issues="showFieldIssues('evaluationWorkflow')"
:tooltip="
hasRuns
? locale.baseText('testDefinition.edit.step.compareExecutions.tooltip')
: undefined
"
@mouseenter="
showTooltip($event, locale.baseText('testDefinition.edit.step.compareExecutions.tooltip'))
"
@mouseleave="hideTooltip"
>
<template #icon><font-awesome-icon icon="equals" size="lg" /></template>
<template #containerPrefix>
<BlockArrow :class="[$style.right, $style.diagramArrow]" />
<BlockArrow :class="[$style.left, $style.diagramArrow, $style.lg]" />
</template>
<template #cardContent>
<WorkflowSelector
v-model="evaluationWorkflow"
:example-pinned-data="examplePinnedData"
:class="{ 'has-issues': getFieldIssues('evaluationWorkflow').length > 0 }"
:sample-workflow-name="sampleWorkflowName"
@update:model-value="updateChangedFieldsKeys('evaluationWorkflow')"
@workflow-created="$emit('evaluationWorkflowCreated', $event)"
/>
</template>
@ -155,44 +239,74 @@ function showFieldIssues(fieldKey: string) {
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.metrics')"
:description="locale.baseText('testDefinition.edit.step.metrics.description')"
:issues="getFieldIssues('metrics')"
:show-issues="showFieldIssues('metrics')"
:description="locale.baseText('testDefinition.edit.step.metrics.description')"
:tooltip="hasRuns ? locale.baseText('testDefinition.edit.step.metrics.tooltip') : undefined"
@mouseenter="
showTooltip($event, locale.baseText('testDefinition.edit.step.metrics.tooltip'))
"
@mouseleave="hideTooltip"
>
<template #icon><font-awesome-icon icon="chart-bar" size="lg" /></template>
<template #containerPrefix>
<BlockArrow :class="[$style.middle, $style.diagramArrow]" />
</template>
<template #cardContent>
<MetricsInput
v-model="metrics"
:class="{ 'has-issues': getFieldIssues('metrics').length > 0 }"
@delete-metric="(metric) => emit('deleteMetric', metric)"
@update:model-value="updateChangedFieldsKeys('metrics')"
/>
</template>
</EvaluationStep>
</div>
<Modal ref="nodePinningModal" width="80vw" height="85vh" :name="NODE_PINNING_MODAL_KEY">
<template #header>
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{
<N8nHeading size="large" :bold="true">{{
locale.baseText('testDefinition.edit.selectNodes')
}}</N8nHeading>
<br />
<N8nText :class="$style.modalDescription">{{
locale.baseText('testDefinition.edit.modal.description')
}}</N8nText>
</template>
<template #content>
<NodesPinning v-model="mockedNodes" data-test-id="nodes-pinning-modal" />
</template>
</Modal>
<div
v-if="tooltipPosition && !hasRuns"
:class="$style.customTooltip"
:style="{
left: `${tooltipPosition.x}px`,
top: `${tooltipPosition.y}px`,
width: `${tooltipPosition.right - tooltipPosition.x}px`,
height: `${tooltipPosition.height}px`,
}"
>
{{ activeTooltip }}
</div>
</div>
</template>
<style module lang="scss">
.panelBlock {
.container {
overflow-y: auto;
overflow-x: visible;
width: auto;
margin-left: var(--spacing-2xl);
}
.editForm {
width: var(--evaluation-edit-panel-width);
display: grid;
height: 100%;
overflow-y: auto;
height: fit-content;
flex-shrink: 0;
padding-bottom: var(--spacing-l);
margin-left: var(--spacing-2xl);
transition: width 0.2s ease;
position: relative;
gap: var(--spacing-l);
margin: 0 auto;
&.hidden {
margin-left: 0;
@ -206,6 +320,18 @@ function showFieldIssues(fieldKey: string) {
}
}
.customTooltip {
position: fixed;
z-index: 1000;
padding: var(--spacing-xs);
max-width: 25rem;
display: flex;
align-items: center;
font-size: var(--font-size-xs);
color: var(--color-text-light);
line-height: 1rem;
}
.panelIntro {
font-size: var(--font-size-m);
color: var(--color-text-dark);
@ -215,28 +341,45 @@ function showFieldIssues(fieldKey: string) {
display: block;
}
.step {
position: relative;
&:not(:first-child) {
margin-top: var(--spacing-m);
}
}
.introArrow {
--arrow-height: 1.5rem;
margin-bottom: -1rem;
justify-self: center;
}
.evaluationArrows {
--arrow-height: 23rem;
display: flex;
justify-content: space-between;
width: 100%;
max-width: 80%;
margin: 0 auto;
margin-bottom: -100%;
.diagramArrow {
--arrow-height: 4rem;
position: absolute;
bottom: 100%;
left: var(--spacing-2xl);
z-index: 0;
// increase hover radius of the arrow
&.right {
left: unset;
right: var(--spacing-2xl);
}
&.middle {
left: 50%;
transform: translateX(-50%);
}
&.sm {
--arrow-height: 1.5rem;
}
&.lg {
--arrow-height: 14rem;
}
}
.tagInputContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.tagInputTag {
display: flex;
gap: var(--spacing-3xs);
font-size: var(--font-size-2xs);
color: var(--color-text-base);
}
.tagInputControls {
display: flex;
gap: var(--spacing-2xs);
}
</style>

View file

@ -1,134 +0,0 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import TestNameInput from '@/components/TestDefinition/EditDefinition/TestNameInput.vue';
import DescriptionInput from '@/components/TestDefinition/EditDefinition/DescriptionInput.vue';
import type { EditableField, EditableFormState } from '@/components/TestDefinition/types';
import { computed } from 'vue';
const props = defineProps<{
hasRuns: boolean;
isSaving: boolean;
showConfig: boolean;
runTestEnabled: boolean;
startEditing: <T extends keyof EditableFormState>(field: T) => void;
saveChanges: <T extends keyof EditableFormState>(field: T) => void;
handleKeydown: <T extends keyof EditableFormState>(event: KeyboardEvent, field: T) => void;
onSaveTest: () => Promise<void>;
runTest: () => Promise<void>;
toggleConfig: () => void;
getFieldIssues: (key: string) => Array<{ field: string; message: string }>;
}>();
const name = defineModel<EditableField<string>>('name', { required: true });
const description = defineModel<EditableField<string>>('description', { required: true });
const locale = useI18n();
const showSavingIndicator = computed(() => {
return !name.value.isEditing;
});
</script>
<template>
<div :class="$style.headerSection">
<div :class="$style.headerMeta">
<div :class="$style.name">
<n8n-icon-button
:class="$style.backButton"
icon="arrow-left"
type="tertiary"
:title="locale.baseText('testDefinition.edit.backButtonTitle')"
@click="$router.back()"
/>
<TestNameInput
v-model="name"
:class="{ 'has-issues': getFieldIssues('name').length > 0 }"
:start-editing="startEditing"
:save-changes="saveChanges"
:handle-keydown="handleKeydown"
/>
<div v-if="showSavingIndicator" :class="$style.lastSaved">
<template v-if="isSaving">
{{ locale.baseText('testDefinition.edit.saving') }}
</template>
<template v-else> {{ locale.baseText('testDefinition.edit.saved') }} </template>
</div>
</div>
<DescriptionInput
v-model="description"
:start-editing="startEditing"
:save-changes="saveChanges"
:handle-keydown="handleKeydown"
:class="$style.descriptionInput"
/>
</div>
<div :class="$style.controls">
<N8nButton
v-if="props.hasRuns"
size="small"
:icon="showConfig ? 'eye-slash' : 'eye'"
data-test-id="toggle-config-button"
:label="
showConfig
? locale.baseText('testDefinition.edit.hideConfig')
: locale.baseText('testDefinition.edit.showConfig')
"
type="tertiary"
@click="toggleConfig"
/>
<N8nTooltip :disabled="runTestEnabled" :placement="'left'">
<N8nButton
:disabled="!runTestEnabled"
:class="$style.runTestButton"
size="small"
data-test-id="run-test-button"
:label="locale.baseText('testDefinition.runTest')"
type="primary"
@click="runTest"
/>
<template #content>
<slot name="runTestTooltip" />
</template>
</N8nTooltip>
</div>
</div>
</template>
<style module lang="scss">
.headerSection {
display: flex;
justify-content: space-between;
align-items: flex-start;
background-color: var(--color-background-light);
width: 100%;
}
.headerMeta {
max-width: 50%;
}
.name {
display: flex;
align-items: center;
justify-content: flex-start;
.lastSaved {
font-size: var(--font-size-s);
color: var(--color-text-light);
}
}
.descriptionInput {
margin-top: var(--spacing-2xs);
}
.controls {
display: flex;
gap: var(--spacing-s);
}
.backButton {
--button-font-color: var(--color-text-light);
border: none;
padding-left: 0;
}
</style>

View file

@ -1,10 +1,14 @@
<script setup lang="ts">
import type { TestRunRecord } from '@/api/testDefinition.ee';
import type { AppliedThemeOption } from '@/Interface';
import MetricsChart from '@/components/TestDefinition/ListRuns/MetricsChart.vue';
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
import { useI18n } from '@/composables/useI18n';
import { VIEWS } from '@/constants';
import type { AppliedThemeOption } from '@/Interface';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
defineProps<{
const props = defineProps<{
runs: TestRunRecord[];
testId: string;
appliedTheme: AppliedThemeOption;
@ -13,25 +17,78 @@ defineProps<{
const emit = defineEmits<{
deleteRuns: [runs: TestRunRecord[]];
}>();
const locale = useI18n();
const router = useRouter();
const selectedMetric = defineModel<string>('selectedMetric', { required: true });
function onDeleteRuns(toDelete: TestRunRecord[]) {
emit('deleteRuns', toDelete);
}
const metrics = computed(() => {
const metricKeys = props.runs.reduce((acc, run) => {
Object.keys(run.metrics ?? {}).forEach((metric) => acc.add(metric));
return acc;
}, new Set<string>());
return [...metricKeys];
});
const metricColumns = computed(() =>
metrics.value.map((metric) => ({
prop: `metrics.${metric}`,
label: metric,
sortable: true,
showHeaderTooltip: true,
sortMethod: (a: TestRunRecord, b: TestRunRecord) =>
(a.metrics?.[metric] ?? 0) - (b.metrics?.[metric] ?? 0),
formatter: (row: TestRunRecord) => (row.metrics?.[metric] ?? 0).toFixed(2),
})),
);
const columns = computed(() => [
{
prop: 'runNumber',
label: locale.baseText('testDefinition.listRuns.runNumber'),
formatter: (row: TestRunRecord) => `${row.id}`,
showOverflowTooltip: true,
},
{
prop: 'runAt',
label: 'Run at',
sortable: true,
showOverflowTooltip: true,
sortMethod: (a: TestRunRecord, b: TestRunRecord) =>
new Date(a.runAt ?? a.createdAt).getTime() - new Date(b.runAt ?? b.createdAt).getTime(),
},
{
prop: 'status',
label: locale.baseText('testDefinition.listRuns.status'),
sortable: true,
},
...metricColumns.value,
]);
const handleRowClick = (row: TestRunRecord) => {
void router.push({
name: VIEWS.TEST_DEFINITION_RUNS_DETAIL,
params: { testId: row.testDefinitionId, runId: row.id },
});
};
</script>
<template>
<div :class="$style.runs">
<!-- Metrics Chart -->
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
<!-- Past Runs Table -->
<TestRunsTable
:class="$style.runsTable"
:runs="runs"
:runs
:columns
:selectable="true"
data-test-id="past-runs-table"
@delete-runs="onDeleteRuns"
@row-click="handleRowClick"
/>
</div>
</template>
@ -42,11 +99,7 @@ function onDeleteRuns(toDelete: TestRunRecord[]) {
flex-direction: column;
gap: var(--spacing-m);
flex: 1;
padding-top: var(--spacing-3xs);
overflow: auto;
@media (min-height: 56rem) {
margin-top: var(--spacing-2xl);
}
margin-bottom: 20px;
}
</style>

View file

@ -1,57 +1,119 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { N8nBadge, N8nButton, N8nText } from 'n8n-design-system';
import { computed } from 'vue';
defineEmits<{ 'create-test': [] }>();
const locale = useI18n();
/**
* TODO: fully implement the logic here
*/
const canCreateEvaluations = computed(() => true);
const isRegisteredCommunity = computed(() => false);
const isNotRegisteredCommunity = computed(() => false);
const hasReachedLimit = computed(() => false);
</script>
<template>
<div :class="$style.container">
<div :class="$style.header">
<h1>{{ locale.baseText('testDefinition.list.tests') }}</h1>
<div :class="{ [$style.card]: true, [$style.cardActive]: true }">
<N8nBadge theme="warning" size="small">New</N8nBadge>
<div :class="$style.cardContent">
<N8nText tag="h2" size="xlarge" color="text-base" class="mb-2xs">
{{ locale.baseText('testDefinition.list.evaluations') }}
</N8nText>
<N8nText tag="div" color="text-base" class="mb-s ml-s mr-s">
{{ locale.baseText('testDefinition.list.actionDescription') }}
</N8nText>
<template v-if="canCreateEvaluations">
<N8nButton @click="$emit('create-test')">
{{ locale.baseText('testDefinition.list.actionButton') }}
</N8nButton>
</template>
<template v-else-if="isRegisteredCommunity">
<N8nButton @click="$emit('create-test')">
{{ locale.baseText('testDefinition.list.actionButton') }}
</N8nButton>
<N8nText tag="div" color="text-light" size="small" class="mt-2xs">
{{ locale.baseText('testDefinition.list.actionDescription.registered') }}
</N8nText>
</template>
<template v-else-if="isNotRegisteredCommunity">
<div :class="$style.divider" class="mb-s"></div>
<N8nText tag="div" color="text-light" size="small" class="mb-s">
{{ locale.baseText('testDefinition.list.actionDescription.unregistered') }}
</N8nText>
<N8nButton>
{{ locale.baseText('testDefinition.list.actionButton.unregistered') }}
</N8nButton>
</template>
<template v-else-if="hasReachedLimit">
<div :class="$style.divider" class="mb-s"></div>
<N8nText tag="div" color="text-light" size="small" class="mb-s">
{{ locale.baseText('testDefinition.list.actionDescription.atLimit') }}
</N8nText>
<N8nButton>
{{ locale.baseText('generic.upgrade') }}
</N8nButton>
</template>
</div>
</div>
<div :class="{ [$style.card]: true, [$style.cardInActive]: true }">
<N8nBadge>
{{ locale.baseText('testDefinition.list.unitTests.badge') }}
</N8nBadge>
<div :class="$style.cardContent">
<N8nText tag="h2" size="xlarge" color="text-base" class="mb-2xs">
{{ locale.baseText('testDefinition.list.unitTests.title') }}
</N8nText>
<N8nText tag="div" color="text-base" class="mb-s">
{{ locale.baseText('testDefinition.list.unitTests.description') }}
</N8nText>
<N8nButton type="secondary">
{{ locale.baseText('testDefinition.list.unitTests.cta') }}
</N8nButton>
</div>
<div :class="$style.content">
<n8n-action-box
:class="$style.actionBox"
:heading="locale.baseText('testDefinition.list.evaluations')"
:description="locale.baseText('testDefinition.list.actionDescription')"
:button-text="locale.baseText('testDefinition.list.actionButton')"
@click:button="$emit('create-test')"
/>
<n8n-action-box
:class="$style.actionBox"
:heading="locale.baseText('testDefinition.list.unitTests.title')"
:description="locale.baseText('testDefinition.list.unitTests.description')"
:button-text="locale.baseText('testDefinition.list.unitTests.cta')"
button-type="tertiary"
/>
</div>
</div>
</template>
<style module lang="scss">
.container {
max-width: 75rem;
}
.header {
display: flex;
justify-content: space-between;
justify-content: center;
height: 100%;
align-items: center;
color: var(--color-text-dark);
font-size: var(--font-size-l);
margin-bottom: var(--spacing-xl);
gap: 24px;
}
h1 {
margin: 0;
}
}
.content {
width: 100%;
.card {
border-radius: var(--border-radius-base);
width: 280px;
height: 290px;
display: flex;
gap: var(--spacing-m);
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 20px;
text-align: center;
}
.actionBox {
flex: 1;
.cardContent {
margin: auto;
}
.cardActive {
border: 1px solid var(--color-foreground-base);
background-color: var(--color-background-xlight);
}
.cardInActive {
border: 1px dashed var(--color-foreground-base);
}
.divider {
border-top: 1px solid var(--color-foreground-light);
}
</style>

View file

@ -1,139 +1,181 @@
<script setup lang="ts">
import type { TestListItem, TestItemAction } from '@/components/TestDefinition/types';
import type { TestRunRecord } from '@/api/testDefinition.ee';
import TimeAgo from '@/components/TimeAgo.vue';
import { useI18n } from '@/composables/useI18n';
import n8nIconButton from 'n8n-design-system/components/N8nIconButton';
import { N8nIcon, N8nText } from 'n8n-design-system';
import type { IconColor } from 'n8n-design-system/types/icon';
import { computed } from 'vue';
export interface TestItemProps {
test: TestListItem;
actions: TestItemAction[];
}
const props = defineProps<TestItemProps>();
const locale = useI18n();
defineEmits<{
'view-details': [testId: string];
const props = defineProps<{
name: string;
testCases: number;
execution?: TestRunRecord;
errors?: Array<{ field: string; message: string }>;
}>();
const visibleActions = computed(() =>
props.actions.filter((action) => action.show?.(props.test.id) ?? true),
);
const locale = useI18n();
type IconDefinition = { icon: string; color: IconColor };
const statusesColorDictionary: Record<TestRunRecord['status'], IconDefinition> = {
new: {
icon: 'circle',
color: 'foreground-dark',
},
running: {
icon: 'spinner',
color: 'secondary',
},
completed: {
icon: 'exclamation-circle',
color: 'success',
},
error: {
icon: 'exclamation-triangle',
color: 'danger',
},
cancelled: {
icon: 'minus-circle',
color: 'success',
},
warning: {
icon: 'exclamation-circle',
color: 'warning',
},
success: {
icon: 'circle-check',
color: 'success',
},
} as const;
const statusRender = computed<IconDefinition & { label: string }>(() => {
if (props.errors?.length) {
return {
icon: 'adjust',
color: 'foreground-dark',
label: 'Incomplete',
};
}
if (!props.execution) {
return {
icon: 'circle',
color: 'foreground-dark',
label: 'Never ran',
};
}
return {
...statusesColorDictionary[props.execution.status],
label: props.execution.status,
};
});
</script>
<template>
<div
:class="$style.testItem"
:data-test-id="`test-item-${test.id}`"
@click="$emit('view-details', test.id)"
>
<div :class="$style.testInfo">
<div :class="$style.testName">
{{ test.name }}
<div :class="$style.testCard">
<div :class="$style.testCardContent">
<div>
<N8nText bold tag="div">{{ name }}</N8nText>
<N8nText tag="div" color="text-base" size="small">
{{
locale.baseText('testDefinition.list.item.tests', {
adjustToNumber: testCases,
})
}}
</N8nText>
</div>
<div :class="$style.testCases">
<n8n-text size="small">
{{ locale.baseText('testDefinition.list.testRuns', { adjustToNumber: test.testCases }) }}
</n8n-text>
<template v-if="test.execution.status === 'running'">
{{ locale.baseText('testDefinition.list.running') }}
<n8n-spinner />
</template>
<span v-else-if="test.execution.lastRun">
{{ locale.baseText('testDefinition.list.lastRun') }}
<TimeAgo :date="test.execution.lastRun" />
</span>
<div>
<div :class="$style.status">
<N8nIcon :icon="statusRender.icon" size="small" :color="statusRender.color"></N8nIcon>
<div>
<N8nText size="small" color="text-base">
{{ statusRender.label }}
</N8nText>
</div>
</div>
<N8nText v-if="errors?.length" tag="div" color="text-base" size="small" class="ml-m">
{{
locale.baseText('testDefinition.list.item.missingFields', {
adjustToNumber: errors.length,
})
}}
</N8nText>
<N8nText v-else-if="execution" tag="div" color="text-base" size="small" class="ml-m">
<TimeAgo :date="execution.updatedAt" />
</N8nText>
</div>
<div :class="$style.metrics">
<div :class="$style.metric">
{{
locale.baseText('testDefinition.list.errorRate', {
interpolate: { errorRate: test.execution.errorRate ?? '-' },
})
}}
</div>
<div v-for="(value, key) in test.execution.metrics" :key="key" :class="$style.metric">
{{ key }}: {{ value.toFixed(2) ?? '-' }}
<template v-if="execution?.metrics">
<template v-for="[key, value] in Object.entries(execution.metrics)" :key>
<N8nText
color="text-base"
size="small"
style="overflow: hidden; text-overflow: ellipsis"
>
{{ key }}
</N8nText>
<N8nText color="text-base" size="small" bold>
{{ value }}
</N8nText>
</template>
</template>
</div>
</div>
<div :class="$style.actions">
<n8n-tooltip
v-for="action in visibleActions"
:key="action.icon"
placement="top"
:show-after="1000"
:content="action.tooltip(test.id)"
>
<component
:is="n8nIconButton"
:icon="action.icon"
:data-test-id="`${action.id}-test-button-${test.id}`"
type="tertiary"
size="mini"
:disabled="action?.disabled ? action.disabled(test.id) : false"
@click.stop="action.event(test.id)"
/>
</n8n-tooltip>
</div>
<slot name="prepend"></slot>
<slot name="append"></slot>
</div>
</template>
<style module lang="scss">
.testItem {
.testCard {
display: flex;
align-items: center;
padding: var(--spacing-s) var(--spacing-s) var(--spacing-s) var(--spacing-xl);
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-base);
background-color: var(--color-background-light);
background-color: var(--color-background-xlight);
padding: var(--spacing-xs) 20px var(--spacing-xs) var(--spacing-m);
gap: var(--spacing-s);
border-bottom: 1px solid var(--color-foreground-base);
cursor: pointer;
&:first-child {
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
&:last-child {
border-bottom-color: transparent;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
}
&:hover {
background-color: var(--color-background-base);
background-color: var(--color-background-light);
.name {
color: var(--color-primary);
}
}
}
.testInfo {
display: flex;
.status {
display: inline-flex;
gap: 8px;
text-transform: capitalize;
align-items: center;
}
.testCardContent {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
align-items: center;
flex: 1;
gap: var(--spacing-2xs);
}
.testName {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-s);
}
.testCases {
color: var(--color-text-base);
font-size: var(--font-size-2xs);
display: flex;
align-items: center;
gap: var(--spacing-2xs);
gap: var(--spacing-xs);
}
.metrics {
display: flex;
gap: var(--spacing-l);
margin: 0 var(--spacing-l);
}
.metric {
font-size: var(--font-size-2xs);
color: var(--color-text-dark);
white-space: nowrap;
}
.actions {
display: flex;
gap: var(--spacing-xs);
--color-button-secondary-font: var(--color-callout-info-icon);
display: grid;
grid-template-columns: 120px 1fr;
column-gap: 18px;
}
</style>

View file

@ -1,43 +0,0 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import TestItem from './TestItem.vue';
import type { TestListItem, TestItemAction } from '@/components/TestDefinition/types';
export interface TestListProps {
tests: TestListItem[];
actions: TestItemAction[];
}
defineEmits<{ 'create-test': []; 'view-details': [testId: string] }>();
defineProps<TestListProps>();
const locale = useI18n();
</script>
<template>
<div :class="$style.testsList" data-test-id="test-definition-list">
<div :class="$style.testsHeader">
<n8n-button
:label="locale.baseText('testDefinition.list.createNew')"
@click="$emit('create-test')"
/>
</div>
<TestItem
v-for="test in tests"
:key="test.id"
:test="test"
:actions="actions"
@view-details="$emit('view-details', test.id)"
/>
</div>
</template>
<style module lang="scss">
.testsList {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
}
.testsHeader {
margin-bottom: var(--spacing-m);
}
</style>

View file

@ -1,10 +1,10 @@
<script setup lang="ts">
import { computed, watchEffect } from 'vue';
import { Line } from 'vue-chartjs';
import { useMetricsChart } from '../composables/useMetricsChart';
import type { TestRunRecord } from '@/api/testDefinition.ee';
import { useI18n } from '@/composables/useI18n';
import type { AppliedThemeOption } from '@/Interface';
import { computed, watchEffect } from 'vue';
import { Line } from 'vue-chartjs';
import { useMetricsChart } from '../composables/useMetricsChart';
const emit = defineEmits<{
'update:selectedMetric': [value: string];
@ -59,7 +59,6 @@ watchEffect(() => {
:value="metric"
/>
</N8nSelect>
<N8nText>{{ locale.baseText('testDefinition.listRuns.metricsOverTime') }}</N8nText>
</div>
<div :class="$style.chartWrapper">
<Line
@ -84,7 +83,6 @@ watchEffect(() => {
justify-content: flex-start;
align-items: center;
gap: var(--spacing-s);
margin-bottom: var(--spacing-m);
padding: var(--spacing-xs) var(--spacing-s);
border-bottom: 1px solid var(--color-foreground-base);
}
@ -101,7 +99,7 @@ watchEffect(() => {
.chartWrapper {
position: relative;
height: var(--metrics-chart-height, 400px);
height: var(--metrics-chart-height, 200px);
width: 100%;
padding: var(--spacing-s);
}

View file

@ -1,25 +1,35 @@
<script setup lang="ts">
import type { TestRunRecord } from '@/api/testDefinition.ee';
import { useI18n } from '@/composables/useI18n';
import { N8nIcon, N8nText } from 'n8n-design-system';
import { computed, ref } from 'vue';
import type { TestTableColumn } from '../shared/TestTableBase.vue';
import TestTableBase from '../shared/TestTableBase.vue';
import { convertToDisplayDate } from '@/utils/typesUtils';
import { VIEWS } from '@/constants';
import { useI18n } from '@/composables/useI18n';
const emit = defineEmits<{
getRunDetail: [run: TestRunRecord];
rowClick: [run: TestRunRecord];
selectionChange: [runs: TestRunRecord[]];
deleteRuns: [runs: TestRunRecord[]];
}>();
const props = defineProps<{
runs: TestRunRecord[];
columns: Array<TestTableColumn<TestRunRecord>>;
selectable?: boolean;
}>();
const statusesColorDictionary: Record<TestRunRecord['status'], string> = {
new: 'var(--color-primary)',
running: 'var(--color-secondary)',
completed: 'var(--color-success)',
error: 'var(--color-danger)',
cancelled: 'var(--color-foreground-dark)',
warning: 'var(--color-warning)',
success: 'var(--color-success)',
};
const locale = useI18n();
const navigateToRunDetail = (run: TestRunRecord) => emit('getRunDetail', run);
const selectedRows = ref<TestRunRecord[]>([]);
// Combine test run statuses and finalResult to get the final status
@ -33,70 +43,6 @@ const runSummaries = computed(() => {
});
});
const metrics = computed(() => {
return props.runs.reduce((acc, run) => {
const metricKeys = Object.keys(run.metrics ?? {});
return [...new Set([...acc, ...metricKeys])];
}, [] as string[]);
});
const getErrorTooltipLinkRoute = (row: TestRunRecord) => {
if (row.errorCode === 'EVALUATION_WORKFLOW_NOT_FOUND') {
return {
name: VIEWS.TEST_DEFINITION_EDIT,
params: {
testId: row.testDefinitionId,
},
};
}
return undefined;
};
const columns = computed((): Array<TestTableColumn<TestRunRecord>> => {
return [
{
prop: 'runNumber',
label: locale.baseText('testDefinition.listRuns.runNumber'),
width: 200,
route: (row: TestRunRecord) => ({
name: VIEWS.TEST_DEFINITION_RUNS_DETAIL,
params: { testId: row.testDefinitionId, runId: row.id },
}),
formatter: (row: TestRunRecord) => `${row.id}`,
},
{
prop: 'status',
label: locale.baseText('testDefinition.listRuns.status'),
filters: [
{ text: locale.baseText('testDefinition.listRuns.status.new'), value: 'new' },
{ text: locale.baseText('testDefinition.listRuns.status.running'), value: 'running' },
{ text: locale.baseText('testDefinition.listRuns.status.completed'), value: 'completed' },
{ text: locale.baseText('testDefinition.listRuns.status.error'), value: 'error' },
{ text: locale.baseText('testDefinition.listRuns.status.cancelled'), value: 'cancelled' },
],
errorRoute: getErrorTooltipLinkRoute,
filterMethod: (value: string, row: TestRunRecord) => row.status === value,
},
{
prop: 'date',
label: locale.baseText('testDefinition.listRuns.runDate'),
sortable: true,
formatter: (row: TestRunRecord) =>
convertToDisplayDate(new Date(row.runAt ?? row.createdAt).getTime()),
sortMethod: (a: TestRunRecord, b: TestRunRecord) =>
new Date(a.runAt ?? a.createdAt).getTime() - new Date(b.runAt ?? b.createdAt).getTime(),
},
...metrics.value.map((metric) => ({
prop: `metrics.${metric}`,
label: metric,
sortable: true,
formatter: (row: TestRunRecord) => `${row.metrics?.[metric]?.toFixed(2) ?? '-'}`,
})),
];
});
function onSelectionChange(runs: TestRunRecord[]) {
selectedRows.value = runs;
emit('selectionChange', runs);
@ -110,7 +56,7 @@ async function deleteRuns() {
<template>
<div :class="$style.container">
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{
locale.baseText('testDefinition.edit.pastRuns')
locale.baseText('testDefinition.edit.pastRuns.total', { adjustToNumber: runs.length })
}}</N8nHeading>
<div :class="$style.header">
<n8n-button
@ -129,16 +75,33 @@ async function deleteRuns() {
}}
</n8n-button>
</div>
<TestTableBase
:data="runSummaries"
:columns="columns"
selectable
@row-click="navigateToRunDetail"
:default-sort="{ prop: 'runAt', order: 'descending' }"
@row-click="(row) => emit('rowClick', row)"
@selection-change="onSelectionChange"
/>
<N8nText :class="$style.runsTableTotal">{{
locale.baseText('testDefinition.edit.pastRuns.total', { adjustToNumber: runs.length })
}}</N8nText>
>
<template #status="{ row }">
<div
style="display: inline-flex; gap: 8px; text-transform: capitalize; align-items: center"
>
<N8nIcon
icon="circle"
size="xsmall"
:style="{ color: statusesColorDictionary[row.status] }"
></N8nIcon>
<N8nText v-if="row.status === 'error'" size="small" bold color="text-base">
{{ row.failedCases }} / {{ row.totalCases }} {{ row.status }}
</N8nText>
<N8nText v-else size="small" bold color="text-base">
{{ row.status }}
</N8nText>
</div>
</template>
</TestTableBase>
</div>
</template>

View file

@ -56,9 +56,9 @@ export function useTestDefinitionForm() {
/**
* Load test data including metrics.
*/
const loadTestData = async (testId: string) => {
const loadTestData = async (testId: string, workflowId: string) => {
try {
await evaluationsStore.fetchAll({ force: true });
await evaluationsStore.fetchAll({ force: true, workflowId });
const testDefinition = evaluationsStore.testDefinitionsById[testId];
if (testDefinition) {
@ -86,6 +86,7 @@ export function useTestDefinitionForm() {
};
state.value.metrics = metrics;
state.value.mockedNodes = testDefinition.mockedNodes ?? [];
evaluationsStore.updateRunFieldIssues(testDefinition.id);
}
} catch (error) {
console.error('Failed to load test data', error);
@ -114,6 +115,10 @@ export function useTestDefinitionForm() {
state.value.metrics = state.value.metrics.filter((metric) => metric.id !== metricId);
};
/**
* This method would perform unnecessary updates on the BE
* it's a performance degradation candidate if metrics reach certain amount
*/
const updateMetrics = async (testId: string) => {
const promises = state.value.metrics.map(async (metric) => {
if (!metric.name) return;
@ -159,9 +164,7 @@ export function useTestDefinitionForm() {
if (annotationTagId) {
params.annotationTagId = annotationTagId;
}
if (state.value.mockedNodes.length > 0) {
params.mockedNodes = state.value.mockedNodes;
}
const response = await evaluationsStore.update({ ...params, id: testId });
return response;

View file

@ -1,11 +1,10 @@
<script setup lang="ts" generic="T extends object">
import type { RouteLocationRaw } from 'vue-router';
import TableCell from './TableCell.vue';
import TableStatusCell from './TableStatusCell.vue';
import { ElTable, ElTableColumn } from 'element-plus';
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue';
import type { TableInstance } from 'element-plus';
import { ElTable, ElTableColumn } from 'element-plus';
import { isEqual } from 'lodash-es';
import { N8nIcon, N8nTooltip } from 'n8n-design-system';
import { nextTick, ref, watch } from 'vue';
import type { RouteLocationRaw } from 'vue-router';
/**
* A reusable table component for displaying evaluation results data
* @template T - The type of data being displayed in the table rows
@ -18,6 +17,8 @@ import { isEqual } from 'lodash-es';
export type TestTableColumn<TRow> = {
prop: string;
label: string;
showHeaderTooltip?: boolean;
showOverflowTooltip?: boolean;
width?: number;
sortable?: boolean;
filters?: Array<{ text: string; value: string }>;
@ -30,11 +31,6 @@ export type TestTableColumn<TRow> = {
};
type TableRow = T & { id: string };
type TableRowWithStatus = TableRow & { status: string };
const MIN_TABLE_HEIGHT = 350;
const MAX_TABLE_HEIGHT = 1400;
const props = withDefaults(
defineProps<{
data: TableRow[];
@ -54,7 +50,6 @@ const props = withDefaults(
const tableRef = ref<TableInstance>();
const selectedRows = ref<TableRow[]>([]);
const localData = ref<TableRow[]>([]);
const tableHeight = ref<string>('100%');
const emit = defineEmits<{
rowClick: [row: TableRow];
selectionChange: [rows: TableRow[]];
@ -92,25 +87,21 @@ const handleSelectionChange = (rows: TableRow[]) => {
emit('selectionChange', rows);
};
const computeTableHeight = () => {
const containerHeight = tableRef.value?.$el?.parentElement?.clientHeight ?? 600;
const height = Math.min(Math.max(containerHeight, MIN_TABLE_HEIGHT), MAX_TABLE_HEIGHT);
tableHeight.value = `${height - 100}px`;
const handleColumnResize = (
newWidth: number,
_oldWidth: number,
column: { minWidth: number; width: number },
// event: MouseEvent,
) => {
if (column.minWidth && newWidth < column.minWidth) {
column.width = column.minWidth;
}
};
function hasStatus(row: unknown): row is TableRowWithStatus {
return typeof row === 'object' && row !== null && 'status' in row;
}
onMounted(() => {
computeTableHeight();
window.addEventListener('resize', computeTableHeight);
});
onUnmounted(() => {
window.removeEventListener('resize', computeTableHeight);
});
defineSlots<{
id(props: { row: TableRow }): unknown;
status(props: { row: TableRow }): unknown;
}>();
</script>
<template>
@ -120,16 +111,21 @@ onUnmounted(() => {
:default-sort="defaultSort"
:data="localData"
:border="true"
:max-height="tableHeight"
resizable
:cell-class-name="$style.customCell"
:row-class-name="$style.customRow"
scrollbar-always-on
@selection-change="handleSelectionChange"
@vue:mounted="computeTableHeight"
@header-dragend="handleColumnResize"
@row-click="(row) => $emit('rowClick', row)"
>
<ElTableColumn
v-if="selectable"
type="selection"
:selectable="selectableFilter"
data-test-id="table-column-select"
width="46"
fixed
align="center"
/>
<ElTableColumn
v-for="column in columns"
@ -137,30 +133,102 @@ onUnmounted(() => {
v-bind="column"
:resizable="true"
data-test-id="table-column"
:min-width="125"
>
<template #header="headerProps">
<N8nTooltip
:content="headerProps.column.label"
placement="top"
:disabled="!column.showHeaderTooltip"
>
<div :class="$style.customHeaderCell">
<div :class="$style.customHeaderCellLabel">
{{ headerProps.column.label }}
</div>
<div
v-if="headerProps.column.sortable && headerProps.column.order"
:class="$style.customHeaderCellSort"
>
<N8nIcon
:icon="headerProps.column.order === 'descending' ? 'arrow-up' : 'arrow-down'"
size="small"
/>
</div>
</div>
</N8nTooltip>
</template>
<template #default="{ row }">
<TableStatusCell
v-if="column.prop === 'status' && hasStatus(row)"
:column="column"
:row="row"
/>
<TableCell
v-else
:key="row.id + column.prop"
:column="column"
:row="row"
data-test-id="table-cell"
@click="$emit('rowClick', row)"
/>
<slot v-if="column.prop === 'id'" name="id" v-bind="{ row }"></slot>
<slot v-if="column.prop === 'status'" name="status" v-bind="{ row }"></slot>
</template>
</ElTableColumn>
</ElTable>
</template>
<style module lang="scss">
.customCell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid var(--border-color-light) !important;
}
.cell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.customRow {
cursor: pointer;
&:hover {
& > .customCell {
background-color: var(--color-background-light);
}
}
}
.customHeaderCell {
display: flex;
gap: 4px;
}
.customHeaderCellLabel {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
font-weight: 600;
color: var(--color-text-base);
}
.customHeaderCellSort {
display: flex;
align-items: center;
}
.table {
:global(.el-table__cell) {
padding: var(--spacing-3xs) 0;
border-radius: 12px;
:global(.el-table__column-resize-proxy) {
background-color: var(--color-primary);
width: 3px;
}
:global(thead th) {
padding: 6px 0;
}
:global(.caret-wrapper) {
display: none;
}
:global(.el-scrollbar__thumb) {
background-color: var(--color-foreground-base);
}
:global(.el-scrollbar__bar) {
opacity: 1;
}
}
</style>

View file

@ -6,11 +6,14 @@ import userEvent from '@testing-library/user-event';
const renderComponent = createComponentRenderer(MetricsInput);
describe('MetricsInput', () => {
let props: { modelValue: Array<{ name: string }> };
let props: { modelValue: Array<{ id?: string; name: string }> };
beforeEach(() => {
props = {
modelValue: [{ name: 'Metric 1' }, { name: 'Metric 2' }],
modelValue: [
{ name: 'Metric 1', id: 'metric-1' },
{ name: 'Metric 2', id: 'metric-2' },
],
};
});
@ -62,7 +65,7 @@ describe('MetricsInput', () => {
// Check the structure of one of the emissions
// Initial: [{ name: 'Metric 1' }, { name: 'Metric 2' }]
// After first click: [{ name: 'Metric 1' }, { name: 'Metric 2' }, { name: '' }]
expect(updateEvents[0]).toEqual([[{ name: 'Metric 1' }, { name: 'Metric 2' }, { name: '' }]]);
expect(updateEvents[0]).toEqual([[...props.modelValue, { name: '' }]]);
});
it('should emit "deleteMetric" event when a delete button is clicked', async () => {
@ -76,7 +79,7 @@ describe('MetricsInput', () => {
await userEvent.click(deleteButtons[1]);
expect(emitted('deleteMetric')).toBeTruthy();
expect(emitted('deleteMetric')[0]).toEqual([{ name: 'Metric 2' }]);
expect(emitted('deleteMetric')[0]).toEqual([props.modelValue[1]]);
});
it('should emit multiple update events as the user types and reflect the final name correctly', async () => {
@ -129,7 +132,7 @@ describe('MetricsInput', () => {
await userEvent.click(deleteButtons[0]);
expect(emitted('deleteMetric')).toBeTruthy();
expect(emitted('deleteMetric')[0]).toEqual([{ name: 'Metric 1' }]);
expect(emitted('deleteMetric')[0]).toEqual([props.modelValue[0]]);
await rerender({ modelValue: [{ name: 'Metric 2' }] });
const updatedInputs = getAllByPlaceholderText('Enter metric name');

View file

@ -13,6 +13,7 @@ const TEST_DEF_A: TestDefinitionRecord = {
workflowId: '123',
annotationTagId: '789',
annotationTag: null,
createdAt: '2023-01-01T00:00:00.000Z',
};
const TEST_DEF_B: TestDefinitionRecord = {
id: '2',
@ -20,6 +21,7 @@ const TEST_DEF_B: TestDefinitionRecord = {
workflowId: '123',
description: 'Description B',
annotationTag: null,
createdAt: '2023-01-01T00:00:00.000Z',
};
const TEST_DEF_NEW: TestDefinitionRecord = {
id: '3',
@ -27,6 +29,7 @@ const TEST_DEF_NEW: TestDefinitionRecord = {
name: 'New Test Definition',
description: 'New Description',
annotationTag: null,
createdAt: '2023-01-01T00:00:00.000Z',
};
beforeEach(() => {
@ -66,7 +69,7 @@ describe('useTestDefinitionForm', () => {
[TEST_DEF_B.id]: TEST_DEF_B,
};
await loadTestData(TEST_DEF_A.id);
await loadTestData(TEST_DEF_A.id, '123');
expect(fetchSpy).toBeCalled();
expect(fetchMetricsSpy).toBeCalledWith(TEST_DEF_A.id);
expect(state.value.name.value).toEqual(TEST_DEF_A.name);
@ -85,7 +88,7 @@ describe('useTestDefinitionForm', () => {
evaluationsStore.testDefinitionsById = {};
await loadTestData('unknown-id');
await loadTestData('unknown-id', '123');
expect(fetchSpy).toBeCalled();
// Should remain unchanged since no definition found
expect(state.value.description.value).toBe('');
@ -101,7 +104,7 @@ describe('useTestDefinitionForm', () => {
.mockRejectedValue(new Error('Fetch Failed'));
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await loadTestData(TEST_DEF_A.id);
await loadTestData(TEST_DEF_A.id, '123');
expect(fetchSpy).toBeCalled();
expect(consoleErrorSpy).toBeCalledWith('Failed to load test data', expect.any(Error));
consoleErrorSpy.mockRestore();
@ -150,6 +153,7 @@ describe('useTestDefinitionForm', () => {
id: TEST_DEF_A.id,
name: TEST_DEF_B.name,
description: TEST_DEF_B.description,
mockedNodes: [],
});
expect(updatedTest).toEqual(updatedBTest);
});

View file

@ -1,4 +1,4 @@
import type { TestMetricRecord, TestRunRecord } from '@/api/testDefinition.ee';
import type { TestMetricRecord } from '@/api/testDefinition.ee';
import type { INodeParameterResourceLocator } from 'n8n-workflow';
export interface EditableField<T = string> {
@ -6,16 +6,6 @@ export interface EditableField<T = string> {
tempValue: T;
isEditing: boolean;
}
export interface TestItemAction {
icon: string;
id: string;
event: (testId: string) => void | Promise<void>;
tooltip: (testId: string) => string;
disabled?: (testId: string) => boolean;
show?: (testId: string) => boolean;
}
export interface EditableFormState {
name: EditableField<string>;
tags: EditableField<string[]>;
@ -27,20 +17,3 @@ export interface EvaluationFormState extends EditableFormState {
metrics: TestMetricRecord[];
mockedNodes: Array<{ name: string; id: string }>;
}
export interface TestExecution {
lastRun: string | null;
errorRate: number | null;
metrics: Record<string, number>;
status: TestRunRecord['status'];
id: string | null;
}
export interface TestListItem {
id: string;
name: string;
tagName: string;
testCases: number;
execution: TestExecution;
fieldsIssues?: Array<{ field: string; message: string }>;
}

View file

@ -36,6 +36,7 @@ interface Props {
parameterIssues?: string[];
parameter: INodeProperties;
sampleWorkflow?: IWorkflowDataCreate;
newResourceLabel?: string;
}
const props = withDefaults(defineProps<Props>(), {
@ -45,6 +46,7 @@ const props = withDefaults(defineProps<Props>(), {
isReadOnly: false,
forceShowExpression: false,
expressionDisplayValue: '',
newResourceLabel: '',
parameterIssues: () => [],
sampleWorkflow: () => SAMPLE_SUBWORKFLOW_WORKFLOW,
});
@ -104,6 +106,10 @@ const currentProjectName = computed(() => {
});
const getCreateResourceLabel = computed(() => {
if (props.newResourceLabel) {
return props.newResourceLabel;
}
if (!currentProjectName.value) {
return i18n.baseText('executeWorkflowTrigger.createNewSubworkflow.noProject');
}

View file

@ -78,6 +78,7 @@ function onRetryMenuItemSelect(action: string): void {
:to="{
name: VIEWS.EXECUTION_PREVIEW,
params: { name: currentWorkflow, executionId: execution.id },
query: route.query,
}"
:data-test-execution-status="executionUIDetails.name"
>

View file

@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { onBeforeRouteLeave, useRouter } from 'vue-router';
import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue';
import { MAIN_HEADER_TABS, VIEWS } from '@/constants';
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
import type { ExecutionSummary } from 'n8n-workflow';
import { getNodeViewTab } from '@/utils/nodeViewUtils';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { MAIN_HEADER_TABS } from '@/constants';
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
import { getNodeViewTab } from '@/utils/nodeViewUtils';
import type { ExecutionSummary } from 'n8n-workflow';
import { computed } from 'vue';
import { onBeforeRouteLeave, useRouter } from 'vue-router';
const props = withDefaults(
defineProps<{
@ -34,7 +34,6 @@ const emit = defineEmits<{
}>();
const workflowHelpers = useWorkflowHelpers({ router: useRouter() });
const router = useRouter();
const temporaryExecution = computed<ExecutionSummary | undefined>(() =>
props.executions.find((execution) => execution.id === props.execution?.id)
@ -67,22 +66,6 @@ const onRetryExecution = (payload: { execution: ExecutionSummary; command: strin
});
};
watch(
() => props.execution,
(value: ExecutionSummary | undefined) => {
if (!value) {
return;
}
router
.push({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: props.workflow.id, executionId: value.id },
})
.catch(() => {});
},
);
onBeforeRouteLeave(async (to, _, next) => {
if (getNodeViewTab(to) === MAIN_HEADER_TABS.WORKFLOW) {
next();

View file

@ -2,7 +2,6 @@ import { describe, expect } from 'vitest';
import userEvent from '@testing-library/user-event';
import { faker } from '@faker-js/faker';
import { createRouter, createWebHistory, RouterLink } from 'vue-router';
import { createPinia, setActivePinia } from 'pinia';
import { randomInt, type ExecutionSummary } from 'n8n-workflow';
import { useSettingsStore } from '@/stores/settings.store';
import WorkflowExecutionsPreview from '@/components/executions/workflow/WorkflowExecutionsPreview.vue';
@ -10,8 +9,8 @@ import { EnterpriseEditionFeature, VIEWS } from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { ExecutionSummaryWithScopes, IWorkflowDb } from '@/Interface';
import { createComponentRenderer } from '@/__tests__/render';
let pinia: ReturnType<typeof createPinia>;
import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
const routes = [
{ path: '/', name: 'home', component: { template: '<div></div>' } },
@ -27,10 +26,6 @@ const router = createRouter({
routes,
});
const $route = {
params: {},
};
const generateUndefinedNullOrString = () => {
switch (randomInt(4)) {
case 0:
@ -69,23 +64,14 @@ const renderComponent = createComponentRenderer(WorkflowExecutionsPreview, {
'router-link': RouterLink,
},
plugins: [router],
mocks: {
$route,
},
},
});
describe('WorkflowExecutionsPreview.vue', () => {
let settingsStore: ReturnType<typeof useSettingsStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
const executionData: ExecutionSummary = executionDataFactory();
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
settingsStore = useSettingsStore();
workflowsStore = useWorkflowsStore();
createTestingPinia();
});
test.each([
@ -97,12 +83,17 @@ describe('WorkflowExecutionsPreview.vue', () => {
])(
'when debug enterprise feature is %s with workflow scopes %s it should handle debug link click accordingly',
async (availability, scopes, path) => {
const settingsStore = mockedStore(useSettingsStore);
const workflowsStore = mockedStore(useWorkflowsStore);
settingsStore.settings.enterprise = {
...(settingsStore.settings.enterprise ?? {}),
[EnterpriseEditionFeature.DebugInEditor]: availability,
};
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({ scopes } as IWorkflowDb);
workflowsStore.workflowsById[executionData.workflowId] = { scopes } as IWorkflowDb;
await router.push(path);
const { getByTestId } = renderComponent({ props: { execution: executionData } });
@ -113,13 +104,6 @@ describe('WorkflowExecutionsPreview.vue', () => {
);
it('disables the stop execution button when the user cannot update', () => {
settingsStore.settings.enterprise = {
...(settingsStore.settings.enterprise ?? {}),
};
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({
scopes: undefined,
} as IWorkflowDb);
const { getByTestId } = renderComponent({
props: { execution: { ...executionData, status: 'running' } },
});

View file

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { computed, ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElDropdown } from 'element-plus';
import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
import { useMessage } from '@/composables/useMessage';
@ -10,10 +10,15 @@ import { EnterpriseEditionFeature, MODAL_CONFIRM, VIEWS } from '@/constants';
import type { ExecutionSummary } from 'n8n-workflow';
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useI18n } from '@/composables/useI18n';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { getResourcePermissions } from '@/permissions';
import { useSettingsStore } from '@/stores/settings.store';
import type { ButtonType } from 'n8n-design-system';
import { useExecutionsStore } from '@/stores/executions.store';
import ProjectCreateResource from '@/components/Projects/ProjectCreateResource.vue';
import { useToast } from '@/composables/useToast';
type RetryDropdownRef = InstanceType<typeof ElDropdown>;
@ -28,15 +33,19 @@ const emit = defineEmits<{
}>();
const route = useRoute();
const router = useRouter();
const locale = useI18n();
const executionHelpers = useExecutionHelpers();
const message = useMessage();
const toast = useToast();
const executionDebugging = useExecutionDebugging();
const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore();
const testDefinitionStore = useTestDefinitionStore();
const executionsStore = useExecutionsStore();
const retryDropdownRef = ref<RetryDropdownRef | null>(null);
const actionToggleRef = ref<InstanceType<typeof ProjectCreateResource> | null>(null);
const workflowId = computed(() => route.params.name as string);
const workflowPermissions = computed(
() => getResourcePermissions(workflowsStore.getWorkflowById(workflowId.value)?.scopes).workflow,
@ -70,6 +79,61 @@ const hasAnnotation = computed(
(props.execution?.annotation.vote || props.execution?.annotation.tags.length > 0),
);
const testDefinitions = computed(
() => testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId.value],
);
const testDefinition = computed(() =>
testDefinitions.value.find((test) => test.id === route.query.testId),
);
const addToTestActions = computed(() => {
const testAction = testDefinitions.value
.filter((test) => test.annotationTagId)
.map((test) => {
const isAlreadyAdded = isTagAlreadyAdded(test.annotationTagId ?? '');
return {
label: `${test.name}`,
value: test.annotationTagId ?? '',
disabled: !workflowPermissions.value.update || isAlreadyAdded,
};
});
const newTestAction = {
label: '+ New Test',
value: 'new',
disabled: !workflowPermissions.value.update,
};
return [newTestAction, ...testAction];
});
function getTestButtonLabel(isAdded: boolean): string {
if (isAdded) {
return locale.baseText('testDefinition.executions.addedTo', {
interpolate: { name: testDefinition.value?.name ?? '' },
});
}
return testDefinition.value
? locale.baseText('testDefinition.executions.addTo.existing', {
interpolate: { name: testDefinition.value.name },
})
: locale.baseText('testDefinition.executions.addTo.new');
}
const addTestButtonData = computed<{ label: string; type: ButtonType }>(() => {
const isAdded = isTagAlreadyAdded(route.query.tag as string);
return {
label: getTestButtonLabel(isAdded),
type: route.query.testId ? 'primary' : 'secondary',
disabled: !workflowPermissions.value.update || isAdded,
};
});
function isTagAlreadyAdded(tagId?: string | null) {
return Boolean(tagId && props.execution?.annotation?.tags.some((tag) => tag.id === tagId));
}
async function onDeleteExecution(): Promise<void> {
// Prepend the message with a note about annotations if they exist
const confirmationText = [
@ -108,6 +172,40 @@ function onRetryButtonBlur(event: FocusEvent) {
retryDropdownRef.value.handleClose();
}
}
async function handleAddToTestAction(actionValue: string) {
if (actionValue === 'new') {
await router.push({
name: VIEWS.NEW_TEST_DEFINITION,
params: {
name: workflowId.value,
},
});
return;
}
const currentTags = props.execution?.annotation?.tags ?? [];
const newTags = [...currentTags.map((t) => t.id), actionValue];
await executionsStore.annotateExecution(props.execution.id, { tags: newTags });
toast.showMessage({
title: locale.baseText('testDefinition.executions.toast.addedTo.title'),
message: locale.baseText('testDefinition.executions.toast.addedTo', {
interpolate: { name: testDefinition.value?.name ?? '' },
}),
type: 'success',
});
}
async function handleEvaluationButton() {
if (!testDefinition.value) {
actionToggleRef.value?.openActionToggle(true);
} else {
await handleAddToTestAction(route.query.tag as string);
}
}
onMounted(async () => {
await testDefinitionStore.fetchTestDefinitionsByWorkflowId(workflowId.value);
});
</script>
<template>
@ -201,7 +299,20 @@ function onRetryButtonBlur(event: FocusEvent) {
</router-link>
</N8nText>
</div>
<div>
<div :class="$style.actions">
<ProjectCreateResource
v-if="testDefinitions && testDefinitions.length"
ref="actionToggleRef"
:actions="addToTestActions"
:type="addTestButtonData.type"
@action="handleAddToTestAction"
>
<N8nButton
data-test-id="add-to-test-button"
v-bind="addTestButtonData"
@click="handleEvaluationButton"
/>
</ProjectCreateResource>
<router-link
:to="{
name: VIEWS.EXECUTION_DEBUG,
@ -341,12 +452,15 @@ function onRetryButtonBlur(event: FocusEvent) {
}
.debugLink {
margin-right: var(--spacing-xs);
a > span {
display: block;
padding: var(--button-padding-vertical, var(--spacing-xs))
var(--button-padding-horizontal, var(--spacing-m));
}
}
.actions {
display: flex;
gap: var(--spacing-xs);
}
</style>

View file

@ -503,7 +503,6 @@ export const enum VIEWS {
WORKFLOW_EXECUTIONS = 'WorkflowExecutions',
TEST_DEFINITION = 'TestDefinition',
TEST_DEFINITION_EDIT = 'TestDefinitionEdit',
TEST_DEFINITION_RUNS = 'TestDefinitionRuns',
TEST_DEFINITION_RUNS_COMPARE = 'TestDefinitionRunsCompare',
TEST_DEFINITION_RUNS_DETAIL = 'TestDefinitionRunsDetail',
NEW_TEST_DEFINITION = 'NewTestDefinition',

View file

@ -46,19 +46,19 @@ export const SAMPLE_EVALUATION_WORKFLOW: IWorkflowDataCreate = {
parameters: {
inputSource: 'passthrough',
},
id: 'ad3156ed-3007-4a09-8527-920505339812',
id: 'c20c82d6-5f71-4fb6-a398-a10a6e6944c5',
name: 'When called by a test run',
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [620, 380],
position: [80, 440],
},
{
parameters: {},
id: '5ff0deaf-6ec9-4a0f-a906-70f1d8375e7c',
id: '4e14d09a-2699-4659-9a20-e4f4965f473e',
name: 'Replace me',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [860, 380],
position: [340, 440],
},
{
parameters: {
@ -66,87 +66,80 @@ export const SAMPLE_EVALUATION_WORKFLOW: IWorkflowDataCreate = {
assignments: [
{
id: 'a748051d-ebdb-4fcf-aaed-02756130ce2a',
name: 'my_metric',
value: 1,
name: 'latency',
value:
'={{(() => {\n const newExecutionRuns = Object.values($json.newExecution)\n .reduce((acc, node) => {\n acc.push(node.runs.filter(run => run.output.main !== undefined))\n return acc\n }, []).flat()\n\n const latency = newExecutionRuns.reduce((acc, run) => acc + run.executionTime, 0)\n\n return latency\n})()}}',
type: 'number',
},
],
},
options: {},
},
id: '2cae7e85-7808-4cab-85c0-d233f47701a1',
id: '33e2e94a-ec48-4e7b-b750-f56718d5105c',
name: 'Return metric(s)',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [1100, 380],
position: [600, 440],
},
{
parameters: {
content:
"### 1. Receive execution data\n\nThis workflow will be passed:\n- A past execution from the test\n- The execution produced by re-running it\n\n\nWe've pinned some example data to get you started",
height: 438,
width: 217,
"### 1. Receive execution data\n\nThis workflow will be passed:\n- The benchmark execution (`$json.originalExecution`)\n- The evaluation execution (`$json.newExecution`) produced by re-running the workflow using trigger data from benchmark execution\n\n\nWe've pinned some example data to get you started",
height: 458,
width: 257,
color: 7,
},
id: 'ecb90156-30a3-4a90-93d5-6aca702e2f6b',
id: '55e5e311-e285-4000-bd1e-900bc3a07da3',
name: 'Sticky Note',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [560, 105],
},
{
parameters: {
content: '### 2. Compare actual and expected result\n',
height: 439,
width: 217,
color: 7,
},
id: '556464f8-b86d-41e2-9249-ca6d541c9147',
name: 'Sticky Note1',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [800, 104],
},
{
parameters: {
content: '### 3. Return metrics\n\nMetrics should always be numerical',
height: 439,
width: 217,
color: 7,
},
id: '04c96a00-b360-423a-90a6-b3943c7d832f',
name: 'Sticky Note2',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [1040, 104],
position: [0, 140],
},
{
parameters: {
content:
'## Evaluation workflow\nThis workflow is used to check whether a single past execution being tested gives similar results when re-run',
height: 105,
width: 694,
'### 2. Evaluation logic\n\nReplace with logic to perform the tests you want to perform.\n\nE.g. compare against benchmark data, use LLMs to evaluate sentiment, compare token usage, and more.',
height: 459,
width: 237,
color: 7,
},
id: '2250a6ec-7c4f-45e4-8dfe-c4b50c98b34b',
id: 'ea74e341-ff9c-456a-83f0-c10758f0844a',
name: 'Sticky Note1',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [280, 140],
},
{
parameters: {
content:
'### 3. Return metrics\n\nDefine evaluation metrics you want to show on your report.\n\n__Note:__ Metrics need to be numeric',
height: 459,
width: 217,
color: 7,
},
id: '9b3c3408-19e1-43d5-b2bb-29d61bd129b8',
name: 'Sticky Note2',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [540, 140],
},
{
parameters: {
content:
'## Evaluation workflow\nThis workflow is used to define evaluation logic and calculate metrics. You can compare against benchmark executions, use LLMs to evaluate, or write any other logic you choose.',
height: 105,
width: 754,
},
id: '0fc1356e-6238-4557-a920-e50806c1ec13',
name: 'Sticky Note3',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [560, -25],
position: [0, 0],
},
],
pinData: {
'When called by a test run': [
{
json: {
newExecution: {},
originalExecution: {},
},
},
],
},
connections: {
'When called by a test run': {
[NodeConnectionType.Main]: [
main: [
[
{
node: 'Replace me',
@ -157,7 +150,7 @@ export const SAMPLE_EVALUATION_WORKFLOW: IWorkflowDataCreate = {
],
},
'Replace me': {
[NodeConnectionType.Main]: [
main: [
[
{
node: 'Return metric(s)',
@ -168,6 +161,16 @@ export const SAMPLE_EVALUATION_WORKFLOW: IWorkflowDataCreate = {
],
},
},
pinData: {
'When called by a test run': [
{
json: {
newExecution: {},
originalExecution: {},
},
},
],
},
settings: {
executionOrder: 'v1',
},

View file

@ -2817,14 +2817,14 @@
"testDefinition.edit.namePlaceholder": "Enter test name",
"testDefinition.edit.metricsTitle": "Metrics",
"testDefinition.edit.metricsHelpText": "The output field of the last node in the evaluation workflow. Metrics will be averaged across all test cases.",
"testDefinition.edit.metricsFields": "Output field(s)",
"testDefinition.edit.metricsFields": "Output fields to use as metrics",
"testDefinition.edit.metricsPlaceholder": "Enter metric name",
"testDefinition.edit.metricsNew": "New metric",
"testDefinition.edit.selectTag": "Select tag...",
"testDefinition.edit.tagsHelpText": "Executions with this tag will be added as test cases to this test.",
"testDefinition.edit.workflowSelectorLabel": "Workflow to make comparisons",
"testDefinition.edit.workflowSelectorLabel": "Use a second workflow to make the comparison",
"testDefinition.edit.workflowSelectorDisplayName": "Workflow",
"testDefinition.edit.workflowSelectorTitle": "Workflow to make comparisons",
"testDefinition.edit.workflowSelectorTitle": "Use a second workflow to make the comparison",
"testDefinition.edit.workflowSelectorHelpText": "This workflow will be called once for each test case.",
"testDefinition.edit.updateTest": "Update test",
"testDefinition.edit.saveTest": "Save test",
@ -2837,34 +2837,46 @@
"testDefinition.edit.pinNodes.noNodes.description": "Your workflow needs to have at least one node to run a test",
"testDefinition.edit.tagName": "Tag name",
"testDefinition.edit.step.intro": "When running a test",
"testDefinition.edit.step.executions": "1. Fetch N past executions tagged | 1. Fetch {count} past execution tagged | 1. 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.executions": "1. Fetch benchmark executions | 1. Fetch {count} benchmark execution | 1. Fetch {count} benchmark executions",
"testDefinition.edit.step.tag": "Any past executions tagged {tag} are fetched",
"testDefinition.edit.step.tag.placeholder": "Enter new tag name",
"testDefinition.edit.step.tag.validation.required": "Tag name is required",
"testDefinition.edit.step.tag.validation.tooLong": "Tag name is too long",
"testDefinition.edit.step.executions.tooltip": "Past executions are used as benchmark data. Each one will be re-executed during the test to check whether performance has changed.",
"testDefinition.edit.step.mockedNodes": "2. Mock nodes |2. Mock {count} node |2. Mock {count} nodes",
"testDefinition.edit.step.nodes.tooltip": "Mocked nodes have their data replayed rather than being re-executed. Do this to avoid calling external services, or save time executing.",
"testDefinition.edit.step.reRunExecutions": "3. Re-run executions",
"testDefinition.edit.step.reRunExecutions.tooltip": "Each past execution is re-run using the latest version of the workflow being tested",
"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.compareExecutions.tooltip": "Each past execution is compared with its new equivalent to check how similar they are. This is done using a separate evaluation workflow: it receives the two execution versions as input, and outputs metrics.",
"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.metrics.tooltip": "Metrics 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.metrics.description": "The names of fields output by your evaluation workflow in the step above.",
"testDefinition.edit.step.collapse": "Collapse",
"testDefinition.edit.step.expand": "Expand",
"testDefinition.edit.selectNodes": "Select nodes",
"testDefinition.edit.step.configure": "Configure",
"testDefinition.edit.selectNodes": "Select nodes to mock",
"testDefinition.edit.modal.description": "Choose which past data to keep when re-running the execution(s). Any mocked node will be replayed rather than re-executed. The trigger is always mocked.",
"testDefinition.edit.runExecution": "Run execution",
"testDefinition.edit.pastRuns": "Past runs",
"testDefinition.edit.pastRuns.total": "No runs | {count} run | {count} runs",
"testDefinition.edit.nodesPinning.pinButtonTooltip": "Pin execution data of this node during test run",
"testDefinition.edit.pastRuns.total": "No runs | Past run ({count}) | Past runs ({count})",
"testDefinition.edit.nodesPinning.pinButtonTooltip": "Use benchmark data for this node during evaluation execution",
"testDefinition.edit.saving": "Saving...",
"testDefinition.edit.saved": "Changes saved",
"testDefinition.list.testDeleted": "Test deleted",
"testDefinition.list.tests": "Tests",
"testDefinition.list.evaluations": "Evaluations",
"testDefinition.list.unitTests.title": "Unit tests",
"testDefinition.list.unitTests.description": "Test sections of your workflow in isolation",
"testDefinition.list.evaluations": "Evaluation",
"testDefinition.list.unitTests.badge": "Coming soon",
"testDefinition.list.unitTests.title": "Unit test",
"testDefinition.list.unitTests.description": "Validate workflow logic by checking for specific conditions",
"testDefinition.list.unitTests.cta": "Register interest",
"testDefinition.list.createNew": "Create new test",
"testDefinition.list.actionDescription": "Replay past executions to check whether performance has changed",
"testDefinition.list.createNew": "Create new evaluation",
"testDefinition.list.runAll": "Run all evaluations",
"testDefinition.list.actionDescription": "Measure changes in output by comparing results over time (for AI workflows)",
"testDefinition.list.actionButton": "Create an Evaluation",
"testDefinition.list.actionButton.unregistered": "Unlock evaluation",
"testDefinition.list.actionDescription.registered": "Your plan allows one evaluation",
"testDefinition.list.actionDescription.unregistered": "Unlock a free test when you register",
"testDefinition.list.actionDescription.atLimit": "You've reached your evaluation limit, upgrade to add more",
"testDefinition.list.testRuns": "No test runs | {count} test run | {count} test runs",
"testDefinition.list.lastRun": "Ran",
"testDefinition.list.running": "Running",
@ -2873,6 +2885,8 @@
"testDefinition.list.testStarted": "Test run started",
"testDefinition.list.testCancelled": "Test run cancelled",
"testDefinition.list.loadError": "Failed to load tests",
"testDefinition.list.item.tests": "No test cases | {count} test case | {count} test cases",
"testDefinition.list.item.missingFields": "No fields missing | {count} field missing| {count} fields missing",
"testDefinition.listRuns.status.new": "New",
"testDefinition.listRuns.status.running": "Running",
"testDefinition.listRuns.status.evaluating": "Evaluating",
@ -2883,7 +2897,7 @@
"testDefinition.listRuns.status.warning": "Warning",
"testDefinition.listRuns.metricsOverTime": "Metrics over time",
"testDefinition.listRuns.status": "Status",
"testDefinition.listRuns.runNumber": "Run #",
"testDefinition.listRuns.runNumber": "Run",
"testDefinition.listRuns.runDate": "Run date",
"testDefinition.listRuns.runStatus": "Run status",
"testDefinition.listRuns.noRuns": "No test runs",
@ -2891,19 +2905,27 @@
"testDefinition.listRuns.deleteRuns": "No runs to delete | Delete {count} run | Delete {count} runs",
"testDefinition.listRuns.noRuns.button": "Run Test",
"testDefinition.listRuns.error.noPastExecutions": "No executions added to the specified tag",
"testDefinition.listRuns.error.evaluationWorkflowNotFound": "Selected evaluation workflow does not exist. <br /><a href=\"{url}\">Fix test configuration</a>.",
"testDefinition.listRuns.error.evaluationWorkflowNotFound": "Selected evaluation workflow does not exist. {link}.",
"testDefinition.listRuns.error.evaluationWorkflowNotFound.solution": "Fix test configuration",
"testDefinition.runDetail.ranAt": "Ran at",
"testDefinition.runDetail.testCase": "Test case",
"testDefinition.runDetail.testCase.id": "Test case ID",
"testDefinition.runDetail.testCase.status": "Test case status",
"testDefinition.runDetail.totalCases": "Total cases",
"testDefinition.runDetail.error.mockedNodeMissing": "Output for a mocked node does not exist in benchmark execution. <br /><a href=\"{url}\">Fix test configuration</a>.",
"testDefinition.runDetail.error.executionFailed": "Failed to execute workflow with benchmark trigger. <br /><a href=\"{url}\" target=\"_blank\">View execution</a>.",
"testDefinition.runDetail.error.evaluationFailed": "Failed to execute the evaluation workflow. <br /><a href=\"{url}\" target=\"_blank\">View evaluation execution</a>.",
"testDefinition.runDetail.error.triggerNoLongerExists": "Trigger in benchmark execution no longer exists in workflow. <br /><a href=\"{url}\" target=\"_blank\">View benchmark</a>.",
"testDefinition.runDetail.error.metricsMissing": "Metrics defined in test were not returned by evaluation workflow. <br /><a href=\"{url}\">Fix test configuration</a>.",
"testDefinition.runDetail.error.unknownMetrics": "Evaluation workflow defined metrics that are not defined in the test. <br /><a href=\"{url}\">Fix test configuration</a>.",
"testDefinition.runDetail.error.invalidMetrics": "Evaluation workflow returned invalid metrics. Only numeric values are expected. View evaluation execution. <br /><a href=\"{url}\" target=\"_blank\">View evaluation execution</a>.",
"testDefinition.runDetail.error.mockedNodeMissing": "Output for a mocked node does not exist in benchmark execution.{link}.",
"testDefinition.runDetail.error.mockedNodeMissing.solution": "Fix test configuration",
"testDefinition.runDetail.error.executionFailed": "Failed to execute workflow with benchmark trigger. {link}.",
"testDefinition.runDetail.error.executionFailed.solution": "View execution",
"testDefinition.runDetail.error.evaluationFailed": "Failed to execute the evaluation workflow. {link}.",
"testDefinition.runDetail.error.evaluationFailed.solution": "View evaluation execution",
"testDefinition.runDetail.error.triggerNoLongerExists": "Trigger in benchmark execution no longer exists in workflow.{link}.",
"testDefinition.runDetail.error.triggerNoLongerExists.solution": "View benchmark",
"testDefinition.runDetail.error.metricsMissing": "Metrics defined in test were not returned by evaluation workflow {link}.",
"testDefinition.runDetail.error.metricsMissing.solution": "Fix test configuration",
"testDefinition.runDetail.error.unknownMetrics": "Evaluation workflow defined metrics that are not defined in the test. {link}.",
"testDefinition.runDetail.error.unknownMetrics.solution": "Fix test configuration",
"testDefinition.runDetail.error.invalidMetrics": "Evaluation workflow returned invalid metrics. Only numeric values are expected. View evaluation execution. {link}.",
"testDefinition.runDetail.error.invalidMetrics.solution": "View evaluation execution",
"testDefinition.runTest": "Run Test",
"testDefinition.cancelTestRun": "Cancel Test Run",
"testDefinition.notImplemented": "This feature is not implemented yet!",
@ -2919,6 +2941,14 @@
"testDefinition.configError.noMetrics": "No metrics set",
"testDefinition.workflowInput.subworkflowName": "Evaluation workflow for {name}",
"testDefinition.workflowInput.subworkflowName.default": "My Evaluation Sub-Workflow",
"testDefinition.executions.addTo": "Add to Test",
"testDefinition.executions.addTo.new": "Add to Test",
"testDefinition.executions.addTo.existing": "Add to \"{name}\"",
"testDefinition.executions.addedTo": "Added to \"{name}\"",
"testDefinition.executions.toast.addedTo": "1 past execution added as a test case to \"{name}\"",
"testDefinition.workflow.createNew": "Create new evaluation workflow",
"testDefinition.workflow.createNew.or": "or use existing evaluation sub-workflow",
"testDefinition.executions.toast.addedTo.title": "Added to test",
"freeAi.credits.callout.claim.title": "Get {credits} free OpenAI API credits",
"freeAi.credits.callout.claim.button.label": "Claim credits",
"freeAi.credits.callout.success.title.part1": "Claimed {credits} free OpenAI API credits! Please note these free credits are only for the following models:",

View file

@ -29,6 +29,7 @@ import {
faCheckSquare,
faChevronDown,
faChevronUp,
faCircle,
faChevronLeft,
faChevronRight,
faCode,
@ -166,6 +167,8 @@ import {
faPowerOff,
faPaperPlane,
faExclamationCircle,
faMinusCircle,
faAdjust,
} from '@fortawesome/free-solid-svg-icons';
import { faVariable, faXmark, faVault, faRefresh } from './custom';
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
@ -206,6 +209,7 @@ export const FontAwesomePlugin: Plugin = {
addIcon(faChevronRight);
addIcon(faChevronDown);
addIcon(faChevronUp);
addIcon(faCircle);
addIcon(faCode);
addIcon(faCodeBranch);
addIcon(faCog);
@ -346,6 +350,8 @@ export const FontAwesomePlugin: Plugin = {
addIcon(faPowerOff);
addIcon(faPaperPlane);
addIcon(faRefresh);
addIcon(faMinusCircle);
addIcon(faAdjust);
app.component('FontAwesomeIcon', FontAwesomeIcon);
},

View file

@ -11,6 +11,7 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useTemplatesStore } from '@/stores/templates.store';
import { useUIStore } from '@/stores/ui.store';
import { useSSOStore } from '@/stores/sso.store';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { EnterpriseEditionFeature, VIEWS, EDITABLE_CANVAS_VIEWS } from '@/constants';
import { useTelemetry } from '@/composables/useTelemetry';
import { middleware } from '@/utils/rbac/middleware';
@ -18,7 +19,6 @@ import type { RouterMiddleware } from '@/types/router';
import { initializeAuthenticatedFeatures, initializeCore } from '@/init';
import { tryToParseNumber } from '@/utils/typesUtils';
import { projectsRoutes } from '@/routes/projects.routes';
import TestDefinitionRunsListView from './views/TestDefinition/TestDefinitionRunsListView.vue';
import TestDefinitionRunDetailView from './views/TestDefinition/TestDefinitionRunDetailView.vue';
const ChangePasswordView = async () => await import('./views/ChangePasswordView.vue');
@ -61,6 +61,8 @@ const WorkflowHistory = async () => await import('@/views/WorkflowHistory.vue');
const WorkflowOnboardingView = async () => await import('@/views/WorkflowOnboardingView.vue');
const TestDefinitionListView = async () =>
await import('./views/TestDefinition/TestDefinitionListView.vue');
const TestDefinitionNewView = async () =>
await import('./views/TestDefinition/TestDefinitionNewView.vue');
const TestDefinitionEditView = async () =>
await import('./views/TestDefinition/TestDefinitionEditView.vue');
const TestDefinitionRootView = async () =>
@ -264,65 +266,42 @@ export const routes: RouteRecordRaw[] = [
header: MainHeader,
sidebar: MainSidebar,
},
props: true,
meta: {
keepWorkflowAlive: true,
middleware: ['authenticated'],
middleware: ['authenticated', 'custom'],
middlewareOptions: {
custom: () => useTestDefinitionStore().isFeatureEnabled,
},
},
children: [
{
path: '',
name: VIEWS.TEST_DEFINITION,
components: {
default: TestDefinitionListView,
},
meta: {
keepWorkflowAlive: true,
middleware: ['authenticated'],
},
component: TestDefinitionListView,
props: true,
},
{
path: 'new',
name: VIEWS.NEW_TEST_DEFINITION,
components: {
default: TestDefinitionEditView,
},
meta: {
keepWorkflowAlive: true,
middleware: ['authenticated'],
},
component: TestDefinitionNewView,
props: true,
},
{
path: ':testId',
name: VIEWS.TEST_DEFINITION_EDIT,
props: true,
components: {
default: TestDefinitionEditView,
},
meta: {
keepWorkflowAlive: true,
middleware: ['authenticated'],
},
},
{
path: ':testId/runs',
name: VIEWS.TEST_DEFINITION_RUNS,
components: {
default: TestDefinitionRunsListView,
},
meta: {
keepWorkflowAlive: true,
middleware: ['authenticated'],
},
},
{
path: ':testId/runs/:runId',
name: VIEWS.TEST_DEFINITION_RUNS_DETAIL,
props: true,
components: {
default: TestDefinitionRunDetailView,
},
meta: {
keepWorkflowAlive: true,
middleware: ['authenticated'],
},
},
],
},

View file

@ -88,9 +88,22 @@ const createTagsStore = (id: STORES.TAGS | STORES.ANNOTATION_TAGS) => {
return retrievedTags;
};
const create = async (name: string) => {
const createdTag = await tagsApi.createTag(rootStore.restApiContext, { name });
const create = async (
name: string,
{ incrementExisting }: { incrementExisting?: boolean } = {},
) => {
let tagName = name;
if (incrementExisting) {
const tagNameRegex = new RegExp(tagName);
const existingTags = allTags.value.filter((tag) => tagNameRegex.test(tag.name));
if (existingTags.length > 0) {
tagName = `${tagName} (${existingTags.length + 1})`;
}
}
const createdTag = await tagsApi.createTag(rootStore.restApiContext, { name: tagName });
upsertTags([createdTag]);
return createdTag;
};

View file

@ -60,24 +60,28 @@ const TEST_DEF_A: TestDefinitionRecord = {
name: 'Test Definition A',
workflowId: '123',
description: 'Description A',
createdAt: '2023-01-01T00:00:00.000Z',
};
const TEST_DEF_B: TestDefinitionRecord = {
id: '2',
name: 'Test Definition B',
workflowId: '123',
description: 'Description B',
createdAt: '2023-01-01T00:00:00.000Z',
};
const TEST_DEF_NEW: TestDefinitionRecord = {
id: '3',
name: 'New Test Definition',
workflowId: '123',
description: 'New Description',
createdAt: '2023-01-01T00:00:00.000Z',
};
const TEST_METRIC = {
id: 'metric1',
name: 'Test Metric',
testDefinitionId: '1',
createdAt: '2023-01-01T00:00:00.000Z',
};
const TEST_RUN: TestRunRecord = {
@ -89,6 +93,9 @@ const TEST_RUN: TestRunRecord = {
updatedAt: '2024-01-01',
runAt: '2024-01-01',
completedAt: '2024-01-01',
failedCases: 0,
totalCases: 1,
passedCases: 1,
};
describe('testDefinition.store.ee', () => {
@ -140,10 +147,7 @@ describe('testDefinition.store.ee', () => {
'2': TEST_DEF_B,
});
expect(store.isLoading).toBe(false);
expect(result).toEqual({
count: 2,
testDefinitions: [TEST_DEF_A, TEST_DEF_B],
});
expect(result).toEqual([TEST_DEF_A, TEST_DEF_B]);
});
test('Fetching Test Definitions with force flag', async () => {
@ -159,10 +163,7 @@ describe('testDefinition.store.ee', () => {
'2': TEST_DEF_B,
});
expect(store.isLoading).toBe(false);
expect(result).toEqual({
count: 2,
testDefinitions: [TEST_DEF_A, TEST_DEF_B],
});
expect(result).toEqual([TEST_DEF_A, TEST_DEF_B]);
});
test('Fetching Test Definitions when already fetched', async () => {
@ -198,6 +199,7 @@ describe('testDefinition.store.ee', () => {
name: 'Updated Test Definition A',
description: 'Updated Description A',
workflowId: '123',
createdAt: '2023-01-01T00:00:00.000Z',
};
store.upsertTestDefinitions([updatedDefinition]);
@ -246,7 +248,7 @@ describe('testDefinition.store.ee', () => {
workflowId: '123',
});
expect(store.testDefinitionsById).toEqual({
'1': params,
'1': { ...TEST_DEF_A, ...params },
'2': TEST_DEF_B,
});
expect(result).toEqual(params);

View file

@ -172,6 +172,15 @@ export const useTestDefinitionStore = defineStore(
return testDefinition;
};
const fetchTestDefinitionsByWorkflowId = async (workflowId: string) => {
const testDefinitions = await testDefinitionsApi.getTestDefinitions(
rootStore.restApiContext,
{ workflowId },
);
setAllTestDefinitions(testDefinitions.testDefinitions);
return testDefinitions.testDefinitions;
};
const fetchTestCaseExecutions = async (params: { testDefinitionId: string; runId: string }) => {
const testCaseExecutions = await testDefinitionsApi.getTestCaseExecutions(
rootStore.restApiContext,
@ -202,16 +211,15 @@ export const useTestDefinitionStore = defineStore(
loading.value = true;
try {
const retrievedDefinitions = await testDefinitionsApi.getTestDefinitions(
rootStore.restApiContext,
{ workflowId },
);
if (!workflowId) {
return;
}
setAllTestDefinitions(retrievedDefinitions.testDefinitions);
const retrievedDefinitions = await fetchTestDefinitionsByWorkflowId(workflowId);
fetchedAll.value = true;
await Promise.all([
tagsStore.fetchAll({ withUsageCount: true }),
tagsStore.fetchAll({ force: true, withUsageCount: true }),
fetchRunsForAllTests(),
fetchMetricsForAllTests(),
]);
@ -463,6 +471,7 @@ export const useTestDefinitionStore = defineStore(
// Methods
fetchTestDefinition,
fetchTestDefinitionsByWorkflowId,
fetchTestCaseExecutions,
fetchAll,
fetchExampleEvaluationInput,

View file

@ -1,139 +1,98 @@
<script setup lang="ts">
import { computed, onMounted, watch, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { NODE_PINNING_MODAL_KEY, VIEWS } from '@/constants';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@/composables/useI18n';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import { useDebounce } from '@/composables/useDebounce';
import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm';
import HeaderSection from '@/components/TestDefinition/EditDefinition/sections/HeaderSection.vue';
import RunsSection from '@/components/TestDefinition/EditDefinition/sections/RunsSection.vue';
import type { TestMetricRecord, TestRunRecord } from '@/api/testDefinition.ee';
import { useUIStore } from '@/stores/ui.store';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import ConfigSection from '@/components/TestDefinition/EditDefinition/sections/ConfigSection.vue';
import { useDebounce } from '@/composables/useDebounce';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useRootStore } from '@/stores/root.store';
import { useToast } from '@/composables/useToast';
import { NODE_PINNING_MODAL_KEY, VIEWS } from '@/constants';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { TestMetricRecord, TestRunRecord } from '@/api/testDefinition.ee';
import InlineNameEdit from '@/components/InlineNameEdit.vue';
import ConfigSection from '@/components/TestDefinition/EditDefinition/sections/ConfigSection.vue';
import RunsSection from '@/components/TestDefinition/EditDefinition/sections/RunsSection.vue';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useDocumentVisibility } from '@vueuse/core';
import { N8nButton, N8nIconButton, N8nText } from 'n8n-design-system';
import type { IDataObject, IPinData } from 'n8n-workflow';
const props = defineProps<{
testId?: string;
testId: string;
name: string;
}>();
const router = useRouter();
const route = useRoute();
const locale = useI18n();
const { debounce } = useDebounce();
const toast = useToast();
const testDefinitionStore = useTestDefinitionStore();
const tagsStore = useAnnotationTagsStore();
const uiStore = useUIStore();
const telemetry = useTelemetry();
const workflowStore = useWorkflowsStore();
const telemetry = useTelemetry();
const visibility = useDocumentVisibility();
watch(visibility, async () => {
if (visibility.value !== 'visible') return;
await tagsStore.fetchAll({ force: true, withUsageCount: true });
await getExamplePinnedDataForTags();
testDefinitionStore.updateRunFieldIssues(props.testId);
});
const {
state,
isSaving,
cancelEditing,
loadTestData,
createTest,
updateTest,
startEditing,
saveChanges,
handleKeydown,
deleteMetric,
updateMetrics,
} = useTestDefinitionForm();
const isLoading = computed(() => tagsStore.isLoading);
const allTags = computed(() => tagsStore.allTags);
const tagsById = computed(() => tagsStore.tagsById);
const testId = computed(() => props.testId ?? (route.params.testId as string));
const currentWorkflowId = computed(() => route.params.name as string);
const currentWorkflowId = computed(() => props.name);
const appliedTheme = computed(() => uiStore.appliedTheme);
const tagUsageCount = computed(
() => tagsStore.tagsById[state.value.tags.value[0]]?.usageCount ?? 0,
);
const workflowName = computed(() => workflowStore.workflow.name);
const hasRuns = computed(() => runs.value.length > 0);
const fieldsIssues = computed(() => testDefinitionStore.getFieldIssues(testId.value) ?? []);
const fieldsIssues = computed(() => testDefinitionStore.getFieldIssues(props.testId) ?? []);
const showConfig = ref(true);
const selectedMetric = ref<string>('');
const examplePinnedData = ref<IPinData>({});
onMounted(async () => {
if (!testDefinitionStore.isFeatureEnabled) {
toast.showMessage({
title: locale.baseText('testDefinition.notImplemented'),
type: 'warning',
});
void loadTestData(props.testId, props.name);
void router.push({
name: VIEWS.WORKFLOW,
params: { name: router.currentRoute.value.params.name },
});
return; // Add early return to prevent loading if feature is disabled
}
if (testId.value) {
await loadTestData(testId.value);
} else {
await onSaveTest();
}
});
async function onSaveTest() {
const handleUpdateTest = async () => {
try {
let savedTest;
if (testId.value) {
savedTest = await updateTest(testId.value);
} else {
savedTest = await createTest(currentWorkflowId.value);
}
if (savedTest && route.name === VIEWS.NEW_TEST_DEFINITION) {
await router.replace({
name: VIEWS.TEST_DEFINITION_EDIT,
params: { testId: savedTest.id },
});
telemetry.track(
'User created test',
{
test_id: savedTest.id,
workflow_id: currentWorkflowId.value,
session_id: useRootStore().pushRef,
},
{
withPostHog: true,
},
);
}
await updateTest(props.testId);
} catch (e: unknown) {
toast.showError(e, locale.baseText('testDefinition.edit.testSaveFailed'));
}
}
};
const handleUpdateTestDebounced = debounce(handleUpdateTest, { debounceTime: 400, trailing: true });
const handleUpdateMetricsDebounced = debounce(
async (testId: string) => {
await updateMetrics(testId);
testDefinitionStore.updateRunFieldIssues(testId);
},
{ debounceTime: 400, trailing: true },
);
function getFieldIssues(key: string) {
return fieldsIssues.value.filter((issue) => issue.field === key);
}
async function onDeleteMetric(deletedMetric: Partial<TestMetricRecord>) {
if (deletedMetric.id) {
await deleteMetric(deletedMetric.id, testId.value);
}
}
async function handleCreateTag(tagName: string) {
try {
const newTag = await tagsStore.create(tagName);
return newTag;
} catch (error) {
toast.showError(error, 'Error', error.message);
throw error;
}
async function onDeleteMetric(deletedMetric: TestMetricRecord) {
await deleteMetric(deletedMetric.id, props.testId);
}
async function openPinningModal() {
@ -141,13 +100,23 @@ async function openPinningModal() {
}
async function runTest() {
await testDefinitionStore.startTestRun(testId.value);
await testDefinitionStore.fetchTestRuns(testId.value);
await testDefinitionStore.startTestRun(props.testId);
await testDefinitionStore.fetchTestRuns(props.testId);
}
async function openExecutionsViewForTag() {
const executionsRoute = router.resolve({
name: VIEWS.WORKFLOW_EXECUTIONS,
params: { name: currentWorkflowId.value },
query: { tag: state.value.tags.value[0], testId: props.testId },
});
window.open(executionsRoute.href, '_blank');
}
const runs = computed(() =>
Object.values(testDefinitionStore.testRunsById ?? {}).filter(
(run) => run.testDefinitionId === testId.value,
(run) => run.testDefinitionId === props.testId,
),
);
@ -157,18 +126,18 @@ const isRunTestEnabled = computed(() => fieldsIssues.value.length === 0 && !isRu
async function onDeleteRuns(toDelete: TestRunRecord[]) {
await Promise.all(
toDelete.map(async (run) => {
await testDefinitionStore.deleteTestRun({ testDefinitionId: testId.value, runId: run.id });
await testDefinitionStore.deleteTestRun({ testDefinitionId: props.testId, runId: run.id });
}),
);
}
function toggleConfig() {
showConfig.value = !showConfig.value;
async function renameTag(newName: string) {
await tagsStore.rename({ id: state.value.tags.value[0], name: newName });
}
async function getExamplePinnedDataForTags() {
const exampleInput = await testDefinitionStore.fetchExampleEvaluationInput(
testId.value,
props.testId,
state.value.tags.value[0],
);
@ -183,52 +152,64 @@ async function getExamplePinnedDataForTags() {
}
}
// Debounced watchers for auto-saving
watch(
() => state.value.metrics,
debounce(async () => await updateMetrics(testId.value), { debounceTime: 400, trailing: true }),
{ deep: true },
);
watch(() => state.value.tags.value, getExamplePinnedDataForTags);
watch(
() => [
state.value.description,
state.value.name,
state.value.tags,
state.value.evaluationWorkflow,
state.value.mockedNodes,
],
debounce(onSaveTest, { debounceTime: 400, trailing: true }),
{ deep: true },
);
watch(() => state.value.tags, getExamplePinnedDataForTags);
const updateName = (value: string) => {
state.value.name.value = value;
void handleUpdateTestDebounced();
};
const updateDescription = (value: string) => {
state.value.description.value = value;
void handleUpdateTestDebounced();
};
function onEvaluationWorkflowCreated(workflowId: string) {
telemetry.track('User created evaluation workflow from test', {
test_id: testId.value,
test_id: props.testId,
subworkflow_id: workflowId,
});
}
</script>
<template>
<div :class="[$style.container, { [$style.noRuns]: !hasRuns }]">
<HeaderSection
v-model:name="state.name"
v-model:description="state.description"
v-model:tags="state.tags"
:has-runs="hasRuns"
:is-saving="isSaving"
:get-field-issues="getFieldIssues"
:start-editing="startEditing"
:save-changes="saveChanges"
:handle-keydown="handleKeydown"
:on-save-test="onSaveTest"
:run-test="runTest"
:show-config="showConfig"
:toggle-config="toggleConfig"
:run-test-enabled="isRunTestEnabled"
<div v-if="!isLoading" :class="[$style.container, { [$style.noRuns]: !hasRuns }]">
<div :class="$style.header">
<div style="display: flex; align-items: center">
<N8nIconButton
icon="arrow-left"
type="tertiary"
:class="$style.arrowBack"
@click="router.push({ name: VIEWS.TEST_DEFINITION, params: { testId } })"
></N8nIconButton>
<InlineNameEdit
:model-value="state.name.value"
max-height="none"
type="Test name"
@update:model-value="updateName"
>
<template #runTestTooltip>
<N8nText bold size="xlarge" color="text-dark">{{ state.name.value }}</N8nText>
</InlineNameEdit>
</div>
<div style="display: flex; align-items: center; gap: 10px">
<N8nText v-if="hasRuns" color="text-light" size="small">
{{
isSaving
? locale.baseText('testDefinition.edit.saving')
: locale.baseText('testDefinition.edit.saved')
}}
</N8nText>
<N8nTooltip :disabled="isRunTestEnabled" :placement="'left'">
<N8nButton
:disabled="!isRunTestEnabled"
:class="$style.runTestButton"
size="small"
data-test-id="run-test-button"
:label="locale.baseText('testDefinition.runTest')"
type="primary"
@click="runTest"
/>
<template #content>
<template v-if="fieldsIssues.length > 0">
<div>{{ locale.baseText('testDefinition.completeConfig') }}</div>
<div v-for="issue in fieldsIssues" :key="issue.field">- {{ issue.message }}</div>
@ -237,7 +218,26 @@ function onEvaluationWorkflowCreated(workflowId: string) {
{{ locale.baseText('testDefinition.testIsRunning') }}
</template>
</template>
</HeaderSection>
</N8nTooltip>
</div>
</div>
<div :class="$style.wrapper">
<div :class="$style.description">
<InlineNameEdit
:model-value="state.description.value"
placeholder="Add a description..."
:required="false"
:autosize="{ minRows: 1, maxRows: 3 }"
input-type="textarea"
:maxlength="260"
max-height="none"
type="Test description"
:class="$style.editDescription"
@update:model-value="updateDescription"
>
<N8nText size="small" color="text-base">{{ state.description.value }}</N8nText>
</InlineNameEdit>
</div>
<div :class="$style.content">
<RunsSection
@ -256,49 +256,62 @@ function onEvaluationWorkflowCreated(workflowId: string) {
v-model:mockedNodes="state.mockedNodes"
:cancel-editing="cancelEditing"
:show-config="showConfig"
:tag-usage-count="tagUsageCount"
:all-tags="allTags"
:tags-by-id="tagsById"
:is-loading="isLoading"
:get-field-issues="getFieldIssues"
:start-editing="startEditing"
:save-changes="saveChanges"
:create-tag="handleCreateTag"
:has-runs="hasRuns"
:example-pinned-data="examplePinnedData"
:sample-workflow-name="workflowName"
@rename-tag="renameTag"
@update:metrics="() => handleUpdateMetricsDebounced(testId)"
@update:evaluation-workflow="handleUpdateTestDebounced"
@update:mocked-nodes="handleUpdateTestDebounced"
@open-pinning-modal="openPinningModal"
@delete-metric="onDeleteMetric"
@open-executions-view-for-tag="openExecutionsViewForTag"
@evaluation-workflow-created="onEvaluationWorkflowCreated($event)"
/>
</div>
</div>
</div>
</template>
<style module lang="scss">
.container {
--evaluation-edit-panel-width: 24rem;
--metrics-chart-height: 10rem;
height: 100%;
display: flex;
flex-direction: column;
@media (min-height: 56rem) {
--metrics-chart-height: 16rem;
}
@include mixins.breakpoint('lg-and-up') {
--evaluation-edit-panel-width: 30rem;
}
}
.content {
display: flex;
overflow-y: hidden;
position: relative;
.noRuns & {
justify-content: center;
overflow-y: auto;
gap: var(--spacing-m);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-m) var(--spacing-l);
padding-left: 27px;
padding-bottom: 8px;
position: sticky;
top: 0;
left: 0;
background-color: var(--color-background-light);
z-index: 2;
}
.wrapper {
padding: 0 var(--spacing-l);
padding-left: 58px;
}
.description {
max-width: 600px;
margin-bottom: 20px;
}
.arrowBack {
--button-hover-background-color: transparent;
border: 0;
}
</style>

View file

@ -1,134 +1,86 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import EmptyState from '@/components/TestDefinition/ListDefinition/EmptyState.vue';
import TestItem from '@/components/TestDefinition/ListDefinition/TestItem.vue';
import { useI18n } from '@/composables/useI18n';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { MODAL_CONFIRM, VIEWS } from '@/constants';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@/composables/useI18n';
import EmptyState from '@/components/TestDefinition/ListDefinition/EmptyState.vue';
import TestsList from '@/components/TestDefinition/ListDefinition/TestsList.vue';
import type {
TestExecution,
TestItemAction,
TestListItem,
} from '@/components/TestDefinition/types';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
import { useMessage } from '@/composables/useMessage';
import { useAsyncState } from '@vueuse/core';
import { orderBy } from 'lodash-es';
import {
N8nActionToggle,
N8nButton,
N8nHeading,
N8nIconButton,
N8nLoading,
N8nTooltip,
} from 'n8n-design-system';
import { computed, h } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
const props = defineProps<{
name: string;
}>();
const router = useRouter();
const tagsStore = useAnnotationTagsStore();
const testDefinitionStore = useTestDefinitionStore();
const isLoading = ref(false);
const toast = useToast();
const locale = useI18n();
const { confirm } = useMessage();
const actions = computed<TestItemAction[]>(() => [
const { state: tests, isLoading } = useAsyncState(
async () => {
await testDefinitionStore.fetchAll({ workflowId: props.name });
const response = testDefinitionStore.allTestDefinitionsByWorkflowId[props.name] ?? [];
response.forEach((test) => testDefinitionStore.updateRunFieldIssues(test.id));
return response;
},
[],
{ onError: (error) => toast.showError(error, locale.baseText('testDefinition.list.loadError')) },
);
const listItems = computed(() =>
orderBy(tests.value, [(test) => new Date(test.updatedAt ?? test.createdAt)], ['desc']).map(
(test) => ({
...test,
testCases: (testDefinitionStore.testRunsByTestId[test.id] || []).length,
lastExecution: testDefinitionStore.lastRunByTestId[test.id] ?? undefined,
isTestRunning: isTestRunning(test.id),
setupErrors: testDefinitionStore.getFieldIssues(test.id) ?? [],
}),
),
);
const commands = {
delete: onDeleteTest,
edit: onEditTest,
} as const;
type Action = { label: string; value: keyof typeof commands; disabled: boolean };
const actions = computed<Action[]>(() => [
{
icon: 'play',
id: 'run',
event: onRunTest,
disabled: isRunDisabled,
show: (testId) => !isTestRunning(testId),
tooltip: (testId) =>
isRunDisabled(testId)
? getDisabledRunTooltip(testId)
: locale.baseText('testDefinition.runTest'),
label: 'Edit test',
value: 'edit',
disabled: false,
},
{
icon: 'stop',
id: 'cancel',
event: onCancelTestRun,
tooltip: () => locale.baseText('testDefinition.cancelTestRun'),
show: (testId) => isTestRunning(testId),
},
{
icon: 'list',
id: 'view',
event: onViewDetails,
tooltip: () => locale.baseText('testDefinition.viewDetails'),
},
{
icon: 'pen',
id: 'edit',
event: onEditTest,
tooltip: () => locale.baseText('testDefinition.editTest'),
},
{
icon: 'trash',
id: 'delete',
event: onDeleteTest,
tooltip: () => locale.baseText('testDefinition.deleteTest'),
label: 'Delete',
value: 'delete',
disabled: false,
},
]);
const tests = computed<TestListItem[]>(() => {
return (
testDefinitionStore.allTestDefinitionsByWorkflowId[
router.currentRoute.value.params.name as string
] ?? []
)
.filter((test): test is TestDefinitionRecord => test.id !== undefined)
.sort((a, b) => new Date(b?.updatedAt ?? '').getTime() - new Date(a?.updatedAt ?? '').getTime())
.map((test) => ({
id: test.id,
name: test.name ?? '',
tagName: test.annotationTagId ? getTagName(test.annotationTagId) : '',
testCases: testDefinitionStore.testRunsByTestId[test.id]?.length ?? 0,
execution: getTestExecution(test.id),
fieldsIssues: testDefinitionStore.getFieldIssues(test.id),
}));
});
const hasTests = computed(() => tests.value.length > 0);
const allTags = computed(() => tagsStore.allTags);
function getTagName(tagId: string) {
const matchingTag = allTags.value.find((t) => t.id === tagId);
return matchingTag?.name ?? '';
}
function getDisabledRunTooltip(testId: string) {
const issues = testDefinitionStore
.getFieldIssues(testId)
?.map((i) => i.message)
.join('<br />- ');
return `${locale.baseText('testDefinition.completeConfig')} <br /> - ${issues}`;
}
function getTestExecution(testId: string): TestExecution {
const lastRun = testDefinitionStore.lastRunByTestId[testId];
if (!lastRun) {
return {
id: null,
lastRun: null,
errorRate: 0,
metrics: {},
status: 'new',
};
}
const mockExecutions = {
id: lastRun.id,
lastRun: lastRun.updatedAt ?? '',
errorRate: 0,
metrics: lastRun.metrics ?? {},
status: lastRun.status ?? 'running',
};
return mockExecutions;
}
const handleAction = async (action: string, testId: string) =>
await commands[action as Action['value']](testId);
function isTestRunning(testId: string) {
return testDefinitionStore.lastRunByTestId[testId]?.status === 'running';
}
function isRunDisabled(testId: string) {
return testDefinitionStore.getFieldIssues(testId)?.length > 0;
}
// Action handlers
function onCreateTest() {
void router.push({ name: VIEWS.NEW_TEST_DEFINITION });
}
@ -140,6 +92,11 @@ async function onRunTest(testId: string) {
toast.showMessage({
title: locale.baseText('testDefinition.list.testStarted'),
type: 'success',
message: h(
RouterLink,
{ to: { name: VIEWS.TEST_DEFINITION_EDIT, params: { testId } } },
() => 'Go to runs',
),
});
// Optionally fetch the updated test runs
@ -177,10 +134,6 @@ async function onCancelTestRun(testId: string) {
}
}
async function onViewDetails(testId: string) {
void router.push({ name: VIEWS.TEST_DEFINITION_RUNS, params: { testId } });
}
function onEditTest(testId: string) {
void router.push({ name: VIEWS.TEST_DEFINITION_EDIT, params: { testId } });
}
@ -207,62 +160,91 @@ async function onDeleteTest(testId: string) {
type: 'success',
});
}
// Load initial data
async function loadInitialData() {
if (!isLoading.value) {
// Add guard to prevent multiple loading states
isLoading.value = true;
try {
await testDefinitionStore.fetchAll({
workflowId: router.currentRoute.value.params.name as string,
});
isLoading.value = false;
} catch (error) {
toast.showError(error, locale.baseText('testDefinition.list.loadError'));
} finally {
isLoading.value = false;
}
}
}
onMounted(async () => {
if (!testDefinitionStore.isFeatureEnabled) {
toast.showMessage({
title: locale.baseText('testDefinition.notImplemented'),
type: 'warning',
});
void router.push({
name: VIEWS.WORKFLOW,
params: { name: router.currentRoute.value.params.name },
});
return; // Add early return to prevent loading if feature is disabled
}
await loadInitialData();
tests.value.forEach((test) => testDefinitionStore.updateRunFieldIssues(test.id));
});
</script>
<template>
<div :class="$style.container">
<div v-if="isLoading" :class="$style.loading">
<n8n-loading :loading="true" :rows="3" />
</div>
<template v-else>
<N8nLoading v-if="isLoading" loading :rows="3" data-test-id="test-definition-loader" />
<EmptyState
v-if="!hasTests"
v-else-if="!listItems.length"
data-test-id="test-definition-empty-state"
@create-test="onCreateTest"
/>
<TestsList
v-else
:tests="tests"
:actions="actions"
@view-details="onViewDetails"
@create-test="onCreateTest"
<template v-else>
<div :class="$style.header">
<N8nHeading size="xlarge" color="text-dark" bold>
{{ locale.baseText('testDefinition.list.tests') }}
</N8nHeading>
<div>
<N8nButton
:label="locale.baseText('testDefinition.list.createNew')"
class="mr-xs"
@click="onCreateTest"
/>
<N8nButton
:label="locale.baseText('testDefinition.list.runAll')"
disabled
type="secondary"
/>
</div>
</div>
<div :class="$style.testList" data-test-id="test-definition-list">
<TestItem
v-for="item in listItems"
:key="item.id"
:name="item.name"
:test-cases="item.testCases"
:execution="item.lastExecution"
:errors="item.setupErrors"
:data-test-id="`test-item-${item.id}`"
@click="onEditTest(item.id)"
>
<template #prepend>
<div @click.stop>
<N8nTooltip v-if="item.isTestRunning" content="Cancel test run" placement="top">
<N8nIconButton
icon="stop"
type="secondary"
size="mini"
@click="onCancelTestRun(item.id)"
/>
</N8nTooltip>
<N8nTooltip
v-else
:disabled="!Boolean(item.setupErrors.length)"
placement="top"
teleported
>
<template #content>
<div>{{ locale.baseText('testDefinition.completeConfig') }}</div>
<div v-for="issue in item.setupErrors" :key="issue.field">
- {{ issue.message }}
</div>
</template>
<N8nIconButton
icon="play"
type="secondary"
size="mini"
:disabled="Boolean(item.setupErrors.length)"
:data-test-id="`run-test-${item.id}`"
@click="onRunTest(item.id)"
/>
</N8nTooltip>
</div>
</template>
<template #append>
<div @click.stop>
<N8nActionToggle
:actions="actions"
:data-test-id="`test-actions-${item.id}`"
icon-orientation="horizontal"
@action="(action) => handleAction(action, item.id)"
>
</N8nActionToggle>
</div>
</template>
</TestItem>
</div>
</template>
</div>
</template>
@ -272,6 +254,7 @@ onMounted(async () => {
width: 100%;
max-width: var(--content-container-width);
margin: auto;
padding: var(--spacing-xl) var(--spacing-l);
}
.loading {
display: flex;
@ -279,4 +262,18 @@ onMounted(async () => {
align-items: center;
height: 200px;
}
.testList {
display: flex;
flex-direction: column;
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-base);
// gap: 8px;
}
.header {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
</style>

View file

@ -0,0 +1,78 @@
<script lang="ts" setup>
import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { useToast } from '@/composables/useToast';
import { useTelemetry } from '@/composables/useTelemetry';
import { useRootStore } from '@/stores/root.store';
import { useRouter } from 'vue-router';
import { VIEWS } from '@/constants';
const props = defineProps<{
name: string;
}>();
const { state, createTest, updateTest } = useTestDefinitionForm();
const testDefinitionStore = useTestDefinitionStore();
const tagsStore = useAnnotationTagsStore();
const toast = useToast();
const telemetry = useTelemetry();
const router = useRouter();
function generateTagFromName(name: string): string {
let tag = name.toLowerCase().replace(/\s+/g, '_');
if (tag.length > 18) {
const start = tag.slice(0, 10);
const end = tag.slice(-8);
tag = `${start}..${end}`;
}
return tag;
}
async function createTag(tagName: string) {
try {
const newTag = await tagsStore.create(tagName, { incrementExisting: true });
return newTag;
} catch (error) {
toast.showError(error, 'Error', error.message);
throw error;
}
}
void createTest(props.name).then(async (test) => {
if (!test) {
// Fix ME
throw new Error('no test found');
}
const tag = generateTagFromName(state.value.name.value);
const testTag = await createTag(tag);
state.value.tags.value = [testTag.id];
await updateTest(test.id);
testDefinitionStore.updateRunFieldIssues(test.id);
telemetry.track(
'User created test',
{
test_id: test.id,
workflow_id: props.name,
session_id: useRootStore().pushRef,
},
{
withPostHog: true,
},
);
await router.replace({
name: VIEWS.TEST_DEFINITION_EDIT,
params: { testId: test.id },
});
});
</script>
<template>
<div>creating {{ name }}</div>
</template>

View file

@ -1,28 +1,31 @@
<script setup lang="ts">
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useAsyncState } from '@vueuse/core';
import { useRouter } from 'vue-router';
import { onMounted } from 'vue';
const props = defineProps<{
name: string;
}>();
const router = useRouter();
const workflowHelpers = useWorkflowHelpers({ router });
const workflowStore = useWorkflowsStore();
async function initWorkflow() {
const workflowId = router.currentRoute.value.params.name as string;
const { isReady } = useAsyncState(async () => {
const workflowId = props.name;
const isAlreadyInitialized = workflowStore.workflow.id === workflowId;
if (isAlreadyInitialized) return;
const workflow = await workflowStore.fetchWorkflow(workflowId);
void workflowHelpers.initState(workflow);
}
onMounted(initWorkflow);
workflowHelpers.initState(workflow);
}, undefined);
</script>
<template>
<div :class="$style.evaluationsView">
<router-view />
<router-view v-if="isReady" />
</div>
</template>
@ -30,7 +33,5 @@ onMounted(initWorkflow);
.evaluationsView {
width: 100%;
height: 100%;
margin: auto;
padding: var(--spacing-xl) var(--spacing-l);
}
</style>

View file

@ -1,15 +1,42 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { useRouter } from 'vue-router';
import { convertToDisplayDate } from '@/utils/typesUtils';
import { useI18n } from '@/composables/useI18n';
import { N8nCard, N8nText } from 'n8n-design-system';
import TestTableBase from '@/components/TestDefinition/shared/TestTableBase.vue';
import type { TestTableColumn } from '@/components/TestDefinition/shared/TestTableBase.vue';
import { VIEWS } from '@/constants';
import { useToast } from '@/composables/useToast';
import type { TestCaseExecutionRecord } from '@/api/testDefinition.ee';
import type { TestTableColumn } from '@/components/TestDefinition/shared/TestTableBase.vue';
import TestTableBase from '@/components/TestDefinition/shared/TestTableBase.vue';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { VIEWS } from '@/constants';
import type { BaseTextKey } from '@/plugins/i18n';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { convertToDisplayDate } from '@/utils/typesUtils';
import { N8nActionToggle, N8nButton, N8nText, N8nTooltip } from 'n8n-design-system';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
// TODO: replace with n8n-api type
const TEST_CASE_EXECUTION_ERROR_CODE = {
MOCKED_NODE_DOES_NOT_EXIST: 'MOCKED_NODE_DOES_NOT_EXIST',
TRIGGER_NO_LONGER_EXISTS: 'TRIGGER_NO_LONGER_EXISTS',
FAILED_TO_EXECUTE_WORKFLOW: 'FAILED_TO_EXECUTE_WORKFLOW',
EVALUATION_WORKFLOW_DOES_NOT_EXIST: 'EVALUATION_WORKFLOW_DOES_NOT_EXIST',
FAILED_TO_EXECUTE_EVALUATION_WORKFLOW: 'FAILED_TO_EXECUTE_EVALUATION_WORKFLOW',
METRICS_MISSING: 'METRICS_MISSING',
UNKNOWN_METRICS: 'UNKNOWN_METRICS',
INVALID_METRICS: 'INVALID_METRICS',
PAYLOAD_LIMIT_EXCEEDED: 'PAYLOAD_LIMIT_EXCEEDED',
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
} as const;
type TestCaseExecutionErrorCodes =
(typeof TEST_CASE_EXECUTION_ERROR_CODE)[keyof typeof TEST_CASE_EXECUTION_ERROR_CODE];
const TEST_RUN_ERROR_CODES = {
PAST_EXECUTIONS_NOT_FOUND: 'PAST_EXECUTIONS_NOT_FOUND',
EVALUATION_WORKFLOW_NOT_FOUND: 'EVALUATION_WORKFLOW_NOT_FOUND',
INTERRUPTED: 'INTERRUPTED',
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
} as const;
type TestRunErrorCode = (typeof TEST_RUN_ERROR_CODES)[keyof typeof TEST_RUN_ERROR_CODES];
const router = useRouter();
const toast = useToast();
@ -28,8 +55,64 @@ const filteredTestCases = computed(() => {
return testCases.value;
});
const formattedTime = computed(() =>
convertToDisplayDate(new Date(run.value?.runAt).getTime()).split(' ').reverse(),
);
type Action = { label: string; value: string; disabled: boolean };
const rowActions = (row: TestCaseExecutionRecord): Action[] => {
return [
{
label: 'Original execution',
value: row.pastExecutionId,
disabled: !Boolean(row.pastExecutionId),
},
{
label: 'New Execution',
value: row.executionId,
disabled: !Boolean(row.executionId),
},
{
label: 'Evaluation Execution',
value: row.evaluationExecutionId,
disabled: !Boolean(row.evaluationExecutionId),
},
];
};
const gotToExecution = (executionId: string) => {
const { href } = router.resolve({
name: VIEWS.EXECUTION_PREVIEW,
params: {
name: test.value?.workflowId,
executionId,
},
});
window.open(href, '_blank');
};
const testCaseErrorDictionary: Partial<Record<TestCaseExecutionErrorCodes, BaseTextKey>> = {
MOCKED_NODE_DOES_NOT_EXIST: 'testDefinition.runDetail.error.mockedNodeMissing',
FAILED_TO_EXECUTE_EVALUATION_WORKFLOW: 'testDefinition.runDetail.error.evaluationFailed',
FAILED_TO_EXECUTE_WORKFLOW: 'testDefinition.runDetail.error.executionFailed',
TRIGGER_NO_LONGER_EXISTS: 'testDefinition.runDetail.error.triggerNoLongerExists',
METRICS_MISSING: 'testDefinition.runDetail.error.metricsMissing',
UNKNOWN_METRICS: 'testDefinition.runDetail.error.unknownMetrics',
INVALID_METRICS: 'testDefinition.runDetail.error.invalidMetrics',
} as const;
const testRunErrorDictionary: Partial<Record<TestRunErrorCode, BaseTextKey>> = {
PAST_EXECUTIONS_NOT_FOUND: 'testDefinition.listRuns.error.noPastExecutions',
EVALUATION_WORKFLOW_NOT_FOUND: 'testDefinition.listRuns.error.evaluationWorkflowNotFound',
} as const;
const getErrorBaseKey = (errorCode?: string) =>
testCaseErrorDictionary[errorCode as TestCaseExecutionErrorCodes] ??
testRunErrorDictionary[errorCode as TestRunErrorCode];
const getErrorTooltipLinkRoute = (row: TestCaseExecutionRecord) => {
if (row.errorCode === 'FAILED_TO_EXECUTE_EVALUATION_WORKFLOW') {
if (row.errorCode === TEST_CASE_EXECUTION_ERROR_CODE.FAILED_TO_EXECUTE_EVALUATION_WORKFLOW) {
return {
name: VIEWS.EXECUTION_PREVIEW,
params: {
@ -37,14 +120,14 @@ const getErrorTooltipLinkRoute = (row: TestCaseExecutionRecord) => {
executionId: row.evaluationExecutionId,
},
};
} else if (row.errorCode === 'MOCKED_NODE_DOES_NOT_EXIST') {
} else if (row.errorCode === TEST_CASE_EXECUTION_ERROR_CODE.MOCKED_NODE_DOES_NOT_EXIST) {
return {
name: VIEWS.TEST_DEFINITION_EDIT,
params: {
testId: testId.value,
},
};
} else if (row.errorCode === 'FAILED_TO_EXECUTE_WORKFLOW') {
} else if (row.errorCode === TEST_CASE_EXECUTION_ERROR_CODE.FAILED_TO_EXECUTE_WORKFLOW) {
return {
name: VIEWS.EXECUTION_PREVIEW,
params: {
@ -52,7 +135,7 @@ const getErrorTooltipLinkRoute = (row: TestCaseExecutionRecord) => {
executionId: row.executionId,
},
};
} else if (row.errorCode === 'TRIGGER_NO_LONGER_EXISTS') {
} else if (row.errorCode === TEST_CASE_EXECUTION_ERROR_CODE.TRIGGER_NO_LONGER_EXISTS) {
return {
name: VIEWS.EXECUTION_PREVIEW,
params: {
@ -60,21 +143,21 @@ const getErrorTooltipLinkRoute = (row: TestCaseExecutionRecord) => {
executionId: row.pastExecutionId,
},
};
} else if (row.errorCode === 'METRICS_MISSING') {
} else if (row.errorCode === TEST_CASE_EXECUTION_ERROR_CODE.METRICS_MISSING) {
return {
name: VIEWS.TEST_DEFINITION_EDIT,
params: {
testId: testId.value,
},
};
} else if (row.errorCode === 'UNKNOWN_METRICS') {
} else if (row.errorCode === TEST_CASE_EXECUTION_ERROR_CODE.UNKNOWN_METRICS) {
return {
name: VIEWS.TEST_DEFINITION_EDIT,
params: {
testId: testId.value,
},
};
} else if (row.errorCode === 'INVALID_METRICS') {
} else if (row.errorCode === TEST_CASE_EXECUTION_ERROR_CODE.INVALID_METRICS) {
return {
name: VIEWS.EXECUTION_PREVIEW,
params: {
@ -91,42 +174,21 @@ const columns = computed(
(): Array<TestTableColumn<TestCaseExecutionRecord>> => [
{
prop: 'id',
width: 200,
width: 250,
label: locale.baseText('testDefinition.runDetail.testCase'),
sortable: true,
route: (row: TestCaseExecutionRecord) => {
if (test.value?.evaluationWorkflowId && row.evaluationExecutionId) {
return {
name: VIEWS.EXECUTION_PREVIEW,
params: {
name: test.value?.evaluationWorkflowId,
executionId: row.evaluationExecutionId,
},
};
}
return undefined;
},
formatter: (row: TestCaseExecutionRecord) => `${row.id}`,
openInNewTab: true,
},
{
prop: 'status',
label: locale.baseText('testDefinition.listRuns.status'),
filters: [
{ text: locale.baseText('testDefinition.listRuns.status.new'), value: 'new' },
{ text: locale.baseText('testDefinition.listRuns.status.running'), value: 'running' },
{ text: locale.baseText('testDefinition.listRuns.status.success'), value: 'success' },
{ text: locale.baseText('testDefinition.listRuns.status.error'), value: 'error' },
],
errorRoute: getErrorTooltipLinkRoute,
filterMethod: (value: string, row: TestCaseExecutionRecord) => row.status === value,
},
...Object.keys(run.value?.metrics ?? {}).map((metric) => ({
prop: `metrics.${metric}`,
label: metric,
sortable: true,
filter: true,
showHeaderTooltip: true,
formatter: (row: TestCaseExecutionRecord) => row.metrics?.[metric]?.toFixed(2) ?? '-',
})),
],
@ -172,35 +234,32 @@ onMounted(async () => {
<i class="mr-xs"><font-awesome-icon icon="arrow-left" /></i>
<n8n-heading size="large" :bold="true">{{ test?.name }}</n8n-heading>
<i class="ml-xs mr-xs"><font-awesome-icon icon="chevron-right" /></i>
<n8n-heading size="large" :bold="true"
>{{ locale.baseText('testDefinition.listRuns.runNumber') }}{{ run?.id }}</n8n-heading
>
<n8n-heading size="large" :bold="true">
{{ locale.baseText('testDefinition.listRuns.runNumber') }}{{ run?.id }}
</n8n-heading>
</button>
</div>
<div :class="$style.cardGrid">
<N8nCard :class="$style.summaryCard">
<div :class="$style.stat">
<el-scrollbar always :class="$style.scrollableSummary" class="mb-m">
<div style="display: flex">
<div :class="$style.summaryCard">
<N8nText size="small">
{{ locale.baseText('testDefinition.runDetail.totalCases') }}
</N8nText>
<N8nText size="large">{{ testCases.length }}</N8nText>
<N8nText size="xlarge" style="font-size: 32px" bold>{{ testCases.length }}</N8nText>
</div>
</N8nCard>
<N8nCard :class="$style.summaryCard">
<div :class="$style.stat">
<div :class="$style.summaryCard">
<N8nText size="small">
{{ locale.baseText('testDefinition.runDetail.ranAt') }}
</N8nText>
<N8nText size="medium">{{
convertToDisplayDate(new Date(run?.runAt).getTime())
}}</N8nText>
<div>
<N8nText v-for="item in formattedTime" :key="item" size="medium" tag="div">
{{ item }}
</N8nText>
</div>
</div>
</N8nCard>
<N8nCard :class="$style.summaryCard">
<div :class="$style.stat">
<div :class="$style.summaryCard">
<N8nText size="small">
{{ locale.baseText('testDefinition.listRuns.status') }}
</N8nText>
@ -208,27 +267,67 @@ onMounted(async () => {
{{ run?.status }}
</N8nText>
</div>
</N8nCard>
<N8nCard v-for="(value, key) in metrics" :key="key" :class="$style.summaryCard">
<div :class="$style.stat">
<N8nText size="small">{{ key }}</N8nText>
<N8nText size="large">{{ value.toFixed(2) }}</N8nText>
</div>
</N8nCard>
</div>
<div v-for="(value, key) in metrics" :key="key" :class="$style.summaryCard">
<N8nTooltip :content="key" placement="top">
<N8nText size="small" style="text-overflow: ellipsis; overflow: hidden">
{{ key }}
</N8nText>
</N8nTooltip>
<N8nCard>
<N8nText size="xlarge" style="font-size: 32px" bold>{{ value.toFixed(2) }}</N8nText>
</div>
</div>
</el-scrollbar>
<div v-if="isLoading" :class="$style.loading">
<n8n-loading :loading="true" :rows="5" />
</div>
<TestTableBase
v-else
:data="filteredTestCases"
:columns="columns"
:default-sort="{ prop: 'id', order: 'descending' }"
/>
</N8nCard>
>
<template #id="{ row }">
<div style="display: flex; justify-content: space-between; gap: 10px">
{{ row.id }}
<N8nActionToggle
:actions="rowActions(row)"
icon-orientation="horizontal"
@action="gotToExecution"
>
<N8nButton type="secondary">View</N8nButton>
</N8nActionToggle>
</div>
</template>
<template #status="{ row }">
<template v-if="row.status === 'error'">
<N8nTooltip placement="right" :show-after="300">
<template #content>
<template v-if="getErrorBaseKey(row.errorCode)">
<i18n-t :keypath="getErrorBaseKey(row.errorCode)">
<template #link>
<RouterLink :to="getErrorTooltipLinkRoute(row) ?? ''" target="_blank">
{{
locale.baseText(`${getErrorBaseKey(row.errorCode)}.solution` as BaseTextKey)
}}
</RouterLink>
</template>
</i18n-t>
</template>
<template v-else> UNKNOWN_ERROR </template>
</template>
<div style="display: inline-flex; gap: 8px; text-transform: capitalize">
<N8nIcon icon="exclamation-triangle" color="danger"></N8nIcon>
<N8nText size="small" color="danger">
{{ row.status }}
</N8nText>
</div>
</N8nTooltip>
</template>
</template>
</TestTableBase>
</div>
</template>
@ -238,6 +337,7 @@ onMounted(async () => {
width: 100%;
max-width: var(--content-container-width);
margin: auto;
padding: var(--spacing-l) var(--spacing-2xl) 0;
}
.backButton {
@ -292,25 +392,35 @@ onMounted(async () => {
height: 200px;
}
.cardGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
gap: var(--spacing-xs);
margin-bottom: var(--spacing-m);
.scrollableSummary {
border: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
border-radius: 5px;
background-color: var(--color-background-xlight);
:global(.el-scrollbar__bar) {
opacity: 1;
}
:global(.el-scrollbar__thumb) {
background-color: var(--color-foreground-base);
&:hover {
background-color: var(--color-foreground-dark);
}
}
}
:global {
.new {
color: var(--color-info);
}
.running {
color: var(--color-warning);
}
.completed {
color: var(--color-success);
}
.error {
color: var(--color-danger);
.summaryCard {
padding: var(--spacing-s);
border-right: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
flex-basis: 169px;
flex-shrink: 0;
max-width: 170px;
display: flex;
flex-direction: column;
justify-content: space-between;
&:first-child {
border-top-left-radius: inherit;
border-bottom-left-radius: inherit;
}
}
</style>

View file

@ -1,151 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import type { TestRunRecord } from '@/api/testDefinition.ee';
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
import { MODAL_CONFIRM, VIEWS } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { useUIStore } from '@/stores/ui.store';
import { useMessage } from '@/composables/useMessage';
const router = useRouter();
const testDefinitionStore = useTestDefinitionStore();
const uiStore = useUIStore();
const locale = useI18n();
const toast = useToast();
const selectedMetric = ref();
const isLoading = ref(false);
const appliedTheme = computed(() => uiStore.appliedTheme);
const testId = computed(() => {
return router.currentRoute.value.params.testId as string;
});
async function loadInitialData() {
if (!isLoading.value) {
// Add guard to prevent multiple loading states
isLoading.value = true;
try {
await testDefinitionStore.fetchTestDefinition(testId.value);
await testDefinitionStore.fetchTestRuns(testId.value);
isLoading.value = false;
} catch (error) {
} finally {
isLoading.value = false;
}
}
}
// TODO: We're currently doing the filtering on the FE but there should be an endpoint to get the runs for a test
const runs = computed(() => {
return Object.values(testDefinitionStore.testRunsById ?? {}).filter(
(run) => run.testDefinitionId === testId.value,
);
});
const testDefinition = computed(() => {
return testDefinitionStore.testDefinitionsById[testId.value];
});
const getRunDetail = (run: TestRunRecord) => {
void router.push({
name: VIEWS.TEST_DEFINITION_RUNS_DETAIL,
params: { testId: testId.value, runId: run.id },
});
};
async function runTest() {
try {
const result = await testDefinitionStore.startTestRun(testId.value);
if (result.success) {
toast.showMessage({
title: locale.baseText('testDefinition.list.testStarted'),
type: 'success',
});
// Optionally fetch the updated test runs
await testDefinitionStore.fetchTestRuns(testId.value);
} else {
throw new Error('Test run failed to start');
}
} catch (error) {
toast.showError(error, locale.baseText('testDefinition.list.testStartError'));
}
}
async function onDeleteRuns(runsToDelete: TestRunRecord[]) {
const { confirm } = useMessage();
const deleteConfirmed = await confirm(locale.baseText('testDefinition.deleteTest'), {
type: 'warning',
confirmButtonText: locale.baseText(
'settings.log-streaming.destinationDelete.confirmButtonText',
),
cancelButtonText: locale.baseText('settings.log-streaming.destinationDelete.cancelButtonText'),
});
if (deleteConfirmed !== MODAL_CONFIRM) {
return;
}
await Promise.all(
runsToDelete.map(async (run) => {
await testDefinitionStore.deleteTestRun({ testDefinitionId: testId.value, runId: run.id });
}),
);
}
onMounted(async () => {
await loadInitialData();
});
</script>
<template>
<div :class="$style.container">
<router-link :to="{ name: VIEWS.TEST_DEFINITION }" :class="$style.backButton">
<i class="mr-xs"><font-awesome-icon icon="arrow-left" /></i>
<n8n-heading size="large" :bold="true">{{ testDefinition?.name }}</n8n-heading>
</router-link>
<N8nText :class="$style.description" size="medium">{{ testDefinition?.description }}</N8nText>
<template v-if="isLoading">
<N8nLoading :rows="5" />
<N8nLoading :rows="10" />
</template>
<div v-else-if="runs.length > 0" :class="$style.details">
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
<TestRunsTable :runs="runs" @get-run-detail="getRunDetail" @delete-runs="onDeleteRuns" />
</div>
<template v-else>
<N8nActionBox
:heading="locale.baseText('testDefinition.listRuns.noRuns')"
:description="locale.baseText('testDefinition.listRuns.noRuns.description')"
:button-text="locale.baseText('testDefinition.listRuns.noRuns.button')"
@click:button="runTest"
/>
</template>
</div>
</template>
<style module lang="scss">
.container {
height: 100%;
width: 100%;
max-width: var(--content-container-width);
margin: auto;
display: flex;
flex-direction: column;
}
.backButton {
color: var(--color-text-base);
}
.description {
margin: var(--spacing-s) 0;
display: block;
}
.details {
display: flex;
height: 100%;
flex-direction: column;
gap: var(--spacing-xl);
}
</style>

View file

@ -1,105 +1,43 @@
import type { Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import TestDefinitionEditView from '@/views/TestDefinition/TestDefinitionEditView.vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from '@/composables/useToast';
import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import { ref, nextTick } from 'vue';
import type { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm';
import { ref } from 'vue';
import { cleanupAppModals, createAppModals, mockedStore } from '@/__tests__/utils';
import { VIEWS } from '@/constants';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import type { TestRunRecord } from '@/api/testDefinition.ee';
import userEvent from '@testing-library/user-event';
vi.mock('vue-router');
vi.mock('@/composables/useToast');
vi.mock('@/components/TestDefinition/composables/useTestDefinitionForm');
vi.mock('@/stores/projects.store');
describe('TestDefinitionEditView', () => {
const renderComponent = createComponentRenderer(TestDefinitionEditView);
let createTestMock: Mock;
let updateTestMock: Mock;
let loadTestDataMock: Mock;
let deleteMetricMock: Mock;
let updateMetricsMock: Mock;
let showMessageMock: Mock;
let showErrorMock: Mock;
const renderComponentWithFeatureEnabled = ({
testRunsById = {},
}: { testRunsById?: Record<string, TestRunRecord> } = {}) => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const mockedTestDefinitionStore = mockedStore(useTestDefinitionStore);
mockedTestDefinitionStore.isFeatureEnabled = true;
mockedTestDefinitionStore.testRunsById = testRunsById;
return { ...renderComponent({ pinia }), mockedTestDefinitionStore };
};
beforeEach(() => {
setActivePinia(createPinia());
createAppModals();
// Default route mock: no testId
vi.mocked(useRoute).mockReturnValue({
params: {},
name: VIEWS.NEW_TEST_DEFINITION,
} as ReturnType<typeof useRoute>);
vi.mocked(useRouter).mockReturnValue({
push: vi.fn(),
replace: vi.fn(),
resolve: vi.fn().mockReturnValue({ href: '/test-href' }),
currentRoute: { value: { params: {} } },
} as unknown as ReturnType<typeof useRouter>);
createTestMock = vi.fn().mockResolvedValue({ id: 'newTestId' });
updateTestMock = vi.fn().mockResolvedValue({});
loadTestDataMock = vi.fn();
deleteMetricMock = vi.fn();
updateMetricsMock = vi.fn();
showMessageMock = vi.fn();
showErrorMock = vi.fn();
// const mockedTestDefinitionStore = mockedStore(useTestDefinitionStore);
vi.mocked(useToast).mockReturnValue({
showMessage: showMessageMock,
showError: showErrorMock,
} as unknown as ReturnType<typeof useToast>);
vi.mocked(useTestDefinitionForm).mockReturnValue({
const form: Partial<ReturnType<typeof useTestDefinitionForm>> = {
state: ref({
name: { value: '', isEditing: false, tempValue: '' },
description: { value: '', isEditing: false, tempValue: '' },
tags: { value: [], tempValue: [], isEditing: false },
evaluationWorkflow: { mode: 'list', value: '', __rl: true },
metrics: [],
mockedNodes: [],
}),
fieldsIssues: ref([]),
isSaving: ref(false),
loadTestData: loadTestDataMock,
createTest: createTestMock,
updateTest: updateTestMock,
loadTestData: vi.fn(),
cancelEditing: vi.fn(),
updateTest: vi.fn(),
startEditing: vi.fn(),
saveChanges: vi.fn(),
cancelEditing: vi.fn(),
handleKeydown: vi.fn(),
deleteMetric: deleteMetricMock,
updateMetrics: updateMetricsMock,
} as unknown as ReturnType<typeof useTestDefinitionForm>);
vi.mock('@/stores/projects.store', () => ({
useProjectsStore: vi.fn().mockReturnValue({
isTeamProjectFeatureEnabled: false,
currentProject: null,
currentProjectId: null,
}),
deleteMetric: vi.fn(),
updateMetrics: vi.fn(),
createTest: vi.fn(),
};
vi.mock('@/components/TestDefinition/composables/useTestDefinitionForm', () => ({
useTestDefinitionForm: () => form,
}));
const renderComponent = createComponentRenderer(TestDefinitionEditView, {
props: { testId: '1', name: 'workflow-name' },
});
describe('TestDefinitionEditView', () => {
beforeEach(() => {
createTestingPinia();
createAppModals();
});
afterEach(() => {
@ -107,106 +45,41 @@ describe('TestDefinitionEditView', () => {
cleanupAppModals();
});
it('should load test data when testId is provided', async () => {
vi.mocked(useRoute).mockReturnValue({
params: { testId: '1' },
name: VIEWS.TEST_DEFINITION_EDIT,
} as unknown as ReturnType<typeof useRoute>);
renderComponentWithFeatureEnabled();
mockedStore(useAnnotationTagsStore).fetchAll.mockResolvedValue([]);
expect(loadTestDataMock).toHaveBeenCalledWith('1');
});
it('should not load test data when testId is not provided', async () => {
// Here route returns no testId
vi.mocked(useRoute).mockReturnValue({
params: {},
name: VIEWS.NEW_TEST_DEFINITION,
} as unknown as ReturnType<typeof useRoute>);
renderComponentWithFeatureEnabled();
expect(loadTestDataMock).not.toHaveBeenCalled();
});
it('should create a new test and show success message on save if no testId is present', async () => {
vi.mocked(useRoute).mockReturnValue({
params: {},
name: VIEWS.NEW_TEST_DEFINITION,
} as ReturnType<typeof useRoute>);
const { getByTestId } = renderComponentWithFeatureEnabled();
mockedStore(useAnnotationTagsStore).fetchAll.mockResolvedValue([]);
await nextTick();
const saveButton = getByTestId('run-test-button');
saveButton.click();
await nextTick();
expect(createTestMock).toHaveBeenCalled();
});
it('should show error message on failed test creation', async () => {
createTestMock.mockRejectedValue(new Error('Save failed'));
vi.mocked(useRoute).mockReturnValue({
params: {},
name: VIEWS.NEW_TEST_DEFINITION,
} as unknown as ReturnType<typeof useRoute>);
const { getByTestId } = renderComponentWithFeatureEnabled();
const saveButton = getByTestId('run-test-button');
saveButton.click();
await nextTick();
expect(createTestMock).toHaveBeenCalled();
expect(showErrorMock).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
it('should load test data', async () => {
renderComponent();
expect(form.loadTestData).toHaveBeenCalledWith('1', 'workflow-name');
});
it('should display disabled "run test" button when editing test without tags', async () => {
vi.mocked(useRoute).mockReturnValue({
params: { testId: '1' },
name: VIEWS.TEST_DEFINITION_EDIT,
} as unknown as ReturnType<typeof useRoute>);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
const { getByTestId, mockedTestDefinitionStore } = renderComponentWithFeatureEnabled();
testDefinitionStore.getFieldIssues.mockReturnValueOnce([
{ field: 'tags', message: 'Tag is required' },
]);
mockedTestDefinitionStore.getFieldIssues = vi
.fn()
.mockReturnValue([{ field: 'tags', message: 'Tag is required' }]);
await nextTick();
const { getByTestId } = renderComponent();
const updateButton = getByTestId('run-test-button');
expect(updateButton.textContent?.toLowerCase()).toContain('run test');
expect(updateButton).toHaveClass('disabled');
mockedTestDefinitionStore.getFieldIssues = vi.fn().mockReturnValue([]);
await nextTick();
expect(updateButton).not.toHaveClass('disabled');
});
it('should apply "has-issues" class to inputs with issues', async () => {
const { container, mockedTestDefinitionStore } = renderComponentWithFeatureEnabled();
mockedTestDefinitionStore.getFieldIssues = vi
.fn()
.mockReturnValue([{ field: 'tags', message: 'Tag is required' }]);
await nextTick();
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.getFieldIssues.mockReturnValueOnce([
{ field: 'evaluationWorkflow', message: 'No evaluation workflow set' },
]);
const { container } = renderComponent();
const issueElements = container.querySelectorAll('.has-issues');
expect(issueElements.length).toBeGreaterThan(0);
});
describe('Test Runs functionality', () => {
it('should display test runs table when runs exist', async () => {
vi.mocked(useRoute).mockReturnValue({
params: { testId: '1' },
name: VIEWS.TEST_DEFINITION_EDIT,
} as unknown as ReturnType<typeof useRoute>);
const { getByTestId } = renderComponentWithFeatureEnabled({
testRunsById: {
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.testRunsById = {
run1: {
id: 'run1',
testDefinitionId: '1',
@ -215,57 +88,29 @@ describe('TestDefinitionEditView', () => {
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
completedAt: '2023-01-01',
failedCases: 0,
passedCases: 1,
totalCases: 1,
},
run2: {
id: 'run2',
testDefinitionId: '1',
status: 'running',
runAt: '2023-01-02',
createdAt: '2023-01-02',
updatedAt: '2023-01-02',
completedAt: '',
},
},
});
};
const runsTable = getByTestId('past-runs-table');
expect(runsTable).toBeTruthy();
const { getByTestId } = renderComponent();
expect(getByTestId('past-runs-table')).toBeInTheDocument();
});
it('should not display test runs table when no runs exist', async () => {
const { container } = renderComponentWithFeatureEnabled();
const runsTable = container.querySelector('[data-test-id="past-runs-table"]');
expect(runsTable).toBeFalsy();
const { queryByTestId } = renderComponent();
expect(queryByTestId('past-runs-table')).not.toBeInTheDocument();
});
it('should start a test run when run test button is clicked', async () => {
vi.mocked(useTestDefinitionForm).mockReturnValue({
...vi.mocked(useTestDefinitionForm)(),
state: ref({
name: { value: 'Test', isEditing: false, tempValue: '' },
description: { value: '', isEditing: false, tempValue: '' },
tags: { value: ['tag1'], tempValue: [], isEditing: false },
evaluationWorkflow: { mode: 'list', value: 'workflow1', __rl: true },
metrics: [],
mockedNodes: [],
}),
} as unknown as ReturnType<typeof useTestDefinitionForm>);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
const { getByTestId } = renderComponent();
vi.mocked(useRoute).mockReturnValue({
params: { testId: '1' },
name: VIEWS.TEST_DEFINITION_EDIT,
} as unknown as ReturnType<typeof useRoute>);
await userEvent.click(getByTestId('run-test-button'));
const { getByTestId, mockedTestDefinitionStore } = renderComponentWithFeatureEnabled();
await nextTick();
const runButton = getByTestId('run-test-button');
runButton.click();
await nextTick();
expect(mockedTestDefinitionStore.startTestRun).toHaveBeenCalledWith('1');
expect(mockedTestDefinitionStore.fetchTestRuns).toHaveBeenCalledWith('1');
expect(testDefinitionStore.startTestRun).toHaveBeenCalledWith('1');
expect(testDefinitionStore.fetchTestRuns).toHaveBeenCalledWith('1');
});
});
});

View file

@ -1,173 +1,170 @@
import type { Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import TestDefinitionListView from '@/views/TestDefinition/TestDefinitionListView.vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage';
import type { useToast } from '@/composables/useToast';
import type { useMessage } from '@/composables/useMessage';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { nextTick, ref } from 'vue';
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
import { MODAL_CONFIRM, VIEWS } from '@/constants';
import { mockedStore } from '@/__tests__/utils';
import { MODAL_CONFIRM } from '@/constants';
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
import userEvent from '@testing-library/user-event';
import { within, waitFor } from '@testing-library/dom';
vi.mock('vue-router');
vi.mock('@/composables/useToast');
vi.mock('@/composables/useMessage');
describe('TestDefinitionListView', () => {
const renderComponent = createComponentRenderer(TestDefinitionListView);
let showMessageMock: Mock;
let showErrorMock: Mock;
let confirmMock: Mock;
let startTestRunMock: Mock;
let fetchTestRunsMock: Mock;
let deleteByIdMock: Mock;
let fetchAllMock: Mock;
const workflowId = 'workflow1';
const mockTestDefinitions: TestDefinitionRecord[] = [
{
id: '1',
name: 'Test 1',
workflowId: 'workflow1',
workflowId,
updatedAt: '2023-01-01T00:00:00.000Z',
createdAt: '2023-01-01T00:00:00.000Z',
annotationTagId: 'tag1',
},
{
id: '2',
name: 'Test 2',
workflowId: 'workflow1',
workflowId,
updatedAt: '2023-01-02T00:00:00.000Z',
createdAt: '2023-01-01T00:00:00.000Z',
},
{
id: '3',
name: 'Test 3',
workflowId: 'workflow1',
workflowId,
updatedAt: '2023-01-03T00:00:00.000Z',
createdAt: '2023-01-01T00:00:00.000Z',
},
];
beforeEach(() => {
setActivePinia(createPinia());
vi.mocked(useRoute).mockReturnValue(
ref({
params: { name: 'workflow1' },
name: VIEWS.TEST_DEFINITION,
}) as unknown as ReturnType<typeof useRoute>,
const toast = vi.hoisted(
() =>
({
showMessage: vi.fn(),
showError: vi.fn(),
}) satisfies Partial<ReturnType<typeof useToast>>,
);
vi.mocked(useRouter).mockReturnValue({
push: vi.fn(),
currentRoute: { value: { params: { name: 'workflow1' } } },
} as unknown as ReturnType<typeof useRouter>);
vi.mock('@/composables/useToast', () => ({
useToast: () => toast,
}));
showMessageMock = vi.fn();
showErrorMock = vi.fn();
confirmMock = vi.fn().mockResolvedValue(MODAL_CONFIRM);
startTestRunMock = vi.fn().mockResolvedValue({ success: true });
fetchTestRunsMock = vi.fn();
deleteByIdMock = vi.fn();
fetchAllMock = vi.fn().mockResolvedValue({ testDefinitions: mockTestDefinitions });
const message = vi.hoisted(
() =>
({
confirm: vi.fn(),
}) satisfies Partial<ReturnType<typeof useMessage>>,
);
vi.mocked(useToast).mockReturnValue({
showMessage: showMessageMock,
showError: showErrorMock,
} as unknown as ReturnType<typeof useToast>);
vi.mock('@/composables/useMessage', () => ({
useMessage: () => message,
}));
vi.mocked(useMessage).mockReturnValue({
confirm: confirmMock,
} as unknown as ReturnType<typeof useMessage>);
describe('TestDefinitionListView', () => {
beforeEach(() => {
createTestingPinia();
});
afterEach(() => {
vi.clearAllMocks();
});
const renderComponentWithFeatureEnabled = async (
{ testDefinitions }: { testDefinitions: TestDefinitionRecord[] } = {
testDefinitions: mockTestDefinitions,
},
) => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.isFeatureEnabled = true;
testDefinitionStore.fetchAll = fetchAllMock;
testDefinitionStore.startTestRun = startTestRunMock;
testDefinitionStore.fetchTestRuns = fetchTestRunsMock;
testDefinitionStore.deleteById = deleteByIdMock;
testDefinitionStore.allTestDefinitionsByWorkflowId = { workflow1: testDefinitions };
const component = renderComponent({ pinia });
await waitAllPromises();
return { ...component, testDefinitionStore };
};
it('should render loader', async () => {
const { getByTestId } = renderComponent({ props: { name: 'any' } });
expect(getByTestId('test-definition-loader')).toBeTruthy();
});
it('should render empty state when no tests exist', async () => {
const { getByTestId } = await renderComponentWithFeatureEnabled({ testDefinitions: [] });
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.allTestDefinitionsByWorkflowId = {};
expect(getByTestId('test-definition-empty-state')).toBeTruthy();
const { getByTestId } = renderComponent({ props: { name: 'any' } });
await waitFor(() => expect(getByTestId('test-definition-empty-state')).toBeTruthy());
});
it('should render tests list when tests exist', async () => {
const { getByTestId } = await renderComponentWithFeatureEnabled();
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId] = mockTestDefinitions;
expect(getByTestId('test-definition-list')).toBeTruthy();
const { getByTestId } = renderComponent({ props: { name: workflowId } });
await waitFor(() => expect(getByTestId('test-definition-list')).toBeTruthy());
});
it('should load initial data on mount', async () => {
const { testDefinitionStore } = await renderComponentWithFeatureEnabled();
expect(testDefinitionStore.fetchAll).toHaveBeenCalledWith({
workflowId: 'workflow1',
});
it('should load initial base on route param', async () => {
const testDefinitionStore = mockedStore(useTestDefinitionStore);
renderComponent({ props: { name: workflowId } });
expect(testDefinitionStore.fetchAll).toHaveBeenCalledWith({ workflowId });
});
it('should start test run and show success message', async () => {
const { getByTestId } = await renderComponentWithFeatureEnabled();
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId] = mockTestDefinitions;
testDefinitionStore.startTestRun.mockResolvedValueOnce({ success: true });
const runButton = getByTestId('run-test-button-1');
runButton.click();
await nextTick();
const { getByTestId } = renderComponent({ props: { name: workflowId } });
expect(startTestRunMock).toHaveBeenCalledWith('1');
expect(fetchTestRunsMock).toHaveBeenCalledWith('1');
expect(showMessageMock).toHaveBeenCalledWith({
title: expect.any(String),
type: 'success',
});
await waitFor(() => expect(getByTestId('test-definition-list')).toBeTruthy());
const testToRun = mockTestDefinitions[0].id;
await userEvent.click(getByTestId(`run-test-${testToRun}`));
expect(testDefinitionStore.startTestRun).toHaveBeenCalledWith(testToRun);
expect(toast.showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
expect(testDefinitionStore.fetchTestRuns).toHaveBeenCalledWith(testToRun);
});
it('should show error message on failed test run', async () => {
const { getByTestId, testDefinitionStore } = await renderComponentWithFeatureEnabled();
testDefinitionStore.startTestRun = vi.fn().mockRejectedValue(new Error('Run failed'));
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId] = mockTestDefinitions;
testDefinitionStore.startTestRun.mockRejectedValueOnce(new Error('Run failed'));
const runButton = getByTestId('run-test-button-1');
runButton.click();
await nextTick();
const { getByTestId } = renderComponent({ props: { name: workflowId } });
expect(showErrorMock).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
await waitFor(() => expect(getByTestId('test-definition-list')).toBeTruthy());
const testToRun = mockTestDefinitions[0].id;
await userEvent.click(getByTestId(`run-test-${testToRun}`));
expect(toast.showError).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
});
it('should delete test and show success message', async () => {
const { getByTestId } = await renderComponentWithFeatureEnabled();
const deleteButton = getByTestId('delete-test-button-1');
deleteButton.click();
await waitAllPromises();
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId] = mockTestDefinitions;
testDefinitionStore.startTestRun.mockRejectedValueOnce(new Error('Run failed'));
expect(deleteByIdMock).toHaveBeenCalledWith('1');
expect(showMessageMock).toHaveBeenCalledWith({
title: expect.any(String),
type: 'success',
message.confirm.mockResolvedValueOnce(MODAL_CONFIRM);
const { getByTestId } = renderComponent({
props: { name: workflowId },
});
await waitFor(() => expect(getByTestId('test-definition-list')).toBeTruthy());
const testToDelete = mockTestDefinitions[0].id;
const trigger = getByTestId(`test-actions-${testToDelete}`);
await userEvent.click(trigger);
const dropdownId = within(trigger).getByRole('button').getAttribute('aria-controls');
const dropdown = document.querySelector(`#${dropdownId}`);
expect(dropdown).toBeInTheDocument();
await userEvent.click(await within(dropdown as HTMLElement).findByText('Delete'));
expect(testDefinitionStore.deleteById).toHaveBeenCalledWith(testToDelete);
expect(toast.showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
});
it('should sort tests by updated date in descending order', async () => {
const { container } = await renderComponentWithFeatureEnabled();
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId] = mockTestDefinitions;
const { container, getByTestId } = renderComponent({ props: { name: workflowId } });
await waitFor(() => expect(getByTestId('test-definition-list')).toBeTruthy());
const testItems = container.querySelectorAll('[data-test-id^="test-item-"]');
expect(testItems[0].getAttribute('data-test-id')).toBe('test-item-3');

View file

@ -1,16 +1,13 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import TestDefinitionRootView from '../TestDefinitionRootView.vue';
import { useRouter } from 'vue-router';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { mockedStore } from '@/__tests__/utils';
import type { IWorkflowDb } from '@/Interface';
import * as workflowsApi from '@/api/workflows';
vi.mock('vue-router');
vi.mock('@/api/workflows');
import { waitFor } from '@testing-library/vue';
describe('TestDefinitionRootView', () => {
const renderComponent = createComponentRenderer(TestDefinitionRootView);
@ -33,55 +30,33 @@ describe('TestDefinitionRootView', () => {
};
beforeEach(() => {
setActivePinia(createPinia());
vi.mocked(useRouter).mockReturnValue({
currentRoute: {
value: {
params: {
name: 'workflow123',
},
},
},
} as unknown as ReturnType<typeof useRouter>);
vi.mocked(workflowsApi.getWorkflow).mockResolvedValue({
...mockWorkflow,
id: 'workflow123',
});
createTestingPinia();
});
it('should initialize workflow on mount if not already initialized', async () => {
const pinia = createTestingPinia();
const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.workflow = mockWorkflow;
const newWorkflowId = 'workflow123';
const newWorkflow = {
...mockWorkflow,
id: 'workflow123',
};
workflowsStore.fetchWorkflow.mockResolvedValue(newWorkflow);
renderComponent({ props: { name: newWorkflowId } });
renderComponent({ pinia });
expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith('workflow123');
expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith(newWorkflowId);
});
it('should not initialize workflow if already loaded', async () => {
const pinia = createTestingPinia();
const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.workflow = {
...mockWorkflow,
id: 'workflow123',
};
workflowsStore.workflow = mockWorkflow;
renderComponent({ pinia });
renderComponent({ props: { name: mockWorkflow.id } });
expect(workflowsStore.fetchWorkflow).not.toHaveBeenCalled();
});
it('should render router view', () => {
const { container } = renderComponent();
expect(container.querySelector('router-view')).toBeTruthy();
it('should render router view', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.fetchWorkflow.mockResolvedValue(mockWorkflow);
const { container } = renderComponent({ props: { name: mockWorkflow.id } });
await waitFor(() => expect(container.querySelector('router-view')).toBeTruthy());
});
});

View file

@ -1,245 +0,0 @@
import type { Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import TestDefinitionRunDetailView from '@/views/TestDefinition/TestDefinitionRunDetailView.vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from '@/composables/useToast';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { nextTick, ref } from 'vue';
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
import { VIEWS } from '@/constants';
import type { TestRunRecord } from '@/api/testDefinition.ee';
import type { IWorkflowDb } from '@/Interface';
vi.mock('vue-router');
vi.mock('@/composables/useToast');
describe('TestDefinitionRunDetailView', () => {
const renderComponent = createComponentRenderer(TestDefinitionRunDetailView);
let showErrorMock: Mock;
let getTestRunMock: Mock;
let fetchTestCaseExecutionsMock: Mock;
const mockTestRun: TestRunRecord = {
id: 'run1',
status: 'completed',
runAt: '2023-01-01T00:00:00.000Z',
metrics: {
accuracy: 0.95,
precision: 0.88,
},
testDefinitionId: 'test1',
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2023-01-01T00:00:00.000Z',
completedAt: '2023-01-01T00:00:00.000Z',
};
const mockTestDefinition = {
id: 'test1',
name: 'Test Definition 1',
evaluationWorkflowId: 'workflow1',
workflowId: 'workflow1',
};
const mockWorkflow = {
id: 'workflow1',
name: 'Evaluation Workflow',
};
const mockTestCaseExecutions = [
{ id: 'exec1', status: 'success' },
{ id: 'exec2', status: 'error' },
];
beforeEach(() => {
setActivePinia(createPinia());
// Mock route with testId and runId
vi.mocked(useRoute).mockReturnValue(
ref({
params: { testId: 'test1', runId: 'run1' },
name: VIEWS.TEST_DEFINITION_RUNS,
}) as unknown as ReturnType<typeof useRoute>,
);
vi.mocked(useRouter).mockReturnValue({
back: vi.fn(),
currentRoute: { value: { params: { testId: 'test1', runId: 'run1' } } },
resolve: vi.fn().mockResolvedValue({ href: 'test-definition-run-detail' }),
} as unknown as ReturnType<typeof useRouter>);
showErrorMock = vi.fn();
getTestRunMock = vi.fn().mockResolvedValue(mockTestRun);
fetchTestCaseExecutionsMock = vi.fn().mockResolvedValue(mockTestCaseExecutions);
vi.mocked(useToast).mockReturnValue({
showError: showErrorMock,
} as unknown as ReturnType<typeof useToast>);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should load run details on mount', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.testRunsById = { run1: mockTestRun };
testDefinitionStore.testDefinitionsById = { test1: mockTestDefinition };
testDefinitionStore.getTestRun = getTestRunMock;
const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.workflowsById = { workflow1: mockWorkflow as IWorkflowDb };
const { getByTestId } = renderComponent({ pinia });
await nextTick();
expect(getTestRunMock).toHaveBeenCalledWith({
testDefinitionId: 'test1',
runId: 'run1',
});
// expect(fetchExecutionsMock).toHaveBeenCalled();
expect(getByTestId('test-definition-run-detail')).toBeTruthy();
});
it('should display test run metrics', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.testRunsById = { run1: mockTestRun };
testDefinitionStore.testDefinitionsById = { test1: mockTestDefinition };
testDefinitionStore.getTestRun = getTestRunMock;
const { container } = renderComponent({ pinia });
await nextTick();
const metricsCards = container.querySelectorAll('.summaryCard');
expect(metricsCards.length).toBeGreaterThan(0);
expect(container.textContent).toContain('0.95'); // Check for accuracy metric
});
it('should handle errors when loading run details', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.getTestRun = vi.fn().mockRejectedValue(new Error('Failed to load'));
renderComponent({ pinia });
await waitAllPromises();
expect(showErrorMock).toHaveBeenCalledWith(expect.any(Error), 'Failed to load run details');
});
it('should navigate back when back button is clicked', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const router = useRouter();
const { getByTestId } = renderComponent({ pinia });
await nextTick();
const backButton = getByTestId('test-definition-run-detail').querySelector('.backButton');
backButton?.dispatchEvent(new Event('click'));
expect(router.back).toHaveBeenCalled();
});
// Test loading states
it('should show loading state while fetching data', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.getTestRun = vi
.fn()
.mockImplementation(async () => await new Promise(() => {})); // Never resolves
const { container } = renderComponent({ pinia });
await nextTick();
expect(container.querySelector('.loading')).toBeTruthy();
});
// Test metrics display
it('should correctly format and display all metrics', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testRunWithMultipleMetrics = {
...mockTestRun,
metrics: {
accuracy: 0.956789,
precision: 0.887654,
recall: 0.923456,
f1_score: 0.901234,
},
};
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.testRunsById = { run1: testRunWithMultipleMetrics };
testDefinitionStore.testDefinitionsById = { test1: mockTestDefinition };
const { container } = renderComponent({ pinia });
await nextTick();
// Check if the metrics are displayed correctly with 2 decimal places
expect(container.textContent).toContain('0.96');
expect(container.textContent).toContain('0.89');
expect(container.textContent).toContain('0.92');
expect(container.textContent).toContain('0.90');
});
// Test status display
it('should display correct status with appropriate styling', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testRunWithStatus: TestRunRecord = {
...mockTestRun,
status: 'error',
};
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.testRunsById = { run1: testRunWithStatus };
testDefinitionStore.testDefinitionsById = { test1: mockTestDefinition };
const { container } = renderComponent({ pinia });
await nextTick();
const statusElement = container.querySelector('.error');
expect(statusElement).toBeTruthy();
expect(statusElement?.textContent?.trim()).toBe('error');
});
// Test table data
it('should correctly populate the test cases table', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
// Mock all required store methods
testDefinitionStore.testRunsById = { run1: mockTestRun };
testDefinitionStore.testDefinitionsById = { test1: mockTestDefinition };
testDefinitionStore.getTestRun = getTestRunMock;
// Add this mock for fetchTestDefinition
testDefinitionStore.fetchTestDefinition = vi.fn().mockResolvedValue(mockTestDefinition);
testDefinitionStore.fetchTestCaseExecutions = fetchTestCaseExecutionsMock;
const { container } = renderComponent({ pinia });
// Wait for all promises to resolve
await waitAllPromises();
const tableRows = container.querySelectorAll('.el-table__row');
expect(tableRows.length).toBe(mockTestCaseExecutions.length);
});
});

View file

@ -125,6 +125,7 @@ async function initializeRoute() {
.replace({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: workflow.value.id, executionId: executions.value[0].id },
query: route.query,
})
.catch(() => {});
}