// Copyright 2026 OpenObserve Inc. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // WorkflowNode is a THIN WRAPPER over the shared FlowNodeCard: the card frame, // handles and icon slot are FlowNodeCard's job (covered by its own spec), so // these tests cover what the WRAPPER owns — the node-type meta lookup // (icon / ioType / label), the per-type body, the test-result badge, the // hover-delete and the hover-`+` step picker. // // FlowNodeCard is stubbed (it renders the slots inline and records the props it // receives); the REAL useWorkflowCanvas singleton is used so the wrapper's // effects are asserted on actual `workflowObj` state. import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mount } from "@vue/test-utils"; import { nextTick } from "vue"; import i18n from "@/locales"; vi.mock("@vue-flow/core", () => ({ useVueFlow: () => ({ screenToFlowCoordinate: vi.fn(() => ({ x: 0, y: 0 })), onNodesInitialized: vi.fn(), updateNode: vi.fn(), }), })); vi.mock("@/utils/zincutils", () => ({ getImageURL: (p: string) => `mock/${p}`, getUUID: () => "new-uuid", })); vi.mock("@/services/workflows", () => ({ default: { testWorkflow: vi.fn(), getWorkflowRun: vi.fn() }, })); vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: vi.fn() })); // FlowNodeCard stub — renders the three slots inline and keeps the card props // observable so we can assert the wrapper's meta -> card mapping. vi.mock("@/components/flow/FlowNodeCard.vue", () => ({ default: { name: "FlowNodeCard", props: ["icon", "ioType", "hasInput", "hasOutput"], template: `
`, }, })); // OTooltip portals its content; render it inline so the badge messages are // assertable. vi.mock("@/lib/overlay/Tooltip/OTooltip.vue", () => ({ default: { name: "OTooltip", template: '
', }, })); import WorkflowNode from "@/plugins/workflows/WorkflowNode.vue"; import { workflowObj } from "@/plugins/workflows/useWorkflowCanvas"; import { toast } from "@/lib/feedback/Toast/useToast"; const TRIGGER = { id: "t1", data: { node_type: "workflow_trigger" } }; const CONDITION = { id: "c1", data: { node_type: "condition" } }; const FUNCTION = { id: "f1", data: { node_type: "function" } }; const DESTINATION = { id: "d1", data: { node_type: "destination" } }; const setGraph = (nodes: any[] = [], edges: any[] = []) => { workflowObj.currentSelectedWorkflow = { id: "wf1", name: "wf", nodes, edges, } as any; }; const mountNode = (id: string, data: any) => mount(WorkflowNode as any, { props: { id, data }, global: { plugins: [i18n] }, }); const card = (wrapper: any) => wrapper.findComponent({ name: "FlowNodeCard" }); describe("WorkflowNode", () => { let wrapper: any = null; beforeEach(() => { vi.clearAllMocks(); setGraph( [TRIGGER, CONDITION, FUNCTION, DESTINATION], [ { source: "t1", target: "c1" }, { source: "c1", target: "d1" }, ], ); workflowObj.testRun = { show: false, input: "", fromNode: "", result: null, resultDrawer: { show: false, nodeId: "" }, } as any; workflowObj.deleteConfirm = { show: false, nodeId: "" }; workflowObj.stepPicker = { show: false, source: "", handle: "out" }; workflowObj.dialog = { show: false, name: "", expand: false }; workflowObj.currentSelectedNodeID = ""; workflowObj.currentSelectedNodeData = null; workflowObj.isEditNode = false; }); afterEach(() => { wrapper?.unmount(); wrapper = null; vi.useRealTimers(); }); describe("meta lookup -> FlowNodeCard props", () => { it("maps the trigger to ioType=input (source handle only)", () => { wrapper = mountNode("t1", TRIGGER.data); expect(card(wrapper).props("ioType")).toBe("input"); expect(card(wrapper).props("hasInput")).toBe(false); expect(card(wrapper).props("hasOutput")).toBe(true); }); it("maps condition/function (logic) to ioType=default (both handles)", () => { wrapper = mountNode("c1", CONDITION.data); expect(card(wrapper).props("ioType")).toBe("default"); expect(card(wrapper).props("hasInput")).toBe(true); expect(card(wrapper).props("hasOutput")).toBe(true); wrapper.unmount(); wrapper = mountNode("f1", FUNCTION.data); expect(card(wrapper).props("ioType")).toBe("default"); }); it("maps the destination to ioType=output (target handle only)", () => { wrapper = mountNode("d1", DESTINATION.data); expect(card(wrapper).props("ioType")).toBe("output"); expect(card(wrapper).props("hasInput")).toBe(true); expect(card(wrapper).props("hasOutput")).toBe(false); }); it("falls back to ioType=default with both handles for an unknown node_type", () => { wrapper = mountNode("x1", { node_type: "not_a_type" }); expect(card(wrapper).props("ioType")).toBe("default"); expect(card(wrapper).props("hasInput")).toBe(true); expect(card(wrapper).props("hasOutput")).toBe(true); }); it("survives an undefined data object", () => { wrapper = mountNode("x1", undefined); expect(wrapper.exists()).toBe(true); expect(card(wrapper).props("ioType")).toBe("default"); }); }); describe("node icon", () => { it('renders the pipeline node image as an "img:" glyph', () => { wrapper = mountNode("f1", FUNCTION.data); expect(card(wrapper).props("icon")).toBe("img:mock/images/pipeline/transform_function.png"); }); it("uses each type's own image", () => { wrapper = mountNode("d1", DESTINATION.data); expect(card(wrapper).props("icon")).toBe("img:mock/images/pipeline/output_remote.png"); }); it('falls back to the "help" glyph for an unknown node_type', () => { wrapper = mountNode("x1", { node_type: "not_a_type" }); expect(card(wrapper).props("icon")).toBe("help"); }); }); describe("data-test", () => { it("stamps workflow-node- on the card root", () => { wrapper = mountNode("c1", CONDITION.data); expect(wrapper.attributes("data-test")).toBe("workflow-node-condition"); }); }); describe("placeholder / incomplete badge", () => { const INCOMPLETE_DEST = { id: "d1", data: { node_type: "destination", destination_id: "" }, meta: { incomplete: "true" }, }; it("shows the 'Set Up Later' badge when the node is a placeholder", () => { setGraph([TRIGGER, INCOMPLETE_DEST], [{ source: "t1", target: "d1" }]); wrapper = mountNode("d1", INCOMPLETE_DEST.data); expect( wrapper.find('[data-test="workflow-node-destination-incomplete-badge"]').exists(), ).toBe(true); }); it("hides the badge for a configured (non-placeholder) destination", () => { wrapper = mountNode("d1", DESTINATION.data); // default graph: no meta.incomplete expect( wrapper.find('[data-test="workflow-node-destination-incomplete-badge"]').exists(), ).toBe(false); }); }); describe("#body — node label", () => { it("shows the trigger's type title (never a config detail)", () => { wrapper = mountNode("t1", TRIGGER.data); expect(wrapper.text()).toContain("Alert Trigger"); }); it("shows a configured condition's rule preview as the label", () => { const data = { node_type: "condition", conditions: { filterType: "group", conditions: [ { filterType: "condition", column: "level", operator: "=", value: "error", }, ], }, }; wrapper = mountNode("c1", data); expect(wrapper.text()).toContain("level = 'error'"); }); it("truncates a long condition preview at 28 chars", () => { const data = { node_type: "condition", conditions: { filterType: "group", conditions: [ { filterType: "condition", column: "a_very_long_column_name_here", operator: "=", value: "a_very_long_value_here", }, ], }, }; wrapper = mountNode("c1", data); expect(wrapper.text()).toContain("..."); expect(wrapper.find('[data-test="workflow-node-detail"]').text().length).toBe(31); }); it("falls back to the type title for a not-yet-configured condition", () => { wrapper = mountNode("c1", CONDITION.data); expect(wrapper.text()).toContain("Condition"); }); it("shows a configured destination's name as the label", () => { wrapper = mountNode("d1", { node_type: "destination", destination_id: "my-webhook", }); expect(wrapper.text()).toContain("my-webhook"); }); it("falls back to the type title for a not-yet-configured destination", () => { wrapper = mountNode("d1", DESTINATION.data); expect(wrapper.text()).toContain("Destination"); }); it("falls back to the raw node_type when there is no meta", () => { wrapper = mountNode("x1", { node_type: "not_a_type" }); expect(wrapper.text()).toContain("not_a_type"); }); }); describe("#body — configured function tag", () => { it('renders " - [RAF]" when after_flatten is true', () => { wrapper = mountNode("f1", { node_type: "function", name: "parse_logs", after_flatten: true, }); expect(wrapper.text()).toContain("parse_logs"); expect(wrapper.find("strong").text()).toBe("[RAF]"); }); it('renders " - [RBF]" when after_flatten is false', () => { wrapper = mountNode("f1", { node_type: "function", name: "parse_logs", after_flatten: false, }); expect(wrapper.find("strong").text()).toBe("[RBF]"); }); it("uses the plain label line (no tag) for a function with no name", () => { wrapper = mountNode("f1", FUNCTION.data); expect(wrapper.find("strong").exists()).toBe(false); expect(wrapper.text()).toContain("Function"); }); }); describe("hover behaviour", () => { it("hides the action buttons until the node is hovered", () => { wrapper = mountNode("c1", CONDITION.data); const actions = wrapper.find('[data-test="workflow-node-condition-actions"]'); expect(actions.exists()).toBe(true); expect(actions.element.style.display).toBe("none"); }); it("shows the action buttons on mouseenter", async () => { wrapper = mountNode("c1", CONDITION.data); await wrapper.trigger("mouseenter"); expect( wrapper.find('[data-test="workflow-node-condition-actions"]').element.style.display, ).not.toBe("none"); }); it("hides them again ~200ms after mouseleave", async () => { vi.useFakeTimers(); wrapper = mountNode("c1", CONDITION.data); await wrapper.trigger("mouseenter"); await wrapper.trigger("mouseleave"); // still visible immediately after leaving expect( wrapper.find('[data-test="workflow-node-condition-actions"]').element.style.display, ).not.toBe("none"); vi.advanceTimersByTime(200); await nextTick(); expect( wrapper.find('[data-test="workflow-node-condition-actions"]').element.style.display, ).toBe("none"); }); it("keeps the buttons visible when the cursor moves onto the actions bar", async () => { vi.useFakeTimers(); wrapper = mountNode("c1", CONDITION.data); await wrapper.trigger("mouseenter"); await wrapper.trigger("mouseleave"); const actions = wrapper.find('[data-test="workflow-node-condition-actions"]'); await actions.trigger("mouseenter"); // cancels the pending hide vi.advanceTimersByTime(500); await nextTick(); expect(actions.element.style.display).not.toBe("none"); }); it("hides the buttons ~200ms after leaving the actions bar", async () => { vi.useFakeTimers(); wrapper = mountNode("c1", CONDITION.data); await wrapper.trigger("mouseenter"); const actions = wrapper.find('[data-test="workflow-node-condition-actions"]'); await actions.trigger("mouseleave"); vi.advanceTimersByTime(200); await nextTick(); expect(actions.element.style.display).toBe("none"); }); it("re-hovering cancels a pending hide", async () => { vi.useFakeTimers(); wrapper = mountNode("c1", CONDITION.data); await wrapper.trigger("mouseenter"); await wrapper.trigger("mouseleave"); await wrapper.trigger("mouseenter"); vi.advanceTimersByTime(500); await nextTick(); expect( wrapper.find('[data-test="workflow-node-condition-actions"]').element.style.display, ).not.toBe("none"); }); }); describe("hover -> outgoing edge tint", () => { it("tints this node's outgoing edges with its role colour on hover", async () => { wrapper = mountNode("c1", CONDITION.data); await wrapper.trigger("mouseenter"); const edge = workflowObj.currentSelectedWorkflow.edges.find((e: any) => e.source === "c1"); expect(edge.style).toMatchObject({ stroke: "#f59e0b", strokeWidth: 2 }); expect(edge.markerEnd).toMatchObject({ color: "#f59e0b" }); }); it("uses blue for the trigger (input) and green for the destination (output)", async () => { wrapper = mountNode("t1", TRIGGER.data); await wrapper.trigger("mouseenter"); expect( workflowObj.currentSelectedWorkflow.edges.find((e: any) => e.source === "t1").style.stroke, ).toBe("#3b82f6"); wrapper.unmount(); // an output node with an outgoing edge (defensive: normally terminal) setGraph([DESTINATION], [{ source: "d1", target: "z1" }]); wrapper = mountNode("d1", DESTINATION.data); await wrapper.trigger("mouseenter"); expect(workflowObj.currentSelectedWorkflow.edges[0].style.stroke).toBe("#22c55e"); }); it("resets the edge to the grey token on mouseleave", async () => { wrapper = mountNode("c1", CONDITION.data); await wrapper.trigger("mouseenter"); await wrapper.trigger("mouseleave"); const edge = workflowObj.currentSelectedWorkflow.edges.find((e: any) => e.source === "c1"); expect(edge.style.stroke).toBe("var(--color-grey-500)"); expect(edge.markerEnd.color).toBe("var(--color-grey-500)"); }); it("leaves other nodes' edges untouched", async () => { wrapper = mountNode("c1", CONDITION.data); await wrapper.trigger("mouseenter"); const other = workflowObj.currentSelectedWorkflow.edges.find((e: any) => e.source === "t1"); expect(other.style).toBeUndefined(); }); it("uses the reset grey for an unknown node type (no role colour)", async () => { setGraph([{ id: "x1", data: { node_type: "not_a_type" } }], [{ source: "x1", target: "c1" }]); wrapper = mountNode("x1", { node_type: "not_a_type" }); await wrapper.trigger("mouseenter"); // no meta -> ioType defaults to "default" -> amber expect(workflowObj.currentSelectedWorkflow.edges[0].style.stroke).toBe("#f59e0b"); }); it("does not throw when the workflow has no edges", async () => { setGraph([CONDITION], undefined as any); wrapper = mountNode("c1", CONDITION.data); await expect(wrapper.trigger("mouseenter")).resolves.not.toThrow(); }); }); describe("delete", () => { it("offers delete on the trigger too (so its kind can be swapped)", async () => { wrapper = mountNode("t1", TRIGGER.data); await wrapper.trigger("mouseenter"); const actions = wrapper.find('[data-test="workflow-node-workflow_trigger-actions"]'); expect(actions.element.style.display).not.toBe("none"); await wrapper .find('[data-test="workflow-node-workflow_trigger-delete-btn"]') .trigger("click"); expect(workflowObj.deleteConfirm).toEqual({ show: true, nodeId: "t1" }); expect(toast).not.toHaveBeenCalled(); }); it("opens the delete confirmation for a non-trigger node", async () => { wrapper = mountNode("c1", CONDITION.data); await wrapper.trigger("mouseenter"); await wrapper.find('[data-test="workflow-node-condition-delete-btn"]').trigger("click"); expect(workflowObj.deleteConfirm).toEqual({ show: true, nodeId: "c1" }); expect(toast).not.toHaveBeenCalled(); }); }); describe("click -> edit node", () => { it("opens the config drawer for this node", async () => { wrapper = mountNode("c1", CONDITION.data); await wrapper.trigger("click"); expect(workflowObj.isEditNode).toBe(true); expect(workflowObj.currentSelectedNodeID).toBe("c1"); expect(workflowObj.dialog.show).toBe(true); expect(workflowObj.dialog.name).toBe("condition"); }); it("does nothing when the node is not on the canvas", async () => { wrapper = mountNode("ghost", CONDITION.data); await wrapper.trigger("click"); expect(workflowObj.dialog.show).toBe(false); expect(workflowObj.currentSelectedNodeID).toBe(""); }); }); // The hover-`+` under the card is gone: clicking the node's SOURCE HANDLE // opens the step picker. Terminal (output) nodes render no source handle, so // there is nothing to click on them. describe("source handle -> step picker", () => { const outputClick = (w: any, x = 120, y = 240) => w.findComponent({ name: "FlowNodeCard" }).vm.$emit("outputClick", { clientX: x, clientY: y, } as MouseEvent); it("opens the step picker anchored at the click for a logic node", async () => { wrapper = mountNode("c1", CONDITION.data); outputClick(wrapper, 120, 240); await nextTick(); expect(workflowObj.stepPicker).toEqual({ show: true, source: "c1", handle: "out", mode: "next", // edgeId is only set in insert-on-edge mode (T7); empty here. edgeId: "", position: null, anchor: { x: 120, y: 240 }, }); }); it("opens the step picker for the trigger too", async () => { wrapper = mountNode("t1", TRIGGER.data); outputClick(wrapper); await nextTick(); expect(workflowObj.stepPicker.show).toBe(true); expect(workflowObj.stepPicker.source).toBe("t1"); }); it("renders a source handle on a logic node", () => { wrapper = mountNode("c1", CONDITION.data); expect(wrapper.findComponent({ name: "FlowNodeCard" }).props("hasOutput")).toBe(true); }); it("renders NO source handle on a terminal (output) destination node", () => { wrapper = mountNode("d1", DESTINATION.data); expect(wrapper.findComponent({ name: "FlowNodeCard" }).props("hasOutput")).toBe(false); }); it("does nothing on the read-only Runs canvas", async () => { workflowObj.readOnly = true; wrapper = mountNode("c1", CONDITION.data); outputClick(wrapper); await nextTick(); expect(workflowObj.stepPicker.show).toBe(false); workflowObj.readOnly = false; }); }); describe("test result badge", () => { const setResult = (result: any) => { workflowObj.testRun.result = result; }; it("renders no badge when there was no test run", () => { wrapper = mountNode("c1", CONDITION.data); expect(wrapper.find(".wf-test-badge").exists()).toBe(false); }); it("renders no badge for a node that was not part of the run", () => { setResult({ errors: {}, ranNodeIds: ["t1"], blockedNodeIds: [] }); wrapper = mountNode("c1", CONDITION.data); expect(wrapper.find(".wf-test-badge").exists()).toBe(false); }); it("renders the green tick for a node that ran clean", () => { setResult({ errors: {}, ranNodeIds: ["c1"], blockedNodeIds: [] }); wrapper = mountNode("c1", CONDITION.data); expect(wrapper.find('[data-test="workflow-node-condition-test-ok"]').exists()).toBe(true); }); 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("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", () => { setResult({ errors: { c1: { error_count: 2, errors: [["boom"], ["bang"]] } }, ranNodeIds: ["c1"], blockedNodeIds: [], }); wrapper = mountNode("c1", CONDITION.data); expect(wrapper.find('[data-test="workflow-node-condition-test-error"]').exists()).toBe(true); expect(wrapper.text()).toContain("boom"); expect(wrapper.text()).toContain("bang"); }); it("error wins over blocked for the same node", () => { setResult({ errors: { c1: { error_count: 1, errors: [["boom"]] } }, ranNodeIds: ["c1"], blockedNodeIds: ["c1"], }); wrapper = mountNode("c1", CONDITION.data); expect(wrapper.find('[data-test="workflow-node-condition-test-error"]').exists()).toBe(true); expect(wrapper.find('[data-test="workflow-node-condition-test-skipped"]').exists()).toBe( false, ); }); it("shows the error count only when there is more than one error", () => { setResult({ errors: { c1: { error_count: 3, errors: [["a"], ["b"], ["c"]] } }, ranNodeIds: ["c1"], blockedNodeIds: [], }); wrapper = mountNode("c1", CONDITION.data); expect(wrapper.find(".wf-test-count").text()).toBe("3"); }); it("hides the count badge for a single error", () => { setResult({ errors: { c1: { error_count: 1, errors: [["a"]] } }, ranNodeIds: ["c1"], blockedNodeIds: [], }); wrapper = mountNode("c1", CONDITION.data); expect(wrapper.find(".wf-test-count").exists()).toBe(false); }); it("falls back to the message count when error_count is absent", () => { setResult({ errors: { c1: { errors: [["a"], ["b"]] } }, ranNodeIds: ["c1"], blockedNodeIds: [], }); wrapper = mountNode("c1", CONDITION.data); expect(wrapper.find(".wf-test-count").text()).toBe("2"); }); it("renders plain-string error entries (non-tuple shape)", () => { setResult({ errors: { c1: { error_count: 2, errors: ["plain-one", "plain-two"] } }, ranNodeIds: ["c1"], blockedNodeIds: [], }); wrapper = mountNode("c1", CONDITION.data); expect(wrapper.text()).toContain("plain-one"); expect(wrapper.text()).toContain("plain-two"); }); it("renders the error badge with no messages when errors is not an array", () => { setResult({ errors: { c1: { error_count: 0, errors: null } }, ranNodeIds: ["c1"], blockedNodeIds: [], }); wrapper = mountNode("c1", CONDITION.data); expect(wrapper.find('[data-test="workflow-node-condition-test-error"]').exists()).toBe(true); expect(wrapper.find(".wf-test-count").exists()).toBe(false); }); it("opens the per-node result drawer when the error badge is clicked", async () => { setResult({ errors: { c1: { error_count: 1, errors: [["boom"]] } }, ranNodeIds: ["c1"], blockedNodeIds: [], }); wrapper = mountNode("c1", CONDITION.data); await wrapper.find('[data-test="workflow-node-condition-test-error"]').trigger("click"); expect(workflowObj.testRun.resultDrawer).toEqual({ show: true, nodeId: "c1", }); }); 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).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 () => { wrapper = mountNode("c1", CONDITION.data); expect(wrapper.find(".wf-test-badge").exists()).toBe(false); setResult({ errors: {}, ranNodeIds: ["c1"], blockedNodeIds: [] }); await nextTick(); expect(wrapper.find('[data-test="workflow-node-condition-test-ok"]').exists()).toBe(true); }); it("renders no badge when ranNodeIds is missing from the result", () => { setResult({ errors: {} }); wrapper = mountNode("c1", CONDITION.data); expect(wrapper.find(".wf-test-badge").exists()).toBe(false); }); }); describe("undefined data (defensive: node with no data payload)", () => { it("renders the ok badge with an undefined type in its data-test", () => { workflowObj.testRun.result = { errors: {}, ranNodeIds: ["c1"], blockedNodeIds: [], }; wrapper = mountNode("c1", undefined); expect(wrapper.find('[data-test="workflow-node-undefined-test-ok"]').exists()).toBe(true); }); it("renders the skipped badge with an undefined type in its data-test", () => { workflowObj.testRun.result = { errors: {}, ranNodeIds: ["c1"], blockedNodeIds: ["c1"], }; wrapper = mountNode("c1", undefined); expect(wrapper.find('[data-test="workflow-node-undefined-test-skipped"]').exists()).toBe( true, ); }); it("renders the error badge (and its drawer click) with an undefined type", async () => { workflowObj.testRun.result = { errors: { c1: { error_count: 1, errors: [["boom"]] } }, ranNodeIds: ["c1"], blockedNodeIds: [], }; wrapper = mountNode("c1", undefined); const badge = wrapper.find('[data-test="workflow-node-undefined-test-error"]'); expect(badge.exists()).toBe(true); await badge.trigger("click"); expect(workflowObj.testRun.resultDrawer).toEqual({ show: true, nodeId: "c1", }); }); it("renders the delete button and the actions bar with an undefined type", async () => { wrapper = mountNode("c1", undefined); await wrapper.trigger("mouseenter"); expect(wrapper.find('[data-test="workflow-node-undefined-actions"]').exists()).toBe(true); await wrapper.find('[data-test="workflow-node-undefined-delete-btn"]').trigger("click"); expect(workflowObj.deleteConfirm).toEqual({ show: true, nodeId: "c1" }); }); it("renders no body label and does not crash (no meta)", () => { wrapper = mountNode("c1", undefined); expect(wrapper.text()).toBe(""); }); }); });