github-issue-tower-defence-management 2.12.1 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-D6QOQvoN.js → index-BTFgj8n5.js} +31 -31
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/domain/usecases/ActionAnnouncementUseCase.js +4 -2
- package/bin/domain/usecases/ActionAnnouncementUseCase.js.map +1 -1
- package/bin/domain/usecases/AnalyzeStoriesUseCase.js +1 -1
- package/bin/domain/usecases/AnalyzeStoriesUseCase.js.map +1 -1
- package/bin/domain/usecases/CliErrorReportUseCase.js +1 -1
- package/bin/domain/usecases/CliErrorReportUseCase.js.map +1 -1
- package/bin/domain/usecases/CreateEstimationIssueUseCase.js +2 -1
- package/bin/domain/usecases/CreateEstimationIssueUseCase.js.map +1 -1
- package/bin/domain/usecases/DailySecurityScanUseCase.js +3 -3
- package/bin/domain/usecases/DailySecurityScanUseCase.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +3 -3
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +4 -0
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/SubscriptionDisabledIssueUseCase.js +1 -1
- package/bin/domain/usecases/SubscriptionDisabledIssueUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOfflinePendingActionsPanel.stories.tsx +115 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOfflinePendingActionsPanel.test.tsx +262 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOfflinePendingActionsPanel.tsx +123 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +283 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +146 -4
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +28 -1
- package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +2 -2
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +103 -1
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +128 -0
- package/src/adapter/entry-points/console/ui-dist/assets/{index-D6QOQvoN.js → index-BTFgj8n5.js} +31 -31
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/domain/usecases/ActionAnnouncementUseCase.test.ts +91 -0
- package/src/domain/usecases/ActionAnnouncementUseCase.ts +4 -2
- package/src/domain/usecases/AnalyzeStoriesUseCase.test.ts +2 -0
- package/src/domain/usecases/AnalyzeStoriesUseCase.ts +1 -1
- package/src/domain/usecases/CliErrorReportUseCase.test.ts +11 -0
- package/src/domain/usecases/CliErrorReportUseCase.ts +1 -1
- package/src/domain/usecases/CreateEstimationIssueUseCase.test.ts +2 -0
- package/src/domain/usecases/CreateEstimationIssueUseCase.ts +2 -1
- package/src/domain/usecases/DailySecurityScanUseCase.test.ts +6 -0
- package/src/domain/usecases/DailySecurityScanUseCase.ts +3 -3
- package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +25 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +3 -3
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +20 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +4 -0
- package/src/domain/usecases/SubscriptionDisabledIssueUseCase.test.ts +3 -0
- package/src/domain/usecases/SubscriptionDisabledIssueUseCase.ts +1 -1
- package/types/domain/usecases/ActionAnnouncementUseCase.d.ts.map +1 -1
- package/types/domain/usecases/CreateEstimationIssueUseCase.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
2
|
+
import {
|
|
3
|
+
type ConsoleOfflinePendingActionItem,
|
|
4
|
+
ConsoleOfflinePendingActionsPanel,
|
|
5
|
+
} from './ConsoleOfflinePendingActionsPanel';
|
|
6
|
+
|
|
7
|
+
const approveAction: ConsoleOfflinePendingActionItem = {
|
|
8
|
+
id: 'offline-1',
|
|
9
|
+
message: 'Approved — PR #851',
|
|
10
|
+
color: 'green',
|
|
11
|
+
itemNumber: 851,
|
|
12
|
+
isPr: true,
|
|
13
|
+
currentTitle: 'Add serveConsole subcommand under entry-points',
|
|
14
|
+
currentState: 'open',
|
|
15
|
+
currentPrStatus: null,
|
|
16
|
+
fetchError: false,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const baseProps = {
|
|
20
|
+
actions: [approveAction],
|
|
21
|
+
isOnline: true,
|
|
22
|
+
isConfirming: false,
|
|
23
|
+
onConfirm: () => {},
|
|
24
|
+
onDiscard: () => {},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('ConsoleOfflinePendingActionsPanel', () => {
|
|
28
|
+
it('renders nothing when the action list is empty', () => {
|
|
29
|
+
const { container } = render(
|
|
30
|
+
<ConsoleOfflinePendingActionsPanel {...baseProps} actions={[]} />,
|
|
31
|
+
);
|
|
32
|
+
expect(container.firstChild).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('shows the action count and a confirm-before-sending message when online', () => {
|
|
36
|
+
const { getByText } = render(
|
|
37
|
+
<ConsoleOfflinePendingActionsPanel {...baseProps} />,
|
|
38
|
+
);
|
|
39
|
+
expect(getByText(/1 action held/)).toBeInTheDocument();
|
|
40
|
+
expect(getByText(/confirm each before sending/)).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('shows an awaiting-connectivity message when offline', () => {
|
|
44
|
+
const { getByText } = render(
|
|
45
|
+
<ConsoleOfflinePendingActionsPanel {...baseProps} isOnline={false} />,
|
|
46
|
+
);
|
|
47
|
+
expect(getByText(/awaiting connectivity/)).toBeInTheDocument();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('shows per-action rows with item target when offline', () => {
|
|
51
|
+
const { getByText, queryByText } = render(
|
|
52
|
+
<ConsoleOfflinePendingActionsPanel {...baseProps} isOnline={false} />,
|
|
53
|
+
);
|
|
54
|
+
expect(getByText(/PR #851 — Add serveConsole/)).toBeInTheDocument();
|
|
55
|
+
expect(getByText('Approved — PR #851')).toBeInTheDocument();
|
|
56
|
+
expect(queryByText('Send')).not.toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('shows Discard button even when offline', () => {
|
|
60
|
+
const { getByText } = render(
|
|
61
|
+
<ConsoleOfflinePendingActionsPanel {...baseProps} isOnline={false} />,
|
|
62
|
+
);
|
|
63
|
+
expect(getByText('Discard')).toBeInTheDocument();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('shows the action message and item label when online', () => {
|
|
67
|
+
const { getByText, container } = render(
|
|
68
|
+
<ConsoleOfflinePendingActionsPanel {...baseProps} />,
|
|
69
|
+
);
|
|
70
|
+
expect(getByText('Approved — PR #851')).toBeInTheDocument();
|
|
71
|
+
expect(
|
|
72
|
+
container.querySelector('.console-offline-panel-item-target')
|
|
73
|
+
?.textContent,
|
|
74
|
+
).toContain('PR #851');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('shows the current item title and state when loaded', () => {
|
|
78
|
+
const { getByText } = render(
|
|
79
|
+
<ConsoleOfflinePendingActionsPanel {...baseProps} />,
|
|
80
|
+
);
|
|
81
|
+
expect(
|
|
82
|
+
getByText(/Add serveConsole subcommand under entry-points/),
|
|
83
|
+
).toBeInTheDocument();
|
|
84
|
+
expect(getByText(/open/)).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('shows a loading indicator when the current state is not yet fetched', () => {
|
|
88
|
+
const { getByText } = render(
|
|
89
|
+
<ConsoleOfflinePendingActionsPanel
|
|
90
|
+
{...baseProps}
|
|
91
|
+
actions={[{ ...approveAction, currentTitle: null, currentState: null }]}
|
|
92
|
+
/>,
|
|
93
|
+
);
|
|
94
|
+
expect(getByText(/loading current state/)).toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('disables the Send button while the current state is loading', () => {
|
|
98
|
+
const { getByText } = render(
|
|
99
|
+
<ConsoleOfflinePendingActionsPanel
|
|
100
|
+
{...baseProps}
|
|
101
|
+
actions={[{ ...approveAction, currentTitle: null, currentState: null }]}
|
|
102
|
+
/>,
|
|
103
|
+
);
|
|
104
|
+
expect(getByText('Send')).toBeDisabled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('enables the Send button once the current state is loaded', () => {
|
|
108
|
+
const { getByText } = render(
|
|
109
|
+
<ConsoleOfflinePendingActionsPanel {...baseProps} />,
|
|
110
|
+
);
|
|
111
|
+
expect(getByText('Send')).not.toBeDisabled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('enables the Send button when the state fetch failed so the operator can still proceed', () => {
|
|
115
|
+
const { getByText } = render(
|
|
116
|
+
<ConsoleOfflinePendingActionsPanel
|
|
117
|
+
{...baseProps}
|
|
118
|
+
actions={[
|
|
119
|
+
{
|
|
120
|
+
...approveAction,
|
|
121
|
+
currentTitle: null,
|
|
122
|
+
currentState: null,
|
|
123
|
+
fetchError: true,
|
|
124
|
+
},
|
|
125
|
+
]}
|
|
126
|
+
/>,
|
|
127
|
+
);
|
|
128
|
+
expect(getByText('Send')).not.toBeDisabled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('shows a fetch error message when the state could not be loaded', () => {
|
|
132
|
+
const { getByText } = render(
|
|
133
|
+
<ConsoleOfflinePendingActionsPanel
|
|
134
|
+
{...baseProps}
|
|
135
|
+
actions={[
|
|
136
|
+
{
|
|
137
|
+
...approveAction,
|
|
138
|
+
currentTitle: null,
|
|
139
|
+
currentState: null,
|
|
140
|
+
fetchError: true,
|
|
141
|
+
},
|
|
142
|
+
]}
|
|
143
|
+
/>,
|
|
144
|
+
);
|
|
145
|
+
expect(getByText(/Failed to load current state/)).toBeInTheDocument();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('disables the Send button while a confirmation is in progress', () => {
|
|
149
|
+
const { getByText } = render(
|
|
150
|
+
<ConsoleOfflinePendingActionsPanel {...baseProps} isConfirming={true} />,
|
|
151
|
+
);
|
|
152
|
+
expect(getByText('Send')).toBeDisabled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('calls onConfirm with the action id when Send is clicked', () => {
|
|
156
|
+
const onConfirm = jest.fn();
|
|
157
|
+
const { getByText } = render(
|
|
158
|
+
<ConsoleOfflinePendingActionsPanel
|
|
159
|
+
{...baseProps}
|
|
160
|
+
onConfirm={onConfirm}
|
|
161
|
+
/>,
|
|
162
|
+
);
|
|
163
|
+
fireEvent.click(getByText('Send'));
|
|
164
|
+
expect(onConfirm).toHaveBeenCalledWith('offline-1');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('calls onDiscard with the action id when Discard is clicked', () => {
|
|
168
|
+
const onDiscard = jest.fn();
|
|
169
|
+
const { getByText } = render(
|
|
170
|
+
<ConsoleOfflinePendingActionsPanel
|
|
171
|
+
{...baseProps}
|
|
172
|
+
onDiscard={onDiscard}
|
|
173
|
+
/>,
|
|
174
|
+
);
|
|
175
|
+
fireEvent.click(getByText('Discard'));
|
|
176
|
+
expect(onDiscard).toHaveBeenCalledWith('offline-1');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('uses plural phrasing for the count when more than one action is queued', () => {
|
|
180
|
+
const { getByText } = render(
|
|
181
|
+
<ConsoleOfflinePendingActionsPanel
|
|
182
|
+
{...baseProps}
|
|
183
|
+
actions={[
|
|
184
|
+
approveAction,
|
|
185
|
+
{ ...approveAction, id: 'offline-2', message: 'Rejected — PR #853' },
|
|
186
|
+
]}
|
|
187
|
+
/>,
|
|
188
|
+
);
|
|
189
|
+
expect(getByText(/2 actions held/)).toBeInTheDocument();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('renders a Send and Discard button for each queued action when online', () => {
|
|
193
|
+
const { getAllByText } = render(
|
|
194
|
+
<ConsoleOfflinePendingActionsPanel
|
|
195
|
+
{...baseProps}
|
|
196
|
+
actions={[
|
|
197
|
+
approveAction,
|
|
198
|
+
{ ...approveAction, id: 'offline-2', message: 'Rejected — PR #853' },
|
|
199
|
+
]}
|
|
200
|
+
/>,
|
|
201
|
+
);
|
|
202
|
+
expect(getAllByText('Send')).toHaveLength(2);
|
|
203
|
+
expect(getAllByText('Discard')).toHaveLength(2);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('applies the color modifier class to the action row', () => {
|
|
207
|
+
const { container } = render(
|
|
208
|
+
<ConsoleOfflinePendingActionsPanel {...baseProps} />,
|
|
209
|
+
);
|
|
210
|
+
expect(
|
|
211
|
+
container.querySelector('.console-offline-panel-item-green'),
|
|
212
|
+
).toBeInTheDocument();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('shows PR status information when the action targets a PR and status is loaded', () => {
|
|
216
|
+
const { getByText } = render(
|
|
217
|
+
<ConsoleOfflinePendingActionsPanel
|
|
218
|
+
{...baseProps}
|
|
219
|
+
actions={[
|
|
220
|
+
{
|
|
221
|
+
...approveAction,
|
|
222
|
+
currentPrStatus: {
|
|
223
|
+
found: true,
|
|
224
|
+
isConflicted: false,
|
|
225
|
+
mergeableStatus: 'MERGEABLE',
|
|
226
|
+
isPassedAllCiJob: true,
|
|
227
|
+
isCiStateSuccess: true,
|
|
228
|
+
isBranchOutOfDate: false,
|
|
229
|
+
missingRequiredCheckNames: [],
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
]}
|
|
233
|
+
/>,
|
|
234
|
+
);
|
|
235
|
+
expect(getByText(/CI: pass/)).toBeInTheDocument();
|
|
236
|
+
expect(getByText(/Merge: mergeable/)).toBeInTheDocument();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('does not show PR status when offline even if currentPrStatus is set', () => {
|
|
240
|
+
const { queryByText } = render(
|
|
241
|
+
<ConsoleOfflinePendingActionsPanel
|
|
242
|
+
{...baseProps}
|
|
243
|
+
isOnline={false}
|
|
244
|
+
actions={[
|
|
245
|
+
{
|
|
246
|
+
...approveAction,
|
|
247
|
+
currentPrStatus: {
|
|
248
|
+
found: true,
|
|
249
|
+
isConflicted: false,
|
|
250
|
+
mergeableStatus: 'MERGEABLE',
|
|
251
|
+
isPassedAllCiJob: true,
|
|
252
|
+
isCiStateSuccess: true,
|
|
253
|
+
isBranchOutOfDate: false,
|
|
254
|
+
missingRequiredCheckNames: [],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
]}
|
|
258
|
+
/>,
|
|
259
|
+
);
|
|
260
|
+
expect(queryByText(/CI: pass/)).not.toBeInTheDocument();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { ConsoleToastColor } from '../../logic/actionToast';
|
|
2
|
+
import type { ConsolePullRequestStatus } from '../../logic/types';
|
|
3
|
+
|
|
4
|
+
export type ConsoleOfflinePendingActionItem = {
|
|
5
|
+
id: string;
|
|
6
|
+
message: string;
|
|
7
|
+
color: ConsoleToastColor;
|
|
8
|
+
itemNumber: number;
|
|
9
|
+
isPr: boolean;
|
|
10
|
+
currentTitle: string | null;
|
|
11
|
+
currentState: 'open' | 'closed' | null;
|
|
12
|
+
currentPrStatus: ConsolePullRequestStatus | null;
|
|
13
|
+
fetchError: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ConsoleOfflinePendingActionsPanelProps = {
|
|
17
|
+
actions: ConsoleOfflinePendingActionItem[];
|
|
18
|
+
isOnline: boolean;
|
|
19
|
+
isConfirming: boolean;
|
|
20
|
+
onConfirm: (id: string) => void;
|
|
21
|
+
onDiscard: (id: string) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const itemLabel = (item: ConsoleOfflinePendingActionItem): string =>
|
|
25
|
+
`${item.isPr ? 'PR' : 'Issue'} #${item.itemNumber}`;
|
|
26
|
+
|
|
27
|
+
const stateLabel = (state: 'open' | 'closed' | null): string => {
|
|
28
|
+
if (state === null) return 'loading…';
|
|
29
|
+
return state;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const prStatusLabel = (prStatus: ConsolePullRequestStatus): string => {
|
|
33
|
+
const parts: string[] = [];
|
|
34
|
+
parts.push(`CI: ${prStatus.isCiStateSuccess ? 'pass' : 'fail'}`);
|
|
35
|
+
parts.push(`Merge: ${prStatus.mergeableStatus.toLowerCase()}`);
|
|
36
|
+
if (prStatus.isBranchOutOfDate) {
|
|
37
|
+
parts.push('branch out of date');
|
|
38
|
+
}
|
|
39
|
+
return parts.join(' · ');
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const targetText = (action: ConsoleOfflinePendingActionItem): string => {
|
|
43
|
+
if (action.fetchError) {
|
|
44
|
+
return `${itemLabel(action)} — failed to load current state`;
|
|
45
|
+
}
|
|
46
|
+
if (action.currentTitle === null) {
|
|
47
|
+
return `${itemLabel(action)} — loading current state…`;
|
|
48
|
+
}
|
|
49
|
+
return `${itemLabel(action)} — ${action.currentTitle} (${stateLabel(action.currentState)})`;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const ConsoleOfflinePendingActionsPanel = ({
|
|
53
|
+
actions,
|
|
54
|
+
isOnline,
|
|
55
|
+
isConfirming,
|
|
56
|
+
onConfirm,
|
|
57
|
+
onDiscard,
|
|
58
|
+
}: ConsoleOfflinePendingActionsPanelProps) => {
|
|
59
|
+
if (actions.length === 0) return null;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<section
|
|
63
|
+
className="console-offline-panel"
|
|
64
|
+
aria-label="Offline actions queue"
|
|
65
|
+
>
|
|
66
|
+
<div className="console-offline-panel-header">
|
|
67
|
+
{actions.length} action{actions.length !== 1 ? 's' : ''} held
|
|
68
|
+
{isOnline
|
|
69
|
+
? ' — confirm each before sending'
|
|
70
|
+
: ' — awaiting connectivity'}
|
|
71
|
+
</div>
|
|
72
|
+
{actions.map((action) => (
|
|
73
|
+
<div
|
|
74
|
+
key={action.id}
|
|
75
|
+
className={`console-offline-panel-item console-offline-panel-item-${action.color}`}
|
|
76
|
+
>
|
|
77
|
+
<div className="console-offline-panel-item-message">
|
|
78
|
+
{action.message}
|
|
79
|
+
</div>
|
|
80
|
+
<div className="console-offline-panel-item-target">
|
|
81
|
+
{targetText(action)}
|
|
82
|
+
</div>
|
|
83
|
+
{action.fetchError && (
|
|
84
|
+
<div className="console-offline-panel-item-fetch-error">
|
|
85
|
+
Failed to load current state — sending will act on the last known
|
|
86
|
+
state
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
{isOnline &&
|
|
90
|
+
action.isPr &&
|
|
91
|
+
action.currentPrStatus !== null &&
|
|
92
|
+
!action.fetchError && (
|
|
93
|
+
<div className="console-offline-panel-item-pr-status">
|
|
94
|
+
{prStatusLabel(action.currentPrStatus)}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
<div className="console-offline-panel-item-controls">
|
|
98
|
+
{isOnline && (
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
className="console-offline-panel-confirm"
|
|
102
|
+
disabled={
|
|
103
|
+
isConfirming ||
|
|
104
|
+
(action.currentTitle === null && !action.fetchError)
|
|
105
|
+
}
|
|
106
|
+
onClick={() => onConfirm(action.id)}
|
|
107
|
+
>
|
|
108
|
+
Send
|
|
109
|
+
</button>
|
|
110
|
+
)}
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
className="console-offline-panel-discard"
|
|
114
|
+
onClick={() => onDiscard(action.id)}
|
|
115
|
+
>
|
|
116
|
+
Discard
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
))}
|
|
121
|
+
</section>
|
|
122
|
+
);
|
|
123
|
+
};
|
package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { act, renderHook } from '@testing-library/react';
|
|
2
2
|
import { useConsoleActionQueue } from './useConsoleActionQueue';
|
|
3
3
|
|
|
4
|
+
const OFFLINE_QUEUE_STORAGE_KEY = 'console-offline-action-queue';
|
|
5
|
+
|
|
6
|
+
const offlinePayload = {
|
|
7
|
+
itemUrl: 'https://github.com/o/r/pull/851',
|
|
8
|
+
projectItemId: 'PVTI_1',
|
|
9
|
+
itemNumber: 851,
|
|
10
|
+
repo: 'o/r',
|
|
11
|
+
isPr: true,
|
|
12
|
+
apiPath: '/api/review',
|
|
13
|
+
requestBody: {
|
|
14
|
+
pjcode: 'acme',
|
|
15
|
+
action: 'approve',
|
|
16
|
+
prUrl: 'https://github.com/o/r/pull/851',
|
|
17
|
+
projectItemId: 'PVTI_1',
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
4
21
|
const makeAction = (
|
|
5
22
|
overrides: Partial<
|
|
6
23
|
Parameters<ReturnType<typeof useConsoleActionQueue>['enqueue']>[0]
|
|
@@ -19,10 +36,12 @@ const flushMicrotasks = (): Promise<void> =>
|
|
|
19
36
|
describe('useConsoleActionQueue', () => {
|
|
20
37
|
beforeEach(() => {
|
|
21
38
|
jest.useFakeTimers();
|
|
39
|
+
localStorage.clear();
|
|
22
40
|
});
|
|
23
41
|
|
|
24
42
|
afterEach(() => {
|
|
25
43
|
jest.useRealTimers();
|
|
44
|
+
localStorage.clear();
|
|
26
45
|
});
|
|
27
46
|
|
|
28
47
|
it('advances immediately but only commits after the five second window', () => {
|
|
@@ -212,4 +231,268 @@ describe('useConsoleActionQueue', () => {
|
|
|
212
231
|
});
|
|
213
232
|
expect(result.current.pending).toBeNull();
|
|
214
233
|
});
|
|
234
|
+
it('adds the action to the offline queue when the commit rejects with a network error', async () => {
|
|
235
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
236
|
+
const action = makeAction({
|
|
237
|
+
commit: jest
|
|
238
|
+
.fn<Promise<void>, []>()
|
|
239
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
240
|
+
offline: offlinePayload,
|
|
241
|
+
});
|
|
242
|
+
act(() => {
|
|
243
|
+
result.current.enqueue(action);
|
|
244
|
+
});
|
|
245
|
+
await act(async () => {
|
|
246
|
+
jest.advanceTimersByTime(5000);
|
|
247
|
+
await flushMicrotasks();
|
|
248
|
+
});
|
|
249
|
+
expect(result.current.error).toBeNull();
|
|
250
|
+
expect(result.current.offlineActions).toHaveLength(1);
|
|
251
|
+
expect(result.current.offlineActions[0].message).toBe('Approved — PR #851');
|
|
252
|
+
expect(result.current.offlineActions[0].apiPath).toBe('/api/review');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('surfaces a server error as an error rather than queuing it offline', async () => {
|
|
256
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
257
|
+
const action = makeAction({
|
|
258
|
+
commit: jest
|
|
259
|
+
.fn<Promise<void>, []>()
|
|
260
|
+
.mockRejectedValue(new Error('HTTP 422 review cannot be requested')),
|
|
261
|
+
offline: offlinePayload,
|
|
262
|
+
});
|
|
263
|
+
act(() => {
|
|
264
|
+
result.current.enqueue(action);
|
|
265
|
+
});
|
|
266
|
+
await act(async () => {
|
|
267
|
+
jest.advanceTimersByTime(5000);
|
|
268
|
+
await flushMicrotasks();
|
|
269
|
+
});
|
|
270
|
+
expect(result.current.error).toEqual({
|
|
271
|
+
message: 'Approved — PR #851',
|
|
272
|
+
reason: 'HTTP 422 review cannot be requested',
|
|
273
|
+
});
|
|
274
|
+
expect(result.current.offlineActions).toHaveLength(0);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('keeps the offline queue in localStorage so it survives a remount', async () => {
|
|
278
|
+
const { result, unmount } = renderHook(() => useConsoleActionQueue());
|
|
279
|
+
const action = makeAction({
|
|
280
|
+
commit: jest
|
|
281
|
+
.fn<Promise<void>, []>()
|
|
282
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
283
|
+
offline: offlinePayload,
|
|
284
|
+
});
|
|
285
|
+
act(() => {
|
|
286
|
+
result.current.enqueue(action);
|
|
287
|
+
});
|
|
288
|
+
await act(async () => {
|
|
289
|
+
jest.advanceTimersByTime(5000);
|
|
290
|
+
await flushMicrotasks();
|
|
291
|
+
});
|
|
292
|
+
expect(result.current.offlineActions).toHaveLength(1);
|
|
293
|
+
unmount();
|
|
294
|
+
|
|
295
|
+
const { result: result2 } = renderHook(() => useConsoleActionQueue());
|
|
296
|
+
expect(result2.current.offlineActions).toHaveLength(1);
|
|
297
|
+
expect(result2.current.offlineActions[0].message).toBe(
|
|
298
|
+
'Approved — PR #851',
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('does not send anything before confirmOfflineAction is called', async () => {
|
|
303
|
+
const fetchMock = jest.fn();
|
|
304
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
305
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
306
|
+
const action = makeAction({
|
|
307
|
+
commit: jest
|
|
308
|
+
.fn<Promise<void>, []>()
|
|
309
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
310
|
+
offline: offlinePayload,
|
|
311
|
+
});
|
|
312
|
+
act(() => {
|
|
313
|
+
result.current.enqueue(action);
|
|
314
|
+
});
|
|
315
|
+
await act(async () => {
|
|
316
|
+
jest.advanceTimersByTime(5000);
|
|
317
|
+
await flushMicrotasks();
|
|
318
|
+
});
|
|
319
|
+
expect(result.current.offlineActions).toHaveLength(1);
|
|
320
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('sends the stored request and removes the action when confirmOfflineAction is called', async () => {
|
|
324
|
+
const fetchMock = jest.fn(async () => ({
|
|
325
|
+
ok: true,
|
|
326
|
+
status: 200,
|
|
327
|
+
json: async () => ({}),
|
|
328
|
+
}));
|
|
329
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
330
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
331
|
+
const action = makeAction({
|
|
332
|
+
commit: jest
|
|
333
|
+
.fn<Promise<void>, []>()
|
|
334
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
335
|
+
offline: offlinePayload,
|
|
336
|
+
});
|
|
337
|
+
act(() => {
|
|
338
|
+
result.current.enqueue(action);
|
|
339
|
+
});
|
|
340
|
+
await act(async () => {
|
|
341
|
+
jest.advanceTimersByTime(5000);
|
|
342
|
+
await flushMicrotasks();
|
|
343
|
+
});
|
|
344
|
+
const id = result.current.offlineActions[0].id;
|
|
345
|
+
await act(async () => {
|
|
346
|
+
await result.current.confirmOfflineAction(id);
|
|
347
|
+
});
|
|
348
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
349
|
+
'/api/review',
|
|
350
|
+
expect.objectContaining({ method: 'POST' }),
|
|
351
|
+
);
|
|
352
|
+
expect(result.current.offlineActions).toHaveLength(0);
|
|
353
|
+
expect(result.current.error).toBeNull();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('sets an error and keeps the action when confirmOfflineAction encounters a network error', async () => {
|
|
357
|
+
const fetchMock = jest
|
|
358
|
+
.fn()
|
|
359
|
+
.mockRejectedValue(new TypeError('Failed to fetch'));
|
|
360
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
361
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
362
|
+
const action = makeAction({
|
|
363
|
+
commit: jest
|
|
364
|
+
.fn<Promise<void>, []>()
|
|
365
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
366
|
+
offline: offlinePayload,
|
|
367
|
+
});
|
|
368
|
+
act(() => {
|
|
369
|
+
result.current.enqueue(action);
|
|
370
|
+
});
|
|
371
|
+
await act(async () => {
|
|
372
|
+
jest.advanceTimersByTime(5000);
|
|
373
|
+
await flushMicrotasks();
|
|
374
|
+
});
|
|
375
|
+
const id = result.current.offlineActions[0].id;
|
|
376
|
+
await act(async () => {
|
|
377
|
+
await result.current.confirmOfflineAction(id);
|
|
378
|
+
});
|
|
379
|
+
expect(result.current.error).toEqual({
|
|
380
|
+
message: 'Approved — PR #851',
|
|
381
|
+
reason: 'Still offline — action is still held in the queue',
|
|
382
|
+
});
|
|
383
|
+
expect(result.current.offlineActions).toHaveLength(1);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('surfaces a server rejection from confirmOfflineAction as an error and removes the action', async () => {
|
|
387
|
+
const fetchMock = jest.fn(async () => ({
|
|
388
|
+
ok: false,
|
|
389
|
+
status: 422,
|
|
390
|
+
text: async () => 'review cannot be requested',
|
|
391
|
+
}));
|
|
392
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
393
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
394
|
+
const action = makeAction({
|
|
395
|
+
commit: jest
|
|
396
|
+
.fn<Promise<void>, []>()
|
|
397
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
398
|
+
offline: offlinePayload,
|
|
399
|
+
});
|
|
400
|
+
act(() => {
|
|
401
|
+
result.current.enqueue(action);
|
|
402
|
+
});
|
|
403
|
+
await act(async () => {
|
|
404
|
+
jest.advanceTimersByTime(5000);
|
|
405
|
+
await flushMicrotasks();
|
|
406
|
+
});
|
|
407
|
+
const id = result.current.offlineActions[0].id;
|
|
408
|
+
await act(async () => {
|
|
409
|
+
await result.current.confirmOfflineAction(id);
|
|
410
|
+
});
|
|
411
|
+
expect(result.current.error).not.toBeNull();
|
|
412
|
+
expect(result.current.offlineActions).toHaveLength(0);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('generates a stable action id in non-secure contexts where crypto.randomUUID is unavailable', async () => {
|
|
416
|
+
const originalDescriptor = Object.getOwnPropertyDescriptor(
|
|
417
|
+
crypto,
|
|
418
|
+
'randomUUID',
|
|
419
|
+
);
|
|
420
|
+
Object.defineProperty(crypto, 'randomUUID', {
|
|
421
|
+
value: undefined,
|
|
422
|
+
configurable: true,
|
|
423
|
+
});
|
|
424
|
+
try {
|
|
425
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
426
|
+
const action = makeAction({
|
|
427
|
+
commit: jest
|
|
428
|
+
.fn<Promise<void>, []>()
|
|
429
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
430
|
+
offline: offlinePayload,
|
|
431
|
+
});
|
|
432
|
+
act(() => {
|
|
433
|
+
result.current.enqueue(action);
|
|
434
|
+
});
|
|
435
|
+
await act(async () => {
|
|
436
|
+
jest.advanceTimersByTime(5000);
|
|
437
|
+
await flushMicrotasks();
|
|
438
|
+
});
|
|
439
|
+
expect(result.current.offlineActions).toHaveLength(1);
|
|
440
|
+
expect(typeof result.current.offlineActions[0].id).toBe('string');
|
|
441
|
+
expect(result.current.offlineActions[0].id.length).toBeGreaterThan(0);
|
|
442
|
+
} finally {
|
|
443
|
+
if (originalDescriptor !== undefined) {
|
|
444
|
+
Object.defineProperty(crypto, 'randomUUID', originalDescriptor);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('skips malformed entries when loading the offline queue from localStorage', () => {
|
|
450
|
+
const malformed = [
|
|
451
|
+
{ id: 'a', message: 'ok' },
|
|
452
|
+
{
|
|
453
|
+
id: 'b',
|
|
454
|
+
message: 'valid',
|
|
455
|
+
color: 'green',
|
|
456
|
+
enqueuedAt: 1,
|
|
457
|
+
itemUrl: 'https://github.com/o/r/pull/1',
|
|
458
|
+
projectItemId: 'PVTI_1',
|
|
459
|
+
itemNumber: 1,
|
|
460
|
+
repo: 'o/r',
|
|
461
|
+
isPr: true,
|
|
462
|
+
apiPath: '/api/review',
|
|
463
|
+
requestBody: { action: 'approve' },
|
|
464
|
+
},
|
|
465
|
+
null,
|
|
466
|
+
];
|
|
467
|
+
localStorage.setItem(OFFLINE_QUEUE_STORAGE_KEY, JSON.stringify(malformed));
|
|
468
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
469
|
+
expect(result.current.offlineActions).toHaveLength(1);
|
|
470
|
+
expect(result.current.offlineActions[0].id).toBe('b');
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('removes the action from localStorage when discardOfflineAction is called', async () => {
|
|
474
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
475
|
+
const action = makeAction({
|
|
476
|
+
commit: jest
|
|
477
|
+
.fn<Promise<void>, []>()
|
|
478
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
479
|
+
offline: offlinePayload,
|
|
480
|
+
});
|
|
481
|
+
act(() => {
|
|
482
|
+
result.current.enqueue(action);
|
|
483
|
+
});
|
|
484
|
+
await act(async () => {
|
|
485
|
+
jest.advanceTimersByTime(5000);
|
|
486
|
+
await flushMicrotasks();
|
|
487
|
+
});
|
|
488
|
+
const id = result.current.offlineActions[0].id;
|
|
489
|
+
act(() => {
|
|
490
|
+
result.current.discardOfflineAction(id);
|
|
491
|
+
});
|
|
492
|
+
expect(result.current.offlineActions).toHaveLength(0);
|
|
493
|
+
const stored: unknown[] = JSON.parse(
|
|
494
|
+
localStorage.getItem(OFFLINE_QUEUE_STORAGE_KEY) ?? '[]',
|
|
495
|
+
);
|
|
496
|
+
expect(stored).toHaveLength(0);
|
|
497
|
+
});
|
|
215
498
|
});
|