mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-27 19:15:01 +02:00
feat(editor): Group scenarios + inline runner + lazy-fetch historical (no-changelog)
- Group the scenarios list by test type (Success paths / Failure paths / Any) with section counts and hidden empty sections. - Drop the scenario runner modal. The run now happens inline in the detail pane; rich result rendering moved into a reusable ScenarioRunResult component. - Lazy-fetch historical executions: when a scenario with a lastExecutionId is selected but no live result is in memory, the scenarios store pulls the execution via workflowsStore.getExecution and rebuilds a degraded result (node outputs + start times; no intercepted requests since those weren't persisted). Cached in the session. The rich result card calls out the degraded trace. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
83af4bc2cd
commit
f95cfbfaea
|
|
@ -4896,12 +4896,18 @@
|
|||
"scenarios.detail.expectationLabel": "Test type",
|
||||
"scenarios.detail.expectationHelper": "What this scenario is testing — we'll flag a mismatch when a run doesn't match.",
|
||||
"scenarios.detail.run": "Run scenario",
|
||||
"scenarios.detail.rerun": "Run again",
|
||||
"scenarios.detail.notRunYet": "Not run yet",
|
||||
"scenarios.result.degradedNote": "Intercepted request details are only shown for runs from the current session. Open the execution to see the full trace.",
|
||||
"scenarios.result.loadingHistorical": "Loading execution…",
|
||||
"scenarios.expectation.pass": "Success path",
|
||||
"scenarios.expectation.fail": "Failure path",
|
||||
"scenarios.expectation.any": "Any",
|
||||
"scenarios.expectation.badge.pass": "Success",
|
||||
"scenarios.expectation.badge.fail": "Failure",
|
||||
"scenarios.group.pass": "Success paths",
|
||||
"scenarios.group.fail": "Failure paths",
|
||||
"scenarios.group.any": "Any",
|
||||
"scenarios.displayStatus.unrun": "Not run yet",
|
||||
"scenarios.displayStatus.passed": "Passed",
|
||||
"scenarios.displayStatus.passedWithIssues": "Ran with issues",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,330 @@
|
|||
<script setup lang="ts">
|
||||
import type { InstanceAiEvalExecutionResult, InstanceAiEvalNodeResult } from '@n8n/api-types';
|
||||
import { N8nIcon, N8nText, type IconName } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import ScenarioNodeResult from './ScenarioNodeResult.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
result: InstanceAiEvalExecutionResult;
|
||||
durationMs?: number | null;
|
||||
/** When true, hide sections that won't have data for historical runs
|
||||
* (e.g. intercepted requests aren't persisted beyond the originating session). */
|
||||
degraded?: boolean;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const orderedNodeResults = computed(() =>
|
||||
Object.entries(props.result.nodeResults)
|
||||
.map(([name, result]) => ({ name, result: result as InstanceAiEvalNodeResult }))
|
||||
.sort((a, b) => (a.result.startTime ?? 0) - (b.result.startTime ?? 0)),
|
||||
);
|
||||
|
||||
const warnings = computed(() => props.result.hints?.warnings ?? []);
|
||||
const errors = computed(() => props.result.errors ?? []);
|
||||
|
||||
const nodesWithConfigIssues = computed(() =>
|
||||
orderedNodeResults.value.filter(
|
||||
({ result }) => result.configIssues && Object.keys(result.configIssues).length > 0,
|
||||
),
|
||||
);
|
||||
|
||||
const mockedCallCount = computed(() =>
|
||||
orderedNodeResults.value.reduce(
|
||||
(total, { result }) => total + (result.interceptedRequests?.length ?? 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
const issueCount = computed(
|
||||
() => warnings.value.length + errors.value.length + nodesWithConfigIssues.value.length,
|
||||
);
|
||||
|
||||
type VerdictKind = 'passed' | 'passedWithIssues' | 'failed';
|
||||
|
||||
const verdictKind = computed<VerdictKind>(() => {
|
||||
if (!props.result.success || errors.value.length > 0) return 'failed';
|
||||
if (issueCount.value > 0) return 'passedWithIssues';
|
||||
return 'passed';
|
||||
});
|
||||
|
||||
const verdictIcon = computed<IconName>(() => {
|
||||
switch (verdictKind.value) {
|
||||
case 'passed':
|
||||
return 'circle-check';
|
||||
case 'passedWithIssues':
|
||||
return 'triangle-alert';
|
||||
case 'failed':
|
||||
return 'circle-x';
|
||||
}
|
||||
});
|
||||
|
||||
const verdictColor = computed<'success' | 'warning' | 'danger'>(() => {
|
||||
switch (verdictKind.value) {
|
||||
case 'passed':
|
||||
return 'success';
|
||||
case 'passedWithIssues':
|
||||
return 'warning';
|
||||
case 'failed':
|
||||
return 'danger';
|
||||
}
|
||||
});
|
||||
|
||||
const verdictLabel = computed(() => i18n.baseText(`scenarioRunner.verdict.${verdictKind.value}`));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.wrapper">
|
||||
<section :class="[$style.verdict, $style[`verdict_${verdictKind}`]]">
|
||||
<N8nIcon :icon="verdictIcon" :color="verdictColor" size="medium" />
|
||||
<div :class="$style.verdictText">
|
||||
<N8nText bold size="medium" :color="verdictColor">
|
||||
{{ verdictLabel }}
|
||||
</N8nText>
|
||||
<div :class="$style.verdictStats">
|
||||
<N8nText
|
||||
v-if="durationMs !== null && durationMs !== undefined"
|
||||
size="xsmall"
|
||||
color="text-light"
|
||||
tag="span"
|
||||
>
|
||||
{{
|
||||
i18n.baseText('scenarioRunner.verdict.duration', {
|
||||
interpolate: { s: (durationMs / 1000).toFixed(1) },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
<N8nText size="xsmall" color="text-xlight" tag="span">·</N8nText>
|
||||
<N8nText size="xsmall" color="text-light" tag="span">
|
||||
{{
|
||||
i18n.baseText('scenarioRunner.verdict.nodesRan', {
|
||||
interpolate: { count: orderedNodeResults.length },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
<template v-if="!degraded && mockedCallCount > 0">
|
||||
<N8nText size="xsmall" color="text-xlight" tag="span">·</N8nText>
|
||||
<N8nText size="xsmall" color="text-light" tag="span">
|
||||
{{
|
||||
i18n.baseText('scenarioRunner.verdict.mockedCalls', {
|
||||
interpolate: { count: mockedCallCount },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
</template>
|
||||
<template v-if="issueCount > 0">
|
||||
<N8nText size="xsmall" color="text-xlight" tag="span">·</N8nText>
|
||||
<N8nText size="xsmall" color="warning" tag="span">
|
||||
{{
|
||||
i18n.baseText('scenarioRunner.verdict.issues', {
|
||||
interpolate: { count: issueCount },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details v-if="issueCount > 0" :class="$style.details" :open="issueCount > 0">
|
||||
<summary :class="$style.detailsSummary">
|
||||
<N8nIcon icon="chevron-right" size="small" :class="$style.detailsChevron" />
|
||||
<N8nText bold size="small" color="text-dark">
|
||||
{{ i18n.baseText('scenarioRunner.issues.header') }}
|
||||
</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{ i18n.baseText('scenarioRunner.issues.count', { interpolate: { count: issueCount } }) }}
|
||||
</N8nText>
|
||||
</summary>
|
||||
<div :class="$style.issuesList">
|
||||
<div v-for="(err, i) in errors" :key="`err-${i}`" :class="$style.issueRow">
|
||||
<N8nIcon icon="circle-x" color="danger" size="small" />
|
||||
<N8nText size="small" color="text-base">{{ err }}</N8nText>
|
||||
</div>
|
||||
<div v-for="(w, i) in warnings" :key="`warn-${i}`" :class="$style.issueRow">
|
||||
<N8nIcon icon="triangle-alert" color="warning" size="small" />
|
||||
<N8nText size="small" color="text-base">{{ w }}</N8nText>
|
||||
</div>
|
||||
<div
|
||||
v-for="{ name, result } in nodesWithConfigIssues"
|
||||
:key="`cfg-${name}`"
|
||||
:class="$style.issueRow"
|
||||
>
|
||||
<N8nIcon icon="triangle-alert" color="warning" size="small" />
|
||||
<div :class="$style.issueText">
|
||||
<N8nText size="small" color="text-base">
|
||||
{{ i18n.baseText('scenarioRunner.issues.configPrefix', { interpolate: { name } }) }}
|
||||
</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{ Object.keys(result.configIssues ?? {}).join(', ') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details :class="$style.details" open>
|
||||
<summary :class="$style.detailsSummary">
|
||||
<N8nIcon icon="chevron-right" size="small" :class="$style.detailsChevron" />
|
||||
<N8nText bold size="small" color="text-dark">
|
||||
{{ i18n.baseText('scenarioRunner.trace.header') }}
|
||||
</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{
|
||||
i18n.baseText('scenarioRunner.trace.count', {
|
||||
interpolate: { count: orderedNodeResults.length },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
</summary>
|
||||
<div :class="$style.nodesList">
|
||||
<ScenarioNodeResult
|
||||
v-for="{ name, result } in orderedNodeResults"
|
||||
:key="name"
|
||||
:node-name="name"
|
||||
:result="result"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details v-if="result.hints?.globalContext" :class="$style.details">
|
||||
<summary :class="$style.detailsSummary">
|
||||
<N8nIcon icon="chevron-right" size="small" :class="$style.detailsChevron" />
|
||||
<N8nText bold size="small" color="text-dark">
|
||||
{{ i18n.baseText('scenarioRunner.technical.header') }}
|
||||
</N8nText>
|
||||
</summary>
|
||||
<pre :class="$style.contextText">{{ result.hints.globalContext }}</pre>
|
||||
</details>
|
||||
|
||||
<N8nText v-if="degraded" size="xsmall" color="text-light" :class="$style.degradedNote">
|
||||
{{ i18n.baseText('scenarios.result.degradedNote') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.verdict {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--color--background--light-2);
|
||||
border: var(--border);
|
||||
border-left-width: 3px;
|
||||
}
|
||||
|
||||
.verdict_passed {
|
||||
border-left-color: var(--color--success);
|
||||
}
|
||||
|
||||
.verdict_passedWithIssues {
|
||||
border-left-color: var(--color--warning);
|
||||
}
|
||||
|
||||
.verdict_failed {
|
||||
border-left-color: var(--color--danger);
|
||||
}
|
||||
|
||||
.verdictText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
}
|
||||
|
||||
.verdictStats {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing--3xs);
|
||||
flex-wrap: wrap;
|
||||
line-height: var(--line-height--md);
|
||||
}
|
||||
|
||||
.details {
|
||||
border: var(--border);
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--color--background);
|
||||
overflow: hidden;
|
||||
|
||||
&[open] .detailsChevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.detailsSummary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--2xs) var(--spacing--xs);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
|
||||
&::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color--background--light-2);
|
||||
}
|
||||
}
|
||||
|
||||
.detailsChevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.issuesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--2xs) var(--spacing--xs) var(--spacing--xs);
|
||||
border-top: var(--border);
|
||||
}
|
||||
|
||||
.issueRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.issueText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
}
|
||||
|
||||
.nodesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
padding: var(--spacing--2xs) var(--spacing--xs) var(--spacing--xs);
|
||||
border-top: var(--border);
|
||||
}
|
||||
|
||||
.contextText {
|
||||
font-size: var(--font-size--2xs);
|
||||
line-height: var(--line-height--md);
|
||||
margin: 0;
|
||||
padding: var(--spacing--2xs) var(--spacing--xs) var(--spacing--xs);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color--text);
|
||||
border-top: var(--border);
|
||||
background-color: var(--color--background--light-2);
|
||||
}
|
||||
|
||||
.degradedNote {
|
||||
font-style: italic;
|
||||
padding: var(--spacing--3xs) var(--spacing--xs);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,612 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { InstanceAiEvalNodeResult } from '@n8n/api-types';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nCallout,
|
||||
N8nIcon,
|
||||
N8nIconButton,
|
||||
N8nInput,
|
||||
N8nSpinner,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { ElDialog } from 'element-plus';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useScenarioRunnerStore } from '../scenarioRunner.store';
|
||||
import ScenarioNodeResult from './ScenarioNodeResult.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
workflowId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const store = useScenarioRunnerStore();
|
||||
|
||||
const MAX_SCENARIO_LENGTH = 2000;
|
||||
|
||||
const scenarioText = ref(store.lastScenarioHints);
|
||||
const showGlobalContext = ref(false);
|
||||
const editingScenario = ref(!store.hasResult);
|
||||
|
||||
const charCount = computed(() => scenarioText.value.length);
|
||||
const overLimit = computed(() => charCount.value > MAX_SCENARIO_LENGTH);
|
||||
|
||||
const canRun = computed(() => !store.isRunning && !overLimit.value);
|
||||
const canClose = computed(() => !store.isRunning);
|
||||
|
||||
const orderedNodeResults = computed(() => {
|
||||
if (!store.result) return [];
|
||||
return Object.entries(store.result.nodeResults)
|
||||
.map(([name, result]) => ({ name, result: result as InstanceAiEvalNodeResult }))
|
||||
.sort((a, b) => (a.result.startTime ?? 0) - (b.result.startTime ?? 0));
|
||||
});
|
||||
|
||||
const warnings = computed(() => store.result?.hints.warnings ?? []);
|
||||
const errors = computed(() => store.result?.errors ?? []);
|
||||
|
||||
const nodesWithConfigIssues = computed(() =>
|
||||
orderedNodeResults.value.filter(
|
||||
({ result }) => result.configIssues && Object.keys(result.configIssues).length > 0,
|
||||
),
|
||||
);
|
||||
|
||||
const mockedCallCount = computed(() =>
|
||||
orderedNodeResults.value.reduce(
|
||||
(total, { result }) => total + (result.interceptedRequests?.length ?? 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
const issueCount = computed(
|
||||
() => warnings.value.length + errors.value.length + nodesWithConfigIssues.value.length,
|
||||
);
|
||||
|
||||
type VerdictKind = 'passed' | 'passedWithIssues' | 'failed';
|
||||
|
||||
const verdictKind = computed<VerdictKind>(() => {
|
||||
if (!store.result) return 'passed';
|
||||
if (!store.result.success || errors.value.length > 0) return 'failed';
|
||||
if (issueCount.value > 0) return 'passedWithIssues';
|
||||
return 'passed';
|
||||
});
|
||||
|
||||
const verdictIcon = computed(() => {
|
||||
switch (verdictKind.value) {
|
||||
case 'passed':
|
||||
return 'circle-check';
|
||||
case 'passedWithIssues':
|
||||
return 'triangle-alert';
|
||||
case 'failed':
|
||||
return 'circle-x';
|
||||
}
|
||||
});
|
||||
|
||||
const verdictColor = computed(() => {
|
||||
switch (verdictKind.value) {
|
||||
case 'passed':
|
||||
return 'success';
|
||||
case 'passedWithIssues':
|
||||
return 'warning';
|
||||
case 'failed':
|
||||
return 'danger';
|
||||
}
|
||||
});
|
||||
|
||||
const verdictLabel = computed(() => i18n.baseText(`scenarioRunner.verdict.${verdictKind.value}`));
|
||||
|
||||
const scenarioSummary = computed(() => {
|
||||
const text = store.lastScenarioHints?.trim() ?? '';
|
||||
if (!text) return i18n.baseText('scenarioRunner.summary.empty');
|
||||
return text;
|
||||
});
|
||||
|
||||
function close() {
|
||||
if (!canClose.value) return;
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!canRun.value) return;
|
||||
editingScenario.value = false;
|
||||
await store.runScenario(props.workflowId, scenarioText.value.trim());
|
||||
}
|
||||
|
||||
function editScenario() {
|
||||
editingScenario.value = true;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
scenarioText.value = store.lastScenarioHints;
|
||||
editingScenario.value = !store.hasResult;
|
||||
showGlobalContext.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="modelValue"
|
||||
:close-on-click-modal="canClose"
|
||||
:close-on-press-escape="canClose"
|
||||
:show-close="false"
|
||||
:width="720"
|
||||
append-to-body
|
||||
aria-labelledby="scenario-runner-title"
|
||||
custom-class="scenario-runner-dialog"
|
||||
@update:model-value="(val: boolean) => emit('update:modelValue', val)"
|
||||
>
|
||||
<div :class="$style.container">
|
||||
<header :class="$style.header">
|
||||
<div>
|
||||
<N8nText id="scenario-runner-title" tag="h2" bold size="large" color="text-dark">
|
||||
{{ i18n.baseText('scenarioRunner.title') }}
|
||||
</N8nText>
|
||||
<N8nText tag="p" size="small" color="text-light" :class="$style.subtitle">
|
||||
{{ i18n.baseText('scenarioRunner.subtitle') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nIconButton
|
||||
icon="x"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
:disabled="!canClose"
|
||||
:title="i18n.baseText('generic.close')"
|
||||
data-test-id="scenario-runner-close"
|
||||
@click="close"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<section v-if="store.errorMessage && !store.isRunning" :class="$style.section">
|
||||
<N8nCallout theme="danger" icon="triangle-alert">
|
||||
{{ i18n.baseText('scenarioRunner.error.requestFailed') }}
|
||||
<template #trailingContent>
|
||||
<N8nText size="xsmall" color="text-base">{{ store.errorMessage }}</N8nText>
|
||||
</template>
|
||||
</N8nCallout>
|
||||
</section>
|
||||
|
||||
<section v-if="editingScenario || !store.hasResult" :class="$style.inputSection">
|
||||
<label :class="$style.label" for="scenario-runner-textarea">
|
||||
<N8nText bold size="small" color="text-dark">
|
||||
{{ i18n.baseText('scenarioRunner.input.label') }}
|
||||
</N8nText>
|
||||
<N8nText size="xsmall" color="text-light" tag="span">
|
||||
{{ i18n.baseText('scenarioRunner.input.optional') }}
|
||||
</N8nText>
|
||||
</label>
|
||||
<N8nInput
|
||||
id="scenario-runner-textarea"
|
||||
v-model="scenarioText"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="i18n.baseText('scenarioRunner.input.placeholder')"
|
||||
:maxlength="MAX_SCENARIO_LENGTH"
|
||||
:disabled="store.isRunning"
|
||||
data-test-id="scenario-runner-textarea"
|
||||
/>
|
||||
<div :class="$style.inputMeta">
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{ i18n.baseText('scenarioRunner.input.helper') }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
size="xsmall"
|
||||
:color="overLimit ? 'danger' : 'text-light'"
|
||||
:class="$style.counter"
|
||||
>
|
||||
{{ charCount }} / {{ MAX_SCENARIO_LENGTH }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else :class="$style.inputCollapsed">
|
||||
<div :class="$style.inputCollapsedLabel">
|
||||
<N8nText bold size="xsmall" color="text-light">
|
||||
{{ i18n.baseText('scenarioRunner.summary.label') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
type="tertiary"
|
||||
size="mini"
|
||||
icon="pencil"
|
||||
:label="i18n.baseText('scenarioRunner.summary.edit')"
|
||||
@click="editScenario"
|
||||
/>
|
||||
</div>
|
||||
<N8nText size="small" color="text-base" :class="$style.inputCollapsedText">
|
||||
{{ scenarioSummary }}
|
||||
</N8nText>
|
||||
</section>
|
||||
|
||||
<footer :class="$style.actionBar">
|
||||
<N8nButton
|
||||
type="secondary"
|
||||
size="small"
|
||||
:disabled="!canClose"
|
||||
:label="i18n.baseText('generic.close')"
|
||||
@click="close"
|
||||
/>
|
||||
<N8nButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="store.isRunning"
|
||||
:disabled="!canRun"
|
||||
:label="
|
||||
store.hasResult
|
||||
? i18n.baseText('scenarioRunner.rerunButton')
|
||||
: i18n.baseText('scenarioRunner.runButton')
|
||||
"
|
||||
data-test-id="scenario-runner-run"
|
||||
@click="run"
|
||||
/>
|
||||
</footer>
|
||||
|
||||
<section v-if="store.isRunning" :class="$style.runningBlock">
|
||||
<N8nSpinner size="small" />
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('scenarioRunner.running') }}
|
||||
</N8nText>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="store.result && !store.isRunning"
|
||||
:class="[$style.verdict, $style[`verdict_${verdictKind}`]]"
|
||||
>
|
||||
<N8nIcon :icon="verdictIcon" :color="verdictColor" size="medium" />
|
||||
<div :class="$style.verdictText">
|
||||
<N8nText bold size="medium" :color="verdictColor">
|
||||
{{ verdictLabel }}
|
||||
</N8nText>
|
||||
<div :class="$style.verdictStats">
|
||||
<N8nText v-if="store.durationMs !== null" size="xsmall" color="text-light" tag="span">
|
||||
{{
|
||||
i18n.baseText('scenarioRunner.verdict.duration', {
|
||||
interpolate: { s: (store.durationMs / 1000).toFixed(1) },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
<N8nText size="xsmall" color="text-xlight" tag="span">·</N8nText>
|
||||
<N8nText size="xsmall" color="text-light" tag="span">
|
||||
{{
|
||||
i18n.baseText('scenarioRunner.verdict.nodesRan', {
|
||||
interpolate: { count: orderedNodeResults.length },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
<template v-if="mockedCallCount > 0">
|
||||
<N8nText size="xsmall" color="text-xlight" tag="span">·</N8nText>
|
||||
<N8nText size="xsmall" color="text-light" tag="span">
|
||||
{{
|
||||
i18n.baseText('scenarioRunner.verdict.mockedCalls', {
|
||||
interpolate: { count: mockedCallCount },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
</template>
|
||||
<template v-if="issueCount > 0">
|
||||
<N8nText size="xsmall" color="text-xlight" tag="span">·</N8nText>
|
||||
<N8nText size="xsmall" color="warning" tag="span">
|
||||
{{
|
||||
i18n.baseText('scenarioRunner.verdict.issues', {
|
||||
interpolate: { count: issueCount },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="store.result && !store.isRunning" :class="$style.resultsBlock">
|
||||
<details v-if="issueCount > 0" :class="$style.details" :open="issueCount > 0">
|
||||
<summary :class="$style.detailsSummary">
|
||||
<N8nIcon icon="chevron-right" size="small" :class="$style.detailsChevron" />
|
||||
<N8nText bold size="small" color="text-dark">
|
||||
{{ i18n.baseText('scenarioRunner.issues.header') }}
|
||||
</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{
|
||||
i18n.baseText('scenarioRunner.issues.count', {
|
||||
interpolate: { count: issueCount },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
</summary>
|
||||
<div :class="$style.issuesList">
|
||||
<div v-for="(err, i) in errors" :key="`err-${i}`" :class="$style.issueRow">
|
||||
<N8nIcon icon="circle-x" color="danger" size="small" />
|
||||
<N8nText size="small" color="text-base">{{ err }}</N8nText>
|
||||
</div>
|
||||
<div v-for="(w, i) in warnings" :key="`warn-${i}`" :class="$style.issueRow">
|
||||
<N8nIcon icon="triangle-alert" color="warning" size="small" />
|
||||
<N8nText size="small" color="text-base">{{ w }}</N8nText>
|
||||
</div>
|
||||
<div
|
||||
v-for="{ name, result } in nodesWithConfigIssues"
|
||||
:key="`cfg-${name}`"
|
||||
:class="$style.issueRow"
|
||||
>
|
||||
<N8nIcon icon="triangle-alert" color="warning" size="small" />
|
||||
<div :class="$style.issueText">
|
||||
<N8nText size="small" color="text-base">
|
||||
{{
|
||||
i18n.baseText('scenarioRunner.issues.configPrefix', {
|
||||
interpolate: { name },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{ Object.keys(result.configIssues ?? {}).join(', ') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details :class="$style.details" open>
|
||||
<summary :class="$style.detailsSummary">
|
||||
<N8nIcon icon="chevron-right" size="small" :class="$style.detailsChevron" />
|
||||
<N8nText bold size="small" color="text-dark">
|
||||
{{ i18n.baseText('scenarioRunner.trace.header') }}
|
||||
</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{
|
||||
i18n.baseText('scenarioRunner.trace.count', {
|
||||
interpolate: { count: orderedNodeResults.length },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
</summary>
|
||||
<div :class="$style.nodesList">
|
||||
<ScenarioNodeResult
|
||||
v-for="{ name, result } in orderedNodeResults"
|
||||
:key="name"
|
||||
:node-name="name"
|
||||
:result="result"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details v-if="store.result.hints.globalContext" :class="$style.details">
|
||||
<summary :class="$style.detailsSummary">
|
||||
<N8nIcon icon="chevron-right" size="small" :class="$style.detailsChevron" />
|
||||
<N8nText bold size="small" color="text-dark">
|
||||
{{ i18n.baseText('scenarioRunner.technical.header') }}
|
||||
</N8nText>
|
||||
</summary>
|
||||
<pre :class="$style.contextText">{{ store.result.hints.globalContext }}</pre>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.verdict {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--color--background--light-2);
|
||||
border: var(--border);
|
||||
border-left-width: 3px;
|
||||
}
|
||||
|
||||
.verdict_passed {
|
||||
border-left-color: var(--color--success);
|
||||
}
|
||||
|
||||
.verdict_passedWithIssues {
|
||||
border-left-color: var(--color--warning);
|
||||
}
|
||||
|
||||
.verdict_failed {
|
||||
border-left-color: var(--color--danger);
|
||||
}
|
||||
|
||||
.verdictText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
}
|
||||
|
||||
.verdictStats {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing--3xs);
|
||||
flex-wrap: wrap;
|
||||
line-height: var(--line-height--md);
|
||||
}
|
||||
|
||||
.inputSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.inputMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.inputCollapsed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
padding: var(--spacing--2xs) var(--spacing--xs);
|
||||
background-color: var(--color--background--light-2);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.inputCollapsedLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.inputCollapsedText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-clamp: 2;
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.runningBlock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--sm);
|
||||
background-color: var(--color--background--light-2);
|
||||
border-radius: var(--radius);
|
||||
border: var(--border);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.resultsBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
overflow-y: auto;
|
||||
padding-top: var(--spacing--2xs);
|
||||
border-top: var(--border);
|
||||
}
|
||||
|
||||
.details {
|
||||
border: var(--border);
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--color--background);
|
||||
overflow: hidden;
|
||||
|
||||
&[open] .detailsChevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.detailsSummary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--2xs) var(--spacing--xs);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
|
||||
&::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color--background--light-2);
|
||||
}
|
||||
}
|
||||
|
||||
.detailsChevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.issuesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--2xs) var(--spacing--xs) var(--spacing--xs);
|
||||
border-top: var(--border);
|
||||
}
|
||||
|
||||
.issueRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.issueText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
}
|
||||
|
||||
.nodesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
padding: var(--spacing--2xs) var(--spacing--xs) var(--spacing--xs);
|
||||
border-top: var(--border);
|
||||
}
|
||||
|
||||
.contextText {
|
||||
font-size: var(--font-size--2xs);
|
||||
line-height: var(--line-height--md);
|
||||
margin: 0;
|
||||
padding: var(--spacing--2xs) var(--spacing--xs) var(--spacing--xs);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color--text);
|
||||
border-top: var(--border);
|
||||
background-color: var(--color--background--light-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.scenario-runner-dialog {
|
||||
.el-dialog__header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: var(--spacing--lg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
N8nIconButton,
|
||||
N8nInput,
|
||||
N8nRadioButtons,
|
||||
N8nSpinner,
|
||||
N8nText,
|
||||
N8nTooltip,
|
||||
type IconName,
|
||||
|
|
@ -21,7 +22,7 @@ import type {
|
|||
ScenarioExpectation,
|
||||
ScenarioRecord,
|
||||
} from '../scenarios.store';
|
||||
import ScenarioRunnerDialog from './ScenarioRunnerDialog.vue';
|
||||
import ScenarioRunResult from './ScenarioRunResult.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
workflowId: string;
|
||||
|
|
@ -40,9 +41,9 @@ const editingName = ref(false);
|
|||
// Draft for the inline "new scenario" form. Null when closed.
|
||||
const draft = ref<{ name: string; description: string } | null>(null);
|
||||
|
||||
// Dialog state — opens when user clicks Run on a scenario
|
||||
const dialogOpen = ref(false);
|
||||
const runningScenarioId = ref<string | null>(null);
|
||||
// Which scenario's result is currently held in runnerStore. Lets the detail
|
||||
// pane render the rich trace only for the scenario that produced it.
|
||||
const lastRunScenarioId = ref<string | null>(null);
|
||||
|
||||
const selectedScenario = computed(() =>
|
||||
selectedId.value ? (scenarios.value.find((s) => s.id === selectedId.value) ?? null) : null,
|
||||
|
|
@ -126,16 +127,28 @@ const expectationOptions = computed<ExpectationOption[]>(() => [
|
|||
},
|
||||
]);
|
||||
|
||||
function expectationBadge(
|
||||
expectation: ScenarioExpectation,
|
||||
): { label: string; tone: 'success' | 'danger' } | null {
|
||||
if (expectation === 'pass')
|
||||
return { label: i18n.baseText('scenarios.expectation.badge.pass'), tone: 'success' };
|
||||
if (expectation === 'fail')
|
||||
return { label: i18n.baseText('scenarios.expectation.badge.fail'), tone: 'danger' };
|
||||
return null;
|
||||
interface ScenarioGroup {
|
||||
key: ScenarioExpectation;
|
||||
labelKey: 'scenarios.group.pass' | 'scenarios.group.fail' | 'scenarios.group.any';
|
||||
items: ScenarioRecord[];
|
||||
}
|
||||
|
||||
const groupedScenarios = computed<ScenarioGroup[]>(() => {
|
||||
const groups: Record<ScenarioExpectation, ScenarioRecord[]> = {
|
||||
pass: [],
|
||||
fail: [],
|
||||
any: [],
|
||||
};
|
||||
for (const scenario of scenarios.value) {
|
||||
groups[scenario.expectedOutcome].push(scenario);
|
||||
}
|
||||
return [
|
||||
{ key: 'pass', labelKey: 'scenarios.group.pass', items: groups.pass },
|
||||
{ key: 'fail', labelKey: 'scenarios.group.fail', items: groups.fail },
|
||||
{ key: 'any', labelKey: 'scenarios.group.any', items: groups.any },
|
||||
].filter((g) => g.items.length > 0) as ScenarioGroup[];
|
||||
});
|
||||
|
||||
function commitExpectation(value: ScenarioExpectation) {
|
||||
if (!selectedScenario.value) return;
|
||||
if (value === selectedScenario.value.expectedOutcome) return;
|
||||
|
|
@ -233,40 +246,68 @@ async function deleteSelected() {
|
|||
}
|
||||
}
|
||||
|
||||
function runSelected() {
|
||||
if (!selectedScenario.value) return;
|
||||
runnerStore.reset();
|
||||
runningScenarioId.value = selectedScenario.value.id;
|
||||
// Seed the runner store's textarea with this scenario's description so the
|
||||
// dialog opens with the right text. See ScenarioRunnerDialog's open watcher.
|
||||
runnerStore.setScenarioText(selectedScenario.value.description);
|
||||
dialogOpen.value = true;
|
||||
async function runSelected() {
|
||||
if (!selectedScenario.value || runnerStore.isRunning) return;
|
||||
const scenario = selectedScenario.value;
|
||||
lastRunScenarioId.value = scenario.id;
|
||||
await runnerStore.runScenario(props.workflowId, scenario.description);
|
||||
const result = runnerStore.result;
|
||||
if (result) {
|
||||
store.persistRunResult(
|
||||
props.workflowId,
|
||||
scenario.id,
|
||||
result,
|
||||
runnerStore.durationMs ?? undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// When a run completes inside the dialog, persist the result on the scenario record.
|
||||
watch(
|
||||
() => runnerStore.status,
|
||||
(status, prev) => {
|
||||
if (!runningScenarioId.value) return;
|
||||
if (prev === 'running' && (status === 'succeeded' || status === 'failed')) {
|
||||
const result = runnerStore.result;
|
||||
if (result) {
|
||||
store.persistRunResult(
|
||||
props.workflowId,
|
||||
runningScenarioId.value,
|
||||
result,
|
||||
runnerStore.durationMs ?? undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
// True when runnerStore holds a live result for the currently-selected scenario.
|
||||
// Drives the rich inline trace. For any other scenario, the detail pane shows
|
||||
// only the summary (rich trace for historical runs is the lazy-fetch task).
|
||||
const hasLiveResult = computed(
|
||||
() =>
|
||||
runnerStore.result !== null &&
|
||||
selectedScenario.value !== null &&
|
||||
lastRunScenarioId.value === selectedScenario.value.id,
|
||||
);
|
||||
|
||||
watch(dialogOpen, (open) => {
|
||||
if (!open) {
|
||||
runningScenarioId.value = null;
|
||||
}
|
||||
const isRunningSelected = computed(
|
||||
() =>
|
||||
runnerStore.isRunning &&
|
||||
selectedScenario.value !== null &&
|
||||
lastRunScenarioId.value === selectedScenario.value.id,
|
||||
);
|
||||
|
||||
// Lazy-fetch the historical execution when selecting a scenario that hasn't
|
||||
// been run this session. Rich trace comes back degraded (no intercepted
|
||||
// requests, no hints) but node outputs + timings make it useful.
|
||||
watch(
|
||||
selectedScenario,
|
||||
async (scenario) => {
|
||||
if (!scenario?.lastExecutionId) return;
|
||||
if (hasLiveResult.value) return;
|
||||
if (store.cachedHistoricalResult(scenario.lastExecutionId)) return;
|
||||
try {
|
||||
await store.fetchHistoricalResult(scenario.lastExecutionId);
|
||||
} catch {
|
||||
// Silent — the detail pane falls back to summary-only display.
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const historicalResult = computed(() => {
|
||||
if (hasLiveResult.value) return null;
|
||||
if (!selectedScenario.value?.lastExecutionId) return null;
|
||||
return store.cachedHistoricalResult(selectedScenario.value.lastExecutionId);
|
||||
});
|
||||
|
||||
const isFetchingHistorical = computed(
|
||||
() =>
|
||||
!!selectedScenario.value?.lastExecutionId &&
|
||||
store.fetchingExecutionId === selectedScenario.value.lastExecutionId,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -341,42 +382,49 @@ watch(dialogOpen, (open) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="scenario in scenarios"
|
||||
:key="scenario.id"
|
||||
type="button"
|
||||
:class="[$style.listRow, selectedId === scenario.id && $style.listRowActive]"
|
||||
:data-test-id="`scenario-row-${scenario.id}`"
|
||||
@click="selectedId = scenario.id"
|
||||
>
|
||||
<N8nTooltip placement="top" :content="statusLabel(deriveDisplayStatus(scenario))">
|
||||
<N8nIcon
|
||||
:icon="statusIcon(deriveDisplayStatus(scenario))"
|
||||
:color="statusColor(deriveDisplayStatus(scenario))"
|
||||
size="small"
|
||||
:class="$style.listDot"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
<div :class="$style.listText">
|
||||
<div :class="$style.listTitleRow">
|
||||
<N8nText bold size="small" color="text-dark" :class="$style.listName">
|
||||
{{ scenario.name }}
|
||||
</N8nText>
|
||||
<span
|
||||
v-if="expectationBadge(scenario.expectedOutcome)"
|
||||
:class="[
|
||||
$style.listBadge,
|
||||
$style[`listBadge_${expectationBadge(scenario.expectedOutcome)!.tone}`],
|
||||
]"
|
||||
>
|
||||
{{ expectationBadge(scenario.expectedOutcome)!.label }}
|
||||
</span>
|
||||
</div>
|
||||
<N8nText size="xsmall" color="text-light" :class="$style.listPreview">
|
||||
{{ descriptionPreview(scenario) }}
|
||||
<div v-for="group in groupedScenarios" :key="group.key" :class="$style.listGroup">
|
||||
<header :class="$style.listGroupHeader">
|
||||
<N8nText
|
||||
tag="span"
|
||||
size="xsmall"
|
||||
bold
|
||||
color="text-light"
|
||||
:class="$style.listGroupLabel"
|
||||
>
|
||||
{{ i18n.baseText(group.labelKey) }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</button>
|
||||
<N8nText tag="span" size="xsmall" color="text-xlight">
|
||||
{{ group.items.length }}
|
||||
</N8nText>
|
||||
</header>
|
||||
<button
|
||||
v-for="scenario in group.items"
|
||||
:key="scenario.id"
|
||||
type="button"
|
||||
:class="[$style.listRow, selectedId === scenario.id && $style.listRowActive]"
|
||||
:data-test-id="`scenario-row-${scenario.id}`"
|
||||
@click="selectedId = scenario.id"
|
||||
>
|
||||
<N8nTooltip placement="top" :content="statusLabel(deriveDisplayStatus(scenario))">
|
||||
<N8nIcon
|
||||
:icon="statusIcon(deriveDisplayStatus(scenario))"
|
||||
:color="statusColor(deriveDisplayStatus(scenario))"
|
||||
size="small"
|
||||
:class="$style.listDot"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
<div :class="$style.listText">
|
||||
<div :class="$style.listTitleRow">
|
||||
<N8nText bold size="small" color="text-dark" :class="$style.listName">
|
||||
{{ scenario.name }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nText size="xsmall" color="text-light" :class="$style.listPreview">
|
||||
{{ descriptionPreview(scenario) }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section v-if="selectedScenario" :class="$style.detail">
|
||||
|
|
@ -468,8 +516,14 @@ watch(dialogOpen, (open) => {
|
|||
<N8nButton
|
||||
type="primary"
|
||||
size="small"
|
||||
icon="play"
|
||||
:label="i18n.baseText('scenarios.detail.run')"
|
||||
:icon="isRunningSelected ? undefined : 'play'"
|
||||
:loading="isRunningSelected"
|
||||
:disabled="runnerStore.isRunning"
|
||||
:label="
|
||||
selectedScenario.lastRunStatus
|
||||
? i18n.baseText('scenarios.detail.rerun')
|
||||
: i18n.baseText('scenarios.detail.run')
|
||||
"
|
||||
data-test-id="scenarios-run"
|
||||
@click="runSelected"
|
||||
/>
|
||||
|
|
@ -500,10 +554,44 @@ watch(dialogOpen, (open) => {
|
|||
{{ i18n.baseText('scenarios.detail.notRunYet') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<section v-if="isRunningSelected" :class="$style.runningBlock">
|
||||
<N8nSpinner size="small" />
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('scenarioRunner.running') }}
|
||||
</N8nText>
|
||||
</section>
|
||||
|
||||
<section v-if="runnerStore.errorMessage && !isRunningSelected" :class="$style.field">
|
||||
<N8nCallout theme="danger" icon="triangle-alert">
|
||||
{{ i18n.baseText('scenarioRunner.error.requestFailed') }}
|
||||
<template #trailingContent>
|
||||
<N8nText size="xsmall" color="text-base">{{ runnerStore.errorMessage }}</N8nText>
|
||||
</template>
|
||||
</N8nCallout>
|
||||
</section>
|
||||
|
||||
<ScenarioRunResult
|
||||
v-if="hasLiveResult && runnerStore.result && !isRunningSelected"
|
||||
:result="runnerStore.result"
|
||||
:duration-ms="runnerStore.durationMs"
|
||||
/>
|
||||
|
||||
<section v-else-if="isFetchingHistorical" :class="$style.runningBlock">
|
||||
<N8nSpinner size="small" />
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('scenarios.result.loadingHistorical') }}
|
||||
</N8nText>
|
||||
</section>
|
||||
|
||||
<ScenarioRunResult
|
||||
v-else-if="historicalResult"
|
||||
:result="historicalResult"
|
||||
:duration-ms="selectedScenario.lastRunDurationMs ?? null"
|
||||
degraded
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ScenarioRunnerDialog v-model="dialogOpen" :workflow-id="workflowId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -558,11 +646,29 @@ watch(dialogOpen, (open) => {
|
|||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
gap: var(--spacing--sm);
|
||||
overflow-y: auto;
|
||||
padding-right: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.listGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.listGroupHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--3xs) var(--spacing--xs);
|
||||
}
|
||||
|
||||
.listGroupLabel {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.draftCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -633,29 +739,6 @@ watch(dialogOpen, (open) => {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.listBadge {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-size--3xs);
|
||||
font-weight: var(--font-weight--bold);
|
||||
line-height: 1;
|
||||
padding: var(--spacing--5xs) var(--spacing--3xs);
|
||||
border-radius: var(--radius--sm);
|
||||
border: var(--border-width) var(--border-style) transparent;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.listBadge_success {
|
||||
color: var(--color--success--shade-1);
|
||||
background-color: var(--color--success--tint-4);
|
||||
border-color: var(--color--success--tint-2);
|
||||
}
|
||||
|
||||
.listBadge_danger {
|
||||
color: var(--color--danger--shade-1);
|
||||
background-color: var(--color--danger--tint-4);
|
||||
border-color: var(--color--danger--tint-3);
|
||||
}
|
||||
|
||||
.expectationOption {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -724,6 +807,16 @@ watch(dialogOpen, (open) => {
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.runningBlock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--sm);
|
||||
background-color: var(--color--background--light-2);
|
||||
border-radius: var(--radius);
|
||||
border: var(--border);
|
||||
}
|
||||
|
||||
.lastRun {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import type { InstanceAiEvalExecutionResult } from '@n8n/api-types';
|
||||
import type { InstanceAiEvalExecutionResult, InstanceAiEvalNodeResult } from '@n8n/api-types';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import * as scenarioRunnerApi from './scenarioRunner.api';
|
||||
|
||||
export type ScenarioRunStatus = 'passed' | 'passedWithIssues' | 'failed';
|
||||
|
|
@ -91,10 +92,18 @@ function deriveStatus(result: InstanceAiEvalExecutionResult): ScenarioRunStatus
|
|||
|
||||
export const useScenariosStore = defineStore(STORES.SCENARIOS, () => {
|
||||
const rootStore = useRootStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
const byWorkflowId = ref<StorageShape>(loadFromStorage());
|
||||
const runningScenarioId = ref<string | null>(null);
|
||||
|
||||
// In-session cache of degraded results reconstructed from persisted
|
||||
// executions. Keyed by executionId. Lost on reload — that's fine, the
|
||||
// fetch is cheap and avoids duplicating data we already persisted as
|
||||
// part of the normal execution record.
|
||||
const historicalByExecutionId = ref<Record<string, InstanceAiEvalExecutionResult>>({});
|
||||
const fetchingExecutionId = ref<string | null>(null);
|
||||
|
||||
watch(
|
||||
byWorkflowId,
|
||||
(value) => {
|
||||
|
|
@ -216,9 +225,90 @@ export const useScenariosStore = defineStore(STORES.SCENARIOS, () => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a degraded InstanceAiEvalExecutionResult from a persisted execution
|
||||
* record. Node outputs and start times come from runData; intercepted
|
||||
* requests and hints were never persisted so they come back empty (that's
|
||||
* what the `degraded` flag in ScenarioRunResult acknowledges).
|
||||
*/
|
||||
function mapExecutionToResult(
|
||||
executionId: string,
|
||||
execution: {
|
||||
data?: {
|
||||
resultData?: {
|
||||
runData?: Record<string, unknown>;
|
||||
error?: { message?: string };
|
||||
};
|
||||
};
|
||||
},
|
||||
): InstanceAiEvalExecutionResult {
|
||||
const runData =
|
||||
(execution.data?.resultData?.runData as
|
||||
| Record<string, Array<{ startTime?: number; data?: { main?: unknown[][] } }>>
|
||||
| undefined) ?? {};
|
||||
const nodeResults: Record<string, InstanceAiEvalNodeResult> = {};
|
||||
|
||||
for (const [nodeName, tasks] of Object.entries(runData)) {
|
||||
const lastTask = tasks?.[tasks.length - 1];
|
||||
const mainOutput = lastTask?.data?.main?.[0] as Array<{ json?: unknown }> | undefined;
|
||||
const items = Array.isArray(mainOutput) ? mainOutput : [];
|
||||
nodeResults[nodeName] = {
|
||||
output: items.slice(0, 10).map((i) => i?.json ?? null),
|
||||
outputCount: items.length,
|
||||
interceptedRequests: [],
|
||||
executionMode: 'mocked',
|
||||
startTime: lastTask?.startTime,
|
||||
};
|
||||
}
|
||||
|
||||
const errorMessage = execution.data?.resultData?.error?.message;
|
||||
return {
|
||||
executionId,
|
||||
success: !errorMessage,
|
||||
nodeResults,
|
||||
errors: errorMessage ? [errorMessage] : [],
|
||||
hints: {
|
||||
globalContext: '',
|
||||
triggerContent: {},
|
||||
nodeHints: {},
|
||||
warnings: [],
|
||||
bypassPinData: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchHistoricalResult(
|
||||
executionId: string,
|
||||
): Promise<InstanceAiEvalExecutionResult | null> {
|
||||
const cached = historicalByExecutionId.value[executionId];
|
||||
if (cached) return cached;
|
||||
|
||||
fetchingExecutionId.value = executionId;
|
||||
try {
|
||||
const execution = await workflowsStore.getExecution(executionId);
|
||||
if (!execution) return null;
|
||||
const mapped = mapExecutionToResult(executionId, execution);
|
||||
historicalByExecutionId.value = {
|
||||
...historicalByExecutionId.value,
|
||||
[executionId]: mapped,
|
||||
};
|
||||
return mapped;
|
||||
} finally {
|
||||
fetchingExecutionId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function cachedHistoricalResult(
|
||||
executionId: string | undefined,
|
||||
): InstanceAiEvalExecutionResult | null {
|
||||
if (!executionId) return null;
|
||||
return historicalByExecutionId.value[executionId] ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
byWorkflowId,
|
||||
runningScenarioId,
|
||||
fetchingExecutionId,
|
||||
scenariosFor,
|
||||
hasScenariosFor,
|
||||
create,
|
||||
|
|
@ -226,5 +316,7 @@ export const useScenariosStore = defineStore(STORES.SCENARIOS, () => {
|
|||
remove,
|
||||
run,
|
||||
persistRunResult,
|
||||
fetchHistoricalResult,
|
||||
cachedHistoricalResult,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user