diff --git a/packages/node_modules/@ciscospark/plugin-phone/src/call-membership.js b/packages/node_modules/@ciscospark/plugin-phone/src/call-membership.js index 6c2dcdd4cc8..eb59348cebc 100644 --- a/packages/node_modules/@ciscospark/plugin-phone/src/call-membership.js +++ b/packages/node_modules/@ciscospark/plugin-phone/src/call-membership.js @@ -33,11 +33,30 @@ const CallMembership = AmpState.extend({ type: `boolean` }, + /** + * @instance + * @memberof CallMembership + * @type {string} + * @readonly + */ personId: { required: true, type: `string` }, + /** + * Mostly here for testing and potentially for widget support. Do not use. + * @instance + * @memberof CallMembership + * @private + * @type {string} + * @readonly + */ + personUuid: { + require: true, + type: `string` + }, + /** * Indicates the member's relationship with the call. One of * - notified - the party has been invited to the call but has not yet accepted diff --git a/packages/node_modules/@ciscospark/plugin-phone/src/call.js b/packages/node_modules/@ciscospark/plugin-phone/src/call.js index 2ce60954845..5194a263198 100644 --- a/packages/node_modules/@ciscospark/plugin-phone/src/call.js +++ b/packages/node_modules/@ciscospark/plugin-phone/src/call.js @@ -41,18 +41,52 @@ import {parse} from 'sdp-transform'; * @event ringing * @instance * @memberof Call + * @deprecated with {@link config.phone.enableExperimentalGroupCallingSupport} enabled; + * instead, listen for {@link Call.membership:notified} */ /** * @event connected * @instance * @memberof Call + * @deprecated with {@link config.phone.enableExperimentalGroupCallingSupport} enabled; + * instead, listen for {@link Call.active} */ /** * @event disconnected * @instance * @memberof Call + * @deprecated with {@link config.phone.enableExperimentalGroupCallingSupport} enabled; + * instead, listen for {@link Call.inactive} + */ + +/** + * @event active + * @instance + * @memberof Call + * only emitted if enableExperimentalGroupCallingSupport is enabled + */ + +/** + * @event initializing + * @instance + * @memberof Call + * only emitted if enableExperimentalGroupCallingSupport is enabled + */ + +/** + * @event inactive + * @instance + * @memberof Call + * only emitted if enableExperimentalGroupCallingSupport is enabled + */ + +/** + * @event terminating + * @instance + * @memberof Call + * only emitted if enableExperimentalGroupCallingSupport is enabled */ /** @@ -73,6 +107,70 @@ import {parse} from 'sdp-transform'; * @memberof Call */ +/** + * @event membership:notified + * @instance + * @memberof Call + * @type {CallMembership} + * This replaces the {@link Call.ringing} event, but note that it's subtly + * different. {@link Call.ringing} is emitted when the remote party calls + * {@link Call#acknowledge()} whereas {@link Call.membership:notified} emits + * shortly after (but as a direct result of) locally calling + * {@link Phone#dial()} + */ + +/** + * @event membership:connected + * @instance + * @memberof Call + * @type {CallMembership} + */ + +/** + * @event membership:declined + * @instance + * @memberof Call + * @type {CallMembership} + */ + +/** + * @event membership:disconnected + * @instance + * @memberof Call + * @type {CallMembership} + */ + +/** + * @event membership:waiting + * @instance + * @memberof Call + * @type {CallMembership} + */ + +/** + * @event membership:change + * @instance + * @memberof Call + * @type {CallMembership} + */ + +/** + * @event memberships:add + * @instance + * @memberof Call + * Emitted when a new {@link CallMembership} is added to + * {@link Call#memberships}. Note that {@link CallMembership#state} still needs + * to be read to determine if the instance represents someone actively + * participating the call. + */ + +/** + * @event memberships:remove + * @instance + * @memberof Call + * Emitted when a {@link CallMembership} is removed from {@link Call#memberships}. + */ + /** * Payload for {@link Call#sendFeedback} * @typedef {Object} FeedbackObject @@ -190,6 +288,17 @@ const Call = SparkPlugin.extend({ locusLeaveInFlight: { default: false, type: `boolean` + }, + /** + * Test helper. Shortcut to the current user's membership object. not + * official for now, but may get published at some point + * @instance + * @memberof Call + * @private + * @type {CallMembership} + */ + me: { + type: `object` } }, @@ -571,14 +680,16 @@ const Call = SparkPlugin.extend({ } }); - this.listenTo(this.memberships, `add`, () => this.trigger(`change:memberships`)); - this.listenTo(this.memberships, `remove`, () => this.trigger(`change:memberships`)); - - this.listenTo(this.memberships, `change:state`, () => { + this.listenTo(this.memberships, `add`, (model) => this.trigger(`memberships:add`, model)); + this.listenTo(this.memberships, `remove`, (model) => this.trigger(`memberships:remove`, model)); + this.listenTo(this.memberships, `change`, (model) => this.trigger(`membership:change`, model)); + this.listenTo(this.memberships, `change:state`, (model) => { this.activeParticipantsCount = this - .memberships - .filter((m) => m.state === `connected`) - .length; + .memberships + .filter((m) => m.state === `connected`) + .length; + + this.trigger(`membership:${model.state}`, model); }); if (this.locus) { @@ -597,6 +708,7 @@ const Call = SparkPlugin.extend({ // can avoid making those classes spark aware and therefore keep them a // lot simpler this.memberships.set(participantsToCallMemberships(this.spark, this.locus)); + this.me = this.memberships.find((m) => m._self); } }); diff --git a/packages/node_modules/@ciscospark/plugin-phone/src/state-parsers.js b/packages/node_modules/@ciscospark/plugin-phone/src/state-parsers.js index 860fd45dde3..d3b2c2a4c51 100644 --- a/packages/node_modules/@ciscospark/plugin-phone/src/state-parsers.js +++ b/packages/node_modules/@ciscospark/plugin-phone/src/state-parsers.js @@ -118,6 +118,7 @@ export function participantToCallMembership(spark, locus, participant) { return { _self: locus.self.url === participant.url, isInitiator: participant.isCreator || false, + personUuid: participant.person.id, personId: spark.people.inferPersonIdFromUuid(participant.person.id), state: participantStateToCallMembershipState(participant), audioMuted: remoteAudioMuted(participant), diff --git a/packages/node_modules/@ciscospark/plugin-phone/test/integration/spec/multi-party.js b/packages/node_modules/@ciscospark/plugin-phone/test/integration/spec/multi-party.js index 6092a217198..b3eff2bb887 100644 --- a/packages/node_modules/@ciscospark/plugin-phone/test/integration/spec/multi-party.js +++ b/packages/node_modules/@ciscospark/plugin-phone/test/integration/spec/multi-party.js @@ -9,7 +9,12 @@ import sinon from '@ciscospark/test-helper-sinon'; import CiscoSpark from '@ciscospark/spark-core'; import testUsers from '@ciscospark/test-helper-test-users'; import handleErrorEvent from '../lib/handle-error-event'; -import {browserOnly, maxWaitForEvent} from '@ciscospark/test-helper-mocha'; +import { + browserOnly, + expectEvent, + expectNEvents, + maxWaitForEvent +} from '@ciscospark/test-helper-mocha'; if (process.env.NODE_ENV !== `test`) { throw new Error(`Cannot run the plugin-phone test suite without NODE_ENV === "test"`); @@ -19,52 +24,45 @@ browserOnly(describe)(`plugin-phone`, function() { this.timeout(60000); describe(`Phone`, () => { - let mccoy, spock; - before(`create users and register`, () => testUsers.create({count: 2}) - .then((users) => { - [mccoy, spock] = users; - spock.spark = new CiscoSpark({ - credentials: { - authorization: spock.token - } - }); + const users = { + chekov: null, + mccoy: null, + spock: null, + uhura: null + }; + let chekov, mccoy, spock, uhura; - mccoy.spark = new CiscoSpark({ + before(`create users and register`, () => testUsers.create({count: Object.keys(users).length}) + .then((created) => Promise.all(Object.keys(users).map((name, index) => { + const user = users[name] = created[index]; + user.spark = new CiscoSpark({ credentials: { - authorization: mccoy.token + authorization: user.token } }); - return Promise.all([ - spock.spark.phone.register(), - mccoy.spark.phone.register() - ]); + return user.spark.phone.register(); + }))) + .then(() => { + chekov = users.chekov; + mccoy = users.mccoy; + spock = users.spock; + uhura = users.uhura; })); - let ringMccoy; - - beforeEach(() => { - ringMccoy = sinon.spy(); - mccoy.spark.phone.on(`call:incoming`, ringMccoy); - }); - - beforeEach(() => { - spock.spark.config.phone.enableExperimentalGroupCallingSupport = true; - mccoy.spark.config.phone.enableExperimentalGroupCallingSupport = true; - }); - - afterEach(() => { - spock.spark.config.phone.enableExperimentalGroupCallingSupport = false; - mccoy.spark.config.phone.enableExperimentalGroupCallingSupport = false; - }); + beforeEach(`enable group calling`, () => Object.values(users).forEach((user) => { + user.spark.config.phone.enableExperimentalGroupCallingSupport = true; + })); - after(`unregister spock and mccoy`, () => Promise.all([ - spock && spock.spark.phone.deregister() - .catch((reason) => console.warn(`could not disconnect spock from mercury`, reason)), - mccoy && mccoy.spark.phone.deregister() - .catch((reason) => console.warn(`could not disconnect mccoy from mercury`, reason)) - ])); + afterEach(`disable group calling`, () => Object.values(users).forEach((user) => { + user.spark.config.phone.enableExperimentalGroupCallingSupport = false; + })); + after(`unregister users`, () => Promise.all(Object.keys(users).map((name) => { + const user = users[name]; + return user && user.spark.phone.deregister() + .catch((reason) => console.warn(`could not unregister ${user}`, reason)); + }))); describe(`#dial()`, () => { it(`calls a room by hydra room id`, () => spock.spark.request({ @@ -89,7 +87,6 @@ browserOnly(describe)(`plugin-phone`, function() { }) .then(() => handleErrorEvent(spock.spark.phone.dial(room.id), (call) => { - call.on(`all`, (e) => console.log(e)); let mccoyCall; assert.isUndefined(call.state); return Promise.all([ @@ -162,5 +159,148 @@ browserOnly(describe)(`plugin-phone`, function() { })); }); + + + describe(`group callign events model`, () => { + let room; + + before(() => spock.spark.request({ + method: `POST`, + service: `hydra`, + resource: `rooms`, + body: { + title: `Call Test` + } + }) + .then((res) => { + room = res.body; + }) + .then(() => spock.spark.request({ + method: `POST`, + service: `hydra`, + resource: `memberships`, + body: { + roomId: room.id, + personId: mccoy.id + } + })) + .then(() => spock.spark.request({ + method: `POST`, + service: `hydra`, + resource: `memberships`, + body: { + roomId: room.id, + personId: chekov.id + } + })) + .then(() => spock.spark.request({ + method: `POST`, + service: `hydra`, + resource: `memberships`, + body: { + roomId: room.id, + personId: uhura.id + } + }))); + + + it(`proceeds through a series of events`, () => handleErrorEvent(spock.spark.phone.dial(room.id), (call) => { + return Promise.all([ + // This execution chain represents Spock's view of the call + expectEvent(5000, `change:locus`, call) + .then(() => { + assert.equal(call.state, `active`); + assert.equal(call.me.state, `connected`); + const onMembershipConnected = sinon.spy(); + call.on(`membership:connected`, onMembershipConnected); + return Promise.all([ + expectNEvents(5000, 2, `membership:connected`, call), + expectEvent(5000, `membership:declined`, call) + .then((membership) => assert.equal(membership.personUuid, chekov.id)) + ]) + .then(() => { + call.off(`membership:connected`, onMembershipConnected); + assert.calledTwice(onMembershipConnected); + assert.calledWith(onMembershipConnected, call.memberships.find((m) => m.personUuid === mccoy.id)); + assert.calledWith(onMembershipConnected, call.memberships.find((m) => m.personUuid === uhura.id)); + + return expectEvent(5000, `membership:change`, call); + }) + .then((membership) => { + assert.equal(membership.personUuid, mccoy.id); + assert.isTrue(membership.audioMuted); + console.log(`waiting for membership:disconnected`); + // wait for uhura to hangup + return expectEvent(5000, `membership:disconnected`, call); + }) + // wait for mccoy to hangup + .then(() => expectEvent(5000, `membership:disconnected`, call)) + .then(() => { + assert.equal(call.me.state, `connected`); + assert.equal(call.memberships.find((m) => m.personUuid === mccoy.id).state, `disconnected`); + assert.equal(call.memberships.find((m) => m.personUuid === uhura.id).state, `disconnected`); + assert.equal(call.memberships.find((m) => m.personUuid === chekov.id).state, `declined`); + return call.hangup(); + }); + }), + + // This execution chain represents McCoy's view of the call + expectEvent(5000, `call:incoming`, mccoy.spark.phone) + .then((mc) => { + assert.equal(mc.state, `active`); + assert.equal(mc.me.state, `notified`); + return Promise.all([ + new Promise((resolve) => { + mc.on(`change:activeParticipantsCount`, onAPCchange); + function onAPCchange() { + if (mc.activeParticipantsCount === 3) { + mc.off(`change:activeParticipantsCount`, onAPCchange); + resolve(); + } + } + }), + mc.answer() + ]) + .then(() => mc.toggleSendingAudio()) + .then(() => expectEvent(5000, `membership:disconnected`, mc)) + .then(() => mc.hangup()) + ; + }), + + // This execution chain represents Chekov's view of the call + expectEvent(5000, `call:incoming`, chekov.spark.phone) + .then((cc) => { + assert.equal(cc.state, `active`); + assert.equal(cc.me.state, `notified`); + return cc.decline(); + }), + + // This execution chain represents Uhura's view of the call + expectEvent(5000, `call:incoming`, uhura.spark.phone) + .then((uc) => { + assert.equal(uc.state, `active`); + assert.equal(uc.me.state, `notified`); + return Promise.all([ + new Promise((resolve) => { + uc.on(`change:activeParticipantsCount`, onAPCchange); + function onAPCchange() { + if (uc.activeParticipantsCount === 3) { + uc.off(`change:activeParticipantsCount`, onAPCchange); + resolve(); + } + } + }), + uc.answer() + ]) + .then(() => expectEvent(5000, `membership:change`, uc)) + .then((membership) => { + assert.equal(membership.personUuid, mccoy.id); + assert.isTrue(membership.audioMuted); + return uc.hangup(); + }); + }) + ]); + })); + }); }); }); diff --git a/packages/node_modules/@ciscospark/test-helper-mocha/src/index.js b/packages/node_modules/@ciscospark/test-helper-mocha/src/index.js index a4ccf74cf3f..f24a9d9c207 100644 --- a/packages/node_modules/@ciscospark/test-helper-mocha/src/index.js +++ b/packages/node_modules/@ciscospark/test-helper-mocha/src/index.js @@ -109,6 +109,53 @@ module.exports = { return inNode() ? mochaMethod : noop; }, + expectNEvents: function expectNEvents(max, count, event, emitter) { + var timer; + + return Promise.race([ + new Promise(function setTimer(resolve, reject) { + timer = setTimeout(function handler() { + reject(new Error(event + ' did not fire ' + count + ' times within ' + max + 'ms')); + }, max); + }), + new Promise(function eventEmitter(resolve) { + var currentCount = 0; + + emitter.on(event, fn); + + function fn() { + currentCount += 1; + if (currentCount === count) { + emitter.off(event, fn); + clearTimeout(timer); + resolve(); + } + } + }) + ]); + }, + + /** + * @param {number} max + * @param {string} event + * @param {EventEmitter} emitter + * @returns {Promise} Resolves with the results of the event + */ + expectEvent: function expectEvent(max, event, emitter) { + var timer; + return Promise.race([ + new Promise(function setTimer(resolve, reject) { + timer = setTimeout(function handler() { + reject(new Error(event + ' did not fire within ' + max + 'ms')); + }, max); + }), + new Promise(function eventEmitter(resolve) { + clearTimeout(timer); + emitter.once(event, resolve); + }) + ]); + }, + maxWaitForEvent: function maxWaitForEvent(max, event, emitter) { return Promise.race([ new Promise(function timer(resolve) {