mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 12:57:29 -08:00
feat(editor): Add workflow evaluation run views (no-changelog) (#12258)
This commit is contained in:
parent
ecabe34705
commit
3d990eb555
|
@ -134,6 +134,7 @@ export class TestRunnerService {
|
|||
evaluationWorkflow: WorkflowEntity,
|
||||
expectedData: IRunData,
|
||||
actualData: IRunData,
|
||||
testRunId?: string,
|
||||
) {
|
||||
// Prepare the evaluation wf input data.
|
||||
// Provide both the expected data and the actual data
|
||||
|
@ -146,7 +147,13 @@ export class TestRunnerService {
|
|||
|
||||
// Prepare the data to run the evaluation workflow
|
||||
const data = await getRunData(evaluationWorkflow, [evaluationInputData]);
|
||||
|
||||
// FIXME: This is a hack to add the testRunId to the evaluation workflow execution data
|
||||
// So that we can fetch all execution runs for a test run
|
||||
if (testRunId && data.executionData) {
|
||||
data.executionData.resultData.metadata = {
|
||||
testRunId,
|
||||
};
|
||||
}
|
||||
data.executionMode = 'evaluation';
|
||||
|
||||
// Trigger the evaluation workflow
|
||||
|
@ -264,10 +271,9 @@ export class TestRunnerService {
|
|||
evaluationWorkflow,
|
||||
originalRunData,
|
||||
testCaseRunData,
|
||||
testRun.id,
|
||||
);
|
||||
assert(evalExecution);
|
||||
|
||||
// Extract the output of the last node executed in the evaluation workflow
|
||||
metrics.addResults(this.extractEvaluationResult(evalExecution));
|
||||
}
|
||||
|
||||
|
|
|
@ -31,6 +31,10 @@ const props = defineProps({
|
|||
multiple: {
|
||||
type: Boolean,
|
||||
},
|
||||
multipleLimit: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
filterMethod: {
|
||||
type: Function,
|
||||
},
|
||||
|
@ -120,6 +124,7 @@ defineExpose({
|
|||
<ElSelect
|
||||
v-bind="{ ...$props, ...listeners }"
|
||||
ref="innerSelect"
|
||||
:multiple-limit="props.multipleLimit"
|
||||
:model-value="props.modelValue ?? undefined"
|
||||
:size="computedSize"
|
||||
:popper-class="props.popperClass"
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type { IRestApiContext } from '@/Interface';
|
||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||
import { makeRestApiRequest, request } from '@/utils/apiUtils';
|
||||
|
||||
export interface TestDefinitionRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
|
@ -9,7 +10,10 @@ export interface TestDefinitionRecord {
|
|||
description?: string | null;
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
annotationTag?: string | null;
|
||||
mockedNodes?: Array<{ name: string }>;
|
||||
}
|
||||
|
||||
interface CreateTestDefinitionParams {
|
||||
name: string;
|
||||
workflowId: string;
|
||||
|
@ -21,31 +25,63 @@ export interface UpdateTestDefinitionParams {
|
|||
evaluationWorkflowId?: string | null;
|
||||
annotationTagId?: string | null;
|
||||
description?: string | null;
|
||||
mockedNodes?: Array<{ name: string }>;
|
||||
}
|
||||
|
||||
export interface UpdateTestResponse {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
id: string;
|
||||
name: string;
|
||||
workflowId: string;
|
||||
description: string | null;
|
||||
annotationTag: string | null;
|
||||
evaluationWorkflowId: string | null;
|
||||
annotationTagId: string | null;
|
||||
description?: string | null;
|
||||
annotationTag?: string | null;
|
||||
evaluationWorkflowId?: string | null;
|
||||
annotationTagId?: string | null;
|
||||
}
|
||||
|
||||
export interface TestRunRecord {
|
||||
id: string;
|
||||
testDefinitionId: string;
|
||||
status: 'new' | 'running' | 'completed' | 'error';
|
||||
metrics?: Record<string, number>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
runAt: string;
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
interface GetTestRunParams {
|
||||
testDefinitionId: string;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
interface DeleteTestRunParams {
|
||||
testDefinitionId: string;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
const endpoint = '/evaluation/test-definitions';
|
||||
const getMetricsEndpoint = (testDefinitionId: string, metricId?: string) =>
|
||||
`${endpoint}/${testDefinitionId}/metrics${metricId ? `/${metricId}` : ''}`;
|
||||
|
||||
export async function getTestDefinitions(context: IRestApiContext) {
|
||||
export async function getTestDefinitions(
|
||||
context: IRestApiContext,
|
||||
params?: { workflowId?: string },
|
||||
) {
|
||||
let url = endpoint;
|
||||
if (params?.workflowId) {
|
||||
url += `?filter=${JSON.stringify({ workflowId: params.workflowId })}`;
|
||||
}
|
||||
return await makeRestApiRequest<{ count: number; testDefinitions: TestDefinitionRecord[] }>(
|
||||
context,
|
||||
'GET',
|
||||
endpoint,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTestDefinition(context: IRestApiContext, id: string) {
|
||||
return await makeRestApiRequest<{ id: string }>(context, 'GET', `${endpoint}/${id}`);
|
||||
return await makeRestApiRequest<TestDefinitionRecord>(context, 'GET', `${endpoint}/${id}`);
|
||||
}
|
||||
|
||||
export async function createTestDefinition(
|
||||
|
@ -71,3 +107,125 @@ export async function updateTestDefinition(
|
|||
export async function deleteTestDefinition(context: IRestApiContext, id: string) {
|
||||
return await makeRestApiRequest<{ success: boolean }>(context, 'DELETE', `${endpoint}/${id}`);
|
||||
}
|
||||
|
||||
// Metrics
|
||||
export interface TestMetricRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
testDefinitionId: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateTestMetricParams {
|
||||
testDefinitionId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UpdateTestMetricParams {
|
||||
name: string;
|
||||
id: string;
|
||||
testDefinitionId: string;
|
||||
}
|
||||
|
||||
export interface DeleteTestMetricParams {
|
||||
testDefinitionId: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const getTestMetrics = async (context: IRestApiContext, testDefinitionId: string) => {
|
||||
return await makeRestApiRequest<TestMetricRecord[]>(
|
||||
context,
|
||||
'GET',
|
||||
getMetricsEndpoint(testDefinitionId),
|
||||
);
|
||||
};
|
||||
|
||||
export const getTestMetric = async (
|
||||
context: IRestApiContext,
|
||||
testDefinitionId: string,
|
||||
id: string,
|
||||
) => {
|
||||
return await makeRestApiRequest<TestMetricRecord>(
|
||||
context,
|
||||
'GET',
|
||||
getMetricsEndpoint(testDefinitionId, id),
|
||||
);
|
||||
};
|
||||
|
||||
export const createTestMetric = async (
|
||||
context: IRestApiContext,
|
||||
params: CreateTestMetricParams,
|
||||
) => {
|
||||
return await makeRestApiRequest<TestMetricRecord>(
|
||||
context,
|
||||
'POST',
|
||||
getMetricsEndpoint(params.testDefinitionId),
|
||||
{ name: params.name },
|
||||
);
|
||||
};
|
||||
|
||||
export const updateTestMetric = async (
|
||||
context: IRestApiContext,
|
||||
params: UpdateTestMetricParams,
|
||||
) => {
|
||||
return await makeRestApiRequest<TestMetricRecord>(
|
||||
context,
|
||||
'PATCH',
|
||||
getMetricsEndpoint(params.testDefinitionId, params.id),
|
||||
{ name: params.name },
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteTestMetric = async (
|
||||
context: IRestApiContext,
|
||||
params: DeleteTestMetricParams,
|
||||
) => {
|
||||
return await makeRestApiRequest(
|
||||
context,
|
||||
'DELETE',
|
||||
getMetricsEndpoint(params.testDefinitionId, params.id),
|
||||
);
|
||||
};
|
||||
|
||||
const getRunsEndpoint = (testDefinitionId: string, runId?: string) =>
|
||||
`${endpoint}/${testDefinitionId}/runs${runId ? `/${runId}` : ''}`;
|
||||
|
||||
// Get all test runs for a test definition
|
||||
export const getTestRuns = async (context: IRestApiContext, testDefinitionId: string) => {
|
||||
return await makeRestApiRequest<TestRunRecord[]>(
|
||||
context,
|
||||
'GET',
|
||||
getRunsEndpoint(testDefinitionId),
|
||||
);
|
||||
};
|
||||
|
||||
// Get specific test run
|
||||
export const getTestRun = async (context: IRestApiContext, params: GetTestRunParams) => {
|
||||
return await makeRestApiRequest<TestRunRecord>(
|
||||
context,
|
||||
'GET',
|
||||
getRunsEndpoint(params.testDefinitionId, params.runId),
|
||||
);
|
||||
};
|
||||
|
||||
// Start a new test run
|
||||
export const startTestRun = async (context: IRestApiContext, testDefinitionId: string) => {
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
baseURL: context.baseUrl,
|
||||
endpoint: `${endpoint}/${testDefinitionId}/run`,
|
||||
headers: { 'push-ref': context.pushRef },
|
||||
});
|
||||
// CLI is returning the response without wrapping it in `data` key
|
||||
return response as { success: boolean };
|
||||
};
|
||||
|
||||
// Delete a test run
|
||||
export const deleteTestRun = async (context: IRestApiContext, params: DeleteTestRunParams) => {
|
||||
return await makeRestApiRequest<{ success: boolean }>(
|
||||
context,
|
||||
'DELETE',
|
||||
getRunsEndpoint(params.testDefinitionId, params.runId),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -43,6 +43,25 @@ const executionToReturnTo = ref('');
|
|||
const dirtyState = ref(false);
|
||||
const githubButtonHidden = useLocalStorage(LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON, false);
|
||||
|
||||
// Track the routes that are used for the tabs
|
||||
// This is used to determine which tab to show when the route changes
|
||||
// TODO: It might be easier to manage this in the router config, by passing meta information to the routes
|
||||
// This would allow us to specify it just once on the root route, and then have the tabs be determined for children
|
||||
const testDefinitionRoutes: VIEWS[] = [
|
||||
VIEWS.TEST_DEFINITION,
|
||||
VIEWS.TEST_DEFINITION_EDIT,
|
||||
VIEWS.TEST_DEFINITION_RUNS,
|
||||
VIEWS.TEST_DEFINITION_RUNS_DETAIL,
|
||||
VIEWS.TEST_DEFINITION_RUNS_COMPARE,
|
||||
];
|
||||
|
||||
const workflowRoutes: VIEWS[] = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
||||
|
||||
const executionRoutes: VIEWS[] = [
|
||||
VIEWS.EXECUTION_HOME,
|
||||
VIEWS.WORKFLOW_EXECUTIONS,
|
||||
VIEWS.EXECUTION_PREVIEW,
|
||||
];
|
||||
const tabBarItems = computed(() => {
|
||||
const items = [
|
||||
{ value: MAIN_HEADER_TABS.WORKFLOW, label: locale.baseText('generic.editor') },
|
||||
|
@ -92,24 +111,30 @@ onMounted(async () => {
|
|||
syncTabsWithRoute(route);
|
||||
});
|
||||
|
||||
function isViewRoute(name: unknown): name is VIEWS {
|
||||
return (
|
||||
typeof name === 'string' &&
|
||||
[testDefinitionRoutes, workflowRoutes, executionRoutes].flat().includes(name as VIEWS)
|
||||
);
|
||||
}
|
||||
|
||||
function syncTabsWithRoute(to: RouteLocation, from?: RouteLocation): void {
|
||||
if (to.matched.some((record) => record.name === VIEWS.TEST_DEFINITION)) {
|
||||
activeHeaderTab.value = MAIN_HEADER_TABS.TEST_DEFINITION;
|
||||
}
|
||||
if (
|
||||
to.name === VIEWS.EXECUTION_HOME ||
|
||||
to.name === VIEWS.WORKFLOW_EXECUTIONS ||
|
||||
to.name === VIEWS.EXECUTION_PREVIEW
|
||||
) {
|
||||
activeHeaderTab.value = MAIN_HEADER_TABS.EXECUTIONS;
|
||||
} else if (
|
||||
to.name === VIEWS.WORKFLOW ||
|
||||
to.name === VIEWS.NEW_WORKFLOW ||
|
||||
to.name === VIEWS.EXECUTION_DEBUG
|
||||
) {
|
||||
activeHeaderTab.value = MAIN_HEADER_TABS.WORKFLOW;
|
||||
// Map route types to their corresponding tab in the header
|
||||
const routeTabMapping = [
|
||||
{ routes: testDefinitionRoutes, tab: MAIN_HEADER_TABS.TEST_DEFINITION },
|
||||
{ routes: executionRoutes, tab: MAIN_HEADER_TABS.EXECUTIONS },
|
||||
{ routes: workflowRoutes, tab: MAIN_HEADER_TABS.WORKFLOW },
|
||||
];
|
||||
|
||||
// Update the active tab based on the current route
|
||||
if (to.name && isViewRoute(to.name)) {
|
||||
const matchingTab = routeTabMapping.find(({ routes }) => routes.includes(to.name as VIEWS));
|
||||
if (matchingTab) {
|
||||
activeHeaderTab.value = matchingTab.tab;
|
||||
}
|
||||
}
|
||||
|
||||
// Store the current workflow ID, but only if it's not a new workflow
|
||||
if (to.params.name !== 'new' && typeof to.params.name === 'string') {
|
||||
workflowToReturnTo.value = to.params.name;
|
||||
}
|
||||
|
|
|
@ -16,7 +16,10 @@ interface TagsDropdownProps {
|
|||
allTags: ITag[];
|
||||
isLoading: boolean;
|
||||
tagsById: Record<string, ITag>;
|
||||
createEnabled?: boolean;
|
||||
manageEnabled?: boolean;
|
||||
createTag?: (name: string) => Promise<ITag>;
|
||||
multipleLimit?: number;
|
||||
}
|
||||
|
||||
const i18n = useI18n();
|
||||
|
@ -27,6 +30,10 @@ const props = withDefaults(defineProps<TagsDropdownProps>(), {
|
|||
placeholder: '',
|
||||
modelValue: () => [],
|
||||
eventBus: null,
|
||||
createEnabled: true,
|
||||
manageEnabled: true,
|
||||
createTag: undefined,
|
||||
multipleLimit: 0,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@ -59,6 +66,17 @@ const appliedTags = computed<string[]>(() => {
|
|||
return props.modelValue.filter((id: string) => props.tagsById[id]);
|
||||
});
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
return { 'tags-container': true, focused: focused.value };
|
||||
});
|
||||
|
||||
const dropdownClasses = computed(() => ({
|
||||
'tags-dropdown': true,
|
||||
[`tags-dropdown-${dropdownId}`]: true,
|
||||
'tags-dropdown-create-enabled': props.createEnabled,
|
||||
'tags-dropdown-manage-enabled': props.manageEnabled,
|
||||
}));
|
||||
|
||||
watch(
|
||||
() => props.allTags,
|
||||
() => {
|
||||
|
@ -189,7 +207,7 @@ onClickOutside(
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" :class="{ 'tags-container': true, focused }" @keydown.stop>
|
||||
<div ref="container" :class="containerClasses" @keydown.stop>
|
||||
<N8nSelect
|
||||
ref="selectRef"
|
||||
:teleported="true"
|
||||
|
@ -199,16 +217,17 @@ onClickOutside(
|
|||
:filter-method="filterOptions"
|
||||
filterable
|
||||
multiple
|
||||
:multiple-limit="props.multipleLimit"
|
||||
:reserve-keyword="false"
|
||||
loading-text="..."
|
||||
:popper-class="['tags-dropdown', 'tags-dropdown-' + dropdownId].join(' ')"
|
||||
:popper-class="dropdownClasses"
|
||||
data-test-id="tags-dropdown"
|
||||
@update:model-value="onTagsUpdated"
|
||||
@visible-change="onVisibleChange"
|
||||
@remove-tag="onRemoveTag"
|
||||
>
|
||||
<N8nOption
|
||||
v-if="options.length === 0 && filter"
|
||||
v-if="createEnabled && options.length === 0 && filter"
|
||||
:key="CREATE_KEY"
|
||||
ref="createRef"
|
||||
:value="CREATE_KEY"
|
||||
|
@ -220,7 +239,7 @@ onClickOutside(
|
|||
</span>
|
||||
</N8nOption>
|
||||
<N8nOption v-else-if="options.length === 0" value="message" disabled>
|
||||
<span>{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
|
||||
<span v-if="createEnabled">{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
|
||||
<span v-if="allTags.length > 0">{{
|
||||
i18n.baseText('tagsDropdown.noMatchingTagsExist')
|
||||
}}</span>
|
||||
|
@ -237,7 +256,7 @@ onClickOutside(
|
|||
data-test-id="tag"
|
||||
/>
|
||||
|
||||
<N8nOption :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags">
|
||||
<N8nOption v-if="manageEnabled" :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags">
|
||||
<font-awesome-icon icon="cog" />
|
||||
<span>{{ i18n.baseText('tagsDropdown.manageTags') }}</span>
|
||||
</N8nOption>
|
||||
|
@ -313,7 +332,7 @@ onClickOutside(
|
|||
}
|
||||
}
|
||||
|
||||
&:after {
|
||||
.tags-dropdown-manage-enabled &:after {
|
||||
content: ' ';
|
||||
display: block;
|
||||
min-height: $--item-height;
|
||||
|
|
|
@ -1,18 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { EditableField } from '../types';
|
||||
|
||||
export interface EvaluationHeaderProps {
|
||||
modelValue: {
|
||||
value: string;
|
||||
isEditing: boolean;
|
||||
tempValue: string;
|
||||
};
|
||||
startEditing: (field: string) => void;
|
||||
saveChanges: (field: string) => void;
|
||||
handleKeydown: (e: KeyboardEvent, field: string) => void;
|
||||
modelValue: EditableField<string>;
|
||||
startEditing: (field: 'name') => void;
|
||||
saveChanges: (field: 'name') => void;
|
||||
handleKeydown: (e: KeyboardEvent, field: 'name') => void;
|
||||
}
|
||||
|
||||
defineEmits<{ 'update:modelValue': [value: EvaluationHeaderProps['modelValue']] }>();
|
||||
defineEmits<{ 'update:modelValue': [value: EditableField<string>] }>();
|
||||
defineProps<EvaluationHeaderProps>();
|
||||
|
||||
const locale = useI18n();
|
||||
|
|
|
@ -2,12 +2,14 @@
|
|||
import { useI18n } from '@/composables/useI18n';
|
||||
import { ElCollapseTransition } from 'element-plus';
|
||||
import { ref, nextTick } from 'vue';
|
||||
import N8nTooltip from 'n8n-design-system/components/N8nTooltip';
|
||||
|
||||
interface EvaluationStep {
|
||||
title: string;
|
||||
warning?: boolean;
|
||||
small?: boolean;
|
||||
expanded?: boolean;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<EvaluationStep>(), {
|
||||
|
@ -15,12 +17,14 @@ const props = withDefaults(defineProps<EvaluationStep>(), {
|
|||
warning: false,
|
||||
small: false,
|
||||
expanded: true,
|
||||
tooltip: '',
|
||||
});
|
||||
|
||||
const locale = useI18n();
|
||||
const isExpanded = ref(props.expanded);
|
||||
const contentRef = ref<HTMLElement | null>(null);
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const isTooltipVisible = ref(false);
|
||||
|
||||
const toggleExpand = async () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
|
@ -31,11 +35,32 @@ const toggleExpand = async () => {
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
const showTooltip = () => {
|
||||
isTooltipVisible.value = true;
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
isTooltipVisible.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" :class="[$style.evaluationStep, small && $style.small]">
|
||||
<div :class="$style.content">
|
||||
<div
|
||||
ref="containerRef"
|
||||
:class="[$style.evaluationStep, small && $style.small]"
|
||||
data-test-id="evaluation-step"
|
||||
>
|
||||
<N8nTooltip :disabled="!tooltip" placement="right" :offset="25" :visible="isTooltipVisible">
|
||||
<template #content>
|
||||
{{ tooltip }}
|
||||
</template>
|
||||
<!-- This empty div is needed to ensure the tooltip trigger area spans the full width of the step.
|
||||
Without it, the tooltip would only show when hovering over the content div, which is narrower.
|
||||
The contentPlaceholder creates an invisible full-width area that can trigger the tooltip. -->
|
||||
<div :class="$style.contentPlaceholder"></div>
|
||||
</N8nTooltip>
|
||||
<div :class="$style.content" @mouseenter="showTooltip" @mouseleave="hideTooltip">
|
||||
<div :class="$style.header">
|
||||
<div :class="[$style.icon, warning && $style.warning]">
|
||||
<slot name="icon" />
|
||||
|
@ -47,6 +72,7 @@ const toggleExpand = async () => {
|
|||
:class="$style.collapseButton"
|
||||
:aria-expanded="isExpanded"
|
||||
:aria-controls="'content-' + title.replace(/\s+/g, '-')"
|
||||
data-test-id="evaluation-step-collapse-button"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
{{
|
||||
|
@ -59,7 +85,7 @@ const toggleExpand = async () => {
|
|||
</div>
|
||||
<ElCollapseTransition v-if="$slots.cardContent">
|
||||
<div v-show="isExpanded" :class="$style.cardContentWrapper">
|
||||
<div ref="contentRef" :class="$style.cardContent">
|
||||
<div ref="contentRef" :class="$style.cardContent" data-test-id="evaluation-step-content">
|
||||
<slot name="cardContent" />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -85,6 +111,14 @@ const toggleExpand = async () => {
|
|||
width: 80%;
|
||||
}
|
||||
}
|
||||
.contentPlaceholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
@ -1,22 +1,30 @@
|
|||
<script setup lang="ts">
|
||||
import type { TestMetricRecord } from '@/api/testDefinition.ee';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
export interface MetricsInputProps {
|
||||
modelValue: string[];
|
||||
modelValue: Array<Partial<TestMetricRecord>>;
|
||||
}
|
||||
const props = defineProps<MetricsInputProps>();
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: MetricsInputProps['modelValue']] }>();
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: MetricsInputProps['modelValue']];
|
||||
deleteMetric: [metric: Partial<TestMetricRecord>];
|
||||
}>();
|
||||
const locale = useI18n();
|
||||
|
||||
function addNewMetric() {
|
||||
emit('update:modelValue', [...props.modelValue, '']);
|
||||
emit('update:modelValue', [...props.modelValue, { name: '' }]);
|
||||
}
|
||||
|
||||
function updateMetric(index: number, value: string) {
|
||||
function updateMetric(index: number, name: string) {
|
||||
const newMetrics = [...props.modelValue];
|
||||
newMetrics[index] = value;
|
||||
newMetrics[index].name = name;
|
||||
emit('update:modelValue', newMetrics);
|
||||
}
|
||||
|
||||
function onDeleteMetric(metric: Partial<TestMetricRecord>) {
|
||||
emit('deleteMetric', metric);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -27,14 +35,15 @@ function updateMetric(index: number, value: string) {
|
|||
:class="$style.metricField"
|
||||
>
|
||||
<div :class="$style.metricsContainer">
|
||||
<div v-for="(metric, index) in modelValue" :key="index">
|
||||
<div v-for="(metric, index) in modelValue" :key="index" :class="$style.metricItem">
|
||||
<N8nInput
|
||||
:ref="`metric_${index}`"
|
||||
data-test-id="evaluation-metric-item"
|
||||
:model-value="metric"
|
||||
:model-value="metric.name"
|
||||
:placeholder="locale.baseText('testDefinition.edit.metricsPlaceholder')"
|
||||
@update:model-value="(value: string) => updateMetric(index, value)"
|
||||
/>
|
||||
<n8n-icon-button icon="trash" type="text" @click="onDeleteMetric(metric)" />
|
||||
</div>
|
||||
<n8n-button
|
||||
type="tertiary"
|
||||
|
@ -54,6 +63,11 @@ function updateMetric(index: number, value: string) {
|
|||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.metricItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metricField {
|
||||
width: 100%;
|
||||
margin-top: var(--spacing-xs);
|
||||
|
|
|
@ -0,0 +1,194 @@
|
|||
<script setup lang="ts">
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { computed, onMounted, ref, useCssModule } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||
import { useCanvasMapping } from '@/composables/useCanvasMapping';
|
||||
import { createEventBus, N8nTooltip } from 'n8n-design-system';
|
||||
import type { CanvasConnectionPort, CanvasEventBusEvents, CanvasNodeData } from '@/types';
|
||||
import { useVueFlow } from '@vue-flow/core';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const locale = useI18n();
|
||||
|
||||
const { resetWorkspace, initializeWorkspace } = useCanvasOperations({ router });
|
||||
|
||||
const eventBus = createEventBus<CanvasEventBusEvents>();
|
||||
const style = useCssModule();
|
||||
const uuid = crypto.randomUUID();
|
||||
const props = defineProps<{
|
||||
modelValue: Array<{ name: string }>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Array<{ name: string }>];
|
||||
}>();
|
||||
|
||||
const isLoading = ref(true);
|
||||
|
||||
const workflowId = computed(() => route.params.name as string);
|
||||
const testId = computed(() => route.params.testId as string);
|
||||
const workflow = computed(() => workflowsStore.getWorkflowById(workflowId.value));
|
||||
const workflowObject = computed(() => workflowsStore.getCurrentWorkflow(true));
|
||||
const canvasId = computed(() => `${uuid}-${testId.value}`);
|
||||
|
||||
const { onNodesInitialized, fitView, zoomTo } = useVueFlow({ id: canvasId.value });
|
||||
const nodes = computed(() => {
|
||||
return workflow.value.nodes ?? [];
|
||||
});
|
||||
const connections = computed(() => workflow.value.connections);
|
||||
|
||||
const { nodes: mappedNodes, connections: mappedConnections } = useCanvasMapping({
|
||||
nodes,
|
||||
connections,
|
||||
workflowObject,
|
||||
});
|
||||
async function loadData() {
|
||||
workflowsStore.resetState();
|
||||
resetWorkspace();
|
||||
const loadingPromise = Promise.all([
|
||||
nodeTypesStore.getNodeTypes(),
|
||||
workflowsStore.fetchWorkflow(workflowId.value),
|
||||
]);
|
||||
await loadingPromise;
|
||||
initializeWorkspace(workflow.value);
|
||||
disableAllNodes();
|
||||
}
|
||||
function getNodeNameById(id: string) {
|
||||
return mappedNodes.value.find((node) => node.id === id)?.data?.name;
|
||||
}
|
||||
function updateNodeClasses(nodeIds: string[], isPinned: boolean) {
|
||||
eventBus.emit('nodes:action', {
|
||||
ids: nodeIds,
|
||||
action: 'update:node:class',
|
||||
payload: {
|
||||
className: style.pinnedNode,
|
||||
add: isPinned,
|
||||
},
|
||||
});
|
||||
eventBus.emit('nodes:action', {
|
||||
ids: nodeIds,
|
||||
action: 'update:node:class',
|
||||
payload: {
|
||||
className: style.notPinnedNode,
|
||||
add: !isPinned,
|
||||
},
|
||||
});
|
||||
}
|
||||
function disableAllNodes() {
|
||||
const ids = mappedNodes.value.map((node) => node.id);
|
||||
updateNodeClasses(ids, false);
|
||||
|
||||
const pinnedNodes = props.modelValue
|
||||
.map((node) => {
|
||||
const matchedNode = mappedNodes.value.find(
|
||||
(mappedNode) => mappedNode?.data?.name === node.name,
|
||||
);
|
||||
return matchedNode?.id ?? null;
|
||||
})
|
||||
.filter((n) => n !== null);
|
||||
|
||||
if (pinnedNodes.length > 0) {
|
||||
updateNodeClasses(pinnedNodes, true);
|
||||
}
|
||||
}
|
||||
function onPinButtonClick(data: CanvasNodeData) {
|
||||
const nodeName = getNodeNameById(data.id);
|
||||
if (!nodeName) return;
|
||||
|
||||
const isPinned = props.modelValue.some((node) => node.name === nodeName);
|
||||
const updatedNodes = isPinned
|
||||
? props.modelValue.filter((node) => node.name !== nodeName)
|
||||
: [...props.modelValue, { name: nodeName }];
|
||||
|
||||
emit('update:modelValue', updatedNodes);
|
||||
updateNodeClasses([data.id], !isPinned);
|
||||
}
|
||||
function isPinButtonVisible(outputs: CanvasConnectionPort[]) {
|
||||
return outputs.length === 1;
|
||||
}
|
||||
|
||||
onNodesInitialized(async () => {
|
||||
await fitView();
|
||||
isLoading.value = false;
|
||||
await zoomTo(0.7, { duration: 400 });
|
||||
});
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<N8nSpinner v-if="isLoading" size="xlarge" type="dots" :class="$style.spinner" />
|
||||
<Canvas
|
||||
:id="canvasId"
|
||||
:loading="isLoading"
|
||||
:class="{ [$style.canvas]: true }"
|
||||
:nodes="mappedNodes"
|
||||
:connections="mappedConnections"
|
||||
:show-bug-reporting-button="false"
|
||||
:read-only="true"
|
||||
:event-bus="eventBus"
|
||||
>
|
||||
<template #nodeToolbar="{ data, outputs }">
|
||||
<div :class="$style.pinButtonContainer">
|
||||
<N8nTooltip v-if="isPinButtonVisible(outputs)" placement="left">
|
||||
<template #content>
|
||||
{{ locale.baseText('testDefinition.edit.nodesPinning.pinButtonTooltip') }}
|
||||
</template>
|
||||
<n8n-icon-button
|
||||
type="tertiary"
|
||||
size="large"
|
||||
icon="thumbtack"
|
||||
:class="$style.pinButton"
|
||||
@click="onPinButtonClick(data)"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</template>
|
||||
</Canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
}
|
||||
.pinButtonContainer {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
bottom: 100%;
|
||||
}
|
||||
|
||||
.pinButton {
|
||||
cursor: pointer;
|
||||
color: var(--canvas-node--border-color);
|
||||
border: none;
|
||||
}
|
||||
.notPinnedNode,
|
||||
.pinnedNode {
|
||||
:global(.n8n-node-icon) > div {
|
||||
filter: contrast(40%) brightness(1.5) grayscale(100%);
|
||||
}
|
||||
}
|
||||
.pinnedNode {
|
||||
--canvas-node--border-color: hsla(247, 49%, 55%, 1);
|
||||
|
||||
:global(.n8n-node-icon) > div {
|
||||
filter: contrast(40%) brightness(1.5) grayscale(100%);
|
||||
}
|
||||
}
|
||||
.spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
</style>
|
|
@ -3,40 +3,48 @@ import { useI18n } from '@/composables/useI18n';
|
|||
import type { ITag } from '@/Interface';
|
||||
import { createEventBus } from 'n8n-design-system';
|
||||
import { computed } from 'vue';
|
||||
import type { EditableField } from '../types';
|
||||
|
||||
export interface TagsInputProps {
|
||||
modelValue?: {
|
||||
isEditing: boolean;
|
||||
appliedTagIds: string[];
|
||||
};
|
||||
modelValue: EditableField<string[]>;
|
||||
allTags: ITag[];
|
||||
tagsById: Record<string, ITag>;
|
||||
isLoading: boolean;
|
||||
startEditing: (field: string) => void;
|
||||
saveChanges: (field: string) => void;
|
||||
cancelEditing: (field: string) => void;
|
||||
startEditing: (field: 'tags') => void;
|
||||
saveChanges: (field: 'tags') => void;
|
||||
cancelEditing: (field: 'tags') => void;
|
||||
createTag?: (name: string) => Promise<ITag>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TagsInputProps>(), {
|
||||
modelValue: () => ({
|
||||
isEditing: false,
|
||||
appliedTagIds: [],
|
||||
value: [],
|
||||
tempValue: [],
|
||||
}),
|
||||
createTag: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: TagsInputProps['modelValue']] }>();
|
||||
|
||||
const locale = useI18n();
|
||||
const tagsEventBus = createEventBus();
|
||||
|
||||
/**
|
||||
* Compute the tag name by ID
|
||||
*/
|
||||
const getTagName = computed(() => (tagId: string) => {
|
||||
return props.tagsById[tagId]?.name ?? '';
|
||||
});
|
||||
|
||||
/**
|
||||
* Update the tempValue of the tags when the dropdown changes.
|
||||
* This does not finalize the changes; that happens on blur or hitting enter.
|
||||
*/
|
||||
function updateTags(tags: string[]) {
|
||||
const newTags = tags[0] ? [tags[0]] : [];
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
appliedTagIds: newTags,
|
||||
tempValue: tags,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
@ -48,12 +56,13 @@ function updateTags(tags: string[]) {
|
|||
:bold="false"
|
||||
size="small"
|
||||
>
|
||||
<!-- Read-only view -->
|
||||
<div v-if="!modelValue.isEditing" :class="$style.tagsRead" @click="startEditing('tags')">
|
||||
<n8n-text v-if="modelValue.appliedTagIds.length === 0" size="small">
|
||||
<n8n-text v-if="modelValue.value.length === 0" size="small">
|
||||
{{ locale.baseText('testDefinition.edit.selectTag') }}
|
||||
</n8n-text>
|
||||
<n8n-tag
|
||||
v-for="tagId in modelValue.appliedTagIds"
|
||||
v-for="tagId in modelValue.value"
|
||||
:key="tagId"
|
||||
:text="getTagName(tagId)"
|
||||
data-test-id="evaluation-tag-field"
|
||||
|
@ -66,24 +75,26 @@ function updateTags(tags: string[]) {
|
|||
transparent
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Editing view -->
|
||||
<TagsDropdown
|
||||
v-else
|
||||
:model-value="modelValue.appliedTagIds"
|
||||
:model-value="modelValue.tempValue"
|
||||
:placeholder="locale.baseText('executionAnnotationView.chooseOrCreateATag')"
|
||||
:create-enabled="false"
|
||||
:create-enabled="modelValue.tempValue.length === 0"
|
||||
:all-tags="allTags"
|
||||
:is-loading="isLoading"
|
||||
:tags-by-id="tagsById"
|
||||
data-test-id="workflow-tags-dropdown"
|
||||
:event-bus="tagsEventBus"
|
||||
:create-tag="createTag"
|
||||
:manage-enabled="false"
|
||||
:multiple-limit="1"
|
||||
@update:model-value="updateTags"
|
||||
@esc="cancelEditing('tags')"
|
||||
@blur="saveChanges('tags')"
|
||||
/>
|
||||
</n8n-input-label>
|
||||
<n8n-text size="small" color="text-light">{{
|
||||
locale.baseText('testDefinition.edit.tagsHelpText')
|
||||
}}</n8n-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { TestListItem } from '@/components/TestDefinition/types';
|
||||
import TimeAgo from '@/components/TimeAgo.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import n8nIconButton from 'n8n-design-system/components/N8nIconButton';
|
||||
|
||||
|
@ -20,21 +21,25 @@ const emit = defineEmits<{
|
|||
const actions = [
|
||||
{
|
||||
icon: 'play',
|
||||
id: 'run',
|
||||
event: () => emit('run-test', props.test.id),
|
||||
tooltip: locale.baseText('testDefinition.runTest'),
|
||||
},
|
||||
{
|
||||
icon: 'list',
|
||||
id: 'view',
|
||||
event: () => emit('view-details', props.test.id),
|
||||
tooltip: locale.baseText('testDefinition.viewDetails'),
|
||||
},
|
||||
{
|
||||
icon: 'pen',
|
||||
id: 'edit',
|
||||
event: () => emit('edit-test', props.test.id),
|
||||
tooltip: locale.baseText('testDefinition.editTest'),
|
||||
},
|
||||
{
|
||||
icon: 'trash',
|
||||
id: 'delete',
|
||||
event: () => emit('delete-test', props.test.id),
|
||||
tooltip: locale.baseText('testDefinition.deleteTest'),
|
||||
},
|
||||
|
@ -42,20 +47,27 @@ const actions = [
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.testItem" @click="$emit('view-details', test.id)">
|
||||
<div
|
||||
:class="$style.testItem"
|
||||
:data-test-id="`test-item-${test.id}`"
|
||||
@click="$emit('view-details', test.id)"
|
||||
>
|
||||
<div :class="$style.testInfo">
|
||||
<div :class="$style.testName">
|
||||
{{ test.name }}
|
||||
<n8n-tag v-if="test.tagName" :text="test.tagName" />
|
||||
</div>
|
||||
<div :class="$style.testCases">
|
||||
{{ locale.baseText('testDefinition.list.testCases', { adjustToNumber: test.testCases }) }}
|
||||
<n8n-loading v-if="!test.execution.lastRun" :loading="true" :rows="1" />
|
||||
<span v-else>{{
|
||||
locale.baseText('testDefinition.list.lastRun', {
|
||||
interpolate: { lastRun: test.execution.lastRun },
|
||||
})
|
||||
}}</span>
|
||||
<n8n-text size="small">
|
||||
{{ locale.baseText('testDefinition.list.testRuns', { adjustToNumber: test.testCases }) }}
|
||||
</n8n-text>
|
||||
<template v-if="test.execution.status === 'running'">
|
||||
{{ locale.baseText('testDefinition.list.running') }}
|
||||
<n8n-spinner />
|
||||
</template>
|
||||
<span v-else-if="test.execution.lastRun">
|
||||
{{ locale.baseText('testDefinition.list.lastRun') }}
|
||||
<TimeAgo :date="test.execution.lastRun" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -68,7 +80,7 @@ const actions = [
|
|||
}}
|
||||
</div>
|
||||
<div v-for="(value, key) in test.execution.metrics" :key="key" :class="$style.metric">
|
||||
{{ key }}: {{ value ?? '-' }}
|
||||
{{ key }}: {{ value.toFixed(2) ?? '-' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -80,6 +92,7 @@ const actions = [
|
|||
<component
|
||||
:is="n8nIconButton"
|
||||
:icon="action.icon"
|
||||
:data-test-id="`${action.id}-test-button-${test.id}`"
|
||||
type="tertiary"
|
||||
size="mini"
|
||||
@click.stop="action.event"
|
||||
|
@ -115,7 +128,6 @@ const actions = [
|
|||
align-items: center;
|
||||
gap: var(--spacing-2xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-bottom: var(--spacing-4xs);
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ const locale = useI18n();
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.testsList">
|
||||
<div :class="$style.testsList" data-test-id="test-definition-list">
|
||||
<div :class="$style.testsHeader">
|
||||
<n8n-button
|
||||
:label="locale.baseText('testDefinition.list.createNew')"
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, watchEffect } from 'vue';
|
||||
import { Line } from 'vue-chartjs';
|
||||
import { useMetricsChart } from '../composables/useMetricsChart';
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { AppliedThemeOption } from '@/Interface';
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedMetric': [value: string];
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
selectedMetric: string;
|
||||
runs: TestRunRecord[];
|
||||
theme?: AppliedThemeOption;
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
const metricsChart = useMetricsChart(props.theme);
|
||||
|
||||
const availableMetrics = computed(() => {
|
||||
return props.runs.reduce((acc, run) => {
|
||||
const metricKeys = Object.keys(run.metrics ?? {});
|
||||
return [...new Set([...acc, ...metricKeys])];
|
||||
}, [] as string[]);
|
||||
});
|
||||
|
||||
const chartData = computed(() => metricsChart.generateChartData(props.runs, props.selectedMetric));
|
||||
|
||||
const chartOptions = computed(() =>
|
||||
metricsChart.generateChartOptions({
|
||||
metric: props.selectedMetric,
|
||||
xTitle: locale.baseText('testDefinition.listRuns.runDate'),
|
||||
}),
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.runs.length > 0 && !props.selectedMetric) {
|
||||
emit('update:selectedMetric', availableMetrics.value[0]);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="availableMetrics.length > 0" :class="$style.metricsChartContainer">
|
||||
<div :class="$style.chartHeader">
|
||||
<N8nText>{{ locale.baseText('testDefinition.listRuns.metricsOverTime') }}</N8nText>
|
||||
<N8nSelect
|
||||
:model-value="selectedMetric"
|
||||
:class="$style.metricSelect"
|
||||
placeholder="Select metric"
|
||||
@update:model-value="emit('update:selectedMetric', $event)"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="metric in availableMetrics"
|
||||
:key="metric"
|
||||
:label="metric"
|
||||
:value="metric"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</div>
|
||||
<div :class="$style.chartWrapper">
|
||||
<Line
|
||||
v-if="availableMetrics.length > 0"
|
||||
:key="selectedMetric"
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
:class="$style.metricsChart"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.metricsChartContainer {
|
||||
margin: var(--spacing-m) 0;
|
||||
background: var(--color-background-xlight);
|
||||
border-radius: var(--border-radius-large);
|
||||
box-shadow: var(--box-shadow-base);
|
||||
|
||||
.chartHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-m);
|
||||
padding: var(--spacing-s);
|
||||
border-bottom: 1px solid var(--color-foreground-base);
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
font-size: var(--font-size-l);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
|
||||
.metricSelect {
|
||||
max-width: 15rem;
|
||||
}
|
||||
|
||||
.chartWrapper {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
padding: var(--spacing-s);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,119 @@
|
|||
<script setup lang="ts">
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { TestDefinitionTableColumn } from '../shared/TestDefinitionTable.vue';
|
||||
import TestDefinitionTable from '../shared/TestDefinitionTable.vue';
|
||||
import { convertToDisplayDate } from '@/utils/typesUtils';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const emit = defineEmits<{
|
||||
getRunDetail: [run: TestRunRecord];
|
||||
selectionChange: [runs: TestRunRecord[]];
|
||||
deleteRuns: [runs: TestRunRecord[]];
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
runs: TestRunRecord[];
|
||||
selectable?: boolean;
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
const navigateToRunDetail = (run: TestRunRecord) => emit('getRunDetail', run);
|
||||
const selectedRows = ref<TestRunRecord[]>([]);
|
||||
|
||||
const metrics = computed(() => {
|
||||
return props.runs.reduce((acc, run) => {
|
||||
const metricKeys = Object.keys(run.metrics ?? {});
|
||||
return [...new Set([...acc, ...metricKeys])];
|
||||
}, [] as string[]);
|
||||
});
|
||||
|
||||
const columns = computed((): Array<TestDefinitionTableColumn<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' },
|
||||
],
|
||||
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).getTime()),
|
||||
},
|
||||
|
||||
...metrics.value.map((metric) => ({
|
||||
prop: `metrics.${metric}`,
|
||||
label: metric,
|
||||
sortable: true,
|
||||
formatter: (row: TestRunRecord) => `${row.metrics?.[metric]?.toFixed(2) ?? '-'}`,
|
||||
})),
|
||||
];
|
||||
});
|
||||
|
||||
function onSelectionChange(runs: TestRunRecord[]) {
|
||||
selectedRows.value = runs;
|
||||
emit('selectionChange', runs);
|
||||
}
|
||||
|
||||
function deleteRuns() {
|
||||
emit('deleteRuns', selectedRows.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.footer">
|
||||
<n8n-button
|
||||
v-show="selectedRows.length > 0"
|
||||
type="danger"
|
||||
:class="$style.activator"
|
||||
:size="'medium'"
|
||||
:icon="'trash'"
|
||||
data-test-id="delete-runs-button"
|
||||
@click="deleteRuns"
|
||||
>
|
||||
{{
|
||||
locale.baseText('testDefinition.listRuns.deleteRuns', {
|
||||
adjustToNumber: selectedRows.length,
|
||||
})
|
||||
}}
|
||||
</n8n-button>
|
||||
</div>
|
||||
<TestDefinitionTable
|
||||
:data="runs"
|
||||
:columns="columns"
|
||||
selectable
|
||||
@row-click="navigateToRunDetail"
|
||||
@selection-change="onSelectionChange"
|
||||
/>
|
||||
<N8nText :class="$style.runsTableTotal">{{
|
||||
locale.baseText('testDefinition.edit.pastRuns.total', { adjustToNumber: runs.length })
|
||||
}}</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,145 @@
|
|||
import type { ChartData, ChartOptions } from 'chart.js';
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import dateFormat from 'dateformat';
|
||||
import type { AppliedThemeOption } from '@/Interface';
|
||||
|
||||
const THEME_COLORS = {
|
||||
light: {
|
||||
primary: 'rgb(255, 110, 92)',
|
||||
text: {
|
||||
primary: 'rgb(68, 68, 68)',
|
||||
secondary: 'rgb(102, 102, 102)',
|
||||
},
|
||||
background: 'rgb(255, 255, 255)',
|
||||
grid: 'rgba(68, 68, 68, 0.1)',
|
||||
},
|
||||
dark: {
|
||||
primary: 'rgb(255, 110, 92)',
|
||||
text: {
|
||||
primary: 'rgb(255, 255, 255)',
|
||||
secondary: 'rgba(255, 255, 255, 0.7)',
|
||||
},
|
||||
background: 'rgb(32, 32, 32)',
|
||||
grid: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
};
|
||||
|
||||
export function useMetricsChart(mode: AppliedThemeOption = 'light') {
|
||||
const colors = THEME_COLORS[mode];
|
||||
const toRGBA = (color: string, alpha: number) => {
|
||||
if (color.includes('rgba')) return color;
|
||||
return color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
|
||||
};
|
||||
function generateChartData(runs: TestRunRecord[], metric: string): ChartData<'line'> {
|
||||
const sortedRuns = [...runs]
|
||||
.sort((a, b) => new Date(a.runAt).getTime() - new Date(b.runAt).getTime())
|
||||
.filter((run) => run.metrics?.[metric]);
|
||||
|
||||
return {
|
||||
labels: sortedRuns.map((run) => {
|
||||
return dateFormat(run.runAt, 'yyyy-mm-dd HH:MM');
|
||||
}),
|
||||
datasets: [
|
||||
{
|
||||
label: metric,
|
||||
data: sortedRuns.map((run) => run.metrics?.[metric] ?? 0),
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: toRGBA(colors.primary, 0.1),
|
||||
borderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
pointBackgroundColor: colors.primary,
|
||||
pointBorderColor: colors.primary,
|
||||
pointHoverBackgroundColor: colors.background,
|
||||
pointHoverBorderColor: colors.primary,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function generateChartOptions(params: { metric: string; xTitle: string }): ChartOptions<'line'> {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
devicePixelRatio: 2,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: colors.grid,
|
||||
},
|
||||
ticks: {
|
||||
padding: 8,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: params.metric,
|
||||
padding: 16,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: params.xTitle,
|
||||
padding: 16,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
backgroundColor: colors.background,
|
||||
titleColor: colors.text.primary,
|
||||
titleFont: {
|
||||
weight: '600',
|
||||
},
|
||||
bodyColor: colors.text.secondary,
|
||||
bodySpacing: 4,
|
||||
padding: 12,
|
||||
borderColor: toRGBA(colors.primary, 0.2),
|
||||
borderWidth: 1,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
title: (tooltipItems) => tooltipItems[0].label,
|
||||
label: (context) => `${params.metric}: ${context.parsed.y.toFixed(2)}`,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
duration: 750,
|
||||
easing: 'easeInOutQuart',
|
||||
},
|
||||
transitions: {
|
||||
active: {
|
||||
animation: {
|
||||
duration: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
generateChartData,
|
||||
generateChartOptions,
|
||||
};
|
||||
}
|
|
@ -1,27 +1,10 @@
|
|||
import { ref, computed } from 'vue';
|
||||
import type { ComponentPublicInstance } from 'vue';
|
||||
import type { INodeParameterResourceLocator } from 'n8n-workflow';
|
||||
import type { ComponentPublicInstance, ComputedRef } from 'vue';
|
||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||
import type AnnotationTagsDropdownEe from '@/components/AnnotationTagsDropdown.ee.vue';
|
||||
import type { N8nInput } from 'n8n-design-system';
|
||||
import type { UpdateTestDefinitionParams } from '@/api/testDefinition.ee';
|
||||
|
||||
interface EditableField {
|
||||
value: string;
|
||||
isEditing: boolean;
|
||||
tempValue: string;
|
||||
}
|
||||
|
||||
export interface IEvaluationFormState {
|
||||
name: EditableField;
|
||||
description: string;
|
||||
tags: {
|
||||
isEditing: boolean;
|
||||
appliedTagIds: string[];
|
||||
};
|
||||
evaluationWorkflow: INodeParameterResourceLocator;
|
||||
metrics: string[];
|
||||
}
|
||||
import type { EditableField, EditableFormState, EvaluationFormState } from '../types';
|
||||
|
||||
type FormRefs = {
|
||||
nameInput: ComponentPublicInstance<typeof N8nInput>;
|
||||
|
@ -29,64 +12,75 @@ type FormRefs = {
|
|||
};
|
||||
|
||||
export function useTestDefinitionForm() {
|
||||
// Stores
|
||||
const evaluationsStore = useTestDefinitionStore();
|
||||
|
||||
// Form state
|
||||
const state = ref<IEvaluationFormState>({
|
||||
description: '',
|
||||
// State initialization
|
||||
const state = ref<EvaluationFormState>({
|
||||
name: {
|
||||
value: `My Test [${new Date().toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' })}]`,
|
||||
isEditing: false,
|
||||
value: `My Test ${evaluationsStore.allTestDefinitions.length + 1}`,
|
||||
tempValue: '',
|
||||
isEditing: false,
|
||||
},
|
||||
tags: {
|
||||
value: [],
|
||||
tempValue: [],
|
||||
isEditing: false,
|
||||
appliedTagIds: [],
|
||||
},
|
||||
description: '',
|
||||
evaluationWorkflow: {
|
||||
mode: 'list',
|
||||
value: '',
|
||||
__rl: true,
|
||||
},
|
||||
metrics: [''],
|
||||
metrics: [],
|
||||
mockedNodes: [],
|
||||
});
|
||||
|
||||
// Loading states
|
||||
const isSaving = ref(false);
|
||||
const fieldsIssues = ref<Array<{ field: string; message: string }>>([]);
|
||||
|
||||
// Field refs
|
||||
const fields = ref<FormRefs>({} as FormRefs);
|
||||
|
||||
// Methods
|
||||
// A computed mapping of editable fields to their states
|
||||
// This ensures TS knows the exact type of each field.
|
||||
const editableFields: ComputedRef<{
|
||||
name: EditableField<string>;
|
||||
tags: EditableField<string[]>;
|
||||
}> = computed(() => ({
|
||||
name: state.value.name,
|
||||
tags: state.value.tags,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Load test data including metrics.
|
||||
*/
|
||||
const loadTestData = async (testId: string) => {
|
||||
try {
|
||||
await evaluationsStore.fetchAll({ force: true });
|
||||
const testDefinition = evaluationsStore.testDefinitionsById[testId];
|
||||
|
||||
if (testDefinition) {
|
||||
state.value = {
|
||||
description: testDefinition.description ?? '',
|
||||
name: {
|
||||
value: testDefinition.name ?? '',
|
||||
isEditing: false,
|
||||
tempValue: '',
|
||||
},
|
||||
tags: {
|
||||
isEditing: false,
|
||||
appliedTagIds: testDefinition.annotationTagId ? [testDefinition.annotationTagId] : [],
|
||||
},
|
||||
evaluationWorkflow: {
|
||||
mode: 'list',
|
||||
value: testDefinition.evaluationWorkflowId ?? '',
|
||||
__rl: true,
|
||||
},
|
||||
metrics: [''],
|
||||
const metrics = await evaluationsStore.fetchMetrics(testId);
|
||||
|
||||
state.value.description = testDefinition.description ?? '';
|
||||
state.value.name = {
|
||||
value: testDefinition.name ?? '',
|
||||
isEditing: false,
|
||||
tempValue: '',
|
||||
};
|
||||
state.value.tags = {
|
||||
isEditing: false,
|
||||
value: testDefinition.annotationTagId ? [testDefinition.annotationTagId] : [],
|
||||
tempValue: [],
|
||||
};
|
||||
state.value.evaluationWorkflow = {
|
||||
mode: 'list',
|
||||
value: testDefinition.evaluationWorkflowId ?? '',
|
||||
__rl: true,
|
||||
};
|
||||
state.value.metrics = metrics;
|
||||
state.value.mockedNodes = testDefinition.mockedNodes ?? [];
|
||||
}
|
||||
} catch (error) {
|
||||
// TODO: Throw better errors
|
||||
console.error('Failed to load test data', error);
|
||||
}
|
||||
};
|
||||
|
@ -98,22 +92,43 @@ export function useTestDefinitionForm() {
|
|||
fieldsIssues.value = [];
|
||||
|
||||
try {
|
||||
// Prepare parameters for creating a new test
|
||||
const params = {
|
||||
name: state.value.name.value,
|
||||
workflowId,
|
||||
description: state.value.description,
|
||||
};
|
||||
|
||||
const newTest = await evaluationsStore.create(params);
|
||||
return newTest;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
return await evaluationsStore.create(params);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMetric = async (metricId: string, testId: string) => {
|
||||
await evaluationsStore.deleteMetric({ id: metricId, testDefinitionId: testId });
|
||||
state.value.metrics = state.value.metrics.filter((metric) => metric.id !== metricId);
|
||||
};
|
||||
|
||||
const updateMetrics = async (testId: string) => {
|
||||
const promises = state.value.metrics.map(async (metric) => {
|
||||
if (!metric.name) return;
|
||||
if (!metric.id) {
|
||||
const createdMetric = await evaluationsStore.createMetric({
|
||||
name: metric.name,
|
||||
testDefinitionId: testId,
|
||||
});
|
||||
metric.id = createdMetric.id;
|
||||
} else {
|
||||
await evaluationsStore.updateMetric({
|
||||
name: metric.name,
|
||||
id: metric.id,
|
||||
testDefinitionId: testId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
const updateTest = async (testId: string) => {
|
||||
if (isSaving.value) return;
|
||||
|
||||
|
@ -121,74 +136,93 @@ export function useTestDefinitionForm() {
|
|||
fieldsIssues.value = [];
|
||||
|
||||
try {
|
||||
// Check if the test ID is provided
|
||||
if (!testId) {
|
||||
throw new Error('Test ID is required for updating a test');
|
||||
}
|
||||
|
||||
// Prepare parameters for updating the existing test
|
||||
const params: UpdateTestDefinitionParams = {
|
||||
name: state.value.name.value,
|
||||
description: state.value.description,
|
||||
};
|
||||
|
||||
if (state.value.evaluationWorkflow.value) {
|
||||
params.evaluationWorkflowId = state.value.evaluationWorkflow.value.toString();
|
||||
}
|
||||
|
||||
const annotationTagId = state.value.tags.appliedTagIds[0];
|
||||
const annotationTagId = state.value.tags.value[0];
|
||||
if (annotationTagId) {
|
||||
params.annotationTagId = annotationTagId;
|
||||
}
|
||||
// Update the existing test
|
||||
if (state.value.mockedNodes.length > 0) {
|
||||
params.mockedNodes = state.value.mockedNodes;
|
||||
}
|
||||
|
||||
return await evaluationsStore.update({ ...params, id: testId });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = async (field: string) => {
|
||||
if (field === 'name') {
|
||||
state.value.name.tempValue = state.value.name.value;
|
||||
state.value.name.isEditing = true;
|
||||
} else {
|
||||
state.value.tags.isEditing = true;
|
||||
/**
|
||||
* Start editing an editable field by copying `value` to `tempValue`.
|
||||
*/
|
||||
function startEditing<T extends keyof EditableFormState>(field: T) {
|
||||
const fieldObj = editableFields.value[field];
|
||||
if (fieldObj.isEditing) {
|
||||
// Already editing, do nothing
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const saveChanges = (field: string) => {
|
||||
if (field === 'name') {
|
||||
state.value.name.value = state.value.name.tempValue;
|
||||
state.value.name.isEditing = false;
|
||||
if (Array.isArray(fieldObj.value)) {
|
||||
fieldObj.tempValue = [...fieldObj.value];
|
||||
} else {
|
||||
state.value.tags.isEditing = false;
|
||||
fieldObj.tempValue = fieldObj.value;
|
||||
}
|
||||
};
|
||||
fieldObj.isEditing = true;
|
||||
}
|
||||
/**
|
||||
* Save changes by copying `tempValue` back into `value`.
|
||||
*/
|
||||
function saveChanges<T extends keyof EditableFormState>(field: T) {
|
||||
const fieldObj = editableFields.value[field];
|
||||
fieldObj.value = Array.isArray(fieldObj.tempValue)
|
||||
? [...fieldObj.tempValue]
|
||||
: fieldObj.tempValue;
|
||||
fieldObj.isEditing = false;
|
||||
}
|
||||
|
||||
const cancelEditing = (field: string) => {
|
||||
if (field === 'name') {
|
||||
state.value.name.isEditing = false;
|
||||
state.value.name.tempValue = '';
|
||||
/**
|
||||
* Cancel editing and revert `tempValue` from `value`.
|
||||
*/
|
||||
function cancelEditing<T extends keyof EditableFormState>(field: T) {
|
||||
const fieldObj = editableFields.value[field];
|
||||
if (Array.isArray(fieldObj.value)) {
|
||||
fieldObj.tempValue = [...fieldObj.value];
|
||||
} else {
|
||||
state.value.tags.isEditing = false;
|
||||
fieldObj.tempValue = fieldObj.value;
|
||||
}
|
||||
};
|
||||
fieldObj.isEditing = false;
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent, field: string) => {
|
||||
/**
|
||||
* Handle keyboard events during editing.
|
||||
*/
|
||||
function handleKeydown<T extends keyof EditableFormState>(event: KeyboardEvent, field: T) {
|
||||
if (event.key === 'Escape') {
|
||||
cancelEditing(field);
|
||||
} else if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
saveChanges(field);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
fields,
|
||||
isSaving: computed(() => isSaving.value),
|
||||
fieldsIssues: computed(() => fieldsIssues.value),
|
||||
deleteMetric,
|
||||
updateMetrics,
|
||||
loadTestData,
|
||||
createTest,
|
||||
updateTest,
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
<script setup lang="ts" generic="T">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { TestDefinitionTableColumn } from './TestDefinitionTable.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
defineProps<{
|
||||
column: TestDefinitionTableColumn<T>;
|
||||
row: T;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
const router = useRouter();
|
||||
interface WithStatus {
|
||||
status: string;
|
||||
}
|
||||
|
||||
function hasStatus(row: unknown): row is WithStatus {
|
||||
return typeof row === 'object' && row !== null && 'status' in row;
|
||||
}
|
||||
|
||||
const statusThemeMap: Record<string, string> = {
|
||||
new: 'info',
|
||||
running: 'warning',
|
||||
completed: 'success',
|
||||
error: 'danger',
|
||||
success: 'success',
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
new: locale.baseText('testDefinition.listRuns.status.new'),
|
||||
running: locale.baseText('testDefinition.listRuns.status.running'),
|
||||
completed: locale.baseText('testDefinition.listRuns.status.completed'),
|
||||
error: locale.baseText('testDefinition.listRuns.status.error'),
|
||||
success: locale.baseText('testDefinition.listRuns.status.success'),
|
||||
};
|
||||
|
||||
function hasProperty(row: unknown, prop: string): row is Record<string, unknown> {
|
||||
return typeof row === 'object' && row !== null && prop in row;
|
||||
}
|
||||
|
||||
const getCellContent = (column: TestDefinitionTableColumn<T>, row: T) => {
|
||||
if (column.formatter) {
|
||||
return column.formatter(row);
|
||||
}
|
||||
return hasProperty(row, column.prop) ? row[column.prop] : undefined;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="column.route">
|
||||
<a v-if="column.openInNewTab" :href="router.resolve(column.route(row)).href" target="_blank">
|
||||
{{ getCellContent(column, row) }}
|
||||
</a>
|
||||
<router-link v-else :to="column.route(row)">
|
||||
{{ getCellContent(column, row) }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<N8nBadge
|
||||
v-else-if="column.prop === 'status' && hasStatus(row)"
|
||||
:theme="statusThemeMap[row.status]"
|
||||
class="mr-4xs"
|
||||
>
|
||||
{{ statusLabelMap[row.status] }}
|
||||
</N8nBadge>
|
||||
|
||||
<div v-else>
|
||||
{{ getCellContent(column, row) }}
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,95 @@
|
|||
<script setup lang="ts" generic="T">
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
import TableCell from './TableCell.vue';
|
||||
import { ElTable, ElTableColumn } from 'element-plus';
|
||||
import { ref } from 'vue';
|
||||
import type { TableInstance } from 'element-plus';
|
||||
|
||||
/**
|
||||
* A reusable table component for displaying test definition data
|
||||
* @template T - The type of data being displayed in the table rows
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configuration for a table column
|
||||
* @template TRow - The type of data in each table row
|
||||
*/
|
||||
export type TestDefinitionTableColumn<TRow> = {
|
||||
prop: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
sortable?: boolean;
|
||||
filters?: Array<{ text: string; value: string }>;
|
||||
filterMethod?: (value: string, row: TRow) => boolean;
|
||||
route?: (row: TRow) => RouteLocationRaw;
|
||||
openInNewTab?: boolean;
|
||||
formatter?: (row: TRow) => string;
|
||||
};
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
data: T[];
|
||||
columns: Array<TestDefinitionTableColumn<T>>;
|
||||
showControls?: boolean;
|
||||
defaultSort?: { prop: string; order: 'ascending' | 'descending' };
|
||||
selectable?: boolean;
|
||||
selectableFilter?: (row: T) => boolean;
|
||||
}>(),
|
||||
{
|
||||
defaultSort: () => ({ prop: 'date', order: 'ascending' }),
|
||||
selectable: false,
|
||||
selectableFilter: () => true,
|
||||
},
|
||||
);
|
||||
|
||||
const tableRef = ref<TableInstance>();
|
||||
const selectedRows = ref<T[]>([]);
|
||||
|
||||
const emit = defineEmits<{
|
||||
rowClick: [row: T];
|
||||
selectionChange: [rows: T[]];
|
||||
}>();
|
||||
|
||||
const handleSelectionChange = (rows: T[]) => {
|
||||
selectedRows.value = rows;
|
||||
emit('selectionChange', rows);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElTable
|
||||
ref="tableRef"
|
||||
:default-sort="defaultSort"
|
||||
:data="data"
|
||||
style="width: 100%"
|
||||
:border="true"
|
||||
max-height="800"
|
||||
resizable
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<ElTableColumn
|
||||
v-if="selectable"
|
||||
type="selection"
|
||||
:selectable="selectableFilter"
|
||||
width="55"
|
||||
data-test-id="table-column-select"
|
||||
/>
|
||||
<ElTableColumn
|
||||
v-for="column in columns"
|
||||
:key="column.prop"
|
||||
v-bind="column"
|
||||
style="width: 100%"
|
||||
:resizable="true"
|
||||
data-test-id="table-column"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<TableCell
|
||||
:column="column"
|
||||
:row="row"
|
||||
@click="$emit('rowClick', row)"
|
||||
data-test-id="table-cell"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</template>
|
|
@ -6,11 +6,11 @@ import userEvent from '@testing-library/user-event';
|
|||
const renderComponent = createComponentRenderer(MetricsInput);
|
||||
|
||||
describe('MetricsInput', () => {
|
||||
let props: { modelValue: string[] };
|
||||
let props: { modelValue: Array<{ name: string }> };
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
modelValue: ['Metric 1', 'Metric 2'],
|
||||
modelValue: [{ name: 'Metric 1' }, { name: 'Metric 2' }],
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -25,14 +25,18 @@ describe('MetricsInput', () => {
|
|||
it('should update a metric when typing in the input', async () => {
|
||||
const { getAllByPlaceholderText, emitted } = renderComponent({
|
||||
props: {
|
||||
modelValue: [''],
|
||||
modelValue: [{ name: '' }],
|
||||
},
|
||||
});
|
||||
const inputs = getAllByPlaceholderText('Enter metric name');
|
||||
await userEvent.type(inputs[0], 'Updated Metric 1');
|
||||
|
||||
expect(emitted('update:modelValue')).toBeTruthy();
|
||||
expect(emitted('update:modelValue')).toEqual('Updated Metric 1'.split('').map((c) => [[c]]));
|
||||
// Every character typed triggers an update event. Let's check the last emission.
|
||||
const allEmits = emitted('update:modelValue');
|
||||
expect(allEmits).toBeTruthy();
|
||||
// The last emission should contain the fully updated name
|
||||
const lastEmission = allEmits[allEmits.length - 1];
|
||||
expect(lastEmission).toEqual([[{ name: 'Updated Metric 1' }]]);
|
||||
});
|
||||
|
||||
it('should render correctly with no initial metrics', () => {
|
||||
|
@ -47,10 +51,89 @@ describe('MetricsInput', () => {
|
|||
const { getByText, emitted } = renderComponent({ props });
|
||||
const addButton = getByText('New metric');
|
||||
|
||||
addButton.click();
|
||||
addButton.click();
|
||||
addButton.click();
|
||||
await userEvent.click(addButton);
|
||||
await userEvent.click(addButton);
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(emitted('update:modelValue')).toHaveProperty('length', 3);
|
||||
// Each click adds a new metric
|
||||
const updateEvents = emitted('update:modelValue');
|
||||
expect(updateEvents).toHaveLength(3);
|
||||
|
||||
// Check the structure of one of the emissions
|
||||
// Initial: [{ name: 'Metric 1' }, { name: 'Metric 2' }]
|
||||
// After first click: [{ name: 'Metric 1' }, { name: 'Metric 2' }, { name: '' }]
|
||||
expect(updateEvents[0]).toEqual([[{ name: 'Metric 1' }, { name: 'Metric 2' }, { name: '' }]]);
|
||||
});
|
||||
|
||||
it('should emit "deleteMetric" event when a delete button is clicked', async () => {
|
||||
const { getAllByRole, emitted } = renderComponent({ props });
|
||||
|
||||
// Each metric row has a delete button, identified by "button"
|
||||
const deleteButtons = getAllByRole('button', { name: '' });
|
||||
expect(deleteButtons).toHaveLength(props.modelValue.length);
|
||||
|
||||
// Click on the delete button for the second metric
|
||||
await userEvent.click(deleteButtons[1]);
|
||||
|
||||
expect(emitted('deleteMetric')).toBeTruthy();
|
||||
expect(emitted('deleteMetric')[0]).toEqual([{ name: 'Metric 2' }]);
|
||||
});
|
||||
|
||||
it('should emit multiple update events as the user types and reflect the final name correctly', async () => {
|
||||
const { getAllByPlaceholderText, emitted } = renderComponent({
|
||||
props: {
|
||||
modelValue: [{ name: '' }],
|
||||
},
|
||||
});
|
||||
const inputs = getAllByPlaceholderText('Enter metric name');
|
||||
await userEvent.type(inputs[0], 'ABC');
|
||||
|
||||
const allEmits = emitted('update:modelValue');
|
||||
expect(allEmits).toBeTruthy();
|
||||
// Each character typed should emit a new value
|
||||
expect(allEmits.length).toBe(3);
|
||||
expect(allEmits[2]).toEqual([[{ name: 'ABC' }]]);
|
||||
});
|
||||
|
||||
it('should not break if metrics are empty and still allow adding a new metric', async () => {
|
||||
props.modelValue = [];
|
||||
const { queryAllByRole, getByText, emitted } = renderComponent({ props });
|
||||
|
||||
// No metrics initially
|
||||
const inputs = queryAllByRole('textbox');
|
||||
expect(inputs).toHaveLength(0);
|
||||
|
||||
const addButton = getByText('New metric');
|
||||
await userEvent.click(addButton);
|
||||
|
||||
const updates = emitted('update:modelValue');
|
||||
expect(updates).toBeTruthy();
|
||||
expect(updates[0]).toEqual([[{ name: '' }]]);
|
||||
|
||||
// After adding one metric, we should now have an input
|
||||
const { getAllByPlaceholderText } = renderComponent({
|
||||
props: { modelValue: [{ name: '' }] },
|
||||
});
|
||||
const updatedInputs = getAllByPlaceholderText('Enter metric name');
|
||||
expect(updatedInputs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle deleting the first metric and still display remaining metrics correctly', async () => {
|
||||
const { getAllByPlaceholderText, getAllByRole, rerender, emitted } = renderComponent({
|
||||
props,
|
||||
});
|
||||
const inputs = getAllByPlaceholderText('Enter metric name');
|
||||
expect(inputs).toHaveLength(2);
|
||||
|
||||
const deleteButtons = getAllByRole('button', { name: '' });
|
||||
await userEvent.click(deleteButtons[0]);
|
||||
|
||||
expect(emitted('deleteMetric')).toBeTruthy();
|
||||
expect(emitted('deleteMetric')[0]).toEqual([{ name: 'Metric 1' }]);
|
||||
|
||||
await rerender({ modelValue: [{ name: 'Metric 2' }] });
|
||||
const updatedInputs = getAllByPlaceholderText('Enter metric name');
|
||||
expect(updatedInputs).toHaveLength(1);
|
||||
expect(updatedInputs[0]).toHaveValue('Metric 2');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,127 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { useMetricsChart } from '../composables/useMetricsChart';
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
|
||||
describe('useMetricsChart', () => {
|
||||
const mockRuns: TestRunRecord[] = [
|
||||
{
|
||||
id: '1',
|
||||
testDefinitionId: 'test1',
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-06T10:00:00Z',
|
||||
updatedAt: '2025-01-06T10:00:00Z',
|
||||
completedAt: '2025-01-06T10:00:00Z',
|
||||
runAt: '2025-01-06T10:00:00Z',
|
||||
metrics: { responseTime: 100, successRate: 95 },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
testDefinitionId: 'test1',
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-06T10:00:00Z',
|
||||
updatedAt: '2025-01-06T10:00:00Z',
|
||||
completedAt: '2025-01-06T10:00:00Z',
|
||||
runAt: '2025-01-06T10:00:00Z',
|
||||
metrics: { responseTime: 150, successRate: 98 },
|
||||
},
|
||||
] as TestRunRecord[];
|
||||
|
||||
describe('generateChartData', () => {
|
||||
it('should generate correct chart data structure', () => {
|
||||
const { generateChartData } = useMetricsChart('light');
|
||||
const result = generateChartData(mockRuns, 'responseTime');
|
||||
|
||||
expect(result.labels).toHaveLength(2);
|
||||
expect(result.datasets).toHaveLength(1);
|
||||
expect(result.datasets[0].data).toEqual([100, 150]);
|
||||
});
|
||||
|
||||
it('should sort runs by date', () => {
|
||||
const unsortedRuns = [
|
||||
{
|
||||
id: '1',
|
||||
testDefinitionId: 'test1',
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-06T10:05:00Z',
|
||||
updatedAt: '2025-01-06T10:05:00Z',
|
||||
completedAt: '2025-01-06T10:05:00Z',
|
||||
runAt: '2025-01-06T10:05:00Z',
|
||||
metrics: { responseTime: 150 },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
testDefinitionId: 'test1',
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-06T10:00:00Z',
|
||||
updatedAt: '2025-01-06T10:00:00Z',
|
||||
completedAt: '2025-01-06T10:00:00Z',
|
||||
runAt: '2025-01-06T10:00:00Z',
|
||||
metrics: { responseTime: 100 },
|
||||
},
|
||||
] as TestRunRecord[];
|
||||
|
||||
const { generateChartData } = useMetricsChart('light');
|
||||
const result = generateChartData(unsortedRuns, 'responseTime');
|
||||
|
||||
expect(result.datasets[0].data).toEqual([100, 150]);
|
||||
});
|
||||
|
||||
it('should filter out runs without specified metric', () => {
|
||||
const runsWithMissingMetrics = [
|
||||
{
|
||||
id: '1',
|
||||
testDefinitionId: 'test1',
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-06T10:00:00Z',
|
||||
updatedAt: '2025-01-06T10:00:00Z',
|
||||
completedAt: '2025-01-06T10:00:00Z',
|
||||
runAt: '2025-01-06T10:00:00Z',
|
||||
metrics: { responseTime: 100 },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
testDefinitionId: 'test1',
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-06T10:00:00Z',
|
||||
updatedAt: '2025-01-06T10:00:00Z',
|
||||
completedAt: '2025-01-06T10:00:00Z',
|
||||
runAt: '2025-01-06T10:00:00Z',
|
||||
metrics: {},
|
||||
},
|
||||
] as TestRunRecord[];
|
||||
|
||||
const { generateChartData } = useMetricsChart('light');
|
||||
const result = generateChartData(runsWithMissingMetrics, 'responseTime');
|
||||
|
||||
expect(result.labels).toHaveLength(1);
|
||||
expect(result.datasets[0].data).toEqual([100]);
|
||||
});
|
||||
|
||||
it('should handle dark theme colors', () => {
|
||||
const { generateChartData } = useMetricsChart('dark');
|
||||
const result = generateChartData(mockRuns, 'responseTime');
|
||||
|
||||
expect(result.datasets[0].pointHoverBackgroundColor).toBe('rgb(32, 32, 32)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateChartOptions', () => {
|
||||
it('should generate correct chart options structure', () => {
|
||||
const { generateChartOptions } = useMetricsChart('light');
|
||||
const result = generateChartOptions({ metric: 'responseTime', xTitle: 'Time' });
|
||||
|
||||
expect(result.scales?.y?.title?.text).toBe('responseTime');
|
||||
expect(result.scales?.x?.title?.text).toBe('Time');
|
||||
expect(result.responsive).toBe(true);
|
||||
expect(result.maintainAspectRatio).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply correct theme colors', () => {
|
||||
const { generateChartOptions } = useMetricsChart('dark');
|
||||
const result = generateChartOptions({ metric: 'responseTime', xTitle: 'Time' });
|
||||
|
||||
expect(result.scales?.y?.ticks?.color).toBe('rgb(255, 255, 255)');
|
||||
expect(result.plugins?.tooltip?.backgroundColor).toBe('rgb(32, 32, 32)');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -12,18 +12,21 @@ const TEST_DEF_A: TestDefinitionRecord = {
|
|||
evaluationWorkflowId: '456',
|
||||
workflowId: '123',
|
||||
annotationTagId: '789',
|
||||
annotationTag: null,
|
||||
};
|
||||
const TEST_DEF_B: TestDefinitionRecord = {
|
||||
id: '2',
|
||||
name: 'Test Definition B',
|
||||
workflowId: '123',
|
||||
description: 'Description B',
|
||||
annotationTag: null,
|
||||
};
|
||||
const TEST_DEF_NEW: TestDefinitionRecord = {
|
||||
id: '3',
|
||||
workflowId: '123',
|
||||
name: 'New Test Definition',
|
||||
description: 'New Description',
|
||||
annotationTag: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -35,44 +38,78 @@ afterEach(() => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useTestDefinitionForm', async () => {
|
||||
it('should initialize with default props', async () => {
|
||||
describe('useTestDefinitionForm', () => {
|
||||
it('should initialize with default props', () => {
|
||||
const { state } = useTestDefinitionForm();
|
||||
|
||||
expect(state.value.description).toEqual('');
|
||||
expect(state.value.description).toBe('');
|
||||
expect(state.value.name.value).toContain('My Test');
|
||||
expect(state.value.tags.appliedTagIds).toEqual([]);
|
||||
expect(state.value.metrics).toEqual(['']);
|
||||
expect(state.value.evaluationWorkflow.value).toEqual('');
|
||||
expect(state.value.tags.value).toEqual([]);
|
||||
expect(state.value.metrics).toEqual([]);
|
||||
expect(state.value.evaluationWorkflow.value).toBe('');
|
||||
});
|
||||
|
||||
it('should load test data', async () => {
|
||||
const { loadTestData, state } = useTestDefinitionForm();
|
||||
const fetchSpy = vi.fn();
|
||||
const fetchSpy = vi.spyOn(useTestDefinitionStore(), 'fetchAll');
|
||||
const fetchMetricsSpy = vi.spyOn(useTestDefinitionStore(), 'fetchMetrics').mockResolvedValue([
|
||||
{
|
||||
id: 'metric1',
|
||||
name: 'Metric 1',
|
||||
testDefinitionId: TEST_DEF_A.id,
|
||||
},
|
||||
]);
|
||||
const evaluationsStore = mockedStore(useTestDefinitionStore);
|
||||
|
||||
expect(state.value.description).toEqual('');
|
||||
expect(state.value.name.value).toContain('My Test');
|
||||
evaluationsStore.testDefinitionsById = {
|
||||
[TEST_DEF_A.id]: TEST_DEF_A,
|
||||
[TEST_DEF_B.id]: TEST_DEF_B,
|
||||
};
|
||||
evaluationsStore.fetchAll = fetchSpy;
|
||||
|
||||
await loadTestData(TEST_DEF_A.id);
|
||||
expect(fetchSpy).toBeCalled();
|
||||
expect(fetchMetricsSpy).toBeCalledWith(TEST_DEF_A.id);
|
||||
expect(state.value.name.value).toEqual(TEST_DEF_A.name);
|
||||
expect(state.value.description).toEqual(TEST_DEF_A.description);
|
||||
expect(state.value.tags.appliedTagIds).toEqual([TEST_DEF_A.annotationTagId]);
|
||||
expect(state.value.tags.value).toEqual([TEST_DEF_A.annotationTagId]);
|
||||
expect(state.value.evaluationWorkflow.value).toEqual(TEST_DEF_A.evaluationWorkflowId);
|
||||
expect(state.value.metrics).toEqual([
|
||||
{ id: 'metric1', name: 'Metric 1', testDefinitionId: TEST_DEF_A.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should gracefully handle loadTestData when no test definition found', async () => {
|
||||
const { loadTestData, state } = useTestDefinitionForm();
|
||||
const fetchSpy = vi.spyOn(useTestDefinitionStore(), 'fetchAll');
|
||||
const evaluationsStore = mockedStore(useTestDefinitionStore);
|
||||
|
||||
evaluationsStore.testDefinitionsById = {};
|
||||
|
||||
await loadTestData('unknown-id');
|
||||
expect(fetchSpy).toBeCalled();
|
||||
// Should remain unchanged since no definition found
|
||||
expect(state.value.description).toBe('');
|
||||
expect(state.value.name.value).toContain('My Test');
|
||||
expect(state.value.tags.value).toEqual([]);
|
||||
expect(state.value.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle errors while loading test data', async () => {
|
||||
const { loadTestData } = useTestDefinitionForm();
|
||||
const fetchSpy = vi
|
||||
.spyOn(useTestDefinitionStore(), 'fetchAll')
|
||||
.mockRejectedValue(new Error('Fetch Failed'));
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await loadTestData(TEST_DEF_A.id);
|
||||
expect(fetchSpy).toBeCalled();
|
||||
expect(consoleErrorSpy).toBeCalledWith('Failed to load test data', expect.any(Error));
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should save a new test', async () => {
|
||||
const { createTest, state } = useTestDefinitionForm();
|
||||
const createSpy = vi.fn().mockResolvedValue(TEST_DEF_NEW);
|
||||
const evaluationsStore = mockedStore(useTestDefinitionStore);
|
||||
|
||||
evaluationsStore.create = createSpy;
|
||||
const createSpy = vi.spyOn(useTestDefinitionStore(), 'create').mockResolvedValue(TEST_DEF_NEW);
|
||||
|
||||
state.value.name.value = TEST_DEF_NEW.name;
|
||||
state.value.description = TEST_DEF_NEW.description ?? '';
|
||||
|
@ -86,12 +123,24 @@ describe('useTestDefinitionForm', async () => {
|
|||
expect(newTest).toEqual(TEST_DEF_NEW);
|
||||
});
|
||||
|
||||
it('should handle errors when creating a new test', async () => {
|
||||
const { createTest } = useTestDefinitionForm();
|
||||
const createSpy = vi
|
||||
.spyOn(useTestDefinitionStore(), 'create')
|
||||
.mockRejectedValue(new Error('Create Failed'));
|
||||
|
||||
await expect(createTest('123')).rejects.toThrow('Create Failed');
|
||||
expect(createSpy).toBeCalled();
|
||||
});
|
||||
|
||||
it('should update an existing test', async () => {
|
||||
const { updateTest, state } = useTestDefinitionForm();
|
||||
const updateSpy = vi.fn().mockResolvedValue(TEST_DEF_B);
|
||||
const evaluationsStore = mockedStore(useTestDefinitionStore);
|
||||
|
||||
evaluationsStore.update = updateSpy;
|
||||
const updatedBTest = {
|
||||
...TEST_DEF_B,
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
};
|
||||
const updateSpy = vi.spyOn(useTestDefinitionStore(), 'update').mockResolvedValue(updatedBTest);
|
||||
|
||||
state.value.name.value = TEST_DEF_B.name;
|
||||
state.value.description = TEST_DEF_B.description ?? '';
|
||||
|
@ -102,75 +151,183 @@ describe('useTestDefinitionForm', async () => {
|
|||
name: TEST_DEF_B.name,
|
||||
description: TEST_DEF_B.description,
|
||||
});
|
||||
expect(updatedTest).toEqual(TEST_DEF_B);
|
||||
expect(updatedTest).toEqual(updatedBTest);
|
||||
});
|
||||
|
||||
it('should start editing a field', async () => {
|
||||
it('should throw an error if no testId is provided when updating a test', async () => {
|
||||
const { updateTest } = useTestDefinitionForm();
|
||||
await expect(updateTest('')).rejects.toThrow('Test ID is required for updating a test');
|
||||
});
|
||||
|
||||
it('should handle errors when updating a test', async () => {
|
||||
const { updateTest, state } = useTestDefinitionForm();
|
||||
const updateSpy = vi
|
||||
.spyOn(useTestDefinitionStore(), 'update')
|
||||
.mockRejectedValue(new Error('Update Failed'));
|
||||
|
||||
state.value.name.value = 'Test';
|
||||
state.value.description = 'Some description';
|
||||
|
||||
await expect(updateTest(TEST_DEF_A.id)).rejects.toThrow('Update Failed');
|
||||
expect(updateSpy).toBeCalled();
|
||||
});
|
||||
|
||||
it('should delete a metric', async () => {
|
||||
const { state, deleteMetric } = useTestDefinitionForm();
|
||||
const evaluationsStore = mockedStore(useTestDefinitionStore);
|
||||
const deleteMetricSpy = vi.spyOn(evaluationsStore, 'deleteMetric');
|
||||
|
||||
state.value.metrics = [
|
||||
{
|
||||
id: 'metric1',
|
||||
name: 'Metric 1',
|
||||
testDefinitionId: '1',
|
||||
},
|
||||
{
|
||||
id: 'metric2',
|
||||
name: 'Metric 2',
|
||||
testDefinitionId: '1',
|
||||
},
|
||||
];
|
||||
|
||||
await deleteMetric('metric1', TEST_DEF_A.id);
|
||||
expect(deleteMetricSpy).toBeCalledWith({ id: 'metric1', testDefinitionId: TEST_DEF_A.id });
|
||||
expect(state.value.metrics).toEqual([
|
||||
{ id: 'metric2', name: 'Metric 2', testDefinitionId: '1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should update metrics', async () => {
|
||||
const { state, updateMetrics } = useTestDefinitionForm();
|
||||
const evaluationsStore = mockedStore(useTestDefinitionStore);
|
||||
const updateMetricSpy = vi.spyOn(evaluationsStore, 'updateMetric');
|
||||
const createMetricSpy = vi
|
||||
.spyOn(evaluationsStore, 'createMetric')
|
||||
.mockResolvedValue({ id: 'metric_new', name: 'Metric 2', testDefinitionId: TEST_DEF_A.id });
|
||||
|
||||
state.value.metrics = [
|
||||
{
|
||||
id: 'metric1',
|
||||
name: 'Metric 1',
|
||||
testDefinitionId: TEST_DEF_A.id,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
name: 'Metric 2',
|
||||
testDefinitionId: TEST_DEF_A.id,
|
||||
}, // New metric that needs creation
|
||||
];
|
||||
|
||||
await updateMetrics(TEST_DEF_A.id);
|
||||
expect(createMetricSpy).toHaveBeenCalledWith({
|
||||
name: 'Metric 2',
|
||||
testDefinitionId: TEST_DEF_A.id,
|
||||
});
|
||||
expect(updateMetricSpy).toHaveBeenCalledWith({
|
||||
name: 'Metric 1',
|
||||
id: 'metric1',
|
||||
testDefinitionId: TEST_DEF_A.id,
|
||||
});
|
||||
expect(state.value.metrics).toEqual([
|
||||
{ id: 'metric1', name: 'Metric 1', testDefinitionId: TEST_DEF_A.id },
|
||||
{ id: 'metric_new', name: 'Metric 2', testDefinitionId: TEST_DEF_A.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should start editing a field', () => {
|
||||
const { state, startEditing } = useTestDefinitionForm();
|
||||
|
||||
await startEditing('name');
|
||||
startEditing('name');
|
||||
expect(state.value.name.isEditing).toBe(true);
|
||||
expect(state.value.name.tempValue).toBe(state.value.name.value);
|
||||
|
||||
await startEditing('tags');
|
||||
startEditing('tags');
|
||||
expect(state.value.tags.isEditing).toBe(true);
|
||||
expect(state.value.tags.tempValue).toEqual(state.value.tags.value);
|
||||
});
|
||||
|
||||
it('should save changes to a field', async () => {
|
||||
it('should do nothing if startEditing is called while already editing', () => {
|
||||
const { state, startEditing } = useTestDefinitionForm();
|
||||
state.value.name.isEditing = true;
|
||||
state.value.name.tempValue = 'Original Name';
|
||||
|
||||
startEditing('name');
|
||||
// Should remain unchanged because it was already editing
|
||||
expect(state.value.name.isEditing).toBe(true);
|
||||
expect(state.value.name.tempValue).toBe('Original Name');
|
||||
});
|
||||
|
||||
it('should save changes to a field', () => {
|
||||
const { state, startEditing, saveChanges } = useTestDefinitionForm();
|
||||
|
||||
await startEditing('name');
|
||||
// Name
|
||||
startEditing('name');
|
||||
state.value.name.tempValue = 'New Name';
|
||||
saveChanges('name');
|
||||
expect(state.value.name.isEditing).toBe(false);
|
||||
expect(state.value.name.value).toBe('New Name');
|
||||
|
||||
await startEditing('tags');
|
||||
state.value.tags.appliedTagIds = ['123'];
|
||||
// Tags
|
||||
startEditing('tags');
|
||||
state.value.tags.tempValue = ['123'];
|
||||
saveChanges('tags');
|
||||
expect(state.value.tags.isEditing).toBe(false);
|
||||
expect(state.value.tags.appliedTagIds).toEqual(['123']);
|
||||
expect(state.value.tags.value).toEqual(['123']);
|
||||
});
|
||||
|
||||
it('should cancel editing a field', async () => {
|
||||
it('should cancel editing a field', () => {
|
||||
const { state, startEditing, cancelEditing } = useTestDefinitionForm();
|
||||
|
||||
await startEditing('name');
|
||||
const originalName = state.value.name.value;
|
||||
startEditing('name');
|
||||
state.value.name.tempValue = 'New Name';
|
||||
cancelEditing('name');
|
||||
expect(state.value.name.isEditing).toBe(false);
|
||||
expect(state.value.name.tempValue).toBe('');
|
||||
expect(state.value.name.tempValue).toBe(originalName);
|
||||
|
||||
await startEditing('tags');
|
||||
state.value.tags.appliedTagIds = ['123'];
|
||||
const originalTags = [...state.value.tags.value];
|
||||
startEditing('tags');
|
||||
state.value.tags.tempValue = ['123'];
|
||||
cancelEditing('tags');
|
||||
expect(state.value.tags.isEditing).toBe(false);
|
||||
expect(state.value.tags.tempValue).toEqual(originalTags);
|
||||
});
|
||||
|
||||
it('should handle keydown - Escape', async () => {
|
||||
it('should handle keydown - Escape', () => {
|
||||
const { state, startEditing, handleKeydown } = useTestDefinitionForm();
|
||||
|
||||
await startEditing('name');
|
||||
startEditing('name');
|
||||
handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'name');
|
||||
expect(state.value.name.isEditing).toBe(false);
|
||||
|
||||
await startEditing('tags');
|
||||
startEditing('tags');
|
||||
handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'tags');
|
||||
expect(state.value.tags.isEditing).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle keydown - Enter', async () => {
|
||||
it('should handle keydown - Enter', () => {
|
||||
const { state, startEditing, handleKeydown } = useTestDefinitionForm();
|
||||
|
||||
await startEditing('name');
|
||||
startEditing('name');
|
||||
state.value.name.tempValue = 'New Name';
|
||||
handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), 'name');
|
||||
expect(state.value.name.isEditing).toBe(false);
|
||||
expect(state.value.name.value).toBe('New Name');
|
||||
|
||||
await startEditing('tags');
|
||||
state.value.tags.appliedTagIds = ['123'];
|
||||
startEditing('tags');
|
||||
state.value.tags.tempValue = ['123'];
|
||||
handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), 'tags');
|
||||
expect(state.value.tags.isEditing).toBe(false);
|
||||
expect(state.value.tags.value).toEqual(['123']);
|
||||
});
|
||||
|
||||
it('should not save changes when shift+Enter is pressed', () => {
|
||||
const { state, startEditing, handleKeydown } = useTestDefinitionForm();
|
||||
|
||||
startEditing('name');
|
||||
state.value.name.tempValue = 'New Name With Shift';
|
||||
handleKeydown(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true }), 'name');
|
||||
expect(state.value.name.isEditing).toBe(true);
|
||||
expect(state.value.name.value).not.toBe('New Name With Shift');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,29 @@
|
|||
import type { TestMetricRecord, TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import type { INodeParameterResourceLocator } from 'n8n-workflow';
|
||||
|
||||
export interface EditableField<T = string> {
|
||||
value: T;
|
||||
tempValue: T;
|
||||
isEditing: boolean;
|
||||
}
|
||||
|
||||
export interface EditableFormState {
|
||||
name: EditableField<string>;
|
||||
tags: EditableField<string[]>;
|
||||
}
|
||||
|
||||
export interface EvaluationFormState extends EditableFormState {
|
||||
description: string;
|
||||
evaluationWorkflow: INodeParameterResourceLocator;
|
||||
metrics: TestMetricRecord[];
|
||||
mockedNodes: Array<{ name: string }>;
|
||||
}
|
||||
|
||||
export interface TestExecution {
|
||||
lastRun: string | null;
|
||||
errorRate: number | null;
|
||||
metrics: Record<string, number>;
|
||||
status: TestRunRecord['status'];
|
||||
}
|
||||
|
||||
export interface TestListItem {
|
||||
|
|
|
@ -15,7 +15,7 @@ interface TagsDropdownWrapperProps {
|
|||
const props = withDefaults(defineProps<TagsDropdownWrapperProps>(), {
|
||||
placeholder: '',
|
||||
modelValue: () => [],
|
||||
createEnabled: false,
|
||||
createEnabled: true,
|
||||
eventBus: null,
|
||||
});
|
||||
|
||||
|
|
|
@ -80,6 +80,7 @@ const props = withDefaults(
|
|||
executing?: boolean;
|
||||
keyBindings?: boolean;
|
||||
showBugReportingButton?: boolean;
|
||||
loading?: boolean;
|
||||
}>(),
|
||||
{
|
||||
id: 'canvas',
|
||||
|
@ -90,6 +91,7 @@ const props = withDefaults(
|
|||
readOnly: false,
|
||||
executing: false,
|
||||
keyBindings: true,
|
||||
loading: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -131,7 +133,7 @@ const isPaneReady = ref(false);
|
|||
|
||||
const classes = computed(() => ({
|
||||
[$style.canvas]: true,
|
||||
[$style.ready]: isPaneReady.value,
|
||||
[$style.ready]: !props.loading && isPaneReady.value,
|
||||
}));
|
||||
|
||||
/**
|
||||
|
@ -695,7 +697,11 @@ provide(CanvasKey, {
|
|||
@update:outputs="onUpdateNodeOutputs"
|
||||
@move="onUpdateNodePosition"
|
||||
@add="onClickNodeAdd"
|
||||
/>
|
||||
>
|
||||
<template v-if="$slots.nodeToolbar" #toolbar="toolbarProps">
|
||||
<slot name="nodeToolbar" v-bind="toolbarProps" />
|
||||
</template>
|
||||
</Node>
|
||||
</template>
|
||||
|
||||
<template #edge-canvas-edge="edgeProps">
|
||||
|
|
|
@ -39,6 +39,14 @@ type Props = NodeProps<CanvasNodeData> & {
|
|||
hovered?: boolean;
|
||||
};
|
||||
|
||||
const slots = defineSlots<{
|
||||
toolbar?: (props: {
|
||||
inputs: (typeof mainInputs)['value'];
|
||||
outputs: (typeof mainOutputs)['value'];
|
||||
data: CanvasNodeData;
|
||||
}) => void;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: [id: string, handle: string];
|
||||
delete: [id: string];
|
||||
|
@ -62,6 +70,10 @@ const contextMenu = useContextMenu();
|
|||
|
||||
const { connectingHandle } = useCanvas();
|
||||
|
||||
/*
|
||||
Toolbar slot classes
|
||||
*/
|
||||
const nodeClasses = ref<string[]>([]);
|
||||
const inputs = computed(() => props.data.inputs);
|
||||
const outputs = computed(() => props.data.outputs);
|
||||
const connections = computed(() => props.data.connections);
|
||||
|
@ -83,6 +95,7 @@ const classes = computed(() => ({
|
|||
[style.showToolbar]: showToolbar.value,
|
||||
hovered: props.hovered,
|
||||
selected: props.selected,
|
||||
...Object.fromEntries([...nodeClasses.value].map((c) => [c, true])),
|
||||
}));
|
||||
|
||||
/**
|
||||
|
@ -92,7 +105,7 @@ const classes = computed(() => ({
|
|||
const canvasNodeEventBus = ref(createEventBus<CanvasNodeEventBusEvents>());
|
||||
|
||||
function emitCanvasNodeEvent(event: CanvasEventBusEvents['nodes:action']) {
|
||||
if (event.ids.includes(props.id)) {
|
||||
if (event.ids.includes(props.id) && canvasNodeEventBus.value) {
|
||||
canvasNodeEventBus.value.emit(event.action, event.payload);
|
||||
}
|
||||
}
|
||||
|
@ -233,6 +246,12 @@ function onMove(position: XYPosition) {
|
|||
emit('move', props.id, position);
|
||||
}
|
||||
|
||||
function onUpdateClass({ className, add = true }: CanvasNodeEventBusEvents['update:node:class']) {
|
||||
nodeClasses.value = add
|
||||
? [...new Set([...nodeClasses.value, className])]
|
||||
: nodeClasses.value.filter((c) => c !== className);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide
|
||||
*/
|
||||
|
@ -282,10 +301,12 @@ watch(outputs, (newValue, oldValue) => {
|
|||
|
||||
onMounted(() => {
|
||||
props.eventBus?.on('nodes:action', emitCanvasNodeEvent);
|
||||
canvasNodeEventBus.value?.on('update:node:class', onUpdateClass);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
props.eventBus?.off('nodes:action', emitCanvasNodeEvent);
|
||||
canvasNodeEventBus.value?.off('update:node:class', onUpdateClass);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -323,8 +344,13 @@ onBeforeUnmount(() => {
|
|||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="slots.toolbar">
|
||||
<slot name="toolbar" :inputs="mainInputs" :outputs="mainOutputs" :data="data" />
|
||||
</template>
|
||||
|
||||
<CanvasNodeToolbar
|
||||
v-if="nodeTypeDescription"
|
||||
v-else-if="nodeTypeDescription"
|
||||
data-test-id="canvas-node-toolbar"
|
||||
:read-only="readOnly"
|
||||
:class="$style.canvasNodeToolbar"
|
||||
@delete="onDelete"
|
||||
|
|
|
@ -53,6 +53,7 @@ export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings';
|
|||
export const WORKFLOW_SHARE_MODAL_KEY = 'workflowShare';
|
||||
export const PERSONALIZATION_MODAL_KEY = 'personalization';
|
||||
export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
|
||||
export const NODE_PINNING_MODAL_KEY = 'nodePinning';
|
||||
export const NPS_SURVEY_MODAL_KEY = 'npsSurvey';
|
||||
export const WORKFLOW_ACTIVE_MODAL_KEY = 'activation';
|
||||
export const COMMUNITY_PACKAGE_INSTALL_MODAL_KEY = 'communityPackageInstall';
|
||||
|
@ -497,6 +498,9 @@ export const enum VIEWS {
|
|||
WORKFLOW_EXECUTIONS = 'WorkflowExecutions',
|
||||
TEST_DEFINITION = 'TestDefinition',
|
||||
TEST_DEFINITION_EDIT = 'TestDefinitionEdit',
|
||||
TEST_DEFINITION_RUNS = 'TestDefinitionRuns',
|
||||
TEST_DEFINITION_RUNS_COMPARE = 'TestDefinitionRunsCompare',
|
||||
TEST_DEFINITION_RUNS_DETAIL = 'TestDefinitionRunsDetail',
|
||||
NEW_TEST_DEFINITION = 'NewTestDefinition',
|
||||
USAGE = 'Usage',
|
||||
LOG_STREAMING_SETTINGS = 'LogStreamingSettingsView',
|
||||
|
|
|
@ -2787,24 +2787,58 @@
|
|||
"testDefinition.edit.testSaved": "Test saved",
|
||||
"testDefinition.edit.testSaveFailed": "Failed to save test",
|
||||
"testDefinition.edit.description": "Description",
|
||||
"testDefinition.edit.description.tooltip": "Add details about what this test evaluates and what success looks like",
|
||||
"testDefinition.edit.tagName": "Tag name",
|
||||
"testDefinition.edit.step.intro": "When running a test",
|
||||
"testDefinition.edit.step.executions": "Fetch 5 past executions",
|
||||
"testDefinition.edit.step.executions": "Fetch past executions | Fetch {count} past execution | Fetch {count} past executions",
|
||||
"testDefinition.edit.step.executions.tooltip": "Select which tagged executions to use as test cases. Each execution will be replayed to compare performance",
|
||||
"testDefinition.edit.step.nodes": "Mock nodes",
|
||||
"testDefinition.edit.step.mockedNodes": "No nodes mocked | {count} node mocked | {count} nodes mocked",
|
||||
"testDefinition.edit.step.nodes.tooltip": "Replace specific nodes with test data to isolate what you're testing",
|
||||
"testDefinition.edit.step.reRunExecutions": "Re-run executions",
|
||||
"testDefinition.edit.step.reRunExecutions.tooltip": "Each test case will be re-run using the current workflow version",
|
||||
"testDefinition.edit.step.compareExecutions": "Compare each past and new execution",
|
||||
"testDefinition.edit.step.compareExecutions.tooltip": "Select which workflow to use for running the comparison tests",
|
||||
"testDefinition.edit.step.metrics": "Summarise metrics",
|
||||
"testDefinition.edit.step.metrics.tooltip": "Define which output fields to track and compare between test runs",
|
||||
"testDefinition.edit.step.collapse": "Collapse",
|
||||
"testDefinition.edit.step.expand": "Expand",
|
||||
"testDefinition.edit.selectNodes": "Select nodes",
|
||||
"testDefinition.edit.runExecution": "Run execution",
|
||||
"testDefinition.edit.pastRuns": "Past runs",
|
||||
"testDefinition.edit.pastRuns.total": "No runs | {count} run | {count} runs",
|
||||
"testDefinition.edit.nodesPinning.pinButtonTooltip": "Pin execution data of this node during test run",
|
||||
"testDefinition.list.testDeleted": "Test deleted",
|
||||
"testDefinition.list.tests": "Tests",
|
||||
"testDefinition.list.createNew": "Create new test",
|
||||
"testDefinition.list.actionDescription": "Replay past executions to check whether performance has changed",
|
||||
"testDefinition.list.actionButton": "Create Test",
|
||||
"testDefinition.list.testCases": "No test cases | {count} test case | {count} test cases",
|
||||
"testDefinition.list.lastRun": "Ran {lastRun}",
|
||||
"testDefinition.list.testRuns": "No test runs | {count} test run | {count} test runs",
|
||||
"testDefinition.list.lastRun": "Ran",
|
||||
"testDefinition.list.running": "Running",
|
||||
"testDefinition.list.errorRate": "Error rate: {errorRate}",
|
||||
"testDefinition.list.testStartError": "Failed to start test run",
|
||||
"testDefinition.list.testStarted": "Test run started",
|
||||
"testDefinition.list.loadError": "Failed to load tests",
|
||||
"testDefinition.listRuns.status.new": "New",
|
||||
"testDefinition.listRuns.status.running": "Running",
|
||||
"testDefinition.listRuns.status.completed": "Completed",
|
||||
"testDefinition.listRuns.status.error": "Error",
|
||||
"testDefinition.listRuns.status.success": "Success",
|
||||
"testDefinition.listRuns.metricsOverTime": "Metrics over time",
|
||||
"testDefinition.listRuns.status": "Status",
|
||||
"testDefinition.listRuns.runNumber": "Run #",
|
||||
"testDefinition.listRuns.runDate": "Run date",
|
||||
"testDefinition.listRuns.runStatus": "Run status",
|
||||
"testDefinition.listRuns.noRuns": "No test runs",
|
||||
"testDefinition.listRuns.noRuns.description": "Run a test to see the results here",
|
||||
"testDefinition.listRuns.deleteRuns": "No runs to delete | Delete {count} run | Delete {count} runs",
|
||||
"testDefinition.listRuns.noRuns.button": "Run Test",
|
||||
"testDefinition.runDetail.ranAt": "Ran at",
|
||||
"testDefinition.runDetail.testCase": "Test case",
|
||||
"testDefinition.runDetail.testCase.id": "Test case ID",
|
||||
"testDefinition.runDetail.testCase.status": "Test case status",
|
||||
"testDefinition.runDetail.totalCases": "Total cases",
|
||||
"testDefinition.runTest": "Run Test",
|
||||
"testDefinition.notImplemented": "This feature is not implemented yet!",
|
||||
"testDefinition.viewDetails": "View Details",
|
||||
|
|
|
@ -18,6 +18,8 @@ import type { RouterMiddleware } from '@/types/router';
|
|||
import { initializeAuthenticatedFeatures, initializeCore } from '@/init';
|
||||
import { tryToParseNumber } from '@/utils/typesUtils';
|
||||
import { projectsRoutes } from '@/routes/projects.routes';
|
||||
import TestDefinitionRunsListView from './views/TestDefinition/TestDefinitionRunsListView.vue';
|
||||
import TestDefinitionRunDetailView from './views/TestDefinition/TestDefinitionRunDetailView.vue';
|
||||
|
||||
const ChangePasswordView = async () => await import('./views/ChangePasswordView.vue');
|
||||
const ErrorView = async () => await import('./views/ErrorView.vue');
|
||||
|
@ -299,6 +301,32 @@ export const routes: RouteRecordRaw[] = [
|
|||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':testId/runs',
|
||||
name: VIEWS.TEST_DEFINITION_RUNS,
|
||||
components: {
|
||||
default: TestDefinitionRunsListView,
|
||||
header: MainHeader,
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
meta: {
|
||||
keepWorkflowAlive: true,
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':testId/runs/:runId',
|
||||
name: VIEWS.TEST_DEFINITION_RUNS_DETAIL,
|
||||
components: {
|
||||
default: TestDefinitionRunDetailView,
|
||||
header: MainHeader,
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
meta: {
|
||||
keepWorkflowAlive: true,
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -172,6 +172,7 @@ export const useExecutionsStore = defineStore('executions', () => {
|
|||
|
||||
executionsCount.value = data.count;
|
||||
executionsCountEstimated.value = data.estimated;
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
|
|
|
@ -2,21 +2,49 @@ import { createPinia, setActivePinia } from 'pinia';
|
|||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; // Adjust the import path as necessary
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
|
||||
import type { TestDefinitionRecord, TestRunRecord } from '@/api/testDefinition.ee';
|
||||
|
||||
const { createTestDefinition, deleteTestDefinition, getTestDefinitions, updateTestDefinition } =
|
||||
vi.hoisted(() => ({
|
||||
getTestDefinitions: vi.fn(),
|
||||
createTestDefinition: vi.fn(),
|
||||
updateTestDefinition: vi.fn(),
|
||||
deleteTestDefinition: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
createTestDefinition,
|
||||
deleteTestDefinition,
|
||||
getTestDefinitions,
|
||||
updateTestDefinition,
|
||||
getTestMetrics,
|
||||
createTestMetric,
|
||||
updateTestMetric,
|
||||
deleteTestMetric,
|
||||
getTestRuns,
|
||||
getTestRun,
|
||||
startTestRun,
|
||||
deleteTestRun,
|
||||
} = vi.hoisted(() => ({
|
||||
getTestDefinitions: vi.fn(),
|
||||
createTestDefinition: vi.fn(),
|
||||
updateTestDefinition: vi.fn(),
|
||||
deleteTestDefinition: vi.fn(),
|
||||
getTestMetrics: vi.fn(),
|
||||
createTestMetric: vi.fn(),
|
||||
updateTestMetric: vi.fn(),
|
||||
deleteTestMetric: vi.fn(),
|
||||
getTestRuns: vi.fn(),
|
||||
getTestRun: vi.fn(),
|
||||
startTestRun: vi.fn(),
|
||||
deleteTestRun: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/testDefinition.ee', () => ({
|
||||
createTestDefinition,
|
||||
deleteTestDefinition,
|
||||
getTestDefinitions,
|
||||
updateTestDefinition,
|
||||
getTestMetrics,
|
||||
createTestMetric,
|
||||
updateTestMetric,
|
||||
deleteTestMetric,
|
||||
getTestRuns,
|
||||
getTestRun,
|
||||
startTestRun,
|
||||
deleteTestRun,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/root.store', () => ({
|
||||
|
@ -44,6 +72,23 @@ const TEST_DEF_NEW: TestDefinitionRecord = {
|
|||
description: 'New Description',
|
||||
};
|
||||
|
||||
const TEST_METRIC = {
|
||||
id: 'metric1',
|
||||
name: 'Test Metric',
|
||||
testDefinitionId: '1',
|
||||
};
|
||||
|
||||
const TEST_RUN: TestRunRecord = {
|
||||
id: 'run1',
|
||||
testDefinitionId: '1',
|
||||
status: 'completed',
|
||||
metrics: { metric1: 0.75 },
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
runAt: '2024-01-01',
|
||||
completedAt: '2024-01-01',
|
||||
};
|
||||
|
||||
describe('testDefinition.store.ee', () => {
|
||||
let store: ReturnType<typeof useTestDefinitionStore>;
|
||||
let rootStoreMock: ReturnType<typeof useRootStore>;
|
||||
|
@ -64,6 +109,11 @@ describe('testDefinition.store.ee', () => {
|
|||
createTestDefinition.mockResolvedValue(TEST_DEF_NEW);
|
||||
|
||||
deleteTestDefinition.mockResolvedValue({ success: true });
|
||||
|
||||
getTestRuns.mockResolvedValue([TEST_RUN]);
|
||||
getTestRun.mockResolvedValue(TEST_RUN);
|
||||
startTestRun.mockResolvedValue({ success: true });
|
||||
deleteTestRun.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
test('Initialization', () => {
|
||||
|
@ -72,193 +122,460 @@ describe('testDefinition.store.ee', () => {
|
|||
expect(store.hasTestDefinitions).toBe(false);
|
||||
});
|
||||
|
||||
test('Fetching Test Definitions', async () => {
|
||||
expect(store.isLoading).toBe(false);
|
||||
describe('Test Definitions', () => {
|
||||
test('Fetching Test Definitions', async () => {
|
||||
expect(store.isLoading).toBe(false);
|
||||
|
||||
const result = await store.fetchAll();
|
||||
const result = await store.fetchAll({ workflowId: '123' });
|
||||
|
||||
expect(getTestDefinitions).toHaveBeenCalledWith(rootStoreMock.restApiContext);
|
||||
expect(store.testDefinitionsById).toEqual({
|
||||
'1': TEST_DEF_A,
|
||||
'2': TEST_DEF_B,
|
||||
expect(getTestDefinitions).toHaveBeenCalledWith(rootStoreMock.restApiContext, {
|
||||
workflowId: '123',
|
||||
});
|
||||
expect(store.testDefinitionsById).toEqual({
|
||||
'1': TEST_DEF_A,
|
||||
'2': TEST_DEF_B,
|
||||
});
|
||||
expect(store.isLoading).toBe(false);
|
||||
expect(result).toEqual({
|
||||
count: 2,
|
||||
testDefinitions: [TEST_DEF_A, TEST_DEF_B],
|
||||
});
|
||||
});
|
||||
expect(store.isLoading).toBe(false);
|
||||
expect(result).toEqual({
|
||||
count: 2,
|
||||
testDefinitions: [TEST_DEF_A, TEST_DEF_B],
|
||||
|
||||
test('Fetching Test Definitions with force flag', async () => {
|
||||
expect(store.isLoading).toBe(false);
|
||||
|
||||
const result = await store.fetchAll({ force: true, workflowId: '123' });
|
||||
|
||||
expect(getTestDefinitions).toHaveBeenCalledWith(rootStoreMock.restApiContext, {
|
||||
workflowId: '123',
|
||||
});
|
||||
expect(store.testDefinitionsById).toEqual({
|
||||
'1': TEST_DEF_A,
|
||||
'2': TEST_DEF_B,
|
||||
});
|
||||
expect(store.isLoading).toBe(false);
|
||||
expect(result).toEqual({
|
||||
count: 2,
|
||||
testDefinitions: [TEST_DEF_A, TEST_DEF_B],
|
||||
});
|
||||
});
|
||||
|
||||
test('Fetching Test Definitions when already fetched', async () => {
|
||||
store.fetchedAll = true;
|
||||
|
||||
const result = await store.fetchAll();
|
||||
|
||||
expect(getTestDefinitions).not.toHaveBeenCalled();
|
||||
expect(store.testDefinitionsById).toEqual({});
|
||||
expect(result).toEqual({
|
||||
count: 0,
|
||||
testDefinitions: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('Upserting Test Definitions - New Definition', () => {
|
||||
const newDefinition = TEST_DEF_NEW;
|
||||
|
||||
store.upsertTestDefinitions([newDefinition]);
|
||||
|
||||
expect(store.testDefinitionsById).toEqual({
|
||||
'3': TEST_DEF_NEW,
|
||||
});
|
||||
});
|
||||
|
||||
test('Upserting Test Definitions - Existing Definition', () => {
|
||||
store.testDefinitionsById = {
|
||||
'1': TEST_DEF_A,
|
||||
};
|
||||
|
||||
const updatedDefinition = {
|
||||
id: '1',
|
||||
name: 'Updated Test Definition A',
|
||||
description: 'Updated Description A',
|
||||
workflowId: '123',
|
||||
};
|
||||
|
||||
store.upsertTestDefinitions([updatedDefinition]);
|
||||
|
||||
expect(store.testDefinitionsById).toEqual({
|
||||
1: updatedDefinition,
|
||||
});
|
||||
});
|
||||
|
||||
test('Creating a Test Definition', async () => {
|
||||
const params = {
|
||||
name: 'New Test Definition',
|
||||
workflowId: 'test-workflow-id',
|
||||
evaluationWorkflowId: 'test-evaluation-workflow-id',
|
||||
description: 'New Description',
|
||||
};
|
||||
|
||||
const result = await store.create(params);
|
||||
|
||||
expect(createTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, params);
|
||||
expect(store.testDefinitionsById).toEqual({
|
||||
'3': TEST_DEF_NEW,
|
||||
});
|
||||
expect(result).toEqual(TEST_DEF_NEW);
|
||||
});
|
||||
|
||||
test('Updating a Test Definition', async () => {
|
||||
store.testDefinitionsById = {
|
||||
'1': TEST_DEF_A,
|
||||
'2': TEST_DEF_B,
|
||||
};
|
||||
|
||||
const params = {
|
||||
id: '1',
|
||||
name: 'Updated Test Definition A',
|
||||
description: 'Updated Description A',
|
||||
workflowId: '123',
|
||||
};
|
||||
updateTestDefinition.mockResolvedValue(params);
|
||||
|
||||
const result = await store.update(params);
|
||||
|
||||
expect(updateTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1', {
|
||||
name: 'Updated Test Definition A',
|
||||
description: 'Updated Description A',
|
||||
workflowId: '123',
|
||||
});
|
||||
expect(store.testDefinitionsById).toEqual({
|
||||
'1': params,
|
||||
'2': TEST_DEF_B,
|
||||
});
|
||||
expect(result).toEqual(params);
|
||||
});
|
||||
|
||||
test('Deleting a Test Definition', () => {
|
||||
store.testDefinitionsById = {
|
||||
'1': TEST_DEF_A,
|
||||
'2': TEST_DEF_B,
|
||||
};
|
||||
|
||||
store.deleteTestDefinition('1');
|
||||
|
||||
expect(store.testDefinitionsById).toEqual({
|
||||
'2': TEST_DEF_B,
|
||||
});
|
||||
});
|
||||
|
||||
test('Deleting a Test Definition by ID', async () => {
|
||||
store.testDefinitionsById = {
|
||||
'1': TEST_DEF_A,
|
||||
};
|
||||
|
||||
const result = await store.deleteById('1');
|
||||
|
||||
expect(deleteTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1');
|
||||
expect(store.testDefinitionsById).toEqual({});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('Fetching Test Definitions with force flag', async () => {
|
||||
expect(store.isLoading).toBe(false);
|
||||
describe('Metrics', () => {
|
||||
test('Fetching Metrics for a Test Definition', async () => {
|
||||
getTestMetrics.mockResolvedValue([TEST_METRIC]);
|
||||
|
||||
const result = await store.fetchAll({ force: true });
|
||||
const metrics = await store.fetchMetrics('1');
|
||||
|
||||
expect(getTestDefinitions).toHaveBeenCalledWith(rootStoreMock.restApiContext);
|
||||
expect(store.testDefinitionsById).toEqual({
|
||||
'1': TEST_DEF_A,
|
||||
'2': TEST_DEF_B,
|
||||
expect(getTestMetrics).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1');
|
||||
expect(store.metricsById).toEqual({
|
||||
metric1: TEST_METRIC,
|
||||
});
|
||||
expect(metrics).toEqual([TEST_METRIC]);
|
||||
});
|
||||
expect(store.isLoading).toBe(false);
|
||||
expect(result).toEqual({
|
||||
count: 2,
|
||||
testDefinitions: [TEST_DEF_A, TEST_DEF_B],
|
||||
|
||||
test('Creating a Metric', async () => {
|
||||
createTestMetric.mockResolvedValue(TEST_METRIC);
|
||||
|
||||
const params = {
|
||||
name: 'Test Metric',
|
||||
testDefinitionId: '1',
|
||||
};
|
||||
|
||||
const result = await store.createMetric(params);
|
||||
|
||||
expect(createTestMetric).toHaveBeenCalledWith(rootStoreMock.restApiContext, params);
|
||||
expect(store.metricsById).toEqual({
|
||||
metric1: TEST_METRIC,
|
||||
});
|
||||
expect(result).toEqual(TEST_METRIC);
|
||||
});
|
||||
|
||||
test('Updating a Metric', async () => {
|
||||
const updatedMetric = { ...TEST_METRIC, name: 'Updated Metric' };
|
||||
updateTestMetric.mockResolvedValue(updatedMetric);
|
||||
|
||||
const result = await store.updateMetric(updatedMetric);
|
||||
|
||||
expect(updateTestMetric).toHaveBeenCalledWith(rootStoreMock.restApiContext, updatedMetric);
|
||||
expect(store.metricsById).toEqual({
|
||||
metric1: updatedMetric,
|
||||
});
|
||||
expect(result).toEqual(updatedMetric);
|
||||
});
|
||||
|
||||
test('Deleting a Metric', async () => {
|
||||
store.metricsById = {
|
||||
metric1: TEST_METRIC,
|
||||
};
|
||||
|
||||
const params = { id: 'metric1', testDefinitionId: '1' };
|
||||
deleteTestMetric.mockResolvedValue(undefined);
|
||||
|
||||
await store.deleteMetric(params);
|
||||
|
||||
expect(deleteTestMetric).toHaveBeenCalledWith(rootStoreMock.restApiContext, params);
|
||||
expect(store.metricsById).toEqual({});
|
||||
});
|
||||
|
||||
test('Getting Metrics by Test ID', () => {
|
||||
const metric1 = { ...TEST_METRIC, id: 'metric1', testDefinitionId: '1' };
|
||||
const metric2 = { ...TEST_METRIC, id: 'metric2', testDefinitionId: '1' };
|
||||
const metric3 = { ...TEST_METRIC, id: 'metric3', testDefinitionId: '2' };
|
||||
|
||||
store.metricsById = {
|
||||
metric1,
|
||||
metric2,
|
||||
metric3,
|
||||
};
|
||||
|
||||
const metricsForTest1 = store.metricsByTestId['1'];
|
||||
expect(metricsForTest1).toEqual([metric1, metric2]);
|
||||
|
||||
const metricsForTest2 = store.metricsByTestId['2'];
|
||||
expect(metricsForTest2).toEqual([metric3]);
|
||||
});
|
||||
});
|
||||
|
||||
test('Fetching Test Definitions when already fetched', async () => {
|
||||
store.fetchedAll = true;
|
||||
describe('Computed Properties', () => {
|
||||
test('hasTestDefinitions', () => {
|
||||
store.testDefinitionsById = {};
|
||||
|
||||
const result = await store.fetchAll();
|
||||
expect(store.hasTestDefinitions).toBe(false);
|
||||
store.testDefinitionsById = {
|
||||
'1': TEST_DEF_A,
|
||||
};
|
||||
|
||||
expect(getTestDefinitions).not.toHaveBeenCalled();
|
||||
expect(store.testDefinitionsById).toEqual({});
|
||||
expect(result).toEqual({
|
||||
count: 0,
|
||||
testDefinitions: [],
|
||||
expect(store.hasTestDefinitions).toBe(true);
|
||||
});
|
||||
|
||||
test('isFeatureEnabled', () => {
|
||||
posthogStoreMock.isFeatureEnabled = vi.fn().mockReturnValue(false);
|
||||
|
||||
expect(store.isFeatureEnabled).toBe(false);
|
||||
posthogStoreMock.isFeatureEnabled = vi.fn().mockReturnValue(true);
|
||||
|
||||
expect(store.isFeatureEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test('allTestDefinitionsByWorkflowId', () => {
|
||||
store.testDefinitionsById = {
|
||||
'1': { ...TEST_DEF_A, workflowId: 'workflow1' },
|
||||
'2': { ...TEST_DEF_B, workflowId: 'workflow1' },
|
||||
'3': { ...TEST_DEF_NEW, workflowId: 'workflow2' },
|
||||
};
|
||||
|
||||
expect(store.allTestDefinitionsByWorkflowId).toEqual({
|
||||
workflow1: [
|
||||
{ ...TEST_DEF_A, workflowId: 'workflow1' },
|
||||
{ ...TEST_DEF_B, workflowId: 'workflow1' },
|
||||
],
|
||||
workflow2: [{ ...TEST_DEF_NEW, workflowId: 'workflow2' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('lastRunByTestId', () => {
|
||||
const olderRun = {
|
||||
...TEST_RUN,
|
||||
id: 'run2',
|
||||
testDefinitionId: '1',
|
||||
updatedAt: '2023-12-31',
|
||||
};
|
||||
|
||||
const newerRun = {
|
||||
...TEST_RUN,
|
||||
id: 'run3',
|
||||
testDefinitionId: '2',
|
||||
updatedAt: '2024-01-02',
|
||||
};
|
||||
|
||||
store.testRunsById = {
|
||||
run1: { ...TEST_RUN, testDefinitionId: '1' },
|
||||
run2: olderRun,
|
||||
run3: newerRun,
|
||||
};
|
||||
|
||||
expect(store.lastRunByTestId).toEqual({
|
||||
'1': TEST_RUN,
|
||||
'2': newerRun,
|
||||
});
|
||||
});
|
||||
|
||||
test('lastRunByTestId with no runs', () => {
|
||||
store.testRunsById = {};
|
||||
expect(store.lastRunByTestId).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
test('Upserting Test Definitions - New Definition', () => {
|
||||
const newDefinition = TEST_DEF_NEW;
|
||||
describe('Error Handling', () => {
|
||||
test('create', async () => {
|
||||
createTestDefinition.mockRejectedValue(new Error('Create failed'));
|
||||
|
||||
store.upsertTestDefinitions([newDefinition]);
|
||||
await expect(
|
||||
store.create({ name: 'New Test Definition', workflowId: 'test-workflow-id' }),
|
||||
).rejects.toThrow('Create failed');
|
||||
});
|
||||
|
||||
expect(store.testDefinitionsById).toEqual({
|
||||
'3': TEST_DEF_NEW,
|
||||
test('update', async () => {
|
||||
updateTestDefinition.mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
await expect(store.update({ id: '1', name: 'Updated Test Definition A' })).rejects.toThrow(
|
||||
'Update failed',
|
||||
);
|
||||
});
|
||||
|
||||
test('deleteById', async () => {
|
||||
deleteTestDefinition.mockResolvedValue({ success: false });
|
||||
|
||||
const result = await store.deleteById('1');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('Upserting Test Definitions - Existing Definition', () => {
|
||||
store.testDefinitionsById = {
|
||||
'1': TEST_DEF_A,
|
||||
};
|
||||
describe('Test Runs', () => {
|
||||
test('Fetching Test Runs', async () => {
|
||||
const result = await store.fetchTestRuns('1');
|
||||
|
||||
const updatedDefinition = {
|
||||
id: '1',
|
||||
name: 'Updated Test Definition A',
|
||||
description: 'Updated Description A',
|
||||
workflowId: '123',
|
||||
};
|
||||
expect(getTestRuns).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1');
|
||||
expect(store.testRunsById).toEqual({
|
||||
run1: TEST_RUN,
|
||||
});
|
||||
expect(result).toEqual([TEST_RUN]);
|
||||
});
|
||||
|
||||
store.upsertTestDefinitions([updatedDefinition]);
|
||||
test('Getting specific Test Run', async () => {
|
||||
const params = { testDefinitionId: '1', runId: 'run1' };
|
||||
const result = await store.getTestRun(params);
|
||||
|
||||
expect(store.testDefinitionsById).toEqual({
|
||||
1: updatedDefinition,
|
||||
expect(getTestRun).toHaveBeenCalledWith(rootStoreMock.restApiContext, params);
|
||||
expect(store.testRunsById).toEqual({
|
||||
run1: TEST_RUN,
|
||||
});
|
||||
expect(result).toEqual(TEST_RUN);
|
||||
});
|
||||
|
||||
test('Starting Test Run', async () => {
|
||||
const result = await store.startTestRun('1');
|
||||
|
||||
expect(startTestRun).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1');
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test('Deleting Test Run', async () => {
|
||||
store.testRunsById = { run1: TEST_RUN };
|
||||
const params = { testDefinitionId: '1', runId: 'run1' };
|
||||
|
||||
const result = await store.deleteTestRun(params);
|
||||
|
||||
expect(deleteTestRun).toHaveBeenCalledWith(rootStoreMock.restApiContext, params);
|
||||
expect(store.testRunsById).toEqual({});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test('Getting Test Runs by Test ID', () => {
|
||||
store.testRunsById = {
|
||||
run1: TEST_RUN,
|
||||
run2: { ...TEST_RUN, id: 'run2', testDefinitionId: '2' },
|
||||
};
|
||||
|
||||
const runs = store.testRunsByTestId['1'];
|
||||
|
||||
expect(runs).toEqual([TEST_RUN]);
|
||||
});
|
||||
});
|
||||
|
||||
test('Deleting Test Definitions', () => {
|
||||
store.testDefinitionsById = {
|
||||
'1': TEST_DEF_A,
|
||||
'2': TEST_DEF_B,
|
||||
};
|
||||
|
||||
store.deleteTestDefinition('1');
|
||||
|
||||
expect(store.testDefinitionsById).toEqual({
|
||||
'2': TEST_DEF_B,
|
||||
describe('Polling Mechanism', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
});
|
||||
|
||||
test('Creating a Test Definition', async () => {
|
||||
const params = {
|
||||
name: 'New Test Definition',
|
||||
workflowId: 'test-workflow-id',
|
||||
evaluationWorkflowId: 'test-evaluation-workflow-id',
|
||||
description: 'New Description',
|
||||
};
|
||||
|
||||
const result = await store.create(params);
|
||||
|
||||
expect(createTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, params);
|
||||
expect(store.testDefinitionsById).toEqual({
|
||||
'3': TEST_DEF_NEW,
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
expect(result).toEqual(TEST_DEF_NEW);
|
||||
});
|
||||
|
||||
test('Updating a Test Definition', async () => {
|
||||
store.testDefinitionsById = {
|
||||
'1': TEST_DEF_A,
|
||||
'2': TEST_DEF_B,
|
||||
};
|
||||
test('should start polling for running test runs', async () => {
|
||||
const runningTestRun = {
|
||||
...TEST_RUN,
|
||||
status: 'running',
|
||||
};
|
||||
|
||||
const params = {
|
||||
id: '1',
|
||||
name: 'Updated Test Definition A',
|
||||
description: 'Updated Description A',
|
||||
workflowId: '123',
|
||||
};
|
||||
updateTestDefinition.mockResolvedValue(params);
|
||||
getTestRuns.mockResolvedValueOnce([runningTestRun]);
|
||||
|
||||
const result = await store.update(params);
|
||||
// First call returns running status
|
||||
getTestRun.mockResolvedValueOnce({
|
||||
...runningTestRun,
|
||||
status: 'running',
|
||||
});
|
||||
|
||||
expect(updateTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1', {
|
||||
name: 'Updated Test Definition A',
|
||||
description: 'Updated Description A',
|
||||
workflowId: '123',
|
||||
// Second call returns completed status
|
||||
getTestRun.mockResolvedValueOnce({
|
||||
...runningTestRun,
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
await store.fetchTestRuns('1');
|
||||
|
||||
expect(store.testRunsById).toEqual({
|
||||
run1: runningTestRun,
|
||||
});
|
||||
|
||||
// Advance timer to trigger the first poll
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// Verify first poll happened
|
||||
expect(getTestRun).toHaveBeenCalledWith(rootStoreMock.restApiContext, {
|
||||
testDefinitionId: '1',
|
||||
runId: 'run1',
|
||||
});
|
||||
|
||||
// Advance timer again
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// Verify polling stopped after status changed to completed
|
||||
expect(getTestRun).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(store.testDefinitionsById).toEqual({
|
||||
'1': params,
|
||||
'2': TEST_DEF_B,
|
||||
|
||||
test('should cleanup polling timeouts', async () => {
|
||||
const runningTestRun = {
|
||||
...TEST_RUN,
|
||||
status: 'running',
|
||||
};
|
||||
|
||||
getTestRuns.mockResolvedValueOnce([runningTestRun]);
|
||||
getTestRun.mockResolvedValue({
|
||||
...runningTestRun,
|
||||
status: 'running',
|
||||
});
|
||||
|
||||
await store.fetchTestRuns('1');
|
||||
|
||||
// Wait for the first poll to complete
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
// Clear mock calls from initial setup
|
||||
getTestRun.mockClear();
|
||||
|
||||
store.cleanupPolling();
|
||||
|
||||
// Advance timer
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// Verify no more polling happened after cleanup
|
||||
expect(getTestRun).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(result).toEqual(params);
|
||||
});
|
||||
|
||||
test('Deleting a Test Definition by ID', async () => {
|
||||
store.testDefinitionsById = {
|
||||
'1': TEST_DEF_A,
|
||||
};
|
||||
|
||||
const result = await store.deleteById('1');
|
||||
|
||||
expect(deleteTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1');
|
||||
expect(store.testDefinitionsById).toEqual({});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('Computed Properties - hasTestDefinitions', () => {
|
||||
store.testDefinitionsById = {};
|
||||
|
||||
expect(store.hasTestDefinitions).toBe(false);
|
||||
store.testDefinitionsById = {
|
||||
'1': TEST_DEF_A,
|
||||
};
|
||||
|
||||
expect(store.hasTestDefinitions).toBe(true);
|
||||
});
|
||||
|
||||
test('Computed Properties - isFeatureEnabled', () => {
|
||||
posthogStoreMock.isFeatureEnabled = vi.fn().mockReturnValue(false);
|
||||
|
||||
expect(store.isFeatureEnabled).toBe(false);
|
||||
posthogStoreMock.isFeatureEnabled = vi.fn().mockReturnValue(true);
|
||||
|
||||
expect(store.isFeatureEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test('Error Handling - create', async () => {
|
||||
createTestDefinition.mockRejectedValue(new Error('Create failed'));
|
||||
|
||||
await expect(
|
||||
store.create({ name: 'New Test Definition', workflowId: 'test-workflow-id' }),
|
||||
).rejects.toThrow('Create failed');
|
||||
});
|
||||
|
||||
test('Error Handling - update', async () => {
|
||||
updateTestDefinition.mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
await expect(store.update({ id: '1', name: 'Updated Test Definition A' })).rejects.toThrow(
|
||||
'Update failed',
|
||||
);
|
||||
});
|
||||
|
||||
test('Error Handling - deleteById', async () => {
|
||||
deleteTestDefinition.mockResolvedValue({ success: false });
|
||||
|
||||
const result = await store.deleteById('1');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,7 +2,7 @@ import { defineStore } from 'pinia';
|
|||
import { computed, ref } from 'vue';
|
||||
import { useRootStore } from './root.store';
|
||||
import * as testDefinitionsApi from '@/api/testDefinition.ee';
|
||||
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
|
||||
import type { TestDefinitionRecord, TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import { usePostHog } from './posthog.store';
|
||||
import { STORES, WORKFLOW_EVALUATION_EXPERIMENT } from '@/constants';
|
||||
|
||||
|
@ -13,6 +13,9 @@ export const useTestDefinitionStore = defineStore(
|
|||
const testDefinitionsById = ref<Record<string, TestDefinitionRecord>>({});
|
||||
const loading = ref(false);
|
||||
const fetchedAll = ref(false);
|
||||
const metricsById = ref<Record<string, testDefinitionsApi.TestMetricRecord>>({});
|
||||
const testRunsById = ref<Record<string, TestRunRecord>>({});
|
||||
const pollingTimeouts = ref<Record<string, NodeJS.Timeout>>({});
|
||||
|
||||
// Store instances
|
||||
const posthogStore = usePostHog();
|
||||
|
@ -25,6 +28,19 @@ export const useTestDefinitionStore = defineStore(
|
|||
);
|
||||
});
|
||||
|
||||
const allTestDefinitionsByWorkflowId = computed(() => {
|
||||
return Object.values(testDefinitionsById.value).reduce(
|
||||
(acc: Record<string, TestDefinitionRecord[]>, test) => {
|
||||
if (!acc[test.workflowId]) {
|
||||
acc[test.workflowId] = [];
|
||||
}
|
||||
acc[test.workflowId].push(test);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
// Enable with `window.featureFlags.override('025_workflow_evaluation', true)`
|
||||
const isFeatureEnabled = computed(() =>
|
||||
posthogStore.isFeatureEnabled(WORKFLOW_EVALUATION_EXPERIMENT),
|
||||
|
@ -34,6 +50,56 @@ export const useTestDefinitionStore = defineStore(
|
|||
|
||||
const hasTestDefinitions = computed(() => Object.keys(testDefinitionsById.value).length > 0);
|
||||
|
||||
const metricsByTestId = computed(() => {
|
||||
return Object.values(metricsById.value).reduce(
|
||||
(acc: Record<string, testDefinitionsApi.TestMetricRecord[]>, metric) => {
|
||||
if (!acc[metric.testDefinitionId]) {
|
||||
acc[metric.testDefinitionId] = [];
|
||||
}
|
||||
acc[metric.testDefinitionId].push(metric);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
const testRunsByTestId = computed(() => {
|
||||
return Object.values(testRunsById.value).reduce(
|
||||
(acc: Record<string, TestRunRecord[]>, run) => {
|
||||
if (!acc[run.testDefinitionId]) {
|
||||
acc[run.testDefinitionId] = [];
|
||||
}
|
||||
acc[run.testDefinitionId].push(run);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
const lastRunByTestId = computed(() => {
|
||||
const grouped = Object.values(testRunsById.value).reduce(
|
||||
(acc: Record<string, TestRunRecord[]>, run) => {
|
||||
if (!acc[run.testDefinitionId]) {
|
||||
acc[run.testDefinitionId] = [];
|
||||
}
|
||||
acc[run.testDefinitionId].push(run);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return Object.entries(grouped).reduce(
|
||||
(acc: Record<string, TestRunRecord | null>, [testId, runs]) => {
|
||||
acc[testId] =
|
||||
runs.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
)[0] || null;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const setAllTestDefinitions = (definitions: TestDefinitionRecord[]) => {
|
||||
testDefinitionsById.value = definitions.reduce(
|
||||
|
@ -69,13 +135,32 @@ export const useTestDefinitionStore = defineStore(
|
|||
testDefinitionsById.value = rest;
|
||||
};
|
||||
|
||||
const fetchRunsForAllTests = async () => {
|
||||
const testDefinitions = Object.values(testDefinitionsById.value);
|
||||
try {
|
||||
await Promise.all(testDefinitions.map(async (testDef) => await fetchTestRuns(testDef.id)));
|
||||
} catch (error) {
|
||||
console.error('Error fetching test runs:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTestDefinition = async (id: string) => {
|
||||
const testDefinition = await testDefinitionsApi.getTestDefinition(
|
||||
rootStore.restApiContext,
|
||||
id,
|
||||
);
|
||||
testDefinitionsById.value[testDefinition.id] = testDefinition;
|
||||
|
||||
return testDefinition;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all test definitions from the API.
|
||||
* @param {boolean} force - If true, fetches the definitions from the API even if they were already fetched before.
|
||||
*/
|
||||
const fetchAll = async (params?: { force?: boolean }) => {
|
||||
const { force = false } = params ?? {};
|
||||
if (!force && fetchedAll.value) {
|
||||
const fetchAll = async (params?: { force?: boolean; workflowId?: string }) => {
|
||||
const { force = false, workflowId } = params ?? {};
|
||||
if (!force && fetchedAll.value && !workflowId) {
|
||||
const testDefinitions = Object.values(testDefinitionsById.value);
|
||||
return {
|
||||
count: testDefinitions.length,
|
||||
|
@ -87,10 +172,13 @@ export const useTestDefinitionStore = defineStore(
|
|||
try {
|
||||
const retrievedDefinitions = await testDefinitionsApi.getTestDefinitions(
|
||||
rootStore.restApiContext,
|
||||
{ workflowId },
|
||||
);
|
||||
|
||||
setAllTestDefinitions(retrievedDefinitions.testDefinitions);
|
||||
fetchedAll.value = true;
|
||||
|
||||
await fetchRunsForAllTests();
|
||||
return retrievedDefinitions;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
|
@ -147,24 +235,142 @@ export const useTestDefinitionStore = defineStore(
|
|||
return result.success;
|
||||
};
|
||||
|
||||
const fetchMetrics = async (testId: string) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const metrics = await testDefinitionsApi.getTestMetrics(rootStore.restApiContext, testId);
|
||||
metrics.forEach((metric) => {
|
||||
metricsById.value[metric.id] = metric;
|
||||
});
|
||||
return metrics;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const createMetric = async (params: {
|
||||
name: string;
|
||||
testDefinitionId: string;
|
||||
}): Promise<testDefinitionsApi.TestMetricRecord> => {
|
||||
const metric = await testDefinitionsApi.createTestMetric(rootStore.restApiContext, params);
|
||||
metricsById.value[metric.id] = metric;
|
||||
return metric;
|
||||
};
|
||||
|
||||
const updateMetric = async (
|
||||
params: testDefinitionsApi.TestMetricRecord,
|
||||
): Promise<testDefinitionsApi.TestMetricRecord> => {
|
||||
const metric = await testDefinitionsApi.updateTestMetric(rootStore.restApiContext, params);
|
||||
metricsById.value[metric.id] = metric;
|
||||
return metric;
|
||||
};
|
||||
|
||||
const deleteMetric = async (
|
||||
params: testDefinitionsApi.DeleteTestMetricParams,
|
||||
): Promise<void> => {
|
||||
await testDefinitionsApi.deleteTestMetric(rootStore.restApiContext, params);
|
||||
const { [params.id]: deleted, ...rest } = metricsById.value;
|
||||
metricsById.value = rest;
|
||||
};
|
||||
|
||||
// Test Runs Methods
|
||||
const fetchTestRuns = async (testDefinitionId: string) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const runs = await testDefinitionsApi.getTestRuns(
|
||||
rootStore.restApiContext,
|
||||
testDefinitionId,
|
||||
);
|
||||
runs.forEach((run) => {
|
||||
testRunsById.value[run.id] = run;
|
||||
if (['running', 'new'].includes(run.status)) {
|
||||
startPollingTestRun(testDefinitionId, run.id);
|
||||
}
|
||||
});
|
||||
return runs;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getTestRun = async (params: { testDefinitionId: string; runId: string }) => {
|
||||
const run = await testDefinitionsApi.getTestRun(rootStore.restApiContext, params);
|
||||
testRunsById.value[run.id] = run;
|
||||
return run;
|
||||
};
|
||||
|
||||
const startTestRun = async (testDefinitionId: string) => {
|
||||
const result = await testDefinitionsApi.startTestRun(
|
||||
rootStore.restApiContext,
|
||||
testDefinitionId,
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
const deleteTestRun = async (params: { testDefinitionId: string; runId: string }) => {
|
||||
const result = await testDefinitionsApi.deleteTestRun(rootStore.restApiContext, params);
|
||||
if (result.success) {
|
||||
const { [params.runId]: deleted, ...rest } = testRunsById.value;
|
||||
testRunsById.value = rest;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// TODO: This is a temporary solution to poll for test run status.
|
||||
// We should use a more efficient polling mechanism in the future.
|
||||
const startPollingTestRun = (testDefinitionId: string, runId: string) => {
|
||||
const poll = async () => {
|
||||
const run = await getTestRun({ testDefinitionId, runId });
|
||||
if (['running', 'new'].includes(run.status)) {
|
||||
pollingTimeouts.value[runId] = setTimeout(poll, 1000);
|
||||
} else {
|
||||
delete pollingTimeouts.value[runId];
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
};
|
||||
|
||||
const cleanupPolling = () => {
|
||||
Object.values(pollingTimeouts.value).forEach((timeout) => {
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
pollingTimeouts.value = {};
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
fetchedAll,
|
||||
testDefinitionsById,
|
||||
testRunsById,
|
||||
|
||||
// Computed
|
||||
allTestDefinitions,
|
||||
allTestDefinitionsByWorkflowId,
|
||||
isLoading,
|
||||
hasTestDefinitions,
|
||||
isFeatureEnabled,
|
||||
metricsById,
|
||||
metricsByTestId,
|
||||
testRunsByTestId,
|
||||
lastRunByTestId,
|
||||
|
||||
// Methods
|
||||
fetchTestDefinition,
|
||||
fetchAll,
|
||||
create,
|
||||
update,
|
||||
deleteById,
|
||||
upsertTestDefinitions,
|
||||
deleteTestDefinition,
|
||||
fetchMetrics,
|
||||
createMetric,
|
||||
updateMetric,
|
||||
deleteMetric,
|
||||
fetchTestRuns,
|
||||
getTestRun,
|
||||
startTestRun,
|
||||
deleteTestRun,
|
||||
cleanupPolling,
|
||||
};
|
||||
},
|
||||
{},
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
LOG_STREAM_MODAL_KEY,
|
||||
MFA_SETUP_MODAL_KEY,
|
||||
PERSONALIZATION_MODAL_KEY,
|
||||
NODE_PINNING_MODAL_KEY,
|
||||
STORES,
|
||||
TAGS_MANAGER_MODAL_KEY,
|
||||
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
|
||||
|
@ -45,6 +46,7 @@ import type {
|
|||
NotificationOptions,
|
||||
ModalState,
|
||||
ModalKey,
|
||||
AppliedThemeOption,
|
||||
} from '@/Interface';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
|
@ -98,6 +100,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||
CREDENTIAL_SELECT_MODAL_KEY,
|
||||
DUPLICATE_MODAL_KEY,
|
||||
PERSONALIZATION_MODAL_KEY,
|
||||
NODE_PINNING_MODAL_KEY,
|
||||
INVITE_USER_MODAL_KEY,
|
||||
TAGS_MANAGER_MODAL_KEY,
|
||||
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
|
||||
|
@ -184,8 +187,15 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||
const rootStore = useRootStore();
|
||||
const userStore = useUsersStore();
|
||||
|
||||
// Keep track of the preferred theme and update it when the system preference changes
|
||||
const preferredTheme = getPreferredTheme();
|
||||
const preferredSystemTheme = ref<AppliedThemeOption>(preferredTheme.theme);
|
||||
preferredTheme.mediaQuery?.addEventListener('change', () => {
|
||||
preferredSystemTheme.value = getPreferredTheme().theme;
|
||||
});
|
||||
|
||||
const appliedTheme = computed(() => {
|
||||
return theme.value === 'system' ? getPreferredTheme() : theme.value;
|
||||
return theme.value === 'system' ? preferredSystemTheme.value : theme.value;
|
||||
});
|
||||
|
||||
const contextBasedTranslationKeys = computed(() => {
|
||||
|
|
|
@ -31,9 +31,11 @@ export function updateTheme(theme: ThemeOption) {
|
|||
}
|
||||
}
|
||||
|
||||
export function getPreferredTheme(): AppliedThemeOption {
|
||||
const isDarkMode =
|
||||
!!window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)')?.matches;
|
||||
export function getPreferredTheme(): { theme: AppliedThemeOption; mediaQuery: MediaQueryList } {
|
||||
const isDarkModeQuery = !!window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
return isDarkMode ? 'dark' : 'light';
|
||||
return {
|
||||
theme: isDarkModeQuery?.matches ? 'dark' : 'light',
|
||||
mediaQuery: isDarkModeQuery,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -144,6 +144,7 @@ export interface CanvasInjectionData {
|
|||
export type CanvasNodeEventBusEvents = {
|
||||
'update:sticky:color': never;
|
||||
'update:node:active': never;
|
||||
'update:node:class': { className: string; add?: boolean };
|
||||
};
|
||||
|
||||
export type CanvasEventBusEvents = {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
import { computed, onMounted, watch, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { NODE_PINNING_MODAL_KEY, VIEWS } from '@/constants';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useAnnotationTagsStore } from '@/stores/tags.store';
|
||||
|
@ -14,6 +14,12 @@ import EvaluationStep from '@/components/TestDefinition/EditDefinition/Evaluatio
|
|||
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, TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import type { ModalState } from '@/Interface';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
|
||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||
|
||||
const props = defineProps<{
|
||||
testId?: string;
|
||||
|
@ -24,31 +30,50 @@ const route = useRoute();
|
|||
const locale = useI18n();
|
||||
const { debounce } = useDebounce();
|
||||
const toast = useToast();
|
||||
const { isLoading, allTags, tagsById, fetchAll } = useAnnotationTagsStore();
|
||||
|
||||
const testId = computed(() => props.testId ?? (route.params.testId as string));
|
||||
const currentWorkflowId = computed(() => route.params.name as string);
|
||||
const buttonLabel = computed(() =>
|
||||
testId.value
|
||||
? locale.baseText('testDefinition.edit.updateTest')
|
||||
: locale.baseText('testDefinition.edit.saveTest'),
|
||||
);
|
||||
|
||||
const testDefinitionStore = useTestDefinitionStore();
|
||||
const tagsStore = useAnnotationTagsStore();
|
||||
const uiStore = useUIStore();
|
||||
const {
|
||||
state,
|
||||
fieldsIssues,
|
||||
isSaving,
|
||||
cancelEditing,
|
||||
loadTestData,
|
||||
createTest,
|
||||
updateTest,
|
||||
startEditing,
|
||||
saveChanges,
|
||||
cancelEditing,
|
||||
handleKeydown,
|
||||
deleteMetric,
|
||||
updateMetrics,
|
||||
} = useTestDefinitionForm();
|
||||
|
||||
const isLoading = computed(() => tagsStore.isLoading);
|
||||
const allTags = computed(() => tagsStore.allTags);
|
||||
const tagsById = computed(() => tagsStore.tagsById);
|
||||
const testId = computed(() => props.testId ?? (route.params.testId as string));
|
||||
const currentWorkflowId = computed(() => route.params.name as string);
|
||||
|
||||
const tagUsageCount = computed(
|
||||
() => tagsStore.tagsById[state.value.tags.value[0]]?.usageCount ?? 0,
|
||||
);
|
||||
|
||||
const nodePinningModal = ref<ModalState | null>(null);
|
||||
const modalContentWidth = ref(0);
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAll();
|
||||
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
|
||||
}
|
||||
void tagsStore.fetchAll({ withUsageCount: true });
|
||||
if (testId.value) {
|
||||
await loadTestData(testId.value);
|
||||
} else {
|
||||
|
@ -64,16 +89,12 @@ async function onSaveTest() {
|
|||
} else {
|
||||
savedTest = await createTest(currentWorkflowId.value);
|
||||
}
|
||||
if (savedTest && route.name === VIEWS.TEST_DEFINITION_EDIT) {
|
||||
if (savedTest && route.name === VIEWS.NEW_TEST_DEFINITION) {
|
||||
await router.replace({
|
||||
name: VIEWS.TEST_DEFINITION_EDIT,
|
||||
params: { testId: savedTest.id },
|
||||
});
|
||||
}
|
||||
toast.showMessage({
|
||||
title: locale.baseText('testDefinition.edit.testSaved'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
toast.showError(e, locale.baseText('testDefinition.edit.testSaveFailed'));
|
||||
}
|
||||
|
@ -83,12 +104,69 @@ function hasIssues(key: string) {
|
|||
return fieldsIssues.value.some((issue) => issue.field === key);
|
||||
}
|
||||
|
||||
watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: true });
|
||||
async function onDeleteMetric(deletedMetric: Partial<TestMetricRecord>) {
|
||||
if (deletedMetric.id) {
|
||||
await deleteMetric(deletedMetric.id, testId.value);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateTag(tagName: string) {
|
||||
try {
|
||||
const newTag = await tagsStore.create(tagName);
|
||||
return newTag;
|
||||
} catch (error) {
|
||||
toast.showError(error, 'Error', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function openPinningModal() {
|
||||
uiStore.openModal(NODE_PINNING_MODAL_KEY);
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
await testDefinitionStore.startTestRun(testId.value);
|
||||
await testDefinitionStore.fetchTestRuns(testId.value);
|
||||
}
|
||||
|
||||
const runs = computed(() =>
|
||||
Object.values(testDefinitionStore.testRunsById ?? {}).filter(
|
||||
(run) => run.testDefinitionId === testId.value,
|
||||
),
|
||||
);
|
||||
|
||||
async function onDeleteRuns(runs: TestRunRecord[]) {
|
||||
await Promise.all(
|
||||
runs.map(async (run) => {
|
||||
await testDefinitionStore.deleteTestRun({ testDefinitionId: testId.value, runId: run.id });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Debounced watchers for auto-saving
|
||||
watch(
|
||||
() => state.value.metrics,
|
||||
debounce(async () => await updateMetrics(testId.value), { debounceTime: 400 }),
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [
|
||||
state.value.description,
|
||||
state.value.name,
|
||||
state.value.tags,
|
||||
state.value.evaluationWorkflow,
|
||||
state.value.mockedNodes,
|
||||
],
|
||||
debounce(onSaveTest, { debounceTime: 400 }),
|
||||
{ deep: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.content">
|
||||
<div :class="$style.formContent">
|
||||
<!-- Name -->
|
||||
<EvaluationHeader
|
||||
v-model="state.name"
|
||||
:class="{ 'has-issues': hasIssues('name') }"
|
||||
|
@ -96,24 +174,33 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
|
|||
:save-changes="saveChanges"
|
||||
:handle-keydown="handleKeydown"
|
||||
/>
|
||||
|
||||
<EvaluationStep
|
||||
:class="$style.step"
|
||||
:title="locale.baseText('testDefinition.edit.description')"
|
||||
:expanded="false"
|
||||
>
|
||||
<template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template>
|
||||
<template #cardContent>
|
||||
<DescriptionInput v-model="state.description" />
|
||||
</template>
|
||||
</EvaluationStep>
|
||||
|
||||
<div :class="$style.panelIntro">{{ locale.baseText('testDefinition.edit.step.intro') }}</div>
|
||||
<BlockArrow :class="$style.introArrow" />
|
||||
<div :class="$style.panelBlock">
|
||||
<!-- Description -->
|
||||
<EvaluationStep
|
||||
:class="$style.step"
|
||||
:title="locale.baseText('testDefinition.edit.step.executions')"
|
||||
:title="locale.baseText('testDefinition.edit.description')"
|
||||
:expanded="false"
|
||||
:tooltip="locale.baseText('testDefinition.edit.description.tooltip')"
|
||||
>
|
||||
<template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template>
|
||||
<template #cardContent>
|
||||
<DescriptionInput v-model="state.description" />
|
||||
</template>
|
||||
</EvaluationStep>
|
||||
|
||||
<div :class="$style.panelIntro">
|
||||
{{ locale.baseText('testDefinition.edit.step.intro') }}
|
||||
</div>
|
||||
<BlockArrow :class="$style.introArrow" />
|
||||
<!-- Select Executions -->
|
||||
<EvaluationStep
|
||||
:class="$style.step"
|
||||
:title="
|
||||
locale.baseText('testDefinition.edit.step.executions', {
|
||||
adjustToNumber: tagUsageCount,
|
||||
})
|
||||
"
|
||||
:tooltip="locale.baseText('testDefinition.edit.step.executions.tooltip')"
|
||||
>
|
||||
<template #icon><font-awesome-icon icon="history" size="lg" /></template>
|
||||
<template #cardContent>
|
||||
|
@ -126,6 +213,7 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
|
|||
:start-editing="startEditing"
|
||||
:save-changes="saveChanges"
|
||||
:cancel-editing="cancelEditing"
|
||||
:create-tag="handleCreateTag"
|
||||
/>
|
||||
</template>
|
||||
</EvaluationStep>
|
||||
|
@ -133,29 +221,46 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
|
|||
<BlockArrow />
|
||||
<BlockArrow />
|
||||
</div>
|
||||
|
||||
<!-- Mocked Nodes -->
|
||||
<EvaluationStep
|
||||
:class="$style.step"
|
||||
:title="locale.baseText('testDefinition.edit.step.nodes')"
|
||||
:title="
|
||||
locale.baseText('testDefinition.edit.step.mockedNodes', {
|
||||
adjustToNumber: state.mockedNodes?.length ?? 0,
|
||||
})
|
||||
"
|
||||
:small="true"
|
||||
:expanded="false"
|
||||
:expanded="true"
|
||||
:tooltip="locale.baseText('testDefinition.edit.step.nodes.tooltip')"
|
||||
>
|
||||
<template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template>
|
||||
<template #cardContent>{{
|
||||
locale.baseText('testDefinition.edit.step.mockedNodes', { adjustToNumber: 0 })
|
||||
}}</template>
|
||||
<template #cardContent>
|
||||
<n8n-button
|
||||
size="small"
|
||||
data-test-id="select-nodes-button"
|
||||
:label="locale.baseText('testDefinition.edit.selectNodes')"
|
||||
type="tertiary"
|
||||
@click="openPinningModal"
|
||||
/>
|
||||
</template>
|
||||
</EvaluationStep>
|
||||
|
||||
<!-- Re-run Executions -->
|
||||
<EvaluationStep
|
||||
:class="$style.step"
|
||||
:title="locale.baseText('testDefinition.edit.step.reRunExecutions')"
|
||||
:small="true"
|
||||
:tooltip="locale.baseText('testDefinition.edit.step.reRunExecutions.tooltip')"
|
||||
>
|
||||
<template #icon><font-awesome-icon icon="redo" size="lg" /></template>
|
||||
</EvaluationStep>
|
||||
|
||||
<!-- Compare Executions -->
|
||||
<EvaluationStep
|
||||
:class="$style.step"
|
||||
:title="locale.baseText('testDefinition.edit.step.compareExecutions')"
|
||||
:tooltip="locale.baseText('testDefinition.edit.step.compareExecutions.tooltip')"
|
||||
>
|
||||
<template #icon><font-awesome-icon icon="equals" size="lg" /></template>
|
||||
<template #cardContent>
|
||||
|
@ -166,27 +271,69 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
|
|||
</template>
|
||||
</EvaluationStep>
|
||||
|
||||
<!-- Metrics -->
|
||||
<EvaluationStep
|
||||
:class="$style.step"
|
||||
:title="locale.baseText('testDefinition.edit.step.metrics')"
|
||||
:tooltip="locale.baseText('testDefinition.edit.step.metrics.tooltip')"
|
||||
>
|
||||
<template #icon><font-awesome-icon icon="chart-bar" size="lg" /></template>
|
||||
<template #cardContent>
|
||||
<MetricsInput v-model="state.metrics" :class="{ 'has-issues': hasIssues('metrics') }" />
|
||||
<MetricsInput
|
||||
v-model="state.metrics"
|
||||
:class="{ 'has-issues': hasIssues('metrics') }"
|
||||
@delete-metric="onDeleteMetric"
|
||||
/>
|
||||
</template>
|
||||
</EvaluationStep>
|
||||
</div>
|
||||
|
||||
<div :class="$style.footer">
|
||||
<n8n-button
|
||||
type="primary"
|
||||
data-test-id="run-test-button"
|
||||
:label="buttonLabel"
|
||||
:loading="isSaving"
|
||||
@click="onSaveTest"
|
||||
/>
|
||||
</div>
|
||||
<n8n-button
|
||||
v-if="state.evaluationWorkflow.value && state.tags.value.length > 0"
|
||||
:class="$style.runTestButton"
|
||||
size="small"
|
||||
data-test-id="run-test-button"
|
||||
:label="locale.baseText('testDefinition.runTest')"
|
||||
type="primary"
|
||||
@click="runTest"
|
||||
/>
|
||||
<n8n-button
|
||||
v-else
|
||||
:class="$style.runTestButton"
|
||||
size="small"
|
||||
data-test-id="run-test-button"
|
||||
:label="'Save Test'"
|
||||
type="primary"
|
||||
@click="onSaveTest"
|
||||
/>
|
||||
</div>
|
||||
<!-- Past Runs Table -->
|
||||
<div v-if="runs.length > 0" :class="$style.runsTable">
|
||||
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{
|
||||
locale.baseText('testDefinition.edit.pastRuns')
|
||||
}}</N8nHeading>
|
||||
<TestRunsTable
|
||||
:runs="runs"
|
||||
:selectable="true"
|
||||
data-test-id="past-runs-table"
|
||||
@delete-runs="onDeleteRuns"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal ref="nodePinningModal" width="80vw" height="85vh" :name="NODE_PINNING_MODAL_KEY">
|
||||
<template #header>
|
||||
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{
|
||||
locale.baseText('testDefinition.edit.selectNodes')
|
||||
}}</N8nHeading>
|
||||
</template>
|
||||
<template #content>
|
||||
<NodesPinning
|
||||
v-model="state.mockedNodes"
|
||||
:width="modalContentWidth"
|
||||
data-test-id="nodes-pinning-modal"
|
||||
/>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -194,22 +341,40 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
|
|||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding: var(--spacing-s);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(auto, 24rem) 1fr;
|
||||
gap: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 0;
|
||||
.formContent {
|
||||
width: 100%;
|
||||
min-width: fit-content;
|
||||
padding-bottom: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.runsTableTotal {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
.runsTable {
|
||||
flex-shrink: 1;
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.runsTableHeading {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.panelBlock {
|
||||
max-width: var(--evaluation-edit-panel-width, 24rem);
|
||||
display: grid;
|
||||
|
||||
justify-items: end;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.panelIntro {
|
||||
font-size: var(--font-size-m);
|
||||
|
@ -231,7 +396,7 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
|
|||
justify-self: center;
|
||||
}
|
||||
.evaluationArrows {
|
||||
--arrow-height: 11rem;
|
||||
--arrow-height: 13.8rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
@ -267,4 +432,11 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
|
|||
justify-items: end;
|
||||
align-items: start;
|
||||
}
|
||||
.mockedNodesLabel {
|
||||
min-height: 1.5rem;
|
||||
display: block;
|
||||
}
|
||||
.runTestButton {
|
||||
margin-top: var(--spacing-m);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -19,17 +19,22 @@ const toast = useToast();
|
|||
const locale = useI18n();
|
||||
|
||||
const tests = computed<TestListItem[]>(() => {
|
||||
return testDefinitionStore.allTestDefinitions
|
||||
return (
|
||||
testDefinitionStore.allTestDefinitionsByWorkflowId[
|
||||
router.currentRoute.value.params.name as string
|
||||
] ?? []
|
||||
)
|
||||
.filter((test): test is TestDefinitionRecord => test.id !== undefined)
|
||||
.sort((a, b) => new Date(b?.updatedAt ?? '').getTime() - new Date(a?.updatedAt ?? '').getTime())
|
||||
.map((test) => ({
|
||||
id: test.id,
|
||||
name: test.name ?? '',
|
||||
tagName: test.annotationTagId ? getTagName(test.annotationTagId) : '',
|
||||
testCases: 0, // TODO: This should come from the API
|
||||
testCases: testDefinitionStore.testRunsByTestId[test.id]?.length ?? 0,
|
||||
execution: getTestExecution(test.id),
|
||||
}));
|
||||
});
|
||||
|
||||
const hasTests = computed(() => tests.value.length > 0);
|
||||
const allTags = computed(() => tagsStore.allTags);
|
||||
|
||||
|
@ -39,21 +44,25 @@ function getTagName(tagId: string) {
|
|||
return matchingTag?.name ?? '';
|
||||
}
|
||||
|
||||
// TODO: Replace with actual API call once implemented
|
||||
function getTestExecution(_testId: string): TestExecution {
|
||||
function getTestExecution(testId: string): TestExecution {
|
||||
const lastRun = testDefinitionStore.lastRunByTestId[testId];
|
||||
if (!lastRun) {
|
||||
return {
|
||||
lastRun: null,
|
||||
errorRate: 0,
|
||||
metrics: {},
|
||||
status: 'new',
|
||||
};
|
||||
}
|
||||
|
||||
const mockExecutions = {
|
||||
lastRun: 'an hour ago',
|
||||
lastRun: lastRun.updatedAt ?? '',
|
||||
errorRate: 0,
|
||||
metrics: { metric1: 0.12, metric2: 0.99, metric3: 0.87 },
|
||||
metrics: lastRun.metrics ?? {},
|
||||
status: lastRun.status ?? 'running',
|
||||
};
|
||||
|
||||
return (
|
||||
mockExecutions || {
|
||||
lastRun: null,
|
||||
errorRate: null,
|
||||
metrics: { metric1: null, metric2: null, metric3: null },
|
||||
}
|
||||
);
|
||||
return mockExecutions;
|
||||
}
|
||||
|
||||
// Action handlers
|
||||
|
@ -61,20 +70,27 @@ function onCreateTest() {
|
|||
void router.push({ name: VIEWS.NEW_TEST_DEFINITION });
|
||||
}
|
||||
|
||||
function onRunTest(_testId: string) {
|
||||
// TODO: Implement test run logic
|
||||
toast.showMessage({
|
||||
title: locale.baseText('testDefinition.notImplemented'),
|
||||
type: 'warning',
|
||||
});
|
||||
async function onRunTest(testId: string) {
|
||||
try {
|
||||
const result = await testDefinitionStore.startTestRun(testId);
|
||||
if (result.success) {
|
||||
toast.showMessage({
|
||||
title: locale.baseText('testDefinition.list.testStarted'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
// Optionally fetch the updated test runs
|
||||
await testDefinitionStore.fetchTestRuns(testId);
|
||||
} else {
|
||||
throw new Error('Test run failed to start');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(error, locale.baseText('testDefinition.list.testStartError'));
|
||||
}
|
||||
}
|
||||
|
||||
function onViewDetails(_testId: string) {
|
||||
// TODO: Implement test details view
|
||||
toast.showMessage({
|
||||
title: locale.baseText('testDefinition.notImplemented'),
|
||||
type: 'warning',
|
||||
});
|
||||
async function onViewDetails(testId: string) {
|
||||
void router.push({ name: VIEWS.TEST_DEFINITION_RUNS, params: { testId } });
|
||||
}
|
||||
|
||||
function onEditTest(testId: number) {
|
||||
|
@ -92,12 +108,22 @@ async function onDeleteTest(testId: string) {
|
|||
|
||||
// Load initial data
|
||||
async function loadInitialData() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await tagsStore.fetchAll();
|
||||
await testDefinitionStore.fetchAll();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
if (!isLoading.value) {
|
||||
// Add guard to prevent multiple loading states
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await Promise.all([
|
||||
tagsStore.fetchAll(),
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -112,7 +138,9 @@ onMounted(() => {
|
|||
name: VIEWS.WORKFLOW,
|
||||
params: { name: router.currentRoute.value.params.name },
|
||||
});
|
||||
return; // Add early return to prevent loading if feature is disabled
|
||||
}
|
||||
|
||||
void loadInitialData();
|
||||
});
|
||||
</script>
|
||||
|
@ -124,7 +152,11 @@ onMounted(() => {
|
|||
</div>
|
||||
|
||||
<template v-else>
|
||||
<EmptyState v-if="!hasTests" @create-test="onCreateTest" />
|
||||
<EmptyState
|
||||
v-if="!hasTests"
|
||||
data-test-id="test-definition-empty-state"
|
||||
@create-test="onCreateTest"
|
||||
/>
|
||||
<TestsList
|
||||
v-else
|
||||
:tests="tests"
|
||||
|
|
|
@ -0,0 +1,292 @@
|
|||
<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 TestDefinitionTable from '@/components/TestDefinition/shared/TestDefinitionTable.vue';
|
||||
import type { TestDefinitionTableColumn } from '@/components/TestDefinition/shared/TestDefinitionTable.vue';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
import { get } from 'lodash-es';
|
||||
import type { ExecutionSummaryWithScopes } from '@/Interface';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
||||
interface TestCase extends ExecutionSummaryWithScopes {
|
||||
metrics: Record<string, number>;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const testDefinitionStore = useTestDefinitionStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
const workflowStore = useWorkflowsStore();
|
||||
const locale = useI18n();
|
||||
|
||||
const isLoading = ref(true);
|
||||
const testCases = ref<TestCase[]>([]);
|
||||
|
||||
const runId = computed(() => router.currentRoute.value.params.runId as string);
|
||||
const testId = computed(() => router.currentRoute.value.params.testId as string);
|
||||
|
||||
const run = computed(() => testDefinitionStore.testRunsById[runId.value]);
|
||||
const test = computed(() => testDefinitionStore.testDefinitionsById[testId.value]);
|
||||
const workflow = computed(
|
||||
() => workflowStore.workflowsById[test.value?.evaluationWorkflowId ?? ''],
|
||||
);
|
||||
const filteredTestCases = computed(() => {
|
||||
return testCases.value;
|
||||
});
|
||||
|
||||
const columns = computed(
|
||||
(): Array<TestDefinitionTableColumn<TestCase>> => [
|
||||
{
|
||||
prop: 'id',
|
||||
width: 200,
|
||||
label: locale.baseText('testDefinition.runDetail.testCase'),
|
||||
sortable: true,
|
||||
route: (row: TestCase) => ({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: row.workflowId, executionId: row.id },
|
||||
}),
|
||||
formatter: (row: TestCase) => `[${row.id}] ${workflow.value?.name}`,
|
||||
openInNewTab: true,
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: locale.baseText('testDefinition.listRuns.status'),
|
||||
filters: [
|
||||
{ text: locale.baseText('testDefinition.listRuns.status.new'), value: 'new' },
|
||||
{ text: locale.baseText('testDefinition.listRuns.status.running'), value: 'running' },
|
||||
{ text: locale.baseText('testDefinition.listRuns.status.success'), value: 'success' },
|
||||
{ text: locale.baseText('testDefinition.listRuns.status.error'), value: 'error' },
|
||||
],
|
||||
filterMethod: (value: string, row: TestCase) => row.status === value,
|
||||
},
|
||||
...Object.keys(run.value?.metrics ?? {}).map((metric) => ({
|
||||
prop: `metrics.${metric}`,
|
||||
label: metric,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
formatter: (row: TestCase) => row.metrics[metric]?.toFixed(2) ?? '-',
|
||||
})),
|
||||
],
|
||||
);
|
||||
|
||||
const metrics = computed(() => run.value?.metrics ?? {});
|
||||
|
||||
// Temporary workaround to fetch test cases by manually getting workflow executions
|
||||
// TODO: Replace with dedicated API endpoint once available
|
||||
const fetchExecutionTestCases = async () => {
|
||||
if (!runId.value || !testId.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const testRun = await testDefinitionStore.getTestRun({
|
||||
testDefinitionId: testId.value,
|
||||
runId: runId.value,
|
||||
});
|
||||
const testDefinition = await testDefinitionStore.fetchTestDefinition(testId.value);
|
||||
|
||||
// Fetch workflow executions that match this test run
|
||||
const evaluationWorkflowExecutions = await executionsStore.fetchExecutions({
|
||||
workflowId: testDefinition.evaluationWorkflowId ?? '',
|
||||
metadata: [{ key: 'testRunId', value: testRun.id }],
|
||||
});
|
||||
|
||||
// For each execution, fetch full details and extract metrics
|
||||
const executionsData = await Promise.all(
|
||||
evaluationWorkflowExecutions?.results.map(async (execution) => {
|
||||
const executionData = await executionsStore.fetchExecution(execution.id);
|
||||
const lastExecutedNode = executionData?.data?.resultData?.lastNodeExecuted;
|
||||
if (!lastExecutedNode) {
|
||||
throw new Error('Last executed node is required');
|
||||
}
|
||||
const metricsData = get(
|
||||
executionData,
|
||||
[
|
||||
'data',
|
||||
'resultData',
|
||||
'runData',
|
||||
lastExecutedNode,
|
||||
'0',
|
||||
'data',
|
||||
'main',
|
||||
'0',
|
||||
'0',
|
||||
'json',
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
...execution,
|
||||
metrics: metricsData,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
testCases.value = executionsData ?? [];
|
||||
} catch (error) {
|
||||
toast.showError(error, 'Failed to load run details');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchExecutionTestCases();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container" data-test-id="test-definition-run-detail">
|
||||
<div :class="$style.header">
|
||||
<button :class="$style.backButton" @click="router.back()">
|
||||
<i class="mr-xs"><font-awesome-icon icon="arrow-left" /></i>
|
||||
<n8n-heading size="large" :bold="true">{{ test?.name }}</n8n-heading>
|
||||
<i class="ml-xs mr-xs"><font-awesome-icon icon="chevron-right" /></i>
|
||||
<n8n-heading size="large" :bold="true"
|
||||
>{{ locale.baseText('testDefinition.listRuns.runNumber') }}{{ run?.id }}</n8n-heading
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div :class="$style.cardGrid">
|
||||
<N8nCard :class="$style.summaryCard">
|
||||
<div :class="$style.stat">
|
||||
<N8nText size="small">
|
||||
{{ locale.baseText('testDefinition.runDetail.totalCases') }}
|
||||
</N8nText>
|
||||
<N8nText size="large">{{ testCases.length }}</N8nText>
|
||||
</div>
|
||||
</N8nCard>
|
||||
|
||||
<N8nCard :class="$style.summaryCard">
|
||||
<div :class="$style.stat">
|
||||
<N8nText size="small">
|
||||
{{ locale.baseText('testDefinition.runDetail.ranAt') }}
|
||||
</N8nText>
|
||||
<N8nText size="medium">{{
|
||||
convertToDisplayDate(new Date(run?.runAt).getTime())
|
||||
}}</N8nText>
|
||||
</div>
|
||||
</N8nCard>
|
||||
|
||||
<N8nCard :class="$style.summaryCard">
|
||||
<div :class="$style.stat">
|
||||
<N8nText size="small">
|
||||
{{ locale.baseText('testDefinition.listRuns.status') }}
|
||||
</N8nText>
|
||||
<N8nText size="large" :class="run?.status.toLowerCase()">
|
||||
{{ run?.status }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</N8nCard>
|
||||
|
||||
<N8nCard v-for="(value, key) in metrics" :key="key" :class="$style.summaryCard">
|
||||
<div :class="$style.stat">
|
||||
<N8nText size="small">{{ key }}</N8nText>
|
||||
<N8nText size="large">{{ value.toFixed(2) }}</N8nText>
|
||||
</div>
|
||||
</N8nCard>
|
||||
</div>
|
||||
|
||||
<N8nCard>
|
||||
<div v-if="isLoading" :class="$style.loading">
|
||||
<n8n-loading :loading="true" :rows="5" />
|
||||
</div>
|
||||
<TestDefinitionTable
|
||||
v-else
|
||||
:data="filteredTestCases"
|
||||
:columns="columns"
|
||||
:default-sort="{ prop: 'id', order: 'descending' }"
|
||||
/>
|
||||
</N8nCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
padding: var(--spacing-xl) var(--spacing-l);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: var(--content-container-width);
|
||||
}
|
||||
|
||||
.backButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-l);
|
||||
|
||||
.timestamp {
|
||||
color: var(--color-text-base);
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin-bottom: var(--spacing-m);
|
||||
|
||||
.summaryStats {
|
||||
display: flex;
|
||||
gap: var(--spacing-l);
|
||||
}
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.downloadButton {
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.cardGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
|
||||
gap: var(--spacing-xs);
|
||||
margin-bottom: var(--spacing-m);
|
||||
}
|
||||
|
||||
:global {
|
||||
.new {
|
||||
color: var(--color-info);
|
||||
}
|
||||
.running {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
.completed {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,129 @@
|
|||
<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 { VIEWS } from '@/constants';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
||||
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[]) {
|
||||
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>
|
||||
<template v-else-if="runs.length > 0">
|
||||
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
|
||||
<TestRunsTable :runs="runs" @get-run-detail="getRunDetail" @delete-runs="onDeleteRuns" />
|
||||
</template>
|
||||
<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 {
|
||||
padding: var(--spacing-xl) var(--spacing-l);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: var(--content-container-width);
|
||||
}
|
||||
.backButton {
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
.description {
|
||||
margin-top: var(--spacing-xs);
|
||||
display: block;
|
||||
}
|
||||
</style>
|
|
@ -1,3 +1,4 @@
|
|||
import type { Mock } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
@ -8,57 +9,89 @@ import { useToast } from '@/composables/useToast';
|
|||
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 { VIEWS } from '@/constants';
|
||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
|
||||
vi.mock('vue-router');
|
||||
vi.mock('@/composables/useToast');
|
||||
vi.mock('@/components/TestDefinition/composables/useTestDefinitionForm');
|
||||
vi.mock('@/stores/tags.store');
|
||||
vi.mock('@/stores/projects.store');
|
||||
|
||||
describe('TestDefinitionEditView', () => {
|
||||
const renderComponent = createComponentRenderer(TestDefinitionEditView);
|
||||
|
||||
let createTestMock: Mock;
|
||||
let updateTestMock: Mock;
|
||||
let loadTestDataMock: Mock;
|
||||
let deleteMetricMock: Mock;
|
||||
let updateMetricsMock: Mock;
|
||||
let showMessageMock: Mock;
|
||||
let showErrorMock: Mock;
|
||||
|
||||
const renderComponentWithFeatureEnabled = ({
|
||||
testRunsById = {},
|
||||
}: { testRunsById?: Record<string, TestRunRecord> } = {}) => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const mockedTestDefinitionStore = mockedStore(useTestDefinitionStore);
|
||||
mockedTestDefinitionStore.isFeatureEnabled = true;
|
||||
mockedTestDefinitionStore.testRunsById = testRunsById;
|
||||
return { ...renderComponent({ pinia }), mockedTestDefinitionStore };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
createAppModals();
|
||||
|
||||
// Default route mock: no testId
|
||||
vi.mocked(useRoute).mockReturnValue({
|
||||
params: {},
|
||||
path: '/test-path',
|
||||
name: 'test-route',
|
||||
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: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showMessage: showMessageMock,
|
||||
showError: showErrorMock,
|
||||
} as unknown as ReturnType<typeof useToast>);
|
||||
vi.mocked(useTestDefinitionForm).mockReturnValue({
|
||||
state: ref({
|
||||
name: { value: '', isEditing: false, tempValue: '' },
|
||||
description: '',
|
||||
tags: { appliedTagIds: [], isEditing: false },
|
||||
evaluationWorkflow: { id: '1', name: 'Test Workflow' },
|
||||
tags: { value: [], tempValue: [], isEditing: false },
|
||||
evaluationWorkflow: { mode: 'list', value: '', __rl: true },
|
||||
metrics: [],
|
||||
}),
|
||||
fieldsIssues: ref([]),
|
||||
isSaving: ref(false),
|
||||
loadTestData: vi.fn(),
|
||||
saveTest: vi.fn(),
|
||||
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.mocked(useAnnotationTagsStore).mockReturnValue({
|
||||
isLoading: ref(false),
|
||||
allTags: ref([]),
|
||||
tagsById: ref({}),
|
||||
fetchAll: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useAnnotationTagsStore>);
|
||||
|
||||
vi.mock('@/stores/projects.store', () => ({
|
||||
useProjectsStore: vi.fn().mockReturnValue({
|
||||
|
@ -71,138 +104,197 @@ describe('TestDefinitionEditView', () => {
|
|||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanupAppModals();
|
||||
});
|
||||
|
||||
it('should load test data when testId is provided', async () => {
|
||||
vi.mocked(useRoute).mockReturnValue({
|
||||
params: { testId: '1' },
|
||||
path: '/test-path',
|
||||
name: 'test-route',
|
||||
name: VIEWS.TEST_DEFINITION_EDIT,
|
||||
} as unknown as ReturnType<typeof useRoute>);
|
||||
const loadTestDataMock = vi.fn();
|
||||
vi.mocked(useTestDefinitionForm).mockReturnValue({
|
||||
...vi.mocked(useTestDefinitionForm)(),
|
||||
loadTestData: loadTestDataMock,
|
||||
} as unknown as ReturnType<typeof useTestDefinitionForm>);
|
||||
renderComponentWithFeatureEnabled();
|
||||
|
||||
renderComponent({
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
mockedStore(useAnnotationTagsStore).fetchAll.mockResolvedValue([]);
|
||||
|
||||
await nextTick();
|
||||
expect(loadTestDataMock).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('should not load test data when testId is not provided', async () => {
|
||||
const loadTestDataMock = vi.fn();
|
||||
vi.mocked(useTestDefinitionForm).mockReturnValue({
|
||||
...vi.mocked(useTestDefinitionForm)(),
|
||||
loadTestData: loadTestDataMock,
|
||||
} as unknown as ReturnType<typeof useTestDefinitionForm>);
|
||||
// Here route returns no testId
|
||||
vi.mocked(useRoute).mockReturnValue({
|
||||
params: {},
|
||||
name: VIEWS.NEW_TEST_DEFINITION,
|
||||
} as unknown as ReturnType<typeof useRoute>);
|
||||
renderComponentWithFeatureEnabled();
|
||||
|
||||
renderComponent({
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
expect(loadTestDataMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save test and show success message on successful save', async () => {
|
||||
const saveTestMock = vi.fn().mockResolvedValue({});
|
||||
const routerPushMock = vi.fn();
|
||||
const routerResolveMock = vi.fn().mockReturnValue({ href: '/test-href' });
|
||||
vi.mocked(useTestDefinitionForm).mockReturnValue({
|
||||
...vi.mocked(useTestDefinitionForm)(),
|
||||
createTest: saveTestMock,
|
||||
} as unknown as ReturnType<typeof useTestDefinitionForm>);
|
||||
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();
|
||||
|
||||
vi.mocked(useRouter).mockReturnValue({
|
||||
push: routerPushMock,
|
||||
resolve: routerResolveMock,
|
||||
} as unknown as ReturnType<typeof useRouter>);
|
||||
mockedStore(useAnnotationTagsStore).fetchAll.mockResolvedValue([]);
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
await nextTick();
|
||||
const saveButton = getByTestId('run-test-button');
|
||||
saveButton.click();
|
||||
await nextTick();
|
||||
|
||||
expect(saveTestMock).toHaveBeenCalled();
|
||||
expect(createTestMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show error message on failed save', async () => {
|
||||
const saveTestMock = vi.fn().mockRejectedValue(new Error('Save failed'));
|
||||
const showErrorMock = vi.fn();
|
||||
vi.mocked(useTestDefinitionForm).mockReturnValue({
|
||||
...vi.mocked(useTestDefinitionForm)(),
|
||||
createTest: saveTestMock,
|
||||
} as unknown as ReturnType<typeof useTestDefinitionForm>);
|
||||
vi.mocked(useToast).mockReturnValue({ showError: showErrorMock } as unknown as ReturnType<
|
||||
typeof useToast
|
||||
>);
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
await nextTick();
|
||||
const saveButton = getByTestId('run-test-button');
|
||||
saveButton.click();
|
||||
await nextTick();
|
||||
expect(saveTestMock).toHaveBeenCalled();
|
||||
expect(showErrorMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display "Update Test" button when editing existing test', async () => {
|
||||
it('should update test and show success message on save if testId is present', async () => {
|
||||
vi.mocked(useRoute).mockReturnValue({
|
||||
params: { testId: '1' },
|
||||
path: '/test-path',
|
||||
name: 'test-route',
|
||||
name: VIEWS.TEST_DEFINITION_EDIT,
|
||||
} as unknown as ReturnType<typeof useRoute>);
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
|
||||
const { getByTestId } = renderComponentWithFeatureEnabled();
|
||||
|
||||
const saveButton = getByTestId('run-test-button');
|
||||
saveButton.click();
|
||||
await nextTick();
|
||||
const updateButton = getByTestId('run-test-button');
|
||||
expect(updateButton.textContent).toContain('Update test');
|
||||
|
||||
expect(updateTestMock).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('should display "Run Test" button when creating new test', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
await nextTick();
|
||||
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');
|
||||
expect(saveButton).toBeTruthy();
|
||||
saveButton.click();
|
||||
await nextTick();
|
||||
|
||||
expect(createTestMock).toHaveBeenCalled();
|
||||
expect(showErrorMock).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
|
||||
});
|
||||
|
||||
it('should display "Save Test" button when editing test without eval workflow and tags', async () => {
|
||||
vi.mocked(useRoute).mockReturnValue({
|
||||
params: { testId: '1' },
|
||||
name: VIEWS.TEST_DEFINITION_EDIT,
|
||||
} as unknown as ReturnType<typeof useRoute>);
|
||||
|
||||
const { getByTestId } = renderComponentWithFeatureEnabled();
|
||||
|
||||
await nextTick();
|
||||
const updateButton = getByTestId('run-test-button');
|
||||
expect(updateButton.textContent?.toLowerCase()).toContain('save');
|
||||
});
|
||||
|
||||
it('should display "Save Test" button when creating new test', async () => {
|
||||
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');
|
||||
expect(saveButton.textContent?.toLowerCase()).toContain('save test');
|
||||
});
|
||||
|
||||
it('should apply "has-issues" class to inputs with issues', async () => {
|
||||
vi.mocked(useTestDefinitionForm).mockReturnValue({
|
||||
...vi.mocked(useTestDefinitionForm)(),
|
||||
fieldsIssues: ref([{ field: 'name' }, { field: 'tags' }]),
|
||||
fieldsIssues: ref([
|
||||
{ field: 'name', message: 'Name is required' },
|
||||
{ field: 'tags', message: 'Tag is required' },
|
||||
]),
|
||||
} as unknown as ReturnType<typeof useTestDefinitionForm>);
|
||||
|
||||
const { container } = renderComponent({
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
const { container } = renderComponentWithFeatureEnabled();
|
||||
|
||||
await nextTick();
|
||||
expect(container.querySelector('.has-issues')).toBeTruthy();
|
||||
const issueElements = container.querySelectorAll('.has-issues');
|
||||
expect(issueElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should fetch all tags on mount', async () => {
|
||||
const fetchAllMock = vi.fn();
|
||||
vi.mocked(useAnnotationTagsStore).mockReturnValue({
|
||||
...vi.mocked(useAnnotationTagsStore)(),
|
||||
fetchAll: fetchAllMock,
|
||||
} as unknown as ReturnType<typeof useAnnotationTagsStore>);
|
||||
renderComponentWithFeatureEnabled();
|
||||
await nextTick();
|
||||
expect(mockedStore(useAnnotationTagsStore).fetchAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
renderComponent({
|
||||
pinia: createTestingPinia(),
|
||||
describe('Test Runs functionality', () => {
|
||||
it('should display test runs table when runs exist', async () => {
|
||||
vi.mocked(useRoute).mockReturnValue({
|
||||
params: { testId: '1' },
|
||||
name: VIEWS.TEST_DEFINITION_EDIT,
|
||||
} as unknown as ReturnType<typeof useRoute>);
|
||||
|
||||
const { getByTestId } = renderComponentWithFeatureEnabled({
|
||||
testRunsById: {
|
||||
run1: {
|
||||
id: 'run1',
|
||||
testDefinitionId: '1',
|
||||
status: 'completed',
|
||||
runAt: '2023-01-01',
|
||||
createdAt: '2023-01-01',
|
||||
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');
|
||||
expect(runsTable).toBeTruthy();
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
expect(fetchAllMock).toHaveBeenCalled();
|
||||
it('should not display test runs table when no runs exist', async () => {
|
||||
const { container } = renderComponentWithFeatureEnabled();
|
||||
|
||||
const runsTable = container.querySelector('[data-test-id="past-runs-table"]');
|
||||
expect(runsTable).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should start a test run when run test button is clicked', async () => {
|
||||
vi.mocked(useTestDefinitionForm).mockReturnValue({
|
||||
...vi.mocked(useTestDefinitionForm)(),
|
||||
state: ref({
|
||||
name: { value: 'Test', isEditing: false, tempValue: '' },
|
||||
description: '',
|
||||
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({
|
||||
params: { testId: '1' },
|
||||
name: VIEWS.TEST_DEFINITION_EDIT,
|
||||
} as unknown as ReturnType<typeof useRoute>);
|
||||
|
||||
const { getByTestId, mockedTestDefinitionStore } = renderComponentWithFeatureEnabled();
|
||||
await nextTick();
|
||||
|
||||
const runButton = getByTestId('run-test-button');
|
||||
runButton.click();
|
||||
await nextTick();
|
||||
|
||||
expect(mockedTestDefinitionStore.startTestRun).toHaveBeenCalledWith('1');
|
||||
expect(mockedTestDefinitionStore.fetchTestRuns).toHaveBeenCalledWith('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,174 @@
|
|||
import type { Mock } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import TestDefinitionListView from '@/views/TestDefinition/TestDefinitionListView.vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useAnnotationTagsStore } from '@/stores/tags.store';
|
||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
|
||||
import { VIEWS } from '@/constants';
|
||||
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
|
||||
|
||||
vi.mock('vue-router');
|
||||
vi.mock('@/composables/useToast');
|
||||
|
||||
describe('TestDefinitionListView', () => {
|
||||
const renderComponent = createComponentRenderer(TestDefinitionListView);
|
||||
|
||||
let showMessageMock: Mock;
|
||||
let showErrorMock: 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(() => {
|
||||
setActivePinia(createPinia());
|
||||
|
||||
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();
|
||||
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>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderComponentWithFeatureEnabled = async (
|
||||
{ testDefinitions }: { testDefinitions: TestDefinitionRecord[] } = {
|
||||
testDefinitions: mockTestDefinitions,
|
||||
},
|
||||
) => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const testDefinitionStore = mockedStore(useTestDefinitionStore);
|
||||
// const tagsStore = mockedStore(useAnnotationTagsStore);
|
||||
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 () => {
|
||||
const { getByTestId } = await renderComponentWithFeatureEnabled({ testDefinitions: [] });
|
||||
|
||||
expect(getByTestId('test-definition-empty-state')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render tests list when tests exist', async () => {
|
||||
const { getByTestId } = await renderComponentWithFeatureEnabled();
|
||||
|
||||
expect(getByTestId('test-definition-list')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load initial data on mount', async () => {
|
||||
const { testDefinitionStore } = await renderComponentWithFeatureEnabled();
|
||||
|
||||
expect(testDefinitionStore.fetchAll).toHaveBeenCalledWith({
|
||||
workflowId: 'workflow1',
|
||||
});
|
||||
expect(mockedStore(useAnnotationTagsStore).fetchAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should start test run and show success message', async () => {
|
||||
const { getByTestId } = await renderComponentWithFeatureEnabled();
|
||||
|
||||
const runButton = getByTestId('run-test-button-1');
|
||||
runButton.click();
|
||||
await nextTick();
|
||||
|
||||
expect(startTestRunMock).toHaveBeenCalledWith('1');
|
||||
expect(fetchTestRunsMock).toHaveBeenCalledWith('1');
|
||||
expect(showMessageMock).toHaveBeenCalledWith({
|
||||
title: expect.any(String),
|
||||
type: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error message on failed test run', async () => {
|
||||
const { getByTestId, testDefinitionStore } = await renderComponentWithFeatureEnabled();
|
||||
testDefinitionStore.startTestRun = vi.fn().mockRejectedValue(new Error('Run failed'));
|
||||
|
||||
const runButton = getByTestId('run-test-button-1');
|
||||
runButton.click();
|
||||
await nextTick();
|
||||
|
||||
expect(showErrorMock).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
|
||||
});
|
||||
|
||||
it('should delete test and show success message', async () => {
|
||||
const { getByTestId, testDefinitionStore } = await renderComponentWithFeatureEnabled();
|
||||
|
||||
const deleteButton = getByTestId('delete-test-button-1');
|
||||
deleteButton.click();
|
||||
await nextTick();
|
||||
|
||||
expect(testDefinitionStore.deleteById).toHaveBeenCalledWith('1');
|
||||
expect(showMessageMock).toHaveBeenCalledWith({
|
||||
title: expect.any(String),
|
||||
type: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('should sort tests by updated date in descending order', async () => {
|
||||
const { container } = await renderComponentWithFeatureEnabled();
|
||||
|
||||
const testItems = container.querySelectorAll('[data-test-id^="test-item-"]');
|
||||
expect(testItems[0].getAttribute('data-test-id')).toBe('test-item-3');
|
||||
expect(testItems[1].getAttribute('data-test-id')).toBe('test-item-2');
|
||||
expect(testItems[2].getAttribute('data-test-id')).toBe('test-item-1');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,267 @@
|
|||
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 { useExecutionsStore } from '@/stores/executions.store';
|
||||
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 fetchExecutionsMock: Mock;
|
||||
let fetchExecutionMock: 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 mockExecutions = {
|
||||
results: [
|
||||
{ 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);
|
||||
fetchExecutionsMock = vi.fn().mockResolvedValue(mockExecutions);
|
||||
fetchExecutionMock = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
resultData: {
|
||||
lastNodeExecuted: 'Node1',
|
||||
runData: {
|
||||
Node1: [{ data: { main: [[{ json: { accuracy: 0.95 } }]] } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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 executionsStore = mockedStore(useExecutionsStore);
|
||||
executionsStore.fetchExecutions = fetchExecutionsMock;
|
||||
executionsStore.fetchExecution = fetchExecutionMock;
|
||||
|
||||
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 nextTick();
|
||||
|
||||
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);
|
||||
const executionsStore = mockedStore(useExecutionsStore);
|
||||
|
||||
// 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);
|
||||
|
||||
executionsStore.fetchExecutions = fetchExecutionsMock;
|
||||
executionsStore.fetchExecution = fetchExecutionMock;
|
||||
|
||||
const { container } = renderComponent({ pinia });
|
||||
await nextTick();
|
||||
|
||||
// Wait for all promises to resolve
|
||||
await waitAllPromises();
|
||||
|
||||
const tableRows = container.querySelectorAll('.el-table__row');
|
||||
expect(tableRows.length).toBe(mockExecutions.results.length);
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue