@open-mercato/ui 0.6.8-develop.6878.1.ab817e58ac → 0.6.8-develop.6881.1.8ea75912f2

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.
@@ -24,6 +24,19 @@ jest.mock('../conversation-store', () => {
24
24
  }
25
25
  })
26
26
 
27
+ jest.mock('@open-mercato/shared/lib/logger', () => {
28
+ const mocked = {
29
+ debug: jest.fn(),
30
+ info: jest.fn(),
31
+ warn: jest.fn(),
32
+ error: jest.fn(),
33
+ child: jest.fn(),
34
+ }
35
+ mocked.child.mockImplementation(() => mocked)
36
+ return { createLogger: jest.fn(() => mocked) }
37
+ })
38
+
39
+ import { createLogger } from '@open-mercato/shared/lib/logger'
27
40
  import {
28
41
  listAiServerConversations,
29
42
  createAiServerConversation,
@@ -31,6 +44,8 @@ import {
31
44
 
32
45
  const listMock = listAiServerConversations as jest.MockedFunction<typeof listAiServerConversations>
33
46
  const createMock = createAiServerConversation as jest.MockedFunction<typeof createAiServerConversation>
47
+ const loggerError = createLogger('ui').error as jest.Mock
48
+ const loggerWarn = createLogger('ui').warn as jest.Mock
34
49
 
35
50
  const KEY_PREFIX = 'om-ai-chat-sessions-v1'
36
51
 
@@ -69,6 +84,8 @@ describe('<AiChatSessionsProvider> — tenant/org scope isolation', () => {
69
84
  listMock.mockResolvedValue(null)
70
85
  createMock.mockReset()
71
86
  createMock.mockResolvedValue(null)
87
+ loggerError.mockClear()
88
+ loggerWarn.mockClear()
72
89
  // Reset scope to a known starting point. The module-level state in
73
90
  // organizationEvents persists across tests in the same file.
74
91
  act(() => {
@@ -212,6 +229,51 @@ describe('<AiChatSessionsProvider> — tenant/org scope isolation', () => {
212
229
  })
213
230
  })
214
231
 
232
+ it('warns when the server conversation list is unavailable', async () => {
233
+ listMock.mockReset()
234
+ listMock.mockResolvedValue(null)
235
+
236
+ renderWithProviders(<Harness agentId="assistant" />)
237
+
238
+ await waitFor(() => {
239
+ expect(loggerWarn).toHaveBeenCalledWith(
240
+ 'Could not load server conversations; keeping the locally persisted sessions',
241
+ )
242
+ })
243
+ })
244
+
245
+ it('logs and keeps the locally persisted sessions when the server list rejects', async () => {
246
+ const failure = new Error('[internal] conversations endpoint unreachable')
247
+ listMock.mockReset()
248
+ listMock.mockRejectedValue(failure)
249
+ window.localStorage.setItem(
250
+ scopedKey('T1', 'O1'),
251
+ JSON.stringify({
252
+ sessions: [
253
+ {
254
+ id: 'local-session',
255
+ agentId: 'assistant',
256
+ conversationId: 'local-conv',
257
+ createdAt: 1,
258
+ lastUsedAt: 1,
259
+ status: 'open',
260
+ },
261
+ ],
262
+ activeByAgent: { assistant: 'local-session' },
263
+ }),
264
+ )
265
+
266
+ renderWithProviders(<Harness agentId="assistant" />)
267
+
268
+ await waitFor(() => {
269
+ expect(loggerError).toHaveBeenCalledWith('Failed to load server conversations', {
270
+ err: failure,
271
+ })
272
+ })
273
+
274
+ expect(screen.getByTestId('session-count').textContent).toBe('1')
275
+ })
276
+
215
277
  it('falls back to the no-tenant/no-org bucket when scope is unresolved', async () => {
216
278
  act(() => {
217
279
  emitOrganizationScopeChanged({ tenantId: null, organizationId: null })
@@ -9,6 +9,19 @@ jest.mock('../../../backend/utils/apiCall', () => ({
9
9
  apiCallOrThrow: jest.fn(),
10
10
  }))
11
11
 
12
+ jest.mock('@open-mercato/shared/lib/logger', () => {
13
+ const mocked = {
14
+ debug: jest.fn(),
15
+ info: jest.fn(),
16
+ warn: jest.fn(),
17
+ error: jest.fn(),
18
+ child: jest.fn(),
19
+ }
20
+ mocked.child.mockImplementation(() => mocked)
21
+ return { createLogger: jest.fn(() => mocked) }
22
+ })
23
+
24
+ import { createLogger } from '@open-mercato/shared/lib/logger'
12
25
  import { apiCallOrThrow } from '../../../backend/utils/apiCall'
13
26
  import { useAiPendingActionPolling } from '../useAiPendingActionPolling'
14
27
  import type { AiPendingActionCardAction, AiPendingActionCardStatus } from '../types'
@@ -48,9 +61,12 @@ function mockResponse(action: AiPendingActionCardAction) {
48
61
  }))
49
62
  }
50
63
 
64
+ const loggerError = createLogger('ui').error as jest.Mock
65
+
51
66
  describe('useAiPendingActionPolling', () => {
52
67
  beforeEach(() => {
53
68
  ;(apiCallOrThrow as jest.Mock).mockReset()
69
+ loggerError.mockClear()
54
70
  })
55
71
 
56
72
  it('fetches on mount and continues polling while status is pending', async () => {
@@ -148,4 +164,49 @@ describe('useAiPendingActionPolling', () => {
148
164
  jest.useRealTimers()
149
165
  }
150
166
  })
167
+
168
+ it('logs a failed fetch and keeps polling until it recovers', async () => {
169
+ const failure = new Error('[internal] pending action endpoint unreachable')
170
+ ;(apiCallOrThrow as jest.Mock)
171
+ .mockImplementationOnce(async () => {
172
+ throw failure
173
+ })
174
+ .mockImplementation(async () => ({
175
+ ok: true,
176
+ status: 200,
177
+ result: { pendingAction: makeAction({ status: 'pending' }) },
178
+ response: {},
179
+ cacheStatus: null,
180
+ }))
181
+
182
+ jest.useFakeTimers()
183
+ try {
184
+ const { result, unmount } = renderHook(() =>
185
+ useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 1000 }),
186
+ )
187
+
188
+ await act(async () => {
189
+ await Promise.resolve()
190
+ await Promise.resolve()
191
+ })
192
+ expect(loggerError).toHaveBeenCalledWith('Failed to load the pending AI action', {
193
+ err: failure,
194
+ pendingActionId: 'pa-1',
195
+ })
196
+ expect(result.current.error?.message).toBe(failure.message)
197
+ expect(result.current.action).toBeNull()
198
+
199
+ await act(async () => {
200
+ jest.advanceTimersByTime(1000)
201
+ await Promise.resolve()
202
+ await Promise.resolve()
203
+ })
204
+ expect(result.current.action?.status).toBe('pending')
205
+ expect(result.current.error).toBeNull()
206
+
207
+ unmount()
208
+ } finally {
209
+ jest.useRealTimers()
210
+ }
211
+ })
151
212
  })
@@ -1,9 +1,12 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
+ import { createLogger } from '@open-mercato/shared/lib/logger'
4
5
  import { apiCallOrThrow } from '../../backend/utils/apiCall'
5
6
  import type { AiPendingActionCardAction, AiPendingActionCardStatus } from './types'
6
7
 
8
+ const logger = createLogger('ui').child({ component: 'useAiPendingActionPolling' })
9
+
7
10
  /**
8
11
  * Shared polling hook for the Phase 3 mutation-approval cards (Step 5.10).
9
12
  *
@@ -148,6 +151,7 @@ export function useAiPendingActionPolling(
148
151
  }
149
152
  return result.pendingAction
150
153
  } catch (err) {
154
+ logger.error('Failed to load the pending AI action', { err, pendingActionId })
151
155
  if (!mountedRef.current) return null
152
156
  const message = err instanceof Error ? err.message : 'Failed to load pending action.'
153
157
  setError({ message })
@@ -168,7 +172,11 @@ export function useAiPendingActionPolling(
168
172
  }
169
173
  setIsPolling(true)
170
174
  timerRef.current = setTimeout(async () => {
171
- await refresh()
175
+ try {
176
+ await refresh()
177
+ } catch (err) {
178
+ logger.error('Polling the pending AI action failed unexpectedly', { err })
179
+ }
172
180
  scheduleNext()
173
181
  }, intervalMs)
174
182
  }, [clearTimer, disabled, intervalMs, refresh])
@@ -184,11 +192,18 @@ export function useAiPendingActionPolling(
184
192
  }
185
193
  }
186
194
  setIsPolling(true)
187
- // Always fetch on mount — the "reconnect behavior" guarantee.
188
- void refresh().then(() => {
189
- if (!mountedRef.current) return
190
- scheduleNext()
191
- })
195
+ // Always fetch on mount — the "reconnect behavior" guarantee. The catch
196
+ // runs before the scheduling step so a rejected first fetch degrades to a
197
+ // logged error instead of silently killing the polling loop.
198
+ void refresh()
199
+ .catch((err) => {
200
+ logger.error('Loading the pending AI action failed unexpectedly', { err })
201
+ return null
202
+ })
203
+ .then(() => {
204
+ if (!mountedRef.current) return
205
+ scheduleNext()
206
+ })
192
207
  return () => {
193
208
  mountedRef.current = false
194
209
  clearTimer()