@adatechnology/conversations-ui 0.9.0 → 0.10.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 (90) hide show
  1. package/README.md +106 -0
  2. package/dist/{ConversationSimulatorPanel-q-wVgBCN.d.ts → ConversationSimulatorPanel-P6eqTSAO.d.ts} +1 -16
  3. package/dist/{chunk-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
  4. package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
  5. package/dist/index.d.ts +10 -3
  6. package/dist/index.js +3 -3
  7. package/dist/participant/index.d.ts +51 -3
  8. package/dist/participant/index.js +1363 -319
  9. package/dist/preview/index.d.ts +2 -2
  10. package/dist/preview/index.js +2 -2
  11. package/dist/styles.css +182 -0
  12. package/package.json +2 -2
  13. package/src/AudioRecorderButton.tsx +4 -28
  14. package/src/audioRecorderFormat.ts +27 -0
  15. package/src/participant/ConversationThread.tsx +4 -0
  16. package/src/participant/ParticipantAttachmentItem.tsx +7 -10
  17. package/src/participant/ParticipantAudioAttachment.tsx +34 -0
  18. package/src/participant/ParticipantAudioPlayer.tsx +68 -0
  19. package/src/participant/ParticipantAudioPlayerControls.tsx +68 -0
  20. package/src/participant/ParticipantAudioRecorderView.tsx +55 -0
  21. package/src/participant/ParticipantAudioTranscription.tsx +90 -0
  22. package/src/participant/ParticipantComposer.tsx +50 -15
  23. package/src/participant/ParticipantComposerAudio.tsx +95 -0
  24. package/src/participant/ParticipantConversations.tsx +3 -0
  25. package/src/participant/ParticipantRecorderParts.tsx +118 -0
  26. package/src/participant/ParticipantThread.tsx +7 -0
  27. package/src/participant/ParticipantThreadScreen.tsx +1 -0
  28. package/src/participant/index.ts +3 -0
  29. package/src/participant/participantAudio.types.ts +10 -0
  30. package/src/participant/participantAudioCapture.test.ts +15 -0
  31. package/src/participant/participantAudioCapture.ts +85 -0
  32. package/src/participant/participantAudioCoordinator.ts +13 -0
  33. package/src/participant/participantAudioFormat.test.ts +93 -0
  34. package/src/participant/participantAudioFormat.ts +72 -0
  35. package/src/participant/participantAudioGolden.test.tsx +168 -0
  36. package/src/participant/participantAudioLabels.test.ts +22 -0
  37. package/src/participant/participantAudioLabels.ts +76 -0
  38. package/src/participant/participantAudioPlayerControlsDom.test.tsx +123 -0
  39. package/src/participant/participantAudioPlayerCss.test.ts +63 -0
  40. package/src/participant/participantAudioPlayerDom.test.tsx +154 -0
  41. package/src/participant/participantAudioPlayerDomKit.test-helper.tsx +84 -0
  42. package/src/participant/participantAudioPlayerState.ts +35 -0
  43. package/src/participant/participantAudioRecorderDom.test.tsx +127 -0
  44. package/src/participant/participantAudioRecorderInterruptionDom.test.tsx +135 -0
  45. package/src/participant/participantAudioRecorderSpecDom.test.tsx +110 -0
  46. package/src/participant/participantAudioRecorderState.test.ts +144 -0
  47. package/src/participant/participantAudioRecorderState.ts +93 -0
  48. package/src/participant/participantAudioRecorderView.test.tsx +98 -0
  49. package/src/participant/participantAudioSupport.ts +36 -0
  50. package/src/participant/participantAudioTranscriptionDom.test.tsx +198 -0
  51. package/src/participant/participantComposerAudioDom.test.tsx +185 -0
  52. package/src/participant/participantComposerAudioGolden.test.tsx +68 -0
  53. package/src/participant/participantComposerAudioSsr.test.tsx +74 -0
  54. package/src/participant/participantFileValidation.ts +1 -1
  55. package/src/participant/participantGolden.test.tsx +2 -0
  56. package/src/participant/participantGoldenScenarios.test-helper.tsx +14 -1
  57. package/src/participant/participantLabels.ts +4 -1
  58. package/src/participant/participantMediaFakes.test-helper.ts +82 -0
  59. package/src/participant/participantNoMicrophoneDom.test.tsx +44 -0
  60. package/src/participant/participantPublicTypes.test.ts +11 -0
  61. package/src/participant/participantRecorderDomKit.test-helper.tsx +131 -0
  62. package/src/participant/participantRecorderFakes.test-helper.ts +47 -0
  63. package/src/participant/participantSpecRecorderFake.test-helper.ts +52 -0
  64. package/src/participant/participantThemeContrast.test.ts +9 -0
  65. package/src/participant/test-fixtures/composer-audio-denied.golden.html +1 -0
  66. package/src/participant/test-fixtures/composer-audio-idle.golden.html +1 -0
  67. package/src/participant/test-fixtures/composer-audio-recording.golden.html +1 -0
  68. package/src/participant/test-fixtures/composer-audio-review.golden.html +1 -0
  69. package/src/participant/test-fixtures/thread-audio-native.golden.html +1 -0
  70. package/src/participant/test-fixtures/thread-audio-player.golden.html +1 -0
  71. package/src/participant/test-fixtures/thread-audio-transcription-empty.golden.html +1 -0
  72. package/src/participant/test-fixtures/thread-audio-transcription-pending.golden.html +1 -0
  73. package/src/participant/test-fixtures/thread-audio-transcription-ready.golden.html +1 -0
  74. package/src/participant/test-fixtures/thread-audio-transcription-too_long.golden.html +1 -0
  75. package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
  76. package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
  77. package/src/participant/useParticipantAudioMediaEvents.ts +46 -0
  78. package/src/participant/useParticipantAudioPlayer.ts +52 -0
  79. package/src/participant/useParticipantAudioPlayerActions.ts +47 -0
  80. package/src/participant/useParticipantAudioRecorder.ts +153 -0
  81. package/src/participant/useParticipantAudioRecorderCore.ts +55 -0
  82. package/src/participant/useParticipantAudioRecorderEffects.ts +70 -0
  83. package/src/participant/useParticipantAudioSourceRetry.ts +42 -0
  84. package/src/participant/useParticipantSendAudio.test.tsx +85 -0
  85. package/src/participant/useParticipantSendAudio.ts +28 -0
  86. package/src/participant/useParticipantThreadController.ts +20 -7
  87. package/src/participant/useStableAttachmentUrl.ts +71 -0
  88. package/src/participant/useTranscriptionOverflow.ts +25 -0
  89. package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
  90. package/src/styles.css +29 -0
@@ -0,0 +1,154 @@
1
+ import { act, useState } from 'react'
2
+
3
+ import type { ParticipantAttachment } from '@adatechnology/conversation-contracts'
4
+ import { afterEach, describe, expect, it } from 'bun:test'
5
+
6
+ import { wait } from '../domHarness.test-helper'
7
+ import { ATTACHMENT_URL_RETRY_DELAY_MS } from './useStableAttachmentUrl'
8
+ import { ParticipantAudioAttachment } from './ParticipantAudioAttachment'
9
+ import { DEFAULT_PARTICIPANT_AUDIO_LABELS } from './participantAudioLabels'
10
+ import { buildAttachment, setupPlayerDom } from './participantAudioPlayerDomKit.test-helper'
11
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
12
+
13
+ const kit = setupPlayerDom()
14
+ const { mountAttachments, audioOf, toggleOf, find, tap } = kit
15
+
16
+ const realSetTimeout = globalThis.setTimeout
17
+ const SETTLE_MS = 20
18
+ let heldRetries: Array<() => void> = []
19
+
20
+ /** Holds the retry timers (1 s) instead of sleeping: the test fires them on demand, with no wall-clock margin. */
21
+ function holdRetryTimers(): void {
22
+ heldRetries = []
23
+ const shim = ((handler: TimerHandler, delay?: number, ...args: unknown[]) => {
24
+ if (delay !== ATTACHMENT_URL_RETRY_DELAY_MS) return realSetTimeout(handler, delay, ...args)
25
+ heldRetries.push(() => (handler as () => void)())
26
+ return 0
27
+ }) as unknown as typeof setTimeout
28
+ globalThis.setTimeout = shim
29
+ }
30
+
31
+ async function fireHeldRetries(): Promise<void> {
32
+ const pending = heldRetries
33
+ heldRetries = []
34
+ for (const retry of pending) retry()
35
+ await wait(SETTLE_MS)
36
+ }
37
+
38
+ afterEach(() => {
39
+ globalThis.setTimeout = realSetTimeout
40
+ })
41
+
42
+ describe('source: resolved at mount by id, never downloaded before the tap', () => {
43
+ it('assigns the resolved src with preload="none" and resolves once', async () => {
44
+ await mountAttachments([buildAttachment('a')])
45
+ expect(audioOf().getAttribute('src')).toBe('https://files.test/a-1.webm')
46
+ expect(audioOf().getAttribute('preload')).toBe('none')
47
+ expect(audioOf().hasAttribute('controls')).toBe(false)
48
+ expect(kit.resolverCalls).toEqual(['a'])
49
+ expect(kit.media.playCalls).toHaveLength(0)
50
+ })
51
+
52
+ it('a list update with a new attachment object and a new URL does not resolve again nor change the src', async () => {
53
+ let setAttachment: (attachment: ParticipantAttachment) => void = () => undefined
54
+ function Harness() {
55
+ const [attachment, update] = useState(buildAttachment('a'))
56
+ setAttachment = update
57
+ return <ParticipantAudioAttachment attachment={attachment} resolveAttachmentUrl={(item) => kit.resolveAttachmentUrl(item)} labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS} />
58
+ }
59
+ kit.container = kit.harness.mount(<Harness />).container
60
+ await wait(0)
61
+ tap(toggleOf())
62
+ kit.media.stateOf(audioOf()).currentTime = 20
63
+ act(() => setAttachment(buildAttachment('a', 65_000)))
64
+ await wait(0)
65
+ expect(kit.resolverCalls).toEqual(['a'])
66
+ expect(audioOf().getAttribute('src')).toBe('https://files.test/a-1.webm')
67
+ expect(kit.media.stateOf(audioOf()).currentTime).toBe(20)
68
+ expect(kit.media.stateOf(audioOf()).paused).toBe(false)
69
+ })
70
+
71
+ it('shows the unavailable state when the URL cannot be resolved after the second attempt', async () => {
72
+ holdRetryTimers()
73
+ await mountAttachments([buildAttachment('a')], async () => {
74
+ throw new Error('nope')
75
+ })
76
+ await fireHeldRetries()
77
+ expect(kit.container.textContent).toContain(DEFAULT_PARTICIPANT_AUDIO_LABELS.audioUnavailable)
78
+ expect(audioOf().hasAttribute('src')).toBe(false)
79
+ })
80
+
81
+ it('a transient failure of the first resolution gets one new attempt before giving up', async () => {
82
+ {
83
+ holdRetryTimers()
84
+ let attempts = 0
85
+ await mountAttachments([buildAttachment('a')], async () => {
86
+ attempts += 1
87
+ if (attempts === 1) throw new Error('offline for a moment')
88
+ return 'https://files.test/a-retry.webm'
89
+ })
90
+ expect(audioOf().hasAttribute('src')).toBe(false)
91
+ expect(kit.container.querySelector('.cv-p-audio__error')).toBeNull()
92
+ await fireHeldRetries()
93
+ expect(attempts).toBe(2)
94
+ expect(audioOf().getAttribute('src')).toBe('https://files.test/a-retry.webm')
95
+ expect(kit.container.querySelector('.cv-p-audio__error')).toBeNull()
96
+ }
97
+ })
98
+
99
+ it('says it with static text: no alert role (N failing players would be N assertive announcements) and a disabled toggle', async () => {
100
+ holdRetryTimers()
101
+ await mountAttachments([buildAttachment('a'), buildAttachment('b')], async () => {
102
+ throw new Error('nope')
103
+ })
104
+ await fireHeldRetries()
105
+ const messages = kit.container.querySelectorAll('.cv-p-audio__error')
106
+ expect(messages).toHaveLength(2)
107
+ for (const message of Array.from(messages)) expect(message.hasAttribute('role')).toBe(false)
108
+ expect(kit.container.querySelector('[role="alert"]')).toBeNull()
109
+ expect(toggleOf().disabled).toBe(true)
110
+ })
111
+
112
+ it('puts the file name after the player', async () => {
113
+ await mountAttachments([buildAttachment('a')])
114
+ const wrapper = find('.cv-p-attachment--audio')
115
+ expect(wrapper.firstElementChild?.className).toBe('cv-p-audio')
116
+ expect(wrapper.lastElementChild?.className).toBe('cv-p-attachment__name')
117
+ expect(kit.container.querySelector('.cv-p-attachment__audio')).toBeNull()
118
+ })
119
+ })
120
+
121
+ describe('play and pause', () => {
122
+ it('calls play() synchronously in the tap (a play after an await loses the iOS gesture)', async () => {
123
+ await mountAttachments([buildAttachment('a')])
124
+ tap(toggleOf())
125
+ expect(kit.media.playCalls).toHaveLength(1)
126
+ expect(kit.media.playCalls[0] === audioOf()).toBe(true)
127
+ expect(toggleOf().getAttribute('aria-label')).toBe('Pause audio')
128
+ tap(toggleOf())
129
+ expect(toggleOf().getAttribute('aria-label')).toBe('Play audio')
130
+ })
131
+
132
+ it('stays on "Play audio" when the browser refuses play()', async () => {
133
+ await mountAttachments([buildAttachment('a')])
134
+ kit.media.playOutcome = 'reject'
135
+ tap(toggleOf())
136
+ await wait(0)
137
+ expect(toggleOf().getAttribute('aria-label')).toBe('Play audio')
138
+ })
139
+
140
+ it('playing one audio pauses the other', async () => {
141
+ await mountAttachments([buildAttachment('a'), buildAttachment('b')])
142
+ tap(toggleOf(0))
143
+ tap(toggleOf(1))
144
+ expect(kit.media.pauseCalls).toHaveLength(1)
145
+ expect(kit.media.pauseCalls[0] === audioOf(0)).toBe(true)
146
+ expect(toggleOf(0).getAttribute('aria-label')).toBe('Play audio')
147
+ expect(toggleOf(1).getAttribute('aria-label')).toBe('Pause audio')
148
+ })
149
+
150
+ it('the toggle is at least the touch size and a plain button', async () => {
151
+ await mountAttachments([buildAttachment('a')])
152
+ expect(toggleOf().getAttribute('type')).toBe('button')
153
+ })
154
+ })
@@ -0,0 +1,84 @@
1
+ import { act } from 'react'
2
+
3
+ import type { ParticipantAttachment } from '@adatechnology/conversation-contracts'
4
+ import { afterAll, afterEach, beforeAll, beforeEach } from 'bun:test'
5
+
6
+ import { setupDom, wait, type DomHarness } from '../domHarness.test-helper'
7
+ import { ParticipantAudioAttachment } from './ParticipantAudioAttachment'
8
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
9
+ import { installFakeMedia, type FakeMediaControls } from './participantMediaFakes.test-helper'
10
+
11
+ export type PlayerDomKit = {
12
+ harness: DomHarness
13
+ media: FakeMediaControls
14
+ container: HTMLElement
15
+ resolverCalls: string[]
16
+ urlSequence: string[]
17
+ resolveAttachmentUrl: (attachment: ParticipantAttachment) => Promise<string>
18
+ mountAttachments: (attachments: ParticipantAttachment[], resolver?: PlayerDomKit['resolveAttachmentUrl']) => Promise<void>
19
+ audioOf: (index?: number) => HTMLAudioElement
20
+ toggleOf: (index?: number) => HTMLButtonElement
21
+ find: <TElement extends Element>(selector: string) => TElement
22
+ tap: (element: Element) => void
23
+ }
24
+
25
+ export function buildAttachment(id: string, durationMs?: number): ParticipantAttachment {
26
+ return { id, kind: 'audio', filename: `${id}.webm`, mimeType: 'audio/webm', sizeBytes: 2048, ...(durationMs === undefined ? {} : { durationMs }) } as ParticipantAttachment
27
+ }
28
+
29
+ export function setupPlayerDom(): PlayerDomKit {
30
+ const kit: PlayerDomKit = {
31
+ resolveAttachmentUrl: async (attachment) => {
32
+ kit.resolverCalls.push(attachment.id)
33
+ const url = kit.urlSequence[kit.resolverCalls.filter((id) => id === attachment.id).length - 1]
34
+ if (!url) throw new Error('unavailable')
35
+ return attachment.id === 'b' ? 'https://files.test/b-1.webm' : url
36
+ },
37
+ mountAttachments: async (attachments, resolver = kit.resolveAttachmentUrl) => {
38
+ kit.container = kit.harness.mount(
39
+ <>
40
+ {attachments.map((attachment) => (
41
+ <ParticipantAudioAttachment key={attachment.id} attachment={attachment} resolveAttachmentUrl={resolver} labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS} />
42
+ ))}
43
+ </>,
44
+ ).container
45
+ await wait(0)
46
+ },
47
+ audioOf: (index = 0) => {
48
+ const audio = kit.container.querySelectorAll<HTMLAudioElement>('audio')[index]
49
+ if (!audio) throw new Error('missing audio')
50
+ return audio
51
+ },
52
+ toggleOf: (index = 0) => {
53
+ const toggle = kit.container.querySelectorAll<HTMLButtonElement>('.cv-p-audio__toggle')[index]
54
+ if (!toggle) throw new Error('missing toggle')
55
+ return toggle
56
+ },
57
+ find: <TElement extends Element>(selector: string): TElement => {
58
+ const element = kit.container.querySelector<TElement>(selector)
59
+ if (!element) throw new Error(`missing ${selector}`)
60
+ return element
61
+ },
62
+ tap: (element) => {
63
+ act(() => {
64
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
65
+ })
66
+ },
67
+ } as PlayerDomKit
68
+
69
+ beforeAll(async () => {
70
+ kit.harness = await setupDom()
71
+ })
72
+
73
+ afterAll(() => kit.harness.teardown())
74
+
75
+ beforeEach(() => {
76
+ kit.media = installFakeMedia()
77
+ kit.resolverCalls = []
78
+ kit.urlSequence = ['https://files.test/a-1.webm', 'https://files.test/a-2.webm', 'https://files.test/a-3.webm']
79
+ })
80
+
81
+ afterEach(() => kit.media.restore())
82
+
83
+ return kit
84
+ }
@@ -0,0 +1,35 @@
1
+ import type { MutableRefObject } from 'react'
2
+
3
+ import type { AudioPlaybackHandle } from './participantAudioCoordinator'
4
+ import { PLAYBACK_RATES } from './participantAudioFormat'
5
+
6
+ export type PlayerState = {
7
+ readonly isPlaying: boolean
8
+ readonly position: number
9
+ readonly mediaDuration: number | undefined
10
+ readonly maxSeen: number
11
+ readonly rateIndex: number
12
+ readonly hasFailed: boolean
13
+ }
14
+
15
+ export const INITIAL_PLAYER_STATE: PlayerState = {
16
+ isPlaying: false,
17
+ position: 0,
18
+ mediaDuration: undefined,
19
+ maxSeen: 0,
20
+ rateIndex: 0,
21
+ hasFailed: false,
22
+ }
23
+
24
+ export type PlayerContext = {
25
+ readonly audioRef: MutableRefObject<HTMLAudioElement | null>
26
+ readonly handleRef: MutableRefObject<AudioPlaybackHandle>
27
+ readonly state: PlayerState
28
+ readonly update: (patch: Partial<PlayerState>) => void
29
+ }
30
+
31
+ export function applyPlaybackRate(audio: HTMLAudioElement, rateIndex: number): void {
32
+ const rate = PLAYBACK_RATES[rateIndex] ?? 1
33
+ audio.defaultPlaybackRate = rate
34
+ audio.playbackRate = rate
35
+ }
@@ -0,0 +1,127 @@
1
+ import { act } from 'react'
2
+
3
+ import { describe, expect, it } from 'bun:test'
4
+
5
+ import { setupRecorderDom } from './participantRecorderDomKit.test-helper'
6
+
7
+ const kit = setupRecorderDom()
8
+ const { mountProbe, start, advance, recordFor, deferStream } = kit
9
+
10
+ describe('the happy path', () => {
11
+ it('goes idle -> requesting -> recording -> review with a coherent File', async () => {
12
+ mountProbe()
13
+ expect(kit.api.status).toBe('idle')
14
+ deferStream()
15
+ await start()
16
+ expect(kit.api.status).toBe('requesting')
17
+ await act(async () => kit.resolveStream?.(kit.stream))
18
+ expect(kit.api.status).toBe('recording')
19
+ await advance(1500)
20
+ act(() => kit.api.stop())
21
+ expect(kit.api.status).toBe('review')
22
+ expect(kit.api.recorded?.file.type).toBe('audio/ogg')
23
+ expect(kit.api.recorded?.file.name).toMatch(/^audio-\d{8}-\d{6}\.ogg$/)
24
+ expect(kit.api.recorded?.file.size).toBe(4096)
25
+ expect(kit.api.recorded?.objectUrl).toBe('blob:fake-recording')
26
+ expect(kit.stream.track.stopCount).toBeGreaterThan(0)
27
+ })
28
+
29
+ it('falls back to m4a with the matching mime type', async () => {
30
+ kit.supportedTypes = ['audio/mp4']
31
+ mountProbe()
32
+ await recordFor(1000)
33
+ act(() => kit.api.stop())
34
+ expect(kit.api.recorded?.file.name).toMatch(/\.m4a$/)
35
+ expect(kit.api.recorded?.file.type).toBe('audio/mp4')
36
+ })
37
+
38
+ it('reports capture from requesting until it leaves review, and revokes the URL on every exit', async () => {
39
+ mountProbe()
40
+ await recordFor(1000)
41
+ act(() => kit.api.stop())
42
+ expect(kit.captureEvents).toEqual([true])
43
+ let file: File | undefined
44
+ act(() => {
45
+ file = kit.api.confirm()
46
+ })
47
+ expect(file?.size).toBe(4096)
48
+ expect(kit.api.status).toBe('idle')
49
+ expect(kit.revoked).toEqual(['blob:fake-recording'])
50
+ expect(kit.captureEvents).toEqual([true, false])
51
+ })
52
+
53
+ it('discard from review revokes the URL and returns to idle', async () => {
54
+ mountProbe()
55
+ await recordFor(1000)
56
+ act(() => kit.api.stop())
57
+ act(() => kit.api.discard())
58
+ expect(kit.api.status).toBe('idle')
59
+ expect(kit.revoked).toEqual(['blob:fake-recording'])
60
+ })
61
+
62
+ it('discard while recording stops the track and records nothing', async () => {
63
+ mountProbe()
64
+ await recordFor(1000)
65
+ act(() => kit.api.discard())
66
+ expect(kit.api.status).toBe('idle')
67
+ expect(kit.stream.track.stopCount).toBeGreaterThan(0)
68
+ expect(kit.api.recorded).toBeUndefined()
69
+ })
70
+ })
71
+
72
+ describe('failures', () => {
73
+ it('permission denied -> denied, and the next tap asks again', async () => {
74
+ kit.getUserMediaImpl = () => Promise.reject(Object.assign(new Error('x'), { name: 'NotAllowedError' }))
75
+ mountProbe()
76
+ await start()
77
+ expect(kit.api.status).toBe('denied')
78
+ kit.getUserMediaImpl = async () => kit.stream
79
+ await start()
80
+ expect(kit.api.status).toBe('recording')
81
+ })
82
+
83
+ it('no microphone device -> unsupported', async () => {
84
+ kit.getUserMediaImpl = () => Promise.reject(Object.assign(new Error('x'), { name: 'NotFoundError' }))
85
+ mountProbe()
86
+ await start()
87
+ expect(kit.api.status).toBe('unsupported')
88
+ })
89
+
90
+ it('no MediaRecorder at all -> unsupported without asking for the microphone', async () => {
91
+ delete (globalThis as Record<string, unknown>).MediaRecorder
92
+ let asked = false
93
+ kit.getUserMediaImpl = async () => {
94
+ asked = true
95
+ return kit.stream
96
+ }
97
+ mountProbe()
98
+ await start()
99
+ expect(kit.api.status).toBe('unsupported')
100
+ expect(asked).toBe(false)
101
+ })
102
+
103
+ it('a recording with no bytes -> empty, tracks released', async () => {
104
+ kit.chunkBytes = 0
105
+ mountProbe()
106
+ await recordFor(1000)
107
+ act(() => kit.api.stop())
108
+ expect(kit.api.status).toBe('empty')
109
+ expect(kit.stream.track.stopCount).toBeGreaterThan(0)
110
+ expect(kit.revoked).toEqual([])
111
+ })
112
+
113
+ it('stopping before the minimum duration -> empty', async () => {
114
+ mountProbe()
115
+ await recordFor(100)
116
+ act(() => kit.api.stop())
117
+ expect(kit.api.status).toBe('empty')
118
+ })
119
+
120
+ it('above the byte ceiling -> tooLarge, nothing kept', async () => {
121
+ mountProbe({ maxSizeBytes: 1000 })
122
+ await recordFor(1000)
123
+ act(() => kit.api.stop())
124
+ expect(kit.api.status).toBe('tooLarge')
125
+ expect(kit.api.recorded).toBeUndefined()
126
+ })
127
+ })
@@ -0,0 +1,135 @@
1
+ import { act } from 'react'
2
+
3
+ import { describe, expect, it } from 'bun:test'
4
+
5
+ import { setupRecorderDom } from './participantRecorderDomKit.test-helper'
6
+
7
+ const kit = setupRecorderDom()
8
+ const { mountProbe, start, advance, recordFor, deferStream, hidePage } = kit
9
+
10
+ describe('the duration ceiling', () => {
11
+ it('stops by itself at the ceiling and lands in review', async () => {
12
+ mountProbe({ maxDurationMs: 2000 })
13
+ await recordFor(1000)
14
+ expect(kit.api.status).toBe('recording')
15
+ await advance(1100)
16
+ expect(kit.api.status).toBe('review')
17
+ })
18
+
19
+ it('shows the elapsed time while recording', async () => {
20
+ mountProbe()
21
+ await recordFor(3000)
22
+ expect(kit.api.elapsedMs).toBeGreaterThanOrEqual(2750)
23
+ })
24
+ })
25
+
26
+ describe('cancellation token of getUserMedia (contract 2.1)', () => {
27
+ it('unmount while requesting: a late stream gets every track stopped at once', async () => {
28
+ deferStream()
29
+ const tree = mountProbe()
30
+ await start()
31
+ tree.unmount()
32
+ await act(async () => kit.resolveStream?.(kit.stream))
33
+ expect(kit.stream.track.stopCount).toBe(1)
34
+ })
35
+
36
+ it('page hidden while requesting: back to idle and the late kit.stream is stopped', async () => {
37
+ deferStream()
38
+ mountProbe()
39
+ await start()
40
+ hidePage()
41
+ expect(kit.api.status).toBe('idle')
42
+ await act(async () => kit.resolveStream?.(kit.stream))
43
+ expect(kit.stream.track.stopCount).toBe(1)
44
+ expect(kit.api.status).toBe('idle')
45
+ })
46
+
47
+ it('pagehide while requesting also cancels', async () => {
48
+ deferStream()
49
+ mountProbe()
50
+ await start()
51
+ act(() => {
52
+ window.dispatchEvent(new Event('pagehide'))
53
+ })
54
+ await act(async () => kit.resolveStream?.(kit.stream))
55
+ expect(kit.stream.track.stopCount).toBe(1)
56
+ expect(kit.api.status).toBe('idle')
57
+ })
58
+ })
59
+
60
+ describe('interruptions (contract 2.1)', () => {
61
+ it('track.onended while recording stops and goes to review', async () => {
62
+ mountProbe()
63
+ await recordFor(1000)
64
+ act(() => kit.stream.track.onended?.())
65
+ expect(kit.api.status).toBe('review')
66
+ })
67
+
68
+ it('a mute shorter than 3 s does not interrupt', async () => {
69
+ mountProbe()
70
+ await recordFor(1000)
71
+ act(() => kit.stream.track.onmute?.())
72
+ await advance(2900)
73
+ act(() => kit.stream.track.onunmute?.())
74
+ await advance(5000)
75
+ expect(kit.api.status).toBe('recording')
76
+ })
77
+
78
+ it('a mute that persists past 3 s interrupts', async () => {
79
+ mountProbe()
80
+ await recordFor(1000)
81
+ act(() => kit.stream.track.onmute?.())
82
+ await advance(2900)
83
+ expect(kit.api.status).toBe('recording')
84
+ await advance(200)
85
+ expect(kit.api.status).toBe('review')
86
+ })
87
+
88
+ it('page hidden while recording stops and goes to review', async () => {
89
+ mountProbe()
90
+ await recordFor(1000)
91
+ hidePage()
92
+ expect(kit.api.status).toBe('review')
93
+ })
94
+
95
+ it('pagehide while recording stops and goes to review', async () => {
96
+ mountProbe()
97
+ await recordFor(1000)
98
+ act(() => {
99
+ window.dispatchEvent(new Event('pagehide'))
100
+ })
101
+ expect(kit.api.status).toBe('review')
102
+ })
103
+
104
+ it('page hidden in review pauses the player and KEEPS the recording', async () => {
105
+ mountProbe()
106
+ await recordFor(1000)
107
+ act(() => kit.api.stop())
108
+ let paused = 0
109
+ ;(kit.api.audioRef as { current: unknown }).current = { pause: () => (paused += 1) }
110
+ hidePage()
111
+ expect(paused).toBe(1)
112
+ expect(kit.api.status).toBe('review')
113
+ expect(kit.api.recorded?.file.size).toBe(4096)
114
+ expect(kit.revoked).toEqual([])
115
+ })
116
+ })
117
+
118
+ describe('unmount discards everything', () => {
119
+ it('while recording: tracks stopped, capture reported off', async () => {
120
+ const tree = mountProbe()
121
+ await recordFor(1000)
122
+ tree.unmount()
123
+ expect(kit.stream.track.stopCount).toBeGreaterThan(0)
124
+ expect(kit.captureEvents.at(-1)).toBe(false)
125
+ })
126
+
127
+ it('in review: the object URL is revoked', async () => {
128
+ const tree = mountProbe()
129
+ await recordFor(1000)
130
+ act(() => kit.api.stop())
131
+ tree.unmount()
132
+ expect(kit.revoked).toEqual(['blob:fake-recording'])
133
+ expect(kit.captureEvents.at(-1)).toBe(false)
134
+ })
135
+ })
@@ -0,0 +1,110 @@
1
+ import { act } from 'react'
2
+
3
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest } from 'bun:test'
4
+
5
+ import { setupDom, type DomHarness } from '../domHarness.test-helper'
6
+ import { SpecMediaRecorder, SpecStream } from './participantSpecRecorderFake.test-helper'
7
+ import { useParticipantAudioRecorder } from './useParticipantAudioRecorder'
8
+
9
+ let harness: DomHarness
10
+ let api: ReturnType<typeof useParticipantAudioRecorder>
11
+ let stream: SpecStream
12
+
13
+ let probeMaxDurationMs: number | undefined
14
+
15
+ function Probe() {
16
+ api = useParticipantAudioRecorder(
17
+ probeMaxDurationMs === undefined ? {} : { options: { maxDurationMs: probeMaxDurationMs } },
18
+ )
19
+ return null
20
+ }
21
+
22
+ async function flush(): Promise<void> {
23
+ await act(async () => {
24
+ await Promise.resolve()
25
+ await Promise.resolve()
26
+ })
27
+ }
28
+
29
+ async function startAndRecord(): Promise<void> {
30
+ harness.mount(<Probe />)
31
+ await act(async () => {
32
+ void api.start()
33
+ })
34
+ await act(async () => {
35
+ jest.advanceTimersByTime(1000)
36
+ })
37
+ }
38
+
39
+ function hidePage(): void {
40
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' })
41
+ act(() => {
42
+ document.dispatchEvent(new Event('visibilitychange'))
43
+ })
44
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' })
45
+ }
46
+
47
+ beforeAll(async () => {
48
+ harness = await setupDom()
49
+ })
50
+
51
+ afterAll(() => harness.teardown())
52
+
53
+ beforeEach(() => {
54
+ probeMaxDurationMs = undefined
55
+ jest.useFakeTimers()
56
+ SpecMediaRecorder.instances = []
57
+ stream = new SpecStream()
58
+ ;(globalThis as Record<string, unknown>).MediaRecorder = SpecMediaRecorder
59
+ ;(navigator as unknown as Record<string, unknown>).mediaDevices = { getUserMedia: async () => stream }
60
+ URL.createObjectURL = () => 'blob:fake-recording'
61
+ URL.revokeObjectURL = () => {}
62
+ })
63
+
64
+ afterEach(() => {
65
+ jest.useRealTimers()
66
+ delete (globalThis as Record<string, unknown>).MediaRecorder
67
+ })
68
+
69
+ describe('a recorder that follows the MediaRecorder specification (stop is asynchronous)', () => {
70
+ it('stopping twice still ends in review with the audio', async () => {
71
+ await startAndRecord()
72
+ act(() => {
73
+ api.stop()
74
+ api.stop()
75
+ })
76
+ await flush()
77
+ expect(api.status).toBe('review')
78
+ expect(api.recorded?.file.size).toBe(4096)
79
+ expect(SpecMediaRecorder.instances[0]?.stopCalls).toBe(1)
80
+ })
81
+
82
+ it('track.onended after the recorder stopped by itself still ends in review', async () => {
83
+ await startAndRecord()
84
+ SpecMediaRecorder.instances[0]?.autoStop()
85
+ act(() => stream.track.onended?.())
86
+ await flush()
87
+ expect(api.status).toBe('review')
88
+ expect(api.recorded?.file.size).toBe(4096)
89
+ })
90
+
91
+ it('the page becoming hidden right after Stop still ends in review', async () => {
92
+ await startAndRecord()
93
+ act(() => api.stop())
94
+ hidePage()
95
+ await flush()
96
+ expect(api.status).toBe('review')
97
+ expect(api.recorded?.file.size).toBe(4096)
98
+ })
99
+
100
+ it('the duration ceiling coinciding with Stop still ends in review', async () => {
101
+ probeMaxDurationMs = 3000
102
+ await startAndRecord()
103
+ act(() => api.stop())
104
+ await act(async () => {
105
+ jest.advanceTimersByTime(4000)
106
+ })
107
+ await flush()
108
+ expect(api.status).toBe('review')
109
+ })
110
+ })