@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
@@ -42,111 +42,84 @@ describe('Avatar', () => {
42
42
  )
43
43
 
44
44
  it('does not show a star by default but shows one when starred', () => {
45
- const defaultAvatar = renderWithProviders(
46
- <Avatar id="user-1" name="Alice" />
47
- )
48
- expect(defaultAvatar.container.querySelector('svg')).not.toBeInTheDocument()
45
+ const view = renderWithProviders(<Avatar id="user-1" name="Alice" />)
46
+ expect(screen.queryByTestId('avatar-star')).not.toBeInTheDocument()
47
+ view.unmount()
49
48
 
50
- const starredAvatar = renderWithProviders(
51
- <Avatar id="user-1" name="Alice" starred />
52
- )
53
- expect(starredAvatar.container.querySelector('svg')).toBeInTheDocument()
49
+ renderWithProviders(<Avatar id="user-1" name="Alice" starred />)
50
+ expect(screen.getByTestId('avatar-star')).toBeInTheDocument()
54
51
  })
55
52
 
56
53
  it('uses a squircle by default and a circle when explicitly requested', () => {
57
- const squircleAvatar = renderWithProviders(
58
- <Avatar id="user-1" name="Alice" />
59
- )
60
- const squircleElements = squircleAvatar.container.querySelectorAll(
61
- '[style*="border-radius"]'
62
- )
63
- expect(squircleElements).toHaveLength(3)
64
- squircleElements.forEach((element) =>
54
+ // All three layers (frame, ring, clipped inner) have to agree on the
55
+ // radius, or the corners of one show through another.
56
+ const rounded = () =>
57
+ ['avatar', 'avatar-ring', 'avatar-inner'].map((testId) =>
58
+ screen.getByTestId(testId)
59
+ )
60
+
61
+ const view = renderWithProviders(<Avatar id="user-1" name="Alice" />)
62
+ rounded().forEach((element) =>
65
63
  expect(element).toHaveStyle({ borderRadius: '1rem' })
66
64
  )
65
+ view.unmount()
67
66
 
68
- const circleAvatar = renderWithProviders(
69
- <Avatar id="user-1" name="Alice" shape="circle" />
70
- )
71
- const circleElements = circleAvatar.container.querySelectorAll(
72
- '[style*="border-radius"]'
73
- )
74
- expect(circleElements).toHaveLength(3)
75
- circleElements.forEach((element) =>
67
+ renderWithProviders(<Avatar id="user-1" name="Alice" shape="circle" />)
68
+ rounded().forEach((element) =>
76
69
  expect(element).toHaveStyle({ borderRadius: '50%' })
77
70
  )
78
71
  })
79
72
 
80
73
  it('only applies the AI ring when dmAgentEnabled is true', () => {
81
- const defaultAvatar = renderWithProviders(
74
+ const { unmount: unmountPlain } = renderWithProviders(
82
75
  <Avatar id="user-1" name="Alice" size={40} />
83
76
  )
84
- expect(
85
- defaultAvatar.container.querySelector('[data-testid="avatar-ring"]')
86
- ).not.toHaveStyle({
77
+ expect(screen.getByTestId('avatar-ring')).not.toHaveStyle({
87
78
  borderWidth: '2px',
88
79
  borderStyle: 'solid',
89
80
  })
81
+ unmountPlain()
90
82
 
91
- const enabledAvatar = renderWithProviders(
83
+ const { unmount: unmountEnabled } = renderWithProviders(
92
84
  <Avatar id="user-1" name="Alice" size={40} dmAgentEnabled />
93
85
  )
94
- expect(
95
- enabledAvatar.container.querySelector('[data-testid="avatar-ring"]')
96
- ).toHaveStyle({
86
+ expect(screen.getByTestId('avatar-ring')).toHaveStyle({
97
87
  borderWidth: '2px',
98
88
  borderStyle: 'solid',
99
89
  borderColor: 'var(--AI-Gradient, #7F22FE)',
100
90
  boxShadow: 'inset 0 1px 2px 0 rgba(255, 255, 255, 0.5)',
101
91
  })
92
+ unmountEnabled()
102
93
 
103
- const smallEnabledAvatar = renderWithProviders(
94
+ renderWithProviders(
104
95
  <Avatar id="user-1" name="Alice" size={20} dmAgentEnabled />
105
96
  )
106
- expect(
107
- smallEnabledAvatar.container.querySelector('[data-testid="avatar-ring"]')
108
- ).toHaveStyle({ borderWidth: '1px' })
97
+ expect(screen.getByTestId('avatar-ring')).toHaveStyle({
98
+ borderWidth: '1px',
99
+ })
109
100
  })
110
101
 
111
102
  it('renders an image instead of the emoji fallback when image is provided', () => {
112
- const fallbackAvatar = renderWithProviders(<Avatar id="" name="Alice" />)
113
- expect(
114
- fallbackAvatar.container.querySelector('img')
115
- ).not.toBeInTheDocument()
116
- expect(fallbackAvatar.container).toHaveTextContent('🍎')
117
- expect(
118
- fallbackAvatar.container.querySelector('.avatar-fallback')
119
- ).toHaveClass(
120
- 'avatar-fallback',
121
- 'flex',
122
- 'h-full',
123
- 'w-full',
124
- 'items-center',
125
- 'justify-center',
126
- 'font-semibold',
127
- 'select-none',
128
- 'transition-colors'
129
- )
103
+ const view = renderWithProviders(<Avatar id="" name="Alice" />)
104
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
105
+ expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('🍎')
106
+ view.unmount()
130
107
 
131
- const imageAvatar = renderWithProviders(
108
+ renderWithProviders(
132
109
  <Avatar id="" name="Alice" image="https://example.com/avatar.png" />
133
110
  )
134
- expect(imageAvatar.container.querySelector('img')).toHaveAttribute(
111
+ expect(screen.getByRole('img')).toHaveAttribute(
135
112
  'src',
136
113
  'https://example.com/avatar.png'
137
114
  )
138
- expect(imageAvatar.container).not.toHaveTextContent('🍎')
115
+ expect(screen.queryByTestId('avatar-fallback')).not.toBeInTheDocument()
139
116
  })
140
117
 
141
118
  it('applies the fallback background color and forwards custom classes', () => {
142
- const { container } = renderWithProviders(
143
- <Avatar id="" name="Alice" className="custom-avatar" />
144
- )
145
- const root = container.firstElementChild
146
- const ring = container.querySelector('[data-testid="avatar-ring"]')
119
+ renderWithProviders(<Avatar id="" name="Alice" className="custom-avatar" />)
120
+ const root = screen.getByTestId('avatar')
147
121
 
148
- expect(root).toHaveClass('relative', 'flex-shrink-0', 'custom-avatar')
149
- expect(ring).toHaveClass('h-full', 'w-full', 'bg-transparent')
122
+ expect(root).toHaveClass('custom-avatar')
150
123
  expect(root).toHaveStyle({
151
124
  backgroundColor: '#FFD1DD',
152
125
  width: '40px',
@@ -50,12 +50,17 @@ export const Avatar = ({
50
50
  shape === 'circle' ? { borderRadius: '50%' } : { borderRadius: '1rem' }
51
51
 
52
52
  const avatarInner = (
53
- <div className="h-full w-full overflow-hidden" style={borderStyle}>
53
+ <div
54
+ data-testid="avatar-inner"
55
+ className="h-full w-full overflow-hidden"
56
+ style={borderStyle}
57
+ >
54
58
  {image ? (
55
59
  <img src={image} alt="" className="h-full w-full object-cover" />
56
60
  ) : (
57
61
  <div
58
62
  aria-hidden="true"
63
+ data-testid="avatar-fallback"
59
64
  className={classNames(
60
65
  'avatar-fallback flex h-full w-full items-center justify-center font-semibold select-none transition-colors'
61
66
  )}
@@ -69,6 +74,7 @@ export const Avatar = ({
69
74
 
70
75
  return (
71
76
  <div
77
+ data-testid="avatar"
72
78
  className={classNames('relative flex-shrink-0', className)}
73
79
  style={
74
80
  {
@@ -83,6 +89,7 @@ export const Avatar = ({
83
89
  {starred && (
84
90
  <div
85
91
  aria-hidden="true"
92
+ data-testid="avatar-star"
86
93
  className="absolute -left-1.5 -top-1.5 z-10 flex size-5 items-center justify-center rounded-full bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_4px_8px_rgba(0,0,0,0.06)]"
87
94
  >
88
95
  <StarIcon className="size-3 text-yellow-600" weight="duotone" />
@@ -87,7 +87,7 @@ describe('CustomChannelPreview', () => {
87
87
  defaultProps.onChannelSelect.mockClear()
88
88
  })
89
89
 
90
- it('applies selected and unselected row styling from context', () => {
90
+ it('drops the hover affordance on the selected row only', () => {
91
91
  const channel = createMockChannel([])
92
92
  const otherChannel = createMockChannel([])
93
93
  otherChannel.id = 'channel-2'
@@ -105,7 +105,6 @@ describe('CustomChannelPreview', () => {
105
105
  )
106
106
 
107
107
  let row = screen.getByRole('button')
108
- expect(row).toHaveClass('bg-black/[0.04]')
109
108
  expect(row).not.toHaveClass('hover:bg-black/[0.02]')
110
109
 
111
110
  rerender(
@@ -121,7 +120,6 @@ describe('CustomChannelPreview', () => {
121
120
  )
122
121
 
123
122
  row = screen.getByRole('button')
124
- expect(row).not.toHaveClass('bg-black/[0.04]')
125
123
  expect(row).toHaveClass('hover:bg-black/[0.02]')
126
124
 
127
125
  rerender(
@@ -141,12 +139,12 @@ describe('CustomChannelPreview', () => {
141
139
  it('selects the channel by click and activation keys only', () => {
142
140
  const channel = createMockChannel([])
143
141
  const onChannelSelect = vi.fn()
144
- const { container } = renderWithProviders(
142
+ renderWithProviders(
145
143
  <ChannelListProvider value={{ onChannelSelect, debug: false }}>
146
144
  <CustomChannelPreview channel={channel} />
147
145
  </ChannelListProvider>
148
146
  )
149
- const row = container.querySelector('[role="button"]') as HTMLElement
147
+ const row = screen.getByRole('button')
150
148
 
151
149
  fireEvent.click(row)
152
150
  fireEvent.keyDown(row, { key: 'Enter' })
@@ -213,11 +211,11 @@ describe('CustomChannelPreview', () => {
213
211
  image: 'https://example.com/alice.png',
214
212
  }
215
213
 
216
- const { container, rerender } = renderWithProviders(
214
+ const { rerender } = renderWithProviders(
217
215
  <CustomChannelPreview {...defaultProps} channel={channel} />
218
216
  )
219
- expect(container.querySelectorAll('img')).toHaveLength(1)
220
- expect(container.querySelector('img')).toHaveAttribute(
217
+ expect(screen.getAllByRole('img', { hidden: true })).toHaveLength(1)
218
+ expect(screen.getByRole('img', { hidden: true })).toHaveAttribute(
221
219
  'src',
222
220
  'https://example.com/alice.png'
223
221
  )
@@ -229,7 +227,7 @@ describe('CustomChannelPreview', () => {
229
227
  rerender(
230
228
  <CustomChannelPreview {...defaultProps} channel={channel} unread={1} />
231
229
  )
232
- expect(container.querySelectorAll('img')).toHaveLength(0)
230
+ expect(screen.queryAllByRole('img', { hidden: true })).toHaveLength(0)
233
231
  })
234
232
 
235
233
  it.each([
@@ -239,7 +237,7 @@ describe('CustomChannelPreview', () => {
239
237
  [100, '99+'],
240
238
  ])('renders the unread badge for unread=%s', (unread, expectedBadge) => {
241
239
  const channel = createMockChannel([])
242
- const { container } = renderWithProviders(
240
+ renderWithProviders(
243
241
  <CustomChannelPreview
244
242
  {...defaultProps}
245
243
  channel={channel}
@@ -248,10 +246,12 @@ describe('CustomChannelPreview', () => {
248
246
  )
249
247
 
250
248
  if (expectedBadge) {
251
- expect(screen.getByText(expectedBadge)).toBeInTheDocument()
249
+ expect(
250
+ screen.getByTestId('channel-preview-unread-badge')
251
+ ).toHaveTextContent(expectedBadge)
252
252
  } else {
253
253
  expect(
254
- container.querySelector('span[class*="bg-[#7f22fe]"]')
254
+ screen.queryByTestId('channel-preview-unread-badge')
255
255
  ).not.toBeInTheDocument()
256
256
  }
257
257
  })
@@ -267,21 +267,21 @@ describe('CustomChannelPreview', () => {
267
267
  },
268
268
  ])
269
269
 
270
- const { container, rerender } = renderWithProviders(
270
+ const { rerender } = renderWithProviders(
271
271
  <CustomChannelPreview {...defaultProps} channel={channel} />
272
272
  )
273
- const timestamp = () =>
274
- container.querySelector('h3')?.parentElement?.querySelector('span')
275
273
 
276
- expect(timestamp()).toHaveTextContent('Just now')
277
- expect(screen.getByText('Just now')).toBeInTheDocument()
274
+ expect(screen.getByTestId('channel-preview-timestamp')).toHaveTextContent(
275
+ 'Just now'
276
+ )
278
277
 
279
278
  Object.assign(channel.state.messages[0], { created_at: undefined })
280
279
  rerender(
281
280
  <CustomChannelPreview {...defaultProps} channel={channel} unread={1} />
282
281
  )
283
- expect(timestamp()).toBeNull()
284
- expect(screen.queryByText('Just now')).not.toBeInTheDocument()
282
+ expect(
283
+ screen.queryByTestId('channel-preview-timestamp')
284
+ ).not.toBeInTheDocument()
285
285
  })
286
286
 
287
287
  it.each([
@@ -192,7 +192,10 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
192
192
  {participantName}
193
193
  </h3>
194
194
  {lastMessageTime && (
195
- <span className="text-xs text-[#717070] flex-shrink-0">
195
+ <span
196
+ data-testid="channel-preview-timestamp"
197
+ className="text-xs text-[#717070] flex-shrink-0"
198
+ >
196
199
  {lastMessageTime}
197
200
  </span>
198
201
  )}
@@ -204,7 +207,10 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
204
207
  {messagePreview}
205
208
  </p>
206
209
  {unreadCount > 0 && (
207
- <span className="bg-[#7f22fe] text-white text-[10px] rounded-full h-4 flex items-center justify-center p-1 min-w-4 text-center flex-shrink-0">
210
+ <span
211
+ data-testid="channel-preview-unread-badge"
212
+ className="bg-[#7f22fe] text-white text-[10px] rounded-full h-4 flex items-center justify-center p-1 min-w-4 text-center flex-shrink-0"
213
+ >
208
214
  {unreadCount > 99 ? '99+' : unreadCount}
209
215
  </span>
210
216
  )}
@@ -1,4 +1,4 @@
1
- import { act, render, screen } from '@testing-library/react'
1
+ import { render, screen, waitFor } from '@testing-library/react'
2
2
  import React, { useEffect } from 'react'
3
3
  import { Channel as StreamChannel, StreamChat } from 'stream-chat'
4
4
  import { Channel, Chat } from 'stream-chat-react'
@@ -75,42 +75,37 @@ describe('ChannelView outbound resolution', () => {
75
75
  <RowProbe>{messageNode}</RowProbe>
76
76
  )
77
77
 
78
- let rerender!: ReturnType<typeof render>['rerender']
79
- await act(async () => {
80
- rerender = render(
81
- <Chat client={client}>
82
- <Channel channel={channel as StreamChannel}>
83
- <ChannelView
84
- channel={channel as StreamChannel}
85
- resolveOutbound={firstResolver}
86
- renderMessage={renderMessage}
87
- />
88
- </Channel>
89
- </Chat>
90
- ).rerender
91
- })
78
+ const { rerender } = render(
79
+ <Chat client={client}>
80
+ <Channel channel={channel as StreamChannel}>
81
+ <ChannelView
82
+ channel={channel as StreamChannel}
83
+ resolveOutbound={firstResolver}
84
+ renderMessage={renderMessage}
85
+ />
86
+ </Channel>
87
+ </Chat>
88
+ )
92
89
 
90
+ await waitFor(() => expect(mounts).toHaveBeenCalledOnce())
93
91
  expect(
94
92
  screen.queryByTestId('sent-message-outbound-status')
95
93
  ).not.toBeInTheDocument()
96
- expect(mounts).toHaveBeenCalledOnce()
97
94
 
98
- act(() => {
99
- rerender(
100
- <Chat client={client}>
101
- <Channel channel={channel as StreamChannel}>
102
- <ChannelView
103
- channel={channel as StreamChannel}
104
- resolveOutbound={secondResolver}
105
- renderMessage={renderMessage}
106
- />
107
- </Channel>
108
- </Chat>
109
- )
110
- })
95
+ rerender(
96
+ <Chat client={client}>
97
+ <Channel channel={channel as StreamChannel}>
98
+ <ChannelView
99
+ channel={channel as StreamChannel}
100
+ resolveOutbound={secondResolver}
101
+ renderMessage={renderMessage}
102
+ />
103
+ </Channel>
104
+ </Chat>
105
+ )
111
106
 
112
107
  expect(
113
- screen.getByTestId('sent-message-outbound-status')
108
+ await screen.findByTestId('sent-message-outbound-status')
114
109
  ).toHaveTextContent('July 9th Class absentee')
115
110
  expect(mounts).toHaveBeenCalledOnce()
116
111
  expect(unmounts).not.toHaveBeenCalled()
@@ -3,7 +3,7 @@ import React from 'react'
3
3
  import type { Channel } from 'stream-chat'
4
4
  import { describe, expect, it, vi, beforeEach } from 'vitest'
5
5
 
6
- import { renderWithProviders, screen } from '../test/utils'
6
+ import { renderWithProviders, screen, within } from '../test/utils'
7
7
 
8
8
  import { ChannelView } from './ChannelView'
9
9
 
@@ -222,26 +222,22 @@ describe('ChannelView', () => {
222
222
  })
223
223
 
224
224
  it('widens the content column in the official (read-only) channel via the width var', () => {
225
- const { container } = renderWithProviders(
225
+ renderWithProviders(
226
226
  <ChannelView
227
227
  channel={createChannel({ channelType: OFFICIAL_CHANNEL_TYPE })}
228
228
  />
229
229
  )
230
230
 
231
- const root = container.querySelector(
232
- '.messaging-channel-view'
233
- ) as HTMLElement
231
+ const root = screen.getByTestId('channel-view')
234
232
  expect(root.style.getPropertyValue('--lt-msg-card-width')).toBe('368px')
235
233
  })
236
234
 
237
235
  it('leaves the card-width var unset in a regular channel (default widths)', () => {
238
- const { container } = renderWithProviders(
236
+ renderWithProviders(
239
237
  <ChannelView channel={createChannel({ channelType: 'messaging' })} />
240
238
  )
241
239
 
242
- const root = container.querySelector(
243
- '.messaging-channel-view'
244
- ) as HTMLElement
240
+ const root = screen.getByTestId('channel-view')
245
241
  expect(root.style.getPropertyValue('--lt-msg-card-width')).toBe('')
246
242
  })
247
243
 
@@ -266,9 +262,7 @@ describe('ChannelView', () => {
266
262
  ).not.toBeInTheDocument()
267
263
  expect(screen.getAllByText('Linker').length).toBeGreaterThanOrEqual(1)
268
264
 
269
- const avatars = screen.getAllByTestId('avatar')
270
- expect(avatars.length).toBeGreaterThanOrEqual(1)
271
- expect(avatars[0].closest('button')).toBeNull()
265
+ expect(screen.getAllByTestId('avatar').length).toBeGreaterThanOrEqual(1)
272
266
  expect(screen.getByTestId('channel-actions-menu')).toBeInTheDocument()
273
267
  })
274
268
 
@@ -284,16 +278,10 @@ describe('ChannelView', () => {
284
278
  const button = screen.getByRole('button', {
285
279
  name: /view details for linker/i,
286
280
  })
287
- expect(button).toHaveClass(
288
- 'appearance-none',
289
- 'border-0',
290
- 'bg-transparent',
291
- 'flex-col'
292
- )
293
281
 
294
282
  const avatars = screen.getAllByTestId('avatar')
295
283
  expect(avatars.length).toBeGreaterThanOrEqual(1)
296
- expect(avatars[0].closest('button')).toBe(button)
284
+ expect(button).toContainElement(avatars[0])
297
285
  expect(screen.getByTestId('channel-actions-menu')).toBeInTheDocument()
298
286
 
299
287
  button.click()
@@ -346,8 +334,8 @@ describe('ChannelView', () => {
346
334
  )
347
335
 
348
336
  expect(
349
- screen.getByText('Linker').closest('[data-testid="message-list"]')
350
- ).toBeNull()
337
+ within(screen.getByTestId('message-list')).queryByText('Linker')
338
+ ).not.toBeInTheDocument()
351
339
  })
352
340
 
353
341
  it('renders the actions menu by default', () => {
@@ -641,14 +629,14 @@ describe('ChannelView', () => {
641
629
 
642
630
  it('fires onMessageLinkClick with the href and undefined message when the link is not inside a message wrapper', () => {
643
631
  const handler = vi.fn()
644
- const { container } = renderWithProviders(
632
+ renderWithProviders(
645
633
  <ChannelView channel={createChannel()} onMessageLinkClick={handler} />
646
634
  )
647
635
 
648
636
  const anchor = document.createElement('a')
649
637
  anchor.href = 'https://example.com/clicked'
650
638
  anchor.addEventListener('click', (e) => e.preventDefault())
651
- container.querySelector('.messaging-channel-view')?.appendChild(anchor)
639
+ screen.getByTestId('channel-view').appendChild(anchor)
652
640
  anchor.click()
653
641
 
654
642
  expect(handler).toHaveBeenCalledOnce()
@@ -670,7 +658,7 @@ describe('ChannelView', () => {
670
658
  message,
671
659
  ]
672
660
 
673
- const { container } = renderWithProviders(
661
+ renderWithProviders(
674
662
  <ChannelView channel={channel} onMessageLinkClick={handler} />
675
663
  )
676
664
 
@@ -680,7 +668,7 @@ describe('ChannelView', () => {
680
668
  anchor.href = 'https://example.com/clicked'
681
669
  anchor.addEventListener('click', (e) => e.preventDefault())
682
670
  wrapper.appendChild(anchor)
683
- container.querySelector('.messaging-channel-view')?.appendChild(wrapper)
671
+ screen.getByTestId('channel-view').appendChild(wrapper)
684
672
  anchor.click()
685
673
 
686
674
  expect(handler).toHaveBeenCalledOnce()
@@ -689,7 +677,7 @@ describe('ChannelView', () => {
689
677
 
690
678
  it('passes undefined when the wrapper data-message-id does not match any message in state', () => {
691
679
  const handler = vi.fn()
692
- const { container } = renderWithProviders(
680
+ renderWithProviders(
693
681
  <ChannelView channel={createChannel()} onMessageLinkClick={handler} />
694
682
  )
695
683
 
@@ -699,7 +687,7 @@ describe('ChannelView', () => {
699
687
  anchor.href = 'https://example.com/clicked'
700
688
  anchor.addEventListener('click', (e) => e.preventDefault())
701
689
  wrapper.appendChild(anchor)
702
- container.querySelector('.messaging-channel-view')?.appendChild(wrapper)
690
+ screen.getByTestId('channel-view').appendChild(wrapper)
703
691
  anchor.click()
704
692
 
705
693
  expect(handler).toHaveBeenCalledOnce()
@@ -710,14 +698,12 @@ describe('ChannelView', () => {
710
698
  })
711
699
 
712
700
  it('does not throw when a link is clicked without onMessageLinkClick set', () => {
713
- const { container } = renderWithProviders(
714
- <ChannelView channel={createChannel()} />
715
- )
701
+ renderWithProviders(<ChannelView channel={createChannel()} />)
716
702
 
717
703
  const anchor = document.createElement('a')
718
704
  anchor.href = 'https://example.com'
719
705
  anchor.addEventListener('click', (e) => e.preventDefault())
720
- container.querySelector('.messaging-channel-view')?.appendChild(anchor)
706
+ screen.getByTestId('channel-view').appendChild(anchor)
721
707
  expect(() => anchor.click()).not.toThrow()
722
708
  })
723
709
  })
@@ -391,6 +391,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
391
391
  return (
392
392
  <div
393
393
  ref={containerRef}
394
+ data-testid="channel-view"
394
395
  className={classNames(
395
396
  'messaging-channel-view h-full flex flex-col',
396
397
  className
@@ -36,13 +36,16 @@ describe('CustomLinkPreviewList', () => {
36
36
  // alone dropped the hero for the whole ~1.5s debounce + scrape window, so
37
37
  // the skeleton was two bare text bars that then snapped open to the 250px
38
38
  // featured box (MES-1349).
39
- const { container } = stage({ status: LinkPreviewStatus.LOADING })
39
+ stage({ status: LinkPreviewStatus.LOADING })
40
40
 
41
41
  const shell = screen.getByLabelText(LOADING_LABEL)
42
42
  expect(shell).toHaveAttribute('aria-busy', 'true')
43
+ // eslint-disable-next-line local/no-style-only-assertion -- MES-1349: the reserved 250px is the regression itself, and jsdom has no layout to observe the snap-open.
43
44
  expect(shell.className).toContain('h-[250px]')
44
- // Hero placeholder + the two chin bars: without the hero there are two.
45
- expect(container.querySelectorAll('.animate-pulse')).toHaveLength(3)
45
+ // The hero placeholder is the regression guard; the two chin bars stand
46
+ // in for the title and URL either way.
47
+ expect(screen.getByTestId('card-thumbnail-skeleton')).toBeInTheDocument()
48
+ expect(screen.getAllByTestId('card-body-skeleton-bar')).toHaveLength(2)
46
49
  })
47
50
 
48
51
  it('keeps the featured hero once a preview loads with an image', () => {
@@ -68,13 +71,13 @@ describe('CustomLinkPreviewList', () => {
68
71
  // The inverted layout contract — see this component's doc comment. While
69
72
  // this list scrolled and shrank on its own, a staged preview was cropped to
70
73
  // keep the textarea above the keyboard instead of scrolling with it.
71
- const { container } = stage({
74
+ stage({
72
75
  status: LinkPreviewStatus.LOADED,
73
76
  title: 'Example story',
74
77
  image_url: 'https://cdn.example.com/story.jpg',
75
78
  })
76
79
 
77
- const list = container.querySelector('.messaging-link-preview-list')
80
+ const list = screen.getByTestId('link-preview-list')
78
81
  expect(list).not.toHaveClass('overflow-y-auto')
79
82
  expect(list).not.toHaveClass('min-h-0')
80
83
  })
@@ -95,7 +95,10 @@ export const CustomLinkPreviewList = () => {
95
95
  // scrolls sideways instead of spilling; at 320px the dismiss button's right
96
96
  // edge lands ~6px past the fold, reachable by scrolling. That residue
97
97
  // belongs to whoever owns the mobile layout contract, not to this component.
98
- <div className="messaging-link-preview-list flex flex-col items-stretch w-full gap-2">
98
+ <div
99
+ data-testid="link-preview-list"
100
+ className="messaging-link-preview-list flex flex-col items-stretch w-full gap-2"
101
+ >
99
102
  {stateLinkPreviews.map((linkPreview) => {
100
103
  // A LOADING preview has no `image_url` yet, so keying the layout off
101
104
  // the image alone rendered the skeleton as two bare text bars and then