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) => resourceLocatorModeSelector: (paramName: string) =>
this.getters.resourceLocator(paramName).find('[data-test-id="rlc-mode-selector"]'), this.getters.resourceLocator(paramName).find('[data-test-id="rlc-mode-selector"]'),
resourceLocatorSearch: (paramName: string) => 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'), resourceMapperFieldsContainer: () => cy.getByTestId('mapping-fields-container'),
resourceMapperSelectColumn: () => cy.getByTestId('matching-column-select'), resourceMapperSelectColumn: () => cy.getByTestId('matching-column-select'),
resourceMapperRemoveFieldButton: (fieldName: string) => resourceMapperRemoveFieldButton: (fieldName: string) =>

View file

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

View file

@ -11,7 +11,8 @@ withDefaults(defineProps<TagProps>(), {
<template> <template>
<span :class="['n8n-tag', $style.tag, { [$style.clickable]: clickable }]" v-bind="$attrs"> <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> </span>
</template> </template>

View file

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, useCssModule } from 'vue'; 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 { interface TextProps {
bold?: boolean; bold?: boolean;
@ -121,6 +121,14 @@ const classes = computed(() => {
color: var(--color-warning); color: var(--color-warning);
} }
.foreground-dark {
color: var(--color-foreground-dark);
}
.foreground-xdark {
color: var(--color-foreground-xdark);
}
.align-left { .align-left {
text-align: left; text-align: left;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -213,7 +213,7 @@ defineExpose({ isWithinDropdown });
:width="width" :width="width"
:popper-class="$style.popover" :popper-class="$style.popover"
:visible="show" :visible="show"
:teleported="false" :teleported="true"
data-test-id="resource-locator-dropdown" data-test-id="resource-locator-dropdown"
> >
<div v-if="errorView" :class="$style.messageContainer"> <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> <template>
<div :class="$style.arrowConnector"></div> <div :class="classes">
<div :class="$style.stalk"></div>
<div :class="$style.arrowHead"></div>
</div>
</template> </template>
<style module lang="scss"> <style module lang="scss">
.arrowConnector { .arrowConnector {
$arrow-width: 12px;
$arrow-height: 8px;
$stalk-width: 2px;
$color: var(--color-text-dark);
position: relative; position: relative;
height: var(--arrow-height, 3rem); 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 { &::after {
content: ''; content: '';
position: absolute; position: absolute;
top: 50%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translate(-50%, -50%);
width: 1rem;
height: 100%;
cursor: pointer;
} }
}
&::before { .arrowHead {
top: 0; position: absolute;
width: $stalk-width; bottom: 0;
height: calc(100% - #{$arrow-height}); left: 50%;
background-color: $color; transform: translateX(-50%);
} width: 0;
height: 0;
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 { &::after {
bottom: 0; content: '';
width: 0; position: absolute;
height: 0; top: 50%;
border-left: calc($arrow-width / 2) solid transparent; left: 50%;
border-right: calc($arrow-width / 2) solid transparent; transform: translate(-50%, -50%);
border-top: $arrow-height solid $color; 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> </style>

View file

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

View file

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

View file

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

View file

@ -1,9 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { VIEWS } from '@/constants';
import { SAMPLE_EVALUATION_WORKFLOW } from '@/constants.workflows'; import { SAMPLE_EVALUATION_WORKFLOW } from '@/constants.workflows';
import type { IWorkflowDataCreate } from '@/Interface'; 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 type { INodeParameterResourceLocator, IPinData } from 'n8n-workflow';
import { computed } from 'vue'; import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
interface WorkflowSelectorProps { interface WorkflowSelectorProps {
modelValue: INodeParameterResourceLocator; modelValue: INodeParameterResourceLocator;
@ -21,12 +26,16 @@ const props = withDefaults(defineProps<WorkflowSelectorProps>(), {
sampleWorkflowName: undefined, sampleWorkflowName: undefined,
}); });
defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: WorkflowSelectorProps['modelValue']]; 'update:modelValue': [value: WorkflowSelectorProps['modelValue']];
workflowCreated: [workflowId: string]; workflowCreated: [workflowId: string];
}>(); }>();
const locale = useI18n(); const locale = useI18n();
const projectStore = useProjectsStore();
const workflowsStore = useWorkflowsStore();
const router = useRouter();
const subworkflowName = computed(() => { const subworkflowName = computed(() => {
if (props.sampleWorkflowName) { if (props.sampleWorkflowName) {
return locale.baseText('testDefinition.workflowInput.subworkflowName', { return locale.baseText('testDefinition.workflowInput.subworkflowName', {
@ -43,31 +52,71 @@ const sampleWorkflow = computed<IWorkflowDataCreate>(() => {
pinData: props.examplePinnedData, 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> </script>
<template> <template>
<div> <div class="mt-xs">
<n8n-input-label <template v-if="!modelValue.value">
:label="locale.baseText('testDefinition.edit.workflowSelectorLabel')" <N8nButton type="secondary" class="mb-xs" @click="handleDefineEvaluation">
:bold="false" {{ locale.baseText('testDefinition.workflow.createNew') }}
> </N8nButton>
<WorkflowSelectorParameterInput <N8nLink class="mb-xs" style="display: block" @click="selectorVisible = !selectorVisible">
ref="workflowInput" {{ locale.baseText('testDefinition.workflow.createNew.or') }}
:parameter="{ </N8nLink>
displayName: locale.baseText('testDefinition.edit.workflowSelectorDisplayName'), </template>
name: 'workflowId',
type: 'workflowSelector', <WorkflowSelectorParameterInput
default: '', v-if="modelValue.value || selectorVisible"
}" :parameter="{
:model-value="modelValue" displayName: locale.baseText('testDefinition.edit.workflowSelectorDisplayName'),
:display-title="locale.baseText('testDefinition.edit.workflowSelectorTitle')" name: 'workflowId',
:is-value-expression="false" type: 'workflowSelector',
:expression-edit-dialog-visible="false" default: '',
:path="'workflows'" }"
allow-new :model-value="modelValue"
:sample-workflow="sampleWorkflow" :display-title="locale.baseText('testDefinition.edit.workflowSelectorTitle')"
@update:model-value="$emit('update:modelValue', $event)" :is-value-expression="false"
@workflow-created="$emit('workflowCreated', $event)" :expression-edit-dialog-visible="false"
/> :path="'workflows'"
</n8n-input-label> :allow-new="false"
:sample-workflow="sampleWorkflow"
:new-resource-label="locale.baseText('testDefinition.workflow.createNew')"
@update:model-value="updateModelValue"
@workflow-created="emit('workflowCreated', $event)"
/>
</div> </div>
</template> </template>

View file

@ -1,40 +1,72 @@
<script setup lang="ts"> <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 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 { 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 { NODE_PINNING_MODAL_KEY } from '@/constants';
import { ref } from 'vue'; import type { ITag, ModalState } from '@/Interface';
import type { IPinData } from 'n8n-workflow'; import type { IPinData } from 'n8n-workflow';
import { computed, ref } from 'vue';
defineProps<{ const props = defineProps<{
showConfig: boolean; showConfig: boolean;
tagUsageCount: number;
allTags: ITag[];
tagsById: Record<string, ITag>; tagsById: Record<string, ITag>;
isLoading: boolean; isLoading: boolean;
examplePinnedData?: IPinData; examplePinnedData?: IPinData;
sampleWorkflowName?: string; sampleWorkflowName?: string;
hasRuns: boolean;
getFieldIssues: (key: string) => Array<{ field: string; message: string }>; getFieldIssues: (key: string) => Array<{ field: string; message: string }>;
startEditing: (field: keyof EditableFormState) => void; startEditing: (field: keyof EditableFormState) => void;
saveChanges: (field: keyof EditableFormState) => void; saveChanges: (field: keyof EditableFormState) => void;
cancelEditing: (field: keyof EditableFormState) => void; cancelEditing: (field: keyof EditableFormState) => void;
createTag?: (name: string) => Promise<ITag>;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
openPinningModal: []; openPinningModal: [];
deleteMetric: [metric: Partial<TestMetricRecord>]; deleteMetric: [metric: TestMetricRecord];
openExecutionsViewForTag: [];
renameTag: [tag: string];
evaluationWorkflowCreated: [workflowId: string]; evaluationWorkflowCreated: [workflowId: string];
}>(); }>();
const locale = useI18n(); 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 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']>( const evaluationWorkflow = defineModel<EvaluationFormState['evaluationWorkflow']>(
'evaluationWorkflow', 'evaluationWorkflow',
{ required: true }, { required: true },
@ -46,153 +78,235 @@ const mockedNodes = defineModel<EvaluationFormState['mockedNodes']>('mockedNodes
const nodePinningModal = ref<ModalState | null>(null); const nodePinningModal = ref<ModalState | null>(null);
function updateChangedFieldsKeys(key: string) { const selectedTag = computed(() => {
changedFieldsKeys.value.push(key); return props.tagsById[tags.value.value[0]] ?? {};
});
function openExecutionsView() {
emit('openExecutionsViewForTag');
} }
function showFieldIssues(fieldKey: string) { function showTooltip(event: MouseEvent, tooltip: string) {
return changedFieldsKeys.value.includes(fieldKey); 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> </script>
<template> <template>
<div :class="[$style.panelBlock, { [$style.hidden]: !showConfig }]"> <div :class="[$style.container, { [$style.hidden]: !showConfig }]">
<div :class="$style.panelIntro"> <div :class="$style.editForm">
{{ locale.baseText('testDefinition.edit.step.intro') }} <div :class="$style.panelIntro">
{{ locale.baseText('testDefinition.edit.step.intro') }}
</div>
<!-- Select Executions -->
<EvaluationStep
:class="[$style.step, $style.reducedSpacing]"
:issues="getFieldIssues('tags')"
:tooltip="
hasRuns ? locale.baseText('testDefinition.edit.step.executions.tooltip') : undefined
"
@mouseenter="
showTooltip($event, locale.baseText('testDefinition.edit.step.executions.tooltip'))
"
@mouseleave="hideTooltip"
>
<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>
<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>
<!-- Mocked Nodes -->
<EvaluationStep
:class="$style.step"
:title="
locale.baseText('testDefinition.edit.step.mockedNodes', {
adjustToNumber: mockedNodes?.length ?? 0,
})
"
:small="true"
:issues="getFieldIssues('mockedNodes')"
:tooltip="hasRuns ? locale.baseText('testDefinition.edit.step.nodes.tooltip') : undefined"
@mouseenter="showTooltip($event, locale.baseText('testDefinition.edit.step.nodes.tooltip'))"
@mouseleave="hideTooltip"
>
<template #containerPrefix>
<BlockArrow :class="[$style.diagramArrow, $style.right]" />
</template>
<template #cardContent>
<n8n-button
size="small"
data-test-id="select-nodes-button"
:label="locale.baseText('testDefinition.edit.selectNodes')"
type="tertiary"
@click="$emit('openPinningModal')"
/>
</template>
</EvaluationStep>
<!-- Re-run Executions -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.reRunExecutions')"
:small="true"
:tooltip="
hasRuns ? locale.baseText('testDefinition.edit.step.reRunExecutions.tooltip') : undefined
"
@mouseenter="
showTooltip($event, locale.baseText('testDefinition.edit.step.reRunExecutions.tooltip'))
"
@mouseleave="hideTooltip"
>
<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.workflowSelectorLabel')"
:issues="getFieldIssues('evaluationWorkflow')"
:tooltip="
hasRuns
? locale.baseText('testDefinition.edit.step.compareExecutions.tooltip')
: undefined
"
@mouseenter="
showTooltip($event, locale.baseText('testDefinition.edit.step.compareExecutions.tooltip'))
"
@mouseleave="hideTooltip"
>
<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"
@workflow-created="$emit('evaluationWorkflowCreated', $event)"
/>
</template>
</EvaluationStep>
<!-- Metrics -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.metrics')"
:issues="getFieldIssues('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 #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)"
/>
</template>
</EvaluationStep>
</div> </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')"
:issues="getFieldIssues('tags')"
:show-issues="showFieldIssues('tags')"
>
<template #icon><font-awesome-icon icon="history" size="lg" /></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')"
/>
</template>
</EvaluationStep>
<div :class="$style.evaluationArrows">
<BlockArrow />
<BlockArrow />
</div>
<!-- Mocked Nodes -->
<EvaluationStep
:class="$style.step"
:title="
locale.baseText('testDefinition.edit.step.mockedNodes', {
adjustToNumber: mockedNodes?.length ?? 0,
})
"
:small="true"
:expanded="true"
:description="locale.baseText('testDefinition.edit.step.nodes.description')"
:issues="getFieldIssues('mockedNodes')"
:show-issues="showFieldIssues('mockedNodes')"
>
<template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template>
<template #cardContent>
<n8n-button
size="small"
data-test-id="select-nodes-button"
:label="locale.baseText('testDefinition.edit.selectNodes')"
type="tertiary"
@click="$emit('openPinningModal')"
/>
</template>
</EvaluationStep>
<!-- Re-run Executions -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.reRunExecutions')"
:small="true"
:description="locale.baseText('testDefinition.edit.step.reRunExecutions.description')"
>
<template #icon><font-awesome-icon icon="redo" size="lg" /></template>
</EvaluationStep>
<!-- Compare Executions -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.compareExecutions')"
:description="locale.baseText('testDefinition.edit.step.compareExecutions.description')"
:issues="getFieldIssues('evaluationWorkflow')"
:show-issues="showFieldIssues('evaluationWorkflow')"
>
<template #icon><font-awesome-icon icon="equals" size="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>
</EvaluationStep>
<!-- Metrics -->
<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')"
>
<template #icon><font-awesome-icon icon="chart-bar" size="lg" /></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>
<Modal ref="nodePinningModal" width="80vw" height="85vh" :name="NODE_PINNING_MODAL_KEY"> <Modal ref="nodePinningModal" width="80vw" height="85vh" :name="NODE_PINNING_MODAL_KEY">
<template #header> <template #header>
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{ <N8nHeading size="large" :bold="true">{{
locale.baseText('testDefinition.edit.selectNodes') locale.baseText('testDefinition.edit.selectNodes')
}}</N8nHeading> }}</N8nHeading>
<br />
<N8nText :class="$style.modalDescription">{{
locale.baseText('testDefinition.edit.modal.description')
}}</N8nText>
</template> </template>
<template #content> <template #content>
<NodesPinning v-model="mockedNodes" data-test-id="nodes-pinning-modal" /> <NodesPinning v-model="mockedNodes" data-test-id="nodes-pinning-modal" />
</template> </template>
</Modal> </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> </div>
</template> </template>
<style module lang="scss"> <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); width: var(--evaluation-edit-panel-width);
display: grid; display: grid;
height: 100%; height: fit-content;
overflow-y: auto;
flex-shrink: 0; flex-shrink: 0;
padding-bottom: var(--spacing-l); padding-bottom: var(--spacing-l);
margin-left: var(--spacing-2xl);
transition: width 0.2s ease; transition: width 0.2s ease;
position: relative;
gap: var(--spacing-l);
margin: 0 auto;
&.hidden { &.hidden {
margin-left: 0; 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 { .panelIntro {
font-size: var(--font-size-m); font-size: var(--font-size-m);
color: var(--color-text-dark); color: var(--color-text-dark);
@ -215,28 +341,45 @@ function showFieldIssues(fieldKey: string) {
display: block; display: block;
} }
.step { .diagramArrow {
position: relative; --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%);
}
&:not(:first-child) { &.sm {
margin-top: var(--spacing-m); --arrow-height: 1.5rem;
}
&.lg {
--arrow-height: 14rem;
} }
} }
.introArrow { .tagInputContainer {
--arrow-height: 1.5rem; display: flex;
margin-bottom: -1rem; flex-direction: column;
justify-self: center; gap: var(--spacing-xs);
} }
.evaluationArrows { .tagInputTag {
--arrow-height: 23rem;
display: flex; display: flex;
justify-content: space-between; gap: var(--spacing-3xs);
width: 100%; font-size: var(--font-size-2xs);
max-width: 80%; color: var(--color-text-base);
margin: 0 auto; }
margin-bottom: -100%; .tagInputControls {
z-index: 0; display: flex;
gap: var(--spacing-2xs);
} }
</style> </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"> <script setup lang="ts">
import type { TestRunRecord } from '@/api/testDefinition.ee'; import type { TestRunRecord } from '@/api/testDefinition.ee';
import type { AppliedThemeOption } from '@/Interface';
import MetricsChart from '@/components/TestDefinition/ListRuns/MetricsChart.vue'; import MetricsChart from '@/components/TestDefinition/ListRuns/MetricsChart.vue';
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.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[]; runs: TestRunRecord[];
testId: string; testId: string;
appliedTheme: AppliedThemeOption; appliedTheme: AppliedThemeOption;
@ -13,25 +17,78 @@ defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
deleteRuns: [runs: TestRunRecord[]]; deleteRuns: [runs: TestRunRecord[]];
}>(); }>();
const locale = useI18n();
const router = useRouter();
const selectedMetric = defineModel<string>('selectedMetric', { required: true }); const selectedMetric = defineModel<string>('selectedMetric', { required: true });
function onDeleteRuns(toDelete: TestRunRecord[]) { function onDeleteRuns(toDelete: TestRunRecord[]) {
emit('deleteRuns', toDelete); 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> </script>
<template> <template>
<div :class="$style.runs"> <div :class="$style.runs">
<!-- Metrics Chart -->
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" /> <MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
<!-- Past Runs Table -->
<TestRunsTable <TestRunsTable
:class="$style.runsTable" :class="$style.runsTable"
:runs="runs" :runs
:columns
:selectable="true" :selectable="true"
data-test-id="past-runs-table" data-test-id="past-runs-table"
@delete-runs="onDeleteRuns" @delete-runs="onDeleteRuns"
@row-click="handleRowClick"
/> />
</div> </div>
</template> </template>
@ -42,11 +99,7 @@ function onDeleteRuns(toDelete: TestRunRecord[]) {
flex-direction: column; flex-direction: column;
gap: var(--spacing-m); gap: var(--spacing-m);
flex: 1; flex: 1;
padding-top: var(--spacing-3xs);
overflow: auto; overflow: auto;
margin-bottom: 20px;
@media (min-height: 56rem) {
margin-top: var(--spacing-2xl);
}
} }
</style> </style>

View file

@ -1,57 +1,119 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { N8nBadge, N8nButton, N8nText } from 'n8n-design-system';
import { computed } from 'vue';
defineEmits<{ 'create-test': [] }>(); defineEmits<{ 'create-test': [] }>();
const locale = useI18n(); 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> </script>
<template> <template>
<div :class="$style.container"> <div :class="$style.container">
<div :class="$style.header"> <div :class="{ [$style.card]: true, [$style.cardActive]: true }">
<h1>{{ locale.baseText('testDefinition.list.tests') }}</h1> <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>
<div :class="$style.content"> <div :class="{ [$style.card]: true, [$style.cardInActive]: true }">
<n8n-action-box <N8nBadge>
:class="$style.actionBox" {{ locale.baseText('testDefinition.list.unitTests.badge') }}
:heading="locale.baseText('testDefinition.list.evaluations')" </N8nBadge>
:description="locale.baseText('testDefinition.list.actionDescription')" <div :class="$style.cardContent">
:button-text="locale.baseText('testDefinition.list.actionButton')" <N8nText tag="h2" size="xlarge" color="text-base" class="mb-2xs">
@click:button="$emit('create-test')" {{ locale.baseText('testDefinition.list.unitTests.title') }}
/> </N8nText>
<n8n-action-box <N8nText tag="div" color="text-base" class="mb-s">
:class="$style.actionBox" {{ locale.baseText('testDefinition.list.unitTests.description') }}
:heading="locale.baseText('testDefinition.list.unitTests.title')" </N8nText>
:description="locale.baseText('testDefinition.list.unitTests.description')" <N8nButton type="secondary">
:button-text="locale.baseText('testDefinition.list.unitTests.cta')" {{ locale.baseText('testDefinition.list.unitTests.cta') }}
button-type="tertiary" </N8nButton>
/> </div>
</div> </div>
</div> </div>
</template> </template>
<style module lang="scss"> <style module lang="scss">
.container { .container {
max-width: 75rem;
}
.header {
display: flex; display: flex;
justify-content: space-between; justify-content: center;
height: 100%;
align-items: center; align-items: center;
color: var(--color-text-dark); gap: 24px;
font-size: var(--font-size-l); }
margin-bottom: var(--spacing-xl);
h1 { .card {
margin: 0; border-radius: var(--border-radius-base);
} width: 280px;
} height: 290px;
.content {
width: 100%;
display: flex; 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> </style>

View file

@ -1,139 +1,181 @@
<script setup lang="ts"> <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 TimeAgo from '@/components/TimeAgo.vue';
import { useI18n } from '@/composables/useI18n'; 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'; import { computed } from 'vue';
export interface TestItemProps { const props = defineProps<{
test: TestListItem; name: string;
actions: TestItemAction[]; testCases: number;
} execution?: TestRunRecord;
errors?: Array<{ field: string; message: string }>;
const props = defineProps<TestItemProps>();
const locale = useI18n();
defineEmits<{
'view-details': [testId: string];
}>(); }>();
const visibleActions = computed(() => const locale = useI18n();
props.actions.filter((action) => action.show?.(props.test.id) ?? true),
); 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> </script>
<template> <template>
<div <div :class="$style.testCard">
:class="$style.testItem" <div :class="$style.testCardContent">
:data-test-id="`test-item-${test.id}`" <div>
@click="$emit('view-details', test.id)" <N8nText bold tag="div">{{ name }}</N8nText>
> <N8nText tag="div" color="text-base" size="small">
<div :class="$style.testInfo"> {{
<div :class="$style.testName"> locale.baseText('testDefinition.list.item.tests', {
{{ test.name }} adjustToNumber: testCases,
})
}}
</N8nText>
</div> </div>
<div :class="$style.testCases"> <div>
<n8n-text size="small"> <div :class="$style.status">
{{ locale.baseText('testDefinition.list.testRuns', { adjustToNumber: test.testCases }) }} <N8nIcon :icon="statusRender.icon" size="small" :color="statusRender.color"></N8nIcon>
</n8n-text> <div>
<template v-if="test.execution.status === 'running'"> <N8nText size="small" color="text-base">
{{ locale.baseText('testDefinition.list.running') }} {{ statusRender.label }}
<n8n-spinner /> </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">
<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> </template>
<span v-else-if="test.execution.lastRun">
{{ locale.baseText('testDefinition.list.lastRun') }}
<TimeAgo :date="test.execution.lastRun" />
</span>
</div> </div>
</div> </div>
<div :class="$style.metrics"> <slot name="prepend"></slot>
<div :class="$style.metric"> <slot name="append"></slot>
{{
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) ?? '-' }}
</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>
</div> </div>
</template> </template>
<style module lang="scss"> <style module lang="scss">
.testItem { .testCard {
display: flex; display: flex;
align-items: center; align-items: center;
padding: var(--spacing-s) var(--spacing-s) var(--spacing-s) var(--spacing-xl); background-color: var(--color-background-xlight);
border: 1px solid var(--color-foreground-base); padding: var(--spacing-xs) 20px var(--spacing-xs) var(--spacing-m);
border-radius: var(--border-radius-base); gap: var(--spacing-s);
background-color: var(--color-background-light); border-bottom: 1px solid var(--color-foreground-base);
cursor: pointer; 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 { &:hover {
background-color: var(--color-background-base); background-color: var(--color-background-light);
.name {
color: var(--color-primary);
}
} }
} }
.testInfo { .status {
display: flex; 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; flex: 1;
gap: var(--spacing-2xs); gap: var(--spacing-xs);
}
.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);
} }
.metrics { .metrics {
display: flex; display: grid;
gap: var(--spacing-l); grid-template-columns: 120px 1fr;
margin: 0 var(--spacing-l); column-gap: 18px;
}
.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);
} }
</style> </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"> <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 type { TestRunRecord } from '@/api/testDefinition.ee';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import type { AppliedThemeOption } from '@/Interface'; import type { AppliedThemeOption } from '@/Interface';
import { computed, watchEffect } from 'vue';
import { Line } from 'vue-chartjs';
import { useMetricsChart } from '../composables/useMetricsChart';
const emit = defineEmits<{ const emit = defineEmits<{
'update:selectedMetric': [value: string]; 'update:selectedMetric': [value: string];
@ -59,7 +59,6 @@ watchEffect(() => {
:value="metric" :value="metric"
/> />
</N8nSelect> </N8nSelect>
<N8nText>{{ locale.baseText('testDefinition.listRuns.metricsOverTime') }}</N8nText>
</div> </div>
<div :class="$style.chartWrapper"> <div :class="$style.chartWrapper">
<Line <Line
@ -84,7 +83,6 @@ watchEffect(() => {
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
gap: var(--spacing-s); gap: var(--spacing-s);
margin-bottom: var(--spacing-m);
padding: var(--spacing-xs) var(--spacing-s); padding: var(--spacing-xs) var(--spacing-s);
border-bottom: 1px solid var(--color-foreground-base); border-bottom: 1px solid var(--color-foreground-base);
} }
@ -101,7 +99,7 @@ watchEffect(() => {
.chartWrapper { .chartWrapper {
position: relative; position: relative;
height: var(--metrics-chart-height, 400px); height: var(--metrics-chart-height, 200px);
width: 100%; width: 100%;
padding: var(--spacing-s); padding: var(--spacing-s);
} }

View file

@ -1,25 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TestRunRecord } from '@/api/testDefinition.ee'; 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 { computed, ref } from 'vue';
import type { TestTableColumn } from '../shared/TestTableBase.vue'; import type { TestTableColumn } from '../shared/TestTableBase.vue';
import TestTableBase 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<{ const emit = defineEmits<{
getRunDetail: [run: TestRunRecord]; rowClick: [run: TestRunRecord];
selectionChange: [runs: TestRunRecord[]]; selectionChange: [runs: TestRunRecord[]];
deleteRuns: [runs: TestRunRecord[]]; deleteRuns: [runs: TestRunRecord[]];
}>(); }>();
const props = defineProps<{ const props = defineProps<{
runs: TestRunRecord[]; runs: TestRunRecord[];
columns: Array<TestTableColumn<TestRunRecord>>;
selectable?: boolean; 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 locale = useI18n();
const navigateToRunDetail = (run: TestRunRecord) => emit('getRunDetail', run);
const selectedRows = ref<TestRunRecord[]>([]); const selectedRows = ref<TestRunRecord[]>([]);
// Combine test run statuses and finalResult to get the final status // 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[]) { function onSelectionChange(runs: TestRunRecord[]) {
selectedRows.value = runs; selectedRows.value = runs;
emit('selectionChange', runs); emit('selectionChange', runs);
@ -110,7 +56,7 @@ async function deleteRuns() {
<template> <template>
<div :class="$style.container"> <div :class="$style.container">
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{ <N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{
locale.baseText('testDefinition.edit.pastRuns') locale.baseText('testDefinition.edit.pastRuns.total', { adjustToNumber: runs.length })
}}</N8nHeading> }}</N8nHeading>
<div :class="$style.header"> <div :class="$style.header">
<n8n-button <n8n-button
@ -129,16 +75,33 @@ async function deleteRuns() {
}} }}
</n8n-button> </n8n-button>
</div> </div>
<TestTableBase <TestTableBase
:data="runSummaries" :data="runSummaries"
:columns="columns" :columns="columns"
selectable selectable
@row-click="navigateToRunDetail" :default-sort="{ prop: 'runAt', order: 'descending' }"
@row-click="(row) => emit('rowClick', row)"
@selection-change="onSelectionChange" @selection-change="onSelectionChange"
/> >
<N8nText :class="$style.runsTableTotal">{{ <template #status="{ row }">
locale.baseText('testDefinition.edit.pastRuns.total', { adjustToNumber: runs.length }) <div
}}</N8nText> 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> </div>
</template> </template>

View file

@ -56,9 +56,9 @@ export function useTestDefinitionForm() {
/** /**
* Load test data including metrics. * Load test data including metrics.
*/ */
const loadTestData = async (testId: string) => { const loadTestData = async (testId: string, workflowId: string) => {
try { try {
await evaluationsStore.fetchAll({ force: true }); await evaluationsStore.fetchAll({ force: true, workflowId });
const testDefinition = evaluationsStore.testDefinitionsById[testId]; const testDefinition = evaluationsStore.testDefinitionsById[testId];
if (testDefinition) { if (testDefinition) {
@ -86,6 +86,7 @@ export function useTestDefinitionForm() {
}; };
state.value.metrics = metrics; state.value.metrics = metrics;
state.value.mockedNodes = testDefinition.mockedNodes ?? []; state.value.mockedNodes = testDefinition.mockedNodes ?? [];
evaluationsStore.updateRunFieldIssues(testDefinition.id);
} }
} catch (error) { } catch (error) {
console.error('Failed to load test data', 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); 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 updateMetrics = async (testId: string) => {
const promises = state.value.metrics.map(async (metric) => { const promises = state.value.metrics.map(async (metric) => {
if (!metric.name) return; if (!metric.name) return;
@ -159,9 +164,7 @@ export function useTestDefinitionForm() {
if (annotationTagId) { if (annotationTagId) {
params.annotationTagId = annotationTagId; params.annotationTagId = annotationTagId;
} }
if (state.value.mockedNodes.length > 0) { params.mockedNodes = state.value.mockedNodes;
params.mockedNodes = state.value.mockedNodes;
}
const response = await evaluationsStore.update({ ...params, id: testId }); const response = await evaluationsStore.update({ ...params, id: testId });
return response; return response;

View file

@ -1,11 +1,10 @@
<script setup lang="ts" generic="T extends object"> <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 type { TableInstance } from 'element-plus';
import { ElTable, ElTableColumn } from 'element-plus';
import { isEqual } from 'lodash-es'; 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 * A reusable table component for displaying evaluation results data
* @template T - The type of data being displayed in the table rows * @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> = { export type TestTableColumn<TRow> = {
prop: string; prop: string;
label: string; label: string;
showHeaderTooltip?: boolean;
showOverflowTooltip?: boolean;
width?: number; width?: number;
sortable?: boolean; sortable?: boolean;
filters?: Array<{ text: string; value: string }>; filters?: Array<{ text: string; value: string }>;
@ -30,11 +31,6 @@ export type TestTableColumn<TRow> = {
}; };
type TableRow = T & { id: string }; type TableRow = T & { id: string };
type TableRowWithStatus = TableRow & { status: string };
const MIN_TABLE_HEIGHT = 350;
const MAX_TABLE_HEIGHT = 1400;
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
data: TableRow[]; data: TableRow[];
@ -54,7 +50,6 @@ const props = withDefaults(
const tableRef = ref<TableInstance>(); const tableRef = ref<TableInstance>();
const selectedRows = ref<TableRow[]>([]); const selectedRows = ref<TableRow[]>([]);
const localData = ref<TableRow[]>([]); const localData = ref<TableRow[]>([]);
const tableHeight = ref<string>('100%');
const emit = defineEmits<{ const emit = defineEmits<{
rowClick: [row: TableRow]; rowClick: [row: TableRow];
selectionChange: [rows: TableRow[]]; selectionChange: [rows: TableRow[]];
@ -92,25 +87,21 @@ const handleSelectionChange = (rows: TableRow[]) => {
emit('selectionChange', rows); emit('selectionChange', rows);
}; };
const computeTableHeight = () => { const handleColumnResize = (
const containerHeight = tableRef.value?.$el?.parentElement?.clientHeight ?? 600; newWidth: number,
const height = Math.min(Math.max(containerHeight, MIN_TABLE_HEIGHT), MAX_TABLE_HEIGHT); _oldWidth: number,
tableHeight.value = `${height - 100}px`; column: { minWidth: number; width: number },
// event: MouseEvent,
) => {
if (column.minWidth && newWidth < column.minWidth) {
column.width = column.minWidth;
}
}; };
function hasStatus(row: unknown): row is TableRowWithStatus { defineSlots<{
return typeof row === 'object' && row !== null && 'status' in row; id(props: { row: TableRow }): unknown;
} status(props: { row: TableRow }): unknown;
}>();
onMounted(() => {
computeTableHeight();
window.addEventListener('resize', computeTableHeight);
});
onUnmounted(() => {
window.removeEventListener('resize', computeTableHeight);
});
</script> </script>
<template> <template>
@ -120,16 +111,21 @@ onUnmounted(() => {
:default-sort="defaultSort" :default-sort="defaultSort"
:data="localData" :data="localData"
:border="true" :border="true"
:max-height="tableHeight" :cell-class-name="$style.customCell"
resizable :row-class-name="$style.customRow"
scrollbar-always-on
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
@vue:mounted="computeTableHeight" @header-dragend="handleColumnResize"
@row-click="(row) => $emit('rowClick', row)"
> >
<ElTableColumn <ElTableColumn
v-if="selectable" v-if="selectable"
type="selection" type="selection"
:selectable="selectableFilter" :selectable="selectableFilter"
data-test-id="table-column-select" data-test-id="table-column-select"
width="46"
fixed
align="center"
/> />
<ElTableColumn <ElTableColumn
v-for="column in columns" v-for="column in columns"
@ -137,30 +133,102 @@ onUnmounted(() => {
v-bind="column" v-bind="column"
:resizable="true" :resizable="true"
data-test-id="table-column" 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 }"> <template #default="{ row }">
<TableStatusCell <slot v-if="column.prop === 'id'" name="id" v-bind="{ row }"></slot>
v-if="column.prop === 'status' && hasStatus(row)" <slot v-if="column.prop === 'status'" name="status" v-bind="{ row }"></slot>
: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)"
/>
</template> </template>
</ElTableColumn> </ElTableColumn>
</ElTable> </ElTable>
</template> </template>
<style module lang="scss"> <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 { .table {
:global(.el-table__cell) { border-radius: 12px;
padding: var(--spacing-3xs) 0;
: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> </style>

View file

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

View file

@ -13,6 +13,7 @@ const TEST_DEF_A: TestDefinitionRecord = {
workflowId: '123', workflowId: '123',
annotationTagId: '789', annotationTagId: '789',
annotationTag: null, annotationTag: null,
createdAt: '2023-01-01T00:00:00.000Z',
}; };
const TEST_DEF_B: TestDefinitionRecord = { const TEST_DEF_B: TestDefinitionRecord = {
id: '2', id: '2',
@ -20,6 +21,7 @@ const TEST_DEF_B: TestDefinitionRecord = {
workflowId: '123', workflowId: '123',
description: 'Description B', description: 'Description B',
annotationTag: null, annotationTag: null,
createdAt: '2023-01-01T00:00:00.000Z',
}; };
const TEST_DEF_NEW: TestDefinitionRecord = { const TEST_DEF_NEW: TestDefinitionRecord = {
id: '3', id: '3',
@ -27,6 +29,7 @@ const TEST_DEF_NEW: TestDefinitionRecord = {
name: 'New Test Definition', name: 'New Test Definition',
description: 'New Description', description: 'New Description',
annotationTag: null, annotationTag: null,
createdAt: '2023-01-01T00:00:00.000Z',
}; };
beforeEach(() => { beforeEach(() => {
@ -66,7 +69,7 @@ describe('useTestDefinitionForm', () => {
[TEST_DEF_B.id]: TEST_DEF_B, [TEST_DEF_B.id]: TEST_DEF_B,
}; };
await loadTestData(TEST_DEF_A.id); await loadTestData(TEST_DEF_A.id, '123');
expect(fetchSpy).toBeCalled(); expect(fetchSpy).toBeCalled();
expect(fetchMetricsSpy).toBeCalledWith(TEST_DEF_A.id); expect(fetchMetricsSpy).toBeCalledWith(TEST_DEF_A.id);
expect(state.value.name.value).toEqual(TEST_DEF_A.name); expect(state.value.name.value).toEqual(TEST_DEF_A.name);
@ -85,7 +88,7 @@ describe('useTestDefinitionForm', () => {
evaluationsStore.testDefinitionsById = {}; evaluationsStore.testDefinitionsById = {};
await loadTestData('unknown-id'); await loadTestData('unknown-id', '123');
expect(fetchSpy).toBeCalled(); expect(fetchSpy).toBeCalled();
// Should remain unchanged since no definition found // Should remain unchanged since no definition found
expect(state.value.description.value).toBe(''); expect(state.value.description.value).toBe('');
@ -101,7 +104,7 @@ describe('useTestDefinitionForm', () => {
.mockRejectedValue(new Error('Fetch Failed')); .mockRejectedValue(new Error('Fetch Failed'));
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await loadTestData(TEST_DEF_A.id); await loadTestData(TEST_DEF_A.id, '123');
expect(fetchSpy).toBeCalled(); expect(fetchSpy).toBeCalled();
expect(consoleErrorSpy).toBeCalledWith('Failed to load test data', expect.any(Error)); expect(consoleErrorSpy).toBeCalledWith('Failed to load test data', expect.any(Error));
consoleErrorSpy.mockRestore(); consoleErrorSpy.mockRestore();
@ -150,6 +153,7 @@ describe('useTestDefinitionForm', () => {
id: TEST_DEF_A.id, id: TEST_DEF_A.id,
name: TEST_DEF_B.name, name: TEST_DEF_B.name,
description: TEST_DEF_B.description, description: TEST_DEF_B.description,
mockedNodes: [],
}); });
expect(updatedTest).toEqual(updatedBTest); 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'; import type { INodeParameterResourceLocator } from 'n8n-workflow';
export interface EditableField<T = string> { export interface EditableField<T = string> {
@ -6,16 +6,6 @@ export interface EditableField<T = string> {
tempValue: T; tempValue: T;
isEditing: boolean; 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 { export interface EditableFormState {
name: EditableField<string>; name: EditableField<string>;
tags: EditableField<string[]>; tags: EditableField<string[]>;
@ -27,20 +17,3 @@ export interface EvaluationFormState extends EditableFormState {
metrics: TestMetricRecord[]; metrics: TestMetricRecord[];
mockedNodes: Array<{ name: string; id: string }>; 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[]; parameterIssues?: string[];
parameter: INodeProperties; parameter: INodeProperties;
sampleWorkflow?: IWorkflowDataCreate; sampleWorkflow?: IWorkflowDataCreate;
newResourceLabel?: string;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@ -45,6 +46,7 @@ const props = withDefaults(defineProps<Props>(), {
isReadOnly: false, isReadOnly: false,
forceShowExpression: false, forceShowExpression: false,
expressionDisplayValue: '', expressionDisplayValue: '',
newResourceLabel: '',
parameterIssues: () => [], parameterIssues: () => [],
sampleWorkflow: () => SAMPLE_SUBWORKFLOW_WORKFLOW, sampleWorkflow: () => SAMPLE_SUBWORKFLOW_WORKFLOW,
}); });
@ -104,6 +106,10 @@ const currentProjectName = computed(() => {
}); });
const getCreateResourceLabel = computed(() => { const getCreateResourceLabel = computed(() => {
if (props.newResourceLabel) {
return props.newResourceLabel;
}
if (!currentProjectName.value) { if (!currentProjectName.value) {
return i18n.baseText('executeWorkflowTrigger.createNewSubworkflow.noProject'); return i18n.baseText('executeWorkflowTrigger.createNewSubworkflow.noProject');
} }

View file

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

View file

@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch } from 'vue';
import { onBeforeRouteLeave, useRouter } from 'vue-router';
import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue'; 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 { 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( const props = withDefaults(
defineProps<{ defineProps<{
@ -34,7 +34,6 @@ const emit = defineEmits<{
}>(); }>();
const workflowHelpers = useWorkflowHelpers({ router: useRouter() }); const workflowHelpers = useWorkflowHelpers({ router: useRouter() });
const router = useRouter();
const temporaryExecution = computed<ExecutionSummary | undefined>(() => const temporaryExecution = computed<ExecutionSummary | undefined>(() =>
props.executions.find((execution) => execution.id === props.execution?.id) 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) => { onBeforeRouteLeave(async (to, _, next) => {
if (getNodeViewTab(to) === MAIN_HEADER_TABS.WORKFLOW) { if (getNodeViewTab(to) === MAIN_HEADER_TABS.WORKFLOW) {
next(); next();

View file

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

View file

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue'; import { computed, ref, onMounted } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { ElDropdown } from 'element-plus'; import { ElDropdown } from 'element-plus';
import { useExecutionDebugging } from '@/composables/useExecutionDebugging'; import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
import { useMessage } from '@/composables/useMessage'; import { useMessage } from '@/composables/useMessage';
@ -10,10 +10,15 @@ import { EnterpriseEditionFeature, MODAL_CONFIRM, VIEWS } from '@/constants';
import type { ExecutionSummary } from 'n8n-workflow'; import type { ExecutionSummary } from 'n8n-workflow';
import type { IExecutionUIData } from '@/composables/useExecutionHelpers'; import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers'; import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { getResourcePermissions } from '@/permissions'; import { getResourcePermissions } from '@/permissions';
import { useSettingsStore } from '@/stores/settings.store'; 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>; type RetryDropdownRef = InstanceType<typeof ElDropdown>;
@ -28,15 +33,19 @@ const emit = defineEmits<{
}>(); }>();
const route = useRoute(); const route = useRoute();
const router = useRouter();
const locale = useI18n(); const locale = useI18n();
const executionHelpers = useExecutionHelpers(); const executionHelpers = useExecutionHelpers();
const message = useMessage(); const message = useMessage();
const toast = useToast();
const executionDebugging = useExecutionDebugging(); const executionDebugging = useExecutionDebugging();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const testDefinitionStore = useTestDefinitionStore();
const executionsStore = useExecutionsStore();
const retryDropdownRef = ref<RetryDropdownRef | null>(null); const retryDropdownRef = ref<RetryDropdownRef | null>(null);
const actionToggleRef = ref<InstanceType<typeof ProjectCreateResource> | null>(null);
const workflowId = computed(() => route.params.name as string); const workflowId = computed(() => route.params.name as string);
const workflowPermissions = computed( const workflowPermissions = computed(
() => getResourcePermissions(workflowsStore.getWorkflowById(workflowId.value)?.scopes).workflow, () => getResourcePermissions(workflowsStore.getWorkflowById(workflowId.value)?.scopes).workflow,
@ -70,6 +79,61 @@ const hasAnnotation = computed(
(props.execution?.annotation.vote || props.execution?.annotation.tags.length > 0), (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> { async function onDeleteExecution(): Promise<void> {
// Prepend the message with a note about annotations if they exist // Prepend the message with a note about annotations if they exist
const confirmationText = [ const confirmationText = [
@ -108,6 +172,40 @@ function onRetryButtonBlur(event: FocusEvent) {
retryDropdownRef.value.handleClose(); 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> </script>
<template> <template>
@ -201,7 +299,20 @@ function onRetryButtonBlur(event: FocusEvent) {
</router-link> </router-link>
</N8nText> </N8nText>
</div> </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 <router-link
:to="{ :to="{
name: VIEWS.EXECUTION_DEBUG, name: VIEWS.EXECUTION_DEBUG,
@ -341,12 +452,15 @@ function onRetryButtonBlur(event: FocusEvent) {
} }
.debugLink { .debugLink {
margin-right: var(--spacing-xs);
a > span { a > span {
display: block; display: block;
padding: var(--button-padding-vertical, var(--spacing-xs)) padding: var(--button-padding-vertical, var(--spacing-xs))
var(--button-padding-horizontal, var(--spacing-m)); var(--button-padding-horizontal, var(--spacing-m));
} }
} }
.actions {
display: flex;
gap: var(--spacing-xs);
}
</style> </style>

View file

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

View file

@ -46,19 +46,19 @@ export const SAMPLE_EVALUATION_WORKFLOW: IWorkflowDataCreate = {
parameters: { parameters: {
inputSource: 'passthrough', inputSource: 'passthrough',
}, },
id: 'ad3156ed-3007-4a09-8527-920505339812', id: 'c20c82d6-5f71-4fb6-a398-a10a6e6944c5',
name: 'When called by a test run', name: 'When called by a test run',
type: 'n8n-nodes-base.executeWorkflowTrigger', type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1, typeVersion: 1.1,
position: [620, 380], position: [80, 440],
}, },
{ {
parameters: {}, parameters: {},
id: '5ff0deaf-6ec9-4a0f-a906-70f1d8375e7c', id: '4e14d09a-2699-4659-9a20-e4f4965f473e',
name: 'Replace me', name: 'Replace me',
type: 'n8n-nodes-base.noOp', type: 'n8n-nodes-base.noOp',
typeVersion: 1, typeVersion: 1,
position: [860, 380], position: [340, 440],
}, },
{ {
parameters: { parameters: {
@ -66,87 +66,80 @@ export const SAMPLE_EVALUATION_WORKFLOW: IWorkflowDataCreate = {
assignments: [ assignments: [
{ {
id: 'a748051d-ebdb-4fcf-aaed-02756130ce2a', id: 'a748051d-ebdb-4fcf-aaed-02756130ce2a',
name: 'my_metric', name: 'latency',
value: 1, 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', type: 'number',
}, },
], ],
}, },
options: {}, options: {},
}, },
id: '2cae7e85-7808-4cab-85c0-d233f47701a1', id: '33e2e94a-ec48-4e7b-b750-f56718d5105c',
name: 'Return metric(s)', name: 'Return metric(s)',
type: 'n8n-nodes-base.set', type: 'n8n-nodes-base.set',
typeVersion: 3.4, typeVersion: 3.4,
position: [1100, 380], position: [600, 440],
}, },
{ {
parameters: { parameters: {
content: 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", "### 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: 438, height: 458,
width: 217, width: 257,
color: 7, color: 7,
}, },
id: 'ecb90156-30a3-4a90-93d5-6aca702e2f6b', id: '55e5e311-e285-4000-bd1e-900bc3a07da3',
name: 'Sticky Note', name: 'Sticky Note',
type: 'n8n-nodes-base.stickyNote', type: 'n8n-nodes-base.stickyNote',
typeVersion: 1, typeVersion: 1,
position: [560, 105], position: [0, 140],
},
{
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],
}, },
{ {
parameters: { parameters: {
content: content:
'## Evaluation workflow\nThis workflow is used to check whether a single past execution being tested gives similar results when re-run', '### 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: 105, height: 459,
width: 694, 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', name: 'Sticky Note3',
type: 'n8n-nodes-base.stickyNote', type: 'n8n-nodes-base.stickyNote',
typeVersion: 1, typeVersion: 1,
position: [560, -25], position: [0, 0],
}, },
], ],
pinData: {
'When called by a test run': [
{
json: {
newExecution: {},
originalExecution: {},
},
},
],
},
connections: { connections: {
'When called by a test run': { 'When called by a test run': {
[NodeConnectionType.Main]: [ main: [
[ [
{ {
node: 'Replace me', node: 'Replace me',
@ -157,7 +150,7 @@ export const SAMPLE_EVALUATION_WORKFLOW: IWorkflowDataCreate = {
], ],
}, },
'Replace me': { 'Replace me': {
[NodeConnectionType.Main]: [ main: [
[ [
{ {
node: 'Return metric(s)', 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: { settings: {
executionOrder: 'v1', executionOrder: 'v1',
}, },

View file

@ -2817,14 +2817,14 @@
"testDefinition.edit.namePlaceholder": "Enter test name", "testDefinition.edit.namePlaceholder": "Enter test name",
"testDefinition.edit.metricsTitle": "Metrics", "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.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.metricsPlaceholder": "Enter metric name",
"testDefinition.edit.metricsNew": "New metric", "testDefinition.edit.metricsNew": "New metric",
"testDefinition.edit.selectTag": "Select tag...", "testDefinition.edit.selectTag": "Select tag...",
"testDefinition.edit.tagsHelpText": "Executions with this tag will be added as test cases to this test.", "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.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.workflowSelectorHelpText": "This workflow will be called once for each test case.",
"testDefinition.edit.updateTest": "Update test", "testDefinition.edit.updateTest": "Update test",
"testDefinition.edit.saveTest": "Save 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.pinNodes.noNodes.description": "Your workflow needs to have at least one node to run a test",
"testDefinition.edit.tagName": "Tag name", "testDefinition.edit.tagName": "Tag name",
"testDefinition.edit.step.intro": "When running a test", "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": "1. Fetch benchmark executions | 1. Fetch {count} benchmark execution | 1. Fetch {count} benchmark executions",
"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.tag": "Any past executions tagged {tag} are fetched",
"testDefinition.edit.step.mockedNodes": "2. Mock N nodes |2. Mock {count} node |2. Mock {count} nodes", "testDefinition.edit.step.tag.placeholder": "Enter new tag name",
"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.tag.validation.required": "Tag name is required",
"testDefinition.edit.step.reRunExecutions": "3. Simulation", "testDefinition.edit.step.tag.validation.tooLong": "Tag name is too long",
"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.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": "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": "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.collapse": "Collapse",
"testDefinition.edit.step.expand": "Expand", "testDefinition.edit.step.configure": "Configure",
"testDefinition.edit.selectNodes": "Select nodes", "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.runExecution": "Run execution",
"testDefinition.edit.pastRuns": "Past runs", "testDefinition.edit.pastRuns": "Past runs",
"testDefinition.edit.pastRuns.total": "No runs | {count} run | {count} runs", "testDefinition.edit.pastRuns.total": "No runs | Past run ({count}) | Past runs ({count})",
"testDefinition.edit.nodesPinning.pinButtonTooltip": "Pin execution data of this node during test run", "testDefinition.edit.nodesPinning.pinButtonTooltip": "Use benchmark data for this node during evaluation execution",
"testDefinition.edit.saving": "Saving...", "testDefinition.edit.saving": "Saving...",
"testDefinition.edit.saved": "Changes saved", "testDefinition.edit.saved": "Changes saved",
"testDefinition.list.testDeleted": "Test deleted", "testDefinition.list.testDeleted": "Test deleted",
"testDefinition.list.tests": "Tests", "testDefinition.list.tests": "Tests",
"testDefinition.list.evaluations": "Evaluations", "testDefinition.list.evaluations": "Evaluation",
"testDefinition.list.unitTests.title": "Unit tests", "testDefinition.list.unitTests.badge": "Coming soon",
"testDefinition.list.unitTests.description": "Test sections of your workflow in isolation", "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.unitTests.cta": "Register interest",
"testDefinition.list.createNew": "Create new test", "testDefinition.list.createNew": "Create new evaluation",
"testDefinition.list.actionDescription": "Replay past executions to check whether performance has changed", "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": "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.testRuns": "No test runs | {count} test run | {count} test runs",
"testDefinition.list.lastRun": "Ran", "testDefinition.list.lastRun": "Ran",
"testDefinition.list.running": "Running", "testDefinition.list.running": "Running",
@ -2873,6 +2885,8 @@
"testDefinition.list.testStarted": "Test run started", "testDefinition.list.testStarted": "Test run started",
"testDefinition.list.testCancelled": "Test run cancelled", "testDefinition.list.testCancelled": "Test run cancelled",
"testDefinition.list.loadError": "Failed to load tests", "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.new": "New",
"testDefinition.listRuns.status.running": "Running", "testDefinition.listRuns.status.running": "Running",
"testDefinition.listRuns.status.evaluating": "Evaluating", "testDefinition.listRuns.status.evaluating": "Evaluating",
@ -2883,7 +2897,7 @@
"testDefinition.listRuns.status.warning": "Warning", "testDefinition.listRuns.status.warning": "Warning",
"testDefinition.listRuns.metricsOverTime": "Metrics over time", "testDefinition.listRuns.metricsOverTime": "Metrics over time",
"testDefinition.listRuns.status": "Status", "testDefinition.listRuns.status": "Status",
"testDefinition.listRuns.runNumber": "Run #", "testDefinition.listRuns.runNumber": "Run",
"testDefinition.listRuns.runDate": "Run date", "testDefinition.listRuns.runDate": "Run date",
"testDefinition.listRuns.runStatus": "Run status", "testDefinition.listRuns.runStatus": "Run status",
"testDefinition.listRuns.noRuns": "No test runs", "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.deleteRuns": "No runs to delete | Delete {count} run | Delete {count} runs",
"testDefinition.listRuns.noRuns.button": "Run Test", "testDefinition.listRuns.noRuns.button": "Run Test",
"testDefinition.listRuns.error.noPastExecutions": "No executions added to the specified tag", "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.ranAt": "Ran at",
"testDefinition.runDetail.testCase": "Test case", "testDefinition.runDetail.testCase": "Test case",
"testDefinition.runDetail.testCase.id": "Test case ID", "testDefinition.runDetail.testCase.id": "Test case ID",
"testDefinition.runDetail.testCase.status": "Test case status", "testDefinition.runDetail.testCase.status": "Test case status",
"testDefinition.runDetail.totalCases": "Total cases", "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.mockedNodeMissing": "Output for a mocked node does not exist in benchmark execution.{link}.",
"testDefinition.runDetail.error.executionFailed": "Failed to execute workflow with benchmark trigger. <br /><a href=\"{url}\" target=\"_blank\">View execution</a>.", "testDefinition.runDetail.error.mockedNodeMissing.solution": "Fix test configuration",
"testDefinition.runDetail.error.evaluationFailed": "Failed to execute the evaluation workflow. <br /><a href=\"{url}\" target=\"_blank\">View evaluation execution</a>.", "testDefinition.runDetail.error.executionFailed": "Failed to execute workflow with benchmark trigger. {link}.",
"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.executionFailed.solution": "View execution",
"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.evaluationFailed": "Failed to execute the evaluation workflow. {link}.",
"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.evaluationFailed.solution": "View evaluation execution",
"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.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.runTest": "Run Test",
"testDefinition.cancelTestRun": "Cancel Test Run", "testDefinition.cancelTestRun": "Cancel Test Run",
"testDefinition.notImplemented": "This feature is not implemented yet!", "testDefinition.notImplemented": "This feature is not implemented yet!",
@ -2919,6 +2941,14 @@
"testDefinition.configError.noMetrics": "No metrics set", "testDefinition.configError.noMetrics": "No metrics set",
"testDefinition.workflowInput.subworkflowName": "Evaluation workflow for {name}", "testDefinition.workflowInput.subworkflowName": "Evaluation workflow for {name}",
"testDefinition.workflowInput.subworkflowName.default": "My Evaluation Sub-Workflow", "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.title": "Get {credits} free OpenAI API credits",
"freeAi.credits.callout.claim.button.label": "Claim 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:", "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, faCheckSquare,
faChevronDown, faChevronDown,
faChevronUp, faChevronUp,
faCircle,
faChevronLeft, faChevronLeft,
faChevronRight, faChevronRight,
faCode, faCode,
@ -166,6 +167,8 @@ import {
faPowerOff, faPowerOff,
faPaperPlane, faPaperPlane,
faExclamationCircle, faExclamationCircle,
faMinusCircle,
faAdjust,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { faVariable, faXmark, faVault, faRefresh } from './custom'; import { faVariable, faXmark, faVault, faRefresh } from './custom';
import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
@ -206,6 +209,7 @@ export const FontAwesomePlugin: Plugin = {
addIcon(faChevronRight); addIcon(faChevronRight);
addIcon(faChevronDown); addIcon(faChevronDown);
addIcon(faChevronUp); addIcon(faChevronUp);
addIcon(faCircle);
addIcon(faCode); addIcon(faCode);
addIcon(faCodeBranch); addIcon(faCodeBranch);
addIcon(faCog); addIcon(faCog);
@ -346,6 +350,8 @@ export const FontAwesomePlugin: Plugin = {
addIcon(faPowerOff); addIcon(faPowerOff);
addIcon(faPaperPlane); addIcon(faPaperPlane);
addIcon(faRefresh); addIcon(faRefresh);
addIcon(faMinusCircle);
addIcon(faAdjust);
app.component('FontAwesomeIcon', FontAwesomeIcon); app.component('FontAwesomeIcon', FontAwesomeIcon);
}, },

View file

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

View file

@ -88,9 +88,22 @@ const createTagsStore = (id: STORES.TAGS | STORES.ANNOTATION_TAGS) => {
return retrievedTags; return retrievedTags;
}; };
const create = async (name: string) => { const create = async (
const createdTag = await tagsApi.createTag(rootStore.restApiContext, { name }); 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]); upsertTags([createdTag]);
return createdTag; return createdTag;
}; };

View file

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

View file

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

View file

@ -1,139 +1,98 @@
<script setup lang="ts"> <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 { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm';
import { useDebounce } from '@/composables/useDebounce';
import HeaderSection from '@/components/TestDefinition/EditDefinition/sections/HeaderSection.vue'; import { useI18n } from '@/composables/useI18n';
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 { useTelemetry } from '@/composables/useTelemetry'; 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 { 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'; import type { IDataObject, IPinData } from 'n8n-workflow';
const props = defineProps<{ const props = defineProps<{
testId?: string; testId: string;
name: string;
}>(); }>();
const router = useRouter(); const router = useRouter();
const route = useRoute();
const locale = useI18n(); const locale = useI18n();
const { debounce } = useDebounce(); const { debounce } = useDebounce();
const toast = useToast(); const toast = useToast();
const testDefinitionStore = useTestDefinitionStore(); const testDefinitionStore = useTestDefinitionStore();
const tagsStore = useAnnotationTagsStore(); const tagsStore = useAnnotationTagsStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const telemetry = useTelemetry();
const workflowStore = useWorkflowsStore(); 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 { const {
state, state,
isSaving, isSaving,
cancelEditing, cancelEditing,
loadTestData, loadTestData,
createTest,
updateTest, updateTest,
startEditing, startEditing,
saveChanges, saveChanges,
handleKeydown,
deleteMetric, deleteMetric,
updateMetrics, updateMetrics,
} = useTestDefinitionForm(); } = useTestDefinitionForm();
const isLoading = computed(() => tagsStore.isLoading); const isLoading = computed(() => tagsStore.isLoading);
const allTags = computed(() => tagsStore.allTags);
const tagsById = computed(() => tagsStore.tagsById); const tagsById = computed(() => tagsStore.tagsById);
const testId = computed(() => props.testId ?? (route.params.testId as string)); const currentWorkflowId = computed(() => props.name);
const currentWorkflowId = computed(() => route.params.name as string);
const appliedTheme = computed(() => uiStore.appliedTheme); const appliedTheme = computed(() => uiStore.appliedTheme);
const tagUsageCount = computed(
() => tagsStore.tagsById[state.value.tags.value[0]]?.usageCount ?? 0,
);
const workflowName = computed(() => workflowStore.workflow.name); const workflowName = computed(() => workflowStore.workflow.name);
const hasRuns = computed(() => runs.value.length > 0); 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 showConfig = ref(true);
const selectedMetric = ref<string>(''); const selectedMetric = ref<string>('');
const examplePinnedData = ref<IPinData>({}); const examplePinnedData = ref<IPinData>({});
onMounted(async () => { void loadTestData(props.testId, props.name);
if (!testDefinitionStore.isFeatureEnabled) {
toast.showMessage({
title: locale.baseText('testDefinition.notImplemented'),
type: 'warning',
});
void router.push({ const handleUpdateTest = async () => {
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() {
try { try {
let savedTest; await updateTest(props.testId);
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,
},
);
}
} catch (e: unknown) { } catch (e: unknown) {
toast.showError(e, locale.baseText('testDefinition.edit.testSaveFailed')); 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) { function getFieldIssues(key: string) {
return fieldsIssues.value.filter((issue) => issue.field === key); return fieldsIssues.value.filter((issue) => issue.field === key);
} }
async function onDeleteMetric(deletedMetric: Partial<TestMetricRecord>) { async function onDeleteMetric(deletedMetric: TestMetricRecord) {
if (deletedMetric.id) { await deleteMetric(deletedMetric.id, props.testId);
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 openPinningModal() { async function openPinningModal() {
@ -141,13 +100,23 @@ async function openPinningModal() {
} }
async function runTest() { async function runTest() {
await testDefinitionStore.startTestRun(testId.value); await testDefinitionStore.startTestRun(props.testId);
await testDefinitionStore.fetchTestRuns(testId.value); 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(() => const runs = computed(() =>
Object.values(testDefinitionStore.testRunsById ?? {}).filter( 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[]) { async function onDeleteRuns(toDelete: TestRunRecord[]) {
await Promise.all( await Promise.all(
toDelete.map(async (run) => { toDelete.map(async (run) => {
await testDefinitionStore.deleteTestRun({ testDefinitionId: testId.value, runId: run.id }); await testDefinitionStore.deleteTestRun({ testDefinitionId: props.testId, runId: run.id });
}), }),
); );
} }
function toggleConfig() { async function renameTag(newName: string) {
showConfig.value = !showConfig.value; await tagsStore.rename({ id: state.value.tags.value[0], name: newName });
} }
async function getExamplePinnedDataForTags() { async function getExamplePinnedDataForTags() {
const exampleInput = await testDefinitionStore.fetchExampleEvaluationInput( const exampleInput = await testDefinitionStore.fetchExampleEvaluationInput(
testId.value, props.testId,
state.value.tags.value[0], state.value.tags.value[0],
); );
@ -183,122 +152,166 @@ async function getExamplePinnedDataForTags() {
} }
} }
// Debounced watchers for auto-saving watch(() => state.value.tags, getExamplePinnedDataForTags);
watch(
() => state.value.metrics, const updateName = (value: string) => {
debounce(async () => await updateMetrics(testId.value), { debounceTime: 400, trailing: true }), state.value.name.value = value;
{ deep: true }, void handleUpdateTestDebounced();
); };
watch(() => state.value.tags.value, getExamplePinnedDataForTags);
watch( const updateDescription = (value: string) => {
() => [ state.value.description.value = value;
state.value.description, void handleUpdateTestDebounced();
state.value.name, };
state.value.tags,
state.value.evaluationWorkflow,
state.value.mockedNodes,
],
debounce(onSaveTest, { debounceTime: 400, trailing: true }),
{ deep: true },
);
function onEvaluationWorkflowCreated(workflowId: string) { function onEvaluationWorkflowCreated(workflowId: string) {
telemetry.track('User created evaluation workflow from test', { telemetry.track('User created evaluation workflow from test', {
test_id: testId.value, test_id: props.testId,
subworkflow_id: workflowId, subworkflow_id: workflowId,
}); });
} }
</script> </script>
<template> <template>
<div :class="[$style.container, { [$style.noRuns]: !hasRuns }]"> <div v-if="!isLoading" :class="[$style.container, { [$style.noRuns]: !hasRuns }]">
<HeaderSection <div :class="$style.header">
v-model:name="state.name" <div style="display: flex; align-items: center">
v-model:description="state.description" <N8nIconButton
v-model:tags="state.tags" icon="arrow-left"
:has-runs="hasRuns" type="tertiary"
:is-saving="isSaving" :class="$style.arrowBack"
:get-field-issues="getFieldIssues" @click="router.push({ name: VIEWS.TEST_DEFINITION, params: { testId } })"
:start-editing="startEditing" ></N8nIconButton>
:save-changes="saveChanges" <InlineNameEdit
:handle-keydown="handleKeydown" :model-value="state.name.value"
:on-save-test="onSaveTest" max-height="none"
:run-test="runTest" type="Test name"
:show-config="showConfig" @update:model-value="updateName"
:toggle-config="toggleConfig" >
:run-test-enabled="isRunTestEnabled" <N8nText bold size="xlarge" color="text-dark">{{ state.name.value }}</N8nText>
> </InlineNameEdit>
<template #runTestTooltip> </div>
<template v-if="fieldsIssues.length > 0"> <div style="display: flex; align-items: center; gap: 10px">
<div>{{ locale.baseText('testDefinition.completeConfig') }}</div> <N8nText v-if="hasRuns" color="text-light" size="small">
<div v-for="issue in fieldsIssues" :key="issue.field">- {{ issue.message }}</div> {{
</template> isSaving
<template v-if="isRunning"> ? locale.baseText('testDefinition.edit.saving')
{{ locale.baseText('testDefinition.testIsRunning') }} : locale.baseText('testDefinition.edit.saved')
</template> }}
</template> </N8nText>
</HeaderSection> <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>
</template>
<template v-if="isRunning">
{{ locale.baseText('testDefinition.testIsRunning') }}
</template>
</template>
</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"> <div :class="$style.content">
<RunsSection <RunsSection
v-if="runs.length > 0" v-if="runs.length > 0"
v-model:selectedMetric="selectedMetric" v-model:selectedMetric="selectedMetric"
:runs="runs" :runs="runs"
:test-id="testId" :test-id="testId"
:applied-theme="appliedTheme" :applied-theme="appliedTheme"
@delete-runs="onDeleteRuns" @delete-runs="onDeleteRuns"
/> />
<ConfigSection <ConfigSection
v-model:tags="state.tags" v-model:tags="state.tags"
v-model:evaluationWorkflow="state.evaluationWorkflow" v-model:evaluationWorkflow="state.evaluationWorkflow"
v-model:metrics="state.metrics" v-model:metrics="state.metrics"
v-model:mockedNodes="state.mockedNodes" v-model:mockedNodes="state.mockedNodes"
:cancel-editing="cancelEditing" :cancel-editing="cancelEditing"
:show-config="showConfig" :show-config="showConfig"
:tag-usage-count="tagUsageCount" :tags-by-id="tagsById"
:all-tags="allTags" :is-loading="isLoading"
:tags-by-id="tagsById" :get-field-issues="getFieldIssues"
:is-loading="isLoading" :start-editing="startEditing"
:get-field-issues="getFieldIssues" :save-changes="saveChanges"
:start-editing="startEditing" :has-runs="hasRuns"
:save-changes="saveChanges" :example-pinned-data="examplePinnedData"
:create-tag="handleCreateTag" :sample-workflow-name="workflowName"
:example-pinned-data="examplePinnedData" @rename-tag="renameTag"
:sample-workflow-name="workflowName" @update:metrics="() => handleUpdateMetricsDebounced(testId)"
@open-pinning-modal="openPinningModal" @update:evaluation-workflow="handleUpdateTestDebounced"
@delete-metric="onDeleteMetric" @update:mocked-nodes="handleUpdateTestDebounced"
@evaluation-workflow-created="onEvaluationWorkflowCreated($event)" @open-pinning-modal="openPinningModal"
/> @delete-metric="onDeleteMetric"
@open-executions-view-for-tag="openExecutionsViewForTag"
@evaluation-workflow-created="onEvaluationWorkflowCreated($event)"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
<style module lang="scss"> <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 { .content {
display: flex; display: flex;
overflow-y: hidden; justify-content: center;
position: relative;
.noRuns & { gap: var(--spacing-m);
justify-content: center; }
overflow-y: auto;
} .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> </style>

View file

@ -1,134 +1,86 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import EmptyState from '@/components/TestDefinition/ListDefinition/EmptyState.vue';
import { useRouter } from 'vue-router'; 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 { MODAL_CONFIRM, VIEWS } from '@/constants';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { useToast } from '@/composables/useToast'; import { useAsyncState } from '@vueuse/core';
import { useI18n } from '@/composables/useI18n'; import { orderBy } from 'lodash-es';
import EmptyState from '@/components/TestDefinition/ListDefinition/EmptyState.vue'; import {
import TestsList from '@/components/TestDefinition/ListDefinition/TestsList.vue'; N8nActionToggle,
import type { N8nButton,
TestExecution, N8nHeading,
TestItemAction, N8nIconButton,
TestListItem, N8nLoading,
} from '@/components/TestDefinition/types'; N8nTooltip,
import { useAnnotationTagsStore } from '@/stores/tags.store'; } from 'n8n-design-system';
import type { TestDefinitionRecord } from '@/api/testDefinition.ee'; import { computed, h } from 'vue';
import { useMessage } from '@/composables/useMessage'; import { RouterLink, useRouter } from 'vue-router';
const props = defineProps<{
name: string;
}>();
const router = useRouter(); const router = useRouter();
const tagsStore = useAnnotationTagsStore();
const testDefinitionStore = useTestDefinitionStore(); const testDefinitionStore = useTestDefinitionStore();
const isLoading = ref(false);
const toast = useToast(); const toast = useToast();
const locale = useI18n(); const locale = useI18n();
const { confirm } = useMessage(); 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', label: 'Edit test',
id: 'run', value: 'edit',
event: onRunTest, disabled: false,
disabled: isRunDisabled,
show: (testId) => !isTestRunning(testId),
tooltip: (testId) =>
isRunDisabled(testId)
? getDisabledRunTooltip(testId)
: locale.baseText('testDefinition.runTest'),
}, },
{ {
icon: 'stop', label: 'Delete',
id: 'cancel', value: 'delete',
event: onCancelTestRun, disabled: false,
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'),
}, },
]); ]);
const tests = computed<TestListItem[]>(() => { const handleAction = async (action: string, testId: string) =>
return ( await commands[action as Action['value']](testId);
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;
}
function isTestRunning(testId: string) { function isTestRunning(testId: string) {
return testDefinitionStore.lastRunByTestId[testId]?.status === 'running'; return testDefinitionStore.lastRunByTestId[testId]?.status === 'running';
} }
function isRunDisabled(testId: string) {
return testDefinitionStore.getFieldIssues(testId)?.length > 0;
}
// Action handlers
function onCreateTest() { function onCreateTest() {
void router.push({ name: VIEWS.NEW_TEST_DEFINITION }); void router.push({ name: VIEWS.NEW_TEST_DEFINITION });
} }
@ -140,6 +92,11 @@ async function onRunTest(testId: string) {
toast.showMessage({ toast.showMessage({
title: locale.baseText('testDefinition.list.testStarted'), title: locale.baseText('testDefinition.list.testStarted'),
type: 'success', type: 'success',
message: h(
RouterLink,
{ to: { name: VIEWS.TEST_DEFINITION_EDIT, params: { testId } } },
() => 'Go to runs',
),
}); });
// Optionally fetch the updated test 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) { function onEditTest(testId: string) {
void router.push({ name: VIEWS.TEST_DEFINITION_EDIT, params: { testId } }); void router.push({ name: VIEWS.TEST_DEFINITION_EDIT, params: { testId } });
} }
@ -207,62 +160,91 @@ async function onDeleteTest(testId: string) {
type: 'success', 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> </script>
<template> <template>
<div :class="$style.container"> <div :class="$style.container">
<div v-if="isLoading" :class="$style.loading"> <N8nLoading v-if="isLoading" loading :rows="3" data-test-id="test-definition-loader" />
<n8n-loading :loading="true" :rows="3" /> <EmptyState
</div> v-else-if="!listItems.length"
data-test-id="test-definition-empty-state"
@create-test="onCreateTest"
/>
<template v-else> <template v-else>
<EmptyState <div :class="$style.header">
v-if="!hasTests" <N8nHeading size="xlarge" color="text-dark" bold>
data-test-id="test-definition-empty-state" {{ locale.baseText('testDefinition.list.tests') }}
@create-test="onCreateTest" </N8nHeading>
/> <div>
<TestsList <N8nButton
v-else :label="locale.baseText('testDefinition.list.createNew')"
:tests="tests" class="mr-xs"
:actions="actions" @click="onCreateTest"
@view-details="onViewDetails" />
@create-test="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> </template>
</div> </div>
</template> </template>
@ -272,6 +254,7 @@ onMounted(async () => {
width: 100%; width: 100%;
max-width: var(--content-container-width); max-width: var(--content-container-width);
margin: auto; margin: auto;
padding: var(--spacing-xl) var(--spacing-l);
} }
.loading { .loading {
display: flex; display: flex;
@ -279,4 +262,18 @@ onMounted(async () => {
align-items: center; align-items: center;
height: 200px; 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> </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"> <script setup lang="ts">
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useAsyncState } from '@vueuse/core';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { onMounted } from 'vue';
const props = defineProps<{
name: string;
}>();
const router = useRouter(); const router = useRouter();
const workflowHelpers = useWorkflowHelpers({ router }); const workflowHelpers = useWorkflowHelpers({ router });
const workflowStore = useWorkflowsStore(); const workflowStore = useWorkflowsStore();
async function initWorkflow() { const { isReady } = useAsyncState(async () => {
const workflowId = router.currentRoute.value.params.name as string; const workflowId = props.name;
const isAlreadyInitialized = workflowStore.workflow.id === workflowId; const isAlreadyInitialized = workflowStore.workflow.id === workflowId;
if (isAlreadyInitialized) return; if (isAlreadyInitialized) return;
const workflow = await workflowStore.fetchWorkflow(workflowId); const workflow = await workflowStore.fetchWorkflow(workflowId);
void workflowHelpers.initState(workflow); workflowHelpers.initState(workflow);
} }, undefined);
onMounted(initWorkflow);
</script> </script>
<template> <template>
<div :class="$style.evaluationsView"> <div :class="$style.evaluationsView">
<router-view /> <router-view v-if="isReady" />
</div> </div>
</template> </template>
@ -30,7 +33,5 @@ onMounted(initWorkflow);
.evaluationsView { .evaluationsView {
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: auto;
padding: var(--spacing-xl) var(--spacing-l);
} }
</style> </style>

View file

@ -1,15 +1,42 @@
<script setup lang="ts"> <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 { 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 router = useRouter();
const toast = useToast(); const toast = useToast();
@ -28,8 +55,64 @@ const filteredTestCases = computed(() => {
return testCases.value; 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) => { 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 { return {
name: VIEWS.EXECUTION_PREVIEW, name: VIEWS.EXECUTION_PREVIEW,
params: { params: {
@ -37,14 +120,14 @@ const getErrorTooltipLinkRoute = (row: TestCaseExecutionRecord) => {
executionId: row.evaluationExecutionId, 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 { return {
name: VIEWS.TEST_DEFINITION_EDIT, name: VIEWS.TEST_DEFINITION_EDIT,
params: { params: {
testId: testId.value, 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 { return {
name: VIEWS.EXECUTION_PREVIEW, name: VIEWS.EXECUTION_PREVIEW,
params: { params: {
@ -52,7 +135,7 @@ const getErrorTooltipLinkRoute = (row: TestCaseExecutionRecord) => {
executionId: row.executionId, 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 { return {
name: VIEWS.EXECUTION_PREVIEW, name: VIEWS.EXECUTION_PREVIEW,
params: { params: {
@ -60,21 +143,21 @@ const getErrorTooltipLinkRoute = (row: TestCaseExecutionRecord) => {
executionId: row.pastExecutionId, executionId: row.pastExecutionId,
}, },
}; };
} else if (row.errorCode === 'METRICS_MISSING') { } else if (row.errorCode === TEST_CASE_EXECUTION_ERROR_CODE.METRICS_MISSING) {
return { return {
name: VIEWS.TEST_DEFINITION_EDIT, name: VIEWS.TEST_DEFINITION_EDIT,
params: { params: {
testId: testId.value, testId: testId.value,
}, },
}; };
} else if (row.errorCode === 'UNKNOWN_METRICS') { } else if (row.errorCode === TEST_CASE_EXECUTION_ERROR_CODE.UNKNOWN_METRICS) {
return { return {
name: VIEWS.TEST_DEFINITION_EDIT, name: VIEWS.TEST_DEFINITION_EDIT,
params: { params: {
testId: testId.value, testId: testId.value,
}, },
}; };
} else if (row.errorCode === 'INVALID_METRICS') { } else if (row.errorCode === TEST_CASE_EXECUTION_ERROR_CODE.INVALID_METRICS) {
return { return {
name: VIEWS.EXECUTION_PREVIEW, name: VIEWS.EXECUTION_PREVIEW,
params: { params: {
@ -91,42 +174,21 @@ const columns = computed(
(): Array<TestTableColumn<TestCaseExecutionRecord>> => [ (): Array<TestTableColumn<TestCaseExecutionRecord>> => [
{ {
prop: 'id', prop: 'id',
width: 200, width: 250,
label: locale.baseText('testDefinition.runDetail.testCase'), label: locale.baseText('testDefinition.runDetail.testCase'),
sortable: true, 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}`, formatter: (row: TestCaseExecutionRecord) => `${row.id}`,
openInNewTab: true,
}, },
{ {
prop: 'status', prop: 'status',
label: locale.baseText('testDefinition.listRuns.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) => ({ ...Object.keys(run.value?.metrics ?? {}).map((metric) => ({
prop: `metrics.${metric}`, prop: `metrics.${metric}`,
label: metric, label: metric,
sortable: true, sortable: true,
filter: true, filter: true,
showHeaderTooltip: true,
formatter: (row: TestCaseExecutionRecord) => row.metrics?.[metric]?.toFixed(2) ?? '-', 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> <i class="mr-xs"><font-awesome-icon icon="arrow-left" /></i>
<n8n-heading size="large" :bold="true">{{ test?.name }}</n8n-heading> <n8n-heading size="large" :bold="true">{{ test?.name }}</n8n-heading>
<i class="ml-xs mr-xs"><font-awesome-icon icon="chevron-right" /></i> <i class="ml-xs mr-xs"><font-awesome-icon icon="chevron-right" /></i>
<n8n-heading size="large" :bold="true" <n8n-heading size="large" :bold="true">
>{{ locale.baseText('testDefinition.listRuns.runNumber') }}{{ run?.id }}</n8n-heading {{ locale.baseText('testDefinition.listRuns.runNumber') }}{{ run?.id }}
> </n8n-heading>
</button> </button>
</div> </div>
<el-scrollbar always :class="$style.scrollableSummary" class="mb-m">
<div :class="$style.cardGrid"> <div style="display: flex">
<N8nCard :class="$style.summaryCard"> <div :class="$style.summaryCard">
<div :class="$style.stat">
<N8nText size="small"> <N8nText size="small">
{{ locale.baseText('testDefinition.runDetail.totalCases') }} {{ locale.baseText('testDefinition.runDetail.totalCases') }}
</N8nText> </N8nText>
<N8nText size="large">{{ testCases.length }}</N8nText> <N8nText size="xlarge" style="font-size: 32px" bold>{{ testCases.length }}</N8nText>
</div> </div>
</N8nCard>
<N8nCard :class="$style.summaryCard"> <div :class="$style.summaryCard">
<div :class="$style.stat">
<N8nText size="small"> <N8nText size="small">
{{ locale.baseText('testDefinition.runDetail.ranAt') }} {{ locale.baseText('testDefinition.runDetail.ranAt') }}
</N8nText> </N8nText>
<N8nText size="medium">{{ <div>
convertToDisplayDate(new Date(run?.runAt).getTime()) <N8nText v-for="item in formattedTime" :key="item" size="medium" tag="div">
}}</N8nText> {{ item }}
</N8nText>
</div>
</div> </div>
</N8nCard>
<N8nCard :class="$style.summaryCard"> <div :class="$style.summaryCard">
<div :class="$style.stat">
<N8nText size="small"> <N8nText size="small">
{{ locale.baseText('testDefinition.listRuns.status') }} {{ locale.baseText('testDefinition.listRuns.status') }}
</N8nText> </N8nText>
@ -208,27 +267,67 @@ onMounted(async () => {
{{ run?.status }} {{ run?.status }}
</N8nText> </N8nText>
</div> </div>
</N8nCard>
<N8nCard v-for="(value, key) in metrics" :key="key" :class="$style.summaryCard"> <div v-for="(value, key) in metrics" :key="key" :class="$style.summaryCard">
<div :class="$style.stat"> <N8nTooltip :content="key" placement="top">
<N8nText size="small">{{ key }}</N8nText> <N8nText size="small" style="text-overflow: ellipsis; overflow: hidden">
<N8nText size="large">{{ value.toFixed(2) }}</N8nText> {{ key }}
</N8nText>
</N8nTooltip>
<N8nText size="xlarge" style="font-size: 32px" bold>{{ value.toFixed(2) }}</N8nText>
</div> </div>
</N8nCard> </div>
</el-scrollbar>
<div v-if="isLoading" :class="$style.loading">
<n8n-loading :loading="true" :rows="5" />
</div> </div>
<N8nCard> <TestTableBase
<div v-if="isLoading" :class="$style.loading"> v-else
<n8n-loading :loading="true" :rows="5" /> :data="filteredTestCases"
</div> :columns="columns"
<TestTableBase :default-sort="{ prop: 'id', order: 'descending' }"
v-else >
:data="filteredTestCases" <template #id="{ row }">
:columns="columns" <div style="display: flex; justify-content: space-between; gap: 10px">
:default-sort="{ prop: 'id', order: 'descending' }" {{ row.id }}
/> <N8nActionToggle
</N8nCard> :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> </div>
</template> </template>
@ -238,6 +337,7 @@ onMounted(async () => {
width: 100%; width: 100%;
max-width: var(--content-container-width); max-width: var(--content-container-width);
margin: auto; margin: auto;
padding: var(--spacing-l) var(--spacing-2xl) 0;
} }
.backButton { .backButton {
@ -292,25 +392,35 @@ onMounted(async () => {
height: 200px; height: 200px;
} }
.cardGrid { .scrollableSummary {
display: grid; border: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr)); border-radius: 5px;
gap: var(--spacing-xs); background-color: var(--color-background-xlight);
margin-bottom: var(--spacing-m);
:global(.el-scrollbar__bar) {
opacity: 1;
}
:global(.el-scrollbar__thumb) {
background-color: var(--color-foreground-base);
&:hover {
background-color: var(--color-foreground-dark);
}
}
} }
:global { .summaryCard {
.new { padding: var(--spacing-s);
color: var(--color-info); border-right: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
} flex-basis: 169px;
.running { flex-shrink: 0;
color: var(--color-warning); max-width: 170px;
} display: flex;
.completed { flex-direction: column;
color: var(--color-success); justify-content: space-between;
}
.error { &:first-child {
color: var(--color-danger); border-top-left-radius: inherit;
border-bottom-left-radius: inherit;
} }
} }
</style> </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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import TestDefinitionEditView from '@/views/TestDefinition/TestDefinitionEditView.vue'; import TestDefinitionEditView from '@/views/TestDefinition/TestDefinitionEditView.vue';
import { useRoute, useRouter } from 'vue-router'; import type { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm';
import { useToast } from '@/composables/useToast'; import { ref } from 'vue';
import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import { ref, nextTick } from 'vue';
import { cleanupAppModals, createAppModals, mockedStore } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals, mockedStore } from '@/__tests__/utils';
import { VIEWS } from '@/constants';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; 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'); const form: Partial<ReturnType<typeof useTestDefinitionForm>> = {
vi.mock('@/composables/useToast'); state: ref({
vi.mock('@/components/TestDefinition/composables/useTestDefinitionForm'); name: { value: '', isEditing: false, tempValue: '' },
vi.mock('@/stores/projects.store'); description: { value: '', isEditing: false, tempValue: '' },
tags: { value: [], tempValue: [], isEditing: false },
evaluationWorkflow: { mode: 'list', value: '', __rl: true },
metrics: [],
mockedNodes: [],
}),
loadTestData: vi.fn(),
cancelEditing: vi.fn(),
updateTest: vi.fn(),
startEditing: vi.fn(),
saveChanges: vi.fn(),
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', () => { 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(() => { beforeEach(() => {
setActivePinia(createPinia()); createTestingPinia();
createAppModals(); 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({
state: ref({
name: { value: '', isEditing: false, tempValue: '' },
description: { value: '', isEditing: false, tempValue: '' },
tags: { value: [], tempValue: [], isEditing: false },
evaluationWorkflow: { mode: 'list', value: '', __rl: true },
metrics: [],
}),
fieldsIssues: ref([]),
isSaving: ref(false),
loadTestData: loadTestDataMock,
createTest: createTestMock,
updateTest: updateTestMock,
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,
}),
}));
}); });
afterEach(() => { afterEach(() => {
@ -107,165 +45,72 @@ describe('TestDefinitionEditView', () => {
cleanupAppModals(); cleanupAppModals();
}); });
it('should load test data when testId is provided', async () => { it('should load test data', async () => {
vi.mocked(useRoute).mockReturnValue({ renderComponent();
params: { testId: '1' }, expect(form.loadTestData).toHaveBeenCalledWith('1', 'workflow-name');
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 display disabled "run test" button when editing test without tags', async () => { it('should display disabled "run test" button when editing test without tags', async () => {
vi.mocked(useRoute).mockReturnValue({ const testDefinitionStore = mockedStore(useTestDefinitionStore);
params: { testId: '1' },
name: VIEWS.TEST_DEFINITION_EDIT,
} as unknown as ReturnType<typeof useRoute>);
const { getByTestId, mockedTestDefinitionStore } = renderComponentWithFeatureEnabled(); testDefinitionStore.getFieldIssues.mockReturnValueOnce([
{ field: 'tags', message: 'Tag is required' },
]);
mockedTestDefinitionStore.getFieldIssues = vi const { getByTestId } = renderComponent();
.fn()
.mockReturnValue([{ field: 'tags', message: 'Tag is required' }]);
await nextTick();
const updateButton = getByTestId('run-test-button'); const updateButton = getByTestId('run-test-button');
expect(updateButton.textContent?.toLowerCase()).toContain('run test'); expect(updateButton.textContent?.toLowerCase()).toContain('run test');
expect(updateButton).toHaveClass('disabled'); 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 () => { it('should apply "has-issues" class to inputs with issues', async () => {
const { container, mockedTestDefinitionStore } = renderComponentWithFeatureEnabled(); const testDefinitionStore = mockedStore(useTestDefinitionStore);
mockedTestDefinitionStore.getFieldIssues = vi
.fn() testDefinitionStore.getFieldIssues.mockReturnValueOnce([
.mockReturnValue([{ field: 'tags', message: 'Tag is required' }]); { field: 'evaluationWorkflow', message: 'No evaluation workflow set' },
await nextTick(); ]);
const { container } = renderComponent();
const issueElements = container.querySelectorAll('.has-issues'); const issueElements = container.querySelectorAll('.has-issues');
expect(issueElements.length).toBeGreaterThan(0); expect(issueElements.length).toBeGreaterThan(0);
}); });
describe('Test Runs functionality', () => { describe('Test Runs functionality', () => {
it('should display test runs table when runs exist', async () => { it('should display test runs table when runs exist', async () => {
vi.mocked(useRoute).mockReturnValue({ const testDefinitionStore = mockedStore(useTestDefinitionStore);
params: { testId: '1' }, testDefinitionStore.testRunsById = {
name: VIEWS.TEST_DEFINITION_EDIT, run1: {
} as unknown as ReturnType<typeof useRoute>); id: 'run1',
testDefinitionId: '1',
const { getByTestId } = renderComponentWithFeatureEnabled({ status: 'completed',
testRunsById: { runAt: '2023-01-01',
run1: { createdAt: '2023-01-01',
id: 'run1', updatedAt: '2023-01-01',
testDefinitionId: '1', completedAt: '2023-01-01',
status: 'completed', failedCases: 0,
runAt: '2023-01-01', passedCases: 1,
createdAt: '2023-01-01', totalCases: 1,
updatedAt: '2023-01-01',
completedAt: '2023-01-01',
},
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'); const { getByTestId } = renderComponent();
expect(runsTable).toBeTruthy(); expect(getByTestId('past-runs-table')).toBeInTheDocument();
}); });
it('should not display test runs table when no runs exist', async () => { it('should not display test runs table when no runs exist', async () => {
const { container } = renderComponentWithFeatureEnabled(); const { queryByTestId } = renderComponent();
expect(queryByTestId('past-runs-table')).not.toBeInTheDocument();
const runsTable = container.querySelector('[data-test-id="past-runs-table"]');
expect(runsTable).toBeFalsy();
}); });
it('should start a test run when run test button is clicked', async () => { it('should start a test run when run test button is clicked', async () => {
vi.mocked(useTestDefinitionForm).mockReturnValue({ const testDefinitionStore = mockedStore(useTestDefinitionStore);
...vi.mocked(useTestDefinitionForm)(), const { getByTestId } = renderComponent();
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>);
vi.mocked(useRoute).mockReturnValue({ await userEvent.click(getByTestId('run-test-button'));
params: { testId: '1' },
name: VIEWS.TEST_DEFINITION_EDIT,
} as unknown as ReturnType<typeof useRoute>);
const { getByTestId, mockedTestDefinitionStore } = renderComponentWithFeatureEnabled(); expect(testDefinitionStore.startTestRun).toHaveBeenCalledWith('1');
await nextTick(); expect(testDefinitionStore.fetchTestRuns).toHaveBeenCalledWith('1');
const runButton = getByTestId('run-test-button');
runButton.click();
await nextTick();
expect(mockedTestDefinitionStore.startTestRun).toHaveBeenCalledWith('1');
expect(mockedTestDefinitionStore.fetchTestRuns).toHaveBeenCalledWith('1');
}); });
}); });
}); });

View file

@ -1,173 +1,170 @@
import type { Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import TestDefinitionListView from '@/views/TestDefinition/TestDefinitionListView.vue'; import TestDefinitionListView from '@/views/TestDefinition/TestDefinitionListView.vue';
import { useRoute, useRouter } from 'vue-router'; import type { useToast } from '@/composables/useToast';
import { useToast } from '@/composables/useToast'; import type { useMessage } from '@/composables/useMessage';
import { useMessage } from '@/composables/useMessage';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { nextTick, ref } from 'vue'; import { mockedStore } from '@/__tests__/utils';
import { mockedStore, waitAllPromises } from '@/__tests__/utils'; import { MODAL_CONFIRM } from '@/constants';
import { MODAL_CONFIRM, VIEWS } from '@/constants';
import type { TestDefinitionRecord } from '@/api/testDefinition.ee'; import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
import userEvent from '@testing-library/user-event';
import { within, waitFor } from '@testing-library/dom';
const renderComponent = createComponentRenderer(TestDefinitionListView);
const workflowId = 'workflow1';
const mockTestDefinitions: TestDefinitionRecord[] = [
{
id: '1',
name: 'Test 1',
workflowId,
updatedAt: '2023-01-01T00:00:00.000Z',
createdAt: '2023-01-01T00:00:00.000Z',
annotationTagId: 'tag1',
},
{
id: '2',
name: 'Test 2',
workflowId,
updatedAt: '2023-01-02T00:00:00.000Z',
createdAt: '2023-01-01T00:00:00.000Z',
},
{
id: '3',
name: 'Test 3',
workflowId,
updatedAt: '2023-01-03T00:00:00.000Z',
createdAt: '2023-01-01T00:00:00.000Z',
},
];
const toast = vi.hoisted(
() =>
({
showMessage: vi.fn(),
showError: vi.fn(),
}) satisfies Partial<ReturnType<typeof useToast>>,
);
vi.mock('@/composables/useToast', () => ({
useToast: () => toast,
}));
const message = vi.hoisted(
() =>
({
confirm: vi.fn(),
}) satisfies Partial<ReturnType<typeof useMessage>>,
);
vi.mock('@/composables/useMessage', () => ({
useMessage: () => message,
}));
vi.mock('vue-router');
vi.mock('@/composables/useToast');
vi.mock('@/composables/useMessage');
describe('TestDefinitionListView', () => { 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 mockTestDefinitions: TestDefinitionRecord[] = [
{
id: '1',
name: 'Test 1',
workflowId: 'workflow1',
updatedAt: '2023-01-01T00:00:00.000Z',
annotationTagId: 'tag1',
},
{
id: '2',
name: 'Test 2',
workflowId: 'workflow1',
updatedAt: '2023-01-02T00:00:00.000Z',
},
{
id: '3',
name: 'Test 3',
workflowId: 'workflow1',
updatedAt: '2023-01-03T00:00:00.000Z',
},
];
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()); createTestingPinia();
vi.mocked(useRoute).mockReturnValue(
ref({
params: { name: 'workflow1' },
name: VIEWS.TEST_DEFINITION,
}) as unknown as ReturnType<typeof useRoute>,
);
vi.mocked(useRouter).mockReturnValue({
push: vi.fn(),
currentRoute: { value: { params: { name: 'workflow1' } } },
} as unknown as ReturnType<typeof useRouter>);
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 });
vi.mocked(useToast).mockReturnValue({
showMessage: showMessageMock,
showError: showErrorMock,
} as unknown as ReturnType<typeof useToast>);
vi.mocked(useMessage).mockReturnValue({
confirm: confirmMock,
} as unknown as ReturnType<typeof useMessage>);
}); });
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
const renderComponentWithFeatureEnabled = async ( it('should render loader', async () => {
{ testDefinitions }: { testDefinitions: TestDefinitionRecord[] } = { const { getByTestId } = renderComponent({ props: { name: 'any' } });
testDefinitions: mockTestDefinitions, expect(getByTestId('test-definition-loader')).toBeTruthy();
}, });
) => {
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 empty state when no tests exist', async () => { 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 () => { 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 () => { it('should load initial base on route param', async () => {
const { testDefinitionStore } = await renderComponentWithFeatureEnabled(); const testDefinitionStore = mockedStore(useTestDefinitionStore);
renderComponent({ props: { name: workflowId } });
expect(testDefinitionStore.fetchAll).toHaveBeenCalledWith({ expect(testDefinitionStore.fetchAll).toHaveBeenCalledWith({ workflowId });
workflowId: 'workflow1',
});
}); });
it('should start test run and show success message', async () => { 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'); const { getByTestId } = renderComponent({ props: { name: workflowId } });
runButton.click();
await nextTick();
expect(startTestRunMock).toHaveBeenCalledWith('1'); await waitFor(() => expect(getByTestId('test-definition-list')).toBeTruthy());
expect(fetchTestRunsMock).toHaveBeenCalledWith('1');
expect(showMessageMock).toHaveBeenCalledWith({ const testToRun = mockTestDefinitions[0].id;
title: expect.any(String), await userEvent.click(getByTestId(`run-test-${testToRun}`));
type: 'success',
}); 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 () => { it('should show error message on failed test run', async () => {
const { getByTestId, testDefinitionStore } = await renderComponentWithFeatureEnabled(); const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.startTestRun = vi.fn().mockRejectedValue(new Error('Run failed')); testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId] = mockTestDefinitions;
testDefinitionStore.startTestRun.mockRejectedValueOnce(new Error('Run failed'));
const runButton = getByTestId('run-test-button-1'); const { getByTestId } = renderComponent({ props: { name: workflowId } });
runButton.click();
await nextTick();
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 () => { it('should delete test and show success message', async () => {
const { getByTestId } = await renderComponentWithFeatureEnabled(); const testDefinitionStore = mockedStore(useTestDefinitionStore);
const deleteButton = getByTestId('delete-test-button-1'); testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId] = mockTestDefinitions;
deleteButton.click(); testDefinitionStore.startTestRun.mockRejectedValueOnce(new Error('Run failed'));
await waitAllPromises();
expect(deleteByIdMock).toHaveBeenCalledWith('1'); message.confirm.mockResolvedValueOnce(MODAL_CONFIRM);
expect(showMessageMock).toHaveBeenCalledWith({
title: expect.any(String), const { getByTestId } = renderComponent({
type: 'success', 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 () => { 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-"]'); const testItems = container.querySelectorAll('[data-test-id^="test-item-"]');
expect(testItems[0].getAttribute('data-test-id')).toBe('test-item-3'); 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 { describe, it, expect, beforeEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import TestDefinitionRootView from '../TestDefinitionRootView.vue'; import TestDefinitionRootView from '../TestDefinitionRootView.vue';
import { useRouter } from 'vue-router';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import type { IWorkflowDb } from '@/Interface'; import type { IWorkflowDb } from '@/Interface';
import * as workflowsApi from '@/api/workflows';
vi.mock('vue-router'); import { waitFor } from '@testing-library/vue';
vi.mock('@/api/workflows');
describe('TestDefinitionRootView', () => { describe('TestDefinitionRootView', () => {
const renderComponent = createComponentRenderer(TestDefinitionRootView); const renderComponent = createComponentRenderer(TestDefinitionRootView);
@ -33,55 +30,33 @@ describe('TestDefinitionRootView', () => {
}; };
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()); createTestingPinia();
vi.mocked(useRouter).mockReturnValue({
currentRoute: {
value: {
params: {
name: 'workflow123',
},
},
},
} as unknown as ReturnType<typeof useRouter>);
vi.mocked(workflowsApi.getWorkflow).mockResolvedValue({
...mockWorkflow,
id: 'workflow123',
});
}); });
it('should initialize workflow on mount if not already initialized', async () => { it('should initialize workflow on mount if not already initialized', async () => {
const pinia = createTestingPinia();
const workflowsStore = mockedStore(useWorkflowsStore); const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.workflow = mockWorkflow; workflowsStore.workflow = mockWorkflow;
const newWorkflowId = 'workflow123';
const newWorkflow = { renderComponent({ props: { name: newWorkflowId } });
...mockWorkflow,
id: 'workflow123',
};
workflowsStore.fetchWorkflow.mockResolvedValue(newWorkflow);
renderComponent({ pinia }); expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith(newWorkflowId);
expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith('workflow123');
}); });
it('should not initialize workflow if already loaded', async () => { it('should not initialize workflow if already loaded', async () => {
const pinia = createTestingPinia();
const workflowsStore = mockedStore(useWorkflowsStore); const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.workflow = { workflowsStore.workflow = mockWorkflow;
...mockWorkflow,
id: 'workflow123',
};
renderComponent({ pinia }); renderComponent({ props: { name: mockWorkflow.id } });
expect(workflowsStore.fetchWorkflow).not.toHaveBeenCalled(); expect(workflowsStore.fetchWorkflow).not.toHaveBeenCalled();
}); });
it('should render router view', () => { it('should render router view', async () => {
const { container } = renderComponent(); const workflowsStore = mockedStore(useWorkflowsStore);
expect(container.querySelector('router-view')).toBeTruthy(); 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({ .replace({
name: VIEWS.EXECUTION_PREVIEW, name: VIEWS.EXECUTION_PREVIEW,
params: { name: workflow.value.id, executionId: executions.value[0].id }, params: { name: workflow.value.id, executionId: executions.value[0].id },
query: route.query,
}) })
.catch(() => {}); .catch(() => {});
} }