@linktr.ee/messaging-react 4.4.3 → 4.4.4-rc-1788181757

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 (58) hide show
  1. package/dist/AttachmentCard-BbsjzdLJ.cjs +2 -0
  2. package/dist/AttachmentCard-BbsjzdLJ.cjs.map +1 -0
  3. package/dist/{AttachmentCard-BqwWgqvi.js → AttachmentCard-DSekwIOA.js} +35 -20
  4. package/dist/AttachmentCard-DSekwIOA.js.map +1 -0
  5. package/dist/{Card-BIbqWagz.cjs → Card-BS6Fza2a.cjs} +2 -2
  6. package/dist/{Card-BIbqWagz.cjs.map → Card-BS6Fza2a.cjs.map} +1 -1
  7. package/dist/{Card-f51K6V7Y.js → Card-BhwBkgxI.js} +2 -2
  8. package/dist/{Card-f51K6V7Y.js.map → Card-BhwBkgxI.js.map} +1 -1
  9. package/dist/index.cjs +2 -2
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.js +36 -3
  12. package/dist/index.js.map +1 -1
  13. package/package.json +3 -3
  14. package/src/components/AttachmentCard/MediaPlayer.test.tsx +62 -85
  15. package/src/components/AttachmentCard/MediaPlayer.tsx +14 -2
  16. package/src/components/AttachmentCard/Thumbnail.test.tsx +37 -46
  17. package/src/components/AttachmentCard/index.test.tsx +18 -46
  18. package/src/components/AttachmentCard/index.tsx +8 -2
  19. package/src/components/AttachmentCard/utils/icons.ts +11 -1
  20. package/src/components/Avatar/Avatar.test.tsx +37 -64
  21. package/src/components/Avatar/index.tsx +8 -1
  22. package/src/components/ChannelList/CustomChannelPreview.test.tsx +19 -19
  23. package/src/components/ChannelList/CustomChannelPreview.tsx +8 -2
  24. package/src/components/ChannelView.outbound-resolution.integration.test.tsx +25 -30
  25. package/src/components/ChannelView.test.tsx +17 -31
  26. package/src/components/ChannelView.tsx +1 -0
  27. package/src/components/CustomLinkPreviewList/CustomLinkPreviewList.test.tsx +8 -5
  28. package/src/components/CustomLinkPreviewList/index.tsx +4 -1
  29. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +26 -21
  30. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +2 -0
  31. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +43 -56
  32. package/src/components/CustomMessageInput/index.tsx +25 -5
  33. package/src/components/CustomSystemMessage/CustomSystemMessage.test.tsx +17 -23
  34. package/src/components/FaqList/FaqList.test.tsx +5 -4
  35. package/src/components/FaqList/index.tsx +6 -1
  36. package/src/components/LinkAttachment/LinkAttachment.test.tsx +70 -81
  37. package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +1 -0
  38. package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +20 -61
  39. package/src/components/LinkAttachment/components/_shared/CardBody.tsx +10 -2
  40. package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +1 -21
  41. package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +11 -37
  42. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +5 -2
  43. package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +65 -72
  44. package/src/components/LinkAttachment/components/_shared/CardThumbnail.tsx +13 -2
  45. package/src/components/MessageAttachment/MessageAttachment.test.tsx +25 -8
  46. package/src/components/MessageAttachment/_shared/CompactDocumentRow.test.tsx +5 -19
  47. package/src/components/MessageAttachment/_shared/MediaStackGrid.test.tsx +7 -9
  48. package/src/components/MessageAttachment/_shared/VideoViewer.test.tsx +9 -2
  49. package/src/components/MessageAttachment/_shared/VideoViewer.tsx +1 -0
  50. package/src/components/MessageAttachment/_shared/ViewerShell.test.tsx +2 -2
  51. package/src/components/MessageBubble/MessageBubble.test.tsx +0 -1
  52. package/src/components/RichCard/RichCard.test.tsx +24 -54
  53. package/src/components/RichCard/RichCardBody.tsx +4 -1
  54. package/src/logging/index.test.tsx +1 -0
  55. package/src/providers/MessagingProvider.test.tsx +37 -41
  56. package/dist/AttachmentCard-BqwWgqvi.js.map +0 -1
  57. package/dist/AttachmentCard-DUrgGRsq.cjs +0 -2
  58. package/dist/AttachmentCard-DUrgGRsq.cjs.map +0 -1
@@ -2,7 +2,12 @@ import React from 'react'
2
2
  import type { Attachment, LocalMessage } from 'stream-chat'
3
3
  import { describe, expect, it } from 'vitest'
4
4
 
5
- import { fireEvent, renderWithProviders, screen } from '../../test/utils'
5
+ import {
6
+ fireEvent,
7
+ renderWithProviders,
8
+ screen,
9
+ within,
10
+ } from '../../test/utils'
6
11
 
7
12
  import StreamAttachmentMessage, {
8
13
  linkCaptionDuplicatesAttachmentUrl,
@@ -105,7 +110,7 @@ describe('StreamAttachmentMessage', () => {
105
110
  })
106
111
 
107
112
  it('keeps an emoji-only caption instead of dropping it', () => {
108
- const { container } = renderWithProviders(
113
+ renderWithProviders(
109
114
  <StreamAttachmentMessage
110
115
  groupPosition="single"
111
116
  isMyMessage={false}
@@ -124,15 +129,13 @@ describe('StreamAttachmentMessage', () => {
124
129
 
125
130
  // MessageText linkifies the URL (display text drops the scheme), so the
126
131
  // caption is split across a link node and trailing emoji text.
127
- const captionBubble = container.querySelector(
128
- '.str-chat__message-bubble:not([data-attachment-bubble])'
129
- )
132
+ const captionBubble = screen.getByTestId('message-caption-bubble')
130
133
  expect(captionBubble).toHaveTextContent('linktr.ee/someone')
131
134
  expect(captionBubble).toHaveTextContent('😎')
132
135
  })
133
136
 
134
137
  it('renders mixed link and media attachments in order', () => {
135
- const { container } = renderWithProviders(
138
+ renderWithProviders(
136
139
  <StreamAttachmentMessage
137
140
  groupPosition="single"
138
141
  isMyMessage={true}
@@ -160,7 +163,9 @@ describe('StreamAttachmentMessage', () => {
160
163
  expect(link.compareDocumentPosition(imageBubble)).toBe(
161
164
  Node.DOCUMENT_POSITION_FOLLOWING
162
165
  )
163
- expect(container).toHaveTextContent('caption')
166
+ expect(screen.getByTestId('message-caption-bubble')).toHaveTextContent(
167
+ 'caption'
168
+ )
164
169
  })
165
170
 
166
171
  it('optimizes Linktree-hosted image attachments for the channel bubble', () => {
@@ -203,7 +208,9 @@ describe('StreamAttachmentMessage', () => {
203
208
  // Video posters were the last media URL leaving here at full size —
204
209
  // four of them per 2×2 grid.
205
210
  expect(
206
- screen.getByTestId('video-attachment').querySelector('img')
211
+ within(screen.getByTestId('video-attachment')).getByRole('img', {
212
+ hidden: true,
213
+ })
207
214
  ).toHaveAttribute(
208
215
  'src',
209
216
  'https://ugc.production.linktr.ee/poster.jpg?io=true&size=messaging-attachment-v1_0'
@@ -229,13 +236,14 @@ describe('StreamAttachmentMessage', () => {
229
236
  const viewer = screen.getByTestId('image-viewer')
230
237
 
231
238
  expect(viewer).toHaveAttribute('open')
232
- expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
239
+ expect(within(viewer).getByRole('img', { hidden: true })).toHaveAttribute(
240
+ 'src',
233
241
  'https://ugc.production.linktr.ee/photo.jpg'
234
242
  )
235
243
  })
236
244
 
237
245
  it('renders the accompanying message text next to a link attachment', () => {
238
- const { container } = renderWithProviders(
246
+ renderWithProviders(
239
247
  <StreamAttachmentMessage
240
248
  groupPosition="single"
241
249
  isMyMessage={true}
@@ -252,14 +260,14 @@ describe('StreamAttachmentMessage', () => {
252
260
  />
253
261
  )
254
262
 
255
- const bubbles = container.querySelectorAll('.str-chat__message-bubble')
256
- expect(bubbles).toHaveLength(2)
257
- expect(bubbles[0]).toHaveAttribute('data-attachment-bubble', 'true')
258
- expect(bubbles[1]).toHaveTextContent('you have to see this')
263
+ expect(screen.getByTestId('message-attachment-bubble')).toBeInTheDocument()
264
+ expect(screen.getByTestId('message-caption-bubble')).toHaveTextContent(
265
+ 'you have to see this'
266
+ )
259
267
  })
260
268
 
261
269
  it('renders the accompanying message text next to a media attachment', () => {
262
- const { container } = renderWithProviders(
270
+ renderWithProviders(
263
271
  <StreamAttachmentMessage
264
272
  groupPosition="single"
265
273
  isMyMessage={false}
@@ -276,11 +284,8 @@ describe('StreamAttachmentMessage', () => {
276
284
  />
277
285
  )
278
286
 
279
- expect(container).toHaveTextContent('from the trip')
280
- expect(
281
- container.querySelector(
282
- '.str-chat__message-bubble:not([data-attachment-bubble])'
283
- )
284
- ).toHaveTextContent('from the trip')
287
+ expect(screen.getByTestId('message-caption-bubble')).toHaveTextContent(
288
+ 'from the trip'
289
+ )
285
290
  })
286
291
  })
@@ -408,6 +408,7 @@ const StreamAttachmentMessage: React.FC<StreamAttachmentMessageProps> = ({
408
408
  <div
409
409
  className="str-chat__message-bubble"
410
410
  data-attachment-bubble="true"
411
+ data-testid="message-attachment-bubble"
411
412
  style={{
412
413
  background: 'transparent',
413
414
  borderRadius: 0,
@@ -420,6 +421,7 @@ const StreamAttachmentMessage: React.FC<StreamAttachmentMessageProps> = ({
420
421
  </div>
421
422
  {caption ? (
422
423
  <div
424
+ data-testid="message-caption-bubble"
423
425
  className={classNames('str-chat__message-bubble', {
424
426
  'relative isolate': captionShowsTail,
425
427
  })}
@@ -88,9 +88,9 @@ describe('CustomMessageInput', () => {
88
88
  it('renders the interactive message input when channel is not frozen', () => {
89
89
  mockChannelData = {}
90
90
 
91
- const { container } = renderWithProviders(<CustomMessageInput />)
91
+ renderWithProviders(<CustomMessageInput />)
92
92
 
93
- const messageInput = container.firstElementChild
93
+ const messageInput = screen.getByTestId('message-input')
94
94
  expect(messageInput).not.toHaveAttribute('aria-disabled')
95
95
  expect(messageInput).not.toHaveAttribute('inert')
96
96
  expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
@@ -99,9 +99,9 @@ describe('CustomMessageInput', () => {
99
99
  it('renders the frozen message input when channel is frozen', () => {
100
100
  mockChannelData = { frozen: true }
101
101
 
102
- const { container } = renderWithProviders(<CustomMessageInput />)
102
+ renderWithProviders(<CustomMessageInput />)
103
103
 
104
- const messageInput = container.querySelector('.message-input')
104
+ const messageInput = screen.getByTestId('message-input')
105
105
  expect(messageInput).toHaveAttribute('aria-disabled', 'true')
106
106
  expect(messageInput).toHaveAttribute('inert')
107
107
  expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
@@ -125,7 +125,7 @@ describe('CustomMessageInput', () => {
125
125
  it('renders adjacent actions inside the disabled container when frozen', () => {
126
126
  mockChannelData = { frozen: true }
127
127
 
128
- const { container } = renderWithProviders(
128
+ renderWithProviders(
129
129
  <CustomMessageInput
130
130
  renderActions={() => (
131
131
  <button data-testid="custom-action" type="button">
@@ -135,17 +135,16 @@ describe('CustomMessageInput', () => {
135
135
  />
136
136
  )
137
137
 
138
- const messageInput = container.querySelector('.message-input')
139
- const action = screen.getByTestId('custom-action')
138
+ const messageInput = screen.getByTestId('message-input')
140
139
 
141
140
  expect(messageInput).toHaveAttribute('aria-disabled', 'true')
142
- expect(messageInput?.contains(action)).toBe(true)
141
+ expect(messageInput).toContainElement(screen.getByTestId('custom-action'))
143
142
  })
144
143
 
145
144
  it('renders adjacent actions in the interactive container when not frozen', () => {
146
145
  mockChannelData = {}
147
146
 
148
- const { container } = renderWithProviders(
147
+ renderWithProviders(
149
148
  <CustomMessageInput
150
149
  renderActions={() => (
151
150
  <button data-testid="custom-action" type="button">
@@ -155,8 +154,9 @@ describe('CustomMessageInput', () => {
155
154
  />
156
155
  )
157
156
 
158
- const messageInput = container.firstElementChild
159
- expect(messageInput).not.toHaveAttribute('aria-disabled')
157
+ expect(screen.getByTestId('message-input')).not.toHaveAttribute(
158
+ 'aria-disabled'
159
+ )
160
160
  expect(screen.getByTestId('custom-action')).toBeInTheDocument()
161
161
  })
162
162
 
@@ -233,7 +233,7 @@ describe('CustomMessageInput', () => {
233
233
  it('replaces the composer with the locked panel when disabled (channel not frozen)', () => {
234
234
  mockChannelData = {}
235
235
 
236
- const { container } = renderWithProviders(
236
+ renderWithProviders(
237
237
  <CustomMessageInput
238
238
  disabled
239
239
  disabledReason="Only Linktree can send messages on this thread"
@@ -243,12 +243,10 @@ describe('CustomMessageInput', () => {
243
243
  // The interactive input is gone entirely — no textarea, no send button.
244
244
  expect(screen.queryByTestId('stream-message-input')).not.toBeInTheDocument()
245
245
  expect(screen.queryByTestId('textarea-composer')).not.toBeInTheDocument()
246
- expect(container.querySelector('.message-input')).not.toBeInTheDocument()
246
+ expect(screen.queryByTestId('message-input')).not.toBeInTheDocument()
247
247
 
248
248
  // The locked panel with the reason replaces it.
249
- expect(
250
- container.querySelector('.messaging-composer-locked-panel')
251
- ).toBeInTheDocument()
249
+ expect(screen.getByTestId('composer-locked-panel')).toBeInTheDocument()
252
250
  expect(
253
251
  screen.getByText(/Only Linktree can send messages on this thread/i)
254
252
  ).toBeInTheDocument()
@@ -276,24 +274,19 @@ describe('CustomMessageInput', () => {
276
274
  it('renders renderHeader above the floating composer controls', () => {
277
275
  mockChannelData = {}
278
276
 
279
- const { container } = renderWithProviders(
277
+ renderWithProviders(
280
278
  <CustomMessageInput
281
279
  composerInput={() => <div data-testid="custom-composer" />}
282
280
  renderHeader={() => <div data-testid="composer-header">header</div>}
283
281
  />
284
282
  )
285
283
 
286
- const chrome = container.querySelector(
287
- '.messaging-composer-chrome--floating'
288
- ) as HTMLElement | null
289
284
  const header = screen.getByTestId('composer-header')
290
- const messageInput = container.querySelector('.message-input')
285
+ const messageInput = screen.getByTestId('message-input')
291
286
 
292
- expect(chrome).not.toBeNull()
293
- expect(chrome).toContainElement(header)
294
- expect(messageInput).not.toBeNull()
287
+ expect(screen.getByTestId('composer-chrome')).toContainElement(header)
295
288
  expect(
296
- header.compareDocumentPosition(messageInput as Node) &
289
+ header.compareDocumentPosition(messageInput) &
297
290
  Node.DOCUMENT_POSITION_FOLLOWING
298
291
  ).toBeTruthy()
299
292
  })
@@ -301,13 +294,13 @@ describe('CustomMessageInput', () => {
301
294
  it('keeps the frozen wrapper around a custom composerInput', () => {
302
295
  mockChannelData = { frozen: true }
303
296
 
304
- const { container } = renderWithProviders(
297
+ renderWithProviders(
305
298
  <CustomMessageInput
306
299
  composerInput={() => <div data-testid="custom-composer" />}
307
300
  />
308
301
  )
309
302
 
310
- const messageInput = container.querySelector('.message-input')
303
+ const messageInput = screen.getByTestId('message-input')
311
304
  expect(messageInput).toHaveAttribute('aria-disabled', 'true')
312
305
  expect(messageInput).toHaveAttribute('inert')
313
306
  expect(screen.getByTestId('custom-composer')).toBeInTheDocument()
@@ -316,12 +309,10 @@ describe('CustomMessageInput', () => {
316
309
  it('does not render the locked panel when the composer is not disabled', () => {
317
310
  mockChannelData = {}
318
311
 
319
- const { container } = renderWithProviders(
320
- <CustomMessageInput disabledReason="should not show" />
321
- )
312
+ renderWithProviders(<CustomMessageInput disabledReason="should not show" />)
322
313
 
323
314
  expect(
324
- container.querySelector('.messaging-composer-locked-panel')
315
+ screen.queryByTestId('composer-locked-panel')
325
316
  ).not.toBeInTheDocument()
326
317
  expect(screen.queryByText(/should not show/i)).not.toBeInTheDocument()
327
318
  expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
@@ -359,13 +350,13 @@ describe('CustomMessageInput', () => {
359
350
  vi.unstubAllGlobals()
360
351
  })
361
352
 
362
- const renderCard = () => {
363
- const { container } = renderWithProviders(<CustomMessageInput />)
364
- return container.querySelector('.central-container') as HTMLElement
353
+ const mountCard = () => {
354
+ renderWithProviders(<CustomMessageInput />)
355
+ return screen.getByTestId('composer-card')
365
356
  }
366
357
 
367
358
  it('caps the card against the surface height and scrolls it as one region', () => {
368
- const card = renderCard()
359
+ const card = mountCard()
369
360
 
370
361
  expect(card).toHaveClass(
371
362
  'max-h-[calc(var(--messaging-surface-height,100dvh)*0.5)]',
@@ -375,16 +366,13 @@ describe('CustomMessageInput', () => {
375
366
  // Nothing inside may shrink, or the previews get cropped to make room for
376
367
  // the textarea instead of scrolling with it.
377
368
  expect(card).not.toHaveClass('min-h-0')
378
- expect(
379
- card.querySelector('.messaging-composer-previews')
380
- ).not.toHaveClass('min-h-0')
381
- expect(card.querySelector('.messaging-composer-row')).not.toHaveClass(
382
- 'shrink-0'
383
- )
369
+ expect(screen.getByTestId('composer-previews')).not.toHaveClass('min-h-0')
370
+ // eslint-disable-next-line local/no-style-only-assertion -- MES-1037: the row not being shrink-0 is part of the single-scroll contract in this component's doc comment, and jsdom has no layout to observe the squeeze it prevents.
371
+ expect(screen.getByTestId('composer-row')).not.toHaveClass('shrink-0')
384
372
  })
385
373
 
386
374
  it('lets the textarea grow to fit rather than scrolling its own overflow', () => {
387
- const card = renderCard()
375
+ const card = mountCard()
388
376
 
389
377
  // TextareaComposer defaults an absent maxRows to 1, so this has to be set.
390
378
  expect(screen.getByTestId('textarea-composer')).toHaveAttribute(
@@ -403,7 +391,7 @@ describe('CustomMessageInput', () => {
403
391
  })
404
392
 
405
393
  it('leaves plain-text pastes to the browser but lets file pastes reach Stream', () => {
406
- renderCard()
394
+ mountCard()
407
395
 
408
396
  // Stream's paste handler is dispatched from React's root, above this
409
397
  // card, so a listener further up stands in for it: the event reaching
@@ -428,7 +416,7 @@ describe('CustomMessageInput', () => {
428
416
  })
429
417
 
430
418
  it('detects a pasted file via clipboardData.items even when .files is empty (Safari)', () => {
431
- renderCard()
419
+ mountCard()
432
420
 
433
421
  // Safari never populates clipboardData.files on paste, even for an
434
422
  // image — only .items has it. Checking .files here would silently drop
@@ -448,7 +436,7 @@ describe('CustomMessageInput', () => {
448
436
  })
449
437
 
450
438
  it('scrolls the input back into view when the card shrinks under it', () => {
451
- const card = renderCard()
439
+ const card = mountCard()
452
440
  // jsdom has no layout, so stand in for a card whose cap now cuts the
453
441
  // input off below the fold.
454
442
  Object.defineProperty(card, 'scrollHeight', {
@@ -463,7 +451,7 @@ describe('CustomMessageInput', () => {
463
451
  })
464
452
 
465
453
  it('leaves the scroll position alone when nothing in the composer is focused', () => {
466
- const card = renderCard()
454
+ const card = mountCard()
467
455
  Object.defineProperty(card, 'scrollHeight', {
468
456
  configurable: true,
469
457
  value: 380,
@@ -478,7 +466,7 @@ describe('CustomMessageInput', () => {
478
466
  })
479
467
 
480
468
  it('sticks the send button to the bottom of the scroll, at its resting inset', () => {
481
- renderCard()
469
+ mountCard()
482
470
 
483
471
  // The offset must match the wrapper's own margin: sticky resolves against
484
472
  // the border box, so at bottom-0 the button would lose that inset and sit
@@ -487,7 +475,8 @@ describe('CustomMessageInput', () => {
487
475
  // card scrolls far enough for the button to ride up to the top of the
488
476
  // row, a staged preview otherwise meets it flush. Asserted together so
489
477
  // they cannot drift apart.
490
- expect(screen.getByTestId('send-button').parentElement).toHaveClass(
478
+ // eslint-disable-next-line local/no-style-only-assertion -- the point is that these three offsets match; sticky resolution isn't observable in jsdom.
479
+ expect(screen.getByTestId('send-button-wrapper')).toHaveClass(
491
480
  'sticky',
492
481
  'bottom-2',
493
482
  'mb-2',
@@ -539,10 +528,8 @@ describe('CustomMessageInput', () => {
539
528
  // Simulate the composer growing/shrinking. The observer only re-pins when
540
529
  // the composer's offsetHeight increased; jsdom reports 0, so set it
541
530
  // explicitly. Mounts with height 0, so any positive value reads as growth.
542
- const setComposerHeight = (container: HTMLElement, height: number) => {
543
- const composer = container.querySelector(
544
- '.messaging-composer-chrome--floating'
545
- ) as HTMLElement
531
+ const setComposerHeight = (height: number) => {
532
+ const composer = screen.getByTestId('composer-chrome')
546
533
  Object.defineProperty(composer, 'offsetHeight', {
547
534
  value: height,
548
535
  configurable: true,
@@ -572,7 +559,7 @@ describe('CustomMessageInput', () => {
572
559
  })
573
560
 
574
561
  it('scrolls to the bottom when the composer grows and the user was at the bottom', () => {
575
- const { container } = renderInLayout()
562
+ renderInLayout()
576
563
  const scroller = screen.getByTestId('scroller')
577
564
 
578
565
  // At the bottom: scrollHeight - scrollTop - clientHeight = 0.
@@ -581,7 +568,7 @@ describe('CustomMessageInput', () => {
581
568
  clientHeight: 300,
582
569
  scrollTop: 260,
583
570
  })
584
- setComposerHeight(container, 200) // grew from 0
571
+ setComposerHeight(200) // grew from 0
585
572
 
586
573
  resizeCallback?.([], {} as ResizeObserver)
587
574
 
@@ -589,7 +576,7 @@ describe('CustomMessageInput', () => {
589
576
  })
590
577
 
591
578
  it('leaves the scroll position alone when the user has scrolled up', () => {
592
- const { container } = renderInLayout()
579
+ renderInLayout()
593
580
  const scroller = screen.getByTestId('scroller')
594
581
 
595
582
  // Scrolled up reading history: distance from bottom (600px) is well over
@@ -599,7 +586,7 @@ describe('CustomMessageInput', () => {
599
586
  clientHeight: 300,
600
587
  scrollTop: 100,
601
588
  })
602
- setComposerHeight(container, 200) // grew, but user is not near the bottom
589
+ setComposerHeight(200) // grew, but user is not near the bottom
603
590
 
604
591
  resizeCallback?.([], {} as ResizeObserver)
605
592
 
@@ -184,6 +184,7 @@ const CustomMessageInputInner: React.FC = () => {
184
184
  // such foreign direct child, without re-adding it to our own wrappers.
185
185
  <div
186
186
  ref={cardRef}
187
+ data-testid="composer-card"
187
188
  className="central-container flex flex-col min-w-0 w-full max-h-[calc(var(--messaging-surface-height,100dvh)*0.5)] overflow-y-auto overscroll-contain bg-white rounded-[1.5rem] shadow-[0_4px_16px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.04)] [&>:not(.messaging-composer-previews):not(.messaging-composer-row)]:p-2"
188
189
  data-dd-privacy="mask"
189
190
  >
@@ -193,12 +194,18 @@ const CustomMessageInputInner: React.FC = () => {
193
194
  textarea's own py-3.5 already separates the last preview from the
194
195
  text, and adding one here double-spaces them now that both sit in the
195
196
  same scroll. */}
196
- <div className="messaging-composer-previews empty:hidden flex flex-col gap-2 px-2 pt-2">
197
+ <div
198
+ data-testid="composer-previews"
199
+ className="messaging-composer-previews empty:hidden flex flex-col gap-2 px-2 pt-2"
200
+ >
197
201
  <QuotedMessagePreview />
198
202
  <CustomLinkPreviewList />
199
203
  <AttachmentPreviewList />
200
204
  </div>
201
- <div className="messaging-composer-row flex items-start">
205
+ <div
206
+ data-testid="composer-row"
207
+ className="messaging-composer-row flex items-start"
208
+ >
202
209
  <TextareaComposer
203
210
  aria-disabled={disabled || undefined}
204
211
  // TextareaComposer wraps the real <textarea> in its own div, so
@@ -271,7 +278,10 @@ const CustomMessageInputInner: React.FC = () => {
271
278
  background to sit on: sticky cannot leave its containing block, so
272
279
  the button stays inside the row and a preview never passes behind
273
280
  it. */}
274
- <div className="sticky bottom-2 mt-auto mb-2 mr-2 shrink-0 pt-2">
281
+ <div
282
+ data-testid="send-button-wrapper"
283
+ className="sticky bottom-2 mt-auto mb-2 mr-2 shrink-0 pt-2"
284
+ >
275
285
  <SendButton
276
286
  sendMessage={handleSubmit}
277
287
  aria-label="Send"
@@ -430,7 +440,10 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
430
440
  return (
431
441
  <>
432
442
  {renderHeader?.()}
433
- <div className="messaging-composer-locked-panel flex w-full flex-col items-center justify-center gap-3 px-6 py-4">
443
+ <div
444
+ data-testid="composer-locked-panel"
445
+ className="messaging-composer-locked-panel flex w-full flex-col items-center justify-center gap-3 px-6 py-4"
446
+ >
434
447
  {disabledReason ? (
435
448
  <p className="max-w-[345px] text-center text-xs font-normal leading-[1.3] tracking-[0.12px] text-black/40">
436
449
  {disabledReason}
@@ -445,12 +458,16 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
445
458
  return (
446
459
  <div
447
460
  ref={composerRef}
461
+ data-testid="composer-chrome"
448
462
  className="messaging-composer-chrome messaging-composer-chrome--floating"
449
463
  >
450
464
  <ComposerBackdrop />
451
465
  <div className="relative z-10 flex flex-col">
452
466
  {renderHeader?.()}
453
- <div className="messaging-composer-locked-panel flex w-full flex-col items-center justify-center gap-3 px-6 py-4">
467
+ <div
468
+ data-testid="composer-locked-panel"
469
+ className="messaging-composer-locked-panel flex w-full flex-col items-center justify-center gap-3 px-6 py-4"
470
+ >
454
471
  {disabledReason ? (
455
472
  <p className="max-w-[345px] text-center text-xs font-normal leading-[1.3] tracking-[0.12px] text-black/40">
456
473
  {disabledReason}
@@ -471,6 +488,7 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
471
488
  // @ts-expect-error Only React 19 onwards has `inert` in its types.
472
489
  inert={isFrozen ? '' : undefined}
473
490
  aria-disabled={isFrozen || undefined}
491
+ data-testid="message-input"
474
492
  className="message-input flex min-h-0 items-end gap-4 aria-disabled:opacity-40"
475
493
  >
476
494
  {renderActions && (
@@ -490,6 +508,7 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
490
508
  return (
491
509
  <div
492
510
  ref={composerRef}
511
+ data-testid="composer-chrome"
493
512
  className="messaging-composer-chrome messaging-composer-chrome--floating"
494
513
  >
495
514
  <ComposerBackdrop />
@@ -501,6 +520,7 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
501
520
  // @ts-expect-error Only React 19 onwards has `inert` in its types.
502
521
  inert={isFrozen ? '' : undefined}
503
522
  aria-disabled={isFrozen || undefined}
523
+ data-testid="message-input"
504
524
  className="message-input flex min-h-0 items-end gap-4 aria-disabled:opacity-40"
505
525
  >
506
526
  {renderActions && (
@@ -19,13 +19,17 @@ const createProps = (
19
19
  },
20
20
  }) as EventComponentProps
21
21
 
22
+ // Clock digits, not a locale-formatted string: the contract is that a
23
+ // timestamp shows, not how it is formatted.
24
+ const TIMESTAMP = /\d{1,2}:\d{2}/
25
+
22
26
  describe('CustomSystemMessage', () => {
23
27
  it.each([
24
28
  'SYSTEM_ESCALATION',
25
29
  'SYSTEM_DM_AGENT_PAUSED',
26
30
  'SYSTEM_DM_AGENT_RESUMED',
27
31
  ] as const)('hides the timestamp for %s', (customType) => {
28
- const { container } = renderWithProviders(
32
+ renderWithProviders(
29
33
  <CustomSystemMessage
30
34
  {...createProps({
31
35
  metadata: { custom_type: customType },
@@ -33,11 +37,11 @@ describe('CustomSystemMessage', () => {
33
37
  />
34
38
  )
35
39
 
36
- expect(container.querySelector('time')).not.toBeInTheDocument()
40
+ expect(screen.queryByText(TIMESTAMP)).not.toBeInTheDocument()
37
41
  })
38
42
 
39
43
  it('shows the timestamp for age safety system messages', () => {
40
- const { container } = renderWithProviders(
44
+ renderWithProviders(
41
45
  <CustomSystemMessage
42
46
  {...createProps({
43
47
  metadata: { custom_type: 'SYSTEM_AGE_SAFETY_BLOCKED' },
@@ -45,22 +49,20 @@ describe('CustomSystemMessage', () => {
45
49
  />
46
50
  )
47
51
 
48
- expect(container.querySelector('time')).toBeInTheDocument()
52
+ expect(screen.getByText(TIMESTAMP)).toBeInTheDocument()
49
53
  })
50
54
 
51
55
  it.each([
52
56
  { metadata: {}, text: 'Unknown system message' },
53
57
  { text: 'Absent system type' },
54
58
  ])('hides the timestamp for unknown or absent system types', (overrides) => {
55
- const { container } = renderWithProviders(
56
- <CustomSystemMessage {...createProps(overrides)} />
57
- )
59
+ renderWithProviders(<CustomSystemMessage {...createProps(overrides)} />)
58
60
 
59
- expect(container.querySelector('time')).not.toBeInTheDocument()
61
+ expect(screen.queryByText(TIMESTAMP)).not.toBeInTheDocument()
60
62
  })
61
63
 
62
64
  it('hides the timestamp for legacy dm-agent messages without a custom_type', () => {
63
- const { container } = renderWithProviders(
65
+ renderWithProviders(
64
66
  <CustomSystemMessage
65
67
  {...createProps({
66
68
  dm_agent_system_type: 'SYSTEM_DM_AGENT_PAUSED',
@@ -68,7 +70,7 @@ describe('CustomSystemMessage', () => {
68
70
  />
69
71
  )
70
72
 
71
- expect(container.querySelector('time')).not.toBeInTheDocument()
73
+ expect(screen.queryByText(TIMESTAMP)).not.toBeInTheDocument()
72
74
  })
73
75
 
74
76
  it('detects DM Agent system subtype from metadata.custom_type', () => {
@@ -112,7 +114,7 @@ describe('CustomSystemMessage', () => {
112
114
  })
113
115
 
114
116
  it('renders the age safety blocked subtype with fallback copy and warning treatment', () => {
115
- const { container } = renderWithProviders(
117
+ renderWithProviders(
116
118
  <CustomSystemMessage
117
119
  {...createProps({
118
120
  text: ' ',
@@ -146,18 +148,10 @@ describe('CustomSystemMessage', () => {
146
148
  expect(
147
149
  screen.getByTestId('age-safety-system-message-icon')
148
150
  ).toBeInTheDocument()
149
- expect(
150
- container.querySelector('.mes-age-safety-system-message__icon')
151
- ).toBeInTheDocument()
152
- expect(
153
- container.querySelector(
154
- '[data-testid="age-safety-system-message-icon"] path[opacity="0.2"]'
155
- )
156
- ).toBeInTheDocument()
157
151
  })
158
152
 
159
153
  it('falls back to legacy generic rendering when subtype is absent or unknown', () => {
160
- const { container } = renderWithProviders(
154
+ renderWithProviders(
161
155
  <CustomSystemMessage
162
156
  {...createProps({
163
157
  text: 'Legacy system message',
@@ -170,9 +164,9 @@ describe('CustomSystemMessage', () => {
170
164
  expect(
171
165
  screen.queryByTestId('dm-agent-system-message')
172
166
  ).not.toBeInTheDocument()
173
- expect(screen.getByText('Legacy system message')).toBeInTheDocument()
174
167
  expect(
175
- container.querySelector('.str-chat__message--system__line')
176
- ).toBeInTheDocument()
168
+ screen.queryByTestId('age-safety-system-message')
169
+ ).not.toBeInTheDocument()
170
+ expect(screen.getByText('Legacy system message')).toBeInTheDocument()
177
171
  })
178
172
  })
@@ -129,11 +129,10 @@ describe('FaqList', () => {
129
129
  })
130
130
 
131
131
  it('renders the header only when headerText is provided', () => {
132
- const { container, rerender } = renderWithProviders(
132
+ const { rerender } = renderWithProviders(
133
133
  <FaqList faqs={faqs} onFaqClick={vi.fn()} />
134
134
  )
135
- expect(screen.queryByText('Frequently asked questions')).toBeNull()
136
- expect(container.querySelector('p')).toBeNull()
135
+ expect(screen.queryByTestId('faq-list-header')).not.toBeInTheDocument()
137
136
 
138
137
  rerender(
139
138
  <FaqList
@@ -142,7 +141,9 @@ describe('FaqList', () => {
142
141
  headerText="Frequently asked questions"
143
142
  />
144
143
  )
145
- expect(screen.getByText('Frequently asked questions')).toBeInTheDocument()
144
+ expect(screen.getByTestId('faq-list-header')).toHaveTextContent(
145
+ 'Frequently asked questions'
146
+ )
146
147
  })
147
148
 
148
149
  it('passes className to the list root', () => {
@@ -60,7 +60,12 @@ export const FaqList: React.FC<FaqListProps> = ({
60
60
  style={{ backgroundColor: '#F1F0EE' }}
61
61
  >
62
62
  {headerText && (
63
- <p className="text-md text-charcoal mb-4">{headerText}</p>
63
+ <p
64
+ data-testid="faq-list-header"
65
+ className="text-md text-charcoal mb-4"
66
+ >
67
+ {headerText}
68
+ </p>
64
69
  )}
65
70
  {enabledFaqs.map((faq) => (
66
71
  <FaqListItem