diff --git a/extensions/teams-meetings/src/transports/teams-meetings-audio-routing.test.ts b/extensions/teams-meetings/src/transports/teams-meetings-audio-routing.test.ts index 7692c74007b7..63380ec70436 100644 --- a/extensions/teams-meetings/src/transports/teams-meetings-audio-routing.test.ts +++ b/extensions/teams-meetings/src/transports/teams-meetings-audio-routing.test.ts @@ -1,45 +1,30 @@ import { describe, expect, it } from "vitest"; import { - MEETING_STATE_KEY, + abortingMedia, + audioStatusParams, + continueStatusScript, control, + liveMediaStream, + pageMedia, + runAudioStatusScript, runStatusScript, type PageMedia, } from "./teams-meetings-platform-adapter.test-helpers.js"; describe("Microsoft Teams meeting audio routing", () => { it("does not let one routed element hide a failed live remote stream", async () => { - const routed: PageMedia = { - sinkId: "", - async setSinkId(value) { - routed.sinkId = value; - }, - }; - const live: PageMedia = { + const routed = pageMedia(); + const live = abortingMedia("The element has no supported source.", { sinkId: "built-in-output", - srcObject: { getAudioTracks: () => [{ readyState: "live" }] }, - async setSinkId() { - throw new DOMException("The element has no supported source.", "AbortError"); - }, - }; - const bridge: PageMedia = { + srcObject: liveMediaStream(), + }); + const bridge = abortingMedia("Cannot route bridge.", { isConnected: false, - sinkId: "", async play() {}, - async setSinkId() { - throw new DOMException("Cannot route bridge.", "AbortError"); - }, - }; - const { result } = await runStatusScript({ - allowMicrophone: true, + }); + const { result } = await runAudioStatusScript({ bridgeMedia: bridge, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), media: [routed, live], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), priorMeeting: { audioInputDeviceId: "blackhole-input", identity: "teams-work:19:meeting_test@thread.v2", @@ -55,31 +40,15 @@ describe("Microsoft Teams meeting audio routing", () => { }); it("does not let one routed element hide an unloaded pending remote stream", async () => { - const routed: PageMedia = { - sinkId: "", - async setSinkId(value) { - routed.sinkId = value; - }, - }; - const pending: PageMedia = { + const routed = pageMedia(); + const pending = abortingMedia("The element has no supported source.", { muted: false, readyState: 0, sinkId: "built-in-output", src: "blob:https://teams.live.com/pending-remote-audio", - async setSinkId() { - throw new DOMException("The element has no supported source.", "AbortError"); - }, - }; - const { result, window } = await runStatusScript({ - allowMicrophone: true, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), + }); + const { result, window } = await runAudioStatusScript({ media: [routed, pending], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), priorMeeting: { audioInputDeviceId: "blackhole-input", identity: "teams-work:19:meeting_test@thread.v2", @@ -98,47 +67,29 @@ describe("Microsoft Teams meeting audio routing", () => { }); it("retries bridge playback instead of trusting a previously selected sink", async () => { - const source: PageMedia = { + const source = abortingMedia("The element has no supported source.", { muted: false, sinkId: "built-in-output", - srcObject: { getAudioTracks: () => [{ readyState: "live" }] }, - async setSinkId() { - throw new DOMException("The element has no supported source.", "AbortError"); - }, - }; + srcObject: liveMediaStream(), + }); let playAttempts = 0; - const bridge: PageMedia = { + const bridge = pageMedia({ isConnected: false, - sinkId: "", async play() { playAttempts += 1; throw new DOMException("Playback blocked.", "NotAllowedError"); }, - async setSinkId(value) { - bridge.sinkId = value; - }, - }; - const params = { - allowMicrophone: true, + }); + const params = audioStatusParams({ bridgeMedia: bridge, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), media: [source], - microphone: control({ label: "Turn microphone off", pressed: true }), priorMeeting: { audioInputDeviceId: "blackhole-input", identity: "teams-work:19:meeting_test@thread.v2", }, - }; - const first = await runStatusScript(params); - const second = await runStatusScript({ - ...params, - priorAudioOutputs: first.window["__openclawTeamsAudioOutputs"] as unknown[], - priorMeeting: first.window[MEETING_STATE_KEY] as Record, }); + const first = await runStatusScript(params); + const second = await continueStatusScript(first, params); expect(first.result.audioOutputRouted).toBe(false); expect(first.result.audioOutputRouteRetryable).toBe(false); @@ -158,18 +109,9 @@ describe("Microsoft Teams meeting audio routing", () => { source.sinkId = value; }, }; - const params = { - allowMicrophone: true, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), + const params = audioStatusParams({ media: [source], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), - priorMeeting: { identity: "teams-work:19:meeting_test@thread.v2" }, - }; + }); const first = await runStatusScript(params); expect(first.result.audioOutputRouted).toBe(false); expect(first.result.audioOutputRouteError).toBeUndefined(); @@ -179,7 +121,7 @@ describe("Microsoft Teams meeting audio routing", () => { expect.objectContaining({ pending: true, source, sourceMuted: false }), ]); - const stream = { getAudioTracks: () => [{ readyState: "live" }] }; + const stream = liveMediaStream(); source.srcObject = stream; expect(source.sinkId).toBe("built-in-output"); expect(source.muted).toBe(true); @@ -187,11 +129,7 @@ describe("Microsoft Teams meeting audio routing", () => { expect.objectContaining({ pending: true, source, sourceMuted: false }), ]); - const second = await runStatusScript({ - ...params, - priorAudioOutputs: first.window["__openclawTeamsAudioOutputs"] as unknown[], - priorMeeting: first.window[MEETING_STATE_KEY] as Record, - }); + const second = await continueStatusScript(first, params); expect(second.result.audioOutputRouted).toBe(true); expect(source.muted).toBe(false); expect(second.window).not.toHaveProperty("__openclawTeamsAudioOutputs"); @@ -208,26 +146,15 @@ describe("Microsoft Teams meeting audio routing", () => { source.sinkId = value; }, }; - const params = { - allowMicrophone: true, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), + const params = audioStatusParams({ media: [source], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), - priorMeeting: { identity: "teams-work:19:meeting_test@thread.v2" }, - }; + }); const first = await runStatusScript(params); - source.srcObject = { getAudioTracks: () => [{ readyState: "live" }] }; - const conflict = await runStatusScript({ + source.srcObject = liveMediaStream(); + const conflict = await continueStatusScript(first, { ...params, currentUrl: "https://teams.microsoft.com/l/meetup-join/19%3ameeting_other%40thread.v2/0", - priorAudioOutputs: first.window["__openclawTeamsAudioOutputs"] as unknown[], - priorMeeting: first.window[MEETING_STATE_KEY] as Record, }); expect(source.sinkId).toBe("built-in-output"); @@ -237,104 +164,59 @@ describe("Microsoft Teams meeting audio routing", () => { }); it("keeps the source muted when a previously working bridge fails", async () => { - const source: PageMedia = { + const source = abortingMedia("The element has no supported source.", { muted: false, sinkId: "built-in-output", - srcObject: { getAudioTracks: () => [{ readyState: "live" }] }, - async setSinkId() { - throw new DOMException("The element has no supported source.", "AbortError"); - }, - }; + srcObject: liveMediaStream(), + }); let failPlayback = false; - const bridge: PageMedia = { + const bridge = pageMedia({ isConnected: false, - sinkId: "", async play() { if (failPlayback) { throw new DOMException("Playback failed.", "AbortError"); } }, - async setSinkId(value) { - bridge.sinkId = value; - }, - }; - const params = { - allowMicrophone: true, + }); + const params = audioStatusParams({ bridgeMedia: bridge, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), media: [source], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), - priorMeeting: { identity: "teams-work:19:meeting_test@thread.v2" }, - }; + }); const first = await runStatusScript(params); expect(first.result.audioOutputRouted).toBe(true); expect(source.muted).toBe(true); failPlayback = true; - const second = await runStatusScript({ - ...params, - priorAudioOutputs: first.window["__openclawTeamsAudioOutputs"] as unknown[], - priorMeeting: first.window[MEETING_STATE_KEY] as Record, - }); + const second = await continueStatusScript(first, params); expect(second.result.audioOutputRouted).toBe(false); expect(second.result.audioOutputRouteRetryable).toBe(true); expect(source.muted).toBe(true); failPlayback = false; - const third = await runStatusScript({ - ...params, - priorAudioOutputs: second.window["__openclawTeamsAudioOutputs"] as unknown[], - priorMeeting: second.window[MEETING_STATE_KEY] as Record, - }); + const third = await continueStatusScript(second, params); expect(third.result.audioOutputRouted).toBe(true); expect(source.muted).toBe(true); }); it("keeps a per-source bridge when another element sharing its stream routes directly", async () => { - const stream = { getAudioTracks: () => [{ readyState: "live" }] }; - const bridgedSource: PageMedia = { + const stream = liveMediaStream(); + const bridgedSource = abortingMedia("The element has no supported source.", { muted: false, sinkId: "built-in-output", srcObject: stream, - async setSinkId() { - throw new DOMException("The element has no supported source.", "AbortError"); - }, - }; - const directSource: PageMedia = { + }); + const directSource = pageMedia({ muted: false, sinkId: "built-in-output", srcObject: stream, - async setSinkId(value) { - directSource.sinkId = value; - }, - }; - const bridge: PageMedia = { + }); + const bridge = pageMedia({ isConnected: false, - sinkId: "", async play() {}, - async setSinkId(value) { - bridge.sinkId = value; - }, - }; - const { result, window } = await runStatusScript({ - allowMicrophone: true, + }); + const { result, window } = await runAudioStatusScript({ bridgeMedia: bridge, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), media: [bridgedSource, directSource], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), - priorMeeting: { - identity: "teams-work:19:meeting_test@thread.v2", - }, }); expect(result.audioOutputRouted).toBe(true); @@ -347,31 +229,20 @@ describe("Microsoft Teams meeting audio routing", () => { }); it("restores an unclaimed source when another source from its entry is rerouted", async () => { - const current: PageMedia = { + const current = pageMedia({ currentSrc: "blob:https://teams.live.com/current", muted: true, sinkId: "built-in-output", - async setSinkId(value) { - current.sinkId = value; - }, - }; - const unclaimed: PageMedia = { + }); + const unclaimed = pageMedia({ currentSrc: "blob:https://teams.live.com/unclaimed", muted: true, sinkId: "built-in-output", async setSinkId() {}, - }; + }); - const { result, window } = await runStatusScript({ - allowMicrophone: true, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), + const { result, window } = await runAudioStatusScript({ media: [current], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), priorAudioOutputs: [ { sessionId: "session-1", @@ -381,7 +252,6 @@ describe("Microsoft Teams meeting audio routing", () => { ], }, ], - priorMeeting: { identity: "teams-work:19:meeting_test@thread.v2" }, }); expect(result.audioOutputRouted).toBe(true); @@ -391,57 +261,32 @@ describe("Microsoft Teams meeting audio routing", () => { }); it("reroutes a replacement stream on an element muted by its prior bridge", async () => { - const firstStream = { getAudioTracks: () => [{ readyState: "live" }] }; - const replacementStream = { getAudioTracks: () => [{ readyState: "live" }] }; - const source: PageMedia = { + const firstStream = liveMediaStream(); + const replacementStream = liveMediaStream(); + const source = abortingMedia("The element has no supported source.", { muted: false, sinkId: "built-in-output", srcObject: firstStream, - async setSinkId() { - throw new DOMException("The element has no supported source.", "AbortError"); - }, - }; - const directSource: PageMedia = { + }); + const directSource = pageMedia({ muted: false, sinkId: "built-in-output", srcObject: firstStream, - async setSinkId(value) { - directSource.sinkId = value; - }, - }; - const bridge: PageMedia = { + }); + const bridge = pageMedia({ isConnected: false, - sinkId: "", async play() {}, - async setSinkId(value) { - bridge.sinkId = value; - }, - }; - const params = { - allowMicrophone: true, + }); + const params = audioStatusParams({ bridgeMedia: bridge, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), media: [source, directSource], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), - priorMeeting: { - identity: "teams-work:19:meeting_test@thread.v2", - }, - }; + }); const first = await runStatusScript(params); expect(first.result.audioOutputRouted).toBe(true); expect(source.muted).toBe(true); source.srcObject = undefined; - const second = await runStatusScript({ - ...params, - priorAudioOutputs: first.window["__openclawTeamsAudioOutputs"] as unknown[], - priorMeeting: first.window[MEETING_STATE_KEY] as Record, - }); + const second = await continueStatusScript(first, params); expect(second.result.audioOutputRouted).toBe(false); expect(source.muted).toBe(true); expect(second.window["__openclawTeamsAudioOutputs"]).toEqual([ @@ -449,11 +294,7 @@ describe("Microsoft Teams meeting audio routing", () => { ]); source.srcObject = replacementStream; - const third = await runStatusScript({ - ...params, - priorAudioOutputs: second.window["__openclawTeamsAudioOutputs"] as unknown[], - priorMeeting: second.window[MEETING_STATE_KEY] as Record, - }); + const third = await continueStatusScript(second, params); expect(third.result.audioOutputRouted).toBe(true); expect(source.muted).toBe(true); expect( @@ -462,51 +303,30 @@ describe("Microsoft Teams meeting audio routing", () => { }); it("keeps a detached source muted after its owned stream is cleared", async () => { - const stream = { getAudioTracks: () => [{ readyState: "live" }] }; - const source: PageMedia = { + const stream = liveMediaStream(); + const source = abortingMedia("The element has no supported source.", { muted: false, sinkId: "built-in-output", srcObject: stream, - async setSinkId() { - throw new DOMException("The element has no supported source.", "AbortError"); - }, - }; - const directSource: PageMedia = { + }); + const directSource = pageMedia({ muted: false, sinkId: "built-in-output", srcObject: stream, - async setSinkId(value) { - directSource.sinkId = value; - }, - }; - const bridge: PageMedia = { + }); + const bridge = pageMedia({ isConnected: false, - sinkId: "", async play() {}, - async setSinkId(value) { - bridge.sinkId = value; - }, - }; - const params = { - allowMicrophone: true, + }); + const params = audioStatusParams({ bridgeMedia: bridge, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), - priorMeeting: { identity: "teams-work:19:meeting_test@thread.v2" }, - }; + }); const first = await runStatusScript({ ...params, media: [source] }); expect(source.muted).toBe(true); - const second = await runStatusScript({ + const second = await continueStatusScript(first, { ...params, media: [directSource], - priorAudioOutputs: first.window["__openclawTeamsAudioOutputs"] as unknown[], - priorMeeting: first.window[MEETING_STATE_KEY] as Record, }); expect(second.result.audioOutputRouted).toBe(true); expect(source.muted).toBe(true); @@ -515,11 +335,9 @@ describe("Microsoft Teams meeting audio routing", () => { ]); source.srcObject = undefined; - const third = await runStatusScript({ + const third = await continueStatusScript(second, { ...params, media: [directSource], - priorAudioOutputs: second.window["__openclawTeamsAudioOutputs"] as unknown[], - priorMeeting: second.window[MEETING_STATE_KEY] as Record, }); expect(source.muted).toBe(true); expect(third.window).not.toHaveProperty("__openclawTeamsAudioOutputs"); @@ -529,43 +347,26 @@ describe("Microsoft Teams meeting audio routing", () => { const source: PageMedia = { muted: false, sinkId: "built-in-output", - srcObject: { getAudioTracks: () => [{ readyState: "live" }] }, + srcObject: liveMediaStream(), async setSinkId() { throw new DOMException("The element has no supported source.", "AbortError"); }, }; let pauses = 0; let removals = 0; - const bridge: PageMedia = { + const bridge = pageMedia({ isConnected: false, - sinkId: "", async play() {}, pause: () => (pauses += 1), remove: () => (removals += 1), - async setSinkId(value) { - bridge.sinkId = value; - }, - }; - const first = await runStatusScript({ - allowMicrophone: true, + }); + const first = await runAudioStatusScript({ bridgeMedia: bridge, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), media: [source], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), - priorMeeting: { identity: "teams-work:19:meeting_test@thread.v2" }, }); expect(source.muted).toBe(true); - const ended = await runStatusScript({ - allowMicrophone: true, - priorAudioOutputs: first.window["__openclawTeamsAudioOutputs"] as unknown[], - priorMeeting: first.window[MEETING_STATE_KEY] as Record, - }); + const ended = await continueStatusScript(first, { allowMicrophone: true }); expect(source.muted).toBe(false); expect(pauses).toBe(1); expect(removals).toBe(1); @@ -573,8 +374,8 @@ describe("Microsoft Teams meeting audio routing", () => { }); it("does not restore a reused media element carrying a replacement stream", async () => { - const bridgedStream = { getAudioTracks: () => [{ readyState: "live" }] }; - const replacementStream = { getAudioTracks: () => [{ readyState: "live" }] }; + const bridgedStream = liveMediaStream(); + const replacementStream = liveMediaStream(); const source: PageMedia = { muted: true, sinkId: "built-in-output", @@ -693,7 +494,7 @@ describe("Microsoft Teams meeting audio routing", () => { }); it("suspends bridge ownership while an in-call media list is temporarily empty", async () => { - const stream = { getAudioTracks: () => [{ readyState: "live" }] }; + const stream = liveMediaStream(); const source: PageMedia = { muted: true, sinkId: "built-in-output", @@ -741,7 +542,7 @@ describe("Microsoft Teams meeting audio routing", () => { const source: PageMedia = { muted: true, sinkId: "built-in-output", - srcObject: { getAudioTracks: () => [{ readyState: "live" }] }, + srcObject: liveMediaStream(), async setSinkId() {}, }; let pauses = 0; @@ -753,13 +554,9 @@ describe("Microsoft Teams meeting audio routing", () => { remove: () => (removals += 1), async setSinkId() {}, }; - const { result, window } = await runStatusScript({ - allowMicrophone: true, + const { result, window } = await runAudioStatusScript({ devices: [{ deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }], - leave: control({ label: "Leave" }), media: [source], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), priorAudioOutputs: [ { bridge, @@ -770,7 +567,6 @@ describe("Microsoft Teams meeting audio routing", () => { stream: source.srcObject, }, ], - priorMeeting: { identity: "teams-work:19:meeting_test@thread.v2" }, }); expect(source.muted).toBe(true); @@ -786,7 +582,7 @@ describe("Microsoft Teams meeting audio routing", () => { const source: PageMedia = { muted: true, sinkId: "built-in-output", - srcObject: { getAudioTracks: () => [{ readyState: "live" }] }, + srcObject: liveMediaStream(), async setSinkId() {}, }; let pauses = 0; @@ -806,15 +602,10 @@ describe("Microsoft Teams meeting audio routing", () => { sourceMuted: false, stream: source.srcObject, }; - const { window } = await runStatusScript({ - allowMicrophone: true, + const { window } = await runAudioStatusScript({ devices: [{ deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }], - leave: control({ label: "Leave" }), media: [source], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), priorAudioOutputs: [activeBridge], - priorMeeting: { identity: "teams-work:19:meeting_test@thread.v2" }, readOnly: true, }); @@ -825,7 +616,7 @@ describe("Microsoft Teams meeting audio routing", () => { }); it("reassigns a prior session bridge source after the new session identity is verified", async () => { - const stream = { getAudioTracks: () => [{ readyState: "live" }] }; + const stream = liveMediaStream(); const source: PageMedia = { muted: true, sinkId: "", srcObject: stream, async setSinkId() {} }; const emptySource: PageMedia = { muted: true, sinkId: "", async setSinkId() {} }; let pauses = 0; @@ -837,7 +628,6 @@ describe("Microsoft Teams meeting audio routing", () => { async setSinkId() {}, }; const { window } = await runStatusScript({ - allowMicrophone: false, leave: control({ label: "Leave" }), priorAudioOutputs: [ { @@ -879,13 +669,7 @@ describe("Microsoft Teams meeting audio routing", () => { }); it("ignores an unrelated media element when the live remote stream is routed", async () => { - const live: PageMedia = { - sinkId: "", - srcObject: { getAudioTracks: () => [{ readyState: "live" }] }, - async setSinkId(value) { - live.sinkId = value; - }, - }; + const live = pageMedia({ srcObject: liveMediaStream() }); const unrelated: PageMedia = { muted: true, sinkId: "built-in-output", @@ -893,16 +677,8 @@ describe("Microsoft Teams meeting audio routing", () => { throw new DOMException("The element has no supported source.", "AbortError"); }, }; - const { result } = await runStatusScript({ - allowMicrophone: true, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), + const { result } = await runAudioStatusScript({ media: [live, unrelated], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), priorMeeting: { audioInputDeviceId: "blackhole-input", identity: "teams-work:19:meeting_test@thread.v2", @@ -917,12 +693,7 @@ describe("Microsoft Teams meeting audio routing", () => { }); it("keeps a loaded non-MediaStream AbortError retryable", async () => { - const routed: PageMedia = { - sinkId: "", - async setSinkId(value) { - routed.sinkId = value; - }, - }; + const routed = pageMedia(); const loaded: PageMedia = { currentSrc: "blob:https://teams.live.com/remote-audio", readyState: 4, @@ -931,17 +702,8 @@ describe("Microsoft Teams meeting audio routing", () => { throw new DOMException("Cannot route loaded media.", "AbortError"); }, }; - const { result } = await runStatusScript({ - allowMicrophone: true, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), + const { result } = await runAudioStatusScript({ media: [routed, loaded], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), - priorMeeting: { identity: "teams-work:19:meeting_test@thread.v2" }, }); expect(result).toMatchObject({ @@ -953,36 +715,17 @@ describe("Microsoft Teams meeting audio routing", () => { }); it("preserves Teams mute state and ignores a muted local media stream", async () => { - const remote: PageMedia = { - muted: false, - sinkId: "", - srcObject: { getAudioTracks: () => [{ readyState: "live" }] }, - async setSinkId(value) { - remote.sinkId = value; - }, - }; + const remote = pageMedia({ muted: false, srcObject: liveMediaStream() }); let localRouteAttempts = 0; const local: PageMedia = { muted: true, sinkId: "built-in-output", - srcObject: { getAudioTracks: () => [{ readyState: "live" }] }, + srcObject: liveMediaStream(), async setSinkId() { localRouteAttempts += 1; }, }; - const params = { - allowMicrophone: true, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), - priorMeeting: { - identity: "teams-work:19:meeting_test@thread.v2", - }, - }; + const params = audioStatusParams(); const pending = await runStatusScript({ ...params, media: [local] }); expect(pending.result).toMatchObject({ audioInputRouted: true, diff --git a/extensions/teams-meetings/src/transports/teams-meetings-caption-capture.test.ts b/extensions/teams-meetings/src/transports/teams-meetings-caption-capture.test.ts index a33b13832776..54f867e4d45a 100644 --- a/extensions/teams-meetings/src/transports/teams-meetings-caption-capture.test.ts +++ b/extensions/teams-meetings/src/transports/teams-meetings-caption-capture.test.ts @@ -11,6 +11,8 @@ import { MEETING_STATE_KEY, control, captionRow, + runCaptionRows, + runCaptionStatusScript, runStatusScript, } from "./teams-meetings-platform-adapter.test-helpers.js"; @@ -39,13 +41,9 @@ describe("Microsoft Teams meeting captions and permissions", () => { }); it("enables live captions and captures the validated Teams caption row DOM", async () => { - const leave = control({ label: "Leave" }); - const { result, window } = await runStatusScript({ - allowMicrophone: false, - captionRows: [captionRow("OpenClaw QA", "Copper lantern validates Teams captions seven.")], - captureCaptions: true, - leave, - }); + const { result, window } = await runCaptionRows([ + captionRow("OpenClaw QA", "Copper lantern validates Teams captions seven."), + ]); expect(result).toMatchObject({ captioning: true, @@ -83,13 +81,9 @@ describe("Microsoft Teams meeting captions and permissions", () => { }); it("retries an unverified live-caption activation", async () => { - const first = await runStatusScript({ - allowMicrophone: false, + const first = await runCaptionRows([captionRow("OpenClaw QA", "Retry captions")], undefined, { captionClickIgnored: true, captionsInitiallyOn: false, - captionRows: [captionRow("OpenClaw QA", "Retry captions")], - captureCaptions: true, - leave: control({ label: "Leave" }), }); expect(first.captionButton.clicks).toBe(1); expect(first.result).toMatchObject({ @@ -97,13 +91,8 @@ describe("Microsoft Teams meeting captions and permissions", () => { captionsEnabledAttempted: false, }); - const second = await runStatusScript({ - allowMicrophone: false, + const second = await runCaptionRows([captionRow("OpenClaw QA", "Retry captions")], first, { captionsInitiallyOn: false, - captionRows: [captionRow("OpenClaw QA", "Retry captions")], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: first.window["__openclawTeamsCaptions"], priorMeeting: first.window[MEETING_STATE_KEY] as Record, }); expect(second.captionButton.clicks).toBe(1); @@ -114,12 +103,7 @@ describe("Microsoft Teams meeting captions and permissions", () => { }); it("preserves valid one-character caption lines", async () => { - const { result } = await runStatusScript({ - allowMicrophone: false, - captionRows: [captionRow("OpenClaw QA", "I")], - captureCaptions: true, - leave: control({ label: "Leave" }), - }); + const { result } = await runCaptionRows([captionRow("OpenClaw QA", "I")]); expect(result).toMatchObject({ lastCaptionText: "I", @@ -129,13 +113,10 @@ describe("Microsoft Teams meeting captions and permissions", () => { }); it("bounds visible and committed caption rows together", async () => { - const { result, window } = await runStatusScript({ - allowMicrophone: false, + const { result, window } = await runCaptionStatusScript({ captionRows: Array.from({ length: 505 }, (_, index) => captionRow("OpenClaw QA", `Bounded caption ${index}`), ), - captureCaptions: true, - leave: control({ label: "Leave" }), }); const state = window["__openclawTeamsCaptions"] as { droppedLines: number; @@ -163,18 +144,8 @@ describe("Microsoft Teams meeting captions and permissions", () => { }); it("keeps repeated utterances from distinct caption rows", async () => { - const first = await runStatusScript({ - allowMicrophone: false, - captionRows: [captionRow("OpenClaw QA", "Yes")], - captureCaptions: true, - leave: control({ label: "Leave" }), - }); - const second = await runStatusScript({ - allowMicrophone: false, - captionRows: [captionRow("OpenClaw QA", "Yes")], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: first.window["__openclawTeamsCaptions"], + const first = await runCaptionRows([captionRow("OpenClaw QA", "Yes")]); + const second = await runCaptionRows([captionRow("OpenClaw QA", "Yes")], first, { priorMeeting: first.window[MEETING_STATE_KEY] as Record, }); @@ -184,12 +155,8 @@ describe("Microsoft Teams meeting captions and permissions", () => { it("keeps the latest caption when Teams shortens a provisional row", async () => { vi.useFakeTimers(); const row = captionRow("OpenClaw QA", "We should leave today"); - const first = await runStatusScript({ - allowMicrophone: false, + const first = await runCaptionRows([row], undefined, { captionsInitiallyOn: true, - captionRows: [row], - captureCaptions: true, - leave: control({ label: "Leave" }), }); await vi.advanceTimersByTimeAsync(1_000); const caption = row.querySelector('[data-tid="closed-caption-text"]'); @@ -197,13 +164,7 @@ describe("Microsoft Teams meeting captions and permissions", () => { throw new Error("expected caption text control"); } caption.textContent = "We should leave"; - const second = await runStatusScript({ - allowMicrophone: false, - captionRows: [row], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: first.window["__openclawTeamsCaptions"], - }); + const second = await runCaptionRows([row], first); expect(second.result.lastCaptionText).toBe("We should leave"); expect(second.result.recentTranscript).toMatchObject([ @@ -213,43 +174,24 @@ describe("Microsoft Teams meeting captions and permissions", () => { it("keeps a mid-sentence caption correction in the same row lifecycle", async () => { const row = captionRow("OpenClaw QA", "I like cats"); - const first = await runStatusScript({ - allowMicrophone: false, - captionRows: [row], - captureCaptions: true, - leave: control({ label: "Leave" }), - }); + const first = await runCaptionRows([row]); const caption = row.querySelector('[data-tid="closed-caption-text"]'); if (!caption) { throw new Error("expected caption text control"); } caption.textContent = "I liked cats"; - const second = await runStatusScript({ - allowMicrophone: false, - captionRows: [row], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: first.window["__openclawTeamsCaptions"], - }); + const second = await runCaptionRows([row], first); expect(second.result.transcriptLines).toBe(1); expect(second.result.lastCaptionText).toBe("I liked cats"); }); it("keeps one utterance when Teams replaces a logically identical caption row", async () => { - const first = await runStatusScript({ - allowMicrophone: false, - captionRows: [captionRow("OpenClaw QA", "Logical row", "8")], - captureCaptions: true, - leave: control({ label: "Leave" }), - }); - const second = await runStatusScript({ - allowMicrophone: false, - captionRows: [captionRow("OpenClaw QA", "Logical row replacement", "8")], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: first.window["__openclawTeamsCaptions"], - }); + const first = await runCaptionRows([captionRow("OpenClaw QA", "Logical row", "8")]); + const second = await runCaptionRows( + [captionRow("OpenClaw QA", "Logical row replacement", "8")], + first, + ); expect(second.result.transcriptLines).toBe(1); expect(second.result.lastCaptionText).toBe("Logical row replacement"); @@ -257,21 +199,18 @@ describe("Microsoft Teams meeting captions and permissions", () => { it("updates a settled logical row in place when Teams corrects it late", async () => { vi.useFakeTimers(); - const first = await runStatusScript({ - allowMicrophone: false, - captionsInitiallyOn: true, - captionRows: [captionRow("OpenClaw QA", "Late logical", "9")], - captureCaptions: true, - leave: control({ label: "Leave" }), - }); + const first = await runCaptionRows( + [captionRow("OpenClaw QA", "Late logical", "9")], + undefined, + { + captionsInitiallyOn: true, + }, + ); await vi.advanceTimersByTimeAsync(1_000); - const second = await runStatusScript({ - allowMicrophone: false, - captionRows: [captionRow("OpenClaw QA", "Late logical correction", "9")], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: first.window["__openclawTeamsCaptions"], - }); + const second = await runCaptionRows( + [captionRow("OpenClaw QA", "Late logical correction", "9")], + first, + ); expect(second.result.transcriptLines).toBe(1); expect(second.result.recentTranscript).toMatchObject([{ text: "Late logical correction" }]); @@ -279,28 +218,19 @@ describe("Microsoft Teams meeting captions and permissions", () => { it("deduplicates a settled logical row after temporary DOM removal", async () => { vi.useFakeTimers(); - const first = await runStatusScript({ - allowMicrophone: false, - captionsInitiallyOn: true, - captionRows: [captionRow("OpenClaw QA", "Virtual row return", "13")], - captureCaptions: true, - leave: control({ label: "Leave" }), - }); + const first = await runCaptionRows( + [captionRow("OpenClaw QA", "Virtual row return", "13")], + undefined, + { + captionsInitiallyOn: true, + }, + ); await vi.advanceTimersByTimeAsync(1_000); - const missing = await runStatusScript({ - allowMicrophone: false, - captionRows: [], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: first.window["__openclawTeamsCaptions"], - }); - const returned = await runStatusScript({ - allowMicrophone: false, - captionRows: [captionRow("OpenClaw QA", "Virtual row return", "13")], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: missing.window["__openclawTeamsCaptions"], - }); + const missing = await runCaptionRows([], first); + const returned = await runCaptionRows( + [captionRow("OpenClaw QA", "Virtual row return", "13")], + missing, + ); expect(returned.result.transcriptLines).toBe(1); expect(returned.result.recentTranscript).toMatchObject([{ text: "Virtual row return" }]); @@ -309,32 +239,17 @@ describe("Microsoft Teams meeting captions and permissions", () => { it("commits an utterance when Teams recycles the same virtual-list row", async () => { vi.useFakeTimers(); const row = captionRow("OpenClaw QA", "First recycled-row utterance"); - const first = await runStatusScript({ - allowMicrophone: false, + const first = await runCaptionRows([row], undefined, { captionsInitiallyOn: true, - captionRows: [row], - captureCaptions: true, - leave: control({ label: "Leave" }), - }); - const disappeared = await runStatusScript({ - allowMicrophone: false, - captionRows: [], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: first.window["__openclawTeamsCaptions"], }); + const disappeared = await runCaptionRows([], first); await vi.advanceTimersByTimeAsync(1_000); const caption = row.querySelector('[data-tid="closed-caption-text"]'); if (!caption) { throw new Error("expected caption text control"); } caption.textContent = "Completely different second utterance"; - const second = await runStatusScript({ - allowMicrophone: false, - captionRows: [row], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: disappeared.window["__openclawTeamsCaptions"], + const second = await runCaptionRows([row], disappeared, { priorMeeting: first.window[MEETING_STATE_KEY] as Record, }); @@ -347,25 +262,14 @@ describe("Microsoft Teams meeting captions and permissions", () => { it("commits a removed row before Teams rapidly reuses its DOM node", async () => { const row = captionRow("OpenClaw QA", "Rapid first utterance"); - const first = await runStatusScript({ - allowMicrophone: false, - captionRows: [row], - captureCaptions: true, - leave: control({ label: "Leave" }), - }); + const first = await runCaptionRows([row]); const caption = row.querySelector('[data-tid="closed-caption-text"]'); if (!caption) { throw new Error("expected caption text control"); } caption.textContent = "Rapid second utterance"; first.triggerCaptionMutation(undefined, row); - const second = await runStatusScript({ - allowMicrophone: false, - captionRows: [row], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: first.window["__openclawTeamsCaptions"], - }); + const second = await runCaptionRows([row], first); expect(second.result.transcriptLines).toBe(2); expect(second.result.recentTranscript).toMatchObject([ @@ -377,33 +281,17 @@ describe("Microsoft Teams meeting captions and permissions", () => { it("does not merge recycled rows that only share a text prefix", async () => { vi.useFakeTimers(); const row = captionRow("OpenClaw QA", "Thank you"); - const first = await runStatusScript({ - allowMicrophone: false, + const first = await runCaptionRows([row], undefined, { captionsInitiallyOn: true, - captionRows: [row], - captureCaptions: true, - leave: control({ label: "Leave" }), - }); - const disappeared = await runStatusScript({ - allowMicrophone: false, - captionRows: [], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: first.window["__openclawTeamsCaptions"], }); + const disappeared = await runCaptionRows([], first); await vi.advanceTimersByTimeAsync(1_000); const caption = row.querySelector('[data-tid="closed-caption-text"]'); if (!caption) { throw new Error("expected caption text control"); } caption.textContent = "Thank you everyone"; - const second = await runStatusScript({ - allowMicrophone: false, - captionRows: [row], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: disappeared.window["__openclawTeamsCaptions"], - }); + const second = await runCaptionRows([row], disappeared); expect(second.result.recentTranscript).toMatchObject([ { text: "Thank you" }, @@ -415,29 +303,13 @@ describe("Microsoft Teams meeting captions and permissions", () => { vi.useFakeTimers(); const firstRow = captionRow("OpenClaw QA", "First settled row", "11"); const secondRow = captionRow("OpenClaw QA", "Second settled row", "12"); - const first = await runStatusScript({ - allowMicrophone: false, + const first = await runCaptionRows([firstRow], undefined, { captionsInitiallyOn: true, - captionRows: [firstRow], - captureCaptions: true, - leave: control({ label: "Leave" }), }); await vi.advanceTimersByTimeAsync(1_000); - const second = await runStatusScript({ - allowMicrophone: false, - captionRows: [firstRow, secondRow], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: first.window["__openclawTeamsCaptions"], - }); + const second = await runCaptionRows([firstRow, secondRow], first); await vi.advanceTimersByTimeAsync(1_000); - const third = await runStatusScript({ - allowMicrophone: false, - captionRows: [firstRow, secondRow], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: second.window["__openclawTeamsCaptions"], - }); + const third = await runCaptionRows([firstRow, secondRow], second); expect(third.result.transcriptLines).toBe(2); expect(third.result.recentTranscript).toMatchObject([ @@ -448,24 +320,13 @@ describe("Microsoft Teams meeting captions and permissions", () => { it("updates a caption when its speaker label arrives late", async () => { const row = captionRow("", "Late attribution"); - const first = await runStatusScript({ - allowMicrophone: false, - captionRows: [row], - captureCaptions: true, - leave: control({ label: "Leave" }), - }); + const first = await runCaptionRows([row]); const author = row.querySelector('[data-tid="author"]'); if (!author) { throw new Error("expected caption author control"); } author.textContent = "OpenClaw QA"; - const second = await runStatusScript({ - allowMicrophone: false, - captionRows: [row], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: first.window["__openclawTeamsCaptions"], - }); + const second = await runCaptionRows([row], first); expect(second.result.transcriptLines).toBe(1); expect(second.result.recentTranscript).toMatchObject([ @@ -474,19 +335,13 @@ describe("Microsoft Teams meeting captions and permissions", () => { }); it("updates a corrected speaker on the same logical row before settlement", async () => { - const first = await runStatusScript({ - allowMicrophone: false, - captionRows: [captionRow("OpenClaw Q", "Stable speaker correction", "10")], - captureCaptions: true, - leave: control({ label: "Leave" }), - }); - const second = await runStatusScript({ - allowMicrophone: false, - captionRows: [captionRow("OpenClaw QA", "Stable speaker correction", "10")], - captureCaptions: true, - leave: control({ label: "Leave" }), - priorCaptions: first.window["__openclawTeamsCaptions"], - }); + const first = await runCaptionRows([ + captionRow("OpenClaw Q", "Stable speaker correction", "10"), + ]); + const second = await runCaptionRows( + [captionRow("OpenClaw QA", "Stable speaker correction", "10")], + first, + ); expect(second.result.transcriptLines).toBe(1); expect(second.result.recentTranscript).toMatchObject([ @@ -497,8 +352,6 @@ describe("Microsoft Teams meeting captions and permissions", () => { it("disconnects stale caption capture outside transcribe mode", async () => { let disconnects = 0; const { window } = await runStatusScript({ - allowMicrophone: false, - captureCaptions: false, leave: control({ label: "Leave" }), priorCaptions: { observer: { disconnect: () => (disconnects += 1) }, @@ -518,18 +371,13 @@ describe("Microsoft Teams meeting captions and permissions", () => { sessionId: "another-session", }; const wrongTab = await runStatusScript({ - allowMicrophone: false, allowSessionAdoption: false, - captureCaptions: false, currentUrl: "https://teams.live.com/v2/", leave: control({ label: "Leave" }), priorCaptions: active, }); - const wrongSession = await runStatusScript({ - allowMicrophone: false, + const wrongSession = await runCaptionStatusScript({ allowSessionAdoption: false, - captureCaptions: true, - leave: control({ label: "Leave" }), priorCaptions: active, }); @@ -549,7 +397,6 @@ describe("Microsoft Teams meeting captions and permissions", () => { visible: [{ text: "Last caption before navigation" }], }; const { window } = await runStatusScript({ - allowMicrophone: false, currentUrl: CONSUMER_URL, priorCaptions: captions, priorMeeting: { @@ -570,11 +417,8 @@ describe("Microsoft Teams meeting captions and permissions", () => { it("finalizes same-session captions when meeting identity is lost", async () => { let disconnects = 0; const currentUrl = "https://teams.live.com/v2/"; - const { window } = await runStatusScript({ - allowMicrophone: false, - captureCaptions: true, + const { window } = await runCaptionStatusScript({ currentUrl, - leave: control({ label: "Leave" }), priorCaptions: { droppedLines: 0, epoch: "caption-epoch", @@ -615,7 +459,6 @@ describe("Microsoft Teams meeting captions and permissions", () => { it("finalizes caption capture before an SPA navigation can mix meetings", async () => { const params = { - allowMicrophone: false, captionRows: [captionRow("OpenClaw QA", "Meeting A caption")], captureCaptions: true, leave: control({ label: "Leave" }), @@ -688,7 +531,6 @@ describe("Microsoft Teams meeting captions and permissions", () => { }; const currentUrl = "https://teams.live.com/v2/"; const { window } = await runStatusScript({ - allowMicrophone: false, captureCaptions: true, currentUrl, priorCaptions: active, @@ -708,10 +550,8 @@ describe("Microsoft Teams meeting captions and permissions", () => { it("keeps the live caption observer during a bounded in-call control rerender", async () => { const leave = control({ label: "Leave" }); const currentUrl = "https://teams.live.com/v2/"; - const page = await runStatusScript({ - allowMicrophone: false, + const page = await runCaptionStatusScript({ captionRows: [captionRow("OpenClaw QA", "Caption before control rerender")], - captureCaptions: true, currentUrl, leave, priorMeeting: { @@ -740,11 +580,7 @@ describe("Microsoft Teams meeting captions and permissions", () => { sessionId: "old-session", visible: [], }; - const { window } = await runStatusScript({ - allowMicrophone: false, - captionRows: [captionRow("OpenClaw QA", "New session")], - captureCaptions: true, - leave: control({ label: "Leave" }), + const { window } = await runCaptionRows([captionRow("OpenClaw QA", "New session")], undefined, { priorCaptions: old, }); const current = window["__openclawTeamsCaptions"] as Record; @@ -773,10 +609,8 @@ describe("Microsoft Teams meeting captions and permissions", () => { visible: [], }; - const { result, window } = await runStatusScript({ - allowMicrophone: false, + const { result, window } = await runCaptionStatusScript({ allowSessionAdoption: false, - captureCaptions: true, leave, priorCaptions, priorMeeting, @@ -809,10 +643,8 @@ describe("Microsoft Teams meeting captions and permissions", () => { visible: [], }; - const { result, window } = await runStatusScript({ - allowMicrophone: false, + const { result, window } = await runCaptionStatusScript({ allowSessionAdoption: false, - captureCaptions: true, leave, meetingSessionId: "", priorCaptions, @@ -837,11 +669,8 @@ describe("Microsoft Teams meeting captions and permissions", () => { sessionId: "stale-session", visible: [], }; - const { result, window } = await runStatusScript({ - allowMicrophone: false, + const { result, window } = await runCaptionStatusScript({ allowSessionAdoption: false, - captureCaptions: true, - leave: control({ label: "Leave" }), priorCaptions: staleCaptions, priorMeeting: { identity: "teams-work:19:meeting_test@thread.v2", @@ -856,12 +685,7 @@ describe("Microsoft Teams meeting captions and permissions", () => { }); it("disconnects caption capture when finalizing a transcript", async () => { - const first = await runStatusScript({ - allowMicrophone: false, - captionRows: [captionRow("OpenClaw QA", "Final caption")], - captureCaptions: true, - leave: control({ label: "Leave" }), - }); + const first = await runCaptionRows([captionRow("OpenClaw QA", "Final caption")]); let disconnects = 0; const captions = first.window["__openclawTeamsCaptions"] as Record; captions.observer = { disconnect: () => (disconnects += 1) }; @@ -880,11 +704,7 @@ describe("Microsoft Teams meeting captions and permissions", () => { expect(captions.identity).toBe("teams-work:19:meeting_test@thread.v2"); expect(typeof captions.finalizedAt).toBe("number"); - const refreshed = await runStatusScript({ - allowMicrophone: false, - captionRows: [captionRow("OpenClaw QA", "Late caption")], - captureCaptions: true, - leave: control({ label: "Leave" }), + const refreshed = await runCaptionRows([captionRow("OpenClaw QA", "Late caption")], undefined, { priorCaptions: captions, }); expect(refreshed.window["__openclawTeamsCaptions"]).toBe(captions); diff --git a/extensions/teams-meetings/src/transports/teams-meetings-platform-adapter.test-helpers.ts b/extensions/teams-meetings/src/transports/teams-meetings-platform-adapter.test-helpers.ts index c284b9631111..732ab27bf636 100644 --- a/extensions/teams-meetings/src/transports/teams-meetings-platform-adapter.test-helpers.ts +++ b/extensions/teams-meetings/src/transports/teams-meetings-platform-adapter.test-helpers.ts @@ -61,6 +61,30 @@ export type PageMedia = { setSinkId(value: string): Promise; }; +export const liveMediaStream = () => ({ getAudioTracks: () => [{ readyState: "live" }] }); + +export function pageMedia(params: Partial = {}): PageMedia { + const media: PageMedia = { + ...params, + sinkId: params.sinkId ?? "", + setSinkId: + params.setSinkId ?? + (async (value) => { + media.sinkId = value; + }), + }; + return media; +} + +export function abortingMedia(message: string, params: Partial = {}) { + return pageMedia({ + ...params, + async setSinkId() { + throw new DOMException(message, "AbortError"); + }, + }); +} + export function control(params: { checked?: boolean; label: string; @@ -113,8 +137,8 @@ export function captionRow( return row; } -export async function runStatusScript(params: { - allowMicrophone: boolean; +type StatusScriptParams = { + allowMicrophone?: boolean; allowSessionAdoption?: boolean; autoJoin?: boolean; bodyText?: string; @@ -145,7 +169,9 @@ export async function runStatusScript(params: { media?: PageMedia[]; meetingSessionId?: string; devices?: Array<{ deviceId: string; kind: string; label: string }>; -}) { +}; + +export async function runStatusScript(params: StatusScriptParams) { const currentUrl = params.currentUrl ?? URL; const location = new globalThis.URL(currentUrl); const controls = [ @@ -288,7 +314,7 @@ export async function runStatusScript(params: { window["__openclawTeamsCaptions"] = params.priorCaptions; } const script = teamsMeetingStatusScript({ - allowMicrophone: params.allowMicrophone, + allowMicrophone: params.allowMicrophone ?? false, allowSessionAdoption: params.allowSessionAdoption ?? true, autoJoin: params.autoJoin ?? true, captureCaptions: params.captureCaptions ?? false, @@ -341,6 +367,61 @@ export async function runStatusScript(params: { }; } +type StatusOverrides = Omit; + +function runInCallStatusScript(params: StatusOverrides = {}) { + return runStatusScript({ + allowMicrophone: false, + leave: control({ label: "Leave" }), + ...params, + }); +} + +export function runCaptionStatusScript(params: StatusOverrides = {}) { + return runInCallStatusScript({ captureCaptions: true, ...params }); +} + +type StatusScriptPage = Awaited>; + +export function continueStatusScript(previous: StatusScriptPage, params: StatusScriptParams = {}) { + return runStatusScript({ + ...params, + priorAudioOutputs: previous.window["__openclawTeamsAudioOutputs"] as unknown[], + priorMeeting: previous.window[MEETING_STATE_KEY] as Record, + }); +} + +export function runCaptionRows( + captionRows: PageControl[], + previous?: StatusScriptPage, + params: StatusOverrides = {}, +) { + return runCaptionStatusScript({ + captionRows, + priorCaptions: previous?.window["__openclawTeamsCaptions"], + ...params, + }); +} + +export function runAudioStatusScript(params: StatusOverrides = {}) { + return runStatusScript(audioStatusParams(params)); +} + +export function audioStatusParams(params: StatusOverrides = {}): StatusScriptParams { + return { + allowMicrophone: true, + devices: [ + { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, + { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, + ], + leave: control({ label: "Leave" }), + microphone: control({ label: "Turn microphone off", pressed: true }), + microphoneDevice: control({ label: "BlackHole 2ch" }), + priorMeeting: { identity: "teams-work:19:meeting_test@thread.v2" }, + ...params, + }; +} + export function runLeaveScript(params: { bodyText?: string; currentUrl?: string; diff --git a/extensions/teams-meetings/src/transports/teams-meetings-platform-adapter.test.ts b/extensions/teams-meetings/src/transports/teams-meetings-platform-adapter.test.ts index 1a6a3a5d3655..dbd7a531d0e0 100644 --- a/extensions/teams-meetings/src/transports/teams-meetings-platform-adapter.test.ts +++ b/extensions/teams-meetings/src/transports/teams-meetings-platform-adapter.test.ts @@ -7,6 +7,9 @@ import { consumerLightMeetingUrl, status, control, + liveMediaStream, + pageMedia, + runAudioStatusScript, runStatusScript, runLeaveScript, type PageMedia, @@ -97,7 +100,6 @@ describe("Microsoft Teams meeting platform adapter", () => { ...(ariaPressed === undefined ? {} : { pressed: ariaPressed === "true" }), }); const { result } = await runStatusScript({ - allowMicrophone: false, ...(kind === "camera" ? { camera: target } : { microphone: target }), readOnly: true, }); @@ -113,7 +115,6 @@ describe("Microsoft Teams meeting platform adapter", () => { ])("reads the live %s switch checked=%s", async (kind, checked, expectedOff) => { const target = control({ checked, label: kind === "camera" ? "Camera" : "Microphone" }); const { result } = await runStatusScript({ - allowMicrophone: false, ...(kind === "camera" ? { camera: target } : { microphone: target }), readOnly: true, }); @@ -134,7 +135,6 @@ describe("Microsoft Teams meeting platform adapter", () => { const join = control({ label: "Join now" }); const { result } = await runStatusScript({ - allowMicrophone: false, camera, join, microphone, @@ -149,7 +149,6 @@ describe("Microsoft Teams meeting platform adapter", () => { it("allows non-adopting recovery to continue for the current page owner", async () => { const join = control({ label: "Join now" }); const { result } = await runStatusScript({ - allowMicrophone: false, allowSessionAdoption: false, camera: control({ label: "Turn camera on", pressed: false }), join, @@ -171,7 +170,6 @@ describe("Microsoft Teams meeting platform adapter", () => { sessionId: "consumer-session", }; const { result, window } = await runStatusScript({ - allowMicrophone: false, allowSessionAdoption: true, currentUrl: CONSUMER_URL, priorMeeting, @@ -262,7 +260,6 @@ describe("Microsoft Teams meeting platform adapter", () => { it("does not stamp meeting identity onto unrelated Teams pages", async () => { const leave = control({ label: "Leave" }); const { result, window } = await runStatusScript({ - allowMicrophone: false, currentUrl: "https://teams.microsoft.com/v2/", leave, }); @@ -273,7 +270,6 @@ describe("Microsoft Teams meeting platform adapter", () => { it("verifies the consumer prejoin redirect from its encoded meeting coordinates", async () => { const { result, window } = await runStatusScript({ - allowMicrophone: false, currentUrl: consumerLightMeetingUrl("9326458712345", "abc"), meetingUrl: CONSUMER_URL, }); @@ -287,7 +283,6 @@ describe("Microsoft Teams meeting platform adapter", () => { it("rejects consumer prejoin coordinates for a different meeting", async () => { const { result, window } = await runStatusScript({ - allowMicrophone: false, currentUrl: consumerLightMeetingUrl("1111111111111", "other"), meetingUrl: CONSUMER_URL, }); @@ -309,7 +304,6 @@ describe("Microsoft Teams meeting platform adapter", () => { sessionId: "session-1", }; const { result, window } = await runStatusScript({ - allowMicrophone: false, currentUrl: inCallUrl, leave, priorMeeting, @@ -323,7 +317,6 @@ describe("Microsoft Teams meeting platform adapter", () => { const prejoin = await runStatusScript({ allowMicrophone: false }); const leave = control({ label: "Leave" }); const admitted = await runStatusScript({ - allowMicrophone: false, currentUrl: "https://teams.microsoft.com/v2/", leave, priorMeeting: prejoin.window[MEETING_STATE_KEY] as Record, @@ -340,7 +333,6 @@ describe("Microsoft Teams meeting platform adapter", () => { it("retains prejoin identity for the configured in-call wait", async () => { const leave = control({ label: "Leave" }); const admitted = await runStatusScript({ - allowMicrophone: false, currentUrl: "https://teams.microsoft.com/v2/", leave, priorMeeting: { @@ -364,7 +356,6 @@ describe("Microsoft Teams meeting platform adapter", () => { const currentLeave = control({ label: "Leave" }); const inCallUrl = "https://teams.microsoft.com/v2/"; const { result, window } = await runStatusScript({ - allowMicrophone: false, currentUrl: inCallUrl, leave: currentLeave, priorMeeting: { @@ -507,7 +498,7 @@ describe("Microsoft Teams meeting platform adapter", () => { isConnected: false, muted: true, pause: vi.fn(), - srcObject: { getAudioTracks: () => [{ readyState: "live" }] }, + srcObject: liveMediaStream(), }; const detachedBridge = { pause: vi.fn(), remove: vi.fn(), srcObject: {} }; const foreignBridge = { sessionId: "session-2" }; @@ -549,8 +540,8 @@ describe("Microsoft Teams meeting platform adapter", () => { }); it("does not unmute a replacement stream during leave cleanup", () => { - const bridgedStream = { getAudioTracks: () => [{ readyState: "live" }] }; - const replacementStream = { getAudioTracks: () => [{ readyState: "live" }] }; + const bridgedStream = liveMediaStream(); + const replacementStream = liveMediaStream(); const source = { muted: true, srcObject: replacementStream }; const bridge = { pause: vi.fn(), remove: vi.fn(), srcObject: bridgedStream }; const { result } = runLeaveScript({ @@ -629,7 +620,7 @@ describe("Microsoft Teams meeting platform adapter", () => { }); it("retires the page-owned audio bridge from the required URL-only leave callback", () => { - const stream = { getAudioTracks: () => [{ readyState: "live" }] }; + const stream = liveMediaStream(); const source = { muted: true, srcObject: stream }; const bridge = { pause: vi.fn(), remove: vi.fn(), srcObject: stream }; const { result, window } = runLeaveScript({ @@ -663,7 +654,6 @@ describe("Microsoft Teams meeting platform adapter", () => { ])("ignores participant-controlled in-call text: %s", async (bodyText) => { const leave = control({ label: "Leave" }); const { result } = await runStatusScript({ - allowMicrophone: false, bodyText, leave, }); @@ -676,7 +666,6 @@ describe("Microsoft Teams meeting platform adapter", () => { it("classifies a stable device permission prompt outside the call", async () => { const { result } = await runStatusScript({ - allowMicrophone: false, permissionPrompt: control({ label: "Device permission prompt" }), }); @@ -689,7 +678,6 @@ describe("Microsoft Teams meeting platform adapter", () => { it("does not report a prompt that it just dismissed while Teams removes the DOM", async () => { const continueWithoutDevices = control({ label: "Continue without audio or video" }); const { result } = await runStatusScript({ - allowMicrophone: false, continueWithoutDevices, permissionPrompt: control({ label: "Device permission prompt" }), }); @@ -773,7 +761,7 @@ describe("Microsoft Teams meeting platform adapter", () => { const microphone = control({ label: "Turn microphone off", pressed: true }); const media = { sinkId: "", - srcObject: { getAudioTracks: () => [{ readyState: "live" }] }, + srcObject: liveMediaStream(), async setSinkId(value: string) { media.sinkId = value; }, @@ -806,15 +794,9 @@ describe("Microsoft Teams meeting platform adapter", () => { it("reports the prepared session input during read-only status inspection", async () => { const media: PageMedia = { sinkId: "blackhole-output", async setSinkId() {} }; - const { result } = await runStatusScript({ - allowMicrophone: true, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), + const { result } = await runAudioStatusScript({ media: [media], - microphone: control({ label: "Turn microphone off", pressed: true }), + microphoneDevice: undefined, priorMeeting: { audioInputDeviceId: "blackhole-input", identity: "teams-work:19:meeting_test@thread.v2", @@ -831,23 +813,9 @@ describe("Microsoft Teams meeting platform adapter", () => { }); it("routes a directly playable media element before its MediaStream is attached", async () => { - const media: PageMedia = { - sinkId: "", - async setSinkId(value) { - media.sinkId = value; - }, - }; - const { result } = await runStatusScript({ - allowMicrophone: true, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), + const media = pageMedia(); + const { result } = await runAudioStatusScript({ media: [media], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), - priorMeeting: { identity: "teams-work:19:meeting_test@thread.v2" }, }); expect(result.audioOutputRouted).toBe(true); @@ -911,14 +879,12 @@ describe("Microsoft Teams meeting platform adapter", () => { expect(continueWithoutDevices.clicks).toBe(0); await runStatusScript({ - allowMicrophone: false, continueWithoutDevices, microphone: control({ label: "Turn microphone on", pressed: false }), }); expect(continueWithoutDevices.clicks).toBe(1); await runStatusScript({ - allowMicrophone: false, autoJoin: false, continueWithoutDevices, microphone: control({ label: "Turn microphone on", pressed: false }), @@ -930,7 +896,7 @@ describe("Microsoft Teams meeting platform adapter", () => { const source: PageMedia = { muted: false, sinkId: "built-in-output", - srcObject: { getAudioTracks: () => [{ readyState: "live" }] }, + srcObject: liveMediaStream(), async setSinkId() { throw new DOMException("The element has no supported source.", "AbortError"); }, @@ -950,17 +916,9 @@ describe("Microsoft Teams meeting platform adapter", () => { bridge.sinkId = value; }, }; - const { result, window } = await runStatusScript({ - allowMicrophone: true, + const { result, window } = await runAudioStatusScript({ bridgeMedia: bridge, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), media: [source], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), priorMeeting: { audioInputDeviceId: "blackhole-input", identity: "teams-work:19:meeting_test@thread.v2", @@ -981,17 +939,9 @@ describe("Microsoft Teams meeting platform adapter", () => { bridge, ); - const repeated = await runStatusScript({ - allowMicrophone: true, + const repeated = await runAudioStatusScript({ bridgeMedia: bridge, - devices: [ - { deviceId: "blackhole-input", kind: "audioinput", label: "BlackHole 2ch" }, - { deviceId: "blackhole-output", kind: "audiooutput", label: "BlackHole 2ch" }, - ], - leave: control({ label: "Leave" }), media: [source, bridge], - microphone: control({ label: "Turn microphone off", pressed: true }), - microphoneDevice: control({ label: "BlackHole 2ch" }), priorAudioOutputs: window["__openclawTeamsAudioOutputs"] as unknown[], priorMeeting: window[MEETING_STATE_KEY] as Record, });