github-issue-tower-defence-management 1.101.6 → 1.101.8

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 (31) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/console/consoleServer.js +32 -10
  3. package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
  4. package/bin/adapter/entry-points/console/ui-dist/assets/index-ES6SLB1Y.css +1 -0
  5. package/bin/adapter/entry-points/console/ui-dist/assets/index-_g2CqG11.js +101 -0
  6. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapter/entry-points/console/consoleServer.test.ts +43 -0
  9. package/src/adapter/entry-points/console/consoleServer.ts +38 -11
  10. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +139 -145
  11. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleErrorToast.stories.tsx +23 -0
  12. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.test.tsx +44 -1
  13. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.tsx +25 -0
  14. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +84 -1
  15. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +31 -6
  16. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.test.ts +2 -2
  17. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +2 -4
  18. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +37 -10
  19. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +19 -1
  20. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +8 -23
  21. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +0 -97
  22. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +10 -40
  23. package/src/adapter/entry-points/console/ui/src/index.css +6 -16
  24. package/src/adapter/entry-points/console/ui-dist/assets/index-ES6SLB1Y.css +1 -0
  25. package/src/adapter/entry-points/console/ui-dist/assets/index-_g2CqG11.js +101 -0
  26. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  27. package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
  28. package/bin/adapter/entry-points/console/ui-dist/assets/index-D4zghbcI.css +0 -1
  29. package/bin/adapter/entry-points/console/ui-dist/assets/index-DX0mapkS.js +0 -101
  30. package/src/adapter/entry-points/console/ui-dist/assets/index-D4zghbcI.css +0 -1
  31. package/src/adapter/entry-points/console/ui-dist/assets/index-DX0mapkS.js +0 -101
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="/assets/index-DX0mapkS.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-D4zghbcI.css">
7
+ <script type="module" crossorigin src="/assets/index-_g2CqG11.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-ES6SLB1Y.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-issue-tower-defence-management",
3
- "version": "1.101.6",
3
+ "version": "1.101.8",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {
@@ -652,6 +652,49 @@ describe('consoleServer new routes integration', () => {
652
652
  }
653
653
  });
654
654
 
655
+ it('returns 502 with the underlying error message when an operation rejects', async () => {
656
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
657
+ const issueRepository = mock<IssueRepository>();
658
+ issueRepository.get.mockResolvedValue({
659
+ ...mock<Issue>(),
660
+ itemId: 'PVTI_loaded',
661
+ });
662
+ issueRepository.approvePullRequest.mockRejectedValue(
663
+ new Error('Failed to approve PR https://github.com/o/r/pull/1: HTTP 422'),
664
+ );
665
+ const server = await startConsoleServer({
666
+ accessToken: testToken,
667
+ uiDistDir: path.join(tmpDir, 'ui-dist'),
668
+ consoleDataOutputDir: null,
669
+ inTmuxDataDir: null,
670
+ dashboardDir: null,
671
+ issueRepository,
672
+ resolveProject: async (pjcode) =>
673
+ pjcode === 'umino' ? { pjcode, project: buildProject() } : null,
674
+ port: 0,
675
+ });
676
+ try {
677
+ const response = await request(
678
+ server,
679
+ 'POST',
680
+ `/api/review?k=${testToken}`,
681
+ {
682
+ pjcode: 'umino',
683
+ action: 'approve',
684
+ prUrl: 'https://github.com/o/r/pull/1',
685
+ projectItemId: 'PVTI_op',
686
+ },
687
+ );
688
+ expect(response.statusCode).toBe(502);
689
+ expect(JSON.parse(response.body)).toEqual({
690
+ error: 'Failed to approve PR https://github.com/o/r/pull/1: HTTP 422',
691
+ });
692
+ } finally {
693
+ await closeServer(server);
694
+ fs.rmSync(tmpDir, { recursive: true, force: true });
695
+ }
696
+ });
697
+
655
698
  it('rejects an operation api with a malformed json body', async () => {
656
699
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
657
700
  const issueRepository = mock<IssueRepository>();
@@ -372,6 +372,32 @@ const handleReadApi = async (
372
372
  }
373
373
  };
374
374
 
375
+ const operationErrorMessage = (error: unknown): string => {
376
+ if (error instanceof Error && error.message.length > 0) {
377
+ return error.message;
378
+ }
379
+ return String(error);
380
+ };
381
+
382
+ const dispatchOperation = (
383
+ context: ConsoleOperationContext,
384
+ requestPath: string,
385
+ body: Record<string, unknown>,
386
+ ): Promise<{ statusCode: number; body: unknown }> | null => {
387
+ switch (requestPath) {
388
+ case '/api/review':
389
+ return handleReview(context, body);
390
+ case '/api/triage':
391
+ return handleTriage(context, body);
392
+ case '/api/intmux':
393
+ return handleIntmux(context, body);
394
+ case '/api/comment':
395
+ return handleComment(context, body);
396
+ default:
397
+ return null;
398
+ }
399
+ };
400
+
375
401
  const handleOperationApi = async (
376
402
  options: ConsoleServerOptions,
377
403
  requestPath: string,
@@ -387,17 +413,18 @@ const handleOperationApi = async (
387
413
  resolveProject,
388
414
  consoleDataOutputDir: options.consoleDataOutputDir,
389
415
  };
390
- switch (requestPath) {
391
- case '/api/review':
392
- return handleReview(context, body);
393
- case '/api/triage':
394
- return handleTriage(context, body);
395
- case '/api/intmux':
396
- return handleIntmux(context, body);
397
- case '/api/comment':
398
- return handleComment(context, body);
399
- default:
400
- return null;
416
+ const dispatched = dispatchOperation(context, requestPath, body);
417
+ if (dispatched === null) {
418
+ return null;
419
+ }
420
+ try {
421
+ return await dispatched;
422
+ } catch (error) {
423
+ console.error('console operation failed', error);
424
+ return {
425
+ statusCode: 502,
426
+ body: { error: operationErrorMessage(error) },
427
+ };
401
428
  }
402
429
  };
403
430
 
@@ -1,4 +1,4 @@
1
- import type { ReactNode, Ref } from 'react';
1
+ import type { ReactNode } from 'react';
2
2
  import type { ImageProxyUrlBuilder } from '../../lib/imageProxy';
3
3
  import { colorFromEnum } from '../../logic/colors';
4
4
  import {
@@ -55,7 +55,6 @@ export type ConsoleItemDetailProps = {
55
55
  now: number;
56
56
  commentComposer: ReactNode;
57
57
  operationBar: ReactNode;
58
- scrollRef?: Ref<HTMLElement>;
59
58
  buildImageProxyUrl?: ImageProxyUrlBuilder;
60
59
  };
61
60
 
@@ -81,7 +80,6 @@ export const ConsoleItemDetail = ({
81
80
  now,
82
81
  commentComposer,
83
82
  operationBar,
84
- scrollRef,
85
83
  buildImageProxyUrl,
86
84
  }: ConsoleItemDetailProps) => {
87
85
  const resolvedState = state?.state ?? 'open';
@@ -100,170 +98,166 @@ export const ConsoleItemDetail = ({
100
98
  commitsAreLoading || commitsError !== null ? null : commits.length;
101
99
 
102
100
  return (
103
- <>
104
- <article className="console-detail" ref={scrollRef}>
105
- {storyName !== null && (
106
- <div className="console-detail-story">
107
- <span className="console-storytag">
108
- <span
109
- className="console-story-dot"
110
- style={{ backgroundColor: storyPalette.dot }}
111
- />
112
- {storyName}
113
- </span>
114
- </div>
115
- )}
101
+ <article className="console-detail">
102
+ {storyName !== null && (
103
+ <div className="console-detail-story">
104
+ <span className="console-storytag">
105
+ <span
106
+ className="console-story-dot"
107
+ style={{ backgroundColor: storyPalette.dot }}
108
+ />
109
+ {storyName}
110
+ </span>
111
+ </div>
112
+ )}
116
113
 
117
- {overlayStatus !== null && statusPalette !== null && (
118
- <span
119
- className="console-detail-status-chip"
120
- style={{
121
- color: statusPalette.fg,
122
- borderColor: statusPalette.border,
123
- backgroundColor: statusPalette.bg,
124
- }}
125
- >
126
- {overlayStatus.name}
114
+ {overlayStatus !== null && statusPalette !== null && (
115
+ <span
116
+ className="console-detail-status-chip"
117
+ style={{
118
+ color: statusPalette.fg,
119
+ borderColor: statusPalette.border,
120
+ backgroundColor: statusPalette.bg,
121
+ }}
122
+ >
123
+ {overlayStatus.name}
124
+ </span>
125
+ )}
126
+
127
+ <h2 className="console-detail-title">
128
+ <ConsoleItemIcon
129
+ isPr={item.isPr}
130
+ state={resolvedState}
131
+ merged={merged}
132
+ isDraft={false}
133
+ stateReason=""
134
+ />
135
+ <span className="console-detail-title-text">{item.title}</span>
136
+ <span className="console-detail-number">
137
+ {item.isPr ? `PR #${item.number}` : `#${item.number}`}
138
+ </span>
139
+ {closedStateLabel !== null && (
140
+ <span className="console-detail-closed-label">
141
+ {closedStateLabel}
127
142
  </span>
128
143
  )}
144
+ </h2>
129
145
 
130
- <h2 className="console-detail-title">
131
- <ConsoleItemIcon
132
- isPr={item.isPr}
133
- state={resolvedState}
134
- merged={merged}
135
- isDraft={false}
136
- stateReason=""
137
- />
138
- <span className="console-detail-title-text">{item.title}</span>
139
- <span className="console-detail-number">
140
- {item.isPr ? `PR #${item.number}` : `#${item.number}`}
141
- </span>
142
- {closedStateLabel !== null && (
143
- <span className="console-detail-closed-label">
144
- {closedStateLabel}
146
+ <div className="console-detail-subbar">
147
+ <a
148
+ href={item.url}
149
+ className="console-detail-link"
150
+ target="_blank"
151
+ rel="noopener noreferrer"
152
+ >
153
+ {item.isPr ? `PR #${item.number}` : `Issue #${item.number}`}
154
+ </a>
155
+ <span className="console-detail-repo">{item.repo}</span>
156
+ <span className="console-detail-pill">
157
+ {item.isPr ? 'PR' : 'Issue'}
158
+ </span>
159
+ </div>
160
+
161
+ {item.labels.length > 0 && (
162
+ <div className="console-detail-labels">
163
+ {item.labels.map((label) => (
164
+ <span key={label} className="console-label-chip">
165
+ {label}
145
166
  </span>
146
- )}
147
- </h2>
167
+ ))}
168
+ </div>
169
+ )}
170
+
171
+ <div
172
+ className="console-detail-createdat"
173
+ title={formatFullTimestamp(item.createdAt)}
174
+ >
175
+ opened {formatRelativeTime(item.createdAt, now)}
176
+ </div>
148
177
 
149
- <div className="console-detail-subbar">
178
+ <ConsolePanel
179
+ title="Description"
180
+ headerAction={
150
181
  <a
151
182
  href={item.url}
152
- className="console-detail-link"
183
+ className="console-panel-open-link"
153
184
  target="_blank"
154
185
  rel="noopener noreferrer"
155
186
  >
156
- {item.isPr ? `PR #${item.number}` : `Issue #${item.number}`}
187
+ open
157
188
  </a>
158
- <span className="console-detail-repo">{item.repo}</span>
159
- <span className="console-detail-pill">
160
- {item.isPr ? 'PR' : 'Issue'}
161
- </span>
162
- </div>
163
-
164
- {item.labels.length > 0 && (
165
- <div className="console-detail-labels">
166
- {item.labels.map((label) => (
167
- <span key={label} className="console-label-chip">
168
- {label}
169
- </span>
170
- ))}
171
- </div>
189
+ }
190
+ >
191
+ {bodyError !== null ? (
192
+ <p role="alert" className="console-detail-body-error">
193
+ Failed to load description: {bodyError}
194
+ </p>
195
+ ) : bodyIsLoading ? (
196
+ <p className="console-detail-body-loading">Loading description...</p>
197
+ ) : (
198
+ <ConsoleMarkdownContent
199
+ body={body}
200
+ buildImageProxyUrl={buildImageProxyUrl}
201
+ />
172
202
  )}
203
+ </ConsolePanel>
173
204
 
174
- <div
175
- className="console-detail-createdat"
176
- title={formatFullTimestamp(item.createdAt)}
177
- >
178
- opened {formatRelativeTime(item.createdAt, now)}
179
- </div>
180
-
181
- <ConsolePanel
182
- title="Description"
183
- headerAction={
184
- <a
185
- href={item.url}
186
- className="console-panel-open-link"
187
- target="_blank"
188
- rel="noopener noreferrer"
189
- >
190
- open
191
- </a>
192
- }
193
- >
194
- {bodyError !== null ? (
195
- <p role="alert" className="console-detail-body-error">
196
- Failed to load description: {bodyError}
197
- </p>
198
- ) : bodyIsLoading ? (
199
- <p className="console-detail-body-loading">
200
- Loading description...
201
- </p>
202
- ) : (
203
- <ConsoleMarkdownContent
204
- body={body}
205
- buildImageProxyUrl={buildImageProxyUrl}
206
- />
207
- )}
205
+ {item.isPr && (
206
+ <ConsolePanel title="Changed files" count={filesCount}>
207
+ <ConsoleChangedFileList
208
+ files={files}
209
+ isLoading={filesAreLoading}
210
+ error={filesError}
211
+ />
208
212
  </ConsolePanel>
213
+ )}
209
214
 
210
- {item.isPr && (
211
- <ConsolePanel title="Changed files" count={filesCount}>
212
- <ConsoleChangedFileList
213
- files={files}
214
- isLoading={filesAreLoading}
215
- error={filesError}
216
- />
217
- </ConsolePanel>
218
- )}
215
+ <ConsolePanel
216
+ title="Comments"
217
+ count={commentsCount}
218
+ defaultCollapsed={item.isPr}
219
+ >
220
+ <ConsoleCommentList
221
+ comments={comments}
222
+ isLoading={commentsAreLoading}
223
+ error={commentsError}
224
+ now={now}
225
+ buildImageProxyUrl={buildImageProxyUrl}
226
+ />
227
+ </ConsolePanel>
219
228
 
220
- <ConsolePanel
221
- title="Comments"
222
- count={commentsCount}
223
- defaultCollapsed={item.isPr}
224
- >
225
- <ConsoleCommentList
226
- comments={comments}
227
- isLoading={commentsAreLoading}
228
- error={commentsError}
229
+ {item.isPr && (
230
+ <ConsolePanel title="Commits" count={commitsCount} defaultCollapsed>
231
+ <ConsoleCommitList
232
+ commits={commits}
233
+ isLoading={commitsAreLoading}
234
+ error={commitsError}
229
235
  now={now}
230
- buildImageProxyUrl={buildImageProxyUrl}
231
236
  />
232
237
  </ConsolePanel>
238
+ )}
233
239
 
234
- {item.isPr && (
235
- <ConsolePanel title="Commits" count={commitsCount} defaultCollapsed>
236
- <ConsoleCommitList
237
- commits={commits}
238
- isLoading={commitsAreLoading}
239
- error={commitsError}
240
- now={now}
241
- />
242
- </ConsolePanel>
243
- )}
244
-
245
- {!item.isPr &&
246
- relatedPullRequests.map((related) => (
247
- <ConsolePullRequestDetail
248
- key={related.pullRequest.url}
249
- pullRequest={related.pullRequest}
250
- body={related.pullRequest.summary?.body ?? ''}
251
- bodyIsLoading={false}
252
- files={related.files}
253
- filesAreLoading={related.filesAreLoading}
254
- filesError={related.filesError}
255
- commits={related.commits}
256
- commitsAreLoading={related.commitsAreLoading}
257
- commitsError={related.commitsError}
258
- now={now}
259
- buildImageProxyUrl={buildImageProxyUrl}
260
- />
261
- ))}
240
+ {!item.isPr &&
241
+ relatedPullRequests.map((related) => (
242
+ <ConsolePullRequestDetail
243
+ key={related.pullRequest.url}
244
+ pullRequest={related.pullRequest}
245
+ body={related.pullRequest.summary?.body ?? ''}
246
+ bodyIsLoading={false}
247
+ files={related.files}
248
+ filesAreLoading={related.filesAreLoading}
249
+ filesError={related.filesError}
250
+ commits={related.commits}
251
+ commitsAreLoading={related.commitsAreLoading}
252
+ commitsError={related.commitsError}
253
+ now={now}
254
+ buildImageProxyUrl={buildImageProxyUrl}
255
+ />
256
+ ))}
262
257
 
263
- {commentComposer}
264
- </article>
258
+ {commentComposer}
265
259
 
266
- <footer className="console-actionbar">{operationBar}</footer>
267
- </>
260
+ <div className="console-actionbar">{operationBar}</div>
261
+ </article>
268
262
  );
269
263
  };
@@ -0,0 +1,23 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { ConsoleErrorToast } from './ConsoleUndoToast';
3
+
4
+ const meta: Meta<typeof ConsoleErrorToast> = {
5
+ title: 'Console/ConsoleErrorToast',
6
+ component: ConsoleErrorToast,
7
+ args: {
8
+ message: '操作に失敗しました: HTTP 422 Review cannot be requested',
9
+ onDismiss: () => {},
10
+ },
11
+ };
12
+
13
+ export default meta;
14
+
15
+ type Story = StoryObj<typeof ConsoleErrorToast>;
16
+
17
+ export const ReviewRejectedByGitHub: Story = {};
18
+
19
+ export const NetworkFailure: Story = {
20
+ args: {
21
+ message: '操作に失敗しました: network down',
22
+ },
23
+ };
@@ -1,5 +1,5 @@
1
1
  import { fireEvent, render } from '@testing-library/react';
2
- import { ConsoleUndoToast } from './ConsoleUndoToast';
2
+ import { ConsoleErrorToast, ConsoleUndoToast } from './ConsoleUndoToast';
3
3
 
4
4
  describe('ConsoleUndoToast', () => {
5
5
  const baseProps = {
@@ -52,3 +52,46 @@ describe('ConsoleUndoToast', () => {
52
52
  expect(onUndo).toHaveBeenCalledTimes(1);
53
53
  });
54
54
  });
55
+
56
+ describe('ConsoleErrorToast', () => {
57
+ it('shows the failure message with the error modifier class and alert role', () => {
58
+ const { getByText, container, getByRole } = render(
59
+ <ConsoleErrorToast
60
+ message="操作に失敗しました: HTTP 422"
61
+ onDismiss={() => {}}
62
+ />,
63
+ );
64
+ expect(getByText('操作に失敗しました: HTTP 422')).toBeInTheDocument();
65
+ expect(
66
+ container.querySelector('.console-undo-toast-error'),
67
+ ).toBeInTheDocument();
68
+ expect(getByRole('alert')).toBeInTheDocument();
69
+ });
70
+
71
+ it('does not render a countdown or progress bar', () => {
72
+ const { container } = render(
73
+ <ConsoleErrorToast
74
+ message="操作に失敗しました: boom"
75
+ onDismiss={() => {}}
76
+ />,
77
+ );
78
+ expect(
79
+ container.querySelector('.console-undo-toast-countdown'),
80
+ ).not.toBeInTheDocument();
81
+ expect(
82
+ container.querySelector('.console-undo-toast-bar'),
83
+ ).not.toBeInTheDocument();
84
+ });
85
+
86
+ it('invokes onDismiss when the Dismiss control is clicked', () => {
87
+ const onDismiss = jest.fn();
88
+ const { getByText } = render(
89
+ <ConsoleErrorToast
90
+ message="操作に失敗しました: boom"
91
+ onDismiss={onDismiss}
92
+ />,
93
+ );
94
+ fireEvent.click(getByText('Dismiss'));
95
+ expect(onDismiss).toHaveBeenCalledTimes(1);
96
+ });
97
+ });
@@ -34,3 +34,28 @@ export const ConsoleUndoToast = ({
34
34
  />
35
35
  </div>
36
36
  );
37
+
38
+ export type ConsoleErrorToastProps = {
39
+ message: string;
40
+ onDismiss: () => void;
41
+ };
42
+
43
+ export const ConsoleErrorToast = ({
44
+ message,
45
+ onDismiss,
46
+ }: ConsoleErrorToastProps) => (
47
+ <div
48
+ className="console-undo-toast console-undo-toast-error"
49
+ role="alert"
50
+ aria-live="assertive"
51
+ >
52
+ <span className="console-undo-toast-message">{message}</span>
53
+ <button
54
+ type="button"
55
+ className="console-undo-toast-undo"
56
+ onClick={onDismiss}
57
+ >
58
+ Dismiss
59
+ </button>
60
+ </div>
61
+ );
@@ -8,11 +8,14 @@ const makeAction = (
8
8
  ) => ({
9
9
  message: 'Approved — PR #851',
10
10
  color: 'green' as const,
11
- commit: jest.fn(),
11
+ commit: jest.fn<Promise<void>, []>().mockResolvedValue(undefined),
12
12
  advance: jest.fn(),
13
13
  ...overrides,
14
14
  });
15
15
 
16
+ const flushMicrotasks = (): Promise<void> =>
17
+ Promise.resolve().then(() => undefined);
18
+
16
19
  describe('useConsoleActionQueue', () => {
17
20
  beforeEach(() => {
18
21
  jest.useFakeTimers();
@@ -114,4 +117,84 @@ describe('useConsoleActionQueue', () => {
114
117
  expect(first.commit).toHaveBeenCalledTimes(1);
115
118
  expect(second.commit).toHaveBeenCalledTimes(1);
116
119
  });
120
+
121
+ it('surfaces the failure reason when the timer commit rejects', async () => {
122
+ const { result } = renderHook(() => useConsoleActionQueue());
123
+ const action = makeAction({
124
+ commit: jest
125
+ .fn<Promise<void>, []>()
126
+ .mockRejectedValue(new Error('HTTP 422 review cannot be requested')),
127
+ });
128
+ act(() => {
129
+ result.current.enqueue(action);
130
+ });
131
+ expect(result.current.error).toBeNull();
132
+ await act(async () => {
133
+ jest.advanceTimersByTime(5000);
134
+ await flushMicrotasks();
135
+ });
136
+ expect(action.commit).toHaveBeenCalledTimes(1);
137
+ expect(result.current.error).toEqual({
138
+ message: 'Approved — PR #851',
139
+ reason: 'HTTP 422 review cannot be requested',
140
+ });
141
+ });
142
+
143
+ it('surfaces the failure reason when the previous action commit rejects on enqueue', async () => {
144
+ const { result } = renderHook(() => useConsoleActionQueue());
145
+ const first = makeAction({
146
+ commit: jest
147
+ .fn<Promise<void>, []>()
148
+ .mockRejectedValue(new Error('network down')),
149
+ });
150
+ const second = makeAction({
151
+ message: 'Rejected — PR #853',
152
+ color: 'amber',
153
+ });
154
+ act(() => {
155
+ result.current.enqueue(first);
156
+ });
157
+ await act(async () => {
158
+ jest.advanceTimersByTime(1000);
159
+ result.current.enqueue(second);
160
+ await flushMicrotasks();
161
+ });
162
+ expect(first.commit).toHaveBeenCalledTimes(1);
163
+ expect(result.current.error).toEqual({
164
+ message: 'Approved — PR #851',
165
+ reason: 'network down',
166
+ });
167
+ });
168
+
169
+ it('clears the surfaced error when dismissError is called', async () => {
170
+ const { result } = renderHook(() => useConsoleActionQueue());
171
+ const action = makeAction({
172
+ commit: jest.fn<Promise<void>, []>().mockRejectedValue(new Error('boom')),
173
+ });
174
+ act(() => {
175
+ result.current.enqueue(action);
176
+ });
177
+ await act(async () => {
178
+ jest.advanceTimersByTime(5000);
179
+ await flushMicrotasks();
180
+ });
181
+ expect(result.current.error).not.toBeNull();
182
+ act(() => {
183
+ result.current.dismissError();
184
+ });
185
+ expect(result.current.error).toBeNull();
186
+ });
187
+
188
+ it('does not surface an error when the commit resolves', async () => {
189
+ const { result } = renderHook(() => useConsoleActionQueue());
190
+ const action = makeAction();
191
+ act(() => {
192
+ result.current.enqueue(action);
193
+ });
194
+ await act(async () => {
195
+ jest.advanceTimersByTime(5000);
196
+ await flushMicrotasks();
197
+ });
198
+ expect(result.current.error).toBeNull();
199
+ });
117
200
  });