@sanity/sdk 2.18.0 → 2.19.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.
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts +8 -11
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
- package/dist/_chunks-es/_internal.js +1 -0
- package/dist/_chunks-es/_internal.js.map +1 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +8 -4
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- package/dist/_chunks-es/telemetryManager.js +2 -2
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/index.d.ts +197 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +286 -102
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/_exports/index.ts +7 -1
- package/src/document/documentStore.concurrency.test.ts +313 -22
- package/src/document/util.test.ts +46 -0
- package/src/document/util.ts +37 -1
- package/src/presence/bifurTransport.test.ts +155 -3
- package/src/presence/bifurTransport.ts +79 -12
- package/src/presence/paths.test.ts +70 -0
- package/src/presence/paths.ts +41 -0
- package/src/presence/presenceStore.test.ts +822 -2
- package/src/presence/presenceStore.ts +518 -102
- package/src/presence/types.ts +175 -3
- package/dist/_chunks-es/version.js +0 -7
- package/dist/_chunks-es/version.js.map +0 -1
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import {type SanityClient} from '@sanity/client'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
BehaviorSubject,
|
|
4
|
+
delay,
|
|
5
|
+
firstValueFrom,
|
|
6
|
+
type Observable,
|
|
7
|
+
of,
|
|
8
|
+
Subject,
|
|
9
|
+
throwError,
|
|
10
|
+
} from 'rxjs'
|
|
3
11
|
import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
|
|
4
12
|
|
|
5
13
|
import {getTokenState} from '../auth/authStore'
|
|
@@ -8,7 +16,7 @@ import {createSanityInstance, type SanityInstance} from '../store/createSanityIn
|
|
|
8
16
|
import {type SanityUser} from '../users/types'
|
|
9
17
|
import {getUserState} from '../users/usersStore'
|
|
10
18
|
import {createBifurTransport} from './bifurTransport'
|
|
11
|
-
import {getPresence} from './presenceStore'
|
|
19
|
+
import {getDocumentPresence, getPresence, reportPresence} from './presenceStore'
|
|
12
20
|
import {type PresenceLocation, type TransportEvent, type TransportMessage} from './types'
|
|
13
21
|
|
|
14
22
|
vi.mock('../auth/authStore')
|
|
@@ -22,6 +30,8 @@ describe('presenceStore', () => {
|
|
|
22
30
|
let mockTokenState: Subject<string | null>
|
|
23
31
|
let mockIncomingEvents: Subject<TransportEvent>
|
|
24
32
|
let mockDispatchMessage: Mock<(message: TransportMessage) => Observable<void>>
|
|
33
|
+
let mockConnections: BehaviorSubject<number>
|
|
34
|
+
let mockUnload: Subject<void>
|
|
25
35
|
let mockGetUserState: Mock<typeof getUserState>
|
|
26
36
|
|
|
27
37
|
const mockUser: SanityUser = {
|
|
@@ -64,9 +74,16 @@ describe('presenceStore', () => {
|
|
|
64
74
|
subscribe: vi.fn(),
|
|
65
75
|
})
|
|
66
76
|
|
|
77
|
+
// A live connection on subscribe, mirroring `bifur.heartbeats`, which emits
|
|
78
|
+
// as soon as the socket is open and authorized.
|
|
79
|
+
mockConnections = new BehaviorSubject<number>(1)
|
|
80
|
+
mockUnload = new Subject<void>()
|
|
81
|
+
|
|
67
82
|
vi.mocked(createBifurTransport).mockReturnValue([
|
|
68
83
|
mockIncomingEvents.asObservable(),
|
|
69
84
|
mockDispatchMessage,
|
|
85
|
+
mockConnections.asObservable(),
|
|
86
|
+
mockUnload.asObservable(),
|
|
70
87
|
])
|
|
71
88
|
|
|
72
89
|
mockGetUserState = vi.fn(() => of(mockUser))
|
|
@@ -339,5 +356,808 @@ describe('presenceStore', () => {
|
|
|
339
356
|
|
|
340
357
|
unsubscribe()
|
|
341
358
|
})
|
|
359
|
+
|
|
360
|
+
it('resolves no users when the canvas organization lookup fails', async () => {
|
|
361
|
+
// Regression guard: this used to be swallowed with `catchError(() => EMPTY)`
|
|
362
|
+
// and consumed with `first()`, so a failed request meant the user stream
|
|
363
|
+
// never emitted and every participant stayed "Unknown user" forever.
|
|
364
|
+
mockClient = {
|
|
365
|
+
withConfig: vi.fn().mockReturnThis(),
|
|
366
|
+
observable: {
|
|
367
|
+
request: vi.fn(() => throwError(() => new Error('canvas lookup failed'))),
|
|
368
|
+
},
|
|
369
|
+
} as unknown as SanityClient
|
|
370
|
+
vi.mocked(getClient).mockReturnValue(mockClient)
|
|
371
|
+
|
|
372
|
+
const source = getPresence(instance, {resource: {canvasId: 'canvas123'}})
|
|
373
|
+
const unsubscribe = source.subscribe(() => {})
|
|
374
|
+
|
|
375
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
376
|
+
|
|
377
|
+
mockIncomingEvents.next({
|
|
378
|
+
type: 'state',
|
|
379
|
+
userId: 'user-1',
|
|
380
|
+
sessionId: 'other-session',
|
|
381
|
+
timestamp: '2023-01-01T12:00:00Z',
|
|
382
|
+
locations: [],
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
await firstValueFrom(of(null).pipe(delay(50)))
|
|
386
|
+
|
|
387
|
+
// The presence itself still surfaces, with a placeholder user.
|
|
388
|
+
const presence = source.getCurrent()
|
|
389
|
+
expect(presence).toHaveLength(1)
|
|
390
|
+
expect(presence[0].user.profile.displayName).toBe('Unknown user')
|
|
391
|
+
// No doomed request is made without an organization to scope it to.
|
|
392
|
+
expect(getUserState).not.toHaveBeenCalled()
|
|
393
|
+
|
|
394
|
+
unsubscribe()
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('gives an unresolved user a structurally valid profile', async () => {
|
|
398
|
+
// The placeholder used to be cast through `as unknown as SanityUser` with
|
|
399
|
+
// no `profile`, so reading `user.profile.displayName` threw on first render.
|
|
400
|
+
mockGetUserState = vi.fn(() => of(undefined))
|
|
401
|
+
vi.mocked(getUserState).mockImplementation(mockGetUserState)
|
|
402
|
+
|
|
403
|
+
const source = getPresence(instance)
|
|
404
|
+
const unsubscribe = source.subscribe(() => {})
|
|
405
|
+
|
|
406
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
407
|
+
|
|
408
|
+
mockIncomingEvents.next({
|
|
409
|
+
type: 'state',
|
|
410
|
+
userId: 'user-1',
|
|
411
|
+
sessionId: 'other-session',
|
|
412
|
+
timestamp: '2023-01-01T12:00:00Z',
|
|
413
|
+
locations: [],
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
await firstValueFrom(of(null).pipe(delay(20)))
|
|
417
|
+
|
|
418
|
+
const [presence] = source.getCurrent()
|
|
419
|
+
expect(presence.user.profile.displayName).toBe('Unknown user')
|
|
420
|
+
expect(presence.user.profile.id).toBe('user-1')
|
|
421
|
+
expect(presence.user.sanityUserId).toBe('user-1')
|
|
422
|
+
expect(presence.user.memberships).toEqual([])
|
|
423
|
+
|
|
424
|
+
unsubscribe()
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
describe('getDocumentPresence', () => {
|
|
429
|
+
/** Puts a peer in a document at the given paths. */
|
|
430
|
+
const peerAt = async (
|
|
431
|
+
sessionId: string,
|
|
432
|
+
locations: {documentId: string; path?: unknown[]; selection?: unknown}[],
|
|
433
|
+
) => {
|
|
434
|
+
mockIncomingEvents.next({
|
|
435
|
+
type: 'state',
|
|
436
|
+
userId: 'user-1',
|
|
437
|
+
sessionId,
|
|
438
|
+
timestamp: '2026-07-30T12:00:00Z',
|
|
439
|
+
locations: locations.map((l) => ({
|
|
440
|
+
type: 'document',
|
|
441
|
+
documentId: l.documentId,
|
|
442
|
+
path: (l.path ?? []) as string[],
|
|
443
|
+
lastActiveAt: '2026-07-30T12:00:00Z',
|
|
444
|
+
...(l.selection === undefined ? {} : {selection: l.selection}),
|
|
445
|
+
})) as never,
|
|
446
|
+
})
|
|
447
|
+
await firstValueFrom(of(null).pipe(delay(20)))
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
it('flattens one entry per participant per location', async () => {
|
|
451
|
+
const source = getDocumentPresence(instance, {documentId: 'movie-1'})
|
|
452
|
+
const unsubscribe = source.subscribe(() => {})
|
|
453
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
454
|
+
|
|
455
|
+
await peerAt('peer-a', [
|
|
456
|
+
{documentId: 'movie-1', path: ['title']},
|
|
457
|
+
{documentId: 'movie-1', path: ['body']},
|
|
458
|
+
])
|
|
459
|
+
|
|
460
|
+
const presence = source.getCurrent()
|
|
461
|
+
expect(presence).toHaveLength(2)
|
|
462
|
+
expect(presence.map((p) => p.path)).toEqual([['title'], ['body']])
|
|
463
|
+
expect(presence.every((p) => p.sessionId === 'peer-a')).toBe(true)
|
|
464
|
+
|
|
465
|
+
unsubscribe()
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('treats a draft, its published version, and a release version as one document', async () => {
|
|
469
|
+
const source = getDocumentPresence(instance, {documentId: 'movie-1'})
|
|
470
|
+
const unsubscribe = source.subscribe(() => {})
|
|
471
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
472
|
+
|
|
473
|
+
await peerAt('peer-a', [{documentId: 'drafts.movie-1'}])
|
|
474
|
+
await peerAt('peer-b', [{documentId: 'versions.r1.movie-1'}])
|
|
475
|
+
await peerAt('peer-c', [{documentId: 'movie-1'}])
|
|
476
|
+
|
|
477
|
+
// What a document list wants: someone is in this document, wherever exactly.
|
|
478
|
+
expect(source.getCurrent()).toHaveLength(3)
|
|
479
|
+
|
|
480
|
+
unsubscribe()
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it('compares ids exactly when excludeVersions is set', async () => {
|
|
484
|
+
const source = getDocumentPresence(instance, {
|
|
485
|
+
documentId: 'drafts.movie-1',
|
|
486
|
+
excludeVersions: true,
|
|
487
|
+
})
|
|
488
|
+
const unsubscribe = source.subscribe(() => {})
|
|
489
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
490
|
+
|
|
491
|
+
await peerAt('peer-a', [{documentId: 'drafts.movie-1'}])
|
|
492
|
+
await peerAt('peer-b', [{documentId: 'versions.r1.movie-1'}])
|
|
493
|
+
|
|
494
|
+
// What a field indicator wants: a release version must not bleed into the
|
|
495
|
+
// draft the user is actually looking at.
|
|
496
|
+
const presence = source.getCurrent()
|
|
497
|
+
expect(presence).toHaveLength(1)
|
|
498
|
+
expect(presence[0].documentId).toBe('drafts.movie-1')
|
|
499
|
+
|
|
500
|
+
unsubscribe()
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it('matches a peer in the draft when queried with a published id and excludeVersions', async () => {
|
|
504
|
+
// The field-indicator case, and the one that only works if the read side
|
|
505
|
+
// resolves the perspective exactly as the write side does. `excludeVersions`
|
|
506
|
+
// turns off published-id normalization, so an unresolved query for `movie-1`
|
|
507
|
+
// would never match a peer reporting `drafts.movie-1`.
|
|
508
|
+
const source = getDocumentPresence(instance, {
|
|
509
|
+
documentId: 'movie-1',
|
|
510
|
+
excludeVersions: true,
|
|
511
|
+
})
|
|
512
|
+
const unsubscribe = source.subscribe(() => {})
|
|
513
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
514
|
+
|
|
515
|
+
await peerAt('peer-a', [{documentId: 'drafts.movie-1', path: ['title']}])
|
|
516
|
+
|
|
517
|
+
const presence = source.getCurrent()
|
|
518
|
+
expect(presence).toHaveLength(1)
|
|
519
|
+
expect(presence[0].documentId).toBe('drafts.movie-1')
|
|
520
|
+
|
|
521
|
+
unsubscribe()
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('narrows to a field subtree, including the field itself', async () => {
|
|
525
|
+
const source = getDocumentPresence(instance, {documentId: 'movie-1', path: ['body']})
|
|
526
|
+
const unsubscribe = source.subscribe(() => {})
|
|
527
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
528
|
+
|
|
529
|
+
await peerAt('peer-a', [
|
|
530
|
+
{documentId: 'movie-1', path: ['title']},
|
|
531
|
+
{documentId: 'movie-1', path: ['body']},
|
|
532
|
+
{documentId: 'movie-1', path: ['body', {_key: 'b1'}, 'children']},
|
|
533
|
+
])
|
|
534
|
+
|
|
535
|
+
const presence = source.getCurrent()
|
|
536
|
+
expect(presence).toHaveLength(2)
|
|
537
|
+
expect(presence.map((p) => p.path)).toEqual([['body'], ['body', {_key: 'b1'}, 'children']])
|
|
538
|
+
|
|
539
|
+
unsubscribe()
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('matches keyed path segments by key, not by identity', async () => {
|
|
543
|
+
const source = getDocumentPresence(instance, {
|
|
544
|
+
documentId: 'movie-1',
|
|
545
|
+
// A different object with the same key, which is what arrives over the wire.
|
|
546
|
+
path: ['cast', {_key: 'member-2'}],
|
|
547
|
+
})
|
|
548
|
+
const unsubscribe = source.subscribe(() => {})
|
|
549
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
550
|
+
|
|
551
|
+
await peerAt('peer-a', [
|
|
552
|
+
{documentId: 'movie-1', path: ['cast', {_key: 'member-1'}, 'name']},
|
|
553
|
+
{documentId: 'movie-1', path: ['cast', {_key: 'member-2'}, 'name']},
|
|
554
|
+
])
|
|
555
|
+
|
|
556
|
+
const presence = source.getCurrent()
|
|
557
|
+
expect(presence).toHaveLength(1)
|
|
558
|
+
expect(presence[0].path).toEqual(['cast', {_key: 'member-2'}, 'name'])
|
|
559
|
+
|
|
560
|
+
unsubscribe()
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('preserves a Portable Text selection', async () => {
|
|
564
|
+
const selection = {
|
|
565
|
+
anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 2},
|
|
566
|
+
focus: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 9},
|
|
567
|
+
backward: false,
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const source = getDocumentPresence(instance, {documentId: 'movie-1'})
|
|
571
|
+
const unsubscribe = source.subscribe(() => {})
|
|
572
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
573
|
+
|
|
574
|
+
await peerAt('peer-a', [{documentId: 'movie-1', path: ['body'], selection}])
|
|
575
|
+
|
|
576
|
+
expect(source.getCurrent()[0].selection).toEqual(selection)
|
|
577
|
+
|
|
578
|
+
unsubscribe()
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it('omits selection when the participant did not report one', async () => {
|
|
582
|
+
const source = getDocumentPresence(instance, {documentId: 'movie-1'})
|
|
583
|
+
const unsubscribe = source.subscribe(() => {})
|
|
584
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
585
|
+
|
|
586
|
+
await peerAt('peer-a', [{documentId: 'movie-1', path: ['title']}])
|
|
587
|
+
|
|
588
|
+
expect('selection' in source.getCurrent()[0]).toBe(false)
|
|
589
|
+
|
|
590
|
+
unsubscribe()
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('returns a referentially stable snapshot', async () => {
|
|
594
|
+
const source = getDocumentPresence(instance, {documentId: 'movie-1'})
|
|
595
|
+
const unsubscribe = source.subscribe(() => {})
|
|
596
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
597
|
+
await peerAt('peer-a', [{documentId: 'movie-1', path: ['title']}])
|
|
598
|
+
|
|
599
|
+
// React calls `getCurrent` as its `useSyncExternalStore` snapshot on every
|
|
600
|
+
// render. Returning a fresh array each call sends it into an infinite
|
|
601
|
+
// render loop, so this is load-bearing rather than a micro-optimisation.
|
|
602
|
+
expect(source.getCurrent()).toBe(source.getCurrent())
|
|
603
|
+
|
|
604
|
+
unsubscribe()
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
it('keeps separate snapshots per query, so callers do not evict each other', async () => {
|
|
608
|
+
const movie = getDocumentPresence(instance, {documentId: 'movie-1'})
|
|
609
|
+
const other = getDocumentPresence(instance, {documentId: 'movie-2'})
|
|
610
|
+
const titleOnly = getDocumentPresence(instance, {documentId: 'movie-1', path: ['title']})
|
|
611
|
+
const subs = [movie, other, titleOnly].map((s) => s.subscribe(() => {}))
|
|
612
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
613
|
+
|
|
614
|
+
await peerAt('peer-a', [
|
|
615
|
+
{documentId: 'movie-1', path: ['title']},
|
|
616
|
+
{documentId: 'movie-1', path: ['body']},
|
|
617
|
+
])
|
|
618
|
+
|
|
619
|
+
// Interleaved reads, which is what concurrent components do.
|
|
620
|
+
const movieFirst = movie.getCurrent()
|
|
621
|
+
const titleFirst = titleOnly.getCurrent()
|
|
622
|
+
expect(movie.getCurrent()).toBe(movieFirst)
|
|
623
|
+
expect(titleOnly.getCurrent()).toBe(titleFirst)
|
|
624
|
+
|
|
625
|
+
expect(movieFirst).toHaveLength(2)
|
|
626
|
+
expect(titleFirst).toHaveLength(1)
|
|
627
|
+
expect(other.getCurrent()).toEqual([])
|
|
628
|
+
|
|
629
|
+
subs.forEach((unsubscribe) => unsubscribe())
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
it('resolves display names without anything else subscribing first', async () => {
|
|
633
|
+
// Regression guard: user resolution used to hang off `getPresence`'s
|
|
634
|
+
// `onSubscribe`, so reading presence any other way showed only
|
|
635
|
+
// "Unknown user". It belongs to the store, not to one state source.
|
|
636
|
+
const source = getDocumentPresence(instance, {documentId: 'movie-1'})
|
|
637
|
+
const unsubscribe = source.subscribe(() => {})
|
|
638
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
639
|
+
|
|
640
|
+
await peerAt('peer-a', [{documentId: 'movie-1', path: ['title']}])
|
|
641
|
+
await firstValueFrom(of(null).pipe(delay(50)))
|
|
642
|
+
|
|
643
|
+
expect(getUserState).toHaveBeenCalledWith(instance, {
|
|
644
|
+
userId: 'user-1',
|
|
645
|
+
resourceType: 'project',
|
|
646
|
+
projectId: 'test-project',
|
|
647
|
+
})
|
|
648
|
+
expect(source.getCurrent()[0].user.profile.displayName).toBe('Test User')
|
|
649
|
+
|
|
650
|
+
unsubscribe()
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
it('ignores other documents entirely', async () => {
|
|
654
|
+
const source = getDocumentPresence(instance, {documentId: 'movie-1'})
|
|
655
|
+
const unsubscribe = source.subscribe(() => {})
|
|
656
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
657
|
+
|
|
658
|
+
await peerAt('peer-a', [{documentId: 'movie-2', path: ['title']}])
|
|
659
|
+
|
|
660
|
+
expect(source.getCurrent()).toEqual([])
|
|
661
|
+
|
|
662
|
+
unsubscribe()
|
|
663
|
+
})
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
describe('connection lifecycle', () => {
|
|
667
|
+
it('clears accumulated presence and re-rollCalls when the socket reconnects', async () => {
|
|
668
|
+
const source = getPresence(instance)
|
|
669
|
+
const unsubscribe = source.subscribe(() => {})
|
|
670
|
+
|
|
671
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
672
|
+
|
|
673
|
+
mockIncomingEvents.next({
|
|
674
|
+
type: 'state',
|
|
675
|
+
userId: 'user-1',
|
|
676
|
+
sessionId: 'other-session',
|
|
677
|
+
timestamp: '2023-01-01T12:00:00Z',
|
|
678
|
+
locations: [],
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
await firstValueFrom(of(null).pipe(delay(20)))
|
|
682
|
+
expect(source.getCurrent()).toHaveLength(1)
|
|
683
|
+
expect(mockDispatchMessage).toHaveBeenCalledTimes(1)
|
|
684
|
+
|
|
685
|
+
// A new generation means a freshly live socket. Anything accumulated is
|
|
686
|
+
// suspect, because peers may have come or gone while we were away.
|
|
687
|
+
mockConnections.next(2)
|
|
688
|
+
|
|
689
|
+
await firstValueFrom(of(null).pipe(delay(20)))
|
|
690
|
+
expect(source.getCurrent()).toHaveLength(0)
|
|
691
|
+
expect(mockDispatchMessage).toHaveBeenNthCalledWith(2, {type: 'rollCall'})
|
|
692
|
+
|
|
693
|
+
// Wiping the map is only safe because peers answer that roll call and
|
|
694
|
+
// repopulate it. If the inbound listener did not survive the reconnect,
|
|
695
|
+
// this would stay empty for the life of the page.
|
|
696
|
+
mockIncomingEvents.next({
|
|
697
|
+
type: 'state',
|
|
698
|
+
userId: 'user-2',
|
|
699
|
+
sessionId: 'a-peer-answering',
|
|
700
|
+
timestamp: '2023-01-01T12:00:05Z',
|
|
701
|
+
locations: [],
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
await firstValueFrom(of(null).pipe(delay(20)))
|
|
705
|
+
expect(source.getCurrent()).toHaveLength(1)
|
|
706
|
+
expect(source.getCurrent()[0].sessionId).toBe('a-peer-answering')
|
|
707
|
+
|
|
708
|
+
unsubscribe()
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
it('still announces a disconnect if an earlier one failed', async () => {
|
|
712
|
+
const source = getPresence(instance)
|
|
713
|
+
const unsubscribe = source.subscribe(() => {})
|
|
714
|
+
|
|
715
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
716
|
+
mockDispatchMessage.mockClear()
|
|
717
|
+
|
|
718
|
+
// `pagehide` can fire more than once: a page restored from the back/forward
|
|
719
|
+
// cache will fire it again. A failure on the first must not kill the stream.
|
|
720
|
+
mockDispatchMessage.mockReturnValueOnce(throwError(() => new Error('socket closed')))
|
|
721
|
+
mockUnload.next()
|
|
722
|
+
mockUnload.next()
|
|
723
|
+
|
|
724
|
+
expect(mockDispatchMessage.mock.calls.filter(([m]) => m.type === 'disconnect')).toHaveLength(
|
|
725
|
+
2,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
unsubscribe()
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
it('announces a disconnect when the page unloads', async () => {
|
|
732
|
+
const source = getPresence(instance)
|
|
733
|
+
const unsubscribe = source.subscribe(() => {})
|
|
734
|
+
|
|
735
|
+
await firstValueFrom(of(null).pipe(delay(10)))
|
|
736
|
+
mockDispatchMessage.mockClear()
|
|
737
|
+
|
|
738
|
+
mockUnload.next()
|
|
739
|
+
|
|
740
|
+
expect(mockDispatchMessage).toHaveBeenCalledWith({type: 'disconnect'})
|
|
741
|
+
|
|
742
|
+
unsubscribe()
|
|
743
|
+
})
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
describe('stale session expiry', () => {
|
|
747
|
+
it('drops a session that stops announcing', async () => {
|
|
748
|
+
vi.useFakeTimers()
|
|
749
|
+
try {
|
|
750
|
+
const source = getPresence(instance)
|
|
751
|
+
const unsubscribe = source.subscribe(() => {})
|
|
752
|
+
|
|
753
|
+
await vi.advanceTimersByTimeAsync(10)
|
|
754
|
+
|
|
755
|
+
mockIncomingEvents.next({
|
|
756
|
+
type: 'state',
|
|
757
|
+
userId: 'user-1',
|
|
758
|
+
sessionId: 'other-session',
|
|
759
|
+
timestamp: new Date().toISOString(),
|
|
760
|
+
locations: [],
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
764
|
+
expect(source.getCurrent()).toHaveLength(1)
|
|
765
|
+
|
|
766
|
+
// Peers re-announce every 30s, so surviving a sweep at 60s proves the
|
|
767
|
+
// sweep is not just deleting everything it sees.
|
|
768
|
+
await vi.advanceTimersByTimeAsync(60_000)
|
|
769
|
+
expect(source.getCurrent()).toHaveLength(1)
|
|
770
|
+
|
|
771
|
+
// Past the 90s TTL with no further announcements, the peer is gone.
|
|
772
|
+
await vi.advanceTimersByTimeAsync(45_000)
|
|
773
|
+
expect(source.getCurrent()).toHaveLength(0)
|
|
774
|
+
|
|
775
|
+
unsubscribe()
|
|
776
|
+
} finally {
|
|
777
|
+
vi.useRealTimers()
|
|
778
|
+
}
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
it('keeps a session alive while it keeps announcing', async () => {
|
|
782
|
+
vi.useFakeTimers()
|
|
783
|
+
try {
|
|
784
|
+
const source = getPresence(instance)
|
|
785
|
+
const unsubscribe = source.subscribe(() => {})
|
|
786
|
+
|
|
787
|
+
await vi.advanceTimersByTimeAsync(10)
|
|
788
|
+
|
|
789
|
+
// Six announcements 30s apart, spanning well past the TTL.
|
|
790
|
+
for (let i = 0; i < 6; i++) {
|
|
791
|
+
mockIncomingEvents.next({
|
|
792
|
+
type: 'state',
|
|
793
|
+
userId: 'user-1',
|
|
794
|
+
sessionId: 'other-session',
|
|
795
|
+
timestamp: new Date().toISOString(),
|
|
796
|
+
locations: [],
|
|
797
|
+
})
|
|
798
|
+
await vi.advanceTimersByTimeAsync(30_000)
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
expect(source.getCurrent()).toHaveLength(1)
|
|
802
|
+
|
|
803
|
+
unsubscribe()
|
|
804
|
+
} finally {
|
|
805
|
+
vi.useRealTimers()
|
|
806
|
+
}
|
|
807
|
+
})
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
describe('reportPresence', () => {
|
|
811
|
+
/** Drains the audit window so a pending announcement is flushed. */
|
|
812
|
+
const flush = () => vi.advanceTimersByTimeAsync(250)
|
|
813
|
+
|
|
814
|
+
const stateCalls = () =>
|
|
815
|
+
mockDispatchMessage.mock.calls
|
|
816
|
+
.map(([message]) => message)
|
|
817
|
+
.filter(
|
|
818
|
+
(message): message is Extract<TransportMessage, {type: 'state'}> =>
|
|
819
|
+
message.type === 'state',
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
it('stays silent until the app reports, so reading never broadcasts', async () => {
|
|
823
|
+
vi.useFakeTimers()
|
|
824
|
+
try {
|
|
825
|
+
getPresence(instance)
|
|
826
|
+
await flush()
|
|
827
|
+
await vi.advanceTimersByTimeAsync(60_000)
|
|
828
|
+
|
|
829
|
+
// A rollCall goes out on connect, but never a state announcement.
|
|
830
|
+
expect(mockDispatchMessage).toHaveBeenCalledWith({type: 'rollCall'})
|
|
831
|
+
expect(stateCalls()).toEqual([])
|
|
832
|
+
} finally {
|
|
833
|
+
vi.useRealTimers()
|
|
834
|
+
}
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
it('resolves the reported id from the perspective', async () => {
|
|
838
|
+
vi.useFakeTimers()
|
|
839
|
+
try {
|
|
840
|
+
getPresence(instance)
|
|
841
|
+
|
|
842
|
+
// Default: the draft, which is what the Studio's form is on and therefore
|
|
843
|
+
// what its field indicators compare against.
|
|
844
|
+
reportPresence(instance, {locations: [{documentId: 'movie-1'}]})
|
|
845
|
+
await flush()
|
|
846
|
+
expect(stateCalls().at(-1)!.locations[0].documentId).toBe('drafts.movie-1')
|
|
847
|
+
|
|
848
|
+
reportPresence(instance, {locations: [{documentId: 'movie-1', perspective: 'published'}]})
|
|
849
|
+
await flush()
|
|
850
|
+
expect(stateCalls().at(-1)!.locations[0].documentId).toBe('movie-1')
|
|
851
|
+
|
|
852
|
+
reportPresence(instance, {
|
|
853
|
+
locations: [{documentId: 'movie-1', perspective: {releaseName: 'autumn'}}],
|
|
854
|
+
})
|
|
855
|
+
await flush()
|
|
856
|
+
expect(stateCalls().at(-1)!.locations[0].documentId).toBe('versions.autumn.movie-1')
|
|
857
|
+
|
|
858
|
+
reportPresence(instance, {locations: [{documentId: 'movie-1', liveEdit: true}]})
|
|
859
|
+
await flush()
|
|
860
|
+
expect(stateCalls().at(-1)!.locations[0].documentId).toBe('movie-1')
|
|
861
|
+
} finally {
|
|
862
|
+
vi.useRealTimers()
|
|
863
|
+
}
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it('announces a document-level location', async () => {
|
|
867
|
+
vi.useFakeTimers()
|
|
868
|
+
vi.setSystemTime(new Date('2026-07-30T12:00:00.000Z'))
|
|
869
|
+
try {
|
|
870
|
+
getPresence(instance)
|
|
871
|
+
reportPresence(instance, {locations: [{documentId: 'doc-1'}]})
|
|
872
|
+
await flush()
|
|
873
|
+
|
|
874
|
+
expect(stateCalls()).toEqual([
|
|
875
|
+
{
|
|
876
|
+
type: 'state',
|
|
877
|
+
locations: [
|
|
878
|
+
{
|
|
879
|
+
type: 'document',
|
|
880
|
+
// Resolved to the draft, which is the default perspective.
|
|
881
|
+
documentId: 'drafts.doc-1',
|
|
882
|
+
path: [],
|
|
883
|
+
lastActiveAt: '2026-07-30T12:00:00.000Z',
|
|
884
|
+
},
|
|
885
|
+
],
|
|
886
|
+
},
|
|
887
|
+
])
|
|
888
|
+
} finally {
|
|
889
|
+
vi.useRealTimers()
|
|
890
|
+
}
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
it('carries keyed path segments and a Portable Text selection', async () => {
|
|
894
|
+
vi.useFakeTimers()
|
|
895
|
+
try {
|
|
896
|
+
getPresence(instance)
|
|
897
|
+
// The shape the Studio expects: keyed segments for array items and spans.
|
|
898
|
+
reportPresence(instance, {
|
|
899
|
+
locations: [
|
|
900
|
+
{
|
|
901
|
+
documentId: 'doc-1',
|
|
902
|
+
path: ['body', {_key: 'block-1'}, 'children', {_key: 'span-1'}, 'text'],
|
|
903
|
+
selection: {
|
|
904
|
+
anchor: {path: [{_key: 'block-1'}, 'children', {_key: 'span-1'}], offset: 3},
|
|
905
|
+
focus: {path: [{_key: 'block-1'}, 'children', {_key: 'span-1'}], offset: 7},
|
|
906
|
+
backward: false,
|
|
907
|
+
},
|
|
908
|
+
},
|
|
909
|
+
],
|
|
910
|
+
})
|
|
911
|
+
await flush()
|
|
912
|
+
|
|
913
|
+
const [announced] = stateCalls()
|
|
914
|
+
expect(announced.locations[0].path).toEqual([
|
|
915
|
+
'body',
|
|
916
|
+
{_key: 'block-1'},
|
|
917
|
+
'children',
|
|
918
|
+
{_key: 'span-1'},
|
|
919
|
+
'text',
|
|
920
|
+
])
|
|
921
|
+
expect(announced.locations[0].selection).toEqual({
|
|
922
|
+
anchor: {path: [{_key: 'block-1'}, 'children', {_key: 'span-1'}], offset: 3},
|
|
923
|
+
focus: {path: [{_key: 'block-1'}, 'children', {_key: 'span-1'}], offset: 7},
|
|
924
|
+
backward: false,
|
|
925
|
+
})
|
|
926
|
+
} finally {
|
|
927
|
+
vi.useRealTimers()
|
|
928
|
+
}
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
it('omits selection entirely when none was reported', async () => {
|
|
932
|
+
vi.useFakeTimers()
|
|
933
|
+
try {
|
|
934
|
+
getPresence(instance)
|
|
935
|
+
reportPresence(instance, {locations: [{documentId: 'doc-1', path: ['title']}]})
|
|
936
|
+
await flush()
|
|
937
|
+
|
|
938
|
+
expect('selection' in stateCalls()[0].locations[0]).toBe(false)
|
|
939
|
+
} finally {
|
|
940
|
+
vi.useRealTimers()
|
|
941
|
+
}
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
it('announces an empty location list as present but nowhere', async () => {
|
|
945
|
+
vi.useFakeTimers()
|
|
946
|
+
try {
|
|
947
|
+
getPresence(instance)
|
|
948
|
+
reportPresence(instance, {locations: []})
|
|
949
|
+
await flush()
|
|
950
|
+
|
|
951
|
+
expect(stateCalls()).toEqual([{type: 'state', locations: []}])
|
|
952
|
+
} finally {
|
|
953
|
+
vi.useRealTimers()
|
|
954
|
+
}
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
it('collapses reports spread across the audit window into one announcement', async () => {
|
|
958
|
+
vi.useFakeTimers()
|
|
959
|
+
try {
|
|
960
|
+
getPresence(instance)
|
|
961
|
+
|
|
962
|
+
// Deliberately spaced out. Reports made in the same tick are collapsed by
|
|
963
|
+
// `switchMap` alone, so stepping the clock between them is what actually
|
|
964
|
+
// exercises the audit window - which is the case that matters, since a
|
|
965
|
+
// user typing produces focus changes tens of milliseconds apart.
|
|
966
|
+
reportPresence(instance, {locations: [{documentId: 'doc-1', path: ['a']}]})
|
|
967
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
968
|
+
reportPresence(instance, {locations: [{documentId: 'doc-1', path: ['b']}]})
|
|
969
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
970
|
+
reportPresence(instance, {locations: [{documentId: 'doc-1', path: ['c']}]})
|
|
971
|
+
await flush()
|
|
972
|
+
|
|
973
|
+
const announced = stateCalls()
|
|
974
|
+
expect(announced).toHaveLength(1)
|
|
975
|
+
expect(announced[0].locations[0].path).toEqual(['c'])
|
|
976
|
+
} finally {
|
|
977
|
+
vi.useRealTimers()
|
|
978
|
+
}
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
it('does not re-announce when only lastActiveAt would differ', async () => {
|
|
982
|
+
vi.useFakeTimers()
|
|
983
|
+
try {
|
|
984
|
+
getPresence(instance)
|
|
985
|
+
reportPresence(instance, {locations: [{documentId: 'doc-1', path: ['title']}]})
|
|
986
|
+
await flush()
|
|
987
|
+
expect(stateCalls()).toHaveLength(1)
|
|
988
|
+
|
|
989
|
+
// Same place, reported again. `lastActiveAt` is regenerated, but nothing
|
|
990
|
+
// moved, so this must not restart the heartbeat.
|
|
991
|
+
await vi.advanceTimersByTimeAsync(5_000)
|
|
992
|
+
reportPresence(instance, {locations: [{documentId: 'doc-1', path: ['title']}]})
|
|
993
|
+
await flush()
|
|
994
|
+
|
|
995
|
+
expect(stateCalls()).toHaveLength(1)
|
|
996
|
+
} finally {
|
|
997
|
+
vi.useRealTimers()
|
|
998
|
+
}
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
it('re-announces every 30s while idle, so peers do not expire it', async () => {
|
|
1002
|
+
vi.useFakeTimers()
|
|
1003
|
+
try {
|
|
1004
|
+
getPresence(instance)
|
|
1005
|
+
reportPresence(instance, {locations: [{documentId: 'doc-1'}]})
|
|
1006
|
+
await flush()
|
|
1007
|
+
expect(stateCalls()).toHaveLength(1)
|
|
1008
|
+
|
|
1009
|
+
await vi.advanceTimersByTimeAsync(30_000)
|
|
1010
|
+
expect(stateCalls()).toHaveLength(2)
|
|
1011
|
+
|
|
1012
|
+
await vi.advanceTimersByTimeAsync(30_000)
|
|
1013
|
+
expect(stateCalls()).toHaveLength(3)
|
|
1014
|
+
} finally {
|
|
1015
|
+
vi.useRealTimers()
|
|
1016
|
+
}
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
it("answers another client's roll call", async () => {
|
|
1020
|
+
vi.useFakeTimers()
|
|
1021
|
+
try {
|
|
1022
|
+
getPresence(instance)
|
|
1023
|
+
reportPresence(instance, {locations: [{documentId: 'doc-1'}]})
|
|
1024
|
+
await flush()
|
|
1025
|
+
expect(stateCalls()).toHaveLength(1)
|
|
1026
|
+
|
|
1027
|
+
mockIncomingEvents.next({
|
|
1028
|
+
type: 'rollCall',
|
|
1029
|
+
userId: 'user-2',
|
|
1030
|
+
sessionId: 'their-session',
|
|
1031
|
+
})
|
|
1032
|
+
await flush()
|
|
1033
|
+
|
|
1034
|
+
expect(stateCalls()).toHaveLength(2)
|
|
1035
|
+
} finally {
|
|
1036
|
+
vi.useRealTimers()
|
|
1037
|
+
}
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1040
|
+
it('ignores the echo of its own roll call', async () => {
|
|
1041
|
+
vi.useFakeTimers()
|
|
1042
|
+
try {
|
|
1043
|
+
getPresence(instance)
|
|
1044
|
+
reportPresence(instance, {locations: [{documentId: 'doc-1'}]})
|
|
1045
|
+
await flush()
|
|
1046
|
+
expect(stateCalls()).toHaveLength(1)
|
|
1047
|
+
|
|
1048
|
+
// Bifur publishes to the topic we are subscribed to, so our own roll call
|
|
1049
|
+
// comes straight back. Answering it would be a pointless round trip.
|
|
1050
|
+
mockIncomingEvents.next({
|
|
1051
|
+
type: 'rollCall',
|
|
1052
|
+
userId: 'user-1',
|
|
1053
|
+
sessionId: 'test-session-id',
|
|
1054
|
+
})
|
|
1055
|
+
await flush()
|
|
1056
|
+
|
|
1057
|
+
expect(stateCalls()).toHaveLength(1)
|
|
1058
|
+
} finally {
|
|
1059
|
+
vi.useRealTimers()
|
|
1060
|
+
}
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
it('keeps announcing after a failed announcement', async () => {
|
|
1064
|
+
vi.useFakeTimers()
|
|
1065
|
+
try {
|
|
1066
|
+
getPresence(instance)
|
|
1067
|
+
reportPresence(instance, {locations: [{documentId: 'doc-1'}]})
|
|
1068
|
+
await flush()
|
|
1069
|
+
expect(stateCalls()).toHaveLength(1)
|
|
1070
|
+
|
|
1071
|
+
// A dropped socket errors the in-flight request. If that error reaches the
|
|
1072
|
+
// announce stream it kills every trigger at once, because the heartbeat,
|
|
1073
|
+
// roll-call responses, and reconnect re-announce all share this chain.
|
|
1074
|
+
mockDispatchMessage.mockReturnValueOnce(throwError(() => new Error('socket closed')))
|
|
1075
|
+
await vi.advanceTimersByTimeAsync(30_000)
|
|
1076
|
+
expect(stateCalls()).toHaveLength(2)
|
|
1077
|
+
|
|
1078
|
+
// The heartbeat must survive the failure.
|
|
1079
|
+
await vi.advanceTimersByTimeAsync(30_000)
|
|
1080
|
+
expect(stateCalls()).toHaveLength(3)
|
|
1081
|
+
|
|
1082
|
+
// So must roll-call responses.
|
|
1083
|
+
mockIncomingEvents.next({type: 'rollCall', userId: 'user-2', sessionId: 'their-session'})
|
|
1084
|
+
await flush()
|
|
1085
|
+
expect(stateCalls()).toHaveLength(4)
|
|
1086
|
+
|
|
1087
|
+
// And so must the re-announce on reconnect, which is the path that matters
|
|
1088
|
+
// most: the failure and the reconnect have the same root cause.
|
|
1089
|
+
mockConnections.next(2)
|
|
1090
|
+
await flush()
|
|
1091
|
+
expect(stateCalls()).toHaveLength(5)
|
|
1092
|
+
} finally {
|
|
1093
|
+
vi.useRealTimers()
|
|
1094
|
+
}
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
it('keeps announcing when a trigger source fails', async () => {
|
|
1098
|
+
vi.useFakeTimers()
|
|
1099
|
+
try {
|
|
1100
|
+
getPresence(instance)
|
|
1101
|
+
reportPresence(instance, {locations: [{documentId: 'doc-1'}]})
|
|
1102
|
+
await flush()
|
|
1103
|
+
expect(stateCalls()).toHaveLength(1)
|
|
1104
|
+
|
|
1105
|
+
// The three announce triggers are combined with `merge`, which propagates
|
|
1106
|
+
// an error from any one of them. The inbound stream feeds the roll-call
|
|
1107
|
+
// trigger, so a failure there would otherwise take the heartbeat and the
|
|
1108
|
+
// reconnect re-announce down with it.
|
|
1109
|
+
mockIncomingEvents.error(new Error('inbound stream failed'))
|
|
1110
|
+
|
|
1111
|
+
await vi.advanceTimersByTimeAsync(30_000)
|
|
1112
|
+
expect(stateCalls()).toHaveLength(2)
|
|
1113
|
+
|
|
1114
|
+
mockConnections.next(2)
|
|
1115
|
+
await flush()
|
|
1116
|
+
expect(stateCalls()).toHaveLength(3)
|
|
1117
|
+
} finally {
|
|
1118
|
+
vi.useRealTimers()
|
|
1119
|
+
}
|
|
1120
|
+
})
|
|
1121
|
+
|
|
1122
|
+
it('re-announces after a reconnect', async () => {
|
|
1123
|
+
vi.useFakeTimers()
|
|
1124
|
+
try {
|
|
1125
|
+
getPresence(instance)
|
|
1126
|
+
reportPresence(instance, {locations: [{documentId: 'doc-1'}]})
|
|
1127
|
+
await flush()
|
|
1128
|
+
expect(stateCalls()).toHaveLength(1)
|
|
1129
|
+
|
|
1130
|
+
mockConnections.next(2)
|
|
1131
|
+
await flush()
|
|
1132
|
+
|
|
1133
|
+
// Peers cleared their view of us when we dropped, so we have to speak up.
|
|
1134
|
+
expect(stateCalls()).toHaveLength(2)
|
|
1135
|
+
} finally {
|
|
1136
|
+
vi.useRealTimers()
|
|
1137
|
+
}
|
|
1138
|
+
})
|
|
1139
|
+
})
|
|
1140
|
+
|
|
1141
|
+
describe('session id', () => {
|
|
1142
|
+
afterEach(() => {
|
|
1143
|
+
vi.unstubAllGlobals()
|
|
1144
|
+
})
|
|
1145
|
+
|
|
1146
|
+
it('gives each store its own id, so nothing can collide across tabs', () => {
|
|
1147
|
+
// Ids are deliberately not persisted: a tab that inherits another's
|
|
1148
|
+
// session storage would otherwise reuse the id and the two live clients
|
|
1149
|
+
// would filter each other out as their own session.
|
|
1150
|
+
let counter = 0
|
|
1151
|
+
vi.stubGlobal('crypto', {randomUUID: vi.fn(() => `session-${++counter}`)})
|
|
1152
|
+
|
|
1153
|
+
getPresence(instance, {resource: {projectId: 'p1', dataset: 'd1'}}).subscribe(() => {})
|
|
1154
|
+
const first = vi.mocked(createBifurTransport).mock.calls.at(-1)?.[0].sessionId
|
|
1155
|
+
|
|
1156
|
+
getPresence(instance, {resource: {projectId: 'p2', dataset: 'd2'}}).subscribe(() => {})
|
|
1157
|
+
const second = vi.mocked(createBifurTransport).mock.calls.at(-1)?.[0].sessionId
|
|
1158
|
+
|
|
1159
|
+
expect(first).toBe('session-1')
|
|
1160
|
+
expect(second).toBe('session-2')
|
|
1161
|
+
})
|
|
342
1162
|
})
|
|
343
1163
|
})
|