Skip to content

Commit de71949

Browse files
test(orchestrator): add frontend hook and status coverage (#3578)
* test(orchestrator): add frontend hook and status coverage Add targeted frontend tests for orchestrator hooks and status components so config and status regressions are caught earlier. Harden the status indicator fallback for unknown states so the covered behavior matches runtime behavior. * test(orchestrator): fix permission hook test typing Use real orchestrator basic permissions in the permission-array hook test so the fixture matches the hook contract, and add the required changeset for the plugin package. * chore: ignore .worktrees directory Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c990755 commit de71949

13 files changed

Lines changed: 889 additions & 2 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,6 @@ site
6363

6464
# Cursor IDE configuration files
6565
.cursorrules
66+
67+
# Local git worktrees
68+
.worktrees/
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-orchestrator': patch
3+
---
4+
5+
Add frontend unit test coverage for orchestrator hooks and status components, and harden the status indicator fallback for unknown states.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import '@testing-library/jest-dom';
18+
19+
import { ReactNode } from 'react';
20+
21+
import { fireEvent, render, screen } from '@testing-library/react';
22+
23+
import MissingSchemaNotice from './MissingSchemaNotice';
24+
25+
jest.mock('../../hooks/useTranslation', () => ({
26+
useTranslation: () => ({
27+
t: (key: string) => key,
28+
}),
29+
}));
30+
31+
jest.mock(
32+
'@red-hat-developer-hub/backstage-plugin-orchestrator-form-react',
33+
() => ({
34+
SubmitButton: ({
35+
children,
36+
handleClick,
37+
submitting,
38+
}: {
39+
children: ReactNode;
40+
handleClick: () => void;
41+
submitting?: boolean;
42+
}) => (
43+
<button
44+
type="button"
45+
onClick={handleClick}
46+
disabled={submitting ?? false}
47+
data-testid="submit-button"
48+
>
49+
{children}
50+
</button>
51+
),
52+
}),
53+
);
54+
55+
describe('MissingSchemaNotice', () => {
56+
it('renders the missing schema guidance and run button', () => {
57+
render(
58+
<MissingSchemaNotice
59+
isExecuting={false}
60+
handleExecute={jest.fn().mockResolvedValue(undefined)}
61+
/>,
62+
);
63+
64+
expect(
65+
screen.getByText('messages.missingJsonSchema.title'),
66+
).toBeInTheDocument();
67+
expect(
68+
screen.getByText(/messages\.missingJsonSchema\.message/),
69+
).toBeInTheDocument();
70+
expect(screen.getByText('dataInputSchema')).toBeInTheDocument();
71+
expect(
72+
screen.getByRole('button', { name: 'common.run' }),
73+
).toBeInTheDocument();
74+
});
75+
76+
it('invokes handleExecute with empty payload when run is clicked', () => {
77+
const handleExecute = jest.fn().mockResolvedValue(undefined);
78+
79+
render(
80+
<MissingSchemaNotice isExecuting={false} handleExecute={handleExecute} />,
81+
);
82+
83+
fireEvent.click(screen.getByRole('button', { name: 'common.run' }));
84+
expect(handleExecute).toHaveBeenCalledWith({});
85+
});
86+
87+
it('renders and invokes execute-as-event button when configured', () => {
88+
const handleExecuteAsEvent = jest.fn().mockResolvedValue(undefined);
89+
90+
render(
91+
<MissingSchemaNotice
92+
isExecuting={false}
93+
handleExecute={jest.fn().mockResolvedValue(undefined)}
94+
handleExecuteAsEvent={handleExecuteAsEvent}
95+
executeAsEventLabel="Run as event"
96+
/>,
97+
);
98+
99+
fireEvent.click(screen.getByRole('button', { name: 'Run as event' }));
100+
expect(handleExecuteAsEvent).toHaveBeenCalledWith({});
101+
});
102+
103+
it('does not render execute-as-event button when label is missing', () => {
104+
render(
105+
<MissingSchemaNotice
106+
isExecuting={false}
107+
handleExecute={jest.fn().mockResolvedValue(undefined)}
108+
handleExecuteAsEvent={jest.fn().mockResolvedValue(undefined)}
109+
/>,
110+
);
111+
112+
expect(screen.queryByRole('button', { name: 'Run as event' })).toBeNull();
113+
});
114+
115+
it('disables action buttons while execution is in progress', () => {
116+
render(
117+
<MissingSchemaNotice
118+
isExecuting
119+
handleExecute={jest.fn().mockResolvedValue(undefined)}
120+
handleExecuteAsEvent={jest.fn().mockResolvedValue(undefined)}
121+
executeAsEventLabel="Run as event"
122+
/>,
123+
);
124+
125+
expect(screen.getByRole('button', { name: 'common.run' })).toBeDisabled();
126+
expect(screen.getByRole('button', { name: 'Run as event' })).toBeDisabled();
127+
});
128+
129+
it('re-enables action buttons when execution completes', () => {
130+
const { rerender } = render(
131+
<MissingSchemaNotice
132+
isExecuting
133+
handleExecute={jest.fn().mockResolvedValue(undefined)}
134+
handleExecuteAsEvent={jest.fn().mockResolvedValue(undefined)}
135+
executeAsEventLabel="Run as event"
136+
/>,
137+
);
138+
139+
expect(screen.getByRole('button', { name: 'common.run' })).toBeDisabled();
140+
expect(screen.getByRole('button', { name: 'Run as event' })).toBeDisabled();
141+
142+
rerender(
143+
<MissingSchemaNotice
144+
isExecuting={false}
145+
handleExecute={jest.fn().mockResolvedValue(undefined)}
146+
handleExecuteAsEvent={jest.fn().mockResolvedValue(undefined)}
147+
executeAsEventLabel="Run as event"
148+
/>,
149+
);
150+
151+
expect(
152+
screen.getByRole('button', { name: 'common.run' }),
153+
).not.toBeDisabled();
154+
expect(
155+
screen.getByRole('button', { name: 'Run as event' }),
156+
).not.toBeDisabled();
157+
});
158+
});
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import '@testing-library/jest-dom';
18+
19+
import { ReactNode } from 'react';
20+
21+
import { render, screen } from '@testing-library/react';
22+
23+
import { ProcessInstanceStatusDTO } from '@red-hat-developer-hub/backstage-plugin-orchestrator-common';
24+
25+
import { VALUE_UNAVAILABLE } from '../../constants';
26+
import { useWorkflowInstanceStateColors } from '../../hooks/useWorkflowInstanceStatusColors';
27+
import { WorkflowInstanceStatusIndicator } from './WorkflowInstanceStatusIndicator';
28+
29+
jest.mock('@backstage/core-components', () => ({
30+
Link: ({ to, children }: { to: string; children: ReactNode }) => (
31+
<a href={to}>{children}</a>
32+
),
33+
}));
34+
35+
jest.mock('../../hooks/useTranslation', () => ({
36+
useTranslation: () => ({
37+
t: (key: string) =>
38+
({
39+
'table.status.running': 'Running',
40+
'table.status.completed': 'Completed',
41+
'tooltips.suspended': 'Suspended',
42+
'table.status.aborted': 'Aborted',
43+
'table.status.failed': 'Failed',
44+
'table.status.pending': 'Pending',
45+
})[key] ?? key,
46+
}),
47+
}));
48+
49+
jest.mock('../../hooks/useWorkflowInstanceStatusColors', () => ({
50+
useWorkflowInstanceStateColors: jest.fn(() => 'status-icon'),
51+
}));
52+
53+
describe('WorkflowInstanceStatusIndicator', () => {
54+
it('renders unavailable value when status is missing', () => {
55+
render(<WorkflowInstanceStatusIndicator />);
56+
57+
expect(screen.getByText(VALUE_UNAVAILABLE)).toBeInTheDocument();
58+
});
59+
60+
it('renders completed status text without link when instanceLink is not provided', () => {
61+
render(
62+
<WorkflowInstanceStatusIndicator
63+
status={ProcessInstanceStatusDTO.Completed}
64+
/>,
65+
);
66+
67+
expect(screen.getByText('Completed')).toBeInTheDocument();
68+
expect(screen.queryByRole('link', { name: 'Completed' })).toBeNull();
69+
});
70+
71+
it('renders status title as a link when instanceLink is provided', () => {
72+
render(
73+
<WorkflowInstanceStatusIndicator
74+
status={ProcessInstanceStatusDTO.Error}
75+
instanceLink="/orchestrator/instances/1"
76+
/>,
77+
);
78+
79+
const link = screen.getByRole('link', { name: 'Failed' });
80+
expect(link).toHaveAttribute('href', '/orchestrator/instances/1');
81+
});
82+
83+
it('renders pending title for pending status', () => {
84+
render(
85+
<WorkflowInstanceStatusIndicator
86+
status={ProcessInstanceStatusDTO.Pending}
87+
/>,
88+
);
89+
90+
expect(screen.getByText('Pending')).toBeInTheDocument();
91+
});
92+
93+
it('renders running title for active status', () => {
94+
render(
95+
<WorkflowInstanceStatusIndicator
96+
status={ProcessInstanceStatusDTO.Active}
97+
/>,
98+
);
99+
100+
expect(screen.getByText('Running')).toBeInTheDocument();
101+
});
102+
103+
it('renders suspended title for suspended status', () => {
104+
render(
105+
<WorkflowInstanceStatusIndicator
106+
status={ProcessInstanceStatusDTO.Suspended}
107+
/>,
108+
);
109+
110+
expect(screen.getByText('Suspended')).toBeInTheDocument();
111+
});
112+
113+
it('aborted title for aborted status', () => {
114+
render(
115+
<WorkflowInstanceStatusIndicator
116+
status={ProcessInstanceStatusDTO.Aborted}
117+
/>,
118+
);
119+
120+
expect(screen.getByText('Aborted')).toBeInTheDocument();
121+
});
122+
123+
it('renders unavailable for unknown status', () => {
124+
render(
125+
<WorkflowInstanceStatusIndicator
126+
status={'UnknownStatus' as ProcessInstanceStatusDTO}
127+
/>,
128+
);
129+
130+
expect(screen.getByText(VALUE_UNAVAILABLE)).toBeInTheDocument();
131+
});
132+
133+
it('calls useWorkflowInstanceStateColors with correct status', () => {
134+
render(
135+
<WorkflowInstanceStatusIndicator
136+
status={ProcessInstanceStatusDTO.Completed}
137+
/>,
138+
);
139+
140+
expect(useWorkflowInstanceStateColors).toHaveBeenCalledWith(
141+
ProcessInstanceStatusDTO.Completed,
142+
);
143+
});
144+
145+
it('applies color class to the rendered icon element', () => {
146+
const { container } = render(
147+
<WorkflowInstanceStatusIndicator
148+
status={ProcessInstanceStatusDTO.Completed}
149+
/>,
150+
);
151+
152+
expect(useWorkflowInstanceStateColors).toHaveBeenCalledWith(
153+
ProcessInstanceStatusDTO.Completed,
154+
);
155+
expect(container.querySelector('svg.status-icon')).toBeInTheDocument();
156+
});
157+
});

workspaces/orchestrator/plugins/orchestrator/src/components/ui/WorkflowInstanceStatusIndicator.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,7 @@ export const WorkflowInstanceStatusIndicator = ({
8080
title = t('table.status.pending');
8181
break;
8282
default:
83-
icon = VALUE_UNAVAILABLE;
84-
break;
83+
return <>{VALUE_UNAVAILABLE}</>;
8584
}
8685

8786
return (

0 commit comments

Comments
 (0)