




















@@ -33,6 +33,20 @@ const flush = async (): Promise<void> => {
3333await new Promise((resolve) => setTimeout(resolve, 0));
3434};
353536+const createDeferred = (): {
37+promise: Promise<void>;
38+resolve: () => void;
39+reject: (error: Error) => void;
40+} => {
41+let resolve!: () => void;
42+let reject!: (error: Error) => void;
43+const promise = new Promise<void>((resolvePromise, rejectPromise) => {
44+resolve = resolvePromise;
45+reject = rejectPromise;
46+});
47+return { promise, resolve, reject };
48+};
49+3650const waitForAbort = (signal: AbortSignal): Promise<void> =>
3751new Promise((resolve) => {
3852if (signal.aborted) {
@@ -502,6 +516,211 @@ describe("MediaStreamHandler security hardening", () => {
502516}
503517});
504518519+it("keeps accepted streams alive while STT readiness exceeds the pre-start timeout", async () => {
520+const sttReady = createDeferred();
521+const sttConnectStarted = createDeferred();
522+const transcriptionReady = createDeferred();
523+const events: string[] = [];
524+525+const session: RealtimeTranscriptionSession = {
526+connect: async () => {
527+events.push("stt-connect-start");
528+sttConnectStarted.resolve();
529+await sttReady.promise;
530+events.push("stt-connect-ready");
531+},
532+sendAudio: () => {},
533+close: () => {},
534+isConnected: () => false,
535+};
536+537+const handler = new MediaStreamHandler({
538+transcriptionProvider: {
539+createSession: () => session,
540+id: "openai",
541+label: "OpenAI",
542+isConfigured: () => true,
543+},
544+providerConfig: {},
545+preStartTimeoutMs: 40,
546+shouldAcceptStream: () => true,
547+onConnect: () => {
548+events.push("onConnect");
549+},
550+onTranscriptionReady: () => {
551+events.push("onTranscriptionReady");
552+transcriptionReady.resolve();
553+},
554+});
555+const server = await startWsServer(handler);
556+557+try {
558+const ws = await connectWs(server.url);
559+ws.send(
560+JSON.stringify({
561+event: "start",
562+streamSid: "MZ-slow-stt",
563+start: { callSid: "CA-slow-stt" },
564+}),
565+);
566+567+await withTimeout(sttConnectStarted.promise);
568+await new Promise((resolve) => setTimeout(resolve, 80));
569+expect(ws.readyState).toBe(WebSocket.OPEN);
570+expect(events).toEqual(["onConnect", "stt-connect-start"]);
571+572+sttReady.resolve();
573+await withTimeout(transcriptionReady.promise);
574+expect(events).toEqual([
575+"onConnect",
576+"stt-connect-start",
577+"stt-connect-ready",
578+"onTranscriptionReady",
579+]);
580+581+ws.close();
582+await waitForClose(ws);
583+} finally {
584+await server.close();
585+}
586+});
587+588+it("forwards early Twilio media into the STT session before readiness", async () => {
589+const sttReady = createDeferred();
590+const sttConnectStarted = createDeferred();
591+const transcriptionReady = createDeferred();
592+const receivedAudio: Buffer[] = [];
593+let onConnectCalls = 0;
594+let onTranscriptionReadyCalls = 0;
595+596+const session: RealtimeTranscriptionSession = {
597+connect: async () => {
598+sttConnectStarted.resolve();
599+await sttReady.promise;
600+},
601+sendAudio: (audio) => {
602+receivedAudio.push(Buffer.from(audio));
603+},
604+close: () => {},
605+isConnected: () => false,
606+};
607+608+const handler = new MediaStreamHandler({
609+transcriptionProvider: {
610+createSession: () => session,
611+id: "openai",
612+label: "OpenAI",
613+isConfigured: () => true,
614+},
615+providerConfig: {},
616+shouldAcceptStream: () => true,
617+onConnect: () => {
618+onConnectCalls += 1;
619+},
620+onTranscriptionReady: () => {
621+onTranscriptionReadyCalls += 1;
622+transcriptionReady.resolve();
623+},
624+});
625+const server = await startWsServer(handler);
626+627+try {
628+const ws = await connectWs(server.url);
629+ws.send(
630+JSON.stringify({
631+event: "start",
632+streamSid: "MZ-early-media",
633+start: { callSid: "CA-early-media" },
634+}),
635+);
636+637+await withTimeout(sttConnectStarted.promise);
638+ws.send(
639+JSON.stringify({
640+event: "media",
641+streamSid: "MZ-early-media",
642+media: { payload: Buffer.from("early").toString("base64") },
643+}),
644+);
645+await flush();
646+647+expect(Buffer.concat(receivedAudio).toString()).toBe("early");
648+expect(onConnectCalls).toBe(1);
649+expect(onTranscriptionReadyCalls).toBe(0);
650+651+sttReady.resolve();
652+await withTimeout(transcriptionReady.promise);
653+expect(onConnectCalls).toBe(1);
654+expect(onTranscriptionReadyCalls).toBe(1);
655+656+ws.close();
657+await waitForClose(ws);
658+} finally {
659+await server.close();
660+}
661+});
662+663+it("closes the media stream and disconnects once when STT readiness fails", async () => {
664+const sttConnectStarted = createDeferred();
665+const onDisconnectReady = createDeferred();
666+const onConnect = vi.fn();
667+const onTranscriptionReady = vi.fn();
668+const onDisconnect = vi.fn(() => {
669+onDisconnectReady.resolve();
670+});
671+672+const session: RealtimeTranscriptionSession = {
673+connect: async () => {
674+sttConnectStarted.resolve();
675+throw new Error("provider unavailable");
676+},
677+sendAudio: () => {},
678+close: vi.fn(),
679+isConnected: () => false,
680+};
681+682+const handler = new MediaStreamHandler({
683+transcriptionProvider: {
684+createSession: () => session,
685+id: "openai",
686+label: "OpenAI",
687+isConfigured: () => true,
688+},
689+providerConfig: {},
690+shouldAcceptStream: () => true,
691+ onConnect,
692+ onTranscriptionReady,
693+ onDisconnect,
694+});
695+const server = await startWsServer(handler);
696+697+try {
698+const ws = await connectWs(server.url);
699+ws.send(
700+JSON.stringify({
701+event: "start",
702+streamSid: "MZ-stt-fail",
703+start: { callSid: "CA-stt-fail" },
704+}),
705+);
706+707+await withTimeout(sttConnectStarted.promise);
708+const closed = await waitForClose(ws);
709+await withTimeout(onDisconnectReady.promise);
710+711+expect(closed.code).toBe(1011);
712+expect(closed.reason).toBe("STT connection failed");
713+expect(onConnect).toHaveBeenCalledTimes(1);
714+expect(onConnect).toHaveBeenCalledWith("CA-stt-fail", "MZ-stt-fail");
715+expect(onTranscriptionReady).not.toHaveBeenCalled();
716+expect(onDisconnect).toHaveBeenCalledTimes(1);
717+expect(onDisconnect).toHaveBeenCalledWith("CA-stt-fail", "MZ-stt-fail");
718+expect(session.close).toHaveBeenCalledTimes(1);
719+} finally {
720+await server.close();
721+}
722+});
723+505724it("rejects oversized pre-start frames at the websocket maxPayload guard before validation runs", async () => {
506725const shouldAcceptStreamCalls: Array<{ callId: string; streamSid: string; token?: string }> =
507726[];
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。