@adatechnology/conversations-ui 0.8.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-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
- package/dist/{chunk-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
- package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
- package/dist/chunk-ZSOD5AQT.js +19 -0
- package/dist/flows/index.js +4 -2
- package/dist/index.d.ts +10 -3
- package/dist/index.js +8 -6
- package/dist/participant/index.d.ts +54 -3
- package/dist/participant/index.js +1722 -418
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +2 -2
- package/dist/styles.css +281 -23
- package/package.json +3 -2
- package/src/AudioRecorderButton.tsx +4 -28
- package/src/Tooltip.tsx +5 -12
- package/src/TooltipDom.test.tsx +78 -0
- package/src/audioRecorderFormat.ts +27 -0
- package/src/domHarness.test-helper.ts +53 -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/ParticipantBackButton.tsx +1 -1
- package/src/participant/ParticipantBubbleBody.tsx +54 -0
- package/src/participant/ParticipantBubbleStatus.tsx +22 -28
- package/src/participant/ParticipantChannelBadges.tsx +10 -2
- package/src/participant/ParticipantComposer.tsx +51 -16
- package/src/participant/ParticipantComposerAudio.tsx +95 -0
- package/src/participant/ParticipantComposerControls.tsx +2 -0
- package/src/participant/ParticipantConversations.tsx +3 -0
- package/src/participant/ParticipantCopyableToken.tsx +1 -0
- package/src/participant/ParticipantFailedMenu.tsx +40 -0
- package/src/participant/ParticipantFailedMenuList.tsx +41 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
- package/src/participant/ParticipantMessageBubble.tsx +20 -52
- package/src/participant/ParticipantMessageText.test.tsx +1 -1
- package/src/participant/ParticipantMessageText.tsx +4 -2
- package/src/participant/ParticipantProtocolBadge.tsx +1 -0
- package/src/participant/ParticipantRecorderParts.tsx +118 -0
- package/src/participant/ParticipantScrollToLatest.tsx +1 -1
- package/src/participant/ParticipantThread.tsx +30 -12
- package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
- package/src/participant/ParticipantThreadScreen.tsx +1 -0
- package/src/participant/failedMenuKeyboard.ts +29 -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/participantFailedMenuDom.test.tsx +154 -0
- package/src/participant/participantFailedMenuState.test.ts +61 -0
- package/src/participant/participantFailedMenuState.ts +57 -0
- package/src/participant/participantFailedMessage.test.tsx +83 -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 +10 -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 +17 -0
- package/src/participant/participantTooltips.test.tsx +74 -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-avatars-initials.golden.html +1 -1
- package/src/participant/test-fixtures/thread-closed.golden.html +1 -1
- package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
- package/src/participant/test-fixtures/thread-default.golden.html +1 -1
- package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
- package/src/participant/test-fixtures/thread-error.golden.html +1 -1
- package/src/participant/test-fixtures/thread-loading.golden.html +1 -1
- package/src/participant/test-fixtures/thread-plain.golden.html +1 -1
- package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -1
- package/src/participant/useFailedMenuEffects.ts +59 -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/useParticipantFailedMenu.ts +60 -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/useSuppressTouchTooltips.ts +38 -0
- package/src/participant/useTranscriptionOverflow.ts +25 -0
- package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
- package/src/styles.css +48 -5
- package/src/tooltipLayerRegistry.test.ts +56 -0
- package/src/tooltipLayerRegistry.ts +34 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { act } from 'react'
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { pressKey, wait } from '../domHarness.test-helper'
|
|
6
|
+
import { ParticipantAudioRecorderView } from './ParticipantAudioRecorderView'
|
|
7
|
+
import { DEFAULT_PARTICIPANT_AUDIO_LABELS } from './participantAudioLabels'
|
|
8
|
+
import { buildAttachment, setupPlayerDom } from './participantAudioPlayerDomKit.test-helper'
|
|
9
|
+
|
|
10
|
+
const kit = setupPlayerDom()
|
|
11
|
+
const { mountAttachments, audioOf, toggleOf, find, tap } = kit
|
|
12
|
+
|
|
13
|
+
describe('time and range', () => {
|
|
14
|
+
it('shows the server duration before the first tap', async () => {
|
|
15
|
+
await mountAttachments([buildAttachment('a', 65_000)])
|
|
16
|
+
expect(find('.cv-p-audio__time').textContent).toBe('0:00 / 1:05')
|
|
17
|
+
expect(find<HTMLInputElement>('.cv-p-audio__seek').getAttribute('max')).toBe('65')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('uses loadedmetadata when the server sent no duration', async () => {
|
|
21
|
+
await mountAttachments([buildAttachment('a')])
|
|
22
|
+
expect(find('.cv-p-audio__time').textContent).toBe('0:00 / --:--')
|
|
23
|
+
kit.media.stateOf(audioOf()).duration = 42
|
|
24
|
+
act(() => kit.media.emit(audioOf(), 'loadedmetadata'))
|
|
25
|
+
expect(find('.cv-p-audio__time').textContent).toBe('0:00 / 0:42')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('follows timeupdate and seeks by dragging the range', async () => {
|
|
29
|
+
await mountAttachments([buildAttachment('a', 65_000)])
|
|
30
|
+
kit.media.stateOf(audioOf()).currentTime = 12
|
|
31
|
+
act(() => kit.media.emit(audioOf(), 'timeupdate'))
|
|
32
|
+
expect(find('.cv-p-audio__time').textContent).toBe('0:12 / 1:05')
|
|
33
|
+
expect(find('.cv-p-audio__seek').getAttribute('aria-valuetext')).toBe('0:12 of 1:05')
|
|
34
|
+
const range = find<HTMLInputElement>('.cv-p-audio__seek')
|
|
35
|
+
act(() => {
|
|
36
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(range, '30')
|
|
37
|
+
range.dispatchEvent(new Event('input', { bubbles: true }))
|
|
38
|
+
})
|
|
39
|
+
expect(kit.media.stateOf(audioOf()).currentTime).toBe(30)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('steps 5 seconds with the arrows and jumps with Home and End', async () => {
|
|
43
|
+
await mountAttachments([buildAttachment('a', 65_000)])
|
|
44
|
+
const range = find('.cv-p-audio__seek')
|
|
45
|
+
pressKey(range, 'ArrowRight')
|
|
46
|
+
expect(kit.media.stateOf(audioOf()).currentTime).toBe(5)
|
|
47
|
+
pressKey(range, 'ArrowRight')
|
|
48
|
+
pressKey(range, 'ArrowLeft')
|
|
49
|
+
expect(kit.media.stateOf(audioOf()).currentTime).toBe(5)
|
|
50
|
+
pressKey(range, 'End')
|
|
51
|
+
expect(kit.media.stateOf(audioOf()).currentTime).toBe(65)
|
|
52
|
+
pressKey(range, 'Home')
|
|
53
|
+
expect(kit.media.stateOf(audioOf()).currentTime).toBe(0)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('tolerates an infinite duration: no broken max, value text with the position only', async () => {
|
|
57
|
+
await mountAttachments([buildAttachment('a')])
|
|
58
|
+
kit.media.stateOf(audioOf()).duration = Number.POSITIVE_INFINITY
|
|
59
|
+
act(() => kit.media.emit(audioOf(), 'durationchange'))
|
|
60
|
+
kit.media.stateOf(audioOf()).currentTime = 12
|
|
61
|
+
act(() => kit.media.emit(audioOf(), 'timeupdate'))
|
|
62
|
+
const range = find('.cv-p-audio__seek')
|
|
63
|
+
expect(range.getAttribute('max')).toBe('12')
|
|
64
|
+
expect(range.getAttribute('aria-valuetext')).toBe('0:12')
|
|
65
|
+
expect(find('.cv-p-audio__time').textContent).toBe('0:12 / --:--')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('speed', () => {
|
|
70
|
+
it('cycles 1x, 1.5x, 2x applying defaultPlaybackRate and playbackRate', async () => {
|
|
71
|
+
await mountAttachments([buildAttachment('a')])
|
|
72
|
+
const rate = find('.cv-p-audio__rate')
|
|
73
|
+
expect(rate.textContent).toBe('1×')
|
|
74
|
+
tap(rate)
|
|
75
|
+
expect(rate.textContent).toBe('1.5×')
|
|
76
|
+
expect(rate.getAttribute('aria-label')).toBe('Playback speed 1.5×')
|
|
77
|
+
expect(kit.media.stateOf(audioOf()).defaultPlaybackRate).toBe(1.5)
|
|
78
|
+
expect(kit.media.stateOf(audioOf()).playbackRate).toBe(1.5)
|
|
79
|
+
tap(rate)
|
|
80
|
+
expect(kit.media.stateOf(audioOf()).playbackRate).toBe(2)
|
|
81
|
+
tap(rate)
|
|
82
|
+
expect(kit.media.stateOf(audioOf()).playbackRate).toBe(1)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('expired signed URL', () => {
|
|
87
|
+
it('re-resolves once on a media error, swaps the src and resumes from the same position', async () => {
|
|
88
|
+
await mountAttachments([buildAttachment('a', 65_000)])
|
|
89
|
+
tap(toggleOf())
|
|
90
|
+
kit.media.stateOf(audioOf()).currentTime = 12
|
|
91
|
+
act(() => kit.media.emit(audioOf(), 'error'))
|
|
92
|
+
await wait(0)
|
|
93
|
+
expect(kit.resolverCalls).toEqual(['a', 'a'])
|
|
94
|
+
expect(audioOf().getAttribute('src')).toBe('https://files.test/a-2.webm')
|
|
95
|
+
expect(kit.media.stateOf(audioOf()).currentTime).toBe(12)
|
|
96
|
+
expect(kit.media.playCalls.length).toBe(2)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('gives up after the single retry and says the audio is unavailable', async () => {
|
|
100
|
+
await mountAttachments([buildAttachment('a', 65_000)])
|
|
101
|
+
tap(toggleOf())
|
|
102
|
+
act(() => kit.media.emit(audioOf(), 'error'))
|
|
103
|
+
await wait(0)
|
|
104
|
+
act(() => kit.media.emit(audioOf(), 'error'))
|
|
105
|
+
await wait(0)
|
|
106
|
+
expect(kit.resolverCalls).toEqual(['a', 'a'])
|
|
107
|
+
expect(kit.container.textContent).toContain(DEFAULT_PARTICIPANT_AUDIO_LABELS.audioUnavailable)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('recorder review reuses the same player', () => {
|
|
112
|
+
it('draws the player with the object URL, hands the audio element to the recorder ref and shows the recorded duration', () => {
|
|
113
|
+
const audioRef: { current: HTMLAudioElement | null } = { current: null }
|
|
114
|
+
kit.container = kit.harness.mount(
|
|
115
|
+
<ParticipantAudioRecorderView state="review" elapsedMs={0} review={{ url: 'blob:fake', audioRef, durationMs: 12_000 }} />,
|
|
116
|
+
).container
|
|
117
|
+
expect(kit.container.querySelector('.cv-p-audio')).not.toBeNull()
|
|
118
|
+
expect(audioOf().getAttribute('src')).toBe('blob:fake')
|
|
119
|
+
expect(audioOf().getAttribute('preload')).toBe('none')
|
|
120
|
+
expect(audioRef.current).toBe(audioOf())
|
|
121
|
+
expect(find('.cv-p-audio__time').textContent).toBe('0:00 / 0:12')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from 'bun:test'
|
|
5
|
+
|
|
6
|
+
import { declarationsIn, mediaBody, PARTICIPANT_CSS } from './participantCss.test-helper'
|
|
7
|
+
|
|
8
|
+
describe('player styles (.cv-p-audio*)', () => {
|
|
9
|
+
it('keeps both buttons and the bar at the touch size, from the existing tokens', () => {
|
|
10
|
+
for (const selector of ['.cv-p-audio__seek']) expect(declarationsIn(selector).get('min-height')).toBe('var(--cv-p-i-touch)')
|
|
11
|
+
expect(PARTICIPANT_CSS).toContain('min-width: var(--cv-p-i-touch); min-height: var(--cv-p-i-touch)')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('has a width floor that never exceeds the bubble, so the play button stays visible at 375 px', () => {
|
|
15
|
+
expect(declarationsIn('.cv-p-audio').get('min-width')).toBe('min(15rem, 100%)')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('does not draw the media element and defines no utility-class dependency', () => {
|
|
19
|
+
expect(declarationsIn('.cv-p-audio__media').get('display')).toBe('none')
|
|
20
|
+
expect(PARTICIPANT_CSS).not.toMatch(/@apply|\btw-/)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('transcription styles (.cv-p-transcription*)', () => {
|
|
25
|
+
it('clamps the text at six lines and keeps it selectable', () => {
|
|
26
|
+
const text = declarationsIn('.cv-p-transcription__text')
|
|
27
|
+
expect(text.get('-webkit-line-clamp')).toBe('6')
|
|
28
|
+
expect(text.get('user-select')).toBe('text')
|
|
29
|
+
expect(declarationsIn(".cv-p-transcription__text[data-expanded='true']").get('overflow')).toBe('visible')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('gives the see-all button the touch size and a forced-colors outline, from existing tokens', () => {
|
|
33
|
+
const toggle = declarationsIn('.cv-p-transcription__toggle')
|
|
34
|
+
expect(toggle.get('min-height')).toBe('var(--cv-p-i-touch)')
|
|
35
|
+
expect(toggle.get('min-width')).toBe('var(--cv-p-i-touch)')
|
|
36
|
+
expect(mediaBody('(forced-colors: active)')).toContain('.cv-p-transcription__toggle')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('has no motion, so reduced-motion is respected by construction', () => {
|
|
40
|
+
expect(PARTICIPANT_CSS).not.toMatch(/\.cv-p-transcription[^{]*\{[^}]*(transition|animation)/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('uses only existing tokens and no utility-class dependency', () => {
|
|
44
|
+
const rules = PARTICIPANT_CSS.split('\n').filter((line) => line.startsWith('.cv-p-transcription'))
|
|
45
|
+
expect(rules.length).toBeGreaterThan(5)
|
|
46
|
+
for (const variable of rules.join('\n').match(/var\(--cv-p-i-[a-z-]+\)/g) ?? []) {
|
|
47
|
+
expect(PARTICIPANT_CSS).toContain(`${variable.slice(4, -1)}:`)
|
|
48
|
+
}
|
|
49
|
+
expect(rules.join('\n')).not.toMatch(/@apply|\btw-/)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('no dead audio selectors', () => {
|
|
54
|
+
it('every .cv-p-recorder*, .cv-p-audio* and .cv-p-transcription* class is rendered by a component', () => {
|
|
55
|
+
const sources = readdirSync(import.meta.dir)
|
|
56
|
+
.filter((name) => name.endsWith('.tsx') && !name.includes('.test'))
|
|
57
|
+
.map((name) => readFileSync(join(import.meta.dir, name), 'utf8'))
|
|
58
|
+
.join('\n')
|
|
59
|
+
const classNames = new Set(PARTICIPANT_CSS.match(/\.cv-p-(?:recorder|audio|transcription)[a-z_-]*/g) ?? [])
|
|
60
|
+
expect(classNames.size).toBeGreaterThan(10)
|
|
61
|
+
for (const selector of classNames) expect(sources).toContain(selector.slice(1))
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -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
|
+
})
|