Skip to content

Commit 512ceef

Browse files
committed
682 client - Add tests
1 parent 2d84430 commit 512ceef

File tree

3 files changed

+556
-0
lines changed

3 files changed

+556
-0
lines changed
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
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 {useProjectHeader} from '../useProjectHeader';
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+
workflowNodeDetailsPanelOpen: false,
31+
},
32+
} as const;
33+
});
34+
35+
// Mocks for Zustand stores used inside the hook
36+
vi.mock('@/pages/platform/workflow-editor/stores/useWorkflowDataStore', () => {
37+
const state = {workflow: {id: 'wf-1'}};
38+
return {default: (selector: (s: typeof state) => unknown) => selector(state)};
39+
});
40+
41+
vi.mock('@/shared/stores/useEnvironmentStore', () => {
42+
const state = {currentEnvironmentId: 1};
43+
return {useEnvironmentStore: (selector: (s: typeof state) => unknown) => selector(state)};
44+
});
45+
46+
vi.mock('@/pages/platform/workflow-editor/stores/useWorkflowEditorStore', () => {
47+
return {
48+
__esModule: true,
49+
__spies: hoisted.editorSpies,
50+
default: (selector: (s: typeof hoisted.editorSpies) => unknown) => selector(hoisted.editorSpies),
51+
};
52+
});
53+
54+
vi.mock('@/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore', () => {
55+
return {
56+
__esModule: true,
57+
__spies: hoisted.nodePanelSpies,
58+
default: (selector: (s: typeof hoisted.nodePanelSpies) => unknown) => selector(hoisted.nodePanelSpies),
59+
};
60+
});
61+
62+
vi.mock('@/pages/platform/workflow-editor/stores/useWorkflowTestChatStore', () => {
63+
return {
64+
__esModule: true,
65+
__spies: hoisted.chatSpies,
66+
default: (selector: (s: typeof hoisted.chatSpies) => unknown) => selector(hoisted.chatSpies),
67+
};
68+
});
69+
70+
vi.mock('@/pages/automation/stores/useWorkspaceStore', () => {
71+
const state = {currentWorkspaceId: 'ws-1'};
72+
return {useWorkspaceStore: (selector: (s: typeof state) => unknown) => selector(state)};
73+
});
74+
75+
// Analytics hook
76+
vi.mock('@/shared/hooks/useAnalytics', () => ({useAnalytics: () => hoisted.analyticsSpies}));
77+
78+
// Toast (unused in these tests but required)
79+
vi.mock('@/hooks/use-toast', () => ({useToast: () => ({toast: vi.fn()})}));
80+
81+
// React Router hooks used inside the hook
82+
vi.mock('react-router-dom', () => ({
83+
useLoaderData: () => ({}),
84+
useNavigate: () => vi.fn(),
85+
useSearchParams: () => [new URLSearchParams(''), vi.fn()],
86+
}));
87+
88+
// Queries and mutations used by the hook
89+
vi.mock('@/shared/queries/automation/projects.queries', () => ({
90+
ProjectKeys: {filteredProjects: (_: unknown) => ['filtered', _], project: (_: number) => ['project', _]},
91+
useGetProjectQuery: () => ({data: {id: 42}}),
92+
}));
93+
vi.mock('@/shared/queries/automation/projectWorkflows.queries', () => ({
94+
useGetProjectWorkflowsQuery: () => ({data: []}),
95+
}));
96+
vi.mock('@/shared/mutations/automation/projects.mutations', () => ({
97+
usePublishProjectMutation: () => ({isPending: false, mutate: vi.fn()}),
98+
}));
99+
100+
// react-query client (prevent the need for a Provider in tests)
101+
vi.mock('@tanstack/react-query', () => ({
102+
useQueryClient: () => ({invalidateQueries: vi.fn()}),
103+
}));
104+
105+
// ProjectVersionKeys used in publish success path
106+
vi.mock('@/shared/queries/automation/projectVersions.queries', () => ({
107+
ProjectVersionKeys: {projectProjectVersions: (_: number) => ['projectVersions', _]},
108+
}));
109+
110+
// Stream request builder for Run
111+
const streamRequest = {
112+
init: {headers: {'Content-Type': 'application/json'}, method: 'POST'} as RequestInit,
113+
url: '/sse/start',
114+
};
115+
vi.mock('@/shared/util/testWorkflow-utils', () => ({
116+
getTestWorkflowAttachRequest: vi.fn((params: {jobId: string}) => ({
117+
init: {
118+
credentials: 'include',
119+
headers: {Accept: 'text/event-stream'},
120+
method: 'GET',
121+
},
122+
url: `${BASE_PATH}/workflow-tests/${params.jobId}/attach`,
123+
})),
124+
getTestWorkflowStreamPostRequest: vi.fn(() => streamRequest),
125+
}));
126+
127+
// WorkflowTestExecution model helper used in result handler – return input as-is for tests
128+
vi.mock('@/shared/middleware/platform/workflow/test/models/WorkflowTestExecution', () => ({
129+
WorkflowTestExecutionFromJSON: (x: unknown) => x,
130+
}));
131+
132+
// ---- Mock useSSE to capture requests and provide controllable handlers ----
133+
import type {SSERequestType, UseSSEOptionsType, UseSSEResultType} from '@/shared/hooks/useSSE';
134+
135+
const latest = {
136+
close: vi.fn(),
137+
handlers: undefined as UseSSEOptionsType['eventHandlers'],
138+
req: null as SSERequestType,
139+
};
140+
141+
vi.mock('@/shared/hooks/useSSE', async () => {
142+
const actual: Record<string, unknown> = await vi.importActual('@/shared/hooks/useSSE');
143+
return {
144+
__esModule: true,
145+
...actual,
146+
useSSE: (req: SSERequestType, options: UseSSEOptionsType = {}): UseSSEResultType => {
147+
latest.req = req;
148+
latest.handlers = options.eventHandlers;
149+
return {close: latest.close, connectionState: 'CONNECTED', data: null, error: null};
150+
},
151+
};
152+
});
153+
154+
// ---- Helpers ----
155+
import type {ImperativePanelHandle} from 'react-resizable-panels';
156+
157+
type PanelRefType = {
158+
current: ImperativePanelHandle;
159+
};
160+
161+
function makePanelRef(getSizeReturn = 0): PanelRefType {
162+
const handle: ImperativePanelHandle = {
163+
collapse: vi.fn(),
164+
expand: vi.fn(),
165+
getId: vi.fn().mockReturnValue('panel'),
166+
getSize: vi.fn().mockReturnValue(getSizeReturn),
167+
isCollapsed: vi.fn().mockReturnValue(false),
168+
isExpanded: vi.fn().mockReturnValue(true),
169+
resize: vi.fn(),
170+
};
171+
return {current: handle};
172+
}
173+
174+
describe('useProjectHeader', () => {
175+
const originalFetch = global.fetch;
176+
177+
function resetIfMock(obj: Record<string, unknown>) {
178+
Object.values(obj).forEach((v) => {
179+
const maybeMock = v as {mockReset?: () => void; mockClear?: () => void};
180+
maybeMock.mockReset?.();
181+
maybeMock.mockClear?.();
182+
});
183+
}
184+
185+
beforeEach(() => {
186+
// Clean localStorage before every test
187+
localStorage.clear();
188+
// Reset spies
189+
resetIfMock(hoisted.editorSpies as unknown as Record<string, unknown>);
190+
resetIfMock(hoisted.nodePanelSpies as unknown as Record<string, unknown>);
191+
resetIfMock(hoisted.chatSpies as unknown as Record<string, unknown>);
192+
resetIfMock(hoisted.analyticsSpies as unknown as Record<string, unknown>);
193+
latest.req = null;
194+
latest.handlers = undefined;
195+
latest.close.mockReset();
196+
document.cookie = '';
197+
});
198+
199+
afterEach(() => {
200+
global.fetch = originalFetch;
201+
});
202+
203+
it('reattaches on mount using saved jobId and processes start/result', async () => {
204+
const jobId = '123';
205+
const storageKey = `bytechef.workflow-test-run.wf-1:1`;
206+
localStorage.setItem(storageKey, jobId);
207+
208+
const panelRef = makePanelRef(0);
209+
210+
const {result} = renderHook(() =>
211+
useProjectHeader({bottomResizablePanelRef: panelRef, chatTrigger: false, projectId: 42})
212+
);
213+
214+
// useSSE should be called with attach request for the saved jobId
215+
await waitFor(() => {
216+
const req = latest.req as NonNullable<SSERequestType>;
217+
expect(req && req.url).toBe(`${BASE_PATH}/workflow-tests/${jobId}/attach`);
218+
const init = req.init as RequestInit;
219+
expect(init.method).toBe('GET');
220+
expect((init.headers as Record<string, string>)['Accept']).toBe('text/event-stream');
221+
expect(init.credentials).toBe('include');
222+
});
223+
224+
// Simulate start -> persist jobId
225+
act(() => latest.handlers?.start?.(JSON.stringify({jobId})));
226+
expect(localStorage.getItem(storageKey)).toBe(jobId);
227+
228+
// Simulate result -> stop running, set execution, clear jobId and resize panel if closed
229+
act(() => latest.handlers?.result?.(JSON.stringify({ok: true})));
230+
await waitFor(() => expect(hoisted.editorSpies.setWorkflowIsRunning).toHaveBeenCalledWith(false));
231+
expect(hoisted.editorSpies.setWorkflowTestExecution).toHaveBeenCalled();
232+
expect(panelRef.current.resize).toHaveBeenCalledWith(35);
233+
expect(localStorage.getItem(storageKey)).toBeNull();
234+
235+
// Expose returned API just to avoid unused warning
236+
expect(typeof result.current.handleRunClick).toBe('function');
237+
});
238+
239+
it('handleStopClick posts to stop endpoint with XSRF and clears jobId', async () => {
240+
const jobId = '555';
241+
const storageKey = `bytechef.workflow-test-run.wf-1:1`;
242+
243+
// Render hook and simulate a running stream with a jobId via start
244+
const panelRef = makePanelRef(0);
245+
const {result} = renderHook(() =>
246+
useProjectHeader({bottomResizablePanelRef: panelRef, chatTrigger: false, projectId: 42})
247+
);
248+
act(() => latest.handlers?.start?.(JSON.stringify({jobId})));
249+
localStorage.setItem(storageKey, jobId);
250+
251+
// Mock fetch and cookie
252+
document.cookie = 'XSRF-TOKEN=token123';
253+
const fetchSpy = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, {status: 200}));
254+
(globalThis as unknown as {fetch: typeof fetch}).fetch = fetchSpy;
255+
256+
// Call stop
257+
const stopCloseBefore = latest.close.mock.calls.length;
258+
act(() => {
259+
result.current.handleStopClick();
260+
});
261+
262+
// Should call close and POST to stop endpoint
263+
expect(latest.close.mock.calls.length).toBeGreaterThanOrEqual(stopCloseBefore + 1);
264+
265+
await waitFor(() => {
266+
expect(fetchSpy).toHaveBeenCalled();
267+
const call = fetchSpy.mock.calls[0] as [string, RequestInit];
268+
const [url, init] = call;
269+
expect(url).toBe(`${BASE_PATH}/workflow-tests/${jobId}/stop`);
270+
expect(init.method).toBe('POST');
271+
});
272+
273+
// JobId cleared from storage
274+
await waitFor(() => expect(localStorage.getItem(storageKey)).toBeNull());
275+
});
276+
277+
it('handleRunClick opens panel, tracks analytics and starts stream via builder', async () => {
278+
const panelRef = makePanelRef(0);
279+
const {result} = renderHook(() =>
280+
useProjectHeader({bottomResizablePanelRef: panelRef, chatTrigger: false, projectId: 42})
281+
);
282+
283+
act(() => {
284+
result.current.handleRunClick();
285+
});
286+
287+
// Panel opened and resized
288+
expect(hoisted.editorSpies.setShowBottomPanelOpen).toHaveBeenCalledWith(true);
289+
expect(panelRef.current.resize).toHaveBeenCalledWith(35);
290+
// Execution cleared and analytics tracked
291+
expect(hoisted.editorSpies.setWorkflowTestExecution).toHaveBeenCalledWith(undefined);
292+
expect(hoisted.analyticsSpies.captureProjectWorkflowTested).toHaveBeenCalled();
293+
294+
// useSSE should be called with the request produced by the builder
295+
await waitFor(() => {
296+
const req = latest.req as NonNullable<SSERequestType>;
297+
expect(req && req.url).toBe(streamRequest.url);
298+
});
299+
});
300+
});

0 commit comments

Comments
 (0)