fix: Release the microphone to the browser when a call ends (#2685)
The microphone is connected when a call needs it and released the moment that call ends, so the browser stops reporting it as in use at hangup rather than `disconnect_seconds` later. Existing configurations are migrated automatically by the visual editor. - Closes: #2681 BREAKING CHANGE: `live.microphone.disconnect_seconds` is removed. The microphone is released when a call ends, so there is no idle countdown to configure. Use `live.microphone.always_connected` to hold it open instead. BREAKING CHANGE: The `microphone_connect` and `microphone_disconnect` actions are removed. The card owns the microphone lifecycle; `microphone_mute` and `microphone_unmute` remain. BREAKING CHANGE: `call` is removed from `live.microphone.auto_mute`, whose default is now `[]`. The microphone is muted when a call ends regardless of this option. BREAKING CHANGE: `microphone_unmute` has no effect outside a call. Nothing carries the audio at any other time, so the request is ignored rather than opening the microphone.
This commit is contained in:
@@ -1,19 +0,0 @@
|
||||
import { expect, it } from 'vitest';
|
||||
|
||||
import { MicrophoneConnectAction } from '../../../../src/card-controller/actions/actions/microphone-connect';
|
||||
import { createCardAPI } from '../../../test-utils';
|
||||
|
||||
it('should handle microphone_connect action', async () => {
|
||||
const api = createCardAPI();
|
||||
const action = new MicrophoneConnectAction(
|
||||
{},
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'microphone_connect',
|
||||
},
|
||||
);
|
||||
|
||||
await action.execute(api);
|
||||
|
||||
expect(api.getMicrophoneManager().connect).toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { expect, it } from 'vitest';
|
||||
|
||||
import { MicrophoneDisconnectAction } from '../../../../src/card-controller/actions/actions/microphone-disconnect';
|
||||
import { createCardAPI } from '../../../test-utils';
|
||||
|
||||
it('should handle microphone_disconnect action', async () => {
|
||||
const api = createCardAPI();
|
||||
const action = new MicrophoneDisconnectAction(
|
||||
{},
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'microphone_disconnect',
|
||||
},
|
||||
);
|
||||
|
||||
await action.execute(api);
|
||||
|
||||
expect(api.getMicrophoneManager().disconnect).toHaveBeenCalled();
|
||||
});
|
||||
@@ -20,8 +20,6 @@ import { InternalCallbackAction } from '../../../src/card-controller/actions/act
|
||||
import { LogAction } from '../../../src/card-controller/actions/actions/log';
|
||||
import { MediaPlayerAction } from '../../../src/card-controller/actions/actions/media-player';
|
||||
import { MenuToggleAction } from '../../../src/card-controller/actions/actions/menu-toggle';
|
||||
import { MicrophoneConnectAction } from '../../../src/card-controller/actions/actions/microphone-connect';
|
||||
import { MicrophoneDisconnectAction } from '../../../src/card-controller/actions/actions/microphone-disconnect';
|
||||
import { MicrophoneMuteAction } from '../../../src/card-controller/actions/actions/microphone-mute';
|
||||
import { MicrophoneUnmuteAction } from '../../../src/card-controller/actions/actions/microphone-unmute';
|
||||
import { MoreInfoAction } from '../../../src/card-controller/actions/actions/more-info';
|
||||
@@ -146,14 +144,6 @@ describe('ActionFactory', () => {
|
||||
MediaPlayerAction,
|
||||
],
|
||||
[{ advanced_camera_card_action: 'menu_toggle' as const }, MenuToggleAction],
|
||||
[
|
||||
{ advanced_camera_card_action: 'microphone_connect' as const },
|
||||
MicrophoneConnectAction,
|
||||
],
|
||||
[
|
||||
{ advanced_camera_card_action: 'microphone_disconnect' as const },
|
||||
MicrophoneDisconnectAction,
|
||||
],
|
||||
[
|
||||
{ advanced_camera_card_action: 'microphone_mute' as const },
|
||||
MicrophoneMuteAction,
|
||||
|
||||
@@ -527,7 +527,7 @@ describe('start', () => {
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneConnected: false,
|
||||
});
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue();
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(true);
|
||||
|
||||
expect(await new CallManager(api).start()).toBe(true);
|
||||
|
||||
@@ -1426,7 +1426,7 @@ describe('answer', () => {
|
||||
microphoneConnected: false,
|
||||
config: inboundConfig,
|
||||
});
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue();
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(true);
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
@@ -1442,9 +1442,9 @@ describe('answer', () => {
|
||||
microphoneConnected: false,
|
||||
config: inboundConfig,
|
||||
});
|
||||
let resolveConnect: () => void = () => {};
|
||||
let resolveConnect: (connected: boolean) => void = () => {};
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveConnect = resolve;
|
||||
}),
|
||||
);
|
||||
@@ -1459,7 +1459,7 @@ describe('answer', () => {
|
||||
// microphone permission prompt to be dealt with.
|
||||
expect(getRingtone().stop).toHaveBeenCalled();
|
||||
|
||||
resolveConnect();
|
||||
resolveConnect(true);
|
||||
expect(await answerPromise).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1540,22 +1540,26 @@ describe('answer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('microphone usage', () => {
|
||||
it('should mark the microphone in use for the duration of the call', async () => {
|
||||
describe('microphone transmission', () => {
|
||||
it('should transmit for the duration of the call', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
const manager = new CallManager(api);
|
||||
|
||||
expect(await manager.start()).toBe(true);
|
||||
|
||||
expect(api.getMicrophoneManager().startUsing).toHaveBeenCalledTimes(1);
|
||||
expect(api.getMicrophoneManager().stopUsing).not.toHaveBeenCalled();
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenCalledWith(true);
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
|
||||
false,
|
||||
);
|
||||
|
||||
manager.end();
|
||||
|
||||
expect(api.getMicrophoneManager().stopUsing).toHaveBeenCalledTimes(1);
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not mark the microphone in use when the start is aborted', async () => {
|
||||
it('should stop transmitting when the call fails to start', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneConnected: false,
|
||||
@@ -1564,10 +1568,18 @@ describe('microphone usage', () => {
|
||||
|
||||
expect(await new CallManager(api).start()).toBe(false);
|
||||
|
||||
expect(api.getMicrophoneManager().startUsing).not.toHaveBeenCalled();
|
||||
// Transmission is granted ahead of the microphone connect, so the failed
|
||||
// start must withdraw it again.
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
true,
|
||||
);
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep the microphone in use when a call supersedes another', async () => {
|
||||
it('should keep transmitting when one call replaces another', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
store: createStore([
|
||||
@@ -1586,26 +1598,331 @@ describe('microphone usage', () => {
|
||||
expect(await manager.start()).toBe(true);
|
||||
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true);
|
||||
|
||||
expect(api.getMicrophoneManager().startUsing).toHaveBeenCalledTimes(2);
|
||||
expect(api.getMicrophoneManager().stopUsing).toHaveBeenCalledTimes(1);
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should mark the microphone unused on uninitialization', async () => {
|
||||
it('should stop transmitting when uninitialized during a call', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
const manager = new CallManager(api);
|
||||
|
||||
expect(await manager.start()).toBe(true);
|
||||
manager.uninitialize();
|
||||
|
||||
expect(api.getMicrophoneManager().stopUsing).toHaveBeenCalledTimes(1);
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not mark the microphone unused when uninitializing without a call', () => {
|
||||
it('should stop transmitting when uninitialized without a call', () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
|
||||
new CallManager(api).uninitialize();
|
||||
|
||||
expect(api.getMicrophoneManager().stopUsing).not.toHaveBeenCalled();
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should leave transmission alone when an unanswered inbound call ends', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
expect(manager.end()).toBe(true);
|
||||
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should stop transmitting when uninitialized while a call connects the microphone', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneConnected: false,
|
||||
});
|
||||
let resolveConnect: (connected: boolean) => void = () => {};
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveConnect = resolve;
|
||||
}),
|
||||
);
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
|
||||
const startPromise = manager.start();
|
||||
manager.uninitialize();
|
||||
|
||||
// The grant must not outlive the manager while the connect is still
|
||||
// pending.
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
);
|
||||
|
||||
resolveConnect(true);
|
||||
expect(await startPromise).toBe(false);
|
||||
});
|
||||
|
||||
it('should let a later call stop transmitting after an abandoned call is uninitialized', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneConnected: false,
|
||||
});
|
||||
let resolveConnect: (connected: boolean) => void = () => {};
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveConnect = resolve;
|
||||
}),
|
||||
);
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
|
||||
// A start from the first lifecycle never gets its microphone.
|
||||
const stalledStart = manager.start();
|
||||
manager.uninitialize();
|
||||
manager.initialize();
|
||||
|
||||
// A call in the new lifecycle must end normally: the stalled request from
|
||||
// the old lifecycle no longer holds transmission.
|
||||
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(true);
|
||||
expect(await manager.start()).toBe(true);
|
||||
expect(manager.end()).toBe(true);
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
);
|
||||
|
||||
resolveConnect(true);
|
||||
expect(await stalledStart).toBe(false);
|
||||
});
|
||||
|
||||
it('should transmit for a call that starts while another call is ending', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
store: createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.garage',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
|
||||
expect(await manager.start()).toBe(true);
|
||||
|
||||
// The superseding start is suspended on the microphone while the answered
|
||||
// call ends and reports transmission inactive.
|
||||
const second = manager.start({ cameraID: 'camera.garage' });
|
||||
expect(manager.end()).toBe(true);
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
);
|
||||
|
||||
expect(await second).toBe(true);
|
||||
|
||||
// The installed session restated its need so the user can be heard on unmute.
|
||||
expect(manager.getCall()?.cameraID).toBe('camera.garage');
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not transmit for an inbound call that rings after another call ends', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
|
||||
expect(await manager.start()).toBe(true);
|
||||
expect(manager.end()).toBe(true);
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
);
|
||||
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
|
||||
// A ring transmits nothing, so it must not restate a need for
|
||||
// transmission.
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should stop transmitting when a call ends and the next cannot get the microphone', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
store: createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.garage',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
|
||||
expect(await manager.start()).toBe(true);
|
||||
|
||||
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(false);
|
||||
let rejectConnect: (reason: unknown) => void = () => {};
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
|
||||
new Promise<boolean>((_, reject) => {
|
||||
rejectConnect = reject;
|
||||
}),
|
||||
);
|
||||
const second = manager.start({ cameraID: 'camera.garage' });
|
||||
|
||||
expect(manager.end()).toBe(true);
|
||||
|
||||
rejectConnect(new Error());
|
||||
expect(await second).toBe(false);
|
||||
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should abort a call when the microphone does not stay connected', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneConnected: false,
|
||||
});
|
||||
let resolveConnect: (connected: boolean) => void = () => {};
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveConnect = resolve;
|
||||
}),
|
||||
);
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
|
||||
const start = manager.start();
|
||||
|
||||
// The microphone manager releases a stream it connected while nothing was
|
||||
// transmitting, and reports that the microphone is not connected.
|
||||
resolveConnect(false);
|
||||
|
||||
expect(await start).toBe(false);
|
||||
expect(manager.isActive()).toBe(false);
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not transmit while an inbound call rings', async () => {
|
||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow transmission before connecting the microphone on answer', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneConnected: false,
|
||||
});
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(true);
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
|
||||
expect(await manager.answer()).toBe(true);
|
||||
|
||||
// If transmission is not yet active when a connect completes, the
|
||||
// microphone manager immediately releases the new stream -- so the answer
|
||||
// must activate transmission before it connects. The ringing start touches
|
||||
// neither method, so index 0 is each method's first and only invocation,
|
||||
// made by the answer.
|
||||
const transmitOrder = vi.mocked(api.getMicrophoneManager().setTransmissionActive)
|
||||
.mock.invocationCallOrder[0];
|
||||
const connectOrder = vi.mocked(api.getMicrophoneManager().connect).mock
|
||||
.invocationCallOrder[0];
|
||||
expect(transmitOrder).toBeLessThan(connectOrder);
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should stop transmitting when the microphone connect fails on answer', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneConnected: false,
|
||||
});
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error());
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
|
||||
expect(await manager.answer()).toBe(false);
|
||||
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
);
|
||||
expect(manager.getCall()?.answered).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep transmitting when a failed start leaves an earlier call in progress', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
store: createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.garage',
|
||||
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||
},
|
||||
]),
|
||||
});
|
||||
const manager = new CallManager(api);
|
||||
|
||||
expect(await manager.start()).toBe(true);
|
||||
|
||||
// The second start fails its microphone connect, but the first call is
|
||||
// still answered and holds the transmission grant.
|
||||
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(false);
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error());
|
||||
|
||||
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(false);
|
||||
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
|
||||
false,
|
||||
);
|
||||
expect(manager.getCall()?.cameraID).toBe('camera.office');
|
||||
});
|
||||
|
||||
it('should stop transmitting when the call ends while answering connects the microphone', async () => {
|
||||
const api = createAPI({
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneConnected: false,
|
||||
});
|
||||
let resolveConnect: (connected: boolean) => void = () => {};
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveConnect = resolve;
|
||||
}),
|
||||
);
|
||||
const manager = new CallManager(api);
|
||||
manager.initialize();
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
|
||||
const answerPromise = manager.answer();
|
||||
manager.end();
|
||||
resolveConnect(true);
|
||||
|
||||
expect(await answerPromise).toBe(false);
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1839,9 +2156,9 @@ describe('state changes during in-flight start', () => {
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneConnected: false,
|
||||
});
|
||||
let resolveConnect: () => void = () => {};
|
||||
let resolveConnect: (connected: boolean) => void = () => {};
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveConnect = resolve;
|
||||
}),
|
||||
);
|
||||
@@ -1850,7 +2167,7 @@ describe('state changes during in-flight start', () => {
|
||||
|
||||
const startPromise = manager.start();
|
||||
manager.uninitialize();
|
||||
resolveConnect();
|
||||
resolveConnect(true);
|
||||
|
||||
expect(await startPromise).toBe(false);
|
||||
expect(manager.isActive()).toBe(false);
|
||||
@@ -1862,9 +2179,9 @@ describe('state changes during in-flight start', () => {
|
||||
view: createView({ camera: 'camera.office' }),
|
||||
microphoneConnected: false,
|
||||
});
|
||||
let resolveConnect: () => void = () => {};
|
||||
let resolveConnect: (connected: boolean) => void = () => {};
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveConnect = resolve;
|
||||
}),
|
||||
);
|
||||
@@ -1874,7 +2191,7 @@ describe('state changes during in-flight start', () => {
|
||||
const startPromise = manager.start();
|
||||
manager.uninitialize();
|
||||
manager.initialize();
|
||||
resolveConnect();
|
||||
resolveConnect(true);
|
||||
|
||||
expect(await startPromise).toBe(false);
|
||||
expect(manager.isActive()).toBe(false);
|
||||
@@ -1896,9 +2213,9 @@ describe('state changes during in-flight start', () => {
|
||||
},
|
||||
]),
|
||||
});
|
||||
let resolveConnect: () => void = () => {};
|
||||
let resolveConnect: (connected: boolean) => void = () => {};
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveConnect = resolve;
|
||||
}),
|
||||
);
|
||||
@@ -1908,17 +2225,18 @@ describe('state changes during in-flight start', () => {
|
||||
// Both requests read the (absent) session before either can install one.
|
||||
const first = manager.start();
|
||||
const second = manager.start({ cameraID: 'camera.garage' });
|
||||
resolveConnect();
|
||||
resolveConnect(true);
|
||||
|
||||
expect(await first).toBe(true);
|
||||
expect(await second).toBe(true);
|
||||
|
||||
// The second request must end the first request's session rather than
|
||||
// overwrite it, leaving exactly one live session and no stranded microphone
|
||||
// marking.
|
||||
// overwrite it, leaving exactly one live session with transmission still
|
||||
// granted.
|
||||
expect(manager.getCall()?.cameraID).toBe('camera.garage');
|
||||
expect(api.getMicrophoneManager().startUsing).toHaveBeenCalledTimes(2);
|
||||
expect(api.getMicrophoneManager().stopUsing).toHaveBeenCalledTimes(1);
|
||||
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should suppress the microphone-failure notification when uninitialized mid-await', async () => {
|
||||
@@ -1928,7 +2246,7 @@ describe('state changes during in-flight start', () => {
|
||||
});
|
||||
let rejectConnect: (reason: unknown) => void = () => {};
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
|
||||
new Promise<void>((_, reject) => {
|
||||
new Promise<boolean>((_, reject) => {
|
||||
rejectConnect = reject;
|
||||
}),
|
||||
);
|
||||
@@ -1957,7 +2275,7 @@ describe('state changes during in-flight answer', () => {
|
||||
api: CardController,
|
||||
): Promise<{
|
||||
manager: CallManager;
|
||||
resolveConnect: () => void;
|
||||
resolveConnect: (connected: boolean) => void;
|
||||
rejectConnect: (reason: unknown) => void;
|
||||
}> => {
|
||||
const manager = new CallManager(api);
|
||||
@@ -1965,10 +2283,10 @@ describe('state changes during in-flight answer', () => {
|
||||
expect(await manager.start({ inbound: true })).toBe(true);
|
||||
|
||||
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(false);
|
||||
let resolveConnect: () => void = () => {};
|
||||
let resolveConnect: (connected: boolean) => void = () => {};
|
||||
let rejectConnect: (reason: unknown) => void = () => {};
|
||||
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
new Promise<boolean>((resolve, reject) => {
|
||||
resolveConnect = resolve;
|
||||
rejectConnect = reject;
|
||||
}),
|
||||
@@ -1985,7 +2303,7 @@ describe('state changes during in-flight answer', () => {
|
||||
|
||||
const answerPromise = manager.answer();
|
||||
manager.uninitialize();
|
||||
resolveConnect();
|
||||
resolveConnect(true);
|
||||
|
||||
expect(await answerPromise).toBe(false);
|
||||
expect(manager.isActive()).toBe(false);
|
||||
@@ -2015,7 +2333,7 @@ describe('state changes during in-flight answer', () => {
|
||||
|
||||
const answerPromise = manager.answer();
|
||||
expect(manager.end()).toBe(true);
|
||||
resolveConnect();
|
||||
resolveConnect(true);
|
||||
|
||||
expect(await answerPromise).toBe(false);
|
||||
expect(manager.isActive()).toBe(false);
|
||||
|
||||
@@ -28,7 +28,6 @@ const medialessNavigatorMock: Navigator = {
|
||||
describe('MicrophoneManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('navigator', navigatorMock);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -44,30 +43,100 @@ describe('MicrophoneManager', () => {
|
||||
return stream;
|
||||
};
|
||||
|
||||
// Streams from `createMockStream` carry exactly one track.
|
||||
const getTrack = (stream: MediaStream): MediaStreamTrack => stream.getTracks()[0];
|
||||
|
||||
const createDeferredStream = (): {
|
||||
promise: Promise<MediaStream>;
|
||||
resolve: (stream: MediaStream) => void;
|
||||
reject: (error: Error) => void;
|
||||
} => {
|
||||
let resolveStream: ((stream: MediaStream) => void) | null = null;
|
||||
let rejectStream: ((error: Error) => void) | null = null;
|
||||
const promise = new Promise<MediaStream>((resolve, reject) => {
|
||||
resolveStream = resolve;
|
||||
rejectStream = reject;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve: (stream: MediaStream) => resolveStream?.(stream),
|
||||
reject: (error: Error) => rejectStream?.(error),
|
||||
};
|
||||
};
|
||||
|
||||
it('should be muted on creation', () => {
|
||||
const manager = new MicrophoneManager(createCardAPI());
|
||||
expect(manager).toBeTruthy();
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should be undefined without creation', () => {
|
||||
it('should have no stream before connecting', () => {
|
||||
const manager = new MicrophoneManager(createCardAPI());
|
||||
expect(manager.getStream()).toBeUndefined();
|
||||
expect(manager.getStream()).toBeNull();
|
||||
});
|
||||
|
||||
it('should connect', async () => {
|
||||
it('should connect while transmission is active', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
|
||||
const stream = createMockStream();
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
|
||||
|
||||
manager.setTransmissionActive(true);
|
||||
expect(await manager.connect()).toBe(true);
|
||||
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
expect(manager.getStream()).toBe(stream);
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should release a stream that connects without active transmission', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
|
||||
const stream = createMockStream();
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
|
||||
|
||||
expect(await manager.connect()).toBe(false);
|
||||
|
||||
expect(manager.isConnected()).toBeFalsy();
|
||||
expect(manager.isForbidden()).toBeFalsy();
|
||||
expect(getTrack(stream).stop).toHaveBeenCalled();
|
||||
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
microphone: expect.objectContaining({ connected: false, muted: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should hold a muted stream without transmission when always connected', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
live: {
|
||||
microphone: {
|
||||
always_connected: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const stream = createMockStream();
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
|
||||
|
||||
await manager.connect();
|
||||
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
expect(manager.getStream()).toBe(stream);
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalled();
|
||||
expect(getTrack(stream).stop).not.toHaveBeenCalled();
|
||||
|
||||
manager.mute();
|
||||
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
expect(getTrack(stream).stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should be unsupported without browser support', () => {
|
||||
@@ -104,24 +173,74 @@ describe('MicrophoneManager', () => {
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should mute and unmute', async () => {
|
||||
it('should mute and unmute while transmission is active', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
|
||||
createMockStream(),
|
||||
);
|
||||
|
||||
await manager.connect();
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
manager.setTransmissionActive(true);
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
|
||||
|
||||
manager.mute();
|
||||
await manager.connect();
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2);
|
||||
|
||||
await manager.unmute();
|
||||
expect(manager.isMuted()).toBeFalsy();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(3);
|
||||
|
||||
manager.mute();
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(4);
|
||||
|
||||
// Unmuting again reuses the held stream rather than re-requesting the
|
||||
// device.
|
||||
await manager.unmute();
|
||||
expect(manager.isMuted()).toBeFalsy();
|
||||
expect(navigatorMock.mediaDevices.getUserMedia).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not unmute without active transmission', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
|
||||
createMockStream(),
|
||||
);
|
||||
|
||||
await manager.unmute();
|
||||
|
||||
expect(navigatorMock.mediaDevices.getUserMedia).not.toHaveBeenCalled();
|
||||
expect(manager.isConnected()).toBeFalsy();
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
expect(api.getCardElementManager().update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not unmute without active transmission when always connected', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
live: {
|
||||
microphone: {
|
||||
always_connected: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const stream = createMockStream();
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
|
||||
|
||||
await manager.connect();
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
|
||||
await manager.unmute();
|
||||
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not unmute when microphone forbidden', async () => {
|
||||
@@ -129,14 +248,15 @@ describe('MicrophoneManager', () => {
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockRejectedValue(new Error());
|
||||
|
||||
manager.setTransmissionActive(true);
|
||||
await expect(manager.connect()).rejects.toThrow(Error);
|
||||
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
|
||||
|
||||
await manager.unmute();
|
||||
|
||||
expect(manager.isForbidden()).toBeTruthy();
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not unmute when not supported', async () => {
|
||||
@@ -150,13 +270,14 @@ describe('MicrophoneManager', () => {
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should connect on unmute', async () => {
|
||||
it('should connect on unmute while transmission is active', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
|
||||
createMockStream(),
|
||||
);
|
||||
|
||||
manager.setTransmissionActive(true);
|
||||
expect(manager.isConnected()).toBeFalsy();
|
||||
|
||||
await manager.unmute();
|
||||
@@ -167,236 +288,234 @@ describe('MicrophoneManager', () => {
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should disconnect', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
|
||||
createMockStream(),
|
||||
);
|
||||
|
||||
await manager.connect();
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
|
||||
|
||||
manager.disconnect();
|
||||
expect(manager.isConnected()).toBeFalsy();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should automatically disconnect', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const disconnectSeconds = 10;
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
|
||||
createMockStream(),
|
||||
);
|
||||
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
live: {
|
||||
microphone: {
|
||||
always_connected: false,
|
||||
disconnect_seconds: disconnectSeconds,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.connect();
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(disconnectSeconds * 1000);
|
||||
|
||||
expect(manager.isConnected()).toBeFalsy();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not automatically disconnect when always connected', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const disconnectSeconds = 10;
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
|
||||
createMockStream(),
|
||||
);
|
||||
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
live: {
|
||||
microphone: {
|
||||
always_connected: true,
|
||||
disconnect_seconds: disconnectSeconds,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.connect();
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(disconnectSeconds * 1000);
|
||||
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('should stay connected while in use', () => {
|
||||
const disconnectSeconds = 10;
|
||||
|
||||
const createManagerWithDisconnectSeconds = (): MicrophoneManager => {
|
||||
describe('should follow transmission state', () => {
|
||||
it('should release the stream and reset mute when transmission ends', async () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
|
||||
createMockStream(),
|
||||
const manager = new MicrophoneManager(api);
|
||||
const stream = createMockStream();
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
|
||||
|
||||
manager.setTransmissionActive(true);
|
||||
await manager.connect();
|
||||
await manager.unmute();
|
||||
expect(manager.isMuted()).toBeFalsy();
|
||||
|
||||
manager.setTransmissionActive(false);
|
||||
|
||||
expect(manager.isConnected()).toBeFalsy();
|
||||
expect(getTrack(stream).stop).toHaveBeenCalled();
|
||||
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
microphone: expect.objectContaining({ connected: false, muted: true }),
|
||||
}),
|
||||
);
|
||||
|
||||
// The next transmission must not start with a hot microphone: the desired mute
|
||||
// was reset when the previous transmission ended.
|
||||
const newStream = createMockStream();
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(newStream);
|
||||
manager.setTransmissionActive(true);
|
||||
await manager.connect();
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should keep the stream muted when transmission ends and always connected', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
live: {
|
||||
microphone: {
|
||||
always_connected: false,
|
||||
disconnect_seconds: disconnectSeconds,
|
||||
always_connected: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return new MicrophoneManager(api);
|
||||
};
|
||||
const stream = createMockStream();
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
|
||||
|
||||
it('should not automatically disconnect while a user is registered', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const manager = createManagerWithDisconnectSeconds();
|
||||
manager.setTransmissionActive(true);
|
||||
await manager.connect();
|
||||
manager.startUsing();
|
||||
|
||||
vi.advanceTimersByTime(disconnectSeconds * 1000);
|
||||
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not automatically disconnect when a user is registered before connection', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const manager = createManagerWithDisconnectSeconds();
|
||||
manager.startUsing();
|
||||
await manager.connect();
|
||||
|
||||
vi.advanceTimersByTime(disconnectSeconds * 1000);
|
||||
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not automatically disconnect on mute or unmute while in use', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const manager = createManagerWithDisconnectSeconds();
|
||||
await manager.connect();
|
||||
manager.startUsing();
|
||||
|
||||
await manager.unmute();
|
||||
manager.mute();
|
||||
vi.advanceTimersByTime(disconnectSeconds * 1000);
|
||||
expect(manager.isMuted()).toBeFalsy();
|
||||
|
||||
manager.setTransmissionActive(false);
|
||||
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
expect(getTrack(stream).stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should restart the countdown in full when use stops', async () => {
|
||||
vi.useFakeTimers();
|
||||
it('should ignore the end of a transmission that never started', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
live: {
|
||||
microphone: {
|
||||
always_connected: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const stream = createMockStream();
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
|
||||
|
||||
const manager = createManagerWithDisconnectSeconds();
|
||||
await manager.connect();
|
||||
manager.startUsing();
|
||||
|
||||
vi.advanceTimersByTime(disconnectSeconds * 1000);
|
||||
manager.stopUsing();
|
||||
|
||||
vi.advanceTimersByTime(disconnectSeconds * 1000 - 1);
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(manager.isConnected()).toBeFalsy();
|
||||
});
|
||||
manager.setTransmissionActive(false);
|
||||
|
||||
it('should become idle on a single stop no matter how often use was started', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const manager = createManagerWithDisconnectSeconds();
|
||||
await manager.connect();
|
||||
manager.startUsing();
|
||||
manager.startUsing();
|
||||
|
||||
manager.stopUsing();
|
||||
vi.advanceTimersByTime(disconnectSeconds * 1000);
|
||||
|
||||
expect(manager.isConnected()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should restart the countdown when stopped without having been in use', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const manager = createManagerWithDisconnectSeconds();
|
||||
await manager.connect();
|
||||
|
||||
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
|
||||
manager.stopUsing();
|
||||
|
||||
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
|
||||
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
|
||||
expect(manager.isConnected()).toBeFalsy();
|
||||
expect(getTrack(stream).stop).not.toHaveBeenCalled();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not reconnect after an explicit disconnect while in use', async () => {
|
||||
vi.useFakeTimers();
|
||||
it('should ignore a transmission report that changes nothing', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
|
||||
const manager = createManagerWithDisconnectSeconds();
|
||||
await manager.connect();
|
||||
manager.startUsing();
|
||||
manager.disconnect();
|
||||
manager.setTransmissionActive(true);
|
||||
manager.setTransmissionActive(true);
|
||||
|
||||
vi.advanceTimersByTime(disconnectSeconds * 1000);
|
||||
|
||||
expect(manager.isConnected()).toBeFalsy();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not disconnect after being explicitly disconnected and reconnected', async () => {
|
||||
vi.useFakeTimers();
|
||||
describe('should guard in-flight connections', () => {
|
||||
it('should release a stream whose connect completes after a mute', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
const stream = createMockStream();
|
||||
const deferred = createDeferredStream();
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockReturnValue(
|
||||
deferred.promise,
|
||||
);
|
||||
|
||||
const disconnectSeconds = 10;
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
|
||||
createMockStream(),
|
||||
);
|
||||
const connectPromise = manager.connect();
|
||||
manager.mute();
|
||||
deferred.resolve(stream);
|
||||
await connectPromise;
|
||||
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
live: {
|
||||
microphone: {
|
||||
always_connected: false,
|
||||
disconnect_seconds: disconnectSeconds,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(manager.isConnected()).toBeFalsy();
|
||||
expect(getTrack(stream).stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await manager.connect();
|
||||
it('should release a stream whose connect completes after transmission ends', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
const stream = createMockStream();
|
||||
const deferred = createDeferredStream();
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockReturnValue(
|
||||
deferred.promise,
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
|
||||
manager.disconnect();
|
||||
await manager.connect();
|
||||
manager.setTransmissionActive(true);
|
||||
const connectPromise = manager.connect();
|
||||
manager.setTransmissionActive(false);
|
||||
deferred.resolve(stream);
|
||||
await connectPromise;
|
||||
|
||||
// The countdown from the first connection must not survive the disconnect
|
||||
// and cut the second one short.
|
||||
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
expect(manager.isConnected()).toBeFalsy();
|
||||
expect(getTrack(stream).stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not install a stream whose connect completes after the held stream is released', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
const held = createMockStream();
|
||||
const late = createMockStream();
|
||||
const deferred = createDeferredStream();
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
|
||||
.mockResolvedValueOnce(held)
|
||||
.mockReturnValueOnce(deferred.promise);
|
||||
|
||||
manager.setTransmissionActive(true);
|
||||
await manager.connect();
|
||||
const connectPromise = manager.connect();
|
||||
|
||||
// Releasing the held stream also invalidates the connect still in
|
||||
// flight.
|
||||
manager.setTransmissionActive(false);
|
||||
deferred.resolve(late);
|
||||
await connectPromise;
|
||||
|
||||
expect(manager.isConnected()).toBeFalsy();
|
||||
expect(getTrack(held).stop).toHaveBeenCalled();
|
||||
expect(getTrack(late).stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should keep only the newest of overlapping connects', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
const streamA = createMockStream();
|
||||
const streamB = createMockStream();
|
||||
const deferredA = createDeferredStream();
|
||||
const deferredB = createDeferredStream();
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
|
||||
.mockReturnValueOnce(deferredA.promise)
|
||||
.mockReturnValueOnce(deferredB.promise);
|
||||
|
||||
manager.setTransmissionActive(true);
|
||||
const connectA = manager.connect();
|
||||
const connectB = manager.connect();
|
||||
|
||||
deferredA.resolve(streamA);
|
||||
deferredB.resolve(streamB);
|
||||
expect(await connectA).toBe(false);
|
||||
expect(await connectB).toBe(true);
|
||||
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
expect(manager.getStream()).toBe(streamB);
|
||||
expect(getTrack(streamA).stop).toHaveBeenCalled();
|
||||
expect(getTrack(streamB).stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not mark forbidden on a stale connect rejection', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
const streamB = createMockStream();
|
||||
const deferredA = createDeferredStream();
|
||||
const deferredB = createDeferredStream();
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
|
||||
.mockReturnValueOnce(deferredA.promise)
|
||||
.mockReturnValueOnce(deferredB.promise);
|
||||
|
||||
manager.setTransmissionActive(true);
|
||||
const connectA = manager.connect();
|
||||
const connectB = manager.connect();
|
||||
|
||||
deferredA.reject(new Error());
|
||||
deferredB.resolve(streamB);
|
||||
await expect(connectA).rejects.toThrow(Error);
|
||||
await connectB;
|
||||
|
||||
expect(manager.isForbidden()).toBeFalsy();
|
||||
expect(manager.isConnected()).toBeTruthy();
|
||||
expect(manager.getStream()).toBe(streamB);
|
||||
});
|
||||
|
||||
it('should stop the replaced stream when connecting over an existing stream', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
const streamA = createMockStream();
|
||||
const streamB = createMockStream();
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
|
||||
.mockResolvedValueOnce(streamA)
|
||||
.mockResolvedValueOnce(streamB);
|
||||
|
||||
manager.setTransmissionActive(true);
|
||||
await manager.connect();
|
||||
expect(manager.getStream()).toBe(streamA);
|
||||
|
||||
await manager.connect();
|
||||
|
||||
expect(manager.getStream()).toBe(streamB);
|
||||
expect(getTrack(streamA).stop).toHaveBeenCalled();
|
||||
expect(getTrack(streamB).stop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should require initialization', async () => {
|
||||
@@ -457,7 +576,7 @@ describe('MicrophoneManager', () => {
|
||||
|
||||
manager.initialize();
|
||||
expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({
|
||||
microphone: { connected: false, muted: true, forbidden: false, stream: undefined },
|
||||
microphone: { connected: false, muted: true, forbidden: false, stream: null },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -469,6 +588,7 @@ describe('MicrophoneManager', () => {
|
||||
|
||||
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
|
||||
|
||||
manager.setTransmissionActive(true);
|
||||
await manager.connect();
|
||||
|
||||
let expectedState: MicrophoneState = {
|
||||
@@ -515,11 +635,11 @@ describe('MicrophoneManager', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
manager.disconnect();
|
||||
manager.setTransmissionActive(false);
|
||||
|
||||
expectedState = {
|
||||
forbidden: false,
|
||||
stream: undefined,
|
||||
stream: null,
|
||||
connected: false,
|
||||
muted: true,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user