mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
fix: workflow history (#13588)
This enhances the workflow history , stores all runs instead of just failed ones and also some UX improvements on FE --------- Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com> Co-authored-by: Yashodhan Joshi <yjdoc2@gmail.com> Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai>
This commit is contained in:
co-authored by
Yashodhan Joshi
sai nikhil kethe
parent
cc759bb6e0
commit
6f0b2dcb07
@@ -39,6 +39,7 @@ use openobserve_api_common::extractors::Headers;
|
||||
use openobserve_core::auth::UserEmail;
|
||||
use search_service::{self as SearchService, query_range::get_settings_max_query_range};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
common::{meta::http::HttpResponse as MetaHttpResponse, utils::http::get_or_create_trace_id},
|
||||
@@ -56,6 +57,7 @@ pub struct WorkflowTestInput {
|
||||
#[derive(Serialize)]
|
||||
pub struct WorkflowTestResult {
|
||||
errors: HashMap<String, NodeErrors>,
|
||||
inputs: HashMap<String, Vec<Value>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -387,7 +389,10 @@ pub async fn test_workflow(
|
||||
workflow.org_id = org_id.clone();
|
||||
workflow.id = format!("test-{}", config::ider::uuid());
|
||||
match workflows::test_workflow(&org_id, workflow, inputs.inputs, inputs.from_node).await {
|
||||
Ok(v) => MetaHttpResponse::json(WorkflowTestResult { errors: v.errors }),
|
||||
Ok(v) => MetaHttpResponse::json(WorkflowTestResult {
|
||||
errors: v.errors,
|
||||
inputs: v.inputs,
|
||||
}),
|
||||
Err(e) => MetaHttpResponse::bad_request(e),
|
||||
}
|
||||
}
|
||||
@@ -528,7 +533,10 @@ pub async fn retry_workflow(
|
||||
Json(details): Json<WorkflowRetryDetails>,
|
||||
) -> Response {
|
||||
match workflows::retry_run(&org_id, &workflow_id, &details.run_id, details.from_node).await {
|
||||
Ok(v) => MetaHttpResponse::json(WorkflowTestResult { errors: v.errors }),
|
||||
Ok(v) => MetaHttpResponse::json(WorkflowTestResult {
|
||||
errors: v.errors,
|
||||
inputs: v.inputs,
|
||||
}),
|
||||
Err(e) => MetaHttpResponse::bad_request(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ struct BatchBuffer {
|
||||
pub struct WorkflowResult {
|
||||
pub stream_details: HashMap<StreamParams, Vec<(usize, Value)>>,
|
||||
pub errors: HashMap<String, NodeErrors>,
|
||||
pub inputs: HashMap<String, Vec<Value>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
@@ -564,6 +565,7 @@ impl ExecutablePipeline {
|
||||
child_senders,
|
||||
result_sender: result_sender_cp,
|
||||
error_sender: error_sender_cp,
|
||||
inputs_sender: None, // not applicable for pipelines
|
||||
};
|
||||
let task = tokio::spawn(process_node(metadata, node, function_runtime, channels));
|
||||
node_tasks.push(task);
|
||||
@@ -775,6 +777,9 @@ impl ExecutablePipeline {
|
||||
let (error_sender, mut error_receiver) =
|
||||
channel::<(String, String, String, Option<String>, Option<Value>)>(batch_size);
|
||||
|
||||
// inputs_channel
|
||||
let (inputs_sender, mut inputs_receiver) = channel::<(String, Value)>(batch_size);
|
||||
|
||||
let mut node_senders = HashMap::new();
|
||||
let mut node_receivers = HashMap::new();
|
||||
|
||||
@@ -798,6 +803,7 @@ impl ExecutablePipeline {
|
||||
.collect();
|
||||
let result_sender_cp = node.children.is_empty().then_some(result_sender.clone());
|
||||
let error_sender_cp = error_sender.clone();
|
||||
let inputs_sender_cp = inputs_sender.clone();
|
||||
let function_runtime: Option<CompiledFunctionRuntime> =
|
||||
self.function_map.get(node_id).cloned();
|
||||
let pipeline_name = pipeline_name.clone();
|
||||
@@ -837,6 +843,7 @@ impl ExecutablePipeline {
|
||||
child_senders,
|
||||
result_sender: result_sender_cp,
|
||||
error_sender: error_sender_cp,
|
||||
inputs_sender: Some(inputs_sender_cp),
|
||||
};
|
||||
let task = tokio::spawn(process_node(metadata, node, function_runtime, channels));
|
||||
node_tasks.push(task);
|
||||
@@ -884,6 +891,14 @@ impl ExecutablePipeline {
|
||||
}
|
||||
});
|
||||
|
||||
let inputs_task = tokio::spawn(async move {
|
||||
let mut input_map = HashMap::new();
|
||||
while let Some((node_id, val)) = inputs_receiver.recv().await {
|
||||
input_map.entry(node_id).or_insert(vec![]).push(val);
|
||||
}
|
||||
input_map
|
||||
});
|
||||
|
||||
// Send records to the source node to begin processing
|
||||
let flattened = {
|
||||
let source_node = self.node_map.get(&self.source_node_id).unwrap();
|
||||
@@ -915,6 +930,7 @@ impl ExecutablePipeline {
|
||||
drop(result_sender);
|
||||
drop(error_sender);
|
||||
drop(node_senders);
|
||||
drop(inputs_sender);
|
||||
log::debug!(
|
||||
"[Workflow] {pipeline_name} [inv={inv_id}]: All records send into pipeline for processing"
|
||||
);
|
||||
@@ -939,6 +955,13 @@ impl ExecutablePipeline {
|
||||
anyhow!("[Workflow] error collecting job failed: {}", e)
|
||||
})?;
|
||||
|
||||
let inputs = inputs_task.await.map_err(|e| {
|
||||
log::error!(
|
||||
"[Workflow] {pipeline_name} [inv={inv_id}]: input collecting job failed: {e}"
|
||||
);
|
||||
anyhow!("[Workflow] input collecting job failed: {}", e)
|
||||
})?;
|
||||
|
||||
let node_errors = errors
|
||||
.as_ref()
|
||||
.map(|v| v.node_errors.clone())
|
||||
@@ -1005,6 +1028,7 @@ impl ExecutablePipeline {
|
||||
Ok(WorkflowResult {
|
||||
stream_details: results,
|
||||
errors: node_errors,
|
||||
inputs,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1152,6 +1176,24 @@ struct ProcessChannels {
|
||||
child_senders: Vec<Sender<PipelineItem>>,
|
||||
result_sender: Option<Sender<(usize, StreamParams, Value)>>,
|
||||
error_sender: Sender<(String, String, String, Option<String>, Option<Value>)>,
|
||||
inputs_sender: Option<Sender<(String, Value)>>,
|
||||
}
|
||||
|
||||
impl ProcessChannels {
|
||||
// TODO YJDoc2: eventually implement these fns for other channels as well, so we have
|
||||
// a common place for error logging and handling, instead of repeating the same code
|
||||
// in each of the node processing fns
|
||||
async fn send_input(&mut self, metadata: &ProcessMetadata, id: &str, value: &Value) {
|
||||
if let Some(ref mut channel) = self.inputs_sender
|
||||
&& let Err(e) = channel.send((id.to_string(), value.clone())).await
|
||||
{
|
||||
log::error!(
|
||||
"[Pipeline] {} [inv={}] error sending input via input channel for node {id} : {e}",
|
||||
metadata.pipeline_name,
|
||||
metadata.inv_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_node(
|
||||
@@ -1179,6 +1221,7 @@ async fn process_node(
|
||||
NodeData::WorkflowTrigger => {
|
||||
let mut count: usize = 0;
|
||||
while let Some(item) = channels.receiver.recv().await {
|
||||
channels.send_input(&metadata, &node.id, &item.record).await;
|
||||
send_to_children(&mut channels.child_senders, item, "WorkflowTrigger").await;
|
||||
count += 1;
|
||||
}
|
||||
@@ -1709,16 +1752,22 @@ async fn process_function_node(
|
||||
) -> usize {
|
||||
let mut count: usize = 0;
|
||||
let cfg = config::get_config();
|
||||
let inv_id = metadata.inv_id;
|
||||
let inv_id = metadata.inv_id.clone();
|
||||
log::debug!(
|
||||
"[Pipeline] {} [inv={inv_id}]: func node {} starts processing",
|
||||
metadata.pipeline_name,
|
||||
metadata.node_idx
|
||||
);
|
||||
let mut vrl_runtime_state = crate::ingestion::init_functions_runtime();
|
||||
let stream_name = metadata.stream_name.unwrap_or("pipeline".to_string());
|
||||
let stream_name = metadata
|
||||
.stream_name
|
||||
.clone()
|
||||
.unwrap_or("pipeline".to_string());
|
||||
let mut result_array_records = Vec::new();
|
||||
while let Some(pipeline_item) = channels.receiver.recv().await {
|
||||
channels
|
||||
.send_input(&metadata, &node.id, &pipeline_item.record)
|
||||
.await;
|
||||
let PipelineItem {
|
||||
idx,
|
||||
mut record,
|
||||
@@ -2024,7 +2073,7 @@ async fn process_condition_node(
|
||||
) -> usize {
|
||||
let mut count: usize = 0;
|
||||
let cfg = config::get_config();
|
||||
let inv_id = metadata.inv_id;
|
||||
let inv_id = metadata.inv_id.clone();
|
||||
log::debug!(
|
||||
"[Pipeline] {} [inv={inv_id}]: cond node {} starts processing",
|
||||
metadata.pipeline_name,
|
||||
@@ -2032,6 +2081,9 @@ async fn process_condition_node(
|
||||
);
|
||||
let mut total_received: usize = 0;
|
||||
while let Some(pipeline_item) = channels.receiver.recv().await {
|
||||
channels
|
||||
.send_input(&metadata, &node.id, &pipeline_item.record)
|
||||
.await;
|
||||
total_received += 1;
|
||||
let PipelineItem {
|
||||
idx,
|
||||
@@ -2344,6 +2396,9 @@ async fn process_destination_node(
|
||||
|
||||
let mut data = Vec::new();
|
||||
while let Some(pipeline_item) = channels.receiver.recv().await {
|
||||
channels
|
||||
.send_input(&metadata, &node.id, &pipeline_item.record)
|
||||
.await;
|
||||
data.push(std::sync::Arc::new(pipeline_item.record));
|
||||
}
|
||||
|
||||
@@ -2750,6 +2805,7 @@ mod tests {
|
||||
child_senders: vec![child_tx],
|
||||
result_sender: None,
|
||||
error_sender: error_tx,
|
||||
inputs_sender: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -41,8 +41,10 @@ use crate::{
|
||||
pub mod runtime;
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct InputMap {
|
||||
complete: Vec<Value>,
|
||||
node_map: HashMap<String, Vec<Value>>,
|
||||
// even though a bad naming convention, node_map is
|
||||
error_node_map: HashMap<String, Vec<Value>>,
|
||||
#[serde(default)]
|
||||
input_map: HashMap<String, Vec<Value>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -453,7 +455,6 @@ async fn execute_workflow(
|
||||
let executable = ExecutablePipeline::new_from_workflow(&workflow).await?;
|
||||
|
||||
let now = chrono::Utc::now().timestamp_micros();
|
||||
let input_copy = inputs.clone();
|
||||
let res = executable.process_workflow(org_id, inputs, None).await?;
|
||||
|
||||
let mut errored_input_map = HashMap::new();
|
||||
@@ -498,33 +499,41 @@ async fn execute_workflow(
|
||||
}
|
||||
}
|
||||
|
||||
if !workflow_errors.is_empty() {
|
||||
let ip_map = InputMap {
|
||||
complete: input_copy,
|
||||
node_map: errored_input_map,
|
||||
};
|
||||
let ip_map = InputMap {
|
||||
error_node_map: errored_input_map,
|
||||
input_map: res.inputs,
|
||||
};
|
||||
|
||||
let errors = WorkflowRunErrors {
|
||||
org_id: org_id.to_string(),
|
||||
cluster: config::get_cluster_name(),
|
||||
id: 0, // will be set directly in db
|
||||
workflow_id: id.to_string(),
|
||||
run_id: run_id.to_string(),
|
||||
ran_at: now,
|
||||
data: workflow_errors,
|
||||
input_data: Some(serde_json::to_string(&ip_map).unwrap()),
|
||||
};
|
||||
// workflow has already run, so not much point in returning error because
|
||||
// we couldn't save the errors to db, log and ignore
|
||||
if let Err(e) = db::workflows::save_workflow_errors(errors).await {
|
||||
log::error!(
|
||||
"[Workflows] : error saving workflow run errors for run id {run_id} for workflow {org_id}/{id} in db : {e}"
|
||||
);
|
||||
}
|
||||
return Ok(WorkflowExecutionStatus::Errored);
|
||||
// if this is not empty, then some node errored
|
||||
let errored = !workflow_errors.is_empty();
|
||||
|
||||
// we hijack the workflow run errors to store both the error as well as execution history map
|
||||
// as both have essentially the same structure and no point in duplicating tables and adding
|
||||
// migration true error v/s just run can be distinguished by workflow_errors is empty or
|
||||
// not.
|
||||
let errors = WorkflowRunErrors {
|
||||
org_id: org_id.to_string(),
|
||||
cluster: config::get_cluster_name(),
|
||||
id: 0, // will be set directly in db
|
||||
workflow_id: id.to_string(),
|
||||
run_id: run_id.to_string(),
|
||||
ran_at: now,
|
||||
data: workflow_errors,
|
||||
input_data: Some(serde_json::to_string(&ip_map).unwrap()),
|
||||
};
|
||||
// workflow has already run, so not much point in returning error because
|
||||
// we couldn't save the errors to db, log and ignore
|
||||
if let Err(e) = db::workflows::save_workflow_errors(errors).await {
|
||||
log::error!(
|
||||
"[Workflows] : error saving workflow run errors for run id {run_id} for workflow {org_id}/{id} in db : {e}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(WorkflowExecutionStatus::Success)
|
||||
if errored {
|
||||
Ok(WorkflowExecutionStatus::Errored)
|
||||
} else {
|
||||
Ok(WorkflowExecutionStatus::Success)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_workflow_errors(
|
||||
@@ -545,6 +554,21 @@ pub async fn retry_run(
|
||||
let workflow = workflows::get_by_org_wid(org_id, wid)
|
||||
.await?
|
||||
.ok_or(anyhow::anyhow!("workflow with given id not found"))?;
|
||||
|
||||
let mut start_id = None;
|
||||
for node in &workflow.nodes {
|
||||
if matches!(node.data, NodeData::WorkflowTrigger) {
|
||||
start_id = Some(node.id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(start_id) = start_id else {
|
||||
log::error!(
|
||||
"missing workflow trigger node in workflow {org_id}/{wid} for retry run {run_id}"
|
||||
);
|
||||
return Err(anyhow::anyhow!("workflow trigger node missing in workflow"));
|
||||
};
|
||||
|
||||
let executable = ExecutablePipeline::new_from_workflow(&workflow).await?;
|
||||
|
||||
let errors = match workflows::list_errors_for_workflow_run(org_id, wid, run_id).await {
|
||||
@@ -577,12 +601,11 @@ pub async fn retry_run(
|
||||
anyhow::anyhow!("error deserializing inputs : {e}")
|
||||
})?;
|
||||
|
||||
let inputs = match from_node.as_ref() {
|
||||
Some(node) => ip_map.node_map.remove(node).ok_or(anyhow::anyhow!(
|
||||
"node id {node} does not have any associated input data in the stored inputs"
|
||||
))?,
|
||||
None => ip_map.complete,
|
||||
};
|
||||
let node_id = from_node.as_ref().unwrap_or(&start_id);
|
||||
|
||||
let inputs = ip_map.input_map.remove(node_id).ok_or(anyhow::anyhow!(
|
||||
"node id {node_id} does not have any associated input data in the stored inputs"
|
||||
))?;
|
||||
|
||||
let res = executable
|
||||
.process_workflow(org_id, inputs, from_node)
|
||||
|
||||
@@ -50,6 +50,7 @@ function createWrapper(props: Record<string, any> = {}) {
|
||||
OSwitch: OSwitchStub,
|
||||
CreateDestinationForm: {
|
||||
name: "CreateDestinationForm",
|
||||
props: ["forcedType"],
|
||||
emits: ["created", "cancel"],
|
||||
template: '<div class="create-destination-stub" />',
|
||||
},
|
||||
@@ -203,6 +204,15 @@ describe("DestinationPicker", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards forcedType to the inline create form", async () => {
|
||||
const wrapper = createWrapper({ forcedType: "custom" });
|
||||
await flushPromises();
|
||||
await wrapper.find(".o-switch").trigger("click"); // into create mode
|
||||
expect(wrapper.findComponent({ name: "CreateDestinationForm" }).props("forcedType")).toBe(
|
||||
"custom",
|
||||
);
|
||||
});
|
||||
|
||||
it("refetches when toggling out of create mode", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
|
||||
@@ -41,6 +41,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<!-- inline create destination form (own save/cancel) -->
|
||||
<div v-if="createNewDestination" class="w-full">
|
||||
<CreateDestinationForm
|
||||
:forced-type="forcedType"
|
||||
@created="onDestinationCreated"
|
||||
@cancel="createNewDestination = false"
|
||||
/>
|
||||
@@ -76,8 +77,11 @@ import {
|
||||
type ExternalDestinationForm,
|
||||
} from "@/components/pipeline/NodeForm/ExternalDestination.schema";
|
||||
|
||||
const props = withDefaults(defineProps<{ initialName?: string }>(), {
|
||||
// `forcedType`, when set, is forwarded to the inline create form to lock its
|
||||
// destination type and skip the type-selection step (workflows → "custom").
|
||||
const props = withDefaults(defineProps<{ initialName?: string; forcedType?: string }>(), {
|
||||
initialName: "",
|
||||
forcedType: undefined,
|
||||
});
|
||||
const emit = defineEmits<{ (e: "expand", value: boolean): void }>();
|
||||
|
||||
|
||||
@@ -541,6 +541,56 @@ describe("CreateDestinationForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe("forcedType (skip step 1, locked type, no going back)", () => {
|
||||
it("opens directly on step 2, locked to the forced type", () => {
|
||||
const w = createWrapper({ forcedType: "custom" });
|
||||
expect(w.vm.step).toBe(2);
|
||||
expect(w.vm.destinationType).toBe("custom");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("seeds the create defaults for the forced type", () => {
|
||||
const w = createWrapper({ forcedType: "custom" });
|
||||
const v = w.vm.form.state.values;
|
||||
expect(v.destination_type).toBe("custom");
|
||||
expect(v.method).toBe("post");
|
||||
expect(v.output_format).toBe("json");
|
||||
expect(v.url_endpoint).toBe("");
|
||||
expect(v.headers).toEqual([{ key: "", value: "" }]);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("hides the Back button (there's no step 1 to return to)", () => {
|
||||
const w = createWrapper({ forcedType: "custom" });
|
||||
expect(w.find('[data-test="step3-back-btn"]').exists()).toBe(false);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("hides the stepper header (single-step flow)", () => {
|
||||
const forced = createWrapper({ forcedType: "custom" });
|
||||
expect(forced.findComponent({ name: "OStepper" }).props("hideHeader")).toBe(true);
|
||||
forced.unmount();
|
||||
// ...and the default (no forcedType) keeps the header.
|
||||
expect(wrapper.findComponent({ name: "OStepper" }).props("hideHeader")).toBe(false);
|
||||
});
|
||||
|
||||
it("resetForm keeps it on step 2 when the type is forced", async () => {
|
||||
const w = createWrapper({ forcedType: "custom" });
|
||||
w.vm.resetForm();
|
||||
await nextTick();
|
||||
expect(w.vm.step).toBe(2);
|
||||
expect(w.vm.form.state.values.destination_type).toBe("custom");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("without forcedType, the Back button and step 1 remain (unchanged default)", () => {
|
||||
// default wrapper has no forcedType
|
||||
expect(wrapper.vm.step).toBe(1);
|
||||
expect(wrapper.find('[data-test="destination-type-card-openobserve"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe("canProceedStep2 Validation", () => {
|
||||
it("is true for valid openobserve form data", async () => {
|
||||
|
||||
@@ -28,9 +28,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
footer below stays full-width so its top border lines up with the
|
||||
full-width page header (matching the other destination forms). -->
|
||||
<div class="w-full max-w-[50vw]">
|
||||
<OStepper v-model="step" ref="stepper" animated>
|
||||
<!-- Step 1: Choose Destination Type -->
|
||||
<OStep :name="1" title="Choose Type" icon="edit" :done="step > 1" :navigable="step > 1">
|
||||
<!-- When the type is forced (step 1 skipped), the stepper is effectively a
|
||||
single step, so hide its header — the progress bar would just be noise. -->
|
||||
<OStepper v-model="step" ref="stepper" animated :hide-header="!!forcedType">
|
||||
<!-- Step 1: Choose Destination Type. When the type is forced, it stays in
|
||||
the header as a done step but is NOT navigable — there's no going back. -->
|
||||
<OStep
|
||||
:name="1"
|
||||
title="Choose Type"
|
||||
icon="edit"
|
||||
:done="step > 1"
|
||||
:navigable="forcedType ? false : step > 1"
|
||||
>
|
||||
<div class="mb-3 text-sm font-medium">
|
||||
Select Destination Type <span class="text-status-error-text">*</span>
|
||||
</div>
|
||||
@@ -382,6 +391,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
<div v-if="step > 1" class="flex gap-2">
|
||||
<OButton
|
||||
v-if="!forcedType"
|
||||
data-test="step3-back-btn"
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
@@ -438,6 +448,11 @@ import { makeDestinationSchema, type DestinationForm } from "./CreateDestination
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
destination?: DestinationData | null;
|
||||
// When set, the type-selection step (step 1) is skipped: the form opens directly
|
||||
// on the Connection step locked to this destination type, and there's no way back
|
||||
// to step 1. Used by the workflow destination node, which only supports Custom
|
||||
// HTTP destinations for now.
|
||||
forcedType?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["created", "updated", "cancel"]);
|
||||
@@ -506,7 +521,9 @@ const destinationTypes = [
|
||||
},
|
||||
];
|
||||
|
||||
const step = ref(1);
|
||||
// Skip straight to the Connection step when the type is forced (step 1 is the type
|
||||
// picker, which is bypassed).
|
||||
const step = ref(props.forcedType ? 2 : 1);
|
||||
|
||||
// A single Headers row. Matches the schema's `headerRowSchema` ({ key, value }).
|
||||
// No `uuid` — the dynamic array-field keys rows by index and add/remove operate
|
||||
@@ -588,26 +605,27 @@ const endpointForType = (type: string): string => {
|
||||
// defaulting) run in a single `{ flush: "sync" }` watch on the form's
|
||||
// destination_type. Edit-prefill seeds the form via `form.reset(record)`.
|
||||
|
||||
// Build the create-mode default record. OpenObserve is the default type, so seed
|
||||
// the OpenObserve endpoint + default headers.
|
||||
const buildCreateDefaults = (): DestinationForm => ({
|
||||
name: "",
|
||||
url: "",
|
||||
skip_tls_verify: false,
|
||||
headers: getDefaultHeaders("openobserve").map((h) => ({
|
||||
key: h.key,
|
||||
value: h.value,
|
||||
})),
|
||||
metadata: {},
|
||||
destination_type: "openobserve",
|
||||
url_endpoint: "/api/default/default/_json", // Default endpoint for OpenObserve
|
||||
method: "post",
|
||||
output_format: "json",
|
||||
esbulk_index: "",
|
||||
separator: "",
|
||||
org: "default",
|
||||
stream: "default",
|
||||
});
|
||||
// Build the create-mode default record. Defaults to OpenObserve, unless a
|
||||
// `forcedType` is supplied (workflow node → "custom"), in which case the record is
|
||||
// seeded for that type instead — so the skipped step-1 selection is still applied.
|
||||
const buildCreateDefaults = (): DestinationForm => {
|
||||
const type = props.forcedType ?? "openobserve";
|
||||
return {
|
||||
name: "",
|
||||
url: "",
|
||||
skip_tls_verify: false,
|
||||
headers: getDefaultHeaders(type).map((h) => ({ key: h.key, value: h.value })),
|
||||
metadata: {},
|
||||
destination_type: type,
|
||||
url_endpoint: type === "openobserve" ? "/api/default/default/_json" : endpointForType(type),
|
||||
method: "post",
|
||||
output_format: type === "splunk" ? "nestedevent" : type === "elasticsearch" ? "esbulk" : "json",
|
||||
esbulk_index: type === "elasticsearch" ? "default" : "",
|
||||
separator: "",
|
||||
org: "default",
|
||||
stream: "default",
|
||||
};
|
||||
};
|
||||
|
||||
const form = useOForm<DestinationForm>({
|
||||
defaultValues: buildCreateDefaults(),
|
||||
@@ -1200,7 +1218,7 @@ const deleteApiHeader = (index: number) => {
|
||||
// field, including the default headers.
|
||||
const resetForm = () => {
|
||||
form.reset(buildCreateDefaults());
|
||||
step.value = 1;
|
||||
step.value = props.forcedType ? 2 : 1;
|
||||
};
|
||||
|
||||
// Expose functions for testing. `form` is the single source of truth — tests
|
||||
|
||||
@@ -256,7 +256,7 @@ describe("WorkflowEditor", () => {
|
||||
expect(workflowObj.dialog.show).toBe(false);
|
||||
});
|
||||
|
||||
it("adds the picked trigger to the canvas and opens its panel", async () => {
|
||||
it("adds the picked trigger to the canvas WITHOUT auto-opening its panel", async () => {
|
||||
wrapper = mountEditor();
|
||||
await flushPromises();
|
||||
|
||||
@@ -274,17 +274,16 @@ describe("WorkflowEditor", () => {
|
||||
.vm.$emit("pick", { key: "workflow_trigger", trigger_kind: "alert_fired" });
|
||||
await nextTick();
|
||||
|
||||
// Committed straight onto the canvas — the trigger panel is read-only and
|
||||
// has no Save, so a staged node could never be committed.
|
||||
// Committed straight onto the canvas.
|
||||
expect(wf().nodes).toHaveLength(1);
|
||||
expect(wf().nodes[0]).toMatchObject({
|
||||
type: "input",
|
||||
position: { x: 100, y: 40 },
|
||||
data: { node_type: "workflow_trigger", trigger_kind: "alert_fired", alert_ids: [] },
|
||||
});
|
||||
// ...and its panel opens in EDIT mode, so closing it keeps the node.
|
||||
expect(workflowObj.isEditNode).toBe(true);
|
||||
expect(workflowObj.dialog.show).toBe(true);
|
||||
// ...but its read-only detail panel does NOT auto-open — placing the trigger
|
||||
// shouldn't interrupt the flow. The user clicks the node to open it.
|
||||
expect(workflowObj.dialog.show).toBe(false);
|
||||
expect(workflowObj.stepPicker.show).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ const OButtonStub = {
|
||||
name: "OButton",
|
||||
props: ["variant"],
|
||||
emits: ["click"],
|
||||
template: `<button class="o-btn" @click="$emit('click')"><slot /></button>`,
|
||||
template: `<button class="o-btn" v-bind="$attrs" @click="$emit('click')"><slot /></button>`,
|
||||
};
|
||||
const RunsPanelStub = {
|
||||
name: "WorkflowRunsPanel",
|
||||
@@ -93,6 +93,7 @@ const globalCfg = {
|
||||
WorkflowRunsPanel: RunsPanelStub,
|
||||
WorkflowCanvas: stub("WorkflowCanvas"),
|
||||
WorkflowStepResultDrawer: stub("WorkflowStepResultDrawer"),
|
||||
WorkflowTestDialog: stub("WorkflowTestDialog"),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -190,15 +191,31 @@ describe("WorkflowRuns", () => {
|
||||
expect(panel(wrapper).props("selectedRunId")).toBe("");
|
||||
});
|
||||
|
||||
it("Edit Workflow navigates to the editor (edit stays separate from inspect)", async () => {
|
||||
it("Edit Workflow navigates to the editor (build stays separate from inspect)", async () => {
|
||||
const wrapper = await mountRuns();
|
||||
await wrapper.find(".o-btn").trigger("click");
|
||||
await wrapper.find('[data-test="workflow-runs-edit"]').trigger("click");
|
||||
expect(mockRouter.push).toHaveBeenCalledWith({
|
||||
name: "workflowEditor",
|
||||
query: { id: "wf-1", name: "my flow", org_identifier: "default" },
|
||||
});
|
||||
});
|
||||
|
||||
it("Test opens the dry-run dialog here and deselects the historical run", async () => {
|
||||
const wrapper = await mountRuns();
|
||||
// Select a run first, so we can prove Test deselects it.
|
||||
panel(wrapper).vm.$emit("select-run", "run-5");
|
||||
await flushPromises();
|
||||
expect(panel(wrapper).props("selectedRunId")).toBe("run-5");
|
||||
|
||||
workflowObj.testRun.show = false;
|
||||
await wrapper.find('[data-test="workflow-runs-test"]').trigger("click");
|
||||
|
||||
expect(workflowObj.testRun.show).toBe(true);
|
||||
expect(panel(wrapper).props("selectedRunId")).toBe("");
|
||||
// Test never navigates away — it stays on the runs page.
|
||||
expect(mockRouter.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("back returns to the workflows list", async () => {
|
||||
const wrapper = await mountRuns();
|
||||
await wrapper.find(".back-btn").trigger("click");
|
||||
|
||||
@@ -27,8 +27,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
└──────────────────────────────┴──────────────────────┘
|
||||
|
||||
Selecting a run loads it onto the canvas (read-only) and updates ?run_id, so a
|
||||
specific run is shareable. "Edit Workflow" is the deliberate switch to the
|
||||
editor — the two surfaces never blur together, and nothing resizes mid-flow.
|
||||
specific run is shareable. "Edit Workflow" is the deliberate switch to the build
|
||||
canvas. Test lives here too — it's a read-only dry-run (never edits the graph),
|
||||
so there's no reason to force a trip to the editor just to run one; a test simply
|
||||
swaps the canvas from the selected historical run to the fresh test result.
|
||||
-->
|
||||
<template>
|
||||
<div data-test="workflow-runs-page" class="flex h-full min-h-0 flex-col">
|
||||
@@ -50,6 +52,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<!-- Test is a read-only dry-run (it never edits the workflow), so it's
|
||||
offered here too — no need to switch to the editor just to test. -->
|
||||
<OButton variant="outline" data-test="workflow-runs-test" @click="onTest">
|
||||
{{ t("workflow.test.button") }}
|
||||
</OButton>
|
||||
<OButton variant="outline" data-test="workflow-runs-edit" @click="onEditWorkflow">
|
||||
{{ t("workflow.runs.edit") }}
|
||||
</OButton>
|
||||
@@ -76,14 +83,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-step Input/Output result drawer — opened by clicking an error node's
|
||||
badge. Read-only (no Replay on a historical run). -->
|
||||
<!-- Test input popup — a fresh dry-run of the current graph, launched from the
|
||||
header. Results paint on the read-only canvas (switching it out of the
|
||||
selected historical run). -->
|
||||
<WorkflowTestDialog v-if="workflowObj.testRun.show" />
|
||||
|
||||
<!-- Per-step Input/Output result drawer — opened by clicking a node's badge.
|
||||
Read-only for a historical run; editable + replayable for a live test. -->
|
||||
<WorkflowStepResultDrawer v-if="workflowObj.testRun.resultDrawer.show" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
@@ -95,6 +107,7 @@ import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
|
||||
import WorkflowCanvas from "@/plugins/workflows/WorkflowCanvas.vue";
|
||||
import WorkflowStepResultDrawer from "./WorkflowStepResultDrawer.vue";
|
||||
import WorkflowTestDialog from "./WorkflowTestDialog.vue";
|
||||
import WorkflowRunsPanel from "./WorkflowRunsPanel.vue";
|
||||
import useWorkflowCanvas, {
|
||||
workflowObj,
|
||||
@@ -125,6 +138,26 @@ const goBack = () => {
|
||||
router.push({ name: "workflows", query: { org_identifier: orgId.value } });
|
||||
};
|
||||
|
||||
// Dry-run the current graph without leaving to the editor. Deselect the historical
|
||||
// run first — the canvas is about to show the LIVE test result instead, and a
|
||||
// lingering row highlight would misrepresent what's on the canvas. Clicking a run
|
||||
// in the list again reloads history, toggling back.
|
||||
const onTest = () => {
|
||||
selectedRunId.value = "";
|
||||
workflowObj.testRun.show = true;
|
||||
};
|
||||
|
||||
// A live test result (from the header Test button, or a step drawer's "Use as Test
|
||||
// Input" which drops the user into a fresh test) replaces the historical run on the
|
||||
// canvas. Deselect the run so the list stops highlighting a row that no longer
|
||||
// matches what's shown. A history load keeps `mode: "history"`, so it's left alone.
|
||||
watch(
|
||||
() => workflowObj.testRun.result,
|
||||
(result: any) => {
|
||||
if (result && result.mode !== "history") selectedRunId.value = "";
|
||||
},
|
||||
);
|
||||
|
||||
// Deliberate switch to the editor — the only bridge between inspect and build.
|
||||
const onEditWorkflow = () => {
|
||||
router.push({
|
||||
|
||||
@@ -68,6 +68,22 @@ const stub = (name: string, props: string[] = []) => ({
|
||||
template: `<div class="${name}" />`,
|
||||
});
|
||||
|
||||
// The real DateTime picker emits `on:date-change` on mount with the initial range —
|
||||
// the panel's FIRST fetch rides on that emit (NOT an immediate watch, which would
|
||||
// double-fetch). Mirror it so the mount-fetch fires in tests.
|
||||
const DateTimeStub = {
|
||||
name: "DateTime",
|
||||
emits: ["on:date-change"],
|
||||
template: `<div class="DateTime" />`,
|
||||
mounted() {
|
||||
(this as any).$emit("on:date-change", {
|
||||
startTime: 1000,
|
||||
endTime: 2000,
|
||||
relativeTimePeriod: "15m",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import i18n from "@/locales";
|
||||
@@ -82,7 +98,7 @@ const globalCfg = {
|
||||
OBadge: stub("OBadge", ["variant", "size"]),
|
||||
OButton: stub("OButton", ["variant", "size", "iconLeft", "loading"]),
|
||||
OTooltip: stub("OTooltip", ["side", "content"]),
|
||||
DateTime: stub("DateTime"),
|
||||
DateTime: DateTimeStub,
|
||||
WorkflowExecutionTimeline: stub("WorkflowExecutionTimeline", [
|
||||
"history",
|
||||
"firingLabel",
|
||||
@@ -111,6 +127,14 @@ describe("WorkflowRunsPanel", () => {
|
||||
expect(wrapper.findComponent(OTableStub as any).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("fetches history EXACTLY ONCE on mount (no immediate-watch double-fetch)", async () => {
|
||||
mockList.mockResolvedValue({ data: [] });
|
||||
mountPanel();
|
||||
await flushPromises();
|
||||
// Only the DateTime picker's on-mount emit fetches — not a second immediate watch.
|
||||
expect(mockList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("emits select-run with the clicked run's id", async () => {
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
@@ -343,13 +343,14 @@ const openRun = (row: any) => {
|
||||
emit("select-run", row.run_id);
|
||||
};
|
||||
|
||||
// Fetch on mount and whenever the workflow changes.
|
||||
// Refetch when the workflow changes. The INITIAL fetch is NOT triggered here — the
|
||||
// DateTime picker emits `@on:date-change` on mount (→ updateDateTime → fetchHistory)
|
||||
// with the actual displayed range, so an `immediate` watch would double-fetch.
|
||||
watch(
|
||||
() => props.workflowId,
|
||||
(id) => {
|
||||
if (id) fetchHistory();
|
||||
(id, prev) => {
|
||||
if (id && id !== prev) fetchHistory();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
defineExpose({ fetchHistory });
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// WorkflowStepResultDrawer — the "Test Step Result" drawer. By design it only
|
||||
// opens for ERROR nodes: Input = the CENTRAL testRun.input (same payload the
|
||||
// Test dialog edits), Output = the NodeErrors messages (the backend returns no
|
||||
// per-node node_io), plus Replay (executeTestRun with fromNode). A history run
|
||||
// is read-only: per-node captured input, no Replay.
|
||||
// WorkflowStepResultDrawer — the "Test Step Result" drawer. Opens for ANY node
|
||||
// after a Test run. Input = the records THIS node received (from the backend
|
||||
// per-node `inputs` map), editable + replayable. Output = what it emitted, derived
|
||||
// per outgoing edge (child input == parent output on a single-incoming tree),
|
||||
// rendered in a read-only editor. Errored nodes show the error AND what they still
|
||||
// forwarded. Status badge = Passed / Errored / No Records. "Use as Test Input"
|
||||
// promotes a pane's records to the whole-flow payload. History runs are read-only.
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
@@ -25,6 +27,8 @@ import { nextTick } from "vue";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
|
||||
const t = (k: string, p?: any) => i18n.global.t(k, p);
|
||||
|
||||
const mockToast = vi.fn();
|
||||
vi.mock("@/lib/feedback/Toast/useToast", () => ({
|
||||
toast: (...a: any[]) => mockToast(...a),
|
||||
@@ -65,7 +69,6 @@ const ODrawerStub = {
|
||||
};
|
||||
const OButtonStub = {
|
||||
name: "OButton",
|
||||
// `title` is intentionally NOT declared so it falls through to the button.
|
||||
props: ["variant", "size", "disabled", "loading"],
|
||||
template: `<button :disabled="disabled" :data-loading="String(!!loading)"><slot /></button>`,
|
||||
};
|
||||
@@ -88,7 +91,7 @@ const CodeQueryEditorStub = {
|
||||
name: "CodeQueryEditor",
|
||||
props: ["editorId", "language", "query", "readOnly", "showAutoComplete"],
|
||||
emits: ["update:query"],
|
||||
template: `<div class="code-editor" :data-readonly="String(!!readOnly)">{{ query }}</div>`,
|
||||
template: `<div class="code-editor" :data-editor="editorId" :data-readonly="String(!!readOnly)">{{ query }}</div>`,
|
||||
};
|
||||
|
||||
const globalConfig = {
|
||||
@@ -103,45 +106,50 @@ const globalConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
// trigger(t1) -> function(f1) -> destination(d1)
|
||||
const NODES = [
|
||||
{ id: "t1", data: { node_type: "workflow_trigger" } },
|
||||
{ id: "f1", data: { node_type: "function", name: "parse_json" } },
|
||||
{ id: "d1", data: { node_type: "destination", destination_id: "sink-a" } },
|
||||
];
|
||||
const EDGES = [
|
||||
{ source: "t1", target: "f1" },
|
||||
{ source: "f1", target: "d1" },
|
||||
];
|
||||
|
||||
const VALID_INPUT = '[{"meta":{"alert_name":"a"},"data":[{"x":1}]}]';
|
||||
// A trigger-shape record (nested { meta, data }).
|
||||
const rec = (x: number) => ({ meta: { alert_name: "a" }, data: [{ x }] });
|
||||
|
||||
const errorResult = (extra: Record<string, any> = {}) => ({
|
||||
errors: {
|
||||
f1: { error_count: 2, errors: [["boom", { x: 1 }], ["bad record"]] },
|
||||
},
|
||||
// Every node received records; nothing errored.
|
||||
const okResult = () => ({
|
||||
errors: {},
|
||||
inputs: { t1: [rec(1)], f1: [rec(2)], d1: [rec(3)] },
|
||||
ranNodeIds: ["t1", "f1", "d1"],
|
||||
blockedNodeIds: [],
|
||||
});
|
||||
|
||||
// f1 errored but still forwarded records to d1.
|
||||
const errorResult = () => ({
|
||||
errors: { f1: { error_count: 2, errors: [["boom", [{ x: 1 }]], ["bad record"]] } },
|
||||
inputs: { t1: [rec(1)], f1: [rec(2)], d1: [rec(3)] },
|
||||
ranNodeIds: ["t1", "f1", "d1"],
|
||||
blockedNodeIds: ["d1"],
|
||||
...extra,
|
||||
});
|
||||
|
||||
const setup = (
|
||||
opts: {
|
||||
nodeId?: string;
|
||||
input?: string;
|
||||
result?: any;
|
||||
nodes?: any[];
|
||||
} = {},
|
||||
opts: { nodeId?: string; input?: string; result?: any; nodes?: any[]; edges?: any[] } = {},
|
||||
) => {
|
||||
workflowObj.currentSelectedWorkflow = {
|
||||
id: "wf1",
|
||||
name: "wf",
|
||||
nodes: opts.nodes ?? NODES,
|
||||
edges: [
|
||||
{ source: "t1", target: "f1" },
|
||||
{ source: "f1", target: "d1" },
|
||||
],
|
||||
edges: opts.edges ?? EDGES,
|
||||
} as any;
|
||||
workflowObj.testRun = {
|
||||
show: false,
|
||||
input: opts.input ?? VALID_INPUT,
|
||||
input: opts.input ?? "",
|
||||
fromNode: "",
|
||||
result: opts.result === undefined ? errorResult() : opts.result,
|
||||
result: opts.result === undefined ? okResult() : opts.result,
|
||||
resultDrawer: { show: true, nodeId: opts.nodeId ?? "f1" },
|
||||
} as any;
|
||||
};
|
||||
@@ -149,9 +157,17 @@ const setup = (
|
||||
const mountDrawer = () =>
|
||||
mount(WorkflowStepResultDrawer, { global: globalConfig, attachTo: document.body });
|
||||
|
||||
const editorVm = (w: any) => w.findComponent(CodeQueryEditorStub as any);
|
||||
const editors = (w: any) => w.findAllComponents(CodeQueryEditorStub as any);
|
||||
const inputEditor = (w: any) =>
|
||||
editors(w).find((e: any) => e.props("editorId") === "workflow-step-input");
|
||||
const outputEditor = (w: any) =>
|
||||
editors(w).find((e: any) => e.props("editorId") === "workflow-step-output");
|
||||
const statusBadge = (w: any) => w.find('[data-test="workflow-step-result-status"]');
|
||||
const replayBtn = (w: any) => w.find('[data-test="workflow-step-replay-btn"]');
|
||||
const iconButtons = (w: any) => w.findAll(".o-drawer button");
|
||||
const useInputBtn = (w: any) => w.find('[data-test="workflow-step-use-input-as-test"]');
|
||||
const useOutputBtn = (w: any) => w.find('[data-test="workflow-step-use-output-as-test"]');
|
||||
const tooltipFor = (w: any, content: string) =>
|
||||
w.findAll(".o-tooltip").find((tt: any) => tt.attributes("data-content") === content);
|
||||
|
||||
describe("WorkflowStepResultDrawer", () => {
|
||||
beforeEach(() => {
|
||||
@@ -160,10 +176,9 @@ describe("WorkflowStepResultDrawer", () => {
|
||||
});
|
||||
|
||||
describe("title", () => {
|
||||
it("shows '<Type> - <detail>' for the errored node", () => {
|
||||
const wrapper = mountDrawer();
|
||||
expect(wrapper.find(".o-drawer").attributes("data-title")).toBe(
|
||||
`${i18n.global.t("workflow.node.function")} - parse_json`,
|
||||
it("shows '<Type> - <detail>' for the node", () => {
|
||||
expect(mountDrawer().find(".o-drawer").attributes("data-title")).toBe(
|
||||
`${t("workflow.node.function")} - parse_json`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -173,212 +188,307 @@ describe("WorkflowStepResultDrawer", () => {
|
||||
nodes: [
|
||||
{
|
||||
id: "f1",
|
||||
data: {
|
||||
node_type: "function",
|
||||
name: "a_really_long_vrl_function_name_here",
|
||||
},
|
||||
data: { node_type: "function", name: "a_really_long_vrl_function_name_here" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mountDrawer();
|
||||
const title = wrapper.find(".o-drawer").attributes("data-title")!;
|
||||
expect(title).toHaveLength(31); // 30 chars + the ellipsis
|
||||
const title = mountDrawer().find(".o-drawer").attributes("data-title")!;
|
||||
expect(title).toHaveLength(31);
|
||||
expect(title.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the raw node_type when it is not in the palette", () => {
|
||||
setup({ nodeId: "x1", nodes: [{ id: "x1", data: { node_type: "mystery" } }] });
|
||||
const wrapper = mountDrawer();
|
||||
expect(wrapper.find(".o-drawer").attributes("data-title")).toBe("mystery");
|
||||
setup({ nodeId: "x1", nodes: [{ id: "x1", data: { node_type: "mystery" } }], edges: [] });
|
||||
expect(mountDrawer().find(".o-drawer").attributes("data-title")).toBe("mystery");
|
||||
});
|
||||
|
||||
it("renders an empty title when the node id no longer exists", () => {
|
||||
setup({ nodeId: "ghost" });
|
||||
const wrapper = mountDrawer();
|
||||
expect(wrapper.find(".o-drawer").attributes("data-title")).toBe("");
|
||||
expect(mountDrawer().find(".o-drawer").attributes("data-title")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("status + output (errors only, by design)", () => {
|
||||
it("always shows the Errored badge (the drawer only opens for error nodes)", () => {
|
||||
const wrapper = mountDrawer();
|
||||
const badge = wrapper.find(".o-badge");
|
||||
describe("status badge", () => {
|
||||
it("Passed (success) when records reached the node", () => {
|
||||
const badge = statusBadge(mountDrawer());
|
||||
expect(badge.attributes("data-variant")).toBe("success-soft");
|
||||
expect(badge.text()).toBe(t("workflow.test.stepResult.status.ok"));
|
||||
});
|
||||
|
||||
it("Errored (error) when the node is in the errors map", () => {
|
||||
setup({ result: errorResult() });
|
||||
const badge = statusBadge(mountDrawer());
|
||||
expect(badge.attributes("data-variant")).toBe("error-soft");
|
||||
expect(badge.text()).toBe(i18n.global.t("workflow.test.stepResult.status.error"));
|
||||
expect(badge.text()).toBe(t("workflow.test.stepResult.status.error"));
|
||||
});
|
||||
|
||||
it("renders each NodeErrors message as the Output — first tuple element only", () => {
|
||||
const wrapper = mountDrawer();
|
||||
const lines = wrapper.findAll("[data-test='workflow-step-result-error-line']");
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0].text()).toBe("boom");
|
||||
expect(lines[1].text()).toBe("bad record");
|
||||
});
|
||||
|
||||
it("stringifies a non-tuple error entry", () => {
|
||||
it("No Records (default) when the node ran but got nothing (absent from inputs)", () => {
|
||||
// d1 reachable but not in inputs -> the condition/function above filtered all.
|
||||
setup({
|
||||
nodeId: "d1",
|
||||
result: {
|
||||
errors: { f1: { error_count: 1, errors: ["flat message"] } },
|
||||
ranNodeIds: [],
|
||||
errors: {},
|
||||
inputs: { t1: [rec(1)], f1: [rec(2)] },
|
||||
ranNodeIds: ["t1", "f1", "d1"],
|
||||
blockedNodeIds: [],
|
||||
},
|
||||
});
|
||||
const badge = statusBadge(mountDrawer());
|
||||
expect(badge.attributes("data-variant")).toBe("default-soft");
|
||||
expect(badge.text()).toBe(t("workflow.test.stepResult.status.skipped"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("input pane — the records this node received", () => {
|
||||
it("shows the node's own input, editable, in the editor", () => {
|
||||
const editor = inputEditor(mountDrawer());
|
||||
expect(editor.props("readOnly")).toBe(false);
|
||||
expect(JSON.parse(editor.props("query"))).toEqual([rec(2)]); // f1's input
|
||||
});
|
||||
|
||||
it("seeds a DIFFERENT node's input (per-node, not a shared sample)", () => {
|
||||
setup({ nodeId: "t1" });
|
||||
expect(JSON.parse(inputEditor(mountDrawer()).props("query"))).toEqual([rec(1)]);
|
||||
});
|
||||
|
||||
it("shows an empty state (not a blank editor) when 0 records reached the node", () => {
|
||||
setup({
|
||||
nodeId: "d1",
|
||||
result: {
|
||||
errors: {},
|
||||
inputs: { t1: [rec(1)], f1: [rec(2)] },
|
||||
ranNodeIds: ["t1", "f1", "d1"],
|
||||
blockedNodeIds: [],
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer();
|
||||
expect(wrapper.find("[data-test='workflow-step-result-error-line']").text()).toBe(
|
||||
"flat message",
|
||||
expect(inputEditor(wrapper)).toBeUndefined(); // no input editor rendered
|
||||
expect(wrapper.find('[data-test="workflow-step-result-no-input"]').text()).toBe(
|
||||
t("workflow.test.stepResult.noInput"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the no-output placeholder when the node has no error entries", () => {
|
||||
setup({ result: { errors: {}, ranNodeIds: [], blockedNodeIds: [] } });
|
||||
const wrapper = mountDrawer();
|
||||
expect(wrapper.find("[data-test='workflow-step-result-error-line']").exists()).toBe(false);
|
||||
expect(wrapper.find("[data-test='workflow-step-result-no-output']").text()).toBe(
|
||||
i18n.global.t("workflow.test.stepResult.noOutput"),
|
||||
);
|
||||
});
|
||||
|
||||
it("tolerates a null result and a malformed errors entry", () => {
|
||||
setup({ result: null });
|
||||
expect(mountDrawer().find("[data-test='workflow-step-result-no-output']").exists()).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
it("unwraps a JSON-stringified `data` field for readability", () => {
|
||||
const flat = { meta_alert_name: "a", data: '[{"x":9}]' };
|
||||
setup({
|
||||
result: { errors: { f1: { errors: "not-an-array" } } },
|
||||
nodeId: "d1",
|
||||
result: { errors: {}, inputs: { d1: [flat] }, ranNodeIds: ["d1"], blockedNodeIds: [] },
|
||||
nodes: [{ id: "d1", data: { node_type: "destination", destination_id: "sink-a" } }],
|
||||
edges: [],
|
||||
});
|
||||
expect(mountDrawer().find("[data-test='workflow-step-result-no-output']").exists()).toBe(
|
||||
true,
|
||||
);
|
||||
const parsed = JSON.parse(inputEditor(mountDrawer()).props("query"));
|
||||
expect(parsed[0].data).toEqual([{ x: 9 }]); // parsed, not the escaped string
|
||||
});
|
||||
});
|
||||
|
||||
describe("input — bound to the central testRun.input (test mode)", () => {
|
||||
it("shows the central input, editable", () => {
|
||||
describe("output pane — what the node emitted (derived)", () => {
|
||||
it("single downstream: the records the child received, in a read-only editor", () => {
|
||||
const editor = outputEditor(mountDrawer());
|
||||
expect(editor.props("readOnly")).toBe(true);
|
||||
expect(JSON.parse(editor.props("query"))).toEqual([rec(3)]); // f1's output == d1's input
|
||||
});
|
||||
|
||||
it("single downstream: NO caption (the target is obvious from the canvas)", () => {
|
||||
// f1 -> d1 (single). No "→ Destination · sink-a" line, no record count — just
|
||||
// the records in the editor.
|
||||
const wrapper = mountDrawer();
|
||||
expect(editorVm(wrapper).props("query")).toBe(VALID_INPUT);
|
||||
expect(editorVm(wrapper).props("readOnly")).toBe(false);
|
||||
});
|
||||
|
||||
it("writes edits straight back to testRun.input (stays in sync with the Test dialog)", async () => {
|
||||
const wrapper = mountDrawer();
|
||||
editorVm(wrapper).vm.$emit("update:query", '[{"y":2}]');
|
||||
await nextTick();
|
||||
expect(workflowObj.testRun.input).toBe('[{"y":2}]');
|
||||
expect(editorVm(wrapper).props("query")).toBe('[{"y":2}]');
|
||||
});
|
||||
|
||||
it("shows the invalid-JSON hint for a malformed payload", async () => {
|
||||
setup({ input: "{oops" });
|
||||
const wrapper = mountDrawer();
|
||||
expect(wrapper.find("[data-test='workflow-step-result-input-error']").text()).toBe(
|
||||
i18n.global.t("workflow.test.invalidJson"),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a non-array payload as invalid", () => {
|
||||
setup({ input: '{"a":1}' });
|
||||
expect(mountDrawer().find("[data-test='workflow-step-result-input-error']").exists()).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an empty payload as invalid (nothing to replay)", () => {
|
||||
setup({ input: " " });
|
||||
const wrapper = mountDrawer();
|
||||
expect(wrapper.find("[data-test='workflow-step-result-input-error']").exists()).toBe(true);
|
||||
expect(replayBtn(wrapper).attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides the invalid-JSON hint for a valid array", () => {
|
||||
expect(mountDrawer().find("[data-test='workflow-step-result-input-error']").exists()).toBe(
|
||||
expect(wrapper.find('[data-test="workflow-step-result-output-branch-label"]').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
expect(outputEditor(wrapper)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("terminal (destination): shows the records it sent, with the 'sent externally' caption", () => {
|
||||
setup({ nodeId: "d1" });
|
||||
const wrapper = mountDrawer();
|
||||
expect(JSON.parse(outputEditor(wrapper).props("query"))).toEqual([rec(3)]);
|
||||
expect(wrapper.text()).toContain(t("workflow.test.stepResult.sentExternally"));
|
||||
});
|
||||
|
||||
it("fan-out: one object keyed by '→ target' so each branch stays labelled", () => {
|
||||
// t1 -> f1 and t1 -> d1 (broadcast). Inspect t1's output.
|
||||
setup({
|
||||
nodeId: "t1",
|
||||
edges: [
|
||||
{ source: "t1", target: "f1" },
|
||||
{ source: "t1", target: "d1" },
|
||||
],
|
||||
result: {
|
||||
errors: {},
|
||||
inputs: { t1: [rec(1)], f1: [rec(2)], d1: [rec(3)] },
|
||||
ranNodeIds: ["t1", "f1", "d1"],
|
||||
blockedNodeIds: [],
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer();
|
||||
const out = JSON.parse(outputEditor(wrapper).props("query"));
|
||||
const keys = Object.keys(out);
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(keys.every((k) => k.startsWith("→ "))).toBe(true);
|
||||
// fan-out DOES keep a caption per branch (to tell them apart)
|
||||
expect(
|
||||
wrapper.findAll('[data-test="workflow-step-result-output-branch-label"]'),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("errored node: shows the error message(s) AND the records it forwarded", () => {
|
||||
setup({ result: errorResult() });
|
||||
const wrapper = mountDrawer();
|
||||
const lines = wrapper.findAll('[data-test="workflow-step-result-error-line"]');
|
||||
expect(lines.map((l: any) => l.text())).toEqual(["boom", "bad record"]);
|
||||
// forwarded editor still present (f1 passed records to d1)
|
||||
expect(JSON.parse(outputEditor(wrapper).props("query"))).toEqual([rec(3)]);
|
||||
// both section headings shown
|
||||
expect(wrapper.text()).toContain(t("workflow.test.stepResult.errorHeading"));
|
||||
expect(wrapper.text()).toContain(t("workflow.test.stepResult.forwardedHeading"));
|
||||
});
|
||||
|
||||
it("errored terminal: shows the error alone (a failed send has no output)", () => {
|
||||
setup({
|
||||
nodeId: "d1",
|
||||
result: {
|
||||
errors: { d1: { error_count: 1, errors: [["send failed"]] } },
|
||||
inputs: { d1: [rec(3)] },
|
||||
ranNodeIds: ["t1", "f1", "d1"],
|
||||
blockedNodeIds: [],
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer();
|
||||
expect(wrapper.find('[data-test="workflow-step-result-error-line"]').text()).toBe(
|
||||
"send failed",
|
||||
);
|
||||
expect(outputEditor(wrapper)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("filtered non-terminal: centered empty state, no editor", () => {
|
||||
// f1 forwarded nothing to d1 -> f1's output is empty.
|
||||
setup({
|
||||
result: {
|
||||
errors: {},
|
||||
inputs: { t1: [rec(1)], f1: [rec(2)] },
|
||||
ranNodeIds: ["t1", "f1", "d1"],
|
||||
blockedNodeIds: [],
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer();
|
||||
expect(outputEditor(wrapper)).toBeUndefined();
|
||||
expect(wrapper.find('[data-test="workflow-step-result-output-empty"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("condition that matched nothing explains why in the empty state", () => {
|
||||
setup({
|
||||
nodeId: "c1",
|
||||
nodes: [
|
||||
{ id: "c1", data: { node_type: "condition" } },
|
||||
{ id: "d1", data: { node_type: "destination", destination_id: "s" } },
|
||||
],
|
||||
edges: [{ source: "c1", target: "d1" }],
|
||||
result: {
|
||||
errors: {},
|
||||
inputs: { c1: [rec(1)] },
|
||||
ranNodeIds: ["c1", "d1"],
|
||||
blockedNodeIds: [],
|
||||
},
|
||||
});
|
||||
expect(mountDrawer().find('[data-test="workflow-step-result-output-empty"]').text()).toBe(
|
||||
t("workflow.test.stepResult.conditionNoMatch"),
|
||||
);
|
||||
});
|
||||
|
||||
it("terminal with 0 records: 'nothing was sent', not a false send", () => {
|
||||
setup({
|
||||
nodeId: "d1",
|
||||
result: {
|
||||
errors: {},
|
||||
inputs: { t1: [rec(1)] },
|
||||
ranNodeIds: ["t1", "f1", "d1"],
|
||||
blockedNodeIds: [],
|
||||
},
|
||||
});
|
||||
expect(mountDrawer().find('[data-test="workflow-step-result-output-empty"]').text()).toBe(
|
||||
t("workflow.test.stepResult.destinationNoRecords"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("history mode (read-only past run)", () => {
|
||||
const historyResult = () =>
|
||||
errorResult({
|
||||
mode: "history",
|
||||
runId: "r1",
|
||||
nodeInputs: { f1: [{ meta: { alert_name: "a" }, data: [{ x: 1 }] }] },
|
||||
describe("use as test input", () => {
|
||||
it("Input: promotes the records to the flow payload and opens the Test dialog", async () => {
|
||||
const wrapper = mountDrawer();
|
||||
expect(useInputBtn(wrapper).attributes("disabled")).toBeUndefined();
|
||||
await useInputBtn(wrapper).trigger("click");
|
||||
expect(JSON.parse(workflowObj.testRun.input)).toEqual([rec(2)]); // f1's input
|
||||
expect(workflowObj.testRun.show).toBe(true);
|
||||
expect(workflowObj.testRun.resultDrawer.show).toBe(false);
|
||||
});
|
||||
|
||||
it("Output: promotes what the node emitted", async () => {
|
||||
const wrapper = mountDrawer();
|
||||
await useOutputBtn(wrapper).trigger("click");
|
||||
expect(JSON.parse(workflowObj.testRun.input)).toEqual([rec(3)]); // f1's output
|
||||
expect(workflowObj.testRun.show).toBe(true);
|
||||
});
|
||||
|
||||
it("disables + explains when the pane has no records", () => {
|
||||
// d1 got nothing -> both input and output empty.
|
||||
setup({
|
||||
nodeId: "d1",
|
||||
result: {
|
||||
errors: {},
|
||||
inputs: { t1: [rec(1)], f1: [rec(2)] },
|
||||
ranNodeIds: ["t1", "f1", "d1"],
|
||||
blockedNodeIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
it("shows the per-node captured input, pretty-printed and read-only", () => {
|
||||
setup({ result: historyResult() });
|
||||
const wrapper = mountDrawer();
|
||||
const editor = editorVm(wrapper);
|
||||
expect(editor.props("readOnly")).toBe(true);
|
||||
expect(editor.props("query")).toBe(
|
||||
JSON.stringify([{ meta: { alert_name: "a" }, data: [{ x: 1 }] }], null, 2),
|
||||
);
|
||||
expect(useInputBtn(wrapper).attributes("disabled")).toBeDefined();
|
||||
expect(useOutputBtn(wrapper).attributes("disabled")).toBeDefined();
|
||||
expect(tooltipFor(wrapper, t("workflow.test.stepResult.useAsTestInputNoInput"))).toBeTruthy();
|
||||
expect(
|
||||
tooltipFor(wrapper, t("workflow.test.stepResult.useAsTestInputNoOutput")),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows an empty input when the run captured none for this node", () => {
|
||||
setup({ result: errorResult({ mode: "history", nodeInputs: {} }) });
|
||||
expect(editorVm(mountDrawer()).props("query")).toBe("");
|
||||
});
|
||||
|
||||
it("ignores editor writes (the setter no-ops) so the central input is untouched", async () => {
|
||||
setup({ result: historyResult() });
|
||||
it("is ALSO available in history mode — it drops the user into a fresh test", async () => {
|
||||
setup({ result: okResult(), input: "" });
|
||||
workflowObj.testRun.result = { ...okResult(), mode: "history" } as any;
|
||||
workflowObj.testRun.show = false;
|
||||
const wrapper = mountDrawer();
|
||||
editorVm(wrapper).vm.$emit("update:query", "tampered");
|
||||
await nextTick();
|
||||
expect(workflowObj.testRun.input).toBe(VALID_INPUT);
|
||||
});
|
||||
expect(useInputBtn(wrapper).exists()).toBe(true);
|
||||
expect(useOutputBtn(wrapper).exists()).toBe(true);
|
||||
|
||||
it("hides Replay and the invalid-JSON hint (a past run is read-only)", () => {
|
||||
setup({ input: "{oops", result: historyResult() });
|
||||
const wrapper = mountDrawer();
|
||||
expect(replayBtn(wrapper).exists()).toBe(false);
|
||||
expect(wrapper.find("[data-test='workflow-step-result-input-error']").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("still shows the error output for the past run", () => {
|
||||
setup({ result: historyResult() });
|
||||
expect(mountDrawer().findAll("[data-test='workflow-step-result-error-line']")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
await useInputBtn(wrapper).trigger("click");
|
||||
// seeds the flow payload with the historical node's input and opens the Test dialog
|
||||
expect(JSON.parse(workflowObj.testRun.input)).toEqual([rec(2)]);
|
||||
expect(workflowObj.testRun.show).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replay", () => {
|
||||
it("re-runs the workflow FROM this node with the edited input, then closes", async () => {
|
||||
mockTestWorkflow.mockResolvedValue({ data: { errors: {} } });
|
||||
it("re-runs FROM this node with its (edited) input, then closes", async () => {
|
||||
mockTestWorkflow.mockResolvedValue({ data: { errors: {}, inputs: {} } });
|
||||
const wrapper = mountDrawer();
|
||||
expect(replayBtn(wrapper).attributes("disabled")).toBeUndefined();
|
||||
|
||||
await replayBtn(wrapper).trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
// The whole in-memory graph is sent (test-without-saving), not just an id.
|
||||
expect(mockTestWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
org_identifier: "default",
|
||||
inputs: JSON.parse(VALID_INPUT),
|
||||
inputs: [rec(2)], // f1's own input, unwrapped
|
||||
from_node: "f1",
|
||||
workflow: expect.objectContaining({ id: "wf1" }),
|
||||
}),
|
||||
);
|
||||
expect(workflowObj.testRun.resultDrawer).toEqual({
|
||||
show: false,
|
||||
nodeId: "",
|
||||
});
|
||||
expect(workflowObj.testRun.resultDrawer).toEqual({ show: false, nodeId: "" });
|
||||
});
|
||||
|
||||
it("keeps the drawer open and toasts the backend message on failure", async () => {
|
||||
mockTestWorkflow.mockRejectedValue({
|
||||
response: { data: { message: "vrl compile error" } },
|
||||
});
|
||||
mockTestWorkflow.mockRejectedValue({ response: { data: { message: "vrl compile error" } } });
|
||||
const wrapper = mountDrawer();
|
||||
await replayBtn(wrapper).trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
message: "vrl compile error",
|
||||
variant: "error",
|
||||
});
|
||||
expect(mockToast).toHaveBeenCalledWith({ message: "vrl compile error", variant: "error" });
|
||||
expect(workflowObj.testRun.resultDrawer.show).toBe(true);
|
||||
});
|
||||
|
||||
@@ -388,98 +498,124 @@ describe("WorkflowStepResultDrawer", () => {
|
||||
await replayBtn(wrapper).trigger("click");
|
||||
await flushPromises();
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
message: i18n.global.t("workflow.test.runError"),
|
||||
message: t("workflow.test.runError"),
|
||||
variant: "error",
|
||||
});
|
||||
});
|
||||
|
||||
it("is disabled and inert while a replay is in flight", async () => {
|
||||
it("is disabled + inert while a replay is in flight", async () => {
|
||||
let resolve!: (v: any) => void;
|
||||
mockTestWorkflow.mockReturnValue(
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
mockTestWorkflow.mockReturnValue(new Promise((r) => (resolve = r)));
|
||||
const wrapper = mountDrawer();
|
||||
await replayBtn(wrapper).trigger("click");
|
||||
await nextTick();
|
||||
|
||||
expect(replayBtn(wrapper).attributes("data-loading")).toBe("true");
|
||||
expect(replayBtn(wrapper).attributes("disabled")).toBeDefined();
|
||||
// a second click while replaying is a no-op
|
||||
await replayBtn(wrapper).trigger("click");
|
||||
expect(mockTestWorkflow).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolve({ data: { errors: {} } });
|
||||
resolve({ data: { errors: {}, inputs: {} } });
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("does not call the API when the input is not a valid JSON array", async () => {
|
||||
setup({ input: "not json" });
|
||||
const wrapper = mountDrawer();
|
||||
await replayBtn(wrapper).trigger("click");
|
||||
await flushPromises();
|
||||
expect(mockTestWorkflow).not.toHaveBeenCalled();
|
||||
it("is disabled when no records reached the node (nothing to replay)", () => {
|
||||
setup({
|
||||
nodeId: "d1",
|
||||
result: {
|
||||
errors: {},
|
||||
inputs: { t1: [rec(1)], f1: [rec(2)] },
|
||||
ranNodeIds: ["t1", "f1", "d1"],
|
||||
blockedNodeIds: [],
|
||||
},
|
||||
});
|
||||
expect(replayBtn(mountDrawer()).attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("carries the replay hint tooltip", () => {
|
||||
expect(mountDrawer().find(".o-tooltip").attributes("data-content")).toBe(
|
||||
i18n.global.t("workflow.test.stepResult.replayHint"),
|
||||
expect(tooltipFor(mountDrawer(), t("workflow.test.stepResult.replayHint"))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("history mode (read-only past run)", () => {
|
||||
// History carries the SAME per-node `inputs` map as a Test run — the drawer
|
||||
// renders Input/Output for every node, just read-only (no Replay).
|
||||
const historyResult = () => ({
|
||||
errors: { f1: { error_count: 1, errors: [["boom"]] } },
|
||||
inputs: { t1: [rec(1)], f1: [{ meta: { alert_name: "a" }, data: [{ x: 1 }] }], d1: [rec(3)] },
|
||||
ranNodeIds: ["t1", "f1", "d1"],
|
||||
blockedNodeIds: ["d1"],
|
||||
mode: "history",
|
||||
runId: "r1",
|
||||
});
|
||||
|
||||
it("shows the per-node input, read-only", () => {
|
||||
setup({ result: historyResult() });
|
||||
const editor = inputEditor(mountDrawer());
|
||||
expect(editor.props("readOnly")).toBe(true);
|
||||
expect(JSON.parse(editor.props("query"))).toEqual([
|
||||
{ meta: { alert_name: "a" }, data: [{ x: 1 }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives Output for a NON-error node too (not just error nodes)", () => {
|
||||
// f1 -> d1: f1's output == d1's input.
|
||||
setup({ result: historyResult() });
|
||||
expect(JSON.parse(outputEditor(mountDrawer()).props("query"))).toEqual([rec(3)]);
|
||||
});
|
||||
|
||||
it("hides Replay (read-only) but KEEPS Use-as-input (seed a fresh test)", () => {
|
||||
setup({ result: historyResult() });
|
||||
const wrapper = mountDrawer();
|
||||
expect(replayBtn(wrapper).exists()).toBe(false);
|
||||
expect(useInputBtn(wrapper).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("still shows the error output for the past run", () => {
|
||||
setup({ result: historyResult() });
|
||||
expect(mountDrawer().find('[data-test="workflow-step-result-error-line"]').text()).toBe(
|
||||
"boom",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("copy", () => {
|
||||
it("copies the input with the input success message", async () => {
|
||||
it("copies the input records with the input success message", async () => {
|
||||
const wrapper = mountDrawer();
|
||||
const btns = wrapper.findAll(
|
||||
`[title="${i18n.global.t("workflow.test.stepResult.copyInput")}"]`,
|
||||
);
|
||||
await btns[0].trigger("click");
|
||||
expect(mockCopy).toHaveBeenCalledWith(VALID_INPUT, {
|
||||
successMessage: i18n.global.t("workflow.test.stepResult.copiedInput"),
|
||||
const btn = wrapper.find(`[title="${t("workflow.test.stepResult.copyInput")}"]`);
|
||||
await btn.trigger("click");
|
||||
expect(mockCopy).toHaveBeenCalledWith(JSON.stringify([rec(2)], null, 2), {
|
||||
successMessage: t("workflow.test.stepResult.copiedInput"),
|
||||
});
|
||||
});
|
||||
|
||||
it("copies the output as the joined error messages", async () => {
|
||||
it("copies the output records", async () => {
|
||||
const wrapper = mountDrawer();
|
||||
const btns = wrapper.findAll(
|
||||
`[title="${i18n.global.t("workflow.test.stepResult.copyOutput")}"]`,
|
||||
);
|
||||
await btns[0].trigger("click");
|
||||
expect(mockCopy).toHaveBeenCalledWith("boom\nbad record", {
|
||||
successMessage: i18n.global.t("workflow.test.stepResult.copiedOutput"),
|
||||
const btn = wrapper.find(`[title="${t("workflow.test.stepResult.copyOutput")}"]`);
|
||||
await btn.trigger("click");
|
||||
expect(mockCopy).toHaveBeenCalledWith(JSON.stringify([rec(3)], null, 2), {
|
||||
successMessage: t("workflow.test.stepResult.copiedOutput"),
|
||||
});
|
||||
});
|
||||
|
||||
it("disables both copy buttons and copies nothing when there is no text", async () => {
|
||||
setup({ input: "", result: { errors: {} } });
|
||||
it("copies error + forwarded together for an errored node", async () => {
|
||||
setup({ result: errorResult() });
|
||||
const wrapper = mountDrawer();
|
||||
const copyIn = wrapper.find(
|
||||
`[title="${i18n.global.t("workflow.test.stepResult.copyInput")}"]`,
|
||||
);
|
||||
const copyOut = wrapper.find(
|
||||
`[title="${i18n.global.t("workflow.test.stepResult.copyOutput")}"]`,
|
||||
);
|
||||
expect(copyIn.attributes("disabled")).toBeDefined();
|
||||
expect(copyOut.attributes("disabled")).toBeDefined();
|
||||
|
||||
// even if invoked, an empty payload is a no-op
|
||||
await copyIn.trigger("click");
|
||||
await copyOut.trigger("click");
|
||||
expect(mockCopy).not.toHaveBeenCalled();
|
||||
const btn = wrapper.find(`[title="${t("workflow.test.stepResult.copyOutput")}"]`);
|
||||
await btn.trigger("click");
|
||||
const copied = mockCopy.mock.calls[0][0];
|
||||
expect(copied).toContain("boom");
|
||||
expect(copied).toContain('"x": 3'); // forwarded records included
|
||||
});
|
||||
});
|
||||
|
||||
describe("fullscreen", () => {
|
||||
const fsButtons = (w: any) =>
|
||||
w.findAll(`[title="${i18n.global.t("workflow.test.stepResult.enterFullscreen")}"]`);
|
||||
w.findAll(`[title="${t("workflow.test.stepResult.enterFullscreen")}"]`);
|
||||
|
||||
it("toggles the Input+Output container as one unit", async () => {
|
||||
const wrapper = mountDrawer();
|
||||
await fsButtons(wrapper)[0].trigger("click");
|
||||
expect(mockToggleFullscreen).toHaveBeenCalledWith(
|
||||
wrapper.find("[data-test='workflow-step-io-container']").element,
|
||||
wrapper.find('[data-test="workflow-step-io-container"]').element,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -493,34 +629,7 @@ describe("WorkflowStepResultDrawer", () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("flips the icon + title once the document reports fullscreen", async () => {
|
||||
const wrapper = mountDrawer();
|
||||
const el = wrapper.find("[data-test='workflow-step-io-container']").element;
|
||||
expect(wrapper.findAll('[data-icon="fullscreen"]')).toHaveLength(2);
|
||||
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: el,
|
||||
});
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.findAll('[data-icon="fullscreen-exit"]')).toHaveLength(2);
|
||||
expect(
|
||||
wrapper.findAll(`[title="${i18n.global.t("workflow.test.stepResult.exitFullscreen")}"]`),
|
||||
).toHaveLength(2);
|
||||
|
||||
// leaving fullscreen flips it back
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: null,
|
||||
});
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('[data-icon="fullscreen"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("removes its fullscreen listeners on unmount", async () => {
|
||||
it("removes its fullscreen listeners on unmount", () => {
|
||||
const remove = vi.spyOn(document, "removeEventListener");
|
||||
mountDrawer().unmount();
|
||||
const events = remove.mock.calls.map((c) => c[0]);
|
||||
@@ -535,12 +644,9 @@ describe("WorkflowStepResultDrawer", () => {
|
||||
const wrapper = mountDrawer();
|
||||
const close = wrapper
|
||||
.findAll(".drawer-footer button")
|
||||
.find((b: any) => b.text() === i18n.global.t("common.close"))!;
|
||||
.find((b: any) => b.text() === t("common.close"))!;
|
||||
await close.trigger("click");
|
||||
expect(workflowObj.testRun.resultDrawer).toEqual({
|
||||
show: false,
|
||||
nodeId: "",
|
||||
});
|
||||
expect(workflowObj.testRun.resultDrawer).toEqual({ show: false, nodeId: "" });
|
||||
});
|
||||
|
||||
it("clears the result drawer when the drawer dismisses itself", async () => {
|
||||
@@ -548,19 +654,11 @@ describe("WorkflowStepResultDrawer", () => {
|
||||
await wrapper.find(".drawer-dismiss").trigger("click");
|
||||
expect(workflowObj.testRun.resultDrawer.show).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps it open when the drawer reports open=true", async () => {
|
||||
const wrapper = mountDrawer();
|
||||
wrapper.findComponent(ODrawerStub as any).vm.$emit("update:open", true);
|
||||
await nextTick();
|
||||
expect(workflowObj.testRun.resultDrawer.show).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("mounts fine when there are only icon buttons and no node data at all", () => {
|
||||
setup({ nodeId: "", nodes: [], result: null });
|
||||
it("mounts fine with no node data and no result at all", () => {
|
||||
setup({ nodeId: "", nodes: [], edges: [], result: null });
|
||||
const wrapper = mountDrawer();
|
||||
expect(wrapper.find(".o-drawer").exists()).toBe(true);
|
||||
expect(iconButtons(wrapper).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,11 +20,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
copy + a shared fullscreen toggle, mirroring the LLM span preview), plus a
|
||||
Replay button that re-runs the workflow from this node with its input.
|
||||
|
||||
Data: the test response is errors-only today, so for an ERROR node the input is
|
||||
derived from the errored records (`NodeErrors`) and the output is the error
|
||||
messages; Replay works. For an OK/skipped node there's no captured I/O yet, so
|
||||
the panels show a placeholder — they fill in automatically once the backend
|
||||
returns per-node `node_io { input, output }` (already read here when present).
|
||||
Data: the test response carries a per-node `inputs` map. Input = the records
|
||||
THIS node received (nodeTestInput). Output = what it emitted, derived per
|
||||
outgoing edge as the downstream node's input (nodeTestOutputBranches) — the graph
|
||||
is a single-incoming tree, so a child's input IS this node's output on that edge.
|
||||
A terminal (destination) shows the records it sent externally; an errored node
|
||||
shows the error message(s). Opens for ANY node (✓ / grey / error), not just errors.
|
||||
-->
|
||||
<template>
|
||||
<ODrawer
|
||||
@@ -35,10 +36,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<div class="flex h-full min-h-0 flex-col gap-3 p-4">
|
||||
<!-- status — the drawer only opens for error nodes, so it's always Errored -->
|
||||
<!-- status — reflects this node's actual test result (Errored / Passed /
|
||||
No Records), same as its canvas badge. -->
|
||||
<div class="flex items-center justify-end">
|
||||
<OBadge variant="error-soft" size="sm">
|
||||
{{ t("workflow.test.stepResult.status.error") }}
|
||||
<OBadge :variant="statusVariant" size="sm" data-test="workflow-step-result-status">
|
||||
{{ t(`workflow.test.stepResult.status.${stepStatus}`) }}
|
||||
</OBadge>
|
||||
</div>
|
||||
|
||||
@@ -57,6 +59,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div class="text-text-body mb-2 flex items-center justify-between text-sm font-bold">
|
||||
<div>{{ t("workflow.test.stepResult.input") }}</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<OTooltip :content="useInputTooltip" :delay="300" side="top">
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:disabled="!inputRecordsForTest"
|
||||
data-test="workflow-step-use-input-as-test"
|
||||
@click="useAsTestInput(inputRecordsForTest)"
|
||||
>
|
||||
<OIcon name="play-circle" size="xs" />
|
||||
</OButton>
|
||||
</OTooltip>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -77,11 +90,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</div>
|
||||
<!-- Test mode: input is EDITABLE and Replay re-runs from this step with
|
||||
it. History mode: read-only per-node input captured for the run. -->
|
||||
it. History mode: read-only per-node input captured for the run.
|
||||
When no records reached this step, show a clear empty state instead
|
||||
of a blank editor (which reads as "type something here"). -->
|
||||
<div
|
||||
class="border-border-default rounded-default bg-code-bg min-h-0 flex-1 overflow-hidden border"
|
||||
>
|
||||
<CodeQueryEditor
|
||||
v-if="receivedRecords"
|
||||
editor-id="workflow-step-input"
|
||||
language="json"
|
||||
:query="inputModel"
|
||||
@@ -89,9 +105,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:show-auto-complete="false"
|
||||
@update:query="inputModel = $event"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
data-test="workflow-step-result-no-input"
|
||||
class="text-text-secondary flex h-full items-center justify-center p-8 text-center text-sm italic"
|
||||
>
|
||||
{{ t("workflow.test.stepResult.noInput") }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isHistory && editableInput && inputInvalid"
|
||||
v-if="!isHistory && receivedRecords && editableInput && inputInvalid"
|
||||
data-test="workflow-step-result-input-error"
|
||||
class="text-input-error-text mt-1 text-xs leading-snug"
|
||||
>
|
||||
@@ -104,6 +127,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div class="text-text-body mb-2 flex items-center justify-between text-sm font-bold">
|
||||
<div>{{ t("workflow.test.stepResult.output") }}</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<OTooltip :content="useOutputTooltip" :delay="300" side="top">
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:disabled="!outputRecordsForTest"
|
||||
data-test="workflow-step-use-output-as-test"
|
||||
@click="useAsTestInput(outputRecordsForTest)"
|
||||
>
|
||||
<OIcon name="play-circle" size="xs" />
|
||||
</OButton>
|
||||
</OTooltip>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -124,29 +158,72 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-border-default rounded-default bg-code-bg min-h-0 flex-1 overflow-hidden border"
|
||||
class="border-border-default rounded-default bg-code-bg flex min-h-0 flex-1 flex-col overflow-hidden border"
|
||||
>
|
||||
<!-- The step errored — show the error message(s) as the output. -->
|
||||
<!-- Nothing produced (no error, no forwarded records) — one centered
|
||||
empty state, matching the Input pane so both sides stay aligned. -->
|
||||
<div
|
||||
v-if="errorMessages.length"
|
||||
class="flex h-full flex-col gap-1.5 overflow-auto px-3 py-2.5"
|
||||
>
|
||||
<div
|
||||
v-for="(m, i) in errorMessages"
|
||||
:key="i"
|
||||
data-test="workflow-step-result-error-line"
|
||||
class="text-status-error-text text-xs leading-snug whitespace-pre-wrap"
|
||||
>
|
||||
{{ m }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
data-test="workflow-step-result-no-output"
|
||||
v-if="outputIsEmpty"
|
||||
data-test="workflow-step-result-output-empty"
|
||||
class="text-text-secondary flex h-full items-center justify-center p-8 text-center text-sm italic"
|
||||
>
|
||||
{{ t("workflow.test.stepResult.noOutput") }}
|
||||
{{ outputEmptyMessage }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Error message(s). Shown ABOVE the forwarded editor when a node
|
||||
errored but still passed records on (e.g. a function that failed
|
||||
on some records, forwarded others). -->
|
||||
<div
|
||||
v-if="showError"
|
||||
data-test="workflow-step-result-error-section"
|
||||
class="flex shrink-0 flex-col gap-1.5 overflow-auto p-2.5"
|
||||
:class="{ 'flex-1': !hasForwardedRecords }"
|
||||
>
|
||||
<div
|
||||
v-if="showSectionHeadings"
|
||||
class="text-status-error-text text-xs font-semibold"
|
||||
>
|
||||
{{ t("workflow.test.stepResult.errorHeading") }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(m, i) in errorMessages"
|
||||
:key="i"
|
||||
data-test="workflow-step-result-error-line"
|
||||
class="text-status-error-text text-xs leading-snug whitespace-pre-wrap"
|
||||
>
|
||||
{{ m }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forwarded records in the SAME read-only editor the Input uses, so
|
||||
the output gets JSON highlighting / folding. -->
|
||||
<template v-if="hasForwardedRecords">
|
||||
<div
|
||||
v-if="showSectionHeadings"
|
||||
class="text-text-secondary shrink-0 px-2.5 pt-2 text-xs font-semibold"
|
||||
>
|
||||
{{ t("workflow.test.stepResult.forwardedHeading") }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(label, i) in showBranchLabels ? outputBranchLabels : []"
|
||||
:key="i"
|
||||
data-test="workflow-step-result-output-branch-label"
|
||||
class="text-text-secondary flex shrink-0 items-center gap-1 px-2.5 pt-2 text-xs font-medium"
|
||||
>
|
||||
<OIcon name="arrow-right" size="xs" />
|
||||
<span class="min-w-0 truncate">{{ label }}</span>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1" data-test="workflow-step-result-output-records">
|
||||
<CodeQueryEditor
|
||||
editor-id="workflow-step-output"
|
||||
language="json"
|
||||
:query="outputRecordsText || ''"
|
||||
:read-only="true"
|
||||
:show-auto-complete="false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,7 +259,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
@@ -201,6 +278,8 @@ import {
|
||||
nodeMeta,
|
||||
nodeConfigDetail,
|
||||
executeTestRun,
|
||||
nodeTestInput,
|
||||
nodeTestOutputBranches,
|
||||
} from "@/plugins/workflows/useWorkflowCanvas";
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -235,29 +314,74 @@ const errorMessages = computed<string[]>(() =>
|
||||
errorEntries.value.map((e) => (Array.isArray(e) ? String(e[0]) : String(e))),
|
||||
);
|
||||
|
||||
// Header status — mirrors the node's canvas badge (same source of truth): Errored
|
||||
// if it's in the errors map, Passed if records reached it, else No Records (it ran
|
||||
// but was fed nothing — an upstream filter/error). The drawer opens for ANY node
|
||||
// now, so this can't be hardcoded to "error".
|
||||
const stepStatus = computed<"ok" | "error" | "skipped">(() => {
|
||||
const r = result.value;
|
||||
if (r?.errors?.[nodeId.value]) return "error";
|
||||
if (r?.inputs) return r.inputs[nodeId.value]?.length ? "ok" : "skipped";
|
||||
if (r?.blockedNodeIds?.includes(nodeId.value)) return "skipped";
|
||||
return "ok";
|
||||
});
|
||||
const statusVariant = computed<"error-soft" | "success-soft" | "default-soft">(() =>
|
||||
stepStatus.value === "error"
|
||||
? "error-soft"
|
||||
: stepStatus.value === "ok"
|
||||
? "success-soft"
|
||||
: "default-soft",
|
||||
);
|
||||
|
||||
// A history run is read-only (viewing a past execution). A live Test run is
|
||||
// editable + replayable.
|
||||
const isHistory = computed(() => result.value?.mode === "history");
|
||||
|
||||
// ONE central input, shared with the Test dialog (workflowObj.testRun.input).
|
||||
// Editing it here or in the Test dialog stays in sync; Replay from this node
|
||||
// feeds this same payload. Bound directly — no per-node copies to desync.
|
||||
const editableInput = computed<string>({
|
||||
get: () => workflowObj.testRun.input || "",
|
||||
set: (val: string) => (workflowObj.testRun.input = val),
|
||||
});
|
||||
// A record's `data` field arrives JSON-stringified (a serialization artifact of
|
||||
// the destination flattening), so a naive stringify renders it as an escaped
|
||||
// one-liner. Recursively unwrap any string value that is itself JSON (object /
|
||||
// array) so records read as clean nested JSON. Applied to BOTH panes' display.
|
||||
const deepUnwrapJson = (value: any): any => {
|
||||
if (typeof value === "string") {
|
||||
const t = value.trim();
|
||||
if (t.startsWith("{") || t.startsWith("[")) {
|
||||
try {
|
||||
return deepUnwrapJson(JSON.parse(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(deepUnwrapJson);
|
||||
if (value && typeof value === "object") {
|
||||
const out: Record<string, any> = {};
|
||||
for (const k of Object.keys(value)) out[k] = deepUnwrapJson(value[k]);
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const prettyRecords = (records: any): string => JSON.stringify(deepUnwrapJson(records), null, 2);
|
||||
|
||||
// History mode: the per-node input captured for this run (node_map[nodeId]),
|
||||
// pretty-printed and read-only.
|
||||
const nodeInputText = computed<string>(() => {
|
||||
const v = result.value?.nodeInputs?.[nodeId.value];
|
||||
return v == null ? "" : JSON.stringify(v, null, 2);
|
||||
});
|
||||
// Test mode: the Input editor shows the records THIS node actually received on
|
||||
// the last run (from the backend `inputs` map), editable so Replay can re-run
|
||||
// from here with a tweaked payload. The stringified `data` field is unwrapped for
|
||||
// readability — Replay then sends that unwrapped shape (an accepted trade-off).
|
||||
const editableInput = ref("");
|
||||
const seedInputFromRun = () => {
|
||||
const recs = nodeTestInput(nodeId.value);
|
||||
editableInput.value = recs == null ? "" : prettyRecords(recs);
|
||||
};
|
||||
// Seed synchronously so the editor is populated on first paint (not a tick later);
|
||||
// the watch + onMounted below re-seed if the selected node changes while open.
|
||||
// Both Test and History runs carry the same per-node `inputs` map, so the Input is
|
||||
// seeded the same way for both — history just renders it read-only (no Replay).
|
||||
seedInputFromRun();
|
||||
|
||||
// What the Input editor shows: read-only per-node input for a past run, else the
|
||||
// editable central input. The setter no-ops in history mode.
|
||||
// What the Input editor shows. In history it's read-only (the editor's :read-only
|
||||
// binding), so the setter no-ops.
|
||||
const inputModel = computed<string>({
|
||||
get: () => (isHistory.value ? nodeInputText.value : editableInput.value),
|
||||
get: () => editableInput.value,
|
||||
set: (val: string) => {
|
||||
if (!isHistory.value) editableInput.value = val;
|
||||
},
|
||||
@@ -276,8 +400,92 @@ const parsedReplayInput = computed<any[] | null>(() => {
|
||||
});
|
||||
const inputInvalid = computed(() => parsedReplayInput.value === null);
|
||||
|
||||
// Output copy = the error text.
|
||||
const copyableOutput = computed(() => errorMessages.value.join("\n"));
|
||||
// Did records actually reach this node on the run? Based on what the backend
|
||||
// captured (not the editable buffer), so it stays stable if the user clears the
|
||||
// editor. When false, the Input pane shows an empty state instead of a blank
|
||||
// editor, and Replay is disabled (there's nothing here to re-run).
|
||||
const receivedRecords = computed(() => nodeTestInput(nodeId.value) != null);
|
||||
|
||||
// OUTPUT = per outgoing edge, the records the downstream node received (== what
|
||||
// this node emitted on that branch). Empty array => terminal node (a sink) with
|
||||
// no derivable downstream output. A branch with `records: null` passed nothing on
|
||||
// (filtered out / never reached).
|
||||
// A node with no outgoing edge is a terminal (destination/sink). Its "output" IS
|
||||
// what it sends externally = the records it received.
|
||||
const isTerminal = computed(() => nodeTestOutputBranches(nodeId.value).length === 0);
|
||||
|
||||
// The OUTPUT as ONE JSON document for the read-only editor — so it gets the same
|
||||
// syntax highlighting / folding as the Input pane. A terminal shows the records it
|
||||
// sent; a single downstream shows that branch's records; a fan-out shows an object
|
||||
// keyed by "→ target" so each branch stays labelled. Null when nothing was emitted.
|
||||
const outputRecordsText = computed<string | null>(() => {
|
||||
if (isTerminal.value) {
|
||||
const recs = nodeTestInput(nodeId.value);
|
||||
return recs == null ? null : prettyRecords(recs);
|
||||
}
|
||||
const branches = nodeTestOutputBranches(nodeId.value).filter((b) => b.records != null);
|
||||
if (!branches.length) return null;
|
||||
if (branches.length === 1) return prettyRecords(branches[0].records);
|
||||
const byBranch: Record<string, any> = {};
|
||||
for (const b of branches) {
|
||||
const typeName = nodeMeta(b.nodeType) ? t(nodeMeta(b.nodeType)!.titleKey) : b.nodeType;
|
||||
const label = b.detail ? `${typeName} · ${b.detail}` : typeName;
|
||||
byBranch[`→ ${label}`] = deepUnwrapJson(b.records);
|
||||
}
|
||||
return JSON.stringify(byBranch, null, 2);
|
||||
});
|
||||
|
||||
// Caption line(s) shown above the output editor — where the records went and how
|
||||
// many. One entry per downstream target (terminal → the external send).
|
||||
const outputBranchLabels = computed<string[]>(() => {
|
||||
if (isTerminal.value) {
|
||||
const recs = nodeTestInput(nodeId.value);
|
||||
return recs && recs.length ? [t("workflow.test.stepResult.sentExternally")] : [];
|
||||
}
|
||||
return nodeTestOutputBranches(nodeId.value)
|
||||
.filter((b) => Array.isArray(b.records) && b.records.length)
|
||||
.map((b) => {
|
||||
const typeName = nodeMeta(b.nodeType) ? t(nodeMeta(b.nodeType)!.titleKey) : b.nodeType;
|
||||
return b.detail ? `${typeName} · ${b.detail}` : typeName;
|
||||
});
|
||||
});
|
||||
// A caption above the output editor is shown ONLY when it adds information: a
|
||||
// terminal (names the external send) or a fan-out (distinguishes branches). For a
|
||||
// single downstream the target is obvious from the canvas, so we show no caption at
|
||||
// all — a "→ to node X" line there just reads as noise.
|
||||
const showBranchLabels = computed(() => isTerminal.value || outputBranchLabels.value.length > 1);
|
||||
|
||||
// When nothing was emitted, explain WHY in the node's own terms: a condition
|
||||
// matched nothing (the common, actionable case) vs. a generic drop.
|
||||
const emptyOutputMessage = computed(() =>
|
||||
node.value?.data?.node_type === "condition"
|
||||
? t("workflow.test.stepResult.conditionNoMatch")
|
||||
: t("workflow.test.stepResult.branchFiltered"),
|
||||
);
|
||||
|
||||
// A node can BOTH error AND forward records (a function that errored on some
|
||||
// records but passed others). Show the error text AND the forwarded editor. The
|
||||
// exception is an errored TERMINAL (a failed send): no successful output to show.
|
||||
const showError = computed(() => errorMessages.value.length > 0);
|
||||
const showForwarded = computed(() => !isTerminal.value || !showError.value);
|
||||
const hasForwardedRecords = computed(() => showForwarded.value && !!outputRecordsText.value);
|
||||
// Label the two sections only when both are visible, so a plain output stays clean.
|
||||
const showSectionHeadings = computed(() => showError.value && hasForwardedRecords.value);
|
||||
|
||||
// Nothing produced (no error, no forwarded records) → one centered empty state,
|
||||
// same container pattern as the Input pane.
|
||||
const outputIsEmpty = computed(() => !showError.value && !hasForwardedRecords.value);
|
||||
const outputEmptyMessage = computed(() =>
|
||||
isTerminal.value ? t("workflow.test.stepResult.destinationNoRecords") : emptyOutputMessage.value,
|
||||
);
|
||||
|
||||
// Output copy = the error text (if any) + the forwarded records.
|
||||
const copyableOutput = computed(() => {
|
||||
const parts: string[] = [];
|
||||
if (showError.value) parts.push(errorMessages.value.join("\n"));
|
||||
if (hasForwardedRecords.value && outputRecordsText.value) parts.push(outputRecordsText.value);
|
||||
return parts.join("\n\n");
|
||||
});
|
||||
|
||||
const copy = (text: string, type: "input" | "output") => {
|
||||
if (!text) return;
|
||||
@@ -309,7 +517,11 @@ const toggleFullscreen = () => {
|
||||
const onFullscreenChange = () => {
|
||||
isFullscreen.value = document.fullscreenElement === ioContainerRef.value;
|
||||
};
|
||||
// Seed the Input editor with this node's received records when the drawer opens,
|
||||
// and re-seed if the selected node changes while open (both Test and History).
|
||||
watch(nodeId, seedInputFromRun);
|
||||
onMounted(() => {
|
||||
seedInputFromRun();
|
||||
document.addEventListener("fullscreenchange", onFullscreenChange);
|
||||
document.addEventListener("webkitfullscreenchange", onFullscreenChange);
|
||||
});
|
||||
@@ -345,4 +557,37 @@ const close = () => {
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (!open) close();
|
||||
};
|
||||
|
||||
// "Use as Test Input" — promote a pane's records to the central test payload and
|
||||
// open the Test dialog (set-and-open), so the WHOLE flow can be re-run with them.
|
||||
// Input uses the (possibly edited) input editor; Output uses what the node emitted
|
||||
// (its sent records for a terminal, or every downstream branch's records).
|
||||
const inputRecordsForTest = computed<any[] | null>(() => {
|
||||
const p = parsedReplayInput.value;
|
||||
return p && p.length ? p : null;
|
||||
});
|
||||
const outputRecordsForTest = computed<any[] | null>(() => {
|
||||
if (isTerminal.value) return nodeTestInput(nodeId.value);
|
||||
const recs = nodeTestOutputBranches(nodeId.value).flatMap((b) => b.records || []);
|
||||
return recs.length ? recs : null;
|
||||
});
|
||||
|
||||
// Disabled-state tooltips explain WHY there's nothing to use.
|
||||
const useInputTooltip = computed(() =>
|
||||
inputRecordsForTest.value
|
||||
? t("workflow.test.stepResult.useAsTestInput")
|
||||
: t("workflow.test.stepResult.useAsTestInputNoInput"),
|
||||
);
|
||||
const useOutputTooltip = computed(() =>
|
||||
outputRecordsForTest.value
|
||||
? t("workflow.test.stepResult.useAsTestInput")
|
||||
: t("workflow.test.stepResult.useAsTestInputNoOutput"),
|
||||
);
|
||||
|
||||
const useAsTestInput = (records: any[] | null) => {
|
||||
if (!records || !records.length) return;
|
||||
workflowObj.testRun.input = JSON.stringify(records, null, 2);
|
||||
close();
|
||||
workflowObj.testRun.show = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -19,6 +19,7 @@ function mountStepper(
|
||||
animated?: boolean;
|
||||
expanded?: boolean;
|
||||
stepCount?: number;
|
||||
hideHeader?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
@@ -28,6 +29,7 @@ function mountStepper(
|
||||
animated = false, // disable animations in tests to avoid async waits
|
||||
expanded = false,
|
||||
stepCount = 2,
|
||||
hideHeader = false,
|
||||
} = options;
|
||||
|
||||
const Wrapper = defineComponent({
|
||||
@@ -41,7 +43,18 @@ function mountStepper(
|
||||
const done1 = computed(() => active.value > 1);
|
||||
const done2 = computed(() => active.value > 2);
|
||||
const done3 = computed(() => active.value > 3);
|
||||
return { active, orientation, navigable, animated, expanded, stepCount, done1, done2, done3 };
|
||||
return {
|
||||
active,
|
||||
orientation,
|
||||
navigable,
|
||||
animated,
|
||||
expanded,
|
||||
stepCount,
|
||||
hideHeader,
|
||||
done1,
|
||||
done2,
|
||||
done3,
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<OStepper
|
||||
@@ -50,6 +63,7 @@ function mountStepper(
|
||||
:navigable="navigable"
|
||||
:animated="animated"
|
||||
:expanded="expanded"
|
||||
:hide-header="hideHeader"
|
||||
>
|
||||
<OStep :name="1" title="First" :done="done1">Step 1 content</OStep>
|
||||
<OStep v-if="stepCount >= 2" :name="2" title="Second" :done="done2">Step 2 content</OStep>
|
||||
@@ -152,6 +166,21 @@ describe("OStepper", () => {
|
||||
|
||||
// --- Orientation ---
|
||||
|
||||
it("hideHeader removes the header bar but still renders the active step content", async () => {
|
||||
const wrapper = mountStepper({ modelValue: 2, hideHeader: true });
|
||||
await wrapper.vm.$nextTick();
|
||||
// Header (role="list") is gone...
|
||||
expect(wrapper.find('[role="list"]').exists()).toBe(false);
|
||||
// ...but the active step's panel still renders.
|
||||
expect(wrapper.text()).toContain("Step 2 content");
|
||||
});
|
||||
|
||||
it("renders the header by default (hideHeader off)", async () => {
|
||||
const wrapper = mountStepper({ modelValue: 1 });
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[role="list"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not render the horizontal header list in vertical orientation", async () => {
|
||||
const wrapper = mountStepper({ orientation: "vertical", modelValue: 1 });
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
@@ -31,6 +31,14 @@ export interface OStepperProps {
|
||||
* orientations. Default: false.
|
||||
*/
|
||||
expanded?: boolean;
|
||||
/**
|
||||
* Hide the horizontal step header (the indicator/title progress bar), rendering
|
||||
* only the active step's panel. Use when the flow is effectively single-step so
|
||||
* the stepper chrome is just noise (e.g. a create form that skips its first
|
||||
* step). Vertical orientation has no separate header, so this is a no-op there.
|
||||
* Default: false.
|
||||
*/
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
export interface OStepperEmits {
|
||||
|
||||
@@ -17,6 +17,7 @@ const props = withDefaults(defineProps<OStepperProps>(), {
|
||||
animated: true,
|
||||
navigable: false,
|
||||
expanded: false,
|
||||
hideHeader: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<OStepperEmits>();
|
||||
@@ -125,7 +126,7 @@ function triggerClasses(step: StepRegistration): string {
|
||||
reactively whenever a step's done/error state changes.
|
||||
-->
|
||||
<div
|
||||
v-if="isHorizontal"
|
||||
v-if="isHorizontal && !hideHeader"
|
||||
role="list"
|
||||
aria-label="Steps"
|
||||
class="bg-surface-base! sticky top-0 z-10 flex w-full items-start pb-2"
|
||||
|
||||
@@ -2203,7 +2203,7 @@
|
||||
"inputLabel": "Sample Input",
|
||||
"invalidJson": "Enter a valid JSON array of records.",
|
||||
"resultHint": "After running, each step shows a check or an error icon on the canvas.",
|
||||
"notVerified": "Not verified — an earlier step errored, so records may not have reached this one.",
|
||||
"notVerified": "No records reached this step — an upstream condition filtered them out, or an earlier step errored.",
|
||||
"run": "Run Test",
|
||||
"allPassed": "All steps passed.",
|
||||
"nErrored": "{count} step(s) errored.",
|
||||
@@ -2212,15 +2212,24 @@
|
||||
"errorsN": "{count} error(s)",
|
||||
"runError": "Unable to run the test.",
|
||||
"stepResult": {
|
||||
"viewHint": "Click To View Input / Output",
|
||||
"viewHint": "Click to view input / output",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"noInput": "No Input Captured For This Step.",
|
||||
"noOutput": "No Output Captured For This Step.",
|
||||
"noInput": "This step received no records on the last run.",
|
||||
"noOutput": "No output captured for this step.",
|
||||
"sentExternally": "Sent to the external destination",
|
||||
"destinationNoRecords": "No records reached this destination — nothing was sent.",
|
||||
"branchFiltered": "0 records — nothing passed to this branch.",
|
||||
"conditionNoMatch": "No records matched this condition — nothing continued downstream.",
|
||||
"errorHeading": "Error",
|
||||
"forwardedHeading": "Passed Downstream",
|
||||
"useAsTestInput": "Use these records as the test input",
|
||||
"useAsTestInputNoInput": "No input records to use as the test input.",
|
||||
"useAsTestInputNoOutput": "This step produced no output to use as the test input.",
|
||||
"copyInput": "Copy Input",
|
||||
"copyOutput": "Copy Output",
|
||||
"copiedInput": "Input Copied To Clipboard",
|
||||
"copiedOutput": "Output Copied To Clipboard",
|
||||
"copiedInput": "Input copied to clipboard",
|
||||
"copiedOutput": "Output copied to clipboard",
|
||||
"enterFullscreen": "Enter Fullscreen",
|
||||
"exitFullscreen": "Exit Fullscreen",
|
||||
"replay": "Replay",
|
||||
@@ -2228,7 +2237,7 @@
|
||||
"status": {
|
||||
"ok": "Passed",
|
||||
"error": "Errored",
|
||||
"skipped": "Not Verified"
|
||||
"skipped": "No Records"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2253,6 +2262,7 @@
|
||||
"linkError": "Unable To Link Alerts."
|
||||
},
|
||||
"chooseTrigger": "Choose a Trigger",
|
||||
"edgeDeleteHint": "Press Backspace / Delete To Remove The Edge",
|
||||
"node": {
|
||||
"kindTrigger": "Trigger",
|
||||
"kindLogic": "Logic",
|
||||
|
||||
@@ -39,6 +39,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
@node-change="onNodeChange"
|
||||
@nodes-change="onNodesChange"
|
||||
@edges-change="onEdgesChange"
|
||||
@edge-click="onEdgeClick"
|
||||
@connect="onConnect"
|
||||
@drop="onDrop"
|
||||
@dragover="onDragOver"
|
||||
@@ -99,6 +100,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</Controls>
|
||||
</VueFlow>
|
||||
|
||||
<!-- Edge-deletion help hint — shown on edge click (same affordance as the
|
||||
pipeline canvas). Auto-hides after a few seconds; never shown on the
|
||||
read-only Runs canvas where edges can't be removed. -->
|
||||
<div
|
||||
v-if="showEdgeHelpNotification"
|
||||
data-test="workflow-edge-delete-hint"
|
||||
class="bg-surface-base text-text-body rounded-default border-border-default absolute top-5 left-1/2 z-1000 flex -translate-x-1/2 items-center border px-4 py-2.5 text-sm shadow-[0_4px_20px_rgba(0,0,0,0.15)] dark:shadow-[0_4px_20px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<OIcon name="info" class="mr-1" size="sm" />
|
||||
{{ t("workflow.edgeDeleteHint") }}
|
||||
</div>
|
||||
|
||||
<!-- Empty-canvas start node (replaces the old "add a trigger" hint text). An
|
||||
OVERLAY, not a Vue Flow node: a real node would land in
|
||||
`currentSelectedWorkflow.nodes` and show up in save, validation and the
|
||||
@@ -140,7 +153,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
// workflow canvases cannot drift. Intentionally global: the selectors target
|
||||
// VueFlow's own markup, which never carries a scoped data-attribute.
|
||||
import "@/components/flow/flow-canvas.css";
|
||||
import { ref, computed } from "vue";
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
||||
import { VueFlow, useVueFlow } from "@vue-flow/core";
|
||||
import { Background } from "@vue-flow/background";
|
||||
import { Controls, ControlButton } from "@vue-flow/controls";
|
||||
@@ -148,6 +161,7 @@ import { useI18n } from "vue-i18n";
|
||||
import WorkflowNode from "./WorkflowNode.vue";
|
||||
import FlowEdge from "@/components/flow/FlowEdge.vue";
|
||||
import FlowNodeCard from "@/components/flow/FlowNodeCard.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import useWorkflowCanvas from "./useWorkflowCanvas";
|
||||
|
||||
import "@vue-flow/core/dist/style.css";
|
||||
@@ -166,12 +180,53 @@ const {
|
||||
openTriggerPicker,
|
||||
} = useWorkflowCanvas();
|
||||
|
||||
const { onNodesInitialized, setViewport, viewport, dimensions, findNode } = useVueFlow();
|
||||
const {
|
||||
onNodesInitialized,
|
||||
setViewport,
|
||||
viewport,
|
||||
dimensions,
|
||||
findNode,
|
||||
getSelectedEdges,
|
||||
removeEdges,
|
||||
} = useVueFlow();
|
||||
|
||||
const vueFlowRef = ref<any>(null);
|
||||
// Read-only inspection canvas (the Runs view) — disables node drag/connect and,
|
||||
// via WorkflowNode, the hover add/delete + click-to-edit. Run overlays stay.
|
||||
const readOnly = computed(() => workflowObj.readOnly);
|
||||
|
||||
// Edges have no delete button; clicking one selects it, so surface a transient
|
||||
// hint that Backspace/Delete removes it (same affordance as the pipeline canvas).
|
||||
// Skipped on the read-only Runs canvas, where edges can't be removed.
|
||||
const showEdgeHelpNotification = ref(false);
|
||||
let edgeHintTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const onEdgeClick = () => {
|
||||
if (readOnly.value) return;
|
||||
if (edgeHintTimeout) clearTimeout(edgeHintTimeout);
|
||||
showEdgeHelpNotification.value = true;
|
||||
edgeHintTimeout = setTimeout(() => {
|
||||
showEdgeHelpNotification.value = false;
|
||||
edgeHintTimeout = null;
|
||||
}, 3500);
|
||||
};
|
||||
|
||||
// Backspace/Delete removes the selected edge (the action the hint advertises).
|
||||
// Scoped to EDGES only so node deletion keeps flowing through the confirm dialog.
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (readOnly.value) return;
|
||||
if (event.key !== "Delete" && event.key !== "Backspace") return;
|
||||
const selected = getSelectedEdges.value;
|
||||
if (!selected.length) return;
|
||||
event.preventDefault();
|
||||
removeEdges(selected.map((e) => e.id));
|
||||
showEdgeHelpNotification.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
if (edgeHintTimeout) clearTimeout(edgeHintTimeout);
|
||||
});
|
||||
// The "Choose a Trigger" start node shows whenever the workflow has NO TRIGGER —
|
||||
// not only when the canvas is empty. A workflow needs exactly one trigger, and
|
||||
// the trigger is now deletable (its kind can be swapped); if the user deletes it
|
||||
|
||||
@@ -530,11 +530,35 @@ describe("WorkflowNode", () => {
|
||||
});
|
||||
|
||||
it("renders the grey not-verified badge for a blocked (downstream-of-error) node", () => {
|
||||
// No `inputs` map (history-run fallback) → blocked-downstream drives grey.
|
||||
setResult({ errors: {}, ranNodeIds: ["c1"], blockedNodeIds: ["c1"] });
|
||||
wrapper = mountNode("c1", CONDITION.data);
|
||||
const badge = wrapper.find('[data-test="workflow-node-condition-test-skipped"]');
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("Not verified");
|
||||
expect(wrapper.text()).toContain("No records reached this step");
|
||||
});
|
||||
|
||||
// Live Test run carries a per-node `inputs` map — the badge reflects whether
|
||||
// records ACTUALLY reached the node, not just graph reachability.
|
||||
it("green tick when the node received records (in the inputs map)", () => {
|
||||
setResult({
|
||||
errors: {},
|
||||
inputs: { c1: [{ meta: {}, data: [{ x: 1 }] }] },
|
||||
ranNodeIds: ["c1"],
|
||||
blockedNodeIds: [],
|
||||
});
|
||||
wrapper = mountNode("c1", CONDITION.data);
|
||||
expect(wrapper.find('[data-test="workflow-node-condition-test-ok"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("grey badge when the node was reached but got 0 records (filtered upstream)", () => {
|
||||
// c1 is reachable (ranNodeIds) but absent from `inputs` → not a false pass.
|
||||
setResult({ errors: {}, inputs: {}, ranNodeIds: ["c1"], blockedNodeIds: [] });
|
||||
wrapper = mountNode("c1", CONDITION.data);
|
||||
expect(wrapper.find('[data-test="workflow-node-condition-test-skipped"]').exists()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(wrapper.find('[data-test="workflow-node-condition-test-ok"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders the red error badge with its messages in the tooltip", () => {
|
||||
@@ -628,11 +652,23 @@ describe("WorkflowNode", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("the ok badge is not clickable (no drawer)", async () => {
|
||||
setResult({ errors: {}, ranNodeIds: ["c1"], blockedNodeIds: [] });
|
||||
it("opens the drawer when the ok badge is clicked (every ran node is inspectable)", async () => {
|
||||
setResult({
|
||||
errors: {},
|
||||
inputs: { c1: [{ meta: {}, data: [{ x: 1 }] }] },
|
||||
ranNodeIds: ["c1"],
|
||||
blockedNodeIds: [],
|
||||
});
|
||||
wrapper = mountNode("c1", CONDITION.data);
|
||||
await wrapper.find('[data-test="workflow-node-condition-test-ok"]').trigger("click");
|
||||
expect(workflowObj.testRun.resultDrawer.show).toBe(false);
|
||||
expect(workflowObj.testRun.resultDrawer).toEqual({ show: true, nodeId: "c1" });
|
||||
});
|
||||
|
||||
it("opens the drawer when the grey (skipped) badge is clicked", async () => {
|
||||
setResult({ errors: {}, inputs: {}, ranNodeIds: ["c1"], blockedNodeIds: [] });
|
||||
wrapper = mountNode("c1", CONDITION.data);
|
||||
await wrapper.find('[data-test="workflow-node-condition-test-skipped"]').trigger("click");
|
||||
expect(workflowObj.testRun.resultDrawer).toEqual({ show: true, nodeId: "c1" });
|
||||
});
|
||||
|
||||
it("reacts to a test result arriving after mount", async () => {
|
||||
|
||||
@@ -81,19 +81,26 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
(red, hover for messages, click to open the step drawer). -->
|
||||
<div
|
||||
v-if="testStatus === 'ok'"
|
||||
class="wf-test-badge wf-test-pop nodrag bg-status-positive text-white"
|
||||
class="wf-test-badge wf-test-pop nodrag bg-status-positive cursor-pointer text-white transition-transform duration-150 hover:scale-110"
|
||||
:data-test="`workflow-node-${data?.node_type}-test-ok`"
|
||||
@pointerdown.stop
|
||||
@click.stop
|
||||
@click.stop="openResult"
|
||||
>
|
||||
<OIcon name="check" size="xs" />
|
||||
<OTooltip side="top" align="center" :side-offset="8" max-width="320px">
|
||||
<template #content>
|
||||
<div class="p-2 text-left text-xs">
|
||||
{{ t("workflow.test.stepResult.viewHint") }}
|
||||
</div>
|
||||
</template>
|
||||
</OTooltip>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="testStatus === 'skipped'"
|
||||
class="wf-test-badge wf-test-pop nodrag bg-badge-default-solid-bg text-badge-default-solid-text cursor-help"
|
||||
class="wf-test-badge wf-test-pop nodrag bg-badge-default-solid-bg text-badge-default-solid-text cursor-pointer transition-transform duration-150 hover:scale-110"
|
||||
:data-test="`workflow-node-${data?.node_type}-test-skipped`"
|
||||
@pointerdown.stop
|
||||
@click.stop
|
||||
@click.stop="openResult"
|
||||
>
|
||||
<OIcon name="remove" size="xs" />
|
||||
<OTooltip side="top" align="center" :side-offset="8" max-width="320px">
|
||||
@@ -152,14 +159,19 @@ const { t } = useI18n();
|
||||
const { editNode, requestDeleteNode, openStepPicker } = useWorkflowCanvas();
|
||||
|
||||
// Test result badge state — read from the last Test run. Null (no run, or this
|
||||
// node wasn't part of a `from_node` run) → no badge. A node passes (✓) only when
|
||||
// it ran, has no error, AND no upstream node errored — otherwise it's "skipped"
|
||||
// (not verified), since the backend gives us errors only, not per-node success.
|
||||
// node wasn't part of a `from_node` run) → no badge. A node is a real ✓ only when
|
||||
// records ACTUALLY reached it (it's in the per-node `inputs` map) and it didn't
|
||||
// error. A node the run reached but that received 0 records — e.g. an upstream
|
||||
// condition filtered everything out — is "skipped" (grey), NOT a false pass.
|
||||
const testResult = computed<any>(() => workflowObj.testRun?.result);
|
||||
const testStatus = computed<"ok" | "error" | "skipped" | null>(() => {
|
||||
const r = testResult.value;
|
||||
if (!r || !r.ranNodeIds?.includes(props.id)) return null;
|
||||
if (r.errors?.[props.id]) return "error";
|
||||
// Live Test run carries the per-node `inputs` map: ✓ only if this node got
|
||||
// records; otherwise it ran but processed nothing → grey.
|
||||
if (r.inputs) return r.inputs[props.id]?.length ? "ok" : "skipped";
|
||||
// History run (no `inputs` map): fall back to the blocked-downstream logic.
|
||||
if (r.blockedNodeIds?.includes(props.id)) return "skipped";
|
||||
return "ok";
|
||||
});
|
||||
|
||||
@@ -66,9 +66,21 @@ describe("alertFields", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("types every meta field as string (the real payload's meta is a string:string map)", () => {
|
||||
const types = new Set(TRIGGER_META_VARS.map((v) => v.type));
|
||||
expect([...types]).toEqual(["string"]);
|
||||
it("types the numeric alert fields as int and the rest as string (matches the real payload)", () => {
|
||||
const typeByRef = Object.fromEntries(TRIGGER_META_VARS.map((v) => [v.ref, v.type]));
|
||||
// numeric alert fields + microsecond timestamps -> int
|
||||
expect(typeByRef["meta.alert_period"]).toBe("int");
|
||||
expect(typeByRef["meta.alert_threshold"]).toBe("int");
|
||||
expect(typeByRef["meta.alert_count"]).toBe("int");
|
||||
expect(typeByRef["meta.alert_start_time"]).toBe("int");
|
||||
expect(typeByRef["meta.alert_end_time"]).toBe("int");
|
||||
// everything else is text
|
||||
expect(typeByRef["meta.org_id"]).toBe("string");
|
||||
expect(typeByRef["meta.stream_type"]).toBe("string");
|
||||
expect(typeByRef["meta.stream_name"]).toBe("string");
|
||||
expect(typeByRef["meta.alert_name"]).toBe("string");
|
||||
expect(typeByRef["meta.alert_type"]).toBe("string");
|
||||
expect(typeByRef["meta.alert_operator"]).toBe("string");
|
||||
});
|
||||
|
||||
it("declares enumValues only on meta.alert_type", () => {
|
||||
|
||||
@@ -46,12 +46,12 @@ export interface TriggerOutputVar {
|
||||
enumValues?: string[];
|
||||
}
|
||||
|
||||
// Every value in the emitted `meta` block is a STRING — the backend serialises
|
||||
// the whole block as a string:string map, so numeric-looking fields (count,
|
||||
// threshold, period) and the microsecond-epoch timestamps arrive quoted (e.g.
|
||||
// `"alert_count": "8"`, `"alert_start_time": "1784027820000000"`). Types below
|
||||
// say `string` to match that real payload, so a Condition author isn't misled
|
||||
// into a numeric comparison. Confirmed against a live alert firing.
|
||||
// The emitted `meta` block is a MIXED-type map: text fields are strings, but the
|
||||
// numeric alert fields (period, threshold, count) and the microsecond-epoch
|
||||
// timestamps (start/end time) are real numbers — the backend now types them
|
||||
// (e.g. `"alert_count": 3`, `"alert_start_time": 1785493175167770`). Types below
|
||||
// match that payload so a Condition author reads the right type. Confirmed
|
||||
// against a live alert firing.
|
||||
export const TRIGGER_META_VARS: TriggerOutputVar[] = [
|
||||
{ ref: "meta.org_id", type: "string", descKey: "workflow.triggerMeta.orgId" },
|
||||
{ ref: "meta.stream_type", type: "string", descKey: "workflow.triggerMeta.streamType" },
|
||||
@@ -63,12 +63,12 @@ export const TRIGGER_META_VARS: TriggerOutputVar[] = [
|
||||
descKey: "workflow.triggerMeta.alertType",
|
||||
enumValues: ["realtime", "scheduled"],
|
||||
},
|
||||
{ ref: "meta.alert_period", type: "string", descKey: "workflow.triggerMeta.alertPeriod" },
|
||||
{ ref: "meta.alert_period", type: "int", descKey: "workflow.triggerMeta.alertPeriod" },
|
||||
{ ref: "meta.alert_operator", type: "string", descKey: "workflow.triggerMeta.alertOperator" },
|
||||
{ ref: "meta.alert_threshold", type: "string", descKey: "workflow.triggerMeta.alertThreshold" },
|
||||
{ ref: "meta.alert_count", type: "string", descKey: "workflow.triggerMeta.alertCount" },
|
||||
{ ref: "meta.alert_start_time", type: "string", descKey: "workflow.triggerMeta.alertStartTime" },
|
||||
{ ref: "meta.alert_end_time", type: "string", descKey: "workflow.triggerMeta.alertEndTime" },
|
||||
{ ref: "meta.alert_threshold", type: "int", descKey: "workflow.triggerMeta.alertThreshold" },
|
||||
{ ref: "meta.alert_count", type: "int", descKey: "workflow.triggerMeta.alertCount" },
|
||||
{ ref: "meta.alert_start_time", type: "int", descKey: "workflow.triggerMeta.alertStartTime" },
|
||||
{ ref: "meta.alert_end_time", type: "int", descKey: "workflow.triggerMeta.alertEndTime" },
|
||||
];
|
||||
|
||||
// All fields are `Utf8` for now — the flattened `meta` block is a string:string
|
||||
|
||||
@@ -36,7 +36,7 @@ const pickerSubmit = vi.fn();
|
||||
vi.mock("@/components/flow/forms/DestinationPicker.vue", () => ({
|
||||
default: {
|
||||
name: "DestinationPicker",
|
||||
props: ["initialName"],
|
||||
props: ["initialName", "forcedType"],
|
||||
emits: ["expand"],
|
||||
methods: {
|
||||
submit: (...args: any[]) => pickerSubmit(...args),
|
||||
@@ -95,6 +95,11 @@ describe("WorkflowDestination", () => {
|
||||
const wrapper = createWrapper();
|
||||
expect(picker(wrapper).props("initialName")).toBe("sink-a");
|
||||
});
|
||||
|
||||
it("locks the inline create form to Custom (workflows only support custom)", () => {
|
||||
const wrapper = createWrapper();
|
||||
expect(picker(wrapper).props("forcedType")).toBe("custom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createNewDestination (exposed to the drawer)", () => {
|
||||
|
||||
@@ -31,9 +31,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<template>
|
||||
<div data-test="workflow-destination-body" class="w-full">
|
||||
<!-- Workflows only support Custom HTTP destinations for now, so lock the inline
|
||||
create form to Custom and skip its type-selection step. -->
|
||||
<DestinationPicker
|
||||
ref="picker"
|
||||
:initial-name="savedData.destination_id || ''"
|
||||
forced-type="custom"
|
||||
@expand="(v) => (creating = v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("buildTestSample", () => {
|
||||
expect(Object.keys(firing.meta)).toEqual(expectedKeys);
|
||||
});
|
||||
|
||||
it("prefills the documented named defaults (all string values, matching the real payload)", () => {
|
||||
it("prefills the documented named defaults (numeric fields as numbers, text as strings)", () => {
|
||||
const [firing] = buildTestSample() as [Envelope];
|
||||
expect(firing.meta).toEqual({
|
||||
org_id: "default",
|
||||
@@ -55,29 +55,35 @@ describe("buildTestSample", () => {
|
||||
stream_name: "default",
|
||||
alert_name: "High Error Rate",
|
||||
alert_type: "scheduled",
|
||||
alert_period: "10",
|
||||
alert_period: 10,
|
||||
alert_operator: ">=",
|
||||
alert_threshold: "100",
|
||||
alert_count: "137",
|
||||
alert_start_time: String(SAMPLE_TS),
|
||||
alert_end_time: String(SAMPLE_TS + 600000000),
|
||||
alert_threshold: 100,
|
||||
alert_count: 137,
|
||||
alert_start_time: SAMPLE_TS,
|
||||
alert_end_time: SAMPLE_TS + 600000000,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses microsecond-epoch timestamps 10 minutes apart", () => {
|
||||
const [firing] = buildTestSample() as [Envelope];
|
||||
expect(firing.meta.alert_start_time).toBe(String(SAMPLE_TS));
|
||||
expect(firing.meta.alert_start_time).toBe(SAMPLE_TS);
|
||||
expect(Number(firing.meta.alert_end_time) - Number(firing.meta.alert_start_time)).toBe(
|
||||
600000000,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits every meta field as a string (the real payload's meta is a string:string map)", () => {
|
||||
it("types the numeric meta fields as numbers and text fields as strings", () => {
|
||||
const [firing] = buildTestSample() as [Envelope];
|
||||
expect(typeof firing.meta.alert_period).toBe("string");
|
||||
expect(typeof firing.meta.alert_threshold).toBe("string");
|
||||
expect(typeof firing.meta.alert_count).toBe("string");
|
||||
expect(typeof firing.meta.alert_start_time).toBe("string");
|
||||
// numeric alert fields + timestamps -> numbers
|
||||
expect(typeof firing.meta.alert_period).toBe("number");
|
||||
expect(typeof firing.meta.alert_threshold).toBe("number");
|
||||
expect(typeof firing.meta.alert_count).toBe("number");
|
||||
expect(typeof firing.meta.alert_start_time).toBe("number");
|
||||
expect(typeof firing.meta.alert_end_time).toBe("number");
|
||||
// text fields stay strings
|
||||
expect(typeof firing.meta.alert_name).toBe("string");
|
||||
expect(typeof firing.meta.stream_type).toBe("string");
|
||||
expect(typeof firing.meta.alert_operator).toBe("string");
|
||||
});
|
||||
|
||||
it("uses a valid enum member for alert_type", () => {
|
||||
@@ -144,17 +150,17 @@ describe("buildFlatTestSample", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("stringifies every meta value (the flattened meta block is string:string)", () => {
|
||||
it("preserves each meta value's native type when flattening", () => {
|
||||
const [row] = buildFlatTestSample() as [Record<string, unknown>];
|
||||
Object.keys(row)
|
||||
.filter((k) => k.startsWith("meta_"))
|
||||
.forEach((k) => {
|
||||
expect(typeof row[k]).toBe("string");
|
||||
});
|
||||
expect(row.meta_alert_period).toBe("10");
|
||||
expect(row.meta_alert_threshold).toBe("100");
|
||||
expect(row.meta_alert_count).toBe("137");
|
||||
expect(row.meta_alert_start_time).toBe(String(SAMPLE_TS));
|
||||
// numeric alert fields + timestamps stay numbers
|
||||
expect(row.meta_alert_period).toBe(10);
|
||||
expect(row.meta_alert_threshold).toBe(100);
|
||||
expect(row.meta_alert_count).toBe(137);
|
||||
expect(row.meta_alert_start_time).toBe(SAMPLE_TS);
|
||||
expect(typeof row.meta_alert_count).toBe("number");
|
||||
// text fields stay strings
|
||||
expect(row.meta_alert_name).toBe("High Error Rate");
|
||||
expect(typeof row.meta_alert_name).toBe("string");
|
||||
});
|
||||
|
||||
it("preserves the original row columns with their original types", () => {
|
||||
@@ -265,9 +271,9 @@ describe("type-based fallbacks for unmapped meta fields", () => {
|
||||
|
||||
it("still prefers the named default over the type default when the key is mapped", async () => {
|
||||
const { buildTestSample: build } = await loadWith([
|
||||
{ ref: "meta.alert_count", type: "string", descKey: "x" },
|
||||
{ ref: "meta.alert_count", type: "int", descKey: "x" },
|
||||
]);
|
||||
expect((build() as any)[0].meta).toEqual({ alert_count: "137" });
|
||||
expect((build() as any)[0].meta).toEqual({ alert_count: 137 });
|
||||
});
|
||||
|
||||
it("produces an empty meta block when the schema is empty", async () => {
|
||||
|
||||
@@ -18,35 +18,36 @@
|
||||
// Derived from TRIGGER_META_VARS so the sample's `meta` keys stay in sync with
|
||||
// the Alert-Trigger schema reference. Shape matches a real alert firing (verified
|
||||
// against live payload):
|
||||
// [ { meta: { ...fixed alert fields, all STRING values }, data: [ { ...rows } ] } ]
|
||||
// The `meta` block is a string:string map — even numeric fields (count,
|
||||
// threshold, period) and the microsecond-epoch timestamps are quoted strings.
|
||||
// `data[]` rows keep their native column types (the alert query's result rows).
|
||||
// [ { meta: { ...fixed alert fields }, data: [ { ...rows } ] } ]
|
||||
// The `meta` block is a MIXED map: text fields are strings, but the numeric alert
|
||||
// fields (period, threshold, count) and the microsecond-epoch timestamps
|
||||
// (start/end time) arrive as real numbers — the backend now types them, so the
|
||||
// sample must too. `data[]` rows keep their native column types.
|
||||
import { TRIGGER_META_VARS } from "./alertFields";
|
||||
|
||||
const SAMPLE_TS = 1700000000000000; // microsecond epoch, matches alert timestamps
|
||||
|
||||
// A readable placeholder per meta field, keyed by the field name (ref minus the
|
||||
// "meta." prefix). All values are STRINGS to match the real payload. Falls back
|
||||
// to "" for anything unmapped.
|
||||
const NAMED_DEFAULTS: Record<string, string> = {
|
||||
// "meta." prefix). Numeric fields are numbers, text fields are strings — matching
|
||||
// the real payload's types. Falls back to "" for anything unmapped.
|
||||
const NAMED_DEFAULTS: Record<string, string | number> = {
|
||||
org_id: "default",
|
||||
stream_type: "logs",
|
||||
stream_name: "default",
|
||||
alert_name: "High Error Rate",
|
||||
alert_type: "scheduled",
|
||||
alert_operator: ">=",
|
||||
alert_period: "10",
|
||||
alert_threshold: "100",
|
||||
alert_count: "137",
|
||||
alert_start_time: String(SAMPLE_TS),
|
||||
alert_end_time: String(SAMPLE_TS + 600000000),
|
||||
alert_period: 10,
|
||||
alert_threshold: 100,
|
||||
alert_count: 137,
|
||||
alert_start_time: SAMPLE_TS,
|
||||
alert_end_time: SAMPLE_TS + 600000000,
|
||||
};
|
||||
|
||||
const typeDefault = (v: { enumValues?: string[] }) => (v.enumValues?.length ? v.enumValues[0] : "");
|
||||
|
||||
export const buildTestSample = (): unknown[] => {
|
||||
const meta: Record<string, string> = {};
|
||||
const meta: Record<string, string | number> = {};
|
||||
for (const v of TRIGGER_META_VARS) {
|
||||
const key = v.ref.replace(/^meta\./, "");
|
||||
meta[key] = key in NAMED_DEFAULTS ? NAMED_DEFAULTS[key] : typeDefault(v);
|
||||
@@ -65,15 +66,15 @@ export const buildTestSample = (): unknown[] => {
|
||||
export const buildTestSampleText = (): string => JSON.stringify(buildTestSample(), null, 2);
|
||||
|
||||
// The FLATTENED view a Function node sees when "After Flattening" (RAF, the
|
||||
// default) is on: the `meta` block becomes `meta_<field>` string columns merged
|
||||
// onto each `data[]` row — matching the field names Conditions use. Derived from
|
||||
// buildTestSample() so both stay in sync. Meta values are strings (the flattened
|
||||
// `meta` block is a string:string map for now).
|
||||
// default) is on: the `meta` block becomes `meta_<field>` columns merged onto
|
||||
// each `data[]` row — matching the field names Conditions use. Derived from
|
||||
// buildTestSample() so both stay in sync, preserving each field's native type
|
||||
// (numeric alert fields stay numbers, text fields stay strings).
|
||||
export const buildFlatTestSample = (): unknown[] => {
|
||||
const [{ meta, data }] = buildTestSample() as [
|
||||
{ meta: Record<string, unknown>; data: Record<string, unknown>[] },
|
||||
];
|
||||
const metaFlat: Record<string, string> = {};
|
||||
for (const k of Object.keys(meta)) metaFlat[`meta_${k}`] = String(meta[k]);
|
||||
const metaFlat: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(meta)) metaFlat[`meta_${k}`] = meta[k];
|
||||
return data.map((row) => ({ ...metaFlat, ...row }));
|
||||
};
|
||||
|
||||
@@ -48,6 +48,8 @@ import useWorkflowCanvas, {
|
||||
loadWorkflowRun,
|
||||
executeTestRun,
|
||||
serializeWorkflow,
|
||||
nodeTestInput,
|
||||
nodeTestOutputBranches,
|
||||
currentTriggerKind,
|
||||
} from "@/plugins/workflows/useWorkflowCanvas";
|
||||
|
||||
@@ -77,12 +79,16 @@ describe("loadWorkflowRun — history run response mapping", () => {
|
||||
workflowObj.testRun.result = null;
|
||||
});
|
||||
|
||||
it("maps errors.data (array) to a node-keyed map and node_map to nodeInputs", async () => {
|
||||
it("maps errors.data (array) to a node-keyed map and input_map to the inputs map", async () => {
|
||||
const envelope = [{ meta: { alert_name: "t" }, data: [{ a: 1 }] }];
|
||||
mockRun.mockResolvedValue({
|
||||
data: {
|
||||
errors: { run_id: "r1", data: [{ node_id: "n2", error: ["boom"] }] },
|
||||
data: { complete: envelope, node_map: { n2: envelope } },
|
||||
// input_map = per-node input for ALL nodes (same shape as a Test run)
|
||||
data: {
|
||||
input_map: { n1: envelope, n2: envelope, n3: envelope },
|
||||
error_node_map: { n2: envelope },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,20 +100,28 @@ describe("loadWorkflowRun — history run response mapping", () => {
|
||||
expect(res.runId).toBe("r1");
|
||||
// array -> keyed map, in the { error_count, errors: [[msg]] } badge shape
|
||||
expect(res.errors.n2).toEqual({ error_count: 1, errors: [["boom"]] });
|
||||
// per-node input carried through verbatim (the {meta,data} envelope)
|
||||
expect(res.nodeInputs.n2).toEqual(envelope);
|
||||
expect(res.fullInput).toEqual(envelope);
|
||||
// per-node input stored under `inputs` (drives Input + derived Output + badges)
|
||||
expect(res.inputs).toEqual({ n1: envelope, n2: envelope, n3: envelope });
|
||||
// every node counts as "ran"; n3 is downstream of the errored n2 -> blocked
|
||||
expect(res.ranNodeIds).toEqual(["n1", "n2", "n3"]);
|
||||
expect(res.blockedNodeIds).toContain("n3");
|
||||
expect(res.blockedNodeIds).not.toContain("n2");
|
||||
});
|
||||
|
||||
it("falls back to error_node_map for older runs that lack input_map", async () => {
|
||||
const envelope = [{ meta: {}, data: [{ a: 1 }] }];
|
||||
mockRun.mockResolvedValue({
|
||||
data: { errors: { data: [] }, data: { error_node_map: { n2: envelope } } },
|
||||
});
|
||||
await loadWorkflowRun({ orgId: "o", workflowId: "wf1", runId: "r1b" });
|
||||
expect((workflowObj.testRun.result as any).inputs).toEqual({ n2: envelope });
|
||||
});
|
||||
|
||||
it("normalizes a non-array error field into a single-message list", async () => {
|
||||
mockRun.mockResolvedValue({
|
||||
data: {
|
||||
errors: { data: [{ node_id: "n2", error: "single" }] },
|
||||
data: { node_map: {} },
|
||||
data: { error_node_map: {} },
|
||||
},
|
||||
});
|
||||
await loadWorkflowRun({ orgId: "o", workflowId: "wf1", runId: "r2" });
|
||||
@@ -119,7 +133,7 @@ describe("loadWorkflowRun — history run response mapping", () => {
|
||||
|
||||
it("handles a clean run (no errors) with empty maps", async () => {
|
||||
mockRun.mockResolvedValue({
|
||||
data: { errors: { data: [] }, data: { node_map: {} } },
|
||||
data: { errors: { data: [] }, data: { error_node_map: {} } },
|
||||
});
|
||||
const r = await loadWorkflowRun({ orgId: "o", workflowId: "wf1", runId: "r3" });
|
||||
expect(r.ok).toBe(true);
|
||||
@@ -142,18 +156,18 @@ describe("loadWorkflowRun — history run response mapping", () => {
|
||||
{ node_id: "deleted-node", error: ["gone"] },
|
||||
],
|
||||
},
|
||||
data: { node_map: {} },
|
||||
data: { error_node_map: {} },
|
||||
},
|
||||
});
|
||||
await loadWorkflowRun({ orgId: "o", workflowId: "wf1", runId: "r1" });
|
||||
expect((workflowObj.testRun.result as any).ghostNodeIds).toEqual(["deleted-node"]);
|
||||
});
|
||||
|
||||
it("also flags a ghost referenced only by node_map (no error)", async () => {
|
||||
it("also flags a ghost referenced only by error_node_map (no error)", async () => {
|
||||
mockRun.mockResolvedValue({
|
||||
data: {
|
||||
errors: { data: [] },
|
||||
data: { node_map: { n1: [], "old-node": [] } },
|
||||
data: { error_node_map: { n1: [], "old-node": [] } },
|
||||
},
|
||||
});
|
||||
await loadWorkflowRun({ orgId: "o", workflowId: "wf1", runId: "r2" });
|
||||
@@ -164,18 +178,18 @@ describe("loadWorkflowRun — history run response mapping", () => {
|
||||
mockRun.mockResolvedValue({
|
||||
data: {
|
||||
errors: { data: [{ node_id: "n2", error: ["boom"] }] },
|
||||
data: { node_map: { n1: [], n3: [] } },
|
||||
data: { error_node_map: { n1: [], n3: [] } },
|
||||
},
|
||||
});
|
||||
await loadWorkflowRun({ orgId: "o", workflowId: "wf1", runId: "r3" });
|
||||
expect((workflowObj.testRun.result as any).ghostNodeIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not double-report a ghost referenced by BOTH errors and node_map", async () => {
|
||||
it("does not double-report a ghost referenced by BOTH errors and error_node_map", async () => {
|
||||
mockRun.mockResolvedValue({
|
||||
data: {
|
||||
errors: { data: [{ node_id: "zombie", error: ["x"] }] },
|
||||
data: { node_map: { zombie: [] } },
|
||||
data: { error_node_map: { zombie: [] } },
|
||||
},
|
||||
});
|
||||
await loadWorkflowRun({ orgId: "o", workflowId: "wf1", runId: "r4" });
|
||||
@@ -317,6 +331,94 @@ describe("executeTestRun — ran-node scope + badge state", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// nodeTestInput / nodeTestOutputBranches derive per-node Input and Output from the
|
||||
// backend `inputs` map. Output on an edge == the child's input (single-incoming
|
||||
// tree), so these two helpers power the whole step-drawer.
|
||||
describe("nodeTestInput + nodeTestOutputBranches — per-node I/O derivation", () => {
|
||||
beforeEach(() => {
|
||||
// trigger(t) -> function(f) -> destination(d); t also -> condition(c) (fan-out)
|
||||
workflowObj.currentSelectedWorkflow = {
|
||||
id: "wf1",
|
||||
name: "wf",
|
||||
nodes: [
|
||||
{ id: "t", data: { node_type: "workflow_trigger" } },
|
||||
{ id: "f", data: { node_type: "function", name: "fn" } },
|
||||
{ id: "d", data: { node_type: "destination", destination_id: "sink" } },
|
||||
{ id: "c", data: { node_type: "condition" } },
|
||||
],
|
||||
edges: [
|
||||
{ source: "t", target: "f" },
|
||||
{ source: "t", target: "c" },
|
||||
{ source: "f", target: "d" },
|
||||
],
|
||||
} as any;
|
||||
workflowObj.testRun.result = {
|
||||
errors: {},
|
||||
inputs: { t: [{ x: 0 }], f: [{ x: 1 }], d: [{ x: 2 }] }, // c got nothing
|
||||
ranNodeIds: ["t", "f", "c", "d"],
|
||||
blockedNodeIds: [],
|
||||
} as any;
|
||||
});
|
||||
|
||||
it("nodeTestInput returns the records a node received, null when absent", () => {
|
||||
expect(nodeTestInput("f")).toEqual([{ x: 1 }]);
|
||||
expect(nodeTestInput("c")).toBeNull(); // filtered out — not in inputs
|
||||
expect(nodeTestInput("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("nodeTestInput is null when there is no run", () => {
|
||||
workflowObj.testRun.result = null as any;
|
||||
expect(nodeTestInput("f")).toBeNull();
|
||||
});
|
||||
|
||||
it("nodeTestOutputBranches: a node's output == each child's input", () => {
|
||||
const branches = nodeTestOutputBranches("f");
|
||||
expect(branches).toHaveLength(1);
|
||||
expect(branches[0]).toMatchObject({ targetId: "d", nodeType: "destination" });
|
||||
expect(branches[0].records).toEqual([{ x: 2 }]); // == inputs[d]
|
||||
});
|
||||
|
||||
it("fan-out yields one branch per outgoing edge; a filtered branch has null records", () => {
|
||||
const branches = nodeTestOutputBranches("t");
|
||||
expect(branches.map((b) => b.targetId).sort()).toEqual(["c", "f"]);
|
||||
const toC = branches.find((b) => b.targetId === "c")!;
|
||||
const toF = branches.find((b) => b.targetId === "f")!;
|
||||
expect(toC.records).toBeNull(); // c received nothing
|
||||
expect(toF.records).toEqual([{ x: 1 }]);
|
||||
});
|
||||
|
||||
it("a terminal (destination) has no outgoing edges → no branches", () => {
|
||||
expect(nodeTestOutputBranches("d")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// executeTestRun stores the per-node inputs map so the drawer/badges can read it.
|
||||
describe("executeTestRun — stores the per-node inputs map", () => {
|
||||
it("keeps res.data.inputs on testRun.result", async () => {
|
||||
workflowObj.currentSelectedWorkflow = {
|
||||
id: "wf1",
|
||||
name: "wf",
|
||||
nodes: [{ id: "t", data: { node_type: "workflow_trigger" } }],
|
||||
edges: [],
|
||||
} as any;
|
||||
mockTest.mockResolvedValue({ data: { errors: {}, inputs: { t: [{ x: 1 }] } } });
|
||||
await executeTestRun({ orgId: "o", inputs: [{ a: 1 }] });
|
||||
expect((workflowObj.testRun.result as any).inputs).toEqual({ t: [{ x: 1 }] });
|
||||
});
|
||||
|
||||
it("defaults inputs to {} when the response omits it", async () => {
|
||||
workflowObj.currentSelectedWorkflow = {
|
||||
id: "wf1",
|
||||
name: "wf",
|
||||
nodes: [{ id: "t", data: { node_type: "workflow_trigger" } }],
|
||||
edges: [],
|
||||
} as any;
|
||||
mockTest.mockResolvedValue({ data: { errors: {} } });
|
||||
await executeTestRun({ orgId: "o", inputs: [{ a: 1 }] });
|
||||
expect((workflowObj.testRun.result as any).inputs).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// serializeWorkflow builds the backend `Workflow` object shared by both the
|
||||
// create/update payload and the Test run — so testing without saving sends the
|
||||
// exact same graph the editor would persist.
|
||||
|
||||
@@ -401,6 +401,10 @@ export const executeTestRun = async (opts: {
|
||||
from_node: opts.fromNode || undefined,
|
||||
});
|
||||
const errors = res.data?.errors || {};
|
||||
// Per-node INPUT map: node_id -> the records that node received. A node's
|
||||
// OUTPUT is derived from this (see nodeTestOutputBranches): since the graph is
|
||||
// a single-incoming tree, a child's input IS its parent's output on that edge.
|
||||
const inputs = res.data?.inputs || {};
|
||||
// Which nodes ran: from a replay, `fromNode` + everything downstream;
|
||||
// otherwise everything reachable from the trigger. Nodes NOT reachable
|
||||
// (unwired / disconnected) never executed, so they must not paint a ✓.
|
||||
@@ -411,6 +415,7 @@ export const executeTestRun = async (opts: {
|
||||
const ranNodeIds = startId ? [...reachableFrom(wf.edges || [], [startId])] : [];
|
||||
workflowObj.testRun.result = {
|
||||
errors,
|
||||
inputs,
|
||||
ranNodeIds,
|
||||
blockedNodeIds: downstreamOfErrorNodes(Object.keys(errors)),
|
||||
};
|
||||
@@ -422,14 +427,55 @@ export const executeTestRun = async (opts: {
|
||||
}
|
||||
};
|
||||
|
||||
// A single downstream branch of a node's OUTPUT: the target it feeds and the
|
||||
// records that target received (== what this node emitted on that edge). `records`
|
||||
// is null when the target got nothing (filtered out / never reached).
|
||||
export interface NodeOutputBranch {
|
||||
targetId: string;
|
||||
nodeType: string;
|
||||
detail: string;
|
||||
records: any[] | null;
|
||||
}
|
||||
|
||||
// The INPUT a node received on the last Test run — the raw records from the
|
||||
// backend `inputs` map (shape varies by node type; rendered as-is). Null when the
|
||||
// node isn't in the map (0 records reached it) or there's no run.
|
||||
export const nodeTestInput = (nodeId: string): any[] | null => {
|
||||
const inputs = workflowObj.testRun.result?.inputs;
|
||||
const v = inputs?.[nodeId];
|
||||
return Array.isArray(v) ? v : null;
|
||||
};
|
||||
|
||||
// A node's OUTPUT, per outgoing edge. The graph is a single-incoming tree, so
|
||||
// each child's input came ONLY from this node — child input == this node's output
|
||||
// on that branch. One entry per outgoing edge (so fan-out reads per-target); an
|
||||
// empty array means a terminal node (a destination/sink) with no derivable output.
|
||||
export const nodeTestOutputBranches = (nodeId: string): NodeOutputBranch[] => {
|
||||
const wf = workflowObj.currentSelectedWorkflow;
|
||||
const byId = new Map<string, any>((wf.nodes || []).map((n: any) => [n.id, n]));
|
||||
return (wf.edges || [])
|
||||
.filter((e: any) => e.source === nodeId)
|
||||
.map((e: any) => {
|
||||
const target = byId.get(e.target);
|
||||
return {
|
||||
targetId: e.target,
|
||||
nodeType: target?.data?.node_type || "",
|
||||
detail: nodeConfigDetail(target?.data, 40),
|
||||
records: nodeTestInput(e.target),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Load a PAST run (from the Executions history) into the same testRun.result the
|
||||
// canvas already reads — so error nodes paint ✗ and open the step drawer, but
|
||||
// read-only (no editable input / Replay). The run detail carries:
|
||||
// errors.data: [{ node_id, error: string[] }] — errored nodes + messages
|
||||
// data.node_map: { node_id: [{ meta, data }] } — per-node input processed
|
||||
// data.complete: [{ meta, data }] — full workflow input
|
||||
// The array-vs-map difference in errors.data is bridged here (each entry has its
|
||||
// node_id), so it lines up with node_map by key.
|
||||
// canvas already reads — read-only (no editable input / Replay). The run detail
|
||||
// now mirrors the Test response shape, so history shows per-node Input/Output for
|
||||
// EVERY node (not just error nodes):
|
||||
// errors.data: [{ node_id, error: string[] }] — errored nodes + messages
|
||||
// data.input_map: { node_id: [records] } — per-node INPUT (all nodes)
|
||||
// data.error_node_map: { node_id: [records] } — legacy: errored node's input
|
||||
// input_map is the same per-node `inputs` map a Test run produces, so we store it
|
||||
// under the same key and the whole drawer (Input + derived Output + badges) works
|
||||
// identically to Test — just read-only. Falls back to error_node_map for older runs.
|
||||
export const loadWorkflowRun = async (opts: {
|
||||
orgId: string;
|
||||
workflowId: string;
|
||||
@@ -458,26 +504,27 @@ export const loadWorkflowRun = async (opts: {
|
||||
};
|
||||
}
|
||||
|
||||
const nodeInputs = payload.data?.node_map || {};
|
||||
// Per-node INPUT for the whole run (all nodes) — the same shape/semantics as a
|
||||
// Test run's `inputs`, so the drawer derives Output the same way. Older runs
|
||||
// only carried error_node_map (errored node's input) — fall back to that.
|
||||
const inputs = payload.data?.input_map || payload.data?.error_node_map || {};
|
||||
|
||||
// GHOST NODES — the run references a node the workflow no longer has (it was
|
||||
// edited/deleted after the run). Its badge has nowhere to render, so an error
|
||||
// would silently vanish and the run would look cleaner than it was. Surface
|
||||
// them so the Runs view can say the graph no longer matches this run.
|
||||
const currentNodeIds = new Set((wf.nodes || []).map((n: any) => n.id));
|
||||
const ghostNodeIds = [...new Set([...Object.keys(errors), ...Object.keys(nodeInputs)])].filter(
|
||||
const ghostNodeIds = [...new Set([...Object.keys(errors), ...Object.keys(inputs)])].filter(
|
||||
(id) => !currentNodeIds.has(id),
|
||||
);
|
||||
|
||||
workflowObj.testRun.result = {
|
||||
errors,
|
||||
// Every node "ran": upstream shows ✓, errored ✗, downstream ⊘ — via the
|
||||
// existing testStatus logic. Only ✗ nodes are clickable.
|
||||
// Same per-node inputs map as a Test run — drives the ✓/grey/✗ badges and the
|
||||
// drawer's Input + derived Output for every node.
|
||||
inputs,
|
||||
ranNodeIds: (wf.nodes || []).map((n: any) => n.id),
|
||||
blockedNodeIds: downstreamOfErrorNodes(Object.keys(errors)),
|
||||
// Per-node input the drawer shows (read-only) for an errored node.
|
||||
nodeInputs,
|
||||
fullInput: payload.data?.complete ?? null,
|
||||
mode: "history",
|
||||
runId: opts.runId,
|
||||
ghostNodeIds,
|
||||
@@ -808,9 +855,8 @@ export default function useWorkflowCanvas() {
|
||||
},
|
||||
},
|
||||
];
|
||||
// Open the (read-only) trigger panel on the now-real node, so closing it
|
||||
// dismisses a panel rather than discarding the node.
|
||||
editNode(id);
|
||||
// Don't auto-open the trigger's (read-only) detail panel — placing the trigger
|
||||
// shouldn't interrupt the build flow. The user can click the node to open it.
|
||||
}
|
||||
|
||||
const NODE_W = 240;
|
||||
|
||||
@@ -105,7 +105,7 @@ const workflows = {
|
||||
},
|
||||
|
||||
// Detail of a single run — errors (per errored node) plus the run's input
|
||||
// data (`complete` = full workflow input, `node_map` = per-node input the
|
||||
// data (`complete` = full workflow input, `error_node_map` = per-node input the
|
||||
// node processed/errored on). Powers the read-only run inspection in the
|
||||
// editor (click a run in history -> error nodes show Input/Output).
|
||||
getWorkflowRun: ({
|
||||
|
||||
Reference in New Issue
Block a user