@juzi/wechaty 1.0.146 → 1.0.147
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/src/mods/impls.d.ts +2 -2
- package/dist/cjs/src/mods/impls.d.ts.map +1 -1
- package/dist/cjs/src/mods/impls.js +2 -1
- package/dist/cjs/src/mods/impls.js.map +1 -1
- package/dist/cjs/src/mods/users.d.ts +2 -1
- package/dist/cjs/src/mods/users.d.ts.map +1 -1
- package/dist/cjs/src/package-json.js +4 -4
- package/dist/cjs/src/package-json.js.map +1 -1
- package/dist/cjs/src/schemas/call-events.d.ts +20 -0
- package/dist/cjs/src/schemas/call-events.d.ts.map +1 -0
- package/dist/cjs/src/schemas/call-events.js +7 -0
- package/dist/cjs/src/schemas/call-events.js.map +1 -0
- package/dist/cjs/src/schemas/mod.d.ts +3 -2
- package/dist/cjs/src/schemas/mod.d.ts.map +1 -1
- package/dist/cjs/src/schemas/mod.js +3 -1
- package/dist/cjs/src/schemas/mod.js.map +1 -1
- package/dist/cjs/src/schemas/wechaty-events.d.ts +23 -2
- package/dist/cjs/src/schemas/wechaty-events.d.ts.map +1 -1
- package/dist/cjs/src/schemas/wechaty-events.js +6 -0
- package/dist/cjs/src/schemas/wechaty-events.js.map +1 -1
- package/dist/cjs/src/user-modules/call.d.ts +158 -0
- package/dist/cjs/src/user-modules/call.d.ts.map +1 -1
- package/dist/cjs/src/user-modules/call.js +289 -1
- package/dist/cjs/src/user-modules/call.js.map +1 -1
- package/dist/cjs/src/user-modules/call.spec.d.ts +7 -0
- package/dist/cjs/src/user-modules/call.spec.d.ts.map +1 -0
- package/dist/cjs/src/user-modules/call.spec.js +759 -0
- package/dist/cjs/src/user-modules/call.spec.js.map +1 -0
- package/dist/cjs/src/user-modules/contact.d.ts +16 -0
- package/dist/cjs/src/user-modules/contact.d.ts.map +1 -1
- package/dist/cjs/src/user-modules/contact.js +16 -0
- package/dist/cjs/src/user-modules/contact.js.map +1 -1
- package/dist/cjs/src/user-modules/mod.d.ts +5 -4
- package/dist/cjs/src/user-modules/mod.d.ts.map +1 -1
- package/dist/cjs/src/user-modules/mod.js +2 -1
- package/dist/cjs/src/user-modules/mod.js.map +1 -1
- package/dist/cjs/src/wechaty/wechaty-base.d.ts +66 -7
- package/dist/cjs/src/wechaty/wechaty-base.d.ts.map +1 -1
- package/dist/cjs/src/wechaty/wechaty-base.js +28 -0
- package/dist/cjs/src/wechaty/wechaty-base.js.map +1 -1
- package/dist/cjs/src/wechaty/wechaty-impl.spec.js +10 -8
- package/dist/cjs/src/wechaty/wechaty-impl.spec.js.map +1 -1
- package/dist/cjs/src/wechaty-mixins/gerror-mixin.d.ts +1 -1
- package/dist/cjs/src/wechaty-mixins/io-mixin.d.ts +2 -2
- package/dist/cjs/src/wechaty-mixins/login-mixin.d.ts +15 -5
- package/dist/cjs/src/wechaty-mixins/login-mixin.d.ts.map +1 -1
- package/dist/cjs/src/wechaty-mixins/misc-mixin.d.ts +15 -5
- package/dist/cjs/src/wechaty-mixins/misc-mixin.d.ts.map +1 -1
- package/dist/cjs/src/wechaty-mixins/plugin-mixin.d.ts +32 -6
- package/dist/cjs/src/wechaty-mixins/plugin-mixin.d.ts.map +1 -1
- package/dist/cjs/src/wechaty-mixins/puppet-mixin.d.ts +39 -5
- package/dist/cjs/src/wechaty-mixins/puppet-mixin.d.ts.map +1 -1
- package/dist/cjs/src/wechaty-mixins/puppet-mixin.js +149 -0
- package/dist/cjs/src/wechaty-mixins/puppet-mixin.js.map +1 -1
- package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.d.ts +5 -3
- package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.d.ts.map +1 -1
- package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.js +3 -0
- package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.js.map +1 -1
- package/dist/esm/src/mods/impls.d.ts +2 -2
- package/dist/esm/src/mods/impls.d.ts.map +1 -1
- package/dist/esm/src/mods/impls.js +1 -1
- package/dist/esm/src/mods/impls.js.map +1 -1
- package/dist/esm/src/mods/users.d.ts +2 -1
- package/dist/esm/src/mods/users.d.ts.map +1 -1
- package/dist/esm/src/package-json.js +4 -4
- package/dist/esm/src/package-json.js.map +1 -1
- package/dist/esm/src/schemas/call-events.d.ts +20 -0
- package/dist/esm/src/schemas/call-events.d.ts.map +1 -0
- package/dist/esm/src/schemas/call-events.js +4 -0
- package/dist/esm/src/schemas/call-events.js.map +1 -0
- package/dist/esm/src/schemas/mod.d.ts +3 -2
- package/dist/esm/src/schemas/mod.d.ts.map +1 -1
- package/dist/esm/src/schemas/mod.js +2 -1
- package/dist/esm/src/schemas/mod.js.map +1 -1
- package/dist/esm/src/schemas/wechaty-events.d.ts +23 -2
- package/dist/esm/src/schemas/wechaty-events.d.ts.map +1 -1
- package/dist/esm/src/schemas/wechaty-events.js +6 -0
- package/dist/esm/src/schemas/wechaty-events.js.map +1 -1
- package/dist/esm/src/user-modules/call.d.ts +158 -0
- package/dist/esm/src/user-modules/call.d.ts.map +1 -1
- package/dist/esm/src/user-modules/call.js +289 -1
- package/dist/esm/src/user-modules/call.js.map +1 -1
- package/dist/esm/src/user-modules/call.spec.d.ts +7 -0
- package/dist/esm/src/user-modules/call.spec.d.ts.map +1 -0
- package/dist/esm/src/user-modules/call.spec.js +734 -0
- package/dist/esm/src/user-modules/call.spec.js.map +1 -0
- package/dist/esm/src/user-modules/contact.d.ts +16 -0
- package/dist/esm/src/user-modules/contact.d.ts.map +1 -1
- package/dist/esm/src/user-modules/contact.js +16 -0
- package/dist/esm/src/user-modules/contact.js.map +1 -1
- package/dist/esm/src/user-modules/mod.d.ts +5 -4
- package/dist/esm/src/user-modules/mod.d.ts.map +1 -1
- package/dist/esm/src/user-modules/mod.js +2 -2
- package/dist/esm/src/user-modules/mod.js.map +1 -1
- package/dist/esm/src/wechaty/wechaty-base.d.ts +66 -7
- package/dist/esm/src/wechaty/wechaty-base.d.ts.map +1 -1
- package/dist/esm/src/wechaty/wechaty-base.js +28 -0
- package/dist/esm/src/wechaty/wechaty-base.js.map +1 -1
- package/dist/esm/src/wechaty/wechaty-impl.spec.js +10 -8
- package/dist/esm/src/wechaty/wechaty-impl.spec.js.map +1 -1
- package/dist/esm/src/wechaty-mixins/gerror-mixin.d.ts +1 -1
- package/dist/esm/src/wechaty-mixins/io-mixin.d.ts +2 -2
- package/dist/esm/src/wechaty-mixins/login-mixin.d.ts +15 -5
- package/dist/esm/src/wechaty-mixins/login-mixin.d.ts.map +1 -1
- package/dist/esm/src/wechaty-mixins/misc-mixin.d.ts +15 -5
- package/dist/esm/src/wechaty-mixins/misc-mixin.d.ts.map +1 -1
- package/dist/esm/src/wechaty-mixins/plugin-mixin.d.ts +32 -6
- package/dist/esm/src/wechaty-mixins/plugin-mixin.d.ts.map +1 -1
- package/dist/esm/src/wechaty-mixins/puppet-mixin.d.ts +39 -5
- package/dist/esm/src/wechaty-mixins/puppet-mixin.d.ts.map +1 -1
- package/dist/esm/src/wechaty-mixins/puppet-mixin.js +149 -0
- package/dist/esm/src/wechaty-mixins/puppet-mixin.js.map +1 -1
- package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.d.ts +5 -3
- package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.d.ts.map +1 -1
- package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.js +4 -1
- package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.js.map +1 -1
- package/package.json +3 -3
- package/src/mods/impls.ts +2 -0
- package/src/mods/users.ts +6 -0
- package/src/package-json.ts +4 -4
- package/src/schemas/call-events.ts +35 -0
- package/src/schemas/mod.ts +6 -0
- package/src/schemas/wechaty-events.ts +28 -0
- package/src/user-modules/call.spec.ts +929 -0
- package/src/user-modules/call.ts +373 -0
- package/src/user-modules/contact.ts +18 -0
- package/src/user-modules/mod.ts +11 -0
- package/src/wechaty/wechaty-base.ts +40 -1
- package/src/wechaty/wechaty-impl.spec.ts +4 -0
- package/src/wechaty-mixins/puppet-mixin.ts +184 -0
- package/src/wechaty-mixins/wechatify-user-module-mixin.ts +6 -0
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --no-warnings --loader ts-node/esm
|
|
2
|
+
/**
|
|
3
|
+
* Tests for the Call first-class object and its lifecycle state machine
|
|
4
|
+
* against the @juzi/wechaty-puppet@^1.0.138 contract.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
test,
|
|
9
|
+
sinon,
|
|
10
|
+
} from 'tstest'
|
|
11
|
+
|
|
12
|
+
import * as PUPPET from '@juzi/wechaty-puppet'
|
|
13
|
+
import { PuppetMock } from '@juzi/wechaty-puppet-mock'
|
|
14
|
+
import { WechatyBuilder } from '../wechaty-builder.js'
|
|
15
|
+
import type { CallInterface } from './call.js'
|
|
16
|
+
import type { ContactImpl, ContactInterface } from './contact.js'
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const sandbox = sinon.createSandbox()
|
|
23
|
+
|
|
24
|
+
function buildWechaty () {
|
|
25
|
+
const puppet = new PuppetMock() as any
|
|
26
|
+
const wechaty = WechatyBuilder.build({ puppet })
|
|
27
|
+
return { puppet, wechaty }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function stubCallPayload (puppet: any, factory: (callId: string) => PUPPET.payloads.Call) {
|
|
31
|
+
const stub = sandbox.stub().callsFake(async (callId: string) => {
|
|
32
|
+
await new Promise(setImmediate)
|
|
33
|
+
return factory(callId)
|
|
34
|
+
})
|
|
35
|
+
puppet.callPayload = stub
|
|
36
|
+
// Used by Call.sync()
|
|
37
|
+
puppet.callPayloadDirty = sandbox.stub().resolves(undefined)
|
|
38
|
+
return stub
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function startAndLogin (puppet: any, wechaty: any, userId = 'bot-self') {
|
|
42
|
+
sandbox.stub(puppet, 'contactPayload').callsFake(async (id: string) => {
|
|
43
|
+
await new Promise(setImmediate)
|
|
44
|
+
return { id, name: id } as PUPPET.payloads.Contact
|
|
45
|
+
})
|
|
46
|
+
sandbox.stub(puppet, 'contactSearch').callsFake(async (...args: any[]) => {
|
|
47
|
+
await new Promise(setImmediate)
|
|
48
|
+
return [ args[0]?.id ?? userId ]
|
|
49
|
+
})
|
|
50
|
+
await wechaty.start()
|
|
51
|
+
await puppet.login(userId)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function flush (ticks = 8): Promise<void> {
|
|
55
|
+
let p = Promise.resolve()
|
|
56
|
+
for (let i = 0; i < ticks; i++) {
|
|
57
|
+
p = p.then(() => new Promise(resolve => setImmediate(resolve)))
|
|
58
|
+
}
|
|
59
|
+
return p
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// 1. bot.call() — outgoing, mints callId via puppet.callInvite, hydrates payload
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
test('bot.call() returns an outgoing Call with status=calling, hydrated payload', async t => {
|
|
67
|
+
const { puppet, wechaty } = buildWechaty()
|
|
68
|
+
await startAndLogin(puppet, wechaty)
|
|
69
|
+
|
|
70
|
+
const CALL_ID = 'call-id-minted-by-protocol'
|
|
71
|
+
const PEER_A_ID = 'peer-a'
|
|
72
|
+
const PEER_B_ID = 'peer-b'
|
|
73
|
+
const START_TS = 1_700_000_000_000
|
|
74
|
+
|
|
75
|
+
const callInviteStub = sandbox.stub().resolves(CALL_ID)
|
|
76
|
+
puppet.callInvite = callInviteStub
|
|
77
|
+
|
|
78
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
79
|
+
id,
|
|
80
|
+
starter : 'bot-self',
|
|
81
|
+
participants : [ PEER_A_ID, PEER_B_ID ],
|
|
82
|
+
media : PUPPET.types.CallMediaType.Video,
|
|
83
|
+
startTime : START_TS,
|
|
84
|
+
}))
|
|
85
|
+
|
|
86
|
+
const contactA = (wechaty.Contact as typeof ContactImpl).load(PEER_A_ID)
|
|
87
|
+
const contactB = (wechaty.Contact as typeof ContactImpl).load(PEER_B_ID)
|
|
88
|
+
|
|
89
|
+
const call: CallInterface = await (wechaty as any).call(
|
|
90
|
+
[ contactA, contactB ],
|
|
91
|
+
{ media: PUPPET.types.CallMediaType.Video },
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
t.equal(call.id, CALL_ID, 'call.id should match callInvite return')
|
|
95
|
+
t.equal(call.direction(), 'outgoing', 'direction should be outgoing')
|
|
96
|
+
t.equal(call.status(), 'calling', 'status should be calling')
|
|
97
|
+
t.equal(call.media(), PUPPET.types.CallMediaType.Video, 'media should reflect payload')
|
|
98
|
+
t.same(call.startTime(), new Date(START_TS), 'startTime should match payload')
|
|
99
|
+
t.equal(call.endTime(), undefined, 'endTime should be undefined for a live call')
|
|
100
|
+
|
|
101
|
+
const participants = await call.participants()
|
|
102
|
+
t.same(participants.map(c => c.id).sort(), [ PEER_A_ID, PEER_B_ID ].sort(), 'participants should match payload')
|
|
103
|
+
|
|
104
|
+
t.ok(callInviteStub.calledOnce, 'puppet.callInvite should be called once')
|
|
105
|
+
t.same(
|
|
106
|
+
callInviteStub.firstCall.args,
|
|
107
|
+
[ [ PEER_A_ID, PEER_B_ID ], PUPPET.types.CallMediaType.Video ],
|
|
108
|
+
'callInvite args should be (contactIds, media)',
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
await wechaty.stop()
|
|
112
|
+
sandbox.restore()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('bot.call() rejects when contacts list is empty', async t => {
|
|
116
|
+
const { puppet, wechaty } = buildWechaty()
|
|
117
|
+
await startAndLogin(puppet, wechaty)
|
|
118
|
+
|
|
119
|
+
await t.rejects(
|
|
120
|
+
(wechaty as any).call([]),
|
|
121
|
+
/at least one contact/,
|
|
122
|
+
'empty contacts should reject',
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
await wechaty.stop()
|
|
126
|
+
sandbox.restore()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// 2. contact.call() — 1v1 syntactic sugar over bot.call()
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
test('contact.call() delegates to bot.call([this])', async t => {
|
|
134
|
+
const { puppet, wechaty } = buildWechaty()
|
|
135
|
+
await startAndLogin(puppet, wechaty)
|
|
136
|
+
|
|
137
|
+
const CALL_ID = 'call-id-sugar'
|
|
138
|
+
const PEER_ID = 'peer-sugar'
|
|
139
|
+
|
|
140
|
+
const callInviteStub = sandbox.stub().resolves(CALL_ID)
|
|
141
|
+
puppet.callInvite = callInviteStub
|
|
142
|
+
|
|
143
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
144
|
+
id,
|
|
145
|
+
starter : 'bot-self',
|
|
146
|
+
participants : [ PEER_ID ],
|
|
147
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
148
|
+
startTime : 1,
|
|
149
|
+
}))
|
|
150
|
+
|
|
151
|
+
const contact = (wechaty.Contact as typeof ContactImpl).load(PEER_ID)
|
|
152
|
+
const call = await contact.call()
|
|
153
|
+
|
|
154
|
+
t.equal(call.id, CALL_ID, 'call.id should match callInvite return')
|
|
155
|
+
t.equal(call.media(), PUPPET.types.CallMediaType.Audio, 'default media should be audio')
|
|
156
|
+
t.same(
|
|
157
|
+
callInviteStub.firstCall.args,
|
|
158
|
+
[ [ PEER_ID ], PUPPET.types.CallMediaType.Audio ],
|
|
159
|
+
'callInvite args should be ([peerId], audio)',
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
await wechaty.stop()
|
|
163
|
+
sandbox.restore()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// 3. Incoming Invite — bot emits 'call' with a hydrated Call
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
test('incoming call: Invite hydrates payload then emits bot.on("call")', async t => {
|
|
171
|
+
const { puppet, wechaty } = buildWechaty()
|
|
172
|
+
await startAndLogin(puppet, wechaty)
|
|
173
|
+
|
|
174
|
+
const CALL_ID = 'call-id-incoming'
|
|
175
|
+
const CALLER_ID = 'caller-incoming'
|
|
176
|
+
const START_TS = 1_700_000_000_999
|
|
177
|
+
|
|
178
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
179
|
+
id,
|
|
180
|
+
starter : CALLER_ID,
|
|
181
|
+
participants : [ CALLER_ID, 'bot-self' ],
|
|
182
|
+
media : PUPPET.types.CallMediaType.Video,
|
|
183
|
+
startTime : START_TS,
|
|
184
|
+
}))
|
|
185
|
+
|
|
186
|
+
let received: CallInterface | undefined
|
|
187
|
+
wechaty.on('call', (c: CallInterface) => { received = c })
|
|
188
|
+
|
|
189
|
+
;(puppet as any).emit('call', {
|
|
190
|
+
callId : CALL_ID,
|
|
191
|
+
signal : PUPPET.types.CallSignal.Invite,
|
|
192
|
+
contactId : CALLER_ID,
|
|
193
|
+
timestamp : START_TS,
|
|
194
|
+
} as PUPPET.payloads.EventCall)
|
|
195
|
+
await flush()
|
|
196
|
+
|
|
197
|
+
t.ok(received, 'bot should emit call event for Invite')
|
|
198
|
+
t.equal(received!.id, CALL_ID, 'call.id should match')
|
|
199
|
+
t.equal(received!.direction(), 'incoming', 'direction should be incoming')
|
|
200
|
+
t.equal(received!.status(), 'ringing', 'status should be ringing')
|
|
201
|
+
t.equal(received!.media(), PUPPET.types.CallMediaType.Video, 'media should reflect payload')
|
|
202
|
+
|
|
203
|
+
const starter = await received!.starter()
|
|
204
|
+
t.ok(starter, 'starter should be resolvable')
|
|
205
|
+
t.equal(starter!.id, CALLER_ID, 'starter should be the caller')
|
|
206
|
+
|
|
207
|
+
await wechaty.stop()
|
|
208
|
+
sandbox.restore()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('duplicate Invite for the same callId is ignored', async t => {
|
|
212
|
+
const { puppet, wechaty } = buildWechaty()
|
|
213
|
+
await startAndLogin(puppet, wechaty)
|
|
214
|
+
|
|
215
|
+
const CALL_ID = 'call-id-dup'
|
|
216
|
+
const CALLER_ID = 'caller-dup'
|
|
217
|
+
|
|
218
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
219
|
+
id,
|
|
220
|
+
starter : CALLER_ID,
|
|
221
|
+
participants : [ CALLER_ID, 'bot-self' ],
|
|
222
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
223
|
+
startTime : 1,
|
|
224
|
+
}))
|
|
225
|
+
|
|
226
|
+
let count = 0
|
|
227
|
+
wechaty.on('call', () => { count++ })
|
|
228
|
+
|
|
229
|
+
;(puppet as any).emit('call', {
|
|
230
|
+
callId : CALL_ID,
|
|
231
|
+
signal : PUPPET.types.CallSignal.Invite,
|
|
232
|
+
contactId : CALLER_ID,
|
|
233
|
+
timestamp : 1,
|
|
234
|
+
} as PUPPET.payloads.EventCall)
|
|
235
|
+
await flush()
|
|
236
|
+
;(puppet as any).emit('call', {
|
|
237
|
+
callId : CALL_ID,
|
|
238
|
+
signal : PUPPET.types.CallSignal.Invite,
|
|
239
|
+
contactId : CALLER_ID,
|
|
240
|
+
timestamp : 2,
|
|
241
|
+
} as PUPPET.payloads.EventCall)
|
|
242
|
+
await flush()
|
|
243
|
+
|
|
244
|
+
t.equal(count, 1, "'call' should fire once even on duplicate Invite")
|
|
245
|
+
|
|
246
|
+
await wechaty.stop()
|
|
247
|
+
sandbox.restore()
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// 4. Ringing / Accept double-emit on outgoing
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
test('outgoing Ringing → object + bot emit; Accept → object + bot emit, status=connected', async t => {
|
|
255
|
+
const { puppet, wechaty } = buildWechaty()
|
|
256
|
+
await startAndLogin(puppet, wechaty)
|
|
257
|
+
|
|
258
|
+
const CALL_ID = 'call-id-outgoing-ringing-accept'
|
|
259
|
+
const PEER_ID = 'peer-ringing-accept'
|
|
260
|
+
|
|
261
|
+
puppet.callInvite = sandbox.stub().resolves(CALL_ID)
|
|
262
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
263
|
+
id,
|
|
264
|
+
starter : 'bot-self',
|
|
265
|
+
participants : [ 'bot-self', PEER_ID ],
|
|
266
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
267
|
+
startTime : 1,
|
|
268
|
+
}))
|
|
269
|
+
|
|
270
|
+
const contact = (wechaty.Contact as typeof ContactImpl).load(PEER_ID)
|
|
271
|
+
const call = await contact.call()
|
|
272
|
+
|
|
273
|
+
let objectRinging = false
|
|
274
|
+
let botRinging = false
|
|
275
|
+
call.on('ringing', () => { objectRinging = true })
|
|
276
|
+
wechaty.on('call-ringing', (c: CallInterface) => { if (c.id === CALL_ID) botRinging = true })
|
|
277
|
+
|
|
278
|
+
;(puppet as any).emit('call', {
|
|
279
|
+
callId : CALL_ID,
|
|
280
|
+
signal : PUPPET.types.CallSignal.Ringing,
|
|
281
|
+
contactId : PEER_ID,
|
|
282
|
+
timestamp : 2,
|
|
283
|
+
} as PUPPET.payloads.EventCall)
|
|
284
|
+
await flush()
|
|
285
|
+
|
|
286
|
+
t.ok(objectRinging, "object should emit 'ringing'")
|
|
287
|
+
t.ok(botRinging, "bot should emit 'call-ringing'")
|
|
288
|
+
t.equal(call.status(), 'ringing', 'status should be ringing')
|
|
289
|
+
|
|
290
|
+
let objectActor: ContactInterface | undefined
|
|
291
|
+
let botActor: ContactInterface | undefined
|
|
292
|
+
call.on('accept', actor => { objectActor = actor })
|
|
293
|
+
wechaty.on('call-accept', (c: CallInterface, actor: ContactInterface) => {
|
|
294
|
+
if (c.id === CALL_ID) {
|
|
295
|
+
botActor = actor
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
;(puppet as any).emit('call', {
|
|
300
|
+
callId : CALL_ID,
|
|
301
|
+
signal : PUPPET.types.CallSignal.Accept,
|
|
302
|
+
contactId : PEER_ID,
|
|
303
|
+
timestamp : 3,
|
|
304
|
+
} as PUPPET.payloads.EventCall)
|
|
305
|
+
await flush()
|
|
306
|
+
|
|
307
|
+
t.ok(objectActor, 'object accept actor should be set')
|
|
308
|
+
t.equal(objectActor!.id, PEER_ID, 'object accept actor should be the peer')
|
|
309
|
+
t.ok(botActor, 'bot accept actor should be set')
|
|
310
|
+
t.equal(botActor!.id, PEER_ID, 'bot accept actor should be the peer')
|
|
311
|
+
t.equal(call.status(), 'connected', 'status should be connected after Accept')
|
|
312
|
+
|
|
313
|
+
await wechaty.stop()
|
|
314
|
+
sandbox.restore()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// 5. accept() control on incoming
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
test('incoming call.accept() invokes puppet.callAccept(callId) and connects', async t => {
|
|
322
|
+
const { puppet, wechaty } = buildWechaty()
|
|
323
|
+
await startAndLogin(puppet, wechaty)
|
|
324
|
+
|
|
325
|
+
const CALL_ID = 'call-id-accept'
|
|
326
|
+
const CALLER_ID = 'caller-accept'
|
|
327
|
+
|
|
328
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
329
|
+
id,
|
|
330
|
+
starter : CALLER_ID,
|
|
331
|
+
participants : [ CALLER_ID, 'bot-self' ],
|
|
332
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
333
|
+
startTime : 1,
|
|
334
|
+
}))
|
|
335
|
+
const acceptStub = sandbox.stub().resolves(undefined)
|
|
336
|
+
puppet.callAccept = acceptStub
|
|
337
|
+
|
|
338
|
+
let incoming: CallInterface | undefined
|
|
339
|
+
wechaty.on('call', (c: CallInterface) => { incoming = c })
|
|
340
|
+
|
|
341
|
+
;(puppet as any).emit('call', {
|
|
342
|
+
callId : CALL_ID,
|
|
343
|
+
signal : PUPPET.types.CallSignal.Invite,
|
|
344
|
+
contactId : CALLER_ID,
|
|
345
|
+
timestamp : 1,
|
|
346
|
+
} as PUPPET.payloads.EventCall)
|
|
347
|
+
await flush()
|
|
348
|
+
|
|
349
|
+
t.ok(incoming, 'should receive incoming call')
|
|
350
|
+
await incoming!.accept()
|
|
351
|
+
|
|
352
|
+
t.equal(incoming!.status(), 'connected', 'status should be connected after accept()')
|
|
353
|
+
t.ok(acceptStub.calledOnce, 'puppet.callAccept should be called once')
|
|
354
|
+
t.same(acceptStub.firstCall.args, [ CALL_ID ], 'callAccept args should be [callId]')
|
|
355
|
+
|
|
356
|
+
await wechaty.stop()
|
|
357
|
+
sandbox.restore()
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('outgoing call.accept() throws (invalid direction)', async t => {
|
|
361
|
+
const { puppet, wechaty } = buildWechaty()
|
|
362
|
+
await startAndLogin(puppet, wechaty)
|
|
363
|
+
|
|
364
|
+
puppet.callInvite = sandbox.stub().resolves('call-id-outgoing-accept-bad')
|
|
365
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
366
|
+
id,
|
|
367
|
+
starter : 'bot-self',
|
|
368
|
+
participants : [ 'peer' ],
|
|
369
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
370
|
+
startTime : 1,
|
|
371
|
+
}))
|
|
372
|
+
|
|
373
|
+
const contact = (wechaty.Contact as typeof ContactImpl).load('peer')
|
|
374
|
+
const call = await contact.call()
|
|
375
|
+
|
|
376
|
+
await t.rejects(call.accept(), /invalid/i, 'accept() on outgoing should throw')
|
|
377
|
+
|
|
378
|
+
await wechaty.stop()
|
|
379
|
+
sandbox.restore()
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// 6. reject() control on incoming + Reject signal terminal handling (outgoing 1v1)
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
test('incoming call.reject() invokes puppet.callReject(callId, reason) and ends', async t => {
|
|
387
|
+
const { puppet, wechaty } = buildWechaty()
|
|
388
|
+
await startAndLogin(puppet, wechaty)
|
|
389
|
+
|
|
390
|
+
const CALL_ID = 'call-id-reject'
|
|
391
|
+
|
|
392
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
393
|
+
id,
|
|
394
|
+
starter : 'caller-x',
|
|
395
|
+
participants : [ 'caller-x', 'bot-self' ],
|
|
396
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
397
|
+
startTime : 1,
|
|
398
|
+
}))
|
|
399
|
+
const rejectStub = sandbox.stub().resolves(undefined)
|
|
400
|
+
puppet.callReject = rejectStub
|
|
401
|
+
|
|
402
|
+
let incoming: CallInterface | undefined
|
|
403
|
+
wechaty.on('call', (c: CallInterface) => { incoming = c })
|
|
404
|
+
|
|
405
|
+
;(puppet as any).emit('call', {
|
|
406
|
+
callId : CALL_ID,
|
|
407
|
+
signal : PUPPET.types.CallSignal.Invite,
|
|
408
|
+
contactId : 'caller-x',
|
|
409
|
+
timestamp : 1,
|
|
410
|
+
} as PUPPET.payloads.EventCall)
|
|
411
|
+
await flush()
|
|
412
|
+
|
|
413
|
+
await incoming!.reject('busy')
|
|
414
|
+
t.equal(incoming!.status(), 'ended', 'status should be ended after reject()')
|
|
415
|
+
t.same(rejectStub.firstCall.args, [ CALL_ID, 'busy' ], 'callReject args should be [callId, reason]')
|
|
416
|
+
|
|
417
|
+
await wechaty.stop()
|
|
418
|
+
sandbox.restore()
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
test('outgoing 1v1 Reject from peer → bot emits call-reject + call-ended, pool cleaned', async t => {
|
|
422
|
+
const { puppet, wechaty } = buildWechaty()
|
|
423
|
+
await startAndLogin(puppet, wechaty)
|
|
424
|
+
|
|
425
|
+
const CALL_ID = 'call-id-outgoing-rejected'
|
|
426
|
+
const PEER_ID = 'peer-rejecter'
|
|
427
|
+
|
|
428
|
+
puppet.callInvite = sandbox.stub().resolves(CALL_ID)
|
|
429
|
+
|
|
430
|
+
// Initial payload: live; on second pull (sync after Reject) the call has endTime set.
|
|
431
|
+
let pulls = 0
|
|
432
|
+
puppet.callPayload = sandbox.stub().callsFake(async (id: string) => {
|
|
433
|
+
pulls++
|
|
434
|
+
await new Promise(setImmediate)
|
|
435
|
+
return {
|
|
436
|
+
id,
|
|
437
|
+
starter : 'bot-self',
|
|
438
|
+
participants : [ 'bot-self', PEER_ID ],
|
|
439
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
440
|
+
startTime : 1,
|
|
441
|
+
endTime : pulls === 1 ? undefined : 100,
|
|
442
|
+
} as PUPPET.payloads.Call
|
|
443
|
+
})
|
|
444
|
+
puppet.callPayloadDirty = sandbox.stub().resolves(undefined)
|
|
445
|
+
|
|
446
|
+
const contact = (wechaty.Contact as typeof ContactImpl).load(PEER_ID)
|
|
447
|
+
const call = await contact.call()
|
|
448
|
+
|
|
449
|
+
let rejectActor: ContactInterface | undefined
|
|
450
|
+
let rejectReason: string | undefined
|
|
451
|
+
let endedEmitCount = 0
|
|
452
|
+
wechaty.on('call-reject', (c: CallInterface, actor: ContactInterface, reason?: string) => {
|
|
453
|
+
if (c.id === CALL_ID) {
|
|
454
|
+
rejectActor = actor
|
|
455
|
+
rejectReason = reason
|
|
456
|
+
}
|
|
457
|
+
})
|
|
458
|
+
wechaty.on('call-ended', (c: CallInterface) => { if (c.id === CALL_ID) endedEmitCount++ })
|
|
459
|
+
|
|
460
|
+
;(puppet as any).emit('call', {
|
|
461
|
+
callId : CALL_ID,
|
|
462
|
+
signal : PUPPET.types.CallSignal.Reject,
|
|
463
|
+
contactId : PEER_ID,
|
|
464
|
+
reason : 'busy',
|
|
465
|
+
timestamp : 50,
|
|
466
|
+
} as PUPPET.payloads.EventCall)
|
|
467
|
+
await flush()
|
|
468
|
+
|
|
469
|
+
t.ok(rejectActor, 'call-reject actor should be set')
|
|
470
|
+
t.equal(rejectActor!.id, PEER_ID, 'call-reject actor should be the peer')
|
|
471
|
+
t.equal(rejectReason, 'busy', 'call-reject reason should be passed through')
|
|
472
|
+
t.equal(endedEmitCount, 1, 'call-ended should fire exactly once after Reject terminates the 1v1 call')
|
|
473
|
+
t.equal(call.status(), 'ended', 'status should be ended after terminal Reject')
|
|
474
|
+
t.notOk((wechaty as any).__callPool.has(CALL_ID), 'call should be evicted from pool')
|
|
475
|
+
|
|
476
|
+
await wechaty.stop()
|
|
477
|
+
sandbox.restore()
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// 7. Cancel signal received by callee → cancel + ended emitted
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
test('Cancel signal terminates incoming call; emits cancel + ended; pool cleaned; status flips before cancel handler runs', async t => {
|
|
485
|
+
const { puppet, wechaty } = buildWechaty()
|
|
486
|
+
await startAndLogin(puppet, wechaty)
|
|
487
|
+
|
|
488
|
+
const CALL_ID = 'call-id-cancel'
|
|
489
|
+
const CALLER_ID = 'caller-cancel'
|
|
490
|
+
|
|
491
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
492
|
+
id,
|
|
493
|
+
starter : CALLER_ID,
|
|
494
|
+
participants : [ CALLER_ID, 'bot-self' ],
|
|
495
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
496
|
+
startTime : 1,
|
|
497
|
+
}))
|
|
498
|
+
|
|
499
|
+
let incoming: CallInterface | undefined
|
|
500
|
+
wechaty.on('call', (c: CallInterface) => { incoming = c })
|
|
501
|
+
|
|
502
|
+
;(puppet as any).emit('call', {
|
|
503
|
+
callId : CALL_ID,
|
|
504
|
+
signal : PUPPET.types.CallSignal.Invite,
|
|
505
|
+
contactId : CALLER_ID,
|
|
506
|
+
timestamp : 1,
|
|
507
|
+
} as PUPPET.payloads.EventCall)
|
|
508
|
+
await flush()
|
|
509
|
+
|
|
510
|
+
let objectCancelReason: string | undefined
|
|
511
|
+
let botCancelReason: string | undefined
|
|
512
|
+
let statusInCancelHandler: string | undefined
|
|
513
|
+
let endedFiredCount = 0
|
|
514
|
+
incoming!.on('cancel', reason => {
|
|
515
|
+
objectCancelReason = reason
|
|
516
|
+
statusInCancelHandler = incoming!.status()
|
|
517
|
+
})
|
|
518
|
+
wechaty.on('call-cancel', (c: CallInterface, reason?: string) => {
|
|
519
|
+
if (c.id === CALL_ID) {
|
|
520
|
+
botCancelReason = reason
|
|
521
|
+
}
|
|
522
|
+
})
|
|
523
|
+
wechaty.on('call-ended', (c: CallInterface) => { if (c.id === CALL_ID) endedFiredCount++ })
|
|
524
|
+
|
|
525
|
+
;(puppet as any).emit('call', {
|
|
526
|
+
callId : CALL_ID,
|
|
527
|
+
signal : PUPPET.types.CallSignal.Cancel,
|
|
528
|
+
contactId : CALLER_ID,
|
|
529
|
+
reason : 'caller-aborted',
|
|
530
|
+
timestamp : 2,
|
|
531
|
+
} as PUPPET.payloads.EventCall)
|
|
532
|
+
await flush()
|
|
533
|
+
|
|
534
|
+
t.equal(objectCancelReason, 'caller-aborted', "object 'cancel' reason should match")
|
|
535
|
+
t.equal(botCancelReason, 'caller-aborted', "bot 'call-cancel' reason should match")
|
|
536
|
+
t.equal(endedFiredCount, 1, "'call-ended' should fire exactly once after Cancel (idempotent via __endedEmitted)")
|
|
537
|
+
t.equal(statusInCancelHandler, 'ended', "status should be 'ended' inside the cancel handler (no race window)")
|
|
538
|
+
t.equal(incoming!.status(), 'ended', 'status should be ended')
|
|
539
|
+
t.notOk((wechaty as any).__callPool.has(CALL_ID), 'call should be evicted from pool')
|
|
540
|
+
|
|
541
|
+
await wechaty.stop()
|
|
542
|
+
sandbox.restore()
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
// 8. hangup() control on connected
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
test('call.hangup() on connected call invokes puppet.callHangup and ends', async t => {
|
|
550
|
+
const { puppet, wechaty } = buildWechaty()
|
|
551
|
+
await startAndLogin(puppet, wechaty)
|
|
552
|
+
|
|
553
|
+
const CALL_ID = 'call-id-hangup'
|
|
554
|
+
const PEER_ID = 'peer-hangup'
|
|
555
|
+
|
|
556
|
+
puppet.callInvite = sandbox.stub().resolves(CALL_ID)
|
|
557
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
558
|
+
id,
|
|
559
|
+
starter : 'bot-self',
|
|
560
|
+
participants : [ 'bot-self', PEER_ID ],
|
|
561
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
562
|
+
startTime : 1,
|
|
563
|
+
}))
|
|
564
|
+
const hangupStub = sandbox.stub().resolves(undefined)
|
|
565
|
+
puppet.callHangup = hangupStub
|
|
566
|
+
|
|
567
|
+
const contact = (wechaty.Contact as typeof ContactImpl).load(PEER_ID)
|
|
568
|
+
const call = await contact.call()
|
|
569
|
+
|
|
570
|
+
;(puppet as any).emit('call', {
|
|
571
|
+
callId : CALL_ID,
|
|
572
|
+
signal : PUPPET.types.CallSignal.Accept,
|
|
573
|
+
contactId : PEER_ID,
|
|
574
|
+
timestamp : 2,
|
|
575
|
+
} as PUPPET.payloads.EventCall)
|
|
576
|
+
await flush()
|
|
577
|
+
|
|
578
|
+
t.equal(call.status(), 'connected', 'prerequisite: call should be connected')
|
|
579
|
+
|
|
580
|
+
await call.hangup('done')
|
|
581
|
+
t.equal(call.status(), 'ended', 'status should be ended after hangup()')
|
|
582
|
+
t.same(hangupStub.firstCall.args, [ CALL_ID, 'done' ], 'callHangup args should be [callId, reason]')
|
|
583
|
+
|
|
584
|
+
await wechaty.stop()
|
|
585
|
+
sandbox.restore()
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
test('call.hangup() rejects when puppet.callHangup fails but local status still ends', async t => {
|
|
589
|
+
const { puppet, wechaty } = buildWechaty()
|
|
590
|
+
await startAndLogin(puppet, wechaty)
|
|
591
|
+
|
|
592
|
+
puppet.callInvite = sandbox.stub().resolves('call-id-hangup-fail')
|
|
593
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
594
|
+
id,
|
|
595
|
+
starter : 'bot-self',
|
|
596
|
+
participants : [ 'bot-self', 'peer' ],
|
|
597
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
598
|
+
startTime : 1,
|
|
599
|
+
}))
|
|
600
|
+
const hangupStub = sandbox.stub().rejects(new Error('network error'))
|
|
601
|
+
puppet.callHangup = hangupStub
|
|
602
|
+
|
|
603
|
+
const contact = (wechaty.Contact as typeof ContactImpl).load('peer')
|
|
604
|
+
const call = await contact.call()
|
|
605
|
+
|
|
606
|
+
;(puppet as any).emit('call', {
|
|
607
|
+
callId : call.id,
|
|
608
|
+
signal : PUPPET.types.CallSignal.Accept,
|
|
609
|
+
contactId : 'peer',
|
|
610
|
+
timestamp : 2,
|
|
611
|
+
} as PUPPET.payloads.EventCall)
|
|
612
|
+
await flush()
|
|
613
|
+
t.equal(call.status(), 'connected', 'prerequisite: call should be connected')
|
|
614
|
+
|
|
615
|
+
await t.rejects(call.hangup(), /network error/, 'hangup() should re-throw')
|
|
616
|
+
t.equal(call.status(), 'ended', 'status should still be ended despite failure')
|
|
617
|
+
|
|
618
|
+
await wechaty.stop()
|
|
619
|
+
sandbox.restore()
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
// 9. add() invokes puppet.callAdd(callId, contactIds)
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
|
|
626
|
+
test('call.add() invokes puppet.callAdd with the contact ids', async t => {
|
|
627
|
+
const { puppet, wechaty } = buildWechaty()
|
|
628
|
+
await startAndLogin(puppet, wechaty)
|
|
629
|
+
|
|
630
|
+
const CALL_ID = 'call-id-add'
|
|
631
|
+
|
|
632
|
+
puppet.callInvite = sandbox.stub().resolves(CALL_ID)
|
|
633
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
634
|
+
id,
|
|
635
|
+
starter : 'bot-self',
|
|
636
|
+
participants : [ 'bot-self', 'peer' ],
|
|
637
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
638
|
+
startTime : 1,
|
|
639
|
+
}))
|
|
640
|
+
const addStub = sandbox.stub().resolves(undefined)
|
|
641
|
+
puppet.callAdd = addStub
|
|
642
|
+
|
|
643
|
+
const contact = (wechaty.Contact as typeof ContactImpl).load('peer')
|
|
644
|
+
const call = await contact.call()
|
|
645
|
+
|
|
646
|
+
const newContactA = (wechaty.Contact as typeof ContactImpl).load('newA')
|
|
647
|
+
const newContactB = (wechaty.Contact as typeof ContactImpl).load('newB')
|
|
648
|
+
|
|
649
|
+
await call.add([ newContactA, newContactB ])
|
|
650
|
+
t.same(addStub.firstCall.args, [ CALL_ID, [ 'newA', 'newB' ] ], 'callAdd args should be [callId, contactIds]')
|
|
651
|
+
|
|
652
|
+
await wechaty.stop()
|
|
653
|
+
sandbox.restore()
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
// 10. mediaEndpoint() pass-through
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
test('call.mediaEndpoint() forwards to puppet.callMediaEndpoint', async t => {
|
|
661
|
+
const { puppet, wechaty } = buildWechaty()
|
|
662
|
+
await startAndLogin(puppet, wechaty)
|
|
663
|
+
|
|
664
|
+
const CALL_ID = 'call-id-media'
|
|
665
|
+
|
|
666
|
+
puppet.callInvite = sandbox.stub().resolves(CALL_ID)
|
|
667
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
668
|
+
id,
|
|
669
|
+
starter : 'bot-self',
|
|
670
|
+
participants : [ 'bot-self', 'peer' ],
|
|
671
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
672
|
+
startTime : 1,
|
|
673
|
+
}))
|
|
674
|
+
const endpoint: PUPPET.payloads.CallMediaEndpoint = {
|
|
675
|
+
url : 'wss://media.example/sfu',
|
|
676
|
+
token : 'token-xyz',
|
|
677
|
+
}
|
|
678
|
+
const endpointStub = sandbox.stub().resolves(endpoint)
|
|
679
|
+
puppet.callMediaEndpoint = endpointStub
|
|
680
|
+
|
|
681
|
+
const contact = (wechaty.Contact as typeof ContactImpl).load('peer')
|
|
682
|
+
const call = await contact.call()
|
|
683
|
+
|
|
684
|
+
const got = await call.mediaEndpoint()
|
|
685
|
+
t.same(got, endpoint, 'mediaEndpoint should pass through')
|
|
686
|
+
t.same(endpointStub.firstCall.args, [ CALL_ID ], 'callMediaEndpoint args should be [callId]')
|
|
687
|
+
|
|
688
|
+
await wechaty.stop()
|
|
689
|
+
sandbox.restore()
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
// ---------------------------------------------------------------------------
|
|
693
|
+
// 11. wechaty.stop() drains the call pool
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
|
|
696
|
+
test('wechaty.stop() clears the call pool', async t => {
|
|
697
|
+
const { puppet, wechaty } = buildWechaty()
|
|
698
|
+
await startAndLogin(puppet, wechaty)
|
|
699
|
+
|
|
700
|
+
puppet.callInvite = sandbox.stub().resolves('call-id-stop')
|
|
701
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
702
|
+
id,
|
|
703
|
+
starter : 'bot-self',
|
|
704
|
+
participants : [ 'bot-self', 'peer' ],
|
|
705
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
706
|
+
startTime : 1,
|
|
707
|
+
}))
|
|
708
|
+
|
|
709
|
+
const contact = (wechaty.Contact as typeof ContactImpl).load('peer')
|
|
710
|
+
await contact.call()
|
|
711
|
+
|
|
712
|
+
t.ok((wechaty as any).__callPool.size > 0, 'pool should be non-empty before stop()')
|
|
713
|
+
|
|
714
|
+
await wechaty.stop()
|
|
715
|
+
t.equal((wechaty as any).__callPool.size, 0, 'pool should be empty after stop()')
|
|
716
|
+
|
|
717
|
+
sandbox.restore()
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
// 12. Unknown callId signal is silently dropped (no error event)
|
|
722
|
+
// ---------------------------------------------------------------------------
|
|
723
|
+
|
|
724
|
+
test('signal for unknown callId is dropped, not surfaced as error', async t => {
|
|
725
|
+
const { puppet, wechaty } = buildWechaty()
|
|
726
|
+
await startAndLogin(puppet, wechaty)
|
|
727
|
+
|
|
728
|
+
let errorEmitted = false
|
|
729
|
+
wechaty.on('error', () => { errorEmitted = true })
|
|
730
|
+
|
|
731
|
+
;(puppet as any).emit('call', {
|
|
732
|
+
callId : 'never-seen',
|
|
733
|
+
signal : PUPPET.types.CallSignal.Hangup,
|
|
734
|
+
contactId : 'someone',
|
|
735
|
+
timestamp : 1,
|
|
736
|
+
} as PUPPET.payloads.EventCall)
|
|
737
|
+
await flush()
|
|
738
|
+
|
|
739
|
+
t.notOk(errorEmitted, 'unknown callId should not emit error')
|
|
740
|
+
|
|
741
|
+
await wechaty.stop()
|
|
742
|
+
sandbox.restore()
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
// ---------------------------------------------------------------------------
|
|
746
|
+
// 13. Local control methods evict from pool + emit object 'ended'
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
|
|
749
|
+
test("local reject() evicts from pool and emits object 'ended' + bot 'call-ended'", async t => {
|
|
750
|
+
const { puppet, wechaty } = buildWechaty()
|
|
751
|
+
await startAndLogin(puppet, wechaty)
|
|
752
|
+
|
|
753
|
+
const CALL_ID = 'call-id-local-reject'
|
|
754
|
+
const CALLER_ID = 'caller-local-reject'
|
|
755
|
+
|
|
756
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
757
|
+
id,
|
|
758
|
+
starter : CALLER_ID,
|
|
759
|
+
participants : [ CALLER_ID, 'bot-self' ],
|
|
760
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
761
|
+
startTime : 1,
|
|
762
|
+
}))
|
|
763
|
+
puppet.callReject = sandbox.stub().resolves(undefined)
|
|
764
|
+
|
|
765
|
+
let incoming: CallInterface | undefined
|
|
766
|
+
wechaty.on('call', (c: CallInterface) => { incoming = c })
|
|
767
|
+
|
|
768
|
+
;(puppet as any).emit('call', {
|
|
769
|
+
callId : CALL_ID,
|
|
770
|
+
signal : PUPPET.types.CallSignal.Invite,
|
|
771
|
+
contactId : CALLER_ID,
|
|
772
|
+
timestamp : 1,
|
|
773
|
+
} as PUPPET.payloads.EventCall)
|
|
774
|
+
await flush()
|
|
775
|
+
|
|
776
|
+
t.ok(incoming, 'should receive incoming call')
|
|
777
|
+
t.ok((wechaty as any).__callPool.has(CALL_ID), 'pool should contain call before reject')
|
|
778
|
+
|
|
779
|
+
let endedEmitted = false
|
|
780
|
+
let botEndedCount = 0
|
|
781
|
+
incoming!.on('ended', () => { endedEmitted = true })
|
|
782
|
+
wechaty.on('call-ended', (c: CallInterface) => { if (c.id === CALL_ID) botEndedCount++ })
|
|
783
|
+
|
|
784
|
+
await incoming!.reject('busy')
|
|
785
|
+
|
|
786
|
+
t.ok(endedEmitted, "object 'ended' should fire after local reject()")
|
|
787
|
+
t.equal(botEndedCount, 1, "bot 'call-ended' should fire exactly once after local reject()")
|
|
788
|
+
t.notOk((wechaty as any).__callPool.has(CALL_ID), 'call should be evicted from pool after reject()')
|
|
789
|
+
|
|
790
|
+
await wechaty.stop()
|
|
791
|
+
sandbox.restore()
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
test("local cancel() evicts from pool and emits object 'ended' + bot 'call-ended'", async t => {
|
|
795
|
+
const { puppet, wechaty } = buildWechaty()
|
|
796
|
+
await startAndLogin(puppet, wechaty)
|
|
797
|
+
|
|
798
|
+
const CALL_ID = 'call-id-local-cancel'
|
|
799
|
+
const PEER_ID = 'peer-local-cancel'
|
|
800
|
+
|
|
801
|
+
puppet.callInvite = sandbox.stub().resolves(CALL_ID)
|
|
802
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
803
|
+
id,
|
|
804
|
+
starter : 'bot-self',
|
|
805
|
+
participants : [ 'bot-self', PEER_ID ],
|
|
806
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
807
|
+
startTime : 1,
|
|
808
|
+
}))
|
|
809
|
+
puppet.callCancel = sandbox.stub().resolves(undefined)
|
|
810
|
+
|
|
811
|
+
const contact = (wechaty.Contact as typeof ContactImpl).load(PEER_ID)
|
|
812
|
+
const call = await contact.call()
|
|
813
|
+
|
|
814
|
+
t.ok((wechaty as any).__callPool.has(CALL_ID), 'pool should contain call before cancel')
|
|
815
|
+
|
|
816
|
+
let endedEmitted = false
|
|
817
|
+
let botEndedCount = 0
|
|
818
|
+
call.on('ended', () => { endedEmitted = true })
|
|
819
|
+
wechaty.on('call-ended', (c: CallInterface) => { if (c.id === CALL_ID) botEndedCount++ })
|
|
820
|
+
|
|
821
|
+
await call.cancel()
|
|
822
|
+
|
|
823
|
+
t.ok(endedEmitted, "object 'ended' should fire after local cancel()")
|
|
824
|
+
t.equal(botEndedCount, 1, "bot 'call-ended' should fire exactly once after local cancel()")
|
|
825
|
+
t.notOk((wechaty as any).__callPool.has(CALL_ID), 'call should be evicted from pool after cancel()')
|
|
826
|
+
|
|
827
|
+
await wechaty.stop()
|
|
828
|
+
sandbox.restore()
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
test("local hangup() evicts from pool and emits object 'ended' + bot 'call-ended'", async t => {
|
|
832
|
+
const { puppet, wechaty } = buildWechaty()
|
|
833
|
+
await startAndLogin(puppet, wechaty)
|
|
834
|
+
|
|
835
|
+
const CALL_ID = 'call-id-local-hangup'
|
|
836
|
+
const PEER_ID = 'peer-local-hangup'
|
|
837
|
+
|
|
838
|
+
puppet.callInvite = sandbox.stub().resolves(CALL_ID)
|
|
839
|
+
stubCallPayload(puppet, (id: string) => ({
|
|
840
|
+
id,
|
|
841
|
+
starter : 'bot-self',
|
|
842
|
+
participants : [ 'bot-self', PEER_ID ],
|
|
843
|
+
media : PUPPET.types.CallMediaType.Audio,
|
|
844
|
+
startTime : 1,
|
|
845
|
+
}))
|
|
846
|
+
puppet.callHangup = sandbox.stub().resolves(undefined)
|
|
847
|
+
|
|
848
|
+
const contact = (wechaty.Contact as typeof ContactImpl).load(PEER_ID)
|
|
849
|
+
const call = await contact.call()
|
|
850
|
+
|
|
851
|
+
;(puppet as any).emit('call', {
|
|
852
|
+
callId : CALL_ID,
|
|
853
|
+
signal : PUPPET.types.CallSignal.Accept,
|
|
854
|
+
contactId : PEER_ID,
|
|
855
|
+
timestamp : 2,
|
|
856
|
+
} as PUPPET.payloads.EventCall)
|
|
857
|
+
await flush()
|
|
858
|
+
|
|
859
|
+
t.equal(call.status(), 'connected', 'prerequisite: call should be connected')
|
|
860
|
+
t.ok((wechaty as any).__callPool.has(CALL_ID), 'pool should contain call before hangup')
|
|
861
|
+
|
|
862
|
+
let endedEmitted = false
|
|
863
|
+
let botEndedCount = 0
|
|
864
|
+
call.on('ended', () => { endedEmitted = true })
|
|
865
|
+
wechaty.on('call-ended', (c: CallInterface) => { if (c.id === CALL_ID) botEndedCount++ })
|
|
866
|
+
|
|
867
|
+
await call.hangup('done')
|
|
868
|
+
|
|
869
|
+
t.ok(endedEmitted, "object 'ended' should fire after local hangup()")
|
|
870
|
+
t.equal(botEndedCount, 1, "bot 'call-ended' should fire exactly once after local hangup()")
|
|
871
|
+
t.notOk((wechaty as any).__callPool.has(CALL_ID), 'call should be evicted from pool after hangup()')
|
|
872
|
+
|
|
873
|
+
await wechaty.stop()
|
|
874
|
+
sandbox.restore()
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
// ---------------------------------------------------------------------------
|
|
878
|
+
// 14. dirty(Call) refreshes user-layer __payload
|
|
879
|
+
// ---------------------------------------------------------------------------
|
|
880
|
+
|
|
881
|
+
test('dirty(Call) refreshes user-layer payload so getters see new value', async t => {
|
|
882
|
+
const { puppet, wechaty } = buildWechaty()
|
|
883
|
+
await startAndLogin(puppet, wechaty)
|
|
884
|
+
|
|
885
|
+
const CALL_ID = 'call-id-dirty'
|
|
886
|
+
const CALLER_ID = 'caller-dirty'
|
|
887
|
+
|
|
888
|
+
// First pull returns Audio; subsequent pulls return Video, simulating a
|
|
889
|
+
// server-side media switch (e.g. add() upgraded the call).
|
|
890
|
+
let pulls = 0
|
|
891
|
+
puppet.callPayload = sandbox.stub().callsFake(async (id: string) => {
|
|
892
|
+
pulls++
|
|
893
|
+
await new Promise(setImmediate)
|
|
894
|
+
return {
|
|
895
|
+
id,
|
|
896
|
+
starter : CALLER_ID,
|
|
897
|
+
participants : [ CALLER_ID, 'bot-self' ],
|
|
898
|
+
media : pulls === 1 ? PUPPET.types.CallMediaType.Audio : PUPPET.types.CallMediaType.Video,
|
|
899
|
+
startTime : 1,
|
|
900
|
+
} as PUPPET.payloads.Call
|
|
901
|
+
})
|
|
902
|
+
puppet.callPayloadDirty = sandbox.stub().resolves(undefined)
|
|
903
|
+
|
|
904
|
+
let incoming: CallInterface | undefined
|
|
905
|
+
wechaty.on('call', (c: CallInterface) => { incoming = c })
|
|
906
|
+
|
|
907
|
+
;(puppet as any).emit('call', {
|
|
908
|
+
callId : CALL_ID,
|
|
909
|
+
signal : PUPPET.types.CallSignal.Invite,
|
|
910
|
+
contactId : CALLER_ID,
|
|
911
|
+
timestamp : 1,
|
|
912
|
+
} as PUPPET.payloads.EventCall)
|
|
913
|
+
await flush()
|
|
914
|
+
|
|
915
|
+
t.ok(incoming, 'should receive incoming call')
|
|
916
|
+
t.equal(incoming!.media(), PUPPET.types.CallMediaType.Audio, 'initial media should be Audio')
|
|
917
|
+
|
|
918
|
+
;(puppet as any).emit('dirty', {
|
|
919
|
+
payloadType : PUPPET.types.Payload.Call,
|
|
920
|
+
payloadId : CALL_ID,
|
|
921
|
+
} as PUPPET.payloads.EventDirty)
|
|
922
|
+
await flush()
|
|
923
|
+
|
|
924
|
+
t.equal(incoming!.media(), PUPPET.types.CallMediaType.Video, 'media should reflect refreshed payload after dirty')
|
|
925
|
+
t.ok((puppet.callPayloadDirty as any).called, 'puppet.callPayloadDirty should be invoked')
|
|
926
|
+
|
|
927
|
+
await wechaty.stop()
|
|
928
|
+
sandbox.restore()
|
|
929
|
+
})
|