@sanity/sdk 2.18.0 → 2.20.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 (75) hide show
  1. package/dist/_exports/_internal.d.ts +2 -2
  2. package/dist/_exports/_internal.d.ts.map +1 -1
  3. package/dist/_exports/_internal.js +196 -22
  4. package/dist/_exports/_internal.js.map +1 -1
  5. package/dist/authStore-CWTAuieY.js +1072 -0
  6. package/dist/authStore-CWTAuieY.js.map +1 -0
  7. package/dist/clientStore-CMzJ7XhZ.js +168 -0
  8. package/dist/clientStore-CMzJ7XhZ.js.map +1 -0
  9. package/dist/createGroqSearchFilter-B0NdA-EP.js +1222 -0
  10. package/dist/createGroqSearchFilter-B0NdA-EP.js.map +1 -0
  11. package/dist/getEnv-76AjZd_f.js +178 -0
  12. package/dist/getEnv-76AjZd_f.js.map +1 -0
  13. package/dist/index.d.ts +2 -1921
  14. package/dist/index.js +4165 -3711
  15. package/dist/index.js.map +1 -1
  16. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  17. package/dist/telemetryManager-Bd3O8z2S.js +127 -0
  18. package/dist/telemetryManager-Bd3O8z2S.js.map +1 -0
  19. package/dist/utils-BMwA8Kg9.d.ts +3334 -0
  20. package/dist/utils-BMwA8Kg9.d.ts.map +1 -0
  21. package/dist/version-DL2l07HG.js +9 -0
  22. package/dist/version-DL2l07HG.js.map +1 -0
  23. package/package.json +13 -21
  24. package/src/_exports/_internal.ts +1 -0
  25. package/src/_exports/index.ts +37 -1
  26. package/src/comments/addonDatasetStore.test.ts +215 -0
  27. package/src/comments/addonDatasetStore.ts +249 -0
  28. package/src/comments/buildCommentThreads.test.ts +83 -0
  29. package/src/comments/buildCommentThreads.ts +54 -0
  30. package/src/comments/commentActions.test.ts +540 -0
  31. package/src/comments/commentActions.ts +555 -0
  32. package/src/comments/commentFieldPath.test.ts +40 -0
  33. package/src/comments/commentFieldPath.ts +44 -0
  34. package/src/comments/commentsConstants.ts +80 -0
  35. package/src/comments/commentsStore.test.ts +419 -0
  36. package/src/comments/commentsStore.ts +483 -0
  37. package/src/comments/normalizeComment.test.ts +116 -0
  38. package/src/comments/normalizeComment.ts +45 -0
  39. package/src/comments/observeComments.test.ts +193 -0
  40. package/src/comments/observeComments.ts +113 -0
  41. package/src/comments/reducers.test.ts +277 -0
  42. package/src/comments/reducers.ts +316 -0
  43. package/src/comments/types.ts +249 -0
  44. package/src/comments/weakenReferences.test.ts +47 -0
  45. package/src/comments/weakenReferences.ts +29 -0
  46. package/src/document/documentConstants.ts +8 -0
  47. package/src/document/documentStore.concurrency.test.ts +313 -22
  48. package/src/document/documentStore.test.ts +98 -1
  49. package/src/document/documentStore.ts +36 -5
  50. package/src/document/patchOperations.test.ts +27 -0
  51. package/src/document/patchOperations.ts +10 -1
  52. package/src/document/permissions.test.ts +3 -1
  53. package/src/document/reducers.test.ts +133 -0
  54. package/src/document/reducers.ts +18 -3
  55. package/src/document/util.test.ts +46 -0
  56. package/src/document/util.ts +37 -1
  57. package/src/presence/bifurTransport.test.ts +155 -3
  58. package/src/presence/bifurTransport.ts +79 -12
  59. package/src/presence/paths.test.ts +70 -0
  60. package/src/presence/paths.ts +41 -0
  61. package/src/presence/presenceStore.test.ts +822 -2
  62. package/src/presence/presenceStore.ts +518 -102
  63. package/src/presence/types.ts +175 -3
  64. package/src/projection/projectionStore.test.ts +0 -8
  65. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -926
  66. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
  67. package/dist/_chunks-es/_internal.js +0 -133
  68. package/dist/_chunks-es/_internal.js.map +0 -1
  69. package/dist/_chunks-es/createGroqSearchFilter.js +0 -1586
  70. package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
  71. package/dist/_chunks-es/telemetryManager.js +0 -93
  72. package/dist/_chunks-es/telemetryManager.js.map +0 -1
  73. package/dist/_chunks-es/version.js +0 -7
  74. package/dist/_chunks-es/version.js.map +0 -1
  75. package/dist/index.d.ts.map +0 -1
@@ -0,0 +1,80 @@
1
+ import {type ListenOptions} from '@sanity/client'
2
+
3
+ /** Data API version used for reading and writing comments in the addon dataset. */
4
+ export const COMMENTS_API_VERSION = 'v2025-05-06'
5
+
6
+ /** Projects API version used to discover and provision the addon dataset. */
7
+ export const ADDON_DATASET_API_VERSION = 'v2025-02-19'
8
+
9
+ /**
10
+ * The dataset profile the Studio tags its comments dataset with. Discovery
11
+ * matches on it, so changing it stops the SDK and the Studio sharing comments.
12
+ */
13
+ export const ADDON_DATASET_PROFILE = 'comments'
14
+
15
+ /**
16
+ * How long a comment list outlives its last subscriber before being dropped.
17
+ *
18
+ * Long enough that navigating away from a document and straight back reuses the
19
+ * loaded list instead of refetching and re-suspending.
20
+ */
21
+ export const COMMENTS_STATE_CLEAR_DELAY = 5000
22
+
23
+ /**
24
+ * Kept identical to the Studio's own listener options
25
+ * (`packages/sanity/src/core/comments/store/useCommentsStore.ts:29-35`), apart
26
+ * from the request tag. `includeResult` is what lets a mutation event carry the
27
+ * changed comment, which is what makes per-comment reconciliation possible
28
+ * rather than refetching the list.
29
+ */
30
+ export const LISTEN_OPTIONS: ListenOptions = {
31
+ events: ['welcome', 'mutation', 'reconnect'],
32
+ includeResult: true,
33
+ includeAllVersions: true,
34
+ visibility: 'query',
35
+ tag: 'comments.listen',
36
+ }
37
+
38
+ const QUERY_PROJECTION = `{
39
+ _createdAt,
40
+ _id,
41
+ _rev,
42
+ authorId,
43
+ contentSnapshot,
44
+ context,
45
+ lastEditedAt,
46
+ message,
47
+ parentCommentId,
48
+ reactions,
49
+ status,
50
+ target,
51
+ threadId
52
+ }`
53
+
54
+ const BASE_FILTERS = [`_type == "comment"`, `target.document._ref == $documentId`]
55
+ const VERSION_FILTER = `target.documentVersionId == $documentVersionId`
56
+ const NO_VERSION_FILTER = `!defined(target.documentVersionId)`
57
+
58
+ /**
59
+ * Which comments belong to a document, matching the Studio's filter.
60
+ *
61
+ * A release's comments are kept apart from the default ones: asking for a
62
+ * release returns only that release's comments, and asking without one returns
63
+ * only comments with no version at all.
64
+ */
65
+ function buildCommentsFilter(documentVersionId?: string): string {
66
+ return [...BASE_FILTERS, documentVersionId ? VERSION_FILTER : NO_VERSION_FILTER].join(' && ')
67
+ }
68
+
69
+ /** The snapshot query. Newest comment first, as the Studio orders it. */
70
+ export function buildCommentsQuery(documentVersionId?: string): string {
71
+ return `*[${buildCommentsFilter(documentVersionId)}] ${QUERY_PROJECTION} | order(_createdAt desc)`
72
+ }
73
+
74
+ /**
75
+ * The listener query. Unprojected on purpose: mutation events carry the whole
76
+ * comment, so there is nothing to trim.
77
+ */
78
+ export function buildCommentsListenQuery(documentVersionId?: string): string {
79
+ return `*[${buildCommentsFilter(documentVersionId)}]`
80
+ }
@@ -0,0 +1,419 @@
1
+ import {type ListenEvent, type SanityClient} from '@sanity/client'
2
+ import {BehaviorSubject, Subject} from 'rxjs'
3
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
4
+
5
+ import {type DocumentResource} from '../config/sanityConfig'
6
+ import {bindActionByResource} from '../store/createActionBinder'
7
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
8
+ import {observeAddonDatasetClient} from './addonDatasetStore'
9
+ import {
10
+ commentsStore,
11
+ getCommentsOptionsKey,
12
+ getCommentsState,
13
+ getCommentThreadsState,
14
+ parseCommentsOptionsKey,
15
+ resolveComments,
16
+ } from './commentsStore'
17
+ import {setPendingTransaction} from './reducers'
18
+ import {type StoredComment} from './types'
19
+
20
+ vi.mock('./addonDatasetStore', () => ({
21
+ observeAddonDatasetClient: vi.fn(),
22
+ }))
23
+
24
+ function comment(overrides: Partial<StoredComment> & Pick<StoredComment, '_id'>) {
25
+ return {
26
+ _type: 'comment',
27
+ _createdAt: '2026-01-01T00:00:00Z',
28
+ _rev: 'rev',
29
+ authorId: 'user-1',
30
+ message: null,
31
+ threadId: 'thread-1',
32
+ status: 'open',
33
+ reactions: null,
34
+ target: {documentType: 'author', document: {_ref: 'doc-1', _type: 'reference', _weak: true}},
35
+ ...overrides,
36
+ } satisfies StoredComment as StoredComment
37
+ }
38
+
39
+ const WELCOME = {type: 'welcome'} as ListenEvent<StoredComment>
40
+
41
+ /** Lets a test seed a pending transaction the way the write actions will. */
42
+ const markPending = bindActionByResource(
43
+ commentsStore,
44
+ ({state}, options: {resource?: DocumentResource; commentId: string; transactionId: string}) =>
45
+ state.set(
46
+ 'setPendingTransaction',
47
+ setPendingTransaction(options.commentId, options.transactionId),
48
+ ),
49
+ )
50
+
51
+ const HANDLE = {documentId: 'doc-1', documentType: 'author'}
52
+
53
+ let instance: SanityInstance
54
+ let listeners: Map<string, Subject<ListenEvent<StoredComment>>>
55
+ let fetches: Subject<StoredComment[]>[]
56
+ let client$: BehaviorSubject<SanityClient | null>
57
+ let client: SanityClient
58
+
59
+ /**
60
+ * Keyed on query *and* params: the document id travels as a parameter, so two
61
+ * documents share one query string but must not share a listener.
62
+ */
63
+ function listenerFor(query: string, params: Record<string, unknown>) {
64
+ const key = `${query}|${JSON.stringify(params)}`
65
+ const existing = listeners.get(key)
66
+ if (existing) return existing
67
+ const subject = new Subject<ListenEvent<StoredComment>>()
68
+ listeners.set(key, subject)
69
+ return subject
70
+ }
71
+
72
+ beforeEach(() => {
73
+ vi.resetAllMocks()
74
+ listeners = new Map()
75
+ fetches = []
76
+
77
+ client = {
78
+ observable: {
79
+ listen: vi.fn((query: string, params: Record<string, unknown>) => listenerFor(query, params)),
80
+ fetch: vi.fn(() => {
81
+ const fetch$ = new Subject<StoredComment[]>()
82
+ fetches.push(fetch$)
83
+ return fetch$
84
+ }),
85
+ },
86
+ } as unknown as SanityClient
87
+
88
+ client$ = new BehaviorSubject<SanityClient | null>(client)
89
+ vi.mocked(observeAddonDatasetClient).mockReturnValue(client$)
90
+
91
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
92
+ })
93
+
94
+ afterEach(() => {
95
+ instance.dispose()
96
+ })
97
+
98
+ describe('getCommentsOptionsKey', () => {
99
+ it('round-trips the options it is given', () => {
100
+ const options = {
101
+ ...HANDLE,
102
+ perspective: {releaseName: 'summer'},
103
+ fieldPath: 'title',
104
+ status: 'resolved' as const,
105
+ }
106
+
107
+ expect(parseCommentsOptionsKey(getCommentsOptionsKey(options))).toEqual(options)
108
+ })
109
+
110
+ it('gives a path array and its string form the same key', () => {
111
+ expect(
112
+ getCommentsOptionsKey({...HANDLE, fieldPath: ['body', {_key: 'intro'}, 'content']}),
113
+ ).toBe(getCommentsOptionsKey({...HANDLE, fieldPath: 'body[_key=="intro"].content'}))
114
+ })
115
+
116
+ it('separates lists that differ only by filter', () => {
117
+ const keys = new Set([
118
+ getCommentsOptionsKey(HANDLE),
119
+ getCommentsOptionsKey({...HANDLE, fieldPath: ''}),
120
+ getCommentsOptionsKey({...HANDLE, fieldPath: 'title'}),
121
+ getCommentsOptionsKey({...HANDLE, status: 'open'}),
122
+ getCommentsOptionsKey({...HANDLE, documentId: 'doc-2'}),
123
+ ])
124
+
125
+ expect(keys.size).toBe(5)
126
+ })
127
+ })
128
+
129
+ describe('getCommentsState', () => {
130
+ it('is undefined until the first snapshot arrives', () => {
131
+ const source = getCommentsState(instance, HANDLE)
132
+ source.subscribe()
133
+
134
+ expect(source.getCurrent()).toBe(undefined)
135
+ })
136
+
137
+ it('loads a document’s comments once someone reads them', () => {
138
+ const source = getCommentsState(instance, HANDLE)
139
+ source.subscribe()
140
+
141
+ listeners.values().next().value!.next(WELCOME)
142
+ fetches[0].next([comment({_id: 'a'})])
143
+
144
+ // Asserted in full rather than by id. This is the entire mapping from the
145
+ // stored document to what a consumer sees, and it is what has to stay put
146
+ // when comments move off the addon dataset.
147
+ expect(source.getCurrent()).toEqual([
148
+ {
149
+ id: 'a',
150
+ createdAt: '2026-01-01T00:00:00Z',
151
+ authorId: 'user-1',
152
+ message: null,
153
+ threadId: 'thread-1',
154
+ status: 'open',
155
+ documentId: 'doc-1',
156
+ documentType: 'author',
157
+ fieldPath: '',
158
+ reactions: [],
159
+ },
160
+ ])
161
+ })
162
+
163
+ it('sorts newest first', () => {
164
+ const source = getCommentsState(instance, HANDLE)
165
+ source.subscribe()
166
+
167
+ const older = comment({_id: 'a', _createdAt: '2026-01-01T00:00:00Z'})
168
+ const newer = comment({_id: 'b', _createdAt: '2026-02-01T00:00:00Z'})
169
+
170
+ listeners.values().next().value!.next(WELCOME)
171
+ fetches[0].next([older, newer])
172
+
173
+ expect(source.getCurrent()!.map((c) => c.id)).toEqual(['b', 'a'])
174
+ })
175
+
176
+ it('settles on an empty list when the project has no comments dataset', () => {
177
+ // Leaving this unset would suspend readers forever on a project nobody has
178
+ // ever commented in.
179
+ client$.next(null)
180
+
181
+ const source = getCommentsState(instance, HANDLE)
182
+ source.subscribe()
183
+
184
+ expect(source.getCurrent()).toEqual([])
185
+ })
186
+
187
+ it('filters by field path', () => {
188
+ const source = getCommentsState(instance, {...HANDLE, fieldPath: 'title'})
189
+ source.subscribe()
190
+
191
+ const onField = comment({
192
+ _id: 'a',
193
+ target: {
194
+ documentType: 'author',
195
+ path: {field: 'title'},
196
+ document: {_ref: 'doc-1', _type: 'reference', _weak: true},
197
+ },
198
+ })
199
+
200
+ listeners.values().next().value!.next(WELCOME)
201
+ fetches[0].next([onField, comment({_id: 'b'})])
202
+
203
+ expect(source.getCurrent()!.map((c) => c.id)).toEqual(['a'])
204
+ })
205
+
206
+ it('matches the field path exactly rather than by prefix', () => {
207
+ // Nothing writes an empty path any more, but the filter is still an exact
208
+ // match, so it must not sweep up every comment on the document.
209
+ const source = getCommentsState(instance, {...HANDLE, fieldPath: ''})
210
+ source.subscribe()
211
+
212
+ const onField = comment({
213
+ _id: 'a',
214
+ target: {
215
+ documentType: 'author',
216
+ path: {field: 'title'},
217
+ document: {_ref: 'doc-1', _type: 'reference', _weak: true},
218
+ },
219
+ })
220
+
221
+ listeners.values().next().value!.next(WELCOME)
222
+ fetches[0].next([onField, comment({_id: 'b'})])
223
+
224
+ expect(source.getCurrent()!.map((c) => c.id)).toEqual(['b'])
225
+ })
226
+
227
+ it('filters by status', () => {
228
+ const source = getCommentsState(instance, {...HANDLE, status: 'resolved'})
229
+ source.subscribe()
230
+
231
+ listeners.values().next().value!.next(WELCOME)
232
+ fetches[0].next([comment({_id: 'a'}), comment({_id: 'b', status: 'resolved'})])
233
+
234
+ expect(source.getCurrent()!.map((c) => c.id)).toEqual(['b'])
235
+ })
236
+
237
+ it('keeps a release’s comments apart from the default ones', () => {
238
+ const base = getCommentsState(instance, HANDLE)
239
+ const release = getCommentsState(instance, {
240
+ ...HANDLE,
241
+ perspective: {releaseName: 'summer'},
242
+ })
243
+ base.subscribe()
244
+ release.subscribe()
245
+
246
+ expect(listeners.size).toBe(2)
247
+
248
+ const [baseListener, releaseListener] = Array.from(listeners.values())
249
+ baseListener.next(WELCOME)
250
+ releaseListener.next(WELCOME)
251
+ fetches[0].next([comment({_id: 'a'})])
252
+ fetches[1].next([comment({_id: 'b'})])
253
+
254
+ expect(base.getCurrent()!.map((c) => c.id)).toEqual(['a'])
255
+ expect(release.getCurrent()!.map((c) => c.id)).toEqual(['b'])
256
+ })
257
+
258
+ it('shares one list between a draft and its published document', () => {
259
+ getCommentsState(instance, HANDLE).subscribe()
260
+ getCommentsState(instance, {...HANDLE, documentId: 'drafts.doc-1'}).subscribe()
261
+
262
+ expect(listeners.size).toBe(1)
263
+ })
264
+ })
265
+
266
+ describe('transaction reconciliation', () => {
267
+ function updateEvent(transactionId: string, status: StoredComment['status']) {
268
+ return {
269
+ type: 'mutation',
270
+ transition: 'update',
271
+ documentId: 'a',
272
+ result: comment({_id: 'a', status}),
273
+ transactionId,
274
+ } as ListenEvent<StoredComment>
275
+ }
276
+
277
+ function loaded() {
278
+ const source = getCommentsState(instance, HANDLE)
279
+ source.subscribe()
280
+ listeners.values().next().value!.next(WELCOME)
281
+ fetches[0].next([comment({_id: 'a'})])
282
+ return source
283
+ }
284
+
285
+ it('ignores an echo from a superseded transaction', () => {
286
+ const source = loaded()
287
+ markPending(instance, {commentId: 'a', transactionId: 'tx-2'})
288
+
289
+ listeners.values().next().value!.next(updateEvent('tx-1', 'resolved'))
290
+
291
+ expect(source.getCurrent()![0].status).toBe('open')
292
+ })
293
+
294
+ it('applies the echo we were waiting for', () => {
295
+ const source = loaded()
296
+ markPending(instance, {commentId: 'a', transactionId: 'tx-2'})
297
+
298
+ listeners.values().next().value!.next(updateEvent('tx-2', 'resolved'))
299
+
300
+ expect(source.getCurrent()![0].status).toBe('resolved')
301
+ })
302
+
303
+ it('applies updates from other clients when nothing is pending', () => {
304
+ const source = loaded()
305
+
306
+ listeners.values().next().value!.next(updateEvent('tx-elsewhere', 'resolved'))
307
+
308
+ expect(source.getCurrent()![0].status).toBe('resolved')
309
+ })
310
+ })
311
+
312
+ describe('getCommentThreadsState', () => {
313
+ it('groups comments into threads', () => {
314
+ const source = getCommentThreadsState(instance, HANDLE)
315
+ source.subscribe()
316
+
317
+ listeners.values().next().value!.next(WELCOME)
318
+ fetches[0].next([comment({_id: 'a'}), comment({_id: 'b', parentCommentId: 'a'})])
319
+
320
+ expect(source.getCurrent()).toMatchObject([{threadId: 'thread-1', commentsCount: 2}])
321
+ })
322
+
323
+ it('filters by the parent status without dropping replies', () => {
324
+ const source = getCommentThreadsState(instance, {...HANDLE, status: 'resolved'})
325
+ source.subscribe()
326
+
327
+ listeners.values().next().value!.next(WELCOME)
328
+ fetches[0].next([
329
+ comment({_id: 'parent', status: 'resolved'}),
330
+ comment({_id: 'reply', parentCommentId: 'parent', status: 'open'}),
331
+ ])
332
+
333
+ expect(source.getCurrent()).toMatchObject([
334
+ {
335
+ parentComment: {id: 'parent'},
336
+ replies: [{id: 'reply'}],
337
+ commentsCount: 2,
338
+ },
339
+ ])
340
+ })
341
+
342
+ it('returns the same array on repeated reads', () => {
343
+ // A fresh array from one read to the next would make useSyncExternalStore
344
+ // re-render without end.
345
+ const source = getCommentThreadsState(instance, HANDLE)
346
+ source.subscribe()
347
+
348
+ listeners.values().next().value!.next(WELCOME)
349
+ fetches[0].next([comment({_id: 'a'})])
350
+
351
+ expect(source.getCurrent()).toBe(source.getCurrent())
352
+ })
353
+
354
+ it('returns the same array when an unrelated document loads', () => {
355
+ // Every state change re-runs the selector, so without a cache keyed on the
356
+ // data, one document loading would re-render readers of every other one.
357
+ const source = getCommentThreadsState(instance, HANDLE)
358
+ source.subscribe()
359
+
360
+ const [ownListener] = Array.from(listeners.values())
361
+ ownListener.next(WELCOME)
362
+ fetches[0].next([comment({_id: 'a'})])
363
+ const before = source.getCurrent()
364
+
365
+ const other = getCommentThreadsState(instance, {...HANDLE, documentId: 'doc-2'})
366
+ other.subscribe()
367
+ Array.from(listeners.values())[1].next(WELCOME)
368
+ fetches[1].next([comment({_id: 'b'})])
369
+
370
+ expect(source.getCurrent()).toBe(before)
371
+ })
372
+ })
373
+
374
+ describe('resolveComments', () => {
375
+ it('starts loading and resolves with the snapshot', async () => {
376
+ const promise = resolveComments(instance, HANDLE)
377
+
378
+ await vi.waitFor(() => expect(listeners.size).toBe(1))
379
+ listeners.values().next().value!.next(WELCOME)
380
+ fetches[0].next([comment({_id: 'a'})])
381
+
382
+ await expect(promise).resolves.toMatchObject([{id: 'a'}])
383
+ })
384
+
385
+ it('rejects when aborted', async () => {
386
+ const controller = new AbortController()
387
+ const promise = resolveComments(instance, {...HANDLE, signal: controller.signal})
388
+
389
+ controller.abort()
390
+
391
+ await expect(promise).rejects.toThrow(/aborted/)
392
+ })
393
+
394
+ it('tears the listener down when an abort leaves no readers', async () => {
395
+ const controller = new AbortController()
396
+ const promise = resolveComments(instance, {...HANDLE, signal: controller.signal})
397
+ await vi.waitFor(() => expect(listeners.size).toBe(1))
398
+
399
+ controller.abort()
400
+ await expect(promise).rejects.toThrow()
401
+
402
+ expect(listeners.values().next().value!.observed).toBe(false)
403
+ })
404
+ })
405
+
406
+ describe('listener recovery', () => {
407
+ it('starts listening again when the addon client changes after a listener error', () => {
408
+ const source = getCommentsState(instance, HANDLE)
409
+ source.subscribe()
410
+
411
+ const failedListener = listeners.values().next().value!
412
+ failedListener.error(new Error('connection failed'))
413
+ listeners.clear()
414
+
415
+ client$.next(client)
416
+
417
+ expect(listeners.size).toBe(1)
418
+ })
419
+ })