@ephia/dova-sdk 1.0.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 (247) hide show
  1. package/README.md +89 -0
  2. package/dist/EphiaBinding-BvRmlqqC.d.ts +36 -0
  3. package/dist/EphiaFloatingButton-CxiF86VW.d.ts +65 -0
  4. package/dist/EphiaTextarea-B4_CAVUg.d.ts +183 -0
  5. package/dist/NativeBinding-ChG0GeSz.d.ts +53 -0
  6. package/dist/TargetBinding-BKGQwUMc.d.ts +89 -0
  7. package/dist/TiptapBinding-B-agfV2H.d.ts +45 -0
  8. package/dist/Transport-zdeA4Pou.d.ts +63 -0
  9. package/dist/audio-state-kZ3KSvux.d.ts +39 -0
  10. package/dist/chunk-35AJK2IO.js +1 -0
  11. package/dist/chunk-35AJK2IO.js.map +1 -0
  12. package/dist/chunk-3LXZODL4.js +886 -0
  13. package/dist/chunk-3LXZODL4.js.map +1 -0
  14. package/dist/chunk-5IK5TLSK.js +67 -0
  15. package/dist/chunk-5IK5TLSK.js.map +1 -0
  16. package/dist/chunk-7E43RY75.js +9 -0
  17. package/dist/chunk-7E43RY75.js.map +1 -0
  18. package/dist/chunk-A5UEXJ5R.js +183 -0
  19. package/dist/chunk-A5UEXJ5R.js.map +1 -0
  20. package/dist/chunk-AEE554FT.js +51 -0
  21. package/dist/chunk-AEE554FT.js.map +1 -0
  22. package/dist/chunk-DIEWY3IT.js +1332 -0
  23. package/dist/chunk-DIEWY3IT.js.map +1 -0
  24. package/dist/chunk-EGIAN7FH.js +18 -0
  25. package/dist/chunk-EGIAN7FH.js.map +1 -0
  26. package/dist/chunk-EMOEAPVU.js +486 -0
  27. package/dist/chunk-EMOEAPVU.js.map +1 -0
  28. package/dist/chunk-IDC7FHIZ.js +40 -0
  29. package/dist/chunk-IDC7FHIZ.js.map +1 -0
  30. package/dist/chunk-ITJFN3VM.js +601 -0
  31. package/dist/chunk-ITJFN3VM.js.map +1 -0
  32. package/dist/chunk-K24GNU27.js +22 -0
  33. package/dist/chunk-K24GNU27.js.map +1 -0
  34. package/dist/chunk-LXMCRXXF.js +778 -0
  35. package/dist/chunk-LXMCRXXF.js.map +1 -0
  36. package/dist/chunk-MJCEOOLW.js +122 -0
  37. package/dist/chunk-MJCEOOLW.js.map +1 -0
  38. package/dist/chunk-N7U5M3VZ.js +33 -0
  39. package/dist/chunk-N7U5M3VZ.js.map +1 -0
  40. package/dist/chunk-PSYX674B.js +27 -0
  41. package/dist/chunk-PSYX674B.js.map +1 -0
  42. package/dist/chunk-RFQRV7ML.js +33 -0
  43. package/dist/chunk-RFQRV7ML.js.map +1 -0
  44. package/dist/chunk-THNHRV2B.js +18 -0
  45. package/dist/chunk-THNHRV2B.js.map +1 -0
  46. package/dist/chunk-VSLGR64U.js +62 -0
  47. package/dist/chunk-VSLGR64U.js.map +1 -0
  48. package/dist/chunk-W2ZP674X.js +346 -0
  49. package/dist/chunk-W2ZP674X.js.map +1 -0
  50. package/dist/chunk-YWZUMUYE.js +695 -0
  51. package/dist/chunk-YWZUMUYE.js.map +1 -0
  52. package/dist/client-options-Uo6jXO8k.d.ts +64 -0
  53. package/dist/connection-state-Bk33YprE.d.ts +32 -0
  54. package/dist/core/bindings/index.d.ts +24 -0
  55. package/dist/core/bindings/index.js +1025 -0
  56. package/dist/core/bindings/index.js.map +1 -0
  57. package/dist/core/index.d.ts +383 -0
  58. package/dist/core/index.js +1284 -0
  59. package/dist/core/index.js.map +1 -0
  60. package/dist/createEphiaClient-BhdZ183V.d.ts +69 -0
  61. package/dist/devices/speechmike/index.d.ts +148 -0
  62. package/dist/devices/speechmike/index.js +40 -0
  63. package/dist/devices/speechmike/index.js.map +1 -0
  64. package/dist/headless/index.d.ts +10 -0
  65. package/dist/headless/index.js +25 -0
  66. package/dist/headless/index.js.map +1 -0
  67. package/dist/index.d.ts +18 -0
  68. package/dist/index.js +119 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/react/index.d.ts +38 -0
  71. package/dist/react/index.js +70 -0
  72. package/dist/react/index.js.map +1 -0
  73. package/dist/rich-editor/index.d.ts +46 -0
  74. package/dist/rich-editor/index.js +13 -0
  75. package/dist/rich-editor/index.js.map +1 -0
  76. package/dist/schema-B2ycPlNB.d.ts +87 -0
  77. package/dist/session-APaXR48R.d.ts +12 -0
  78. package/dist/shared/index.d.ts +16 -0
  79. package/dist/shared/index.js +30 -0
  80. package/dist/shared/index.js.map +1 -0
  81. package/dist/style.css +1093 -0
  82. package/dist/testing/index.d.ts +84 -0
  83. package/dist/testing/index.js +36 -0
  84. package/dist/testing/index.js.map +1 -0
  85. package/dist/types-D5SXPSwR.d.ts +32 -0
  86. package/dist/ui/index.d.ts +30 -0
  87. package/dist/ui/index.js +34 -0
  88. package/dist/ui/index.js.map +1 -0
  89. package/dist/useEphiaSpeechMike-CjD7DWnh.d.ts +64 -0
  90. package/package.json +110 -0
  91. package/src/core/audio/audio-worklet-source.ts +30 -0
  92. package/src/core/audio/index.ts +3 -0
  93. package/src/core/audio/voice-level-meter.test.ts +27 -0
  94. package/src/core/audio/voice-level-meter.ts +270 -0
  95. package/src/core/bindings/EphiaBinding.ts +41 -0
  96. package/src/core/bindings/SegmentBindingBridge.test.ts +422 -0
  97. package/src/core/bindings/SegmentBindingBridge.ts +377 -0
  98. package/src/core/bindings/TargetBinding.ts +142 -0
  99. package/src/core/bindings/adapters/NativeAdapter.test.ts +85 -0
  100. package/src/core/bindings/adapters/NativeAdapter.ts +216 -0
  101. package/src/core/bindings/adapters/ProseMirrorAdapter.ts +231 -0
  102. package/src/core/bindings/adapters/index.ts +2 -0
  103. package/src/core/bindings/binding-factory.ts +78 -0
  104. package/src/core/bindings/detect-editor-type.ts +87 -0
  105. package/src/core/bindings/index.ts +13 -0
  106. package/src/core/bindings/insertion-boundary.test.ts +38 -0
  107. package/src/core/bindings/insertion-boundary.ts +26 -0
  108. package/src/core/bindings/native/NativeBinding.test.ts +277 -0
  109. package/src/core/bindings/native/NativeBinding.ts +239 -0
  110. package/src/core/bindings/resolver.ts +18 -0
  111. package/src/core/bindings/targets/codemirror.binding.ts +293 -0
  112. package/src/core/bindings/targets/contenteditable.binding.ts +452 -0
  113. package/src/core/bindings/targets/index.ts +10 -0
  114. package/src/core/bindings/targets/monaco.binding.ts +315 -0
  115. package/src/core/bindings/targets/tiptap.binding.test.ts +417 -0
  116. package/src/core/bindings/targets/tiptap.binding.ts +1192 -0
  117. package/src/core/bindings/tiptap/TiptapBinding.test.ts +63 -0
  118. package/src/core/bindings/tiptap/TiptapBinding.ts +464 -0
  119. package/src/core/bindings/types.ts +41 -0
  120. package/src/core/client/EphiaAudioClient.ts +654 -0
  121. package/src/core/client/audio-capture.ts +263 -0
  122. package/src/core/client/client-options.ts +39 -0
  123. package/src/core/client/client-state.ts +18 -0
  124. package/src/core/client/constants.ts +23 -0
  125. package/src/core/client/session-api.ts +415 -0
  126. package/src/core/connection/connection-state.ts +78 -0
  127. package/src/core/connection/index.ts +6 -0
  128. package/src/core/index.ts +47 -0
  129. package/src/core/operations/textToDocumentOperations.test.ts +69 -0
  130. package/src/core/operations/textToDocumentOperations.ts +92 -0
  131. package/src/core/runtime/DictationRuntime.test.ts +578 -0
  132. package/src/core/runtime/DictationRuntime.ts +434 -0
  133. package/src/core/runtime/TranscriptApplier.test.ts +355 -0
  134. package/src/core/runtime/TranscriptApplier.ts +229 -0
  135. package/src/core/runtime/index.ts +18 -0
  136. package/src/core/session/index.ts +2 -0
  137. package/src/core/session/session-machine.test.ts +16 -0
  138. package/src/core/session/session-machine.ts +59 -0
  139. package/src/core/targets/EditorContextCollector.ts +71 -0
  140. package/src/core/targets/TargetManager.test.ts +194 -0
  141. package/src/core/targets/TargetManager.ts +194 -0
  142. package/src/core/targets/index.ts +10 -0
  143. package/src/core/text-processing/index.ts +11 -0
  144. package/src/core/text-processing/overlap.test.ts +35 -0
  145. package/src/core/text-processing/overlap.ts +101 -0
  146. package/src/core/text-processing/voice-formatting.normalizer.test.ts +132 -0
  147. package/src/core/text-processing/voice-formatting.normalizer.ts +284 -0
  148. package/src/core/transcript/client-transcript.reducer.ts +366 -0
  149. package/src/core/transcript/client-transcript.state.ts +25 -0
  150. package/src/core/transcript/index.ts +19 -0
  151. package/src/core/transcript/transcript.assembler.test.ts +205 -0
  152. package/src/core/transcript/transcript.assembler.ts +152 -0
  153. package/src/core/transcript/transcript.reducer.test.ts +199 -0
  154. package/src/core/transcript/transcript.reducer.ts +771 -0
  155. package/src/core/transcript/transcript.state.ts +123 -0
  156. package/src/core/transport/LiveKitTransport.publish.test.ts +226 -0
  157. package/src/core/transport/LiveKitTransport.ts +459 -0
  158. package/src/core/transport/MockTransport.ts +231 -0
  159. package/src/core/transport/Transport.ts +82 -0
  160. package/src/debug/sdk-debug-collector.ts +79 -0
  161. package/src/devices/index.ts +2 -0
  162. package/src/devices/speechmike/__tests__/EphiaSpeechMikeProvider.test.tsx +99 -0
  163. package/src/devices/speechmike/__tests__/speechmike-audio-resolver.test.ts +96 -0
  164. package/src/devices/speechmike/__tests__/speechmike-button-router.test.ts +66 -0
  165. package/src/devices/speechmike/__tests__/speechmike-device-manager.test.ts +201 -0
  166. package/src/devices/speechmike/__tests__/speechmike-led-controller.test.ts +68 -0
  167. package/src/devices/speechmike/browser.ts +80 -0
  168. package/src/devices/speechmike/constants.ts +74 -0
  169. package/src/devices/speechmike/dictation-support-loader.ts +81 -0
  170. package/src/devices/speechmike/index.ts +11 -0
  171. package/src/devices/speechmike/react/EphiaSpeechMikeContext.ts +34 -0
  172. package/src/devices/speechmike/react/EphiaSpeechMikeProvider.tsx +287 -0
  173. package/src/devices/speechmike/react/useEphiaSpeechMike.ts +26 -0
  174. package/src/devices/speechmike/speechmike-audio-resolver.ts +58 -0
  175. package/src/devices/speechmike/speechmike-button-router.ts +73 -0
  176. package/src/devices/speechmike/speechmike-device-manager.ts +461 -0
  177. package/src/devices/speechmike/speechmike-led-controller.ts +78 -0
  178. package/src/devices/speechmike/types.ts +96 -0
  179. package/src/dictation_support.d.ts +31 -0
  180. package/src/global.d.ts +10 -0
  181. package/src/headless/createEphiaClient.ts +220 -0
  182. package/src/headless/index.ts +18 -0
  183. package/src/index.ts +89 -0
  184. package/src/react/EphiaAuto.tsx +87 -0
  185. package/src/react/components/EphiaDictationButton.tsx +88 -0
  186. package/src/react/components/EphiaStatusBar.tsx +59 -0
  187. package/src/react/components/EphiaTextarea.tsx +295 -0
  188. package/src/react/ephia-react.css +318 -0
  189. package/src/react/hooks/targets/index.ts +3 -0
  190. package/src/react/hooks/targets/useEphiaCodemirror.ts +35 -0
  191. package/src/react/hooks/targets/useEphiaMonaco.ts +35 -0
  192. package/src/react/hooks/targets/useEphiaTiptap.ts +23 -0
  193. package/src/react/hooks/useEphia.lifecycle.test.tsx +389 -0
  194. package/src/react/hooks/useEphia.ts +367 -0
  195. package/src/react/hooks/useEphiaDiscardTarget.ts +53 -0
  196. package/src/react/hooks/useEphiaServerEvent.ts +33 -0
  197. package/src/react/hooks/useEphiaTarget.ts +47 -0
  198. package/src/react/hooks/useEphiaTranscript.ts +22 -0
  199. package/src/react/index.ts +58 -0
  200. package/src/react/provider/EphiaContext.ts +63 -0
  201. package/src/react/provider/EphiaInternalContext.ts +32 -0
  202. package/src/react/provider/EphiaProvider.tsx +373 -0
  203. package/src/react/registry/binding-factory.ts +7 -0
  204. package/src/react/registry/detect-editor-type.ts +2 -0
  205. package/src/react/registry/events.ts +37 -0
  206. package/src/react/registry/registries/CodeMirrorInstanceRegistry.ts +24 -0
  207. package/src/react/registry/registries/MonacoInstanceRegistry.ts +23 -0
  208. package/src/react/registry/registries/TargetRegistry.ts +327 -0
  209. package/src/react/registry/registries/TiptapInstanceRegistry.ts +43 -0
  210. package/src/react/registry/registries/index.ts +5 -0
  211. package/src/react/store/create-ephia-store.ts +36 -0
  212. package/src/react/store/types.ts +41 -0
  213. package/src/react/utils/flash-range.ts +24 -0
  214. package/src/react/utils/index.ts +1 -0
  215. package/src/rich-editor/adapters/tiptap.test.ts +86 -0
  216. package/src/rich-editor/adapters/tiptap.ts +23 -0
  217. package/src/rich-editor/index.ts +3 -0
  218. package/src/rich-editor/types.ts +24 -0
  219. package/src/rich-editor/use-ephia-rich-editor.test.tsx +202 -0
  220. package/src/rich-editor/use-ephia-rich-editor.ts +47 -0
  221. package/src/shared/config/endpoint.test.ts +45 -0
  222. package/src/shared/config/endpoint.ts +39 -0
  223. package/src/shared/config/schema.ts +32 -0
  224. package/src/shared/effective-text.ts +13 -0
  225. package/src/shared/errors/EphiaSdkError.ts +54 -0
  226. package/src/shared/errors/messages.ts +40 -0
  227. package/src/shared/index.ts +27 -0
  228. package/src/shared/state/audio-state.ts +45 -0
  229. package/src/shared/state/index.ts +2 -0
  230. package/src/shared/store/document-store.ts +32 -0
  231. package/src/shared/store/index.ts +2 -0
  232. package/src/shared/types/editors.ts +28 -0
  233. package/src/shared/types/session.ts +12 -0
  234. package/src/style.css +2 -0
  235. package/src/testing/index.tsx +60 -0
  236. package/src/ui/assets/ephia-logo.svg +4 -0
  237. package/src/ui/components/EphiaLogo.tsx +77 -0
  238. package/src/ui/index.ts +24 -0
  239. package/src/ui/primitives/Button.tsx +53 -0
  240. package/src/ui/primitives/Spinner.tsx +21 -0
  241. package/src/ui/primitives/index.ts +5 -0
  242. package/src/ui/recorder/EphiaFloatingButton.tsx +489 -0
  243. package/src/ui/recorder/MinimalProcessingBars.tsx +122 -0
  244. package/src/ui/recorder/StandardIntensityVisualizer.tsx +148 -0
  245. package/src/ui/recorder/appearance.ts +9 -0
  246. package/src/ui/recorder/index.ts +8 -0
  247. package/src/ui/theme.css +775 -0
@@ -0,0 +1,23 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import { createTiptapEphiaAdapter } from "../../../rich-editor/adapters/tiptap";
3
+ import { useEphiaRichEditor } from "../../../rich-editor/use-ephia-rich-editor";
4
+ import type { UseEphiaNativeTargetOptions } from "../useEphiaTarget";
5
+
6
+ /** @deprecated Use `UseEphiaNativeTargetOptions` instead. */
7
+ export type UseEphiaTargetOptions = UseEphiaNativeTargetOptions;
8
+
9
+ /**
10
+ * @deprecated Use `useEphiaRichEditor(createTiptapEphiaAdapter(editor), options)`.
11
+ *
12
+ * @example
13
+ * const ref = useEphiaRichEditor(
14
+ * createTiptapEphiaAdapter(editor),
15
+ * { id: 'compte-rendu', label: 'Compte rendu' },
16
+ * );
17
+ */
18
+ export function useEphiaTiptap(
19
+ editor: Editor | null,
20
+ options: UseEphiaNativeTargetOptions,
21
+ ): React.RefObject<HTMLDivElement | null> {
22
+ return useEphiaRichEditor(createTiptapEphiaAdapter(editor), options);
23
+ }
@@ -0,0 +1,389 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import React, { useEffect, useState } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { cleanup, render, screen, waitFor, act } from "@testing-library/react";
6
+ import { PROTOCOL_VERSION, type EphiaAudioEvent } from "ephia-protocol";
7
+ // NOTE: EphiaAudioEvent is kept for MockTransport helpers; onEvent prop is removed in V2.
8
+ import { EphiaProvider } from "../provider/EphiaProvider";
9
+ import { EphiaAudioClient } from "../../core/client/EphiaAudioClient";
10
+ import { useEphia } from "./useEphia";
11
+ import { useEphiaContext } from "../provider/EphiaContext";
12
+ import { EphiaDictationButton } from "../components/EphiaDictationButton";
13
+ import { MockTransport } from "../../core/transport/MockTransport";
14
+
15
+ class FakeAudioContext {
16
+ state: AudioContextState = "running";
17
+
18
+ createAnalyser(): AnalyserNode {
19
+ return {
20
+ fftSize: 0,
21
+ smoothingTimeConstant: 0,
22
+ frequencyBinCount: 8,
23
+ getByteTimeDomainData: (data: Uint8Array) => data.fill(128),
24
+ } as unknown as AnalyserNode;
25
+ }
26
+
27
+ createMediaStreamSource(): MediaStreamAudioSourceNode {
28
+ return {
29
+ connect: vi.fn(),
30
+ disconnect: vi.fn(),
31
+ } as unknown as MediaStreamAudioSourceNode;
32
+ }
33
+
34
+ resume(): Promise<void> {
35
+ return Promise.resolve();
36
+ }
37
+
38
+ close(): Promise<void> {
39
+ return Promise.resolve();
40
+ }
41
+ }
42
+
43
+ class FailingPublishTransport extends MockTransport {
44
+ async publishAudio(_track: MediaStreamTrack): Promise<void> {
45
+ throw new Error("publish failed");
46
+ }
47
+ }
48
+
49
+ function event<T extends EphiaAudioEvent["type"]>(
50
+ type: T,
51
+ payload: Extract<EphiaAudioEvent, { type: T }>["payload"],
52
+ seq = 1
53
+ ): Extract<EphiaAudioEvent, { type: T }> {
54
+ return {
55
+ protocolVersion: PROTOCOL_VERSION,
56
+ eventId: `00000000-0000-4000-8000-${String(seq).padStart(12, "0")}`,
57
+ sessionId: "test-session",
58
+ type,
59
+ seq,
60
+ timestampMs: Date.now(),
61
+ payload,
62
+ } as Extract<EphiaAudioEvent, { type: T }>;
63
+ }
64
+
65
+ function createMockMediaStream(): MediaStream {
66
+ const track = {
67
+ id: "track-1",
68
+ label: "Test microphone",
69
+ enabled: true,
70
+ muted: false,
71
+ readyState: "live",
72
+ getSettings: () => ({ deviceId: "device-1" }),
73
+ stop: vi.fn(),
74
+ } as unknown as MediaStreamTrack;
75
+ return {
76
+ getAudioTracks: () => [track],
77
+ getTracks: () => [track],
78
+ } as unknown as MediaStream;
79
+ }
80
+
81
+ function deferred<T>(): {
82
+ promise: Promise<T>;
83
+ resolve: (value: T) => void;
84
+ reject: (reason?: unknown) => void;
85
+ } {
86
+ let resolve!: (value: T) => void;
87
+ let reject!: (reason?: unknown) => void;
88
+ const promise = new Promise<T>((res, rej) => {
89
+ resolve = res;
90
+ reject = rej;
91
+ });
92
+ return { promise, resolve, reject };
93
+ }
94
+
95
+ class PauseAckBlockingTransport extends MockTransport {
96
+ private unpublishGate?: Promise<void>;
97
+ messages: Parameters<MockTransport["sendMessage"]>[0][] = [];
98
+
99
+ constructor(options: ConstructorParameters<typeof MockTransport>[0] = {}) {
100
+ super(options);
101
+ }
102
+
103
+ blockUnpublishUntil(promise: Promise<void>): void {
104
+ this.unpublishGate = promise;
105
+ }
106
+
107
+ async sendMessage(message: Parameters<MockTransport["sendMessage"]>[0]): Promise<void> {
108
+ this.messages.push(message);
109
+ if (message.type === "session.pause") {
110
+ setTimeout(() => {
111
+ this.simulateEvent(event("session.paused", {}, 100));
112
+ }, 0);
113
+ return;
114
+ }
115
+ if (message.type === "session.reset") {
116
+ setTimeout(() => {
117
+ this.simulateEvent(event("session.context.reset", { contextOnly: true }, 101));
118
+ }, 0);
119
+ return;
120
+ }
121
+ await super.sendMessage(message);
122
+ }
123
+
124
+ async unpublishAudio(): Promise<void> {
125
+ if (this.unpublishGate) {
126
+ await this.unpublishGate;
127
+ }
128
+ await super.unpublishAudio();
129
+ }
130
+ }
131
+
132
+ function installBrowserAudioMocks(): void {
133
+ vi.stubGlobal("navigator", {
134
+ mediaDevices: {
135
+ getUserMedia: vi.fn(async () => createMockMediaStream()),
136
+ },
137
+ });
138
+ Object.assign(window, {
139
+ AudioContext: FakeAudioContext,
140
+ webkitAudioContext: undefined,
141
+ });
142
+ }
143
+
144
+ function installDeferredBrowserAudioMocks(): ReturnType<typeof deferred<MediaStream>> {
145
+ const mic = deferred<MediaStream>();
146
+ vi.stubGlobal("navigator", {
147
+ mediaDevices: {
148
+ getUserMedia: vi.fn(() => mic.promise),
149
+ },
150
+ });
151
+ Object.assign(window, {
152
+ AudioContext: FakeAudioContext,
153
+ webkitAudioContext: undefined,
154
+ });
155
+ return mic;
156
+ }
157
+
158
+ function installCreateSessionMock(): void {
159
+ vi.stubGlobal(
160
+ "fetch",
161
+ vi.fn(async () =>
162
+ new Response(
163
+ JSON.stringify({
164
+ session_id: "test-session",
165
+ room_name: "room-1",
166
+ token: "token-1",
167
+ livekit_url: "wss://livekit.test",
168
+ }),
169
+ { status: 200 }
170
+ )
171
+ )
172
+ );
173
+ }
174
+
175
+ function installDomGlobals(): void {
176
+ vi.stubGlobal("Node", window.Node);
177
+ }
178
+
179
+ function StartOnMount({ onError }: { onError: (value: unknown) => void }) {
180
+ const { start, status, error } = useEphia();
181
+ useEffect(() => {
182
+ const timer = setTimeout(() => {
183
+ start().catch(onError);
184
+ }, 0);
185
+ return () => clearTimeout(timer);
186
+ }, [onError, start]);
187
+ return <output data-testid="status" data-error-code={error?.code ?? ""}>{status}</output>;
188
+ }
189
+
190
+ function ForceIdleButton() {
191
+ const { store } = useEphiaContext();
192
+ useEffect(() => {
193
+ store.getState()._setStatus("idle");
194
+ }, [store]);
195
+ return <EphiaDictationButton />;
196
+ }
197
+
198
+ function DictationControl() {
199
+ const { status } = useEphia();
200
+ return (
201
+ <>
202
+ <EphiaDictationButton />
203
+ <output data-testid="status">{status}</output>
204
+ </>
205
+ );
206
+ }
207
+
208
+ function DictationControlWithTarget() {
209
+ return (
210
+ <>
211
+ <textarea data-ephia-target="report" data-ephia-insertion="preview-inline" />
212
+ <DictationControl />
213
+ </>
214
+ );
215
+ }
216
+
217
+
218
+ describe("useEphia lifecycle", () => {
219
+ afterEach(() => {
220
+ cleanup();
221
+ document.body.removeAttribute("data-ephia-session-active");
222
+ vi.restoreAllMocks();
223
+ vi.unstubAllGlobals();
224
+ });
225
+
226
+ it("rolls the React store to error when start fails", async () => {
227
+ installDomGlobals();
228
+ installBrowserAudioMocks();
229
+ installCreateSessionMock();
230
+ const onError = vi.fn();
231
+
232
+ render(
233
+ <EphiaProvider
234
+ apiUrl="https://api.test"
235
+ transport={new FailingPublishTransport({ autoEmitReady: false, latencyMs: 0 })}
236
+ >
237
+ <StartOnMount onError={onError} />
238
+ </EphiaProvider>
239
+ );
240
+
241
+ await waitFor(() => expect(onError).toHaveBeenCalled());
242
+ await waitFor(() => expect(screen.getByTestId("status").textContent).toBe("error"));
243
+ expect(screen.getByTestId("status").getAttribute("data-error-code")).toBe(
244
+ "audio.track_publish_failed"
245
+ );
246
+ await waitFor(() =>
247
+ expect(onError.mock.calls[0]?.[0]).toMatchObject({
248
+ code: "audio.track_publish_failed",
249
+ })
250
+ );
251
+ });
252
+
253
+ it("sets processing during start, then recording once the session is connected", async () => {
254
+ installDomGlobals();
255
+ const mic = installDeferredBrowserAudioMocks();
256
+ installCreateSessionMock();
257
+
258
+ render(
259
+ <EphiaProvider
260
+ apiUrl="https://api.test"
261
+ transport={new MockTransport({ autoEmitReady: false, latencyMs: 0 })}
262
+ >
263
+ <DictationControl />
264
+ </EphiaProvider>
265
+ );
266
+
267
+ await act(async () => {
268
+ screen.getByRole("button", { name: "Démarrer la dictée" }).click();
269
+ });
270
+
271
+ expect(screen.getByTestId("status").textContent).toBe("processing");
272
+ expect(
273
+ (screen.getByRole("button", { name: "Démarrer la dictée" }) as HTMLButtonElement)
274
+ .disabled
275
+ ).toBe(true);
276
+
277
+ await act(async () => {
278
+ mic.resolve(createMockMediaStream());
279
+ });
280
+ await waitFor(() => expect(screen.getByTestId("status").textContent).toBe("recording"));
281
+ expect(
282
+ (screen.getByRole("button", { name: "Arrêter la dictée" }) as HTMLButtonElement)
283
+ .disabled
284
+ ).toBe(false);
285
+ });
286
+
287
+ it("sets processing during stop and returns to idle after the microphone is unpublished", async () => {
288
+ installDomGlobals();
289
+ installBrowserAudioMocks();
290
+ installCreateSessionMock();
291
+ const unpublish = deferred<void>();
292
+ const transport = new PauseAckBlockingTransport({
293
+ autoEmitReady: false,
294
+ latencyMs: 0,
295
+ });
296
+ transport.blockUnpublishUntil(unpublish.promise);
297
+
298
+ render(
299
+ <EphiaProvider apiUrl="https://api.test" transport={transport}>
300
+ <DictationControl />
301
+ </EphiaProvider>
302
+ );
303
+
304
+ await act(async () => {
305
+ screen.getByRole("button", { name: "Démarrer la dictée" }).click();
306
+ });
307
+ await waitFor(() => expect(screen.getByTestId("status").textContent).toBe("recording"));
308
+
309
+ await act(async () => {
310
+ screen.getByRole("button", { name: "Arrêter la dictée" }).click();
311
+ });
312
+
313
+ await waitFor(() => expect(screen.getByTestId("status").textContent).toBe("processing"));
314
+ expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true);
315
+
316
+ await act(async () => {
317
+ unpublish.resolve();
318
+ await new Promise((resolve) => setTimeout(resolve, 0));
319
+ });
320
+ await waitFor(() => expect(screen.getByTestId("status").textContent).toBe("idle"));
321
+ });
322
+
323
+
324
+
325
+ it("does not recreate client when sessionOptions reference changes but content is equal", async () => {
326
+ installDomGlobals();
327
+ installCreateSessionMock();
328
+
329
+ const disposeSpy = vi.spyOn(EphiaAudioClient.prototype, "dispose").mockResolvedValue(undefined);
330
+ let constructorCalls = 0;
331
+ const OriginalClient = EphiaAudioClient;
332
+ vi.spyOn(OriginalClient.prototype, "constructor" as never);
333
+ // Count instantiations by patching the constructor indirectly via a render counter.
334
+ // We use disposeSpy: dispose is called once per client teardown.
335
+
336
+ const stableTransport = new MockTransport();
337
+
338
+ function Wrapper() {
339
+ const [opts, setOpts] = useState({ mode: "smart_s" as const });
340
+ return (
341
+ <>
342
+ <button
343
+ data-testid="bump"
344
+ onClick={() => setOpts({ mode: "smart_s" })} // nouvelle ref, même contenu
345
+ />
346
+ <EphiaProvider
347
+ apiUrl="https://api.test"
348
+ transport={stableTransport}
349
+ options={{ sessionOptions: opts }}
350
+ >
351
+ <span data-testid="child" />
352
+ </EphiaProvider>
353
+ </>
354
+ );
355
+ }
356
+
357
+ render(<Wrapper />);
358
+ // Laisser le Provider monter (Strict Mode déclenche un dispose initial — ignoré)
359
+ await waitFor(() => expect(screen.getByTestId("child")).toBeTruthy());
360
+ disposeSpy.mockClear();
361
+
362
+ // Déclencher un re-render avec nouvelle référence mais même contenu
363
+ await act(async () => {
364
+ screen.getByTestId("bump").click();
365
+ });
366
+
367
+ // dispose ne doit pas avoir été appelé (le client n'est pas recréé)
368
+ expect(disposeSpy).not.toHaveBeenCalled();
369
+ });
370
+
371
+ it("keeps EphiaDictationButton enabled after idle", async () => {
372
+ installDomGlobals();
373
+ installCreateSessionMock();
374
+
375
+ render(
376
+ <EphiaProvider apiUrl="https://api.test" transport={new MockTransport()}>
377
+ <ForceIdleButton />
378
+ </EphiaProvider>
379
+ );
380
+
381
+ await waitFor(() => {
382
+ const button = screen.getByRole("button", {
383
+ name: "Démarrer la dictée",
384
+ }) as HTMLButtonElement;
385
+ expect(button.disabled).toBe(false);
386
+ });
387
+ });
388
+
389
+ });