diff --git a/src/components/PipelineRun/components/RerunPipelineButton.test.tsx b/src/components/PipelineRun/components/RerunPipelineButton.test.tsx index 5eed25c0d..f845488d5 100644 --- a/src/components/PipelineRun/components/RerunPipelineButton.test.tsx +++ b/src/components/PipelineRun/components/RerunPipelineButton.test.tsx @@ -22,6 +22,7 @@ const { mockIsAuthorized, mockGetToken, mockFetch, + mockUseExecutionDataOptional, } = vi.hoisted(() => ({ navigateMock: vi.fn(), notifyMock: vi.fn(), @@ -31,6 +32,7 @@ const { mockIsAuthorized: vi.fn(), mockGetToken: vi.fn(), mockFetch: vi.fn(), + mockUseExecutionDataOptional: vi.fn(), })); // Set up mocks @@ -67,6 +69,10 @@ vi.mock("@/utils/submitPipeline", () => ({ submitPipelineRun: mockSubmitPipelineRun, })); +vi.mock("@/providers/ExecutionDataProvider", () => ({ + useExecutionDataOptional: mockUseExecutionDataOptional, +})); + const testOrigin = import.meta.env.VITE_BASE_URL || "http://localhost:3000"; Object.defineProperty(window, "location", { @@ -108,6 +114,7 @@ describe("", () => { mockIsAuthorized.mockReturnValue(true); mockGetToken.mockReturnValue("mock-token"); mockAwaitAuthorization.mockClear(); + mockUseExecutionDataOptional.mockReturnValue(undefined); }); afterEach(async () => { @@ -334,4 +341,81 @@ describe("", () => { ); }); }); + + test("passes taskArguments from execution data to submitPipelineRun", async () => { + const mockTaskArguments = { + input_param: "test_value", + another_param: "another_value", + }; + + mockUseExecutionDataOptional.mockReturnValue({ + rootDetails: { + task_spec: { + arguments: mockTaskArguments, + }, + }, + }); + + mockSubmitPipelineRun.mockImplementation(async (_, __, { onSuccess }) => { + onSuccess({ id: 456 }); + }); + + await act(async () => { + renderWithProviders( + , + ); + }); + + const rerunButton = screen.getByTestId("rerun-pipeline-button"); + + await act(async () => { + fireEvent.click(rerunButton); + }); + + await waitFor(() => { + expect(mockSubmitPipelineRun).toHaveBeenCalledWith( + componentSpec, + expect.any(String), + expect.objectContaining({ + taskArguments: mockTaskArguments, + authorizationToken: "mock-token", + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + }); + }); + + test("passes undefined taskArguments when execution data is not available", async () => { + mockUseExecutionDataOptional.mockReturnValue(undefined); + + mockSubmitPipelineRun.mockImplementation(async (_, __, { onSuccess }) => { + onSuccess({ id: 789 }); + }); + + await act(async () => { + renderWithProviders( + , + ); + }); + + const rerunButton = screen.getByTestId("rerun-pipeline-button"); + + await act(async () => { + fireEvent.click(rerunButton); + }); + + await waitFor(() => { + expect(mockSubmitPipelineRun).toHaveBeenCalledWith( + componentSpec, + expect.any(String), + expect.objectContaining({ + taskArguments: undefined, + authorizationToken: "mock-token", + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + }); + }); }); diff --git a/src/components/PipelineRun/components/RerunPipelineButton.tsx b/src/components/PipelineRun/components/RerunPipelineButton.tsx index 27e01e039..1fe5ea238 100644 --- a/src/components/PipelineRun/components/RerunPipelineButton.tsx +++ b/src/components/PipelineRun/components/RerunPipelineButton.tsx @@ -9,6 +9,7 @@ import { useAwaitAuthorization } from "@/components/shared/Authentication/useAwa import TooltipButton from "@/components/shared/Buttons/TooltipButton"; import useToastNotification from "@/hooks/useToastNotification"; import { useBackend } from "@/providers/BackendProvider"; +import { useExecutionDataOptional } from "@/providers/ExecutionDataProvider"; import { APP_ROUTES } from "@/routes/router"; import type { PipelineRun } from "@/types/pipelineRun"; import type { ComponentSpec } from "@/utils/componentSpec"; @@ -24,6 +25,7 @@ export const RerunPipelineButton = ({ const { backendUrl } = useBackend(); const navigate = useNavigate(); const notify = useToastNotification(); + const executionData = useExecutionDataOptional(); const { awaitAuthorization, isAuthorized } = useAwaitAuthorization(); const { getToken } = useAuthLocalStorage(); @@ -59,6 +61,7 @@ export const RerunPipelineButton = ({ return new Promise((resolve, reject) => { submitPipelineRun(componentSpec, backendUrl, { + taskArguments: executionData?.rootDetails?.task_spec.arguments, authorizationToken, onSuccess: resolve, onError: reject, diff --git a/src/utils/submitPipeline.test.ts b/src/utils/submitPipeline.test.ts index 52b2c69c3..9a1a03401 100644 --- a/src/utils/submitPipeline.test.ts +++ b/src/utils/submitPipeline.test.ts @@ -1,7 +1,6 @@ import yaml from "js-yaml"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as getArgumentsFromInputs from "@/components/shared/ReactFlow/FlowCanvas/utils/getArgumentsFromInputs"; import * as pipelineRunService from "@/services/pipelineRunService"; import type { PipelineRun } from "@/types/pipelineRun"; @@ -14,13 +13,6 @@ vi.mock("@/services/pipelineRunService", () => ({ savePipelineRun: vi.fn(), })); -vi.mock( - "@/components/shared/ReactFlow/FlowCanvas/utils/getArgumentsFromInputs", - () => ({ - getArgumentsFromInputs: vi.fn(), - }), -); - describe("submitPipelineRun", () => { const mockBackendUrl = "https://api.example.com"; const mockAuthToken = "test-auth-token"; @@ -44,9 +36,6 @@ describe("submitPipelineRun", () => { global.fetch = mockFetch; // Setup default mocks - vi.mocked(getArgumentsFromInputs.getArgumentsFromInputs).mockReturnValue( - {}, - ); vi.mocked(pipelineRunService.createPipelineRun).mockResolvedValue( mockPipelineRun, ); @@ -103,18 +92,17 @@ describe("submitPipelineRun", () => { expect(mockOnError).not.toHaveBeenCalled(); }); - it("should include arguments when getArgumentsFromInputs returns data", async () => { + it("should include arguments when componentSpec has inputs with values", async () => { // Arrange const componentSpec: ComponentSpec = { name: "component-with-args", + inputs: [ + { name: "param1", value: "value1" }, + { name: "param2", value: "value2" }, + ], implementation: { container: { image: "test:latest" } }, }; - const mockArguments = { param1: "value1", param2: "value2" }; - vi.mocked(getArgumentsFromInputs.getArgumentsFromInputs).mockReturnValue( - mockArguments, - ); - // Act await submitPipelineRun(componentSpec, mockBackendUrl); @@ -125,7 +113,7 @@ describe("submitPipelineRun", () => { componentRef: { spec: componentSpec, }, - arguments: mockArguments, + arguments: { param1: "value1", param2: "value2" }, }, }, mockBackendUrl, @@ -168,6 +156,211 @@ describe("submitPipelineRun", () => { }); }); + describe("taskArguments handling", () => { + it("should include taskArguments in payload when provided", async () => { + // Arrange + const componentSpec: ComponentSpec = { + name: "component-with-task-args", + implementation: { container: { image: "test:latest" } }, + }; + + const mockTaskArguments = { + inputA: "valueA", + inputB: "valueB", + }; + + // Act + await submitPipelineRun(componentSpec, mockBackendUrl, { + taskArguments: mockTaskArguments, + }); + + // Assert + expect(pipelineRunService.createPipelineRun).toHaveBeenCalledWith( + { + root_task: { + componentRef: { + spec: componentSpec, + }, + arguments: { + inputA: "valueA", + inputB: "valueB", + }, + }, + }, + mockBackendUrl, + undefined, + ); + }); + + it("should merge taskArguments with argumentsFromInputs", async () => { + // Arrange + const componentSpec: ComponentSpec = { + name: "merged-args-component", + inputs: [ + { name: "fromInput1", value: "inputValue1" }, + { name: "fromInput2", value: "inputValue2" }, + ], + implementation: { container: { image: "test:latest" } }, + }; + + const mockTaskArguments = { + taskArg1: "taskValue1", + taskArg2: "taskValue2", + }; + + // Act + await submitPipelineRun(componentSpec, mockBackendUrl, { + taskArguments: mockTaskArguments, + }); + + // Assert + expect(pipelineRunService.createPipelineRun).toHaveBeenCalledWith( + { + root_task: { + componentRef: { + spec: componentSpec, + }, + arguments: { + fromInput1: "inputValue1", + fromInput2: "inputValue2", + taskArg1: "taskValue1", + taskArg2: "taskValue2", + }, + }, + }, + mockBackendUrl, + undefined, + ); + }); + + it("should override argumentsFromInputs with taskArguments when keys conflict", async () => { + // Arrange + const componentSpec: ComponentSpec = { + name: "override-args-component", + inputs: [ + { name: "sharedKey", value: "fromInputValue" }, + { name: "uniqueInputKey", value: "inputOnlyValue" }, + ], + implementation: { container: { image: "test:latest" } }, + }; + + const mockTaskArguments = { + sharedKey: "fromTaskValue", + uniqueTaskKey: "taskOnlyValue", + }; + + // Act + await submitPipelineRun(componentSpec, mockBackendUrl, { + taskArguments: mockTaskArguments, + }); + + // Assert - taskArguments should override argumentsFromInputs + expect(pipelineRunService.createPipelineRun).toHaveBeenCalledWith( + { + root_task: { + componentRef: { + spec: componentSpec, + }, + arguments: { + sharedKey: "fromTaskValue", + uniqueInputKey: "inputOnlyValue", + uniqueTaskKey: "taskOnlyValue", + }, + }, + }, + mockBackendUrl, + undefined, + ); + }); + + it("should filter out non-string taskArguments values", async () => { + // Arrange + const componentSpec: ComponentSpec = { + name: "filter-args-component", + implementation: { container: { image: "test:latest" } }, + }; + + const mockTaskArguments = { + stringArg: "validString", + objectArg: { nested: "value" }, + numberArg: 123, + }; + + // Act + await submitPipelineRun(componentSpec, mockBackendUrl, { + taskArguments: mockTaskArguments as any, + }); + + // Assert - only string values should be included + expect(pipelineRunService.createPipelineRun).toHaveBeenCalledWith( + { + root_task: { + componentRef: { + spec: componentSpec, + }, + arguments: { + stringArg: "validString", + objectArg: undefined, + numberArg: undefined, + }, + }, + }, + mockBackendUrl, + undefined, + ); + }); + + it("should work with empty taskArguments object", async () => { + // Arrange + const componentSpec: ComponentSpec = { + name: "empty-task-args-component", + inputs: [{ name: "inputArg", value: "inputValue" }], + implementation: { container: { image: "test:latest" } }, + }; + + // Act + await submitPipelineRun(componentSpec, mockBackendUrl, { + taskArguments: {}, + }); + + // Assert - should only contain argumentsFromInputs + expect(pipelineRunService.createPipelineRun).toHaveBeenCalledWith( + { + root_task: { + componentRef: { + spec: componentSpec, + }, + arguments: { + inputArg: "inputValue", + }, + }, + }, + mockBackendUrl, + undefined, + ); + }); + + it("should call onSuccess callback with taskArguments submission", async () => { + // Arrange + const componentSpec: ComponentSpec = { + name: "success-callback-component", + implementation: { container: { image: "test:latest" } }, + }; + + const mockOnSuccess = vi.fn(); + const mockTaskArguments = { arg1: "value1" }; + + // Act + await submitPipelineRun(componentSpec, mockBackendUrl, { + taskArguments: mockTaskArguments, + onSuccess: mockOnSuccess, + }); + + // Assert + expect(mockOnSuccess).toHaveBeenCalledWith(mockPipelineRun); + }); + }); + describe("component processing", () => { it("should fetch and process components with URLs", async () => { // Arrange diff --git a/src/utils/submitPipeline.ts b/src/utils/submitPipeline.ts index f844c16b6..e64de2dbd 100644 --- a/src/utils/submitPipeline.ts +++ b/src/utils/submitPipeline.ts @@ -1,4 +1,7 @@ -import type { BodyCreateApiPipelineRunsPost } from "@/api/types.gen"; +import type { + BodyCreateApiPipelineRunsPost, + TaskSpecOutput, +} from "@/api/types.gen"; import { getArgumentsFromInputs } from "@/components/shared/ReactFlow/FlowCanvas/utils/getArgumentsFromInputs"; import { createPipelineRun, @@ -7,12 +10,14 @@ import { import type { PipelineRun } from "@/types/pipelineRun"; import type { ComponentReference, ComponentSpec } from "./componentSpec"; +import { getArgumentValue } from "./nodes/taskArguments"; import { componentSpecFromYaml } from "./yaml"; export async function submitPipelineRun( componentSpec: ComponentSpec, backendUrl: string, options?: { + taskArguments?: TaskSpecOutput["arguments"]; authorizationToken?: string; onSuccess?: (data: PipelineRun) => void; onError?: (error: Error) => void; @@ -31,13 +36,25 @@ export async function submitPipelineRun( }, ); const argumentsFromInputs = getArgumentsFromInputs(fullyLoadedSpec); + const normalizedTaskArguments = options?.taskArguments + ? Object.fromEntries( + Object.entries(options.taskArguments).map(([key, _]) => [ + key, + getArgumentValue(options.taskArguments, key), + ]), + ) + : {}; + const payloadArguments = { + ...argumentsFromInputs, + ...normalizedTaskArguments, + }; const payload = { root_task: { componentRef: { spec: fullyLoadedSpec, }, - ...(argumentsFromInputs ? { arguments: argumentsFromInputs } : {}), + ...(payloadArguments ? { arguments: payloadArguments } : {}), }, };