@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.
- package/README.md +106 -0
- package/dist/{ConversationSimulatorPanel-q-wVgBCN.d.ts → ConversationSimulatorPanel-P6eqTSAO.d.ts} +1 -16
- package/dist/{chunk-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
- package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
- package/dist/index.d.ts +10 -3
- package/dist/index.js +3 -3
- package/dist/participant/index.d.ts +51 -3
- package/dist/participant/index.js +1363 -319
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +2 -2
- package/dist/styles.css +182 -0
- package/package.json +2 -2
- package/src/AudioRecorderButton.tsx +4 -28
- package/src/audioRecorderFormat.ts +27 -0
- package/src/participant/ConversationThread.tsx +4 -0
- package/src/participant/ParticipantAttachmentItem.tsx +7 -10
- package/src/participant/ParticipantAudioAttachment.tsx +34 -0
- package/src/participant/ParticipantAudioPlayer.tsx +68 -0
- package/src/participant/ParticipantAudioPlayerControls.tsx +68 -0
- package/src/participant/ParticipantAudioRecorderView.tsx +55 -0
- package/src/participant/ParticipantAudioTranscription.tsx +90 -0
- package/src/participant/ParticipantComposer.tsx +50 -15
- package/src/participant/ParticipantComposerAudio.tsx +95 -0
- package/src/participant/ParticipantConversations.tsx +3 -0
- package/src/participant/ParticipantRecorderParts.tsx +118 -0
- package/src/participant/ParticipantThread.tsx +7 -0
- package/src/participant/ParticipantThreadScreen.tsx +1 -0
- package/src/participant/index.ts +3 -0
- package/src/participant/participantAudio.types.ts +10 -0
- package/src/participant/participantAudioCapture.test.ts +15 -0
- package/src/participant/participantAudioCapture.ts +85 -0
- package/src/participant/participantAudioCoordinator.ts +13 -0
- package/src/participant/participantAudioFormat.test.ts +93 -0
- package/src/participant/participantAudioFormat.ts +72 -0
- package/src/participant/participantAudioGolden.test.tsx +168 -0
- package/src/participant/participantAudioLabels.test.ts +22 -0
- package/src/participant/participantAudioLabels.ts +76 -0
- package/src/participant/participantAudioPlayerControlsDom.test.tsx +123 -0
- package/src/participant/participantAudioPlayerCss.test.ts +63 -0
- package/src/participant/participantAudioPlayerDom.test.tsx +154 -0
- package/src/participant/participantAudioPlayerDomKit.test-helper.tsx +84 -0
- package/src/participant/participantAudioPlayerState.ts +35 -0
- package/src/participant/participantAudioRecorderDom.test.tsx +127 -0
- package/src/participant/participantAudioRecorderInterruptionDom.test.tsx +135 -0
- package/src/participant/participantAudioRecorderSpecDom.test.tsx +110 -0
- package/src/participant/participantAudioRecorderState.test.ts +144 -0
- package/src/participant/participantAudioRecorderState.ts +93 -0
- package/src/participant/participantAudioRecorderView.test.tsx +98 -0
- package/src/participant/participantAudioSupport.ts +36 -0
- package/src/participant/participantAudioTranscriptionDom.test.tsx +198 -0
- package/src/participant/participantComposerAudioDom.test.tsx +185 -0
- package/src/participant/participantComposerAudioGolden.test.tsx +68 -0
- package/src/participant/participantComposerAudioSsr.test.tsx +74 -0
- package/src/participant/participantFileValidation.ts +1 -1
- package/src/participant/participantGolden.test.tsx +2 -0
- package/src/participant/participantGoldenScenarios.test-helper.tsx +14 -1
- package/src/participant/participantLabels.ts +4 -1
- package/src/participant/participantMediaFakes.test-helper.ts +82 -0
- package/src/participant/participantNoMicrophoneDom.test.tsx +44 -0
- package/src/participant/participantPublicTypes.test.ts +11 -0
- package/src/participant/participantRecorderDomKit.test-helper.tsx +131 -0
- package/src/participant/participantRecorderFakes.test-helper.ts +47 -0
- package/src/participant/participantSpecRecorderFake.test-helper.ts +52 -0
- package/src/participant/participantThemeContrast.test.ts +9 -0
- package/src/participant/test-fixtures/composer-audio-denied.golden.html +1 -0
- package/src/participant/test-fixtures/composer-audio-idle.golden.html +1 -0
- package/src/participant/test-fixtures/composer-audio-recording.golden.html +1 -0
- package/src/participant/test-fixtures/composer-audio-review.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-native.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-player.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-empty.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-pending.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-ready.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-too_long.golden.html +1 -0
- package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
- package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
- package/src/participant/useParticipantAudioMediaEvents.ts +46 -0
- package/src/participant/useParticipantAudioPlayer.ts +52 -0
- package/src/participant/useParticipantAudioPlayerActions.ts +47 -0
- package/src/participant/useParticipantAudioRecorder.ts +153 -0
- package/src/participant/useParticipantAudioRecorderCore.ts +55 -0
- package/src/participant/useParticipantAudioRecorderEffects.ts +70 -0
- package/src/participant/useParticipantAudioSourceRetry.ts +42 -0
- package/src/participant/useParticipantSendAudio.test.tsx +85 -0
- package/src/participant/useParticipantSendAudio.ts +28 -0
- package/src/participant/useParticipantThreadController.ts +20 -7
- package/src/participant/useStableAttachmentUrl.ts +71 -0
- package/src/participant/useTranscriptionOverflow.ts +25 -0
- package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
- 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
|
+
})
|