import { EventEmitter } from "events"; jest.mock("../src/api/api", () => ({ authService: jest.fn(), })); import { SofyaTranscriber } from "../src/services/transcription/SofyaTranscriber"; import { TranscriptionServiceFactory } from "../src/services/transcription/TranscriptionServiceFactory"; import { createMetricsSnapshot, type MetricsSnapshot, } from "./helpers/metricsFixtures"; import { flushMicrotasks } from "./helpers/testUtils"; type MetricsUpdate = Partial< Omit< MetricsSnapshot, | "transcriptUi" | "session" | "connection" | "recovery" | "buffering" | "browserNetwork" | "audioCapture" > > & { transcriptUi?: Partial; session?: Partial; connection?: Partial; recovery?: Partial; buffering?: Partial; browserNetwork?: Partial; audioCapture?: Partial; }; class MockTranscriptionService extends EventEmitter { private currentMetrics: MetricsSnapshot = createMetricsSnapshot(); public readonly startTranscription = jest.fn(); public readonly pauseTranscription = jest.fn(); public readonly resumeTranscription = jest.fn(); public readonly stopTranscription = jest.fn(async () => { this.emit("stopped"); }); public readonly getResilienceStatus = jest.fn(() => ({ connectionState: "connected", websocketState: "open", totalBufferedAudioBytes: 0, })); public readonly getMetrics = jest.fn(() => this.currentMetrics); public setMetrics(next: MetricsUpdate) { this.currentMetrics = { ...this.currentMetrics, ...next, transcriptUi: { ...this.currentMetrics.transcriptUi, ...next.transcriptUi, }, session: { ...this.currentMetrics.session, ...next.session, }, connection: { ...this.currentMetrics.connection, ...next.connection, }, recovery: { ...this.currentMetrics.recovery, ...next.recovery, }, buffering: { ...this.currentMetrics.buffering, ...next.buffering, }, browserNetwork: { ...this.currentMetrics.browserNetwork, ...next.browserNetwork, }, audioCapture: { ...this.currentMetrics.audioCapture, ...next.audioCapture, }, }; } } describe("SofyaTranscriber debug audit mode", () => { afterEach(() => { delete (globalThis as any).dtrum; jest.restoreAllMocks(); }); it("captures client-side audit telemetry when debug mode is enabled", async () => { const service = new MockTranscriptionService(); jest .spyOn(TranscriptionServiceFactory, "create") .mockResolvedValue(service as any); const transcriber = new SofyaTranscriber({ provider: "sofya_as_service", endpoint: "ws://debug-test", config: { language: "en-US", debug: { enabled: true, label: "manual devtools run", metadata: { scenario: "high-jitter", }, }, }, }); await flushMicrotasks(); transcriber.startTranscription({} as MediaStream); service.emit("connected"); service.emit("recognizing", "partial transcript"); service.emit("recognized", "final transcript"); service.setMetrics({ transcriptUi: { currentPartialText: "partial transcript", lastFinalText: "final transcript", partialCount: 1, finalCount: 1, utteranceCount: 1, avgFirstPartialLatencyMs: 120, p95FirstPartialLatencyMs: 120, avgFinalLatencyMs: 280, p95FinalLatencyMs: 280, }, }); service.emit("resilience_status", { connectionState: "connected", websocketState: "open", totalBufferedAudioBytes: 128, }); const audit = await transcriber.getDebugAudit(); expect(audit).toEqual( expect.objectContaining({ kind: "sofya-transcriber-debug-audit", label: "manual devtools run", requestedProvider: "sofya_as_service", resolvedProvider: "sofya_as_service", metadata: { scenario: "high-jitter", }, report: expect.objectContaining({ summary: expect.objectContaining({ outcome: "degraded_recovered", partialTranscriptCount: 1, finalTranscriptCount: 1, }), }), }) ); expect(audit?.startedAt).not.toBeNull(); expect(audit?.report.summary).toEqual( expect.objectContaining({ outcome: "degraded_recovered", partialTranscriptCount: 1, finalTranscriptCount: 1, }) ); expect(audit?.report.dashboard.heroStats).toEqual( expect.objectContaining({ outcome: "degraded_recovered", finalTranscriptCount: 1, }) ); expect(audit?.report.timeline.phases.map((phase) => phase.type)).toEqual( expect.arrayContaining(["created", "ready", "started", "connected"]) ); expect(audit?.report.timeline.transcriptMilestones).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "first_partial", text: "partial transcript", }), expect.objectContaining({ type: "first_final", text: "final transcript", }), ]) ); expect(audit).not.toHaveProperty("events"); expect(audit).not.toHaveProperty("statuses"); expect(audit).not.toHaveProperty("partials"); expect(audit).not.toHaveProperty("finals"); expect(audit).not.toHaveProperty("metricsSnapshot"); expect(audit).not.toHaveProperty("finalResilienceStatus"); }); it("resets the debug audit session after a stop when a new transcription session starts", async () => { const service = new MockTranscriptionService(); jest .spyOn(TranscriptionServiceFactory, "create") .mockResolvedValue(service as any); const transcriber = new SofyaTranscriber({ provider: "sofya_as_service", endpoint: "ws://debug-test", config: { language: "en-US", debug: { enabled: true, }, }, }); await flushMicrotasks(); transcriber.startTranscription({} as MediaStream); service.emit("recognized", "first session final"); await transcriber.stopTranscription(); const firstAudit = await transcriber.getDebugAudit(); transcriber.startTranscription({} as MediaStream); service.emit("recognized", "second session final"); service.setMetrics({ transcriptUi: { finalCount: 1, lastFinalText: "second session final", }, }); const secondAudit = await transcriber.getDebugAudit(); expect(firstAudit?.sessionId).not.toEqual(secondAudit?.sessionId); expect(secondAudit?.report.summary.finalTranscriptCount).toBe(1); expect(secondAudit?.report.timeline.transcriptMilestones).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "last_final", text: "second session final", }), ]) ); expect(secondAudit?.report.timeline.transcriptMilestones).not.toEqual( expect.arrayContaining([ expect.objectContaining({ text: "first session final", }), ]) ); }); it("downloads a json audit automatically on stop when autoDownload is enabled", async () => { const service = new MockTranscriptionService(); jest .spyOn(TranscriptionServiceFactory, "create") .mockResolvedValue(service as any); const appendChildSpy = jest.spyOn(document.body, "appendChild"); const removeChildSpy = jest.spyOn(document.body, "removeChild"); const createObjectUrlSpy = jest .spyOn(URL, "createObjectURL") .mockReturnValue("blob:debug-audit"); const revokeObjectUrlSpy = jest .spyOn(URL, "revokeObjectURL") .mockImplementation(() => undefined); const anchorClick = jest.fn(); const originalCreateElement = document.createElement.bind(document); const createElementSpy = jest .spyOn(document, "createElement") .mockImplementation((tagName: string) => { if (tagName.toLowerCase() === "a") { const anchor = originalCreateElement("a"); anchor.click = anchorClick; return anchor; } return originalCreateElement(tagName); }); const transcriber = new SofyaTranscriber({ provider: "sofya_as_service", endpoint: "ws://debug-test", config: { language: "en-US", debug: { enabled: true, autoDownload: true, label: "devtools profile", }, }, }); await flushMicrotasks(); transcriber.startTranscription({} as MediaStream); await transcriber.stopTranscription(); const auditAfterDownload = await transcriber.getDebugAudit(); expect(createObjectUrlSpy).toHaveBeenCalledWith(expect.any(Blob)); expect(anchorClick).toHaveBeenCalledTimes(1); expect(appendChildSpy).toHaveBeenCalledTimes(1); expect(removeChildSpy).toHaveBeenCalledTimes(1); expect(revokeObjectUrlSpy).toHaveBeenCalledWith("blob:debug-audit"); const anchor = createElementSpy.mock.results[0]?.value as | { download?: string; href?: string } | undefined; expect(anchor?.download).toBe("sofya-debug-audit-devtools-profile.json"); expect(anchor?.href).toBe("blob:debug-audit"); expect(auditAfterDownload?.report.session.hasStarted).toBe(false); expect(auditAfterDownload?.report.summary.finalTranscriptCount).toBe(0); }); it("exports only interface-ready audit data without raw telemetry payloads", async () => { const service = new MockTranscriptionService(); jest .spyOn(TranscriptionServiceFactory, "create") .mockResolvedValue(service as any); const transcriber = new SofyaTranscriber({ provider: "sofya_as_service", endpoint: "ws://debug-test", config: { language: "en-US", debug: { enabled: true, }, }, }); await flushMicrotasks(); transcriber.startTranscription({} as MediaStream); for (let index = 0; index < 300; index += 1) { service.emit("recognizing", `partial-${index}`); } for (let index = 0; index < 700; index += 1) { service.emit("resilience_status", { connectionState: "connected", websocketState: "open", totalBufferedAudioBytes: index, }); } const audit = await transcriber.getDebugAudit(); expect(audit).not.toHaveProperty("events"); expect(audit).not.toHaveProperty("statuses"); expect(audit).not.toHaveProperty("partials"); expect(audit).not.toHaveProperty("finals"); expect(audit?.report.dashboard).toEqual( expect.objectContaining({ heroStats: expect.any(Object), cards: expect.any(Object), }) ); expect(audit?.report.timeline).toEqual( expect.objectContaining({ phases: expect.any(Array), incidents: expect.any(Array), transcriptMilestones: expect.any(Array), }) ); }); it("returns an essential audit even when debug mode is disabled", async () => { const service = new MockTranscriptionService(); jest .spyOn(TranscriptionServiceFactory, "create") .mockResolvedValue(service as any); const transcriber = new SofyaTranscriber({ provider: "sofya_as_service", endpoint: "ws://debug-test", config: { language: "en-US", }, }); await flushMicrotasks(); transcriber.startTranscription({} as MediaStream); service.emit("connected"); service.emit("recognizing", "partial without debug"); service.emit("recognized", "final without debug"); service.emit("error", new Error("transport failure")); const audit = await transcriber.getDebugAudit(); expect(audit).toEqual( expect.objectContaining({ schemaVersion: 2, kind: "sofya-transcriber-debug-audit", report: expect.objectContaining({ timeline: expect.objectContaining({ phases: expect.arrayContaining([ expect.objectContaining({ type: "started" }), expect.objectContaining({ type: "connected" }), ]), }), }), }) ); expect(audit?.report.summary.finalTranscriptCount).toBe(1); expect(audit?.report.timeline.incidents).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "error" }), ]) ); }); it("dispatches audit automatically to Dynatrace on stop when configured", async () => { const service = new MockTranscriptionService(); jest .spyOn(TranscriptionServiceFactory, "create") .mockResolvedValue(service as any); const sendSessionProperties = jest.fn(); const enterAction = jest.fn(() => 7); const leaveAction = jest.fn(); (globalThis as any).dtrum = { enterAction, leaveAction, sendSessionProperties, addActionProperties: jest.fn(), reportError: jest.fn(), reportCustomError: jest.fn(), }; const transcriber = new SofyaTranscriber({ provider: "sofya_as_service", endpoint: "ws://debug-test", config: { language: "en-US", telemetry: { enabled: true, provider: "dynatrace", }, }, }); await flushMicrotasks(); transcriber.startTranscription({} as MediaStream); service.emit("recognized", "final text"); await transcriber.stopTranscription(); expect(enterAction).toHaveBeenCalled(); expect(sendSessionProperties).toHaveBeenCalled(); expect(leaveAction).toHaveBeenCalledWith(7); }); it("dispatches audit on terminal error/disconnected and emits warning when dtrum is missing", async () => { const service = new MockTranscriptionService(); jest .spyOn(TranscriptionServiceFactory, "create") .mockResolvedValue(service as any); const transcriber = new SofyaTranscriber({ provider: "sofya_as_service", endpoint: "ws://debug-test", config: { language: "en-US", telemetry: { enabled: true, provider: "dynatrace", }, }, }); const warningListener = jest.fn(); transcriber.on("telemetry_integration_warning", warningListener); await flushMicrotasks(); transcriber.startTranscription({} as MediaStream); service.emit("error", new Error("transport issue")); service.emit("disconnected", { code: 1006, reason: "abnormal", wasClean: false, }); for (let attempt = 0; attempt < 8; attempt += 1) { if (warningListener.mock.calls.length > 0) { break; } await flushMicrotasks(); await new Promise((resolve) => setTimeout(resolve, 0)); } expect(warningListener).toHaveBeenCalledWith( expect.objectContaining({ provider: "dynatrace", code: "runtime_missing", }) ); }); it("detaches bridged service listeners after stop and rebinds them on the next start", async () => { const service = new MockTranscriptionService(); jest .spyOn(TranscriptionServiceFactory, "create") .mockResolvedValue(service as any); const transcriber = new SofyaTranscriber({ provider: "sofya_as_service", endpoint: "ws://debug-test", config: { language: "en-US", }, }); const metricsListener = jest.fn(); transcriber.on("telemetry", metricsListener); await flushMicrotasks(); expect(service.listenerCount("telemetry")).toBeGreaterThan(0); await transcriber.stopTranscription(); expect(service.listenerCount("telemetry")).toBe(0); transcriber.startTranscription({} as MediaStream); expect(service.listenerCount("telemetry")).toBeGreaterThan(0); }); it("runs STT audit ingestion on stop before auto-download cleanup when configured", async () => { const service = new MockTranscriptionService(); const ingestSttAudit = jest.fn().mockResolvedValue(null); (service as any).ingestSttAudit = ingestSttAudit; jest .spyOn(TranscriptionServiceFactory, "create") .mockResolvedValue(service as any); const transcriber = new SofyaTranscriber({ provider: "sofya_as_service", endpoint: "ws://debug-test", config: { language: "en-US", debug: { enabled: true, autoDownload: true, }, auditIngestion: { endpoint: "/v1/stt/audits", }, } as any, }); const downloadSpy = jest .spyOn(transcriber, "downloadDebugAudit") .mockResolvedValue("audit.json"); await flushMicrotasks(); transcriber.startTranscription({} as MediaStream); service.emit("recognized", "final text"); await transcriber.stopTranscription(); expect(ingestSttAudit).toHaveBeenCalledTimes(1); expect(downloadSpy).toHaveBeenCalledTimes(1); expect(ingestSttAudit.mock.invocationCallOrder[0]).toBeLessThan( downloadSpy.mock.invocationCallOrder[0] ); }); it("keeps stop non-blocking and emits warning when STT audit ingestion fails", async () => { const service = new MockTranscriptionService(); (service as any).ingestSttAudit = jest.fn().mockResolvedValue({ code: "http_error", message: "STT audit ingestion failed with status 403.", status: 403, attempt: 1, endpoint: "https://scribe.sofya.health/v1/stt/audits", }); jest .spyOn(TranscriptionServiceFactory, "create") .mockResolvedValue(service as any); const transcriber = new SofyaTranscriber({ provider: "sofya_as_service", endpoint: "ws://debug-test", config: { language: "en-US", debug: { enabled: true, }, auditIngestion: { endpoint: "/v1/stt/audits", }, } as any, }); const warningListener = jest.fn(); const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); transcriber.on("stt_audit_ingestion_warning", warningListener); await flushMicrotasks(); transcriber.startTranscription({} as MediaStream); await expect(transcriber.stopTranscription()).resolves.toBeUndefined(); expect(warningListener).toHaveBeenCalledWith( expect.objectContaining({ code: "http_error", status: 403, }) ); expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining("[stt_audit_ingestion:http_error]"), expect.objectContaining({ status: 403, }) ); }); });