github-issue-tower-defence-management 1.101.7 → 1.102.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 (25) 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-CFGeqgfE.js +101 -0
  5. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  6. package/jest.config.js +15 -1
  7. package/package.json +3 -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/operations/ConsoleErrorToast.stories.tsx +23 -0
  11. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.test.tsx +44 -1
  12. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.tsx +25 -0
  13. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +84 -1
  14. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +31 -6
  15. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +37 -10
  16. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +19 -1
  17. package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.test.ts +10 -0
  18. package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.ts +9 -0
  19. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +7 -19
  20. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +10 -1
  21. package/src/adapter/entry-points/console/ui-dist/assets/index-CFGeqgfE.js +101 -0
  22. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  23. package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
  24. package/bin/adapter/entry-points/console/ui-dist/assets/index--7aFZNg9.js +0 -101
  25. package/src/adapter/entry-points/console/ui-dist/assets/index--7aFZNg9.js +0 -101
@@ -4,7 +4,7 @@
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--7aFZNg9.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-CFGeqgfE.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-ES6SLB1Y.css">
9
9
  </head>
10
10
  <body>
package/jest.config.js CHANGED
@@ -57,8 +57,22 @@ module.exports = {
57
57
  },
58
58
  },
59
59
  ],
60
+ '^.+\\.js$': [
61
+ 'ts-jest',
62
+ {
63
+ tsconfig: {
64
+ allowJs: true,
65
+ esModuleInterop: true,
66
+ module: 'CommonJS',
67
+ moduleResolution: 'Node',
68
+ verbatimModuleSyntax: false,
69
+ },
70
+ },
71
+ ],
60
72
  },
61
- transformIgnorePatterns: ['/node_modules/(?!(marked)/)'],
73
+ transformIgnorePatterns: [
74
+ '/node_modules/(?!(marked|marked-emoji|gemoji)/)',
75
+ ],
62
76
  moduleNameMapper: {
63
77
  '\\.(css|less|scss)$': '<rootDir>/jest.styleMock.js',
64
78
  '^@/(.*)$': '<rootDir>/src/$1',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-issue-tower-defence-management",
3
- "version": "1.101.7",
3
+ "version": "1.102.0",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {
@@ -101,9 +101,11 @@
101
101
  "commander": "^14.0.0",
102
102
  "dompurify": "3.4.11",
103
103
  "dotenv": "^17.0.0",
104
+ "gemoji": "8.1.0",
104
105
  "googleapis": "^171.4.0",
105
106
  "ky": "^2.0.0",
106
107
  "marked": "12.0.2",
108
+ "marked-emoji": "2.0.3",
107
109
  "typia": "^12.0.0",
108
110
  "yaml": "^2.6.0"
109
111
  }
@@ -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
 
@@ -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
  });
@@ -7,7 +7,7 @@ import {
7
7
  export type ConsoleQueuedAction = {
8
8
  message: string;
9
9
  color: ConsoleToastColor;
10
- commit: () => void;
10
+ commit: () => Promise<void>;
11
11
  advance: () => void;
12
12
  };
13
13
 
@@ -18,6 +18,18 @@ export type ConsolePendingActionView = {
18
18
  progress: number;
19
19
  };
20
20
 
21
+ export type ConsoleActionError = {
22
+ message: string;
23
+ reason: string;
24
+ };
25
+
26
+ const errorReason = (error: unknown): string => {
27
+ if (error instanceof Error && error.message.length > 0) {
28
+ return error.message;
29
+ }
30
+ return String(error);
31
+ };
32
+
21
33
  const COUNTDOWN_TICK_MS = 100;
22
34
 
23
35
  const computeRemainingSeconds = (elapsedMs: number): number =>
@@ -28,12 +40,15 @@ const computeProgress = (elapsedMs: number): number =>
28
40
 
29
41
  export type ConsoleActionQueue = {
30
42
  pending: ConsolePendingActionView | null;
43
+ error: ConsoleActionError | null;
31
44
  enqueue: (action: ConsoleQueuedAction) => void;
32
45
  undo: () => void;
46
+ dismissError: () => void;
33
47
  };
34
48
 
35
49
  export const useConsoleActionQueue = (): ConsoleActionQueue => {
36
50
  const [pending, setPending] = useState<ConsolePendingActionView | null>(null);
51
+ const [error, setError] = useState<ConsoleActionError | null>(null);
37
52
  const actionRef = useRef<ConsoleQueuedAction | null>(null);
38
53
  const startRef = useRef<number>(0);
39
54
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -46,6 +61,12 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
46
61
  }
47
62
  }, []);
48
63
 
64
+ const runCommit = useCallback((action: ConsoleQueuedAction): void => {
65
+ action.commit().catch((cause: unknown) => {
66
+ setError({ message: action.message, reason: errorReason(cause) });
67
+ });
68
+ }, []);
69
+
49
70
  const commitPending = useCallback((): void => {
50
71
  const action = actionRef.current;
51
72
  clearTimer();
@@ -53,9 +74,9 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
53
74
  setPending(null);
54
75
  if (action !== null && !committedRef.current) {
55
76
  committedRef.current = true;
56
- action.commit();
77
+ runCommit(action);
57
78
  }
58
- }, [clearTimer]);
79
+ }, [clearTimer, runCommit]);
59
80
 
60
81
  const undo = useCallback((): void => {
61
82
  clearTimer();
@@ -64,13 +85,17 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
64
85
  setPending(null);
65
86
  }, [clearTimer]);
66
87
 
88
+ const dismissError = useCallback((): void => {
89
+ setError(null);
90
+ }, []);
91
+
67
92
  const enqueue = useCallback(
68
93
  (action: ConsoleQueuedAction): void => {
69
94
  if (actionRef.current !== null && !committedRef.current) {
70
95
  const previous = actionRef.current;
71
96
  clearTimer();
72
97
  committedRef.current = true;
73
- previous.commit();
98
+ runCommit(previous);
74
99
  }
75
100
  committedRef.current = false;
76
101
  actionRef.current = action;
@@ -96,10 +121,10 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
96
121
  });
97
122
  }, COUNTDOWN_TICK_MS);
98
123
  },
99
- [clearTimer, commitPending],
124
+ [clearTimer, commitPending, runCommit],
100
125
  );
101
126
 
102
127
  useEffect(() => clearTimer, [clearTimer]);
103
128
 
104
- return { pending, enqueue, undo };
129
+ return { pending, error, enqueue, undo, dismissError };
105
130
  };
@@ -8,6 +8,17 @@ const mockFetchOnce = (body: unknown, ok = true): jest.Mock => {
8
8
  ok,
9
9
  status: ok ? 200 : 500,
10
10
  json: async () => body,
11
+ text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
12
+ });
13
+ global.fetch = fetchMock as unknown as typeof fetch;
14
+ return fetchMock;
15
+ };
16
+
17
+ const mockFetchFailureOnce = (status: number, rawBody: string): jest.Mock => {
18
+ const fetchMock = jest.fn().mockResolvedValue({
19
+ ok: false,
20
+ status,
21
+ text: async () => rawBody,
11
22
  });
12
23
  global.fetch = fetchMock as unknown as typeof fetch;
13
24
  return fetchMock;
@@ -169,15 +180,31 @@ describe('postConsoleOperation', () => {
169
180
  expect(init).toMatchObject({ method: 'POST' });
170
181
  });
171
182
 
172
- it('throws on a failed operation', async () => {
173
- mockFetchOnce({}, false);
174
- await expect(
175
- postConsoleOperation(appendToken, '/api/review', {
176
- pjcode: 'umino',
177
- action: 'approve',
178
- prUrl: 'https://github.com/o/r/pull/1',
179
- projectItemId: 'PVTI_1',
180
- }),
181
- ).rejects.toThrow('HTTP 500');
183
+ const failingReview = (): Promise<void> =>
184
+ postConsoleOperation(appendToken, '/api/review', {
185
+ pjcode: 'umino',
186
+ action: 'approve',
187
+ prUrl: 'https://github.com/o/r/pull/1',
188
+ projectItemId: 'PVTI_1',
189
+ });
190
+
191
+ it('throws the error reason from a JSON error body', async () => {
192
+ mockFetchFailureOnce(
193
+ 502,
194
+ JSON.stringify({ error: 'Failed to approve PR: HTTP 422' }),
195
+ );
196
+ await expect(failingReview()).rejects.toThrow(
197
+ 'Failed to approve PR: HTTP 422',
198
+ );
199
+ });
200
+
201
+ it('throws the raw body when the error body is not JSON', async () => {
202
+ mockFetchFailureOnce(500, 'Internal Server Error');
203
+ await expect(failingReview()).rejects.toThrow('Internal Server Error');
204
+ });
205
+
206
+ it('falls back to the status code when the error body is empty', async () => {
207
+ mockFetchFailureOnce(500, '');
208
+ await expect(failingReview()).rejects.toThrow('HTTP 500');
182
209
  });
183
210
  });
@@ -171,6 +171,24 @@ export const createConsoleApiClient = (
171
171
  parseState(await requestJson(appendToken, '/api/issuetitle', url)),
172
172
  });
173
173
 
174
+ const readOperationErrorReason = async (
175
+ response: Response,
176
+ ): Promise<string> => {
177
+ const raw = await response.text().catch(() => '');
178
+ if (raw.length === 0) {
179
+ return `HTTP ${response.status}`;
180
+ }
181
+ try {
182
+ const parsed: unknown = JSON.parse(raw);
183
+ if (isRecord(parsed) && typeof parsed.error === 'string') {
184
+ return parsed.error;
185
+ }
186
+ } catch {
187
+ return raw;
188
+ }
189
+ return raw;
190
+ };
191
+
174
192
  export const postConsoleOperation = async (
175
193
  appendToken: AppendToken,
176
194
  apiPath: string,
@@ -182,7 +200,7 @@ export const postConsoleOperation = async (
182
200
  body: JSON.stringify(body),
183
201
  });
184
202
  if (!response.ok) {
185
- throw new Error(`HTTP ${response.status}`);
203
+ throw new Error(await readOperationErrorReason(response));
186
204
  }
187
205
  };
188
206
 
@@ -44,6 +44,16 @@ describe('renderMarkdownToSafeHtml', () => {
44
44
  it('returns an empty string for blank input', () => {
45
45
  expect(renderMarkdownToSafeHtml(' ')).toBe('');
46
46
  });
47
+
48
+ it('renders GitHub emoji shortcodes as Unicode emoji glyphs', () => {
49
+ const html = renderMarkdownToSafeHtml(':magic_wand: :sparkles: :rocket:');
50
+ expect(html).toContain('🪄');
51
+ expect(html).toContain('✨');
52
+ expect(html).toContain('🚀');
53
+ expect(html).not.toContain(':magic_wand:');
54
+ expect(html).not.toContain(':sparkles:');
55
+ expect(html).not.toContain(':rocket:');
56
+ });
47
57
  });
48
58
 
49
59
  describe('splitMarkdownSegments', () => {
@@ -1,5 +1,14 @@
1
1
  import DOMPurify from 'dompurify';
2
+ import { nameToEmoji } from 'gemoji';
2
3
  import { marked } from 'marked';
4
+ import { markedEmoji } from 'marked-emoji';
5
+
6
+ marked.use(
7
+ markedEmoji({
8
+ emojis: nameToEmoji,
9
+ renderer: (token) => token.emoji,
10
+ }),
11
+ );
3
12
 
4
13
  export const renderMarkdownToSafeHtml = (source: string): string => {
5
14
  const trimmed = source.trim();
@@ -22,7 +22,7 @@ import type {
22
22
  export type ConsoleQueueActionInput = {
23
23
  kind: ConsoleActionKind;
24
24
  item: ConsoleListItem;
25
- commit: () => void;
25
+ commit: () => Promise<void>;
26
26
  };
27
27
 
28
28
  export type ConsoleItemDetailContainerProps = {
@@ -68,54 +68,42 @@ export const ConsoleItemDetailContainer = ({
68
68
  onQueueAction({
69
69
  kind: { type: 'review', action },
70
70
  item,
71
- commit: () => {
72
- void operations.reviewPullRequest(item, prUrl, action);
73
- },
71
+ commit: () => operations.reviewPullRequest(item, prUrl, action),
74
72
  });
75
73
  },
76
74
  onSetNextActionDate: (action) => {
77
75
  onQueueAction({
78
76
  kind: { type: 'next_action_date', action },
79
77
  item,
80
- commit: () => {
81
- void operations.setNextActionDate(item, action);
82
- },
78
+ commit: () => operations.setNextActionDate(item, action),
83
79
  });
84
80
  },
85
81
  onSetStory: (option: ConsoleFieldOption) => {
86
82
  onQueueAction({
87
83
  kind: { type: 'set_story', optionName: option.name },
88
84
  item,
89
- commit: () => {
90
- void operations.setStory(item, option);
91
- },
85
+ commit: () => operations.setStory(item, option),
92
86
  });
93
87
  },
94
88
  onSetStatus: (option: ConsoleFieldOption) => {
95
89
  onQueueAction({
96
90
  kind: { type: 'set_status', optionName: option.name },
97
91
  item,
98
- commit: () => {
99
- void operations.setStatus(item, option);
100
- },
92
+ commit: () => operations.setStatus(item, option),
101
93
  });
102
94
  },
103
95
  onSetInTmuxByHuman: (option: ConsoleFieldOption) => {
104
96
  onQueueAction({
105
97
  kind: { type: 'set_in_tmux_by_human', optionName: option.name },
106
98
  item,
107
- commit: () => {
108
- void operations.setInTmuxByHuman(item, option);
109
- },
99
+ commit: () => operations.setInTmuxByHuman(item, option),
110
100
  });
111
101
  },
112
102
  onClose: (action) => {
113
103
  onQueueAction({
114
104
  kind: { type: 'close', action },
115
105
  item,
116
- commit: () => {
117
- void operations.closeIssue(item, action);
118
- },
106
+ commit: () => operations.closeIssue(item, action),
119
107
  });
120
108
  },
121
109
  };