FN-6980: fix inherited workflow details
Fix task workflow details so inherited board defaults render consistently in the live Workflow tab. - Resolve null task workflow selections through board workflow mappings or the project default for read-only detail surfaces. - Load workflow graphs and optional steps from the effective workflow while preserving explicit selector state. - Add coverage for default inheritance, explicit custom workflows, cleared selections, stale workflow ids, and task detail progress fixtures. Files changed: .../app/components/WorkflowResultsTab.tsx | 60 ++++++++--- ...skDetailModal.models-progress-workflow.test.tsx | 63 +++++++++++ .../__tests__/TaskDetailModal.test-helpers.ts | 23 ++++ .../__tests__/WorkflowResultsTab.test.tsx | 118 ++++++++++++++++++++- 4 files changed, 243 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-6980 Fusion-Task-Lineage: 2ee9ca68-4053-4d69-a4da-e2bfc22cfe43
This commit is contained in:
@@ -13,7 +13,7 @@ import remarkGfm from "remark-gfm";
|
||||
import { ReactFlow, ReactFlowProvider } from "@xyflow/react";
|
||||
import type { AgentLogEntry, Settings, Task, TaskDetail, WorkflowDefinition, WorkflowStep, WorkflowStepResult, ResolvedWorkflowOptionalStep } from "@fusion/core";
|
||||
import { getErrorMessage, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core";
|
||||
import { approveTaskWorkflowCli, fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchTaskWorkflow, fetchWorkflowOptionalSteps, selectTaskWorkflow, submitTaskWorkflowInput } from "../api";
|
||||
import { approveTaskWorkflowCli, fetchBoardWorkflows, fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchTaskWorkflow, fetchWorkflowOptionalSteps, selectTaskWorkflow, submitTaskWorkflowInput } from "../api";
|
||||
import { WorkflowSelector } from "./WorkflowSelector";
|
||||
import { phaseBadge } from "./workflow-phase-badge";
|
||||
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||
@@ -139,12 +139,12 @@ function getOutputPreview(output: string): string {
|
||||
// and the optional-steps dropdown). Imported above.
|
||||
|
||||
function getWorkflowName(
|
||||
selectedWorkflowId: string | null,
|
||||
workflowId: string | null,
|
||||
workflows: WorkflowDefinition[],
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
): string {
|
||||
if (!selectedWorkflowId) return t("app:workflow.defaultWorkflow", "Default");
|
||||
const match = workflows.find((workflow) => workflow.id === selectedWorkflowId);
|
||||
if (!workflowId) return t("app:workflow.noWorkflowAssigned", "No workflow assigned");
|
||||
const match = workflows.find((workflow) => workflow.id === workflowId);
|
||||
return match?.name || t("app:workflow.customWorkflowFallback", "Custom workflow");
|
||||
}
|
||||
|
||||
@@ -323,6 +323,7 @@ export function WorkflowResultsTab({
|
||||
const [allWorkflowSteps, setAllWorkflowSteps] = useState<WorkflowStep[]>([]);
|
||||
const [optionalWorkflowSteps, setOptionalWorkflowSteps] = useState<ResolvedWorkflowOptionalStep[]>([]);
|
||||
const [workflowDefinitions, setWorkflowDefinitions] = useState<WorkflowDefinition[]>([]);
|
||||
const [boardWorkflowFallbackId, setBoardWorkflowFallbackId] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
|
||||
const [resumeError, setResumeError] = useState<string | null>(null);
|
||||
@@ -387,13 +388,38 @@ export function WorkflowResultsTab({
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!graphExpanded || !selectedWorkflowId || workflowGraphCache[selectedWorkflowId]) return;
|
||||
let cancelled = false;
|
||||
setBoardWorkflowFallbackId(null);
|
||||
fetchBoardWorkflows(projectId)
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
const mappedWorkflowId = payload.taskWorkflowIds?.[taskId] || null;
|
||||
const defaultWorkflowId = payload.defaultWorkflowId || null;
|
||||
setBoardWorkflowFallbackId(payload.flagEnabled ? (mappedWorkflowId ?? defaultWorkflowId) : null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setBoardWorkflowFallbackId(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [taskId, projectId]);
|
||||
|
||||
/*
|
||||
FNXC:TaskWorkflowDetails 2026-06-24-09:50:
|
||||
A null per-task workflow selection means "inherit the board workflow" when board-workflows supplies a task mapping or project default. Use this effective id for read-only task-detail surfaces while keeping the explicit selection value for WorkflowSelector.
|
||||
*/
|
||||
const effectiveWorkflowId = selectedWorkflowId ?? boardWorkflowFallbackId;
|
||||
const graphCacheKey = effectiveWorkflowId ? `${projectId ?? ""}::${effectiveWorkflowId}` : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!graphExpanded || !effectiveWorkflowId || !graphCacheKey || workflowGraphCache[graphCacheKey]) return;
|
||||
let cancelled = false;
|
||||
setWorkflowGraphLoading(true);
|
||||
fetchWorkflow(selectedWorkflowId, projectId)
|
||||
fetchWorkflow(effectiveWorkflowId, projectId)
|
||||
.then((definition) => {
|
||||
if (!cancelled) {
|
||||
setWorkflowGraphCache((prev) => ({ ...prev, [selectedWorkflowId]: definition }));
|
||||
setWorkflowGraphCache((prev) => ({ ...prev, [graphCacheKey]: definition }));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -405,7 +431,7 @@ export function WorkflowResultsTab({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [graphExpanded, selectedWorkflowId, projectId, workflowGraphCache]);
|
||||
}, [graphExpanded, effectiveWorkflowId, graphCacheKey, projectId, workflowGraphCache]);
|
||||
|
||||
// Check if any result has pending status
|
||||
const hasPendingStep = results.some((r) => r.status === "pending");
|
||||
@@ -436,11 +462,13 @@ export function WorkflowResultsTab({
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const effectiveOptionalStepsWorkflowId = selectedWorkflowId || "builtin:coding";
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveWorkflowId) {
|
||||
setOptionalWorkflowSteps([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
fetchWorkflowOptionalSteps(effectiveOptionalStepsWorkflowId, projectId)
|
||||
fetchWorkflowOptionalSteps(effectiveWorkflowId, projectId)
|
||||
.then((steps) => {
|
||||
if (!cancelled) setOptionalWorkflowSteps(steps);
|
||||
})
|
||||
@@ -450,7 +478,7 @@ export function WorkflowResultsTab({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [effectiveOptionalStepsWorkflowId, projectId]);
|
||||
}, [effectiveWorkflowId, projectId]);
|
||||
|
||||
const selectedWorkflowSteps = enabledWorkflowSteps ?? [];
|
||||
|
||||
@@ -574,11 +602,11 @@ export function WorkflowResultsTab({
|
||||
});
|
||||
}, [selectedWorkflowSteps, workflowStepLookup, t]);
|
||||
|
||||
const workflowName = useMemo(() => getWorkflowName(selectedWorkflowId, workflowDefinitions, t), [selectedWorkflowId, workflowDefinitions, t]);
|
||||
const workflowName = useMemo(() => getWorkflowName(effectiveWorkflowId, workflowDefinitions, t), [effectiveWorkflowId, workflowDefinitions, t]);
|
||||
const executionPhase = useMemo(() => getExecutionPhase(task, taskStatus, taskPausedReason, results, t), [task, taskStatus, taskPausedReason, results, t]);
|
||||
const aggregateResult = useMemo(() => getAggregateWorkflowResult(results, t), [results, t]);
|
||||
const completedStepCount = useMemo(() => results.filter((result) => ["passed", "skipped", "failed", "advisory_failure"].includes(result.status)).length, [results]);
|
||||
const graphWorkflow = selectedWorkflowId ? workflowGraphCache[selectedWorkflowId] : undefined;
|
||||
const graphWorkflow = graphCacheKey ? workflowGraphCache[graphCacheKey] : undefined;
|
||||
const graphFlow = useMemo(() => (graphWorkflow ? irToFlow(graphWorkflow) : null), [graphWorkflow]);
|
||||
const effectiveExecutor = useMemo(() => (task ? resolveTaskExecutionModel(task, settings) : undefined), [task, settings]);
|
||||
const effectiveValidator = useMemo(() => (task ? resolveTaskValidatorModel(task, settings) : undefined), [task, settings]);
|
||||
@@ -1011,7 +1039,7 @@ export function WorkflowResultsTab({
|
||||
</button>
|
||||
{graphExpanded && (
|
||||
<div className="workflow-disclosure__content">
|
||||
{!selectedWorkflowId ? (
|
||||
{!effectiveWorkflowId ? (
|
||||
<p className="workflow-disclosure__empty" data-testid="workflow-graph-empty">
|
||||
{t("app:workflow.noWorkflowAssigned", "No workflow assigned")}
|
||||
</p>
|
||||
@@ -1050,7 +1078,7 @@ export function WorkflowResultsTab({
|
||||
<section className="card workflow-management" data-testid="workflow-management-section">
|
||||
<div className="workflow-management__header">
|
||||
<h4>{t("app:workflow.workflowName", "Workflow")}</h4>
|
||||
{canEdit && selectedWorkflowId && onEditWorkflow && (
|
||||
{canEdit && effectiveWorkflowId && onEditWorkflow && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
expectBaseRule,
|
||||
readDashboardStylesSource,
|
||||
setupTaskDetailModalHooks,
|
||||
taskDetailSseSubscriptions,
|
||||
} from "./TaskDetailModal.test-helpers";
|
||||
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
|
||||
|
||||
@@ -1057,6 +1058,68 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("updates workflow results from matching task:updated SSE events while ignoring other tasks", async () => {
|
||||
const { fetchWorkflowResults } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchWorkflowResults);
|
||||
mockFetch.mockResolvedValueOnce([
|
||||
{
|
||||
workflowStepId: "WS-INITIAL",
|
||||
workflowStepName: "Initial Check",
|
||||
status: "pending",
|
||||
output: "Initial output",
|
||||
},
|
||||
] as import("@fusion/core").WorkflowStepResult[]);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
initialTab="workflow"
|
||||
task={makeTask({ id: "FN-099", enabledWorkflowSteps: ["WS-INITIAL"] })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Initial Check", {}, { timeout: 15_000 })).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(taskDetailSseSubscriptions.some(({ options }) => typeof options.events?.["task:updated"] === "function")).toBe(true);
|
||||
});
|
||||
const workflowSubscription = taskDetailSseSubscriptions.find(({ options }) => typeof options.events?.["task:updated"] === "function");
|
||||
const emitTaskUpdated = workflowSubscription!.options.events!["task:updated"];
|
||||
|
||||
await act(async () => {
|
||||
emitTaskUpdated(new MessageEvent("task:updated", {
|
||||
data: JSON.stringify({
|
||||
id: "FN-OTHER",
|
||||
workflowStepResults: [
|
||||
{ workflowStepId: "WS-OTHER", workflowStepName: "Other Task Check", status: "failed", output: "Wrong task" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Other Task Check")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Initial Check")).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
emitTaskUpdated(new MessageEvent("task:updated", {
|
||||
data: JSON.stringify({
|
||||
id: "FN-099",
|
||||
workflowStepResults: [
|
||||
{ workflowStepId: "WS-LIVE", workflowStepName: "Live QA Check", status: "passed", output: "Updated from SSE" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Live QA Check")).toBeTruthy();
|
||||
expect(screen.getByText("Updated from SSE")).toBeTruthy();
|
||||
expect(screen.queryByText("Initial Check")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders configured workflow steps state when results are empty", async () => {
|
||||
const { fetchWorkflowResults } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchWorkflowResults);
|
||||
|
||||
@@ -9,6 +9,20 @@ import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
|
||||
import type { TaskDetail, Column, MergeResult, Task } from "@fusion/core";
|
||||
import { clearAuthToken } from "../../auth";
|
||||
|
||||
const taskDetailSseSubscriptions = vi.hoisted(() => [] as Array<{
|
||||
url: string;
|
||||
options: { events?: Record<string, (event: MessageEvent) => void> };
|
||||
}>);
|
||||
|
||||
export { taskDetailSseSubscriptions };
|
||||
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: vi.fn((url: string, options: { events?: Record<string, (event: MessageEvent) => void> }) => {
|
||||
taskDetailSseSubscriptions.push({ url, options });
|
||||
return vi.fn();
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const { createDashboardApiMock } = await import("../../test/mockApi");
|
||||
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
|
||||
@@ -32,6 +46,14 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
|
||||
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
fetchWorkflows: vi.fn().mockResolvedValue([]),
|
||||
fetchTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null }),
|
||||
fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }),
|
||||
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
|
||||
fetchWorkflow: vi.fn().mockResolvedValue({ id: "builtin:coding", name: "Coding", ir: { version: 1, nodes: [], edges: [] } }),
|
||||
selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null, enabledWorkflowSteps: [] }),
|
||||
submitTaskWorkflowInput: vi.fn().mockResolvedValue({ ok: true }),
|
||||
approveTaskWorkflowCli: vi.fn().mockResolvedValue({ approved: "ok" }),
|
||||
refineText: vi.fn(),
|
||||
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
@@ -175,6 +197,7 @@ export function setupTaskDetailModalHooks(): void {
|
||||
mockConfirmWithCheckbox.mockResolvedValue({ choice: "primary", checkboxValue: false });
|
||||
clearAuthToken();
|
||||
localStorage.removeItem("fn.authToken");
|
||||
taskDetailSseSubscriptions.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -23,6 +23,7 @@ const mockedFetchWorkflowSteps = vi.spyOn(api, "fetchWorkflowSteps");
|
||||
const mockedFetchTaskWorkflow = vi.spyOn(api, "fetchTaskWorkflow");
|
||||
const mockedFetchWorkflow = vi.spyOn(api, "fetchWorkflow");
|
||||
const mockedFetchWorkflows = vi.spyOn(api, "fetchWorkflows");
|
||||
const mockedFetchBoardWorkflows = vi.spyOn(api, "fetchBoardWorkflows");
|
||||
const mockedFetchWorkflowOptionalSteps = vi.spyOn(api, "fetchWorkflowOptionalSteps");
|
||||
const mockedSelectTaskWorkflow = vi.spyOn(api, "selectTaskWorkflow");
|
||||
const mockedSubmitTaskWorkflowInput = vi.spyOn(api, "submitTaskWorkflowInput");
|
||||
@@ -67,6 +68,24 @@ describe("WorkflowResultsTab", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const defaultWorkflow: WorkflowDefinition = {
|
||||
id: "builtin:coding",
|
||||
name: "Built-in Coding Workflow",
|
||||
description: "Default workflow",
|
||||
ir: {
|
||||
version: 1,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", config: {} },
|
||||
{ id: "execute", kind: "prompt", config: { name: "Execute task" } },
|
||||
{ id: "end", kind: "end", config: {} },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "execute" },
|
||||
{ from: "execute", to: "end" },
|
||||
],
|
||||
},
|
||||
} as WorkflowDefinition;
|
||||
|
||||
const selectedWorkflow: WorkflowDefinition = {
|
||||
id: "WF-001",
|
||||
name: "Custom Delivery Workflow",
|
||||
@@ -131,9 +150,19 @@ describe("WorkflowResultsTab", () => {
|
||||
mockedFetchTaskWorkflow.mockReset();
|
||||
mockedFetchTaskWorkflow.mockResolvedValue({ workflowId: "WF-001" });
|
||||
mockedFetchWorkflow.mockReset();
|
||||
mockedFetchWorkflow.mockResolvedValue(selectedWorkflow);
|
||||
mockedFetchWorkflow.mockImplementation((workflowId) => Promise.resolve(workflowId === "builtin:coding" ? defaultWorkflow : selectedWorkflow));
|
||||
mockedFetchWorkflows.mockReset();
|
||||
mockedFetchWorkflows.mockResolvedValue([selectedWorkflow]);
|
||||
mockedFetchWorkflows.mockResolvedValue([defaultWorkflow, selectedWorkflow]);
|
||||
mockedFetchBoardWorkflows.mockReset();
|
||||
mockedFetchBoardWorkflows.mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [
|
||||
{ id: "builtin:coding", name: "Built-in Coding Workflow", columns: [] },
|
||||
{ id: "WF-001", name: "Custom Delivery Workflow", columns: [] },
|
||||
],
|
||||
taskWorkflowIds: {},
|
||||
});
|
||||
mockedFetchWorkflowOptionalSteps.mockReset();
|
||||
mockedFetchWorkflowOptionalSteps.mockResolvedValue([
|
||||
{
|
||||
@@ -259,15 +288,94 @@ describe("WorkflowResultsTab", () => {
|
||||
expect(await screen.findByTestId("react-flow-mock")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no workflow assigned when none is selected", async () => {
|
||||
it("resolves a null task workflow selection through the board default and loads its graph", async () => {
|
||||
mockedFetchTaskWorkflow.mockResolvedValueOnce({ workflowId: null });
|
||||
|
||||
render(<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} projectId="project-default" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Built-in Coding Workflow"));
|
||||
expect(screen.queryByText("No workflow assigned")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("workflow-graph-toggle"));
|
||||
|
||||
await waitFor(() => expect(mockedFetchWorkflow).toHaveBeenCalledWith("builtin:coding", "project-default"));
|
||||
expect(await screen.findByTestId("workflow-graph-preview")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("react-flow-mock")).toHaveTextContent("nodes:");
|
||||
});
|
||||
|
||||
it("keeps an explicit custom workflow ahead of the board default", async () => {
|
||||
mockedFetchTaskWorkflow.mockResolvedValueOnce({ workflowId: "WF-001" });
|
||||
|
||||
render(<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} projectId="project-custom" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Custom Delivery Workflow"));
|
||||
|
||||
fireEvent.click(screen.getByTestId("workflow-graph-toggle"));
|
||||
|
||||
await waitFor(() => expect(mockedFetchWorkflow).toHaveBeenCalledWith("WF-001", "project-custom"));
|
||||
expect(mockedFetchWorkflow).not.toHaveBeenCalledWith("builtin:coding", "project-custom");
|
||||
expect(await screen.findByTestId("workflow-graph-preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to the effective default workflow when an explicit selection is cleared", async () => {
|
||||
mockedSelectTaskWorkflow.mockResolvedValueOnce({ workflowId: null, enabledWorkflowSteps: [] });
|
||||
|
||||
render(
|
||||
<WorkflowResultsTab
|
||||
taskId="FN-001"
|
||||
task={baseTask}
|
||||
settings={mockSettings}
|
||||
results={mockResults}
|
||||
canEdit
|
||||
projectId="project-cleared"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Custom Delivery Workflow"));
|
||||
fireEvent.change(await screen.findByLabelText("Custom workflow"), { target: { value: "" } });
|
||||
|
||||
await waitFor(() => expect(mockedSelectTaskWorkflow).toHaveBeenCalledWith("FN-001", null, "project-cleared"));
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Built-in Coding Workflow"));
|
||||
|
||||
fireEvent.click(screen.getByTestId("workflow-graph-toggle"));
|
||||
|
||||
await waitFor(() => expect(mockedFetchWorkflow).toHaveBeenCalledWith("builtin:coding", "project-cleared"));
|
||||
expect(await screen.findByTestId("workflow-graph-preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows graph unavailable without crashing for an unknown stale workflow id", async () => {
|
||||
mockedFetchTaskWorkflow.mockResolvedValueOnce({ workflowId: "WF-STALE" });
|
||||
mockedFetchWorkflows.mockResolvedValueOnce([defaultWorkflow, selectedWorkflow]);
|
||||
mockedFetchWorkflow.mockImplementation((workflowId) => {
|
||||
if (workflowId === "WF-STALE") return Promise.reject(new Error("missing workflow"));
|
||||
return Promise.resolve(workflowId === "builtin:coding" ? defaultWorkflow : selectedWorkflow);
|
||||
});
|
||||
|
||||
render(<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} projectId="project-stale" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Custom workflow"));
|
||||
fireEvent.click(screen.getByTestId("workflow-graph-toggle"));
|
||||
|
||||
await waitFor(() => expect(mockedFetchWorkflow).toHaveBeenCalledWith("WF-STALE", "project-stale"));
|
||||
expect(await screen.findByTestId("workflow-graph-unavailable")).toHaveTextContent("Workflow graph unavailable");
|
||||
expect(screen.queryByTestId("workflow-graph-preview")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no workflow assigned and avoids graph fetch when board workflows provide no usable effective id", async () => {
|
||||
mockedFetchWorkflows.mockResolvedValueOnce([]);
|
||||
const { fetchTaskWorkflow } = await import("../../api");
|
||||
vi.mocked(fetchTaskWorkflow).mockResolvedValueOnce({ workflowId: null });
|
||||
mockedFetchBoardWorkflows.mockResolvedValueOnce({
|
||||
flagEnabled: false,
|
||||
defaultWorkflowId: "",
|
||||
workflows: [],
|
||||
taskWorkflowIds: {},
|
||||
});
|
||||
mockedFetchTaskWorkflow.mockResolvedValueOnce({ workflowId: null });
|
||||
|
||||
render(<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} />);
|
||||
fireEvent.click(screen.getByTestId("workflow-graph-toggle"));
|
||||
|
||||
expect(await screen.findByTestId("workflow-graph-empty")).toHaveTextContent("No workflow assigned");
|
||||
expect(mockedFetchWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows edit workflow affordance only when editable and workflow selected", async () => {
|
||||
|
||||
Reference in New Issue
Block a user