diff --git a/packages/open-workflow-diagram-editor/tests/components/ui/sidebar.test.tsx b/packages/open-workflow-diagram-editor/tests/components/ui/sidebar.test.tsx index 46cf2f1a..a006df0c 100644 --- a/packages/open-workflow-diagram-editor/tests/components/ui/sidebar.test.tsx +++ b/packages/open-workflow-diagram-editor/tests/components/ui/sidebar.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { @@ -111,4 +111,38 @@ describe("Sidebar", () => { expect(screen.getByText("Header Text")).toBeInTheDocument(); expect(screen.getByText("Content Text")).toBeInTheDocument(); }); + + it("calls onOpenChange when used as a controlled component", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + + render( + + + + + + + , + ); + + await user.click(screen.getByRole("button", { name: /toggle sidebar/i })); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it("renders non-collapsible sidebar", () => { + const { container } = render( + + + Content + + , + ); + + const sidebar = findSidebar(container); + + expect(sidebar).not.toHaveAttribute("data-state"); + expect(sidebar).toBeInTheDocument(); + }); }); diff --git a/packages/open-workflow-diagram-editor/tests/core/graph.test.ts b/packages/open-workflow-diagram-editor/tests/core/graph.test.ts index a36189c2..da9112f4 100644 --- a/packages/open-workflow-diagram-editor/tests/core/graph.test.ts +++ b/packages/open-workflow-diagram-editor/tests/core/graph.test.ts @@ -1009,5 +1009,33 @@ describe("graph utils", () => { expect(fixedGraph.edges[3]?.targetId).toBe("task-outside"); expect(fixedGraph.edges[3]?.id).toBe("edge-2-redirected"); }); + + it("does not create redirected edges when parent has no exit node", () => { + const parent = { + id: "parent", + type: GraphNodeType.Do, + } as FlatGraphNode; + + const child = { + id: "child", + type: GraphNodeType.Call, + parentId: "parent", + } as FlatGraphNode; + + const outside = { + id: "outside", + type: GraphNodeType.Call, + } as FlatGraphNode; + + const graph = createFlatGraph( + [parent, child, outside], + [{ id: "e1", sourceId: "child", targetId: "outside", label: "" }], + ); + + const fixed = fixNodesConnections(graph); + + expect(fixed.edges).toHaveLength(1); + expect(fixed.edges[0]?.targetId).toBe("outside"); + }); }); }); diff --git a/packages/open-workflow-diagram-editor/tests/core/taskDetails.test.ts b/packages/open-workflow-diagram-editor/tests/core/taskDetails.test.ts index f227d914..9d4e76c4 100644 --- a/packages/open-workflow-diagram-editor/tests/core/taskDetails.test.ts +++ b/packages/open-workflow-diagram-editor/tests/core/taskDetails.test.ts @@ -142,4 +142,158 @@ describe("getTaskDetails", () => { it("returns no fields for a task with no displayable fields", () => { expect(getTaskDetails(asTask({}))).toEqual([]); }); + + it("ignores null and undefined values", () => { + const fields = getTaskDetails( + asTask({ + set: { + a: null, + b: undefined, + c: "value", + }, + }), + ); + + expect(fields).toEqual([{ path: "set.c", kind: "text", display: "value" }]); + }); + + it("converts boolean values to text", () => { + const fields = getTaskDetails( + asTask({ + set: { + enabled: true, + disabled: false, + }, + }), + ); + + expect(fields).toEqual([ + { path: "set.enabled", kind: "text", display: "true" }, + { path: "set.disabled", kind: "text", display: "false" }, + ]); + }); + + it("ignores empty objects", () => { + const fields = getTaskDetails( + asTask({ + set: {}, + }), + ); + + expect(fields).toEqual([]); + }); + + it("ignores nested empty objects", () => { + const fields = getTaskDetails( + asTask({ + set: { + foo: {}, + }, + }), + ); + + expect(fields).toEqual([]); + }); + + it("ignores input, output and export when they are not objects", () => { + const fields = getTaskDetails( + asTask({ + input: "invalid", + output: 123, + export: true, + }), + ); + + expect(fields).toEqual([]); + }); + + it("ignores timeout object without an after property", () => { + const fields = getTaskDetails( + asTask({ + timeout: {}, + }), + ); + + expect(fields).toEqual([]); + }); + + it("ignores timeout object when after is undefined", () => { + const fields = getTaskDetails( + asTask({ + timeout: { + after: undefined, + }, + }), + ); + + expect(fields).toEqual([]); + }); + + it("supports primitive task-specific values", () => { + const fields = getTaskDetails( + asTask({ + call: 123, + }), + ); + + expect(fields).toEqual([{ path: "call", kind: "text", display: "123" }]); + }); + + it("flattens fields exactly at the maximum supported depth", () => { + const fields = getTaskDetails( + asTask({ + with: { + a: { + b: { + c: { + value: "foo", + }, + }, + }, + }, + }), + ); + + expect(fields).toEqual([ + { + path: "with.a.b.c.value", + kind: "text", + display: "foo", + }, + ]); + }); + + it("returns base fields in the expected order", () => { + const fields = getTaskDetails( + asTask({ + if: "${ .condition }", + input: { from: "${ .input }" }, + output: { as: "${ .output }" }, + export: { as: "${ .export }" }, + timeout: "PT5M", + then: "next", + }), + ); + + expect(fields).toEqual([ + { path: "if", kind: "text", display: "${ .condition }" }, + { path: "input.from", kind: "text", display: "${ .input }" }, + { path: "output.as", kind: "text", display: "${ .output }" }, + { path: "export.as", kind: "text", display: "${ .export }" }, + { path: "timeout", kind: "text", display: "PT5M" }, + { path: "then", kind: "text", display: "next" }, + ]); + }); + + it("returns no fields when only metadata is present", () => { + const fields = getTaskDetails( + asTask({ + metadata: { + author: "john", + }, + }), + ); + + expect(fields).toEqual([]); + }); }); diff --git a/packages/open-workflow-diagram-editor/tests/core/validationErrors.test.ts b/packages/open-workflow-diagram-editor/tests/core/validationErrors.test.ts index 892c0131..ac969adf 100644 --- a/packages/open-workflow-diagram-editor/tests/core/validationErrors.test.ts +++ b/packages/open-workflow-diagram-editor/tests/core/validationErrors.test.ts @@ -210,4 +210,81 @@ describe("getGeneralErrors", () => { expect(getGeneralErrors(errors, nodeIds)).toEqual([documentErr, rawErr]); }); + + describe("additional validation error edge cases", () => { + it("returns each node id only once even if multiple errors belong to it", () => { + const nodeIds = new Set(["/do/0/call"]); + + const errors: SdkError[] = [ + vErr({ path: "/do/0/call", message: "first" }), + vErr({ path: "/do/0/call/with", message: "second" }), + vErr({ path: "/do/0/call/output", message: "third" }), + ]; + + expect(getErrorNodeIds(errors, nodeIds)).toEqual(new Set(["/do/0/call"])); + }); + + it("does not treat non-string missingProperty values as noise", () => { + const nodeIds = new Set(["/do/0/call"]); + + const errors: SdkError[] = [ + vErr({ + path: "/do/0/call", + object: { + missingProperty: 123, + }, + }), + ]; + + const result = getNodeErrors(errors, "/do/0/call", nodeIds); + + expect(result).toHaveLength(1); + expect(result[0]?.object?.missingProperty).toBe(123); + }); + + it("keeps validation errors without an errorType", () => { + const nodeIds = new Set(["/do/0/call"]); + + const errors: SdkError[] = [ + vErr({ + path: "/do/0/call", + }), + ]; + + expect(getNodeErrors(errors, "/do/0/call", nodeIds)).toHaveLength(1); + }); + + it("returns no node errors when there are no node ids", () => { + const errors: SdkError[] = [ + vErr({ + path: "/do/0/call", + message: "owned", + }), + ]; + + expect(getNodeErrors(errors, "/do/0/call", new Set())).toEqual([]); + }); + + it("returns no error node ids when there are no node ids", () => { + const errors: SdkError[] = [ + vErr({ + path: "/do/0/call", + message: "owned", + }), + ]; + + expect(getErrorNodeIds(errors, new Set())).toEqual(new Set()); + }); + + it("treats all validation errors as general when there are no node ids", () => { + const errors: SdkError[] = [ + vErr({ + path: "/do/0/call", + message: "owned", + }), + ]; + + expect(getGeneralErrors(errors, new Set())).toEqual(errors); + }); + }); }); diff --git a/packages/open-workflow-diagram-editor/tests/core/workflowSdk.integration.test.ts b/packages/open-workflow-diagram-editor/tests/core/workflowSdk.integration.test.ts index 47b71da2..c6bff061 100644 --- a/packages/open-workflow-diagram-editor/tests/core/workflowSdk.integration.test.ts +++ b/packages/open-workflow-diagram-editor/tests/core/workflowSdk.integration.test.ts @@ -263,3 +263,81 @@ describe("buildFlatGraph", () => { expect(() => buildFlatGraph(model!)).toThrow(); }); }); + +describe("additional parseValidationErrorMessage edge cases", () => { + it.each(["#/required - ", " - missing property", " - "])( + "ignores malformed format-2 line: %s", + (message) => { + expect(parseValidationErrorMessage(message)).toEqual([]); + }, + ); + + it.each([ + "- | #/required | message | {}", + "- /path | | message | {}", + "- /path | #/required | | {}", + "- /path | #/required | message | ", + ])("ignores malformed format-1 line: %s", (message) => { + expect(parseValidationErrorMessage(message)).toEqual([]); + }); + + it("parses multiple validation errors from a single message", () => { + const message = ` +- /task1 | #/required | missing property | {} +- /task2 | #/type | wrong type | {} +#/document - missing required property 'document' +`; + + expect(parseValidationErrorMessage(message)).toEqual([ + { + path: "/task1", + errorType: "#/required", + message: "missing property", + object: {}, + }, + { + path: "/task2", + errorType: "#/type", + message: "wrong type", + object: {}, + }, + { + errorType: "#/document", + message: "missing required property 'document'", + }, + ]); + }); + + it("trims leading and trailing whitespace from lines", () => { + const message = ` + - /task | #/required | missing property | {} + `; + + expect(parseValidationErrorMessage(message)).toEqual([ + { + path: "/task", + errorType: "#/required", + message: "missing property", + object: {}, + }, + ]); + }); +}); + +describe("additional parseWorkflow edge cases", () => { + it("returns an error for whitespace-only input", () => { + const result = parseWorkflow(" \n\t "); + + expect(result.model).toBeNull(); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toEqual(new Error("Not a valid workflow")); + }); + + it("returns an error when YAML evaluates to null", () => { + const result = parseWorkflow("null"); + + expect(result.model).toBeNull(); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toEqual(new Error("Not a valid workflow")); + }); +}); diff --git a/packages/open-workflow-diagram-editor/tests/diagram-editor/DiagramEditor.test.tsx b/packages/open-workflow-diagram-editor/tests/diagram-editor/DiagramEditor.test.tsx index 17350dec..3c13851c 100644 --- a/packages/open-workflow-diagram-editor/tests/diagram-editor/DiagramEditor.test.tsx +++ b/packages/open-workflow-diagram-editor/tests/diagram-editor/DiagramEditor.test.tsx @@ -19,6 +19,7 @@ import { DiagramEditor } from "../../src/diagram-editor"; import { vi, expect, afterEach, describe, it } from "vitest"; import { BASIC_VALID_WORKFLOW_YAML } from "../fixtures/workflows"; import { t } from "../test-utils"; +import React from "react"; /* When js-yaml throws a YAMLException, parseWorkflow returns a null model and the editor must fall back to the parsing error page. */ @@ -71,4 +72,55 @@ describe("DiagramEditor Component", () => { expect(decRoot).not.toHaveClass("dark"); } }); + + it("sets the lang attribute from the locale prop", () => { + render(); + + expect(screen.getByTestId("dec-root")).toHaveAttribute("lang", "fr"); + }); + + it("updates the rendered content when the workflow content changes", () => { + const { rerender } = render( + , + ); + + expect(screen.getByText(t("workflowError.parsing.title"))).toBeInTheDocument(); + + rerender(); + + expect(screen.getByTestId("diagram-container")).toBeInTheDocument(); + expect(screen.queryByText(t("workflowError.parsing.title"))).not.toBeInTheDocument(); + }); + + it("exposes the imperative ref API", () => { + const ref = React.createRef<{ doSomething: () => void }>(); + + render( + , + ); + + expect(ref.current).not.toBeNull(); + expect(ref.current?.doSomething).toBeTypeOf("function"); + + expect(() => ref.current?.doSomething()).not.toThrow(); + }); + + it("renders the sidebar provider with the diagram", () => { + render(); + + expect(screen.getByTestId("diagram-container")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /toggle sidebar/i })).toBeInTheDocument(); + }); + + it("resets the error boundary when the content changes", () => { + const { rerender } = render( + , + ); + + expect(screen.getByText(t("workflowError.parsing.title"))).toBeInTheDocument(); + + rerender(); + + expect(screen.getByTestId("diagram-container")).toBeInTheDocument(); + }); }); diff --git a/packages/open-workflow-diagram-editor/tests/diagram-editor/error-pages/DiagramEditorErrorBoundary.test.tsx b/packages/open-workflow-diagram-editor/tests/diagram-editor/error-pages/DiagramEditorErrorBoundary.test.tsx index 3fa0debb..7098a916 100644 --- a/packages/open-workflow-diagram-editor/tests/diagram-editor/error-pages/DiagramEditorErrorBoundary.test.tsx +++ b/packages/open-workflow-diagram-editor/tests/diagram-editor/error-pages/DiagramEditorErrorBoundary.test.tsx @@ -16,7 +16,7 @@ import { render, screen } from "@testing-library/react"; import { DiagramEditorErrorBoundary } from "../../../src/diagram-editor/error-pages/DiagramEditorErrorBoundary"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; const ThrowError = ({ message = "Test error" }: { message?: string }) => { throw new Error(message); @@ -93,3 +93,73 @@ describe("DiagramEditorErrorBoundary", () => { spy.mockRestore(); }); }); + +const ThrowString = () => { + throw "boom"; +}; + +describe("additional DiagramEditorErrorBoundary tests", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not reset when resetKey does not change", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { rerender } = render( + + + , + ); + + rerender( + + + , + ); + + expect(screen.queryByText("Safe Content")).not.toBeInTheDocument(); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + + spy.mockRestore(); + }); + + it("does not render an error snippet when a non-Error value is thrown", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + + render( + + + , + ); + + expect(screen.getByText("An unexpected error occurred")).toBeInTheDocument(); + + // The thrown value is not an Error, so no snippet should be displayed. + expect(screen.queryByText("boom")).not.toBeInTheDocument(); + + spy.mockRestore(); + }); + + it("resets when resetKey changes from undefined to a value", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { rerender } = render( + + + , + ); + + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + + rerender( + + + , + ); + + expect(screen.getByText("Safe Content")).toBeInTheDocument(); + + spy.mockRestore(); + }); +}); diff --git a/packages/open-workflow-diagram-editor/tests/side-panel/ErrorsSection.test.tsx b/packages/open-workflow-diagram-editor/tests/side-panel/ErrorsSection.test.tsx index 0bfea6d7..5e118e2b 100644 --- a/packages/open-workflow-diagram-editor/tests/side-panel/ErrorsSection.test.tsx +++ b/packages/open-workflow-diagram-editor/tests/side-panel/ErrorsSection.test.tsx @@ -60,4 +60,33 @@ describe("ErrorSection", () => { expect(container.querySelector(".dec-sidebar-error-field")).toBeNull(); }); + + it("renders field labels only for items that have a field", () => { + const items: ErrorItem[] = [ + { field: "with", message: "missing endpoint" }, + { message: "document-level error" }, + ]; + + const { container } = renderWithProviders(); + + const fields = container.querySelectorAll(".dec-sidebar-error-field"); + + expect(fields).toHaveLength(1); + expect(fields[0]).toHaveTextContent("with"); + + expect(screen.getByText("missing endpoint")).toBeInTheDocument(); + expect(screen.getByText("document-level error")).toBeInTheDocument(); + }); + + it("renders the correct count for a single error", () => { + renderWithProviders(); + + expect(screen.getByText("1")).toBeInTheDocument(); + }); + + it("renders the error list", () => { + const { container } = renderWithProviders(); + + expect(container.querySelector(".dec-sidebar-errors-list")).toBeInTheDocument(); + }); }); diff --git a/packages/open-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx b/packages/open-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx index 2f0da337..c550e154 100644 --- a/packages/open-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx +++ b/packages/open-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx @@ -71,4 +71,112 @@ describe("SidePanel", () => { expect(screen.queryByText(/Copy Mermaid Code/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Download as Mermaid File/i)).not.toBeInTheDocument(); }); + + it("renders node details when a node is selected", () => { + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + + const mockNode = { + id: "node-1", + type: "set", + position: { x: 0, y: 0 }, + data: { label: "My Node" }, + }; + + renderWithProviders(, { + model, + selectedNodeId: "node-1", + nodes: [mockNode], + }); + + expect(screen.getByText("My Node")).toBeInTheDocument(); + expect(screen.queryByTestId("workflow-info")).not.toBeInTheDocument(); + }); + + it("falls back to the translated node label when the selected node has no label", () => { + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + + const mockNode = { + id: "node-1", + type: "set", + position: { x: 0, y: 0 }, + data: {}, + }; + + renderWithProviders(, { + model, + selectedNodeId: "node-1", + nodes: [mockNode], + }); + + expect(screen.getByText("Node")).toBeInTheDocument(); + }); + + it("renders workflow title when no node is selected", () => { + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + + renderWithProviders(, { + model, + selectedNodeId: null, + }); + + expect(screen.getByText("Workflow")).toBeInTheDocument(); + }); + + it("renders the selection hint when no node is selected", () => { + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + + renderWithProviders(, { + model, + selectedNodeId: null, + }); + + expect(screen.getByText(/Select a node/i)).toBeInTheDocument(); + }); + + it("renders sidebar with workflow aria-label when no node is selected", () => { + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + + renderWithProviders(, { model }); + + expect( + screen.getByRole("complementary", { + name: /workflow/i, + }), + ).toBeInTheDocument(); + }); + + it("renders sidebar with node details aria-label when a node is selected", () => { + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + + const mockNode = { + id: "node-1", + type: "set", + position: { x: 0, y: 0 }, + data: { label: "Node" }, + }; + + renderWithProviders(, { + model, + selectedNodeId: "node-1", + nodes: [mockNode], + }); + + expect( + screen.getByRole("complementary", { + name: /node details/i, + }), + ).toBeInTheDocument(); + }); + + it("does not render node details when selectedNodeId does not exist in nodes", () => { + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + + renderWithProviders(, { + model, + selectedNodeId: "missing-node", + nodes: [], + }); + + expect(screen.getByTestId("workflow-info")).toBeInTheDocument(); + }); }); diff --git a/packages/open-workflow-diagram-editor/tests/side-panel/WorkflowInfoView.test.tsx b/packages/open-workflow-diagram-editor/tests/side-panel/WorkflowInfoView.test.tsx index 5e7b2272..d2529966 100644 --- a/packages/open-workflow-diagram-editor/tests/side-panel/WorkflowInfoView.test.tsx +++ b/packages/open-workflow-diagram-editor/tests/side-panel/WorkflowInfoView.test.tsx @@ -105,5 +105,74 @@ describe("WorkflowInfoView", () => { expect(screen.queryByTestId("sidebar-errors")).not.toBeInTheDocument(); }); + + it("renders only the title when summary is not provided", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Metadata")).toBeInTheDocument(); + expect(screen.getByText("Workflow Title")).toBeInTheDocument(); + expect(screen.queryByText("Summary")).not.toBeInTheDocument(); + }); + + it("renders only the summary when title is not provided", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Metadata")).toBeInTheDocument(); + expect(screen.getByText("Workflow summary")).toBeInTheDocument(); + expect(screen.queryByText("Title")).not.toBeInTheDocument(); + }); + + it("renders the metadata section when tags are the only metadata", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Metadata")).toBeInTheDocument(); + expect(screen.getByText("env: prod")).toBeInTheDocument(); + }); + + it("does not render the tags field when tags is an empty object", () => { + renderWithProviders( + , + ); + + expect(screen.queryByText("Tags")).not.toBeInTheDocument(); + }); + + it("renders both general validation errors and raw errors together", () => { + renderWithProviders(, { + nodeIds: new Set(["/do/0/call"]), + errors: [{ path: "/document", message: "Missing version" }, new Error("Invalid workflow")], + }); + + expect(screen.getByText("Missing version")).toBeInTheDocument(); + expect(screen.getByText("Invalid workflow")).toBeInTheDocument(); + }); }); });