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.
Files changed (50) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/console/ui-dist/assets/{index-D6QOQvoN.js → index-BTFgj8n5.js} +31 -31
  3. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  4. package/bin/domain/usecases/ActionAnnouncementUseCase.js +4 -2
  5. package/bin/domain/usecases/ActionAnnouncementUseCase.js.map +1 -1
  6. package/bin/domain/usecases/AnalyzeStoriesUseCase.js +1 -1
  7. package/bin/domain/usecases/AnalyzeStoriesUseCase.js.map +1 -1
  8. package/bin/domain/usecases/CliErrorReportUseCase.js +1 -1
  9. package/bin/domain/usecases/CliErrorReportUseCase.js.map +1 -1
  10. package/bin/domain/usecases/CreateEstimationIssueUseCase.js +2 -1
  11. package/bin/domain/usecases/CreateEstimationIssueUseCase.js.map +1 -1
  12. package/bin/domain/usecases/DailySecurityScanUseCase.js +3 -3
  13. package/bin/domain/usecases/DailySecurityScanUseCase.js.map +1 -1
  14. package/bin/domain/usecases/HandleScheduledEventUseCase.js +3 -3
  15. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  16. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +4 -0
  17. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  18. package/bin/domain/usecases/SubscriptionDisabledIssueUseCase.js +1 -1
  19. package/bin/domain/usecases/SubscriptionDisabledIssueUseCase.js.map +1 -1
  20. package/package.json +1 -1
  21. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOfflinePendingActionsPanel.stories.tsx +115 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOfflinePendingActionsPanel.test.tsx +262 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOfflinePendingActionsPanel.tsx +123 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +283 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +146 -4
  26. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +28 -1
  27. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +2 -2
  28. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +103 -1
  29. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +128 -0
  30. package/src/adapter/entry-points/console/ui-dist/assets/{index-D6QOQvoN.js → index-BTFgj8n5.js} +31 -31
  31. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  32. package/src/domain/usecases/ActionAnnouncementUseCase.test.ts +91 -0
  33. package/src/domain/usecases/ActionAnnouncementUseCase.ts +4 -2
  34. package/src/domain/usecases/AnalyzeStoriesUseCase.test.ts +2 -0
  35. package/src/domain/usecases/AnalyzeStoriesUseCase.ts +1 -1
  36. package/src/domain/usecases/CliErrorReportUseCase.test.ts +11 -0
  37. package/src/domain/usecases/CliErrorReportUseCase.ts +1 -1
  38. package/src/domain/usecases/CreateEstimationIssueUseCase.test.ts +2 -0
  39. package/src/domain/usecases/CreateEstimationIssueUseCase.ts +2 -1
  40. package/src/domain/usecases/DailySecurityScanUseCase.test.ts +6 -0
  41. package/src/domain/usecases/DailySecurityScanUseCase.ts +3 -3
  42. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +25 -0
  43. package/src/domain/usecases/HandleScheduledEventUseCase.ts +3 -3
  44. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +20 -0
  45. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +4 -0
  46. package/src/domain/usecases/SubscriptionDisabledIssueUseCase.test.ts +3 -0
  47. package/src/domain/usecases/SubscriptionDisabledIssueUseCase.ts +1 -1
  48. package/types/domain/usecases/ActionAnnouncementUseCase.d.ts.map +1 -1
  49. package/types/domain/usecases/CreateEstimationIssueUseCase.d.ts.map +1 -1
  50. 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
+ };
@@ -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
  });