Skip to content

Commit 53ee8ab

Browse files
ivicackresimir-coko
authored andcommitted
3851 client - Remove workflowNodeDetailsPanelOpen from workflow execution stop conditions
1 parent 748c0f5 commit 53ee8ab

File tree

4 files changed

+397
-23
lines changed

4 files changed

+397
-23
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import {BASE_PATH} from '@/shared/middleware/platform/workflow/test/runtime';
2+
import {act, renderHook, waitFor} from '@testing-library/react';
3+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
4+
5+
import {useWorkflowBuilderHeader} from '../useWorkflowBuilderHeader';
6+
7+
// ---- Test doubles and module mocks ----
8+
9+
// Create spies in a hoisted block so they are available to vi.mock factories (which are hoisted too)
10+
const hoisted = vi.hoisted(() => {
11+
return {
12+
analyticsSpies: {
13+
captureProjectPublished: vi.fn(),
14+
captureProjectWorkflowTested: vi.fn(),
15+
},
16+
chatSpies: {
17+
resetMessages: vi.fn(),
18+
setWorkflowTestChatPanelOpen: vi.fn(),
19+
workflowTestChatPanelOpen: false,
20+
},
21+
editorSpies: {
22+
setShowBottomPanelOpen: vi.fn(),
23+
setWorkflowIsRunning: vi.fn(),
24+
setWorkflowTestExecution: vi.fn(),
25+
showBottomPanel: false,
26+
},
27+
nodePanelSpies: {
28+
setCurrentNode: vi.fn(),
29+
setWorkflowNodeDetailsPanelOpen: vi.fn(),
30+
},
31+
} as const;
32+
});
33+
34+
// Mocks for Zustand stores used inside the hook
35+
vi.mock('@/pages/platform/workflow-editor/stores/useWorkflowDataStore', () => {
36+
const state = {workflow: {id: 'wf-1'}};
37+
return {default: (selector: (s: typeof state) => unknown) => selector(state)};
38+
});
39+
40+
vi.mock('@/shared/stores/useEnvironmentStore', () => {
41+
const state = {currentEnvironmentId: 1};
42+
return {useEnvironmentStore: (selector: (s: typeof state) => unknown) => selector(state)};
43+
});
44+
45+
vi.mock('@/pages/platform/workflow-editor/stores/useWorkflowEditorStore', () => {
46+
return {
47+
__esModule: true,
48+
__spies: hoisted.editorSpies,
49+
default: (selector: (s: typeof hoisted.editorSpies) => unknown) => selector(hoisted.editorSpies),
50+
};
51+
});
52+
53+
vi.mock('@/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore', () => {
54+
return {
55+
__esModule: true,
56+
__spies: hoisted.nodePanelSpies,
57+
default: (selector: (s: typeof hoisted.nodePanelSpies) => unknown) => selector(hoisted.nodePanelSpies),
58+
};
59+
});
60+
61+
vi.mock('@/pages/platform/workflow-editor/stores/useWorkflowTestChatStore', () => {
62+
return {
63+
__esModule: true,
64+
__spies: hoisted.chatSpies,
65+
default: (selector: (s: typeof hoisted.chatSpies) => unknown) => selector(hoisted.chatSpies),
66+
};
67+
});
68+
69+
const dataPillPanelSpies = {
70+
setDataPillPanelOpen: vi.fn(),
71+
};
72+
73+
vi.mock('@/pages/platform/workflow-editor/stores/useDataPillPanelStore', () => {
74+
return {
75+
__esModule: true,
76+
default: (selector: (s: typeof dataPillPanelSpies) => unknown) => selector(dataPillPanelSpies),
77+
};
78+
});
79+
80+
// Analytics hook
81+
vi.mock('@/shared/hooks/useAnalytics', () => ({useAnalytics: () => hoisted.analyticsSpies}));
82+
83+
// Toast (unused in these tests but required)
84+
vi.mock('@/hooks/use-toast', () => ({useToast: () => ({toast: vi.fn()})}));
85+
86+
// React Router hooks used inside the hook
87+
vi.mock('react-router-dom', () => ({
88+
useNavigate: () => vi.fn(),
89+
useParams: () => ({workflowUuid: 'wf-uuid-1'}),
90+
useSearchParams: () => [new URLSearchParams(''), vi.fn()],
91+
}));
92+
93+
// Queries and mutations used by the hook
94+
vi.mock('@/ee/shared/mutations/embedded/connectedUserProjectWorkflows.mutations', () => ({
95+
usePublishConnectedUserProjectWorkflowMutation: () => ({isPending: false, mutate: vi.fn()}),
96+
}));
97+
98+
vi.mock('@/ee/shared/queries/embedded/connectedUserProjectWorkflows.queries', () => ({
99+
ConnectedUserProjectWorkflowKeys: {
100+
connectedUserProjectWorkflow: (_: string) => ['connectedUserProjectWorkflow', _],
101+
},
102+
}));
103+
104+
// react-query client (prevent the need for a Provider in tests)
105+
vi.mock('@tanstack/react-query', () => ({
106+
useQueryClient: () => ({invalidateQueries: vi.fn()}),
107+
}));
108+
109+
// Stream request builder for Run
110+
const streamRequest = {
111+
init: {headers: {'Content-Type': 'application/json'}, method: 'POST'} as RequestInit,
112+
url: '/sse/start',
113+
};
114+
vi.mock('@/shared/util/testWorkflow-utils', () => ({
115+
getTestWorkflowAttachRequest: vi.fn((params: {jobId: string}) => ({
116+
init: {
117+
credentials: 'include',
118+
headers: {Accept: 'text/event-stream'},
119+
method: 'GET',
120+
},
121+
url: `${BASE_PATH}/workflow-tests/${params.jobId}/attach`,
122+
})),
123+
getTestWorkflowStreamPostRequest: vi.fn(() => streamRequest),
124+
}));
125+
126+
// WorkflowTestExecution model helper used in result handler – return input as-is for tests
127+
vi.mock('@/shared/middleware/platform/workflow/test/models/WorkflowTestExecution', () => ({
128+
WorkflowTestExecutionFromJSON: (x: unknown) => x,
129+
}));
130+
131+
// ---- Mock useSSE to capture requests and provide controllable handlers ----
132+
import type {SSERequestType, UseSSEOptionsType, UseSSEResultType} from '@/shared/hooks/useSSE';
133+
134+
const latest = {
135+
close: vi.fn(),
136+
handlers: undefined as UseSSEOptionsType['eventHandlers'],
137+
req: null as SSERequestType,
138+
};
139+
140+
vi.mock('@/shared/hooks/useSSE', async () => {
141+
const actual: Record<string, unknown> = await vi.importActual('@/shared/hooks/useSSE');
142+
return {
143+
__esModule: true,
144+
...actual,
145+
useSSE: (req: SSERequestType, options: UseSSEOptionsType = {}): UseSSEResultType => {
146+
latest.req = req;
147+
latest.handlers = options.eventHandlers;
148+
return {close: latest.close, connectionState: 'CONNECTED', data: null, error: null};
149+
},
150+
};
151+
});
152+
153+
// ---- Helpers ----
154+
import type {ImperativePanelHandle} from 'react-resizable-panels';
155+
156+
type PanelRefType = {
157+
current: ImperativePanelHandle;
158+
};
159+
160+
function makePanelRef(getSizeReturn = 0): PanelRefType {
161+
const handle: ImperativePanelHandle = {
162+
collapse: vi.fn(),
163+
expand: vi.fn(),
164+
getId: vi.fn().mockReturnValue('panel'),
165+
getSize: vi.fn().mockReturnValue(getSizeReturn),
166+
isCollapsed: vi.fn().mockReturnValue(false),
167+
isExpanded: vi.fn().mockReturnValue(true),
168+
resize: vi.fn(),
169+
};
170+
return {current: handle};
171+
}
172+
173+
describe('useWorkflowBuilderHeader', () => {
174+
const originalFetch = global.fetch;
175+
176+
function resetIfMock(obj: Record<string, unknown>) {
177+
Object.values(obj).forEach((v) => {
178+
const maybeMock = v as {mockReset?: () => void; mockClear?: () => void};
179+
maybeMock.mockReset?.();
180+
maybeMock.mockClear?.();
181+
});
182+
}
183+
184+
beforeEach(() => {
185+
// Clean localStorage before every test
186+
localStorage.clear();
187+
// Reset spies
188+
resetIfMock(hoisted.editorSpies as unknown as Record<string, unknown>);
189+
resetIfMock(hoisted.nodePanelSpies as unknown as Record<string, unknown>);
190+
resetIfMock(hoisted.chatSpies as unknown as Record<string, unknown>);
191+
resetIfMock(hoisted.analyticsSpies as unknown as Record<string, unknown>);
192+
latest.req = null;
193+
latest.handlers = undefined;
194+
latest.close.mockReset();
195+
document.cookie = '';
196+
});
197+
198+
afterEach(() => {
199+
global.fetch = originalFetch;
200+
});
201+
202+
it('reattaches on mount using saved jobId and processes start/result', async () => {
203+
const jobId = '123';
204+
const storageKey = `bytechef.workflow-test-run.wf-1:1`;
205+
localStorage.setItem(storageKey, jobId);
206+
207+
const panelRef = makePanelRef(0);
208+
209+
const {result} = renderHook(() =>
210+
useWorkflowBuilderHeader({bottomResizablePanelRef: panelRef, chatTrigger: false, projectId: 42})
211+
);
212+
213+
// useSSE should be called with attach request for the saved jobId
214+
await waitFor(() => {
215+
const req = latest.req as NonNullable<SSERequestType>;
216+
expect(req && req.url).toBe(`${BASE_PATH}/workflow-tests/${jobId}/attach`);
217+
const init = req.init as RequestInit;
218+
expect(init.method).toBe('GET');
219+
expect((init.headers as Record<string, string>)['Accept']).toBe('text/event-stream');
220+
expect(init.credentials).toBe('include');
221+
});
222+
223+
// Simulate start -> persist jobId
224+
act(() => latest.handlers?.start?.({jobId}));
225+
expect(localStorage.getItem(storageKey)).toBe(jobId);
226+
227+
// Simulate result -> stop running, set execution, clear jobId and resize panel if closed
228+
act(() => latest.handlers?.result?.({ok: true}));
229+
await waitFor(() => expect(hoisted.editorSpies.setWorkflowIsRunning).toHaveBeenCalledWith(false));
230+
expect(hoisted.editorSpies.setWorkflowTestExecution).toHaveBeenCalled();
231+
expect(panelRef.current.resize).toHaveBeenCalledWith(35);
232+
expect(localStorage.getItem(storageKey)).toBeNull();
233+
234+
// Expose returned API just to avoid unused warning
235+
expect(typeof result.current.handleRunClick).toBe('function');
236+
});
237+
238+
it('handleRunClick opens panel, tracks analytics and starts stream via builder', async () => {
239+
const panelRef = makePanelRef(0);
240+
const {result} = renderHook(() =>
241+
useWorkflowBuilderHeader({bottomResizablePanelRef: panelRef, chatTrigger: false, projectId: 42})
242+
);
243+
244+
act(() => {
245+
result.current.handleRunClick();
246+
});
247+
248+
// Panel opened and resized
249+
expect(hoisted.editorSpies.setShowBottomPanelOpen).toHaveBeenCalledWith(true);
250+
expect(panelRef.current.resize).toHaveBeenCalledWith(35);
251+
// Execution cleared and analytics tracked
252+
expect(hoisted.editorSpies.setWorkflowTestExecution).toHaveBeenCalledWith(undefined);
253+
expect(hoisted.analyticsSpies.captureProjectWorkflowTested).toHaveBeenCalled();
254+
255+
// useSSE should be called with the request produced by the builder
256+
await waitFor(() => {
257+
const req = latest.req as NonNullable<SSERequestType>;
258+
expect(req && req.url).toBe(streamRequest.url);
259+
});
260+
});
261+
262+
it('does not stop workflow execution when node details panel is open', async () => {
263+
const panelRef = makePanelRef(0);
264+
const {rerender, result} = renderHook(() =>
265+
useWorkflowBuilderHeader({bottomResizablePanelRef: panelRef, chatTrigger: false, projectId: 42})
266+
);
267+
268+
// Start workflow execution
269+
act(() => {
270+
result.current.handleRunClick();
271+
});
272+
273+
// Verify workflow started
274+
expect(hoisted.editorSpies.setWorkflowIsRunning).toHaveBeenCalledWith(true);
275+
expect(hoisted.analyticsSpies.captureProjectWorkflowTested).toHaveBeenCalled();
276+
277+
// Clear the mock to check if handleStopClick is called
278+
hoisted.editorSpies.setWorkflowIsRunning.mockClear();
279+
280+
// Re-render - even if node details panel were open, workflow should NOT stop
281+
// This tests the fix for issue #3851 where opening the node details panel
282+
// would automatically stop the workflow execution
283+
rerender();
284+
285+
// Wait a bit to ensure the effect doesn't trigger
286+
await new Promise((resolve) => setTimeout(resolve, 100));
287+
288+
// Workflow should still be running - setWorkflowIsRunning(false) should NOT have been called
289+
expect(hoisted.editorSpies.setWorkflowIsRunning).not.toHaveBeenCalledWith(false);
290+
expect(latest.close).not.toHaveBeenCalled();
291+
});
292+
293+
it('stops workflow execution in chat mode when chat panel is closed', async () => {
294+
const panelRef = makePanelRef(0);
295+
const {result} = renderHook(() =>
296+
useWorkflowBuilderHeader({bottomResizablePanelRef: panelRef, chatTrigger: true, projectId: 42})
297+
);
298+
299+
// Start workflow execution via chat
300+
act(() => {
301+
result.current.handleRunClick();
302+
});
303+
304+
// Verify chat panel was opened
305+
expect(hoisted.chatSpies.setWorkflowTestChatPanelOpen).toHaveBeenCalledWith(true);
306+
307+
// Clear mocks
308+
hoisted.editorSpies.setWorkflowIsRunning.mockClear();
309+
latest.close.mockClear();
310+
311+
// Manually call handleStopClick to simulate what happens when chat panel closes
312+
// In a real scenario, the effect would trigger when workflowTestChatPanelOpen becomes false
313+
act(() => {
314+
result.current.handleStopClick();
315+
});
316+
317+
// Verify workflow stopped
318+
expect(hoisted.editorSpies.setWorkflowIsRunning).toHaveBeenCalledWith(false);
319+
expect(latest.close).toHaveBeenCalled();
320+
});
321+
});

client/src/ee/pages/embedded/automation-workflows/workflow-builder/components/workflow-builder-header/hooks/useWorkflowBuilderHeader.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,12 @@ export const useWorkflowBuilderHeader = ({bottomResizablePanelRef, chatTrigger,
3939
showBottomPanel: state.showBottomPanel,
4040
}))
4141
);
42-
const {setCurrentNode, setWorkflowNodeDetailsPanelOpen, workflowNodeDetailsPanelOpen} =
43-
useWorkflowNodeDetailsPanelStore(
44-
useShallow((state) => ({
45-
setCurrentNode: state.setCurrentNode,
46-
setWorkflowNodeDetailsPanelOpen: state.setWorkflowNodeDetailsPanelOpen,
47-
workflowNodeDetailsPanelOpen: state.workflowNodeDetailsPanelOpen,
48-
}))
49-
);
42+
const {setCurrentNode, setWorkflowNodeDetailsPanelOpen} = useWorkflowNodeDetailsPanelStore(
43+
useShallow((state) => ({
44+
setCurrentNode: state.setCurrentNode,
45+
setWorkflowNodeDetailsPanelOpen: state.setWorkflowNodeDetailsPanelOpen,
46+
}))
47+
);
5048
const {resetMessages, setWorkflowTestChatPanelOpen, workflowTestChatPanelOpen} = useWorkflowTestChatStore(
5149
useShallow((state) => ({
5250
resetMessages: state.resetMessages,
@@ -224,13 +222,12 @@ export const useWorkflowBuilderHeader = ({bottomResizablePanelRef, chatTrigger,
224222
}, [workflow.id, currentEnvironmentId, getPersistedJobId, setWorkflowIsRunning, setJobId, setStreamRequest]);
225223

226224
// Stop the workflow execution when:
227-
// - The node details panel is opened (this always cancels runs, regardless of chat mode), or
228225
// - We are in chat mode (`chatTrigger` is true) and the chat panel is not open (`!workflowTestChatPanelOpen`)
229226
useEffect(() => {
230-
if (workflowNodeDetailsPanelOpen || (chatTrigger && !workflowTestChatPanelOpen)) {
227+
if (chatTrigger && !workflowTestChatPanelOpen) {
231228
handleStopClick();
232229
}
233-
}, [chatTrigger, handleStopClick, workflowNodeDetailsPanelOpen, workflowTestChatPanelOpen]);
230+
}, [chatTrigger, handleStopClick, workflowTestChatPanelOpen]);
234231

235232
useEffect(() => {
236233
if (workflowTestStreamError) {

0 commit comments

Comments
 (0)