github-issue-tower-defence-management 2.12.1 → 2.13.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.
@@ -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
  });
@@ -1,14 +1,33 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { postConsoleOperation } from '../lib/consoleApi';
2
3
  import {
3
4
  ACTION_TOAST_DELAY_MS,
4
5
  type ConsoleToastColor,
5
6
  } from '../logic/actionToast';
6
7
 
8
+ export type ConsoleOfflinePayload = {
9
+ itemUrl: string;
10
+ projectItemId: string;
11
+ itemNumber: number;
12
+ repo: string;
13
+ isPr: boolean;
14
+ apiPath: string;
15
+ requestBody: Record<string, unknown>;
16
+ };
17
+
18
+ export type ConsoleOfflineQueuedAction = {
19
+ id: string;
20
+ message: string;
21
+ color: ConsoleToastColor;
22
+ enqueuedAt: number;
23
+ } & ConsoleOfflinePayload;
24
+
7
25
  export type ConsoleQueuedAction = {
8
26
  message: string;
9
27
  color: ConsoleToastColor;
10
28
  commit: () => Promise<void>;
11
29
  advance: () => void;
30
+ offline?: ConsoleOfflinePayload;
12
31
  };
13
32
 
14
33
  export type ConsolePendingActionView = {
@@ -23,6 +42,57 @@ export type ConsoleActionError = {
23
42
  reason: string;
24
43
  };
25
44
 
45
+ const OFFLINE_QUEUE_STORAGE_KEY = 'console-offline-action-queue';
46
+
47
+ const isConsoleOfflineQueuedAction = (
48
+ item: unknown,
49
+ ): item is ConsoleOfflineQueuedAction => {
50
+ if (typeof item !== 'object' || item === null) return false;
51
+ const a = item as Record<string, unknown>;
52
+ return (
53
+ typeof a.id === 'string' &&
54
+ typeof a.message === 'string' &&
55
+ typeof a.color === 'string' &&
56
+ typeof a.enqueuedAt === 'number' &&
57
+ typeof a.itemUrl === 'string' &&
58
+ typeof a.projectItemId === 'string' &&
59
+ typeof a.itemNumber === 'number' &&
60
+ typeof a.repo === 'string' &&
61
+ typeof a.isPr === 'boolean' &&
62
+ typeof a.apiPath === 'string' &&
63
+ typeof a.requestBody === 'object' &&
64
+ a.requestBody !== null
65
+ );
66
+ };
67
+
68
+ const loadOfflineQueue = (): ConsoleOfflineQueuedAction[] => {
69
+ try {
70
+ const raw = localStorage.getItem(OFFLINE_QUEUE_STORAGE_KEY);
71
+ if (raw === null) return [];
72
+ const parsed: unknown = JSON.parse(raw);
73
+ if (!Array.isArray(parsed)) return [];
74
+ return parsed.filter(isConsoleOfflineQueuedAction);
75
+ } catch {
76
+ return [];
77
+ }
78
+ };
79
+
80
+ const saveOfflineQueue = (actions: ConsoleOfflineQueuedAction[]): void => {
81
+ localStorage.setItem(OFFLINE_QUEUE_STORAGE_KEY, JSON.stringify(actions));
82
+ };
83
+
84
+ const generateId = (): string => {
85
+ if (
86
+ typeof crypto !== 'undefined' &&
87
+ typeof crypto.randomUUID === 'function'
88
+ ) {
89
+ return crypto.randomUUID();
90
+ }
91
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
92
+ };
93
+
94
+ const isNetworkError = (error: unknown): boolean => error instanceof TypeError;
95
+
26
96
  const errorReason = (error: unknown): string => {
27
97
  if (error instanceof Error && error.message.length > 0) {
28
98
  return error.message;
@@ -41,15 +111,24 @@ const computeProgress = (elapsedMs: number): number =>
41
111
  export type ConsoleActionQueue = {
42
112
  pending: ConsolePendingActionView | null;
43
113
  error: ConsoleActionError | null;
114
+ offlineActions: ConsoleOfflineQueuedAction[];
44
115
  enqueue: (action: ConsoleQueuedAction) => void;
45
116
  showError: (message: string, reason: string) => void;
46
117
  undo: () => void;
47
118
  dismissError: () => void;
119
+ confirmOfflineAction: (id: string) => Promise<void>;
120
+ discardOfflineAction: (id: string) => void;
48
121
  };
49
122
 
50
123
  export const useConsoleActionQueue = (): ConsoleActionQueue => {
51
124
  const [pending, setPending] = useState<ConsolePendingActionView | null>(null);
52
125
  const [error, setError] = useState<ConsoleActionError | null>(null);
126
+ const [offlineActions, setOfflineActions] =
127
+ useState<ConsoleOfflineQueuedAction[]>(loadOfflineQueue);
128
+ const offlineActionsRef =
129
+ useRef<ConsoleOfflineQueuedAction[]>(offlineActions);
130
+ offlineActionsRef.current = offlineActions;
131
+
53
132
  const actionRef = useRef<ConsoleQueuedAction | null>(null);
54
133
  const startRef = useRef<number>(0);
55
134
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -62,12 +141,35 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
62
141
  }
63
142
  }, []);
64
143
 
65
- const runCommit = useCallback((action: ConsoleQueuedAction): void => {
66
- action.commit().catch((cause: unknown) => {
67
- setError({ message: action.message, reason: errorReason(cause) });
144
+ const addToOfflineQueue = useCallback((action: ConsoleQueuedAction): void => {
145
+ if (action.offline === undefined) return;
146
+ const offlineAction: ConsoleOfflineQueuedAction = {
147
+ id: generateId(),
148
+ message: action.message,
149
+ color: action.color,
150
+ enqueuedAt: Date.now(),
151
+ ...action.offline,
152
+ };
153
+ setOfflineActions((prev) => {
154
+ const next = [...prev, offlineAction];
155
+ saveOfflineQueue(next);
156
+ return next;
68
157
  });
69
158
  }, []);
70
159
 
160
+ const runCommit = useCallback(
161
+ (action: ConsoleQueuedAction): void => {
162
+ action.commit().catch((cause: unknown) => {
163
+ if (isNetworkError(cause) && action.offline !== undefined) {
164
+ addToOfflineQueue(action);
165
+ } else {
166
+ setError({ message: action.message, reason: errorReason(cause) });
167
+ }
168
+ });
169
+ },
170
+ [addToOfflineQueue],
171
+ );
172
+
71
173
  const commitPending = useCallback((): void => {
72
174
  const action = actionRef.current;
73
175
  clearTimer();
@@ -94,6 +196,36 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
94
196
  setError({ message, reason });
95
197
  }, []);
96
198
 
199
+ const discardOfflineAction = useCallback((id: string): void => {
200
+ setOfflineActions((prev) => {
201
+ const next = prev.filter((a) => a.id !== id);
202
+ saveOfflineQueue(next);
203
+ return next;
204
+ });
205
+ }, []);
206
+
207
+ const confirmOfflineAction = useCallback(
208
+ async (id: string): Promise<void> => {
209
+ const action = offlineActionsRef.current.find((a) => a.id === id);
210
+ if (action === undefined) return;
211
+ try {
212
+ await postConsoleOperation(action.apiPath, action.requestBody);
213
+ discardOfflineAction(id);
214
+ } catch (cause: unknown) {
215
+ if (isNetworkError(cause)) {
216
+ setError({
217
+ message: action.message,
218
+ reason: 'Still offline — action is still held in the queue',
219
+ });
220
+ } else {
221
+ setError({ message: action.message, reason: errorReason(cause) });
222
+ discardOfflineAction(id);
223
+ }
224
+ }
225
+ },
226
+ [discardOfflineAction],
227
+ );
228
+
97
229
  const enqueue = useCallback(
98
230
  (action: ConsoleQueuedAction): void => {
99
231
  if (actionRef.current !== null && !committedRef.current) {
@@ -131,5 +263,15 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
131
263
 
132
264
  useEffect(() => clearTimer, [clearTimer]);
133
265
 
134
- return { pending, error, enqueue, showError, undo, dismissError };
266
+ return {
267
+ pending,
268
+ error,
269
+ offlineActions,
270
+ enqueue,
271
+ showError,
272
+ undo,
273
+ dismissError,
274
+ confirmOfflineAction,
275
+ discardOfflineAction,
276
+ };
135
277
  };
@@ -76,7 +76,7 @@ export type ConsoleOperationsApi = {
76
76
  ) => Promise<void>;
77
77
  };
78
78
 
79
- const reviewRequest = (
79
+ export const reviewRequest = (
80
80
  pjcode: string,
81
81
  item: ConsoleListItem,
82
82
  prUrl: string,
@@ -129,6 +129,33 @@ const reviewRequest = (
129
129
  };
130
130
  };
131
131
 
132
+ export const buildTriageRequest = (
133
+ pjcode: string,
134
+ item: ConsoleListItem,
135
+ action:
136
+ | ConsoleNextActionDateAction
137
+ | ConsoleCloseAction
138
+ | 'set_story'
139
+ | 'set_status',
140
+ extra?: { statusName?: string; storyOptionId?: string },
141
+ ): ConsoleTriageRequest => ({
142
+ pjcode,
143
+ action,
144
+ issueUrl: item.url,
145
+ projectItemId: item.projectItemId,
146
+ ...(extra ?? {}),
147
+ });
148
+
149
+ export const buildIntmuxRequest = (
150
+ pjcode: string,
151
+ item: ConsoleListItem,
152
+ ): ConsoleIntmuxRequest => ({
153
+ pjcode,
154
+ action: 'set_intmux',
155
+ issueUrl: item.url,
156
+ projectItemId: item.projectItemId,
157
+ });
158
+
132
159
  const missingPjcodeError = (): Error =>
133
160
  new Error('No project specified in the URL path.');
134
161
 
@@ -293,9 +293,9 @@ const readOperationErrorReason = async (
293
293
  return raw;
294
294
  };
295
295
 
296
- export const postConsoleOperation = async (
296
+ export const postConsoleOperation = async <T extends Record<string, unknown>>(
297
297
  apiPath: string,
298
- body: ConsoleReviewRequest | ConsoleTriageRequest | ConsoleIntmuxRequest,
298
+ body: T,
299
299
  ): Promise<void> => {
300
300
  const response = await fetch(apiPath, {
301
301
  method: 'POST',