Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions src/components/PipelineRun/RunNotesEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEffect, useState } from "react";

import { Textarea } from "@/components/ui/textarea";
import { Paragraph } from "@/components/ui/typography";
import useToastNotification from "@/hooks/useToastNotification";
import { useBackend } from "@/providers/BackendProvider";
import { updateRunAnnotation } from "@/services/pipelineRunService";
import { runAnnotationsQueryOptions } from "@/services/runAnnotations";
Expand All @@ -14,10 +15,16 @@ import {
interface RunNotesEditorProps {
runId: string;
readOnly?: boolean;
teamId?: string;
}

export const RunNotesEditor = ({ runId, readOnly }: RunNotesEditorProps) => {
export const RunNotesEditor = ({
runId,
readOnly,
teamId,
}: RunNotesEditorProps) => {
const { backendUrl } = useBackend();
const notify = useToastNotification();

const {
data: annotations,
Expand All @@ -29,13 +36,21 @@ export const RunNotesEditor = ({ runId, readOnly }: RunNotesEditorProps) => {

const { mutate: saveRunNotes, isPending } = useMutation({
mutationFn: (runId: string) =>
updateRunAnnotation(runId, backendUrl, {
key: PIPELINE_RUN_NOTES_ANNOTATION,
value: value,
}),
updateRunAnnotation(
runId,
backendUrl,
{
key: PIPELINE_RUN_NOTES_ANNOTATION,
value: value,
},
teamId,
),
onSuccess: () => {
refetch();
},
onError: (error) => {
notify(error.message, "error");
},
});

const notes =
Expand Down
4 changes: 4 additions & 0 deletions src/components/PipelineRun/RunToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ vi.mock("@/hooks/useUserDetails", () => ({
})),
}));

vi.mock("@/services/teams/useRunTeam", () => ({
useRunTeam: () => ({ status: "none" }),
}));

describe("<RunToolbar/>", () => {
const queryClient = new QueryClient({
defaultOptions: {
Expand Down
6 changes: 1 addition & 5 deletions src/components/PipelineRun/RunToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,7 @@ export const RunToolbar = () => {
)}

{isComplete && (
<RerunPipelineButton
componentSpec={componentSpec}
runId={runId}
showLabel
/>
<RerunPipelineButton componentSpec={componentSpec} showLabel />
)}
</InlineStack>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ describe("<CancelPipelineRunButton/>", () => {
expect(cancelPipelineRun).toHaveBeenCalledWith(
"test-run-123",
"http://localhost:8000",
undefined,
);
expect(mockNotify).toHaveBeenCalledWith(
"Pipeline run test-run-123 cancelled",
Expand All @@ -154,6 +155,27 @@ describe("<CancelPipelineRunButton/>", () => {
expect(button).toHaveTextContent("Stop");
});

test("cancels a team's run as the team", async () => {
cancelPipelineRun.mockResolvedValue();
renderWithQueryClient(
<CancelPipelineRunButton
runId="test-run-123"
teamId="ml-team@example.com"
/>,
);

await act(() =>
fireEvent.click(screen.getByTestId("cancel-pipeline-run-button")),
);
await act(() => fireEvent.click(screen.getByText("Continue")));

expect(cancelPipelineRun).toHaveBeenCalledWith(
"test-run-123",
"http://localhost:8000",
"ml-team@example.com",
);
});

test("handles cancellation error", async () => {
// arrange
const errorMessage = "Network error";
Expand All @@ -168,7 +190,7 @@ describe("<CancelPipelineRunButton/>", () => {

// assert
expect(mockNotify).toHaveBeenCalledWith(
`Error cancelling run: Error: ${errorMessage}`,
`Error cancelling run: ${errorMessage}`,
"error",
);
expect(button).toBeVisible();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { cancelPipelineRun } from "@/services/pipelineRunService";

type CancelPipelineRunButtonProps = {
runId: string | null | undefined;
teamId?: string;
showLabel?: boolean;
displayLabel?: string;
showTooltip?: boolean;
Expand All @@ -21,6 +22,7 @@ type CancelPipelineRunButtonProps = {

export const CancelPipelineRunButton = ({
runId,
teamId,
showLabel,
displayLabel,
showTooltip = true,
Expand All @@ -36,12 +38,12 @@ export const CancelPipelineRunButton = ({
isPending,
isSuccess,
} = useMutation({
mutationFn: (runId: string) => cancelPipelineRun(runId, backendUrl),
mutationFn: (runId: string) => cancelPipelineRun(runId, backendUrl, teamId),
onSuccess: () => {
notify(`Pipeline run ${runId} cancelled`, "success");
},
onError: (error) => {
notify(`Error cancelling run: ${error}`, "error");
notify(`Error cancelling run: ${error.message}`, "error");
},
});

Expand Down
125 changes: 125 additions & 0 deletions src/components/PipelineRun/components/RerunPipelineButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
import type { ReactElement } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

import { useMissingSecrets } from "@/components/shared/SecretsManagement/useMissingSecrets";
import { BackendProvider } from "@/providers/BackendProvider";
import { useRunTeam } from "@/services/teams/useRunTeam";

import { RerunPipelineButton } from "./RerunPipelineButton";

Expand Down Expand Up @@ -74,6 +76,16 @@ vi.mock("@/providers/ExecutionDataProvider", () => ({
useExecutionDataOptional: mockUseExecutionDataOptional,
}));

vi.mock("@/services/teams/useRunTeam", () => ({
useRunTeam: vi.fn(),
}));

vi.mock("@/components/shared/SecretsManagement/useMissingSecrets", () => ({
useMissingSecrets: vi.fn(),
}));

const ML_TEAM = { id: "ml-team@example.com", label: "ML Research" };

const testOrigin = import.meta.env.VITE_BASE_URL || "http://localhost:3000";

Object.defineProperty(window, "location", {
Expand Down Expand Up @@ -116,6 +128,11 @@ describe("<RerunPipelineButton/>", () => {
mockGetToken.mockReturnValue("mock-token");
mockAwaitAuthorization.mockClear();
mockUseExecutionDataOptional.mockReturnValue(undefined);
vi.mocked(useRunTeam).mockReturnValue({ status: "none" });
vi.mocked(useMissingSecrets).mockReturnValue({
missing: [],
isChecking: false,
});
});

afterEach(async () => {
Expand Down Expand Up @@ -419,4 +436,112 @@ describe("<RerunPipelineButton/>", () => {
);
});
});

describe("for a run filed under a team's project", () => {
const rerunNow = async () => {
await act(async () => {
renderWithProviders(
<RerunPipelineButton componentSpec={componentSpec} />,
);
});
await act(async () => {
fireEvent.click(screen.getByTestId("rerun-pipeline-button"));
});
};

beforeEach(() => {
mockSubmitPipelineRun.mockImplementation(async (_, __, { onSuccess }) => {
onSuccess({ id: 123 });
});
});

test("reruns as the team", async () => {
vi.mocked(useRunTeam).mockReturnValue({
status: "active",
team: { ...ML_TEAM, canWrite: true },
});

await rerunNow();

await waitFor(() => {
expect(mockSubmitPipelineRun).toHaveBeenCalledWith(
componentSpec,
expect.any(String),
expect.objectContaining({ teamId: ML_TEAM.id }),
);
});
});

test("asks before rerunning as you instead of the team", async () => {
vi.mocked(useRunTeam).mockReturnValue({
status: "not-member",
team: ML_TEAM,
});

await rerunNow();

expect(
screen.getByRole("alertdialog", { name: "Rerun this pipeline?" }),
).toBeInTheDocument();
expect(screen.getByText("Runs as you")).toBeInTheDocument();
expect(mockSubmitPipelineRun).not.toHaveBeenCalled();

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
});

await waitFor(() => {
expect(mockSubmitPipelineRun).toHaveBeenCalledWith(
componentSpec,
expect.any(String),
expect.objectContaining({ teamId: undefined }),
);
});
});

test("asks before rerunning without the secrets the run uses", async () => {
vi.mocked(useRunTeam).mockReturnValue({
status: "active",
team: { ...ML_TEAM, canWrite: true },
});
vi.mocked(useMissingSecrets).mockReturnValue({
missing: ["API_KEY"],
isChecking: false,
});

await rerunNow();

expect(screen.getByTestId("info-box-warning")).toHaveTextContent(
"API_KEY isn't one of the ML Research team's secrets, so the run will fail when it starts.",
);
expect(mockSubmitPipelineRun).not.toHaveBeenCalled();
});

test("waits for the run's team before rerunning", async () => {
vi.mocked(useRunTeam).mockReturnValue({ status: "resolving" });

await act(async () => {
renderWithProviders(
<RerunPipelineButton componentSpec={componentSpec} />,
);
});

expect(screen.getByTestId("rerun-pipeline-button")).toBeDisabled();
});

test("won't rerun a run whose projects are on different teams", async () => {
vi.mocked(useRunTeam).mockReturnValue({
status: "conflict",
teams: [ML_TEAM, { id: "data@example.com", label: "Data" }],
});

await act(async () => {
renderWithProviders(
<RerunPipelineButton componentSpec={componentSpec} />,
);
});

expect(screen.getByTestId("rerun-pipeline-button")).toBeDisabled();
});
});
});
Loading
Loading