@mzzsfy/dsh-turn-notify 0.8.0 → 0.9.0
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/README.md +3 -3
- package/package.json +1 -1
- package/src/client.js +38 -21
- package/src/core.mjs +3 -3
- package/test/core.test.mjs +6 -5
- package/test/hooks-destructure.test.mjs +28 -0
- package/test/route.test.mjs +25 -7
package/README.md
CHANGED
|
@@ -78,7 +78,7 @@ turn-notify:
|
|
|
78
78
|
|
|
79
79
|
## webhook 推送
|
|
80
80
|
|
|
81
|
-
在面板填入 webhook URL 即启用。host 直发,标签页全关也送达;payload 为 Slack 兼容 JSON(text 字段承载通知文本,summary 字段承载通知小结),另附 event/category/status/session/workspace/durationMs/tokens/ts 结构化字段;超时 10
|
|
81
|
+
在面板填入 webhook URL 即启用。host 直发,标签页全关也送达;payload 为 Slack 兼容 JSON(text 字段承载通知文本,summary 字段承载通知小结),另附 event/category/status/session/workspace/durationMs/tokens/ts 结构化字段;超时 10 秒,不重试。URL 原文随配置响应回显、保存即提交输入框内容(清空并保存即禁用),测试按钮返回真实投递结果。
|
|
82
82
|
|
|
83
83
|
## IM 推送
|
|
84
84
|
|
|
@@ -106,7 +106,7 @@ turn-notify:
|
|
|
106
106
|
|
|
107
107
|
## settings.yaml 参考
|
|
108
108
|
|
|
109
|
-
面板与 settings.yaml 读写同一命名空间,两边改动互通、保存即生效。webhookUrl
|
|
109
|
+
面板与 settings.yaml 读写同一命名空间,两边改动互通、保存即生效。webhookUrl 随面板配置响应回显,yaml 直改同样可见。
|
|
110
110
|
|
|
111
111
|
```yaml
|
|
112
112
|
turn-notify:
|
|
@@ -155,7 +155,7 @@ turn-notify:
|
|
|
155
155
|
## 安全
|
|
156
156
|
|
|
157
157
|
- 配置写入类接口(config / mapping / upload / 音效改名与删除 / 测试)带同源守卫:Origin 与 Host 不符即 403,JSON 写入另校验 content-type,阻断跨站页面 drive-by 改写配置。
|
|
158
|
-
- webhookUrl
|
|
158
|
+
- webhookUrl 在 settings 层标记为 secret(导出与日志脱敏);面板配置接口原文回显,本机同源可读写。
|
|
159
159
|
- 音效上传双重校验扩展名与真实音频内容;音效读取接口拒绝音频扩展名以外的文件。
|
|
160
160
|
- 已知边界:同源守卫不防 DNS rebinding(Origin 与 Host 相等即放行)。该暴露面属 host webserver 全部 /api 路由的存量问题,应在 host 层统一解决而非逐插件补丁。
|
|
161
161
|
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -8,7 +8,7 @@ window.__ModuleLoader__.load({
|
|
|
8
8
|
id: '@mzzsfy/dsh-turn-notify',
|
|
9
9
|
factory(require) {
|
|
10
10
|
const React = require('react')
|
|
11
|
-
const { useState, useEffect } = React
|
|
11
|
+
const { useState, useEffect, useRef } = React
|
|
12
12
|
|
|
13
13
|
// 通知出口:公共依赖 @mzzsfy/dsh-toast,可选消费——共享依赖包的模块表注入
|
|
14
14
|
// 全仓收敛到唯一权威消费方(session-manager),本插件不再代挂占位条目;
|
|
@@ -827,13 +827,28 @@ window.__ModuleLoader__.load({
|
|
|
827
827
|
|
|
828
828
|
// 宿主重渲染会重建行节点抹掉高亮类,观察器在同一帧内补齐;
|
|
829
829
|
// applySessionHighlights 幂等(类齐不写 DOM),观察器链自然收敛;
|
|
830
|
-
// 令牌承载 observer
|
|
830
|
+
// 令牌承载 observer 与 pending 帧:HMR 重建闭包后先断开旧代 observer、
|
|
831
|
+
// cancel 旧代 pending 帧(帧回调持有旧代高亮 Map,不取消会写幽灵高亮),
|
|
832
|
+
// 旧闭包不滞留;rAF 合批:流式期间 DOM 变更批远高于帧率,同帧多批合并
|
|
833
|
+
// 为一次重应用(同帧补齐语义不变),无高亮条目时不排帧
|
|
831
834
|
const KEY_HL_OBSERVER = 'turn-notify:hl-observer'
|
|
835
|
+
const KEY_HL_FRAME = 'turn-notify:hl-frame'
|
|
832
836
|
function ensureHighlightObserver() {
|
|
833
837
|
if (typeof document === 'undefined' || typeof document.body === 'undefined' || typeof MutationObserver === 'undefined') return
|
|
834
838
|
if (window[KEY_HL_OBSERVER] !== undefined && typeof window[KEY_HL_OBSERVER].disconnect === 'function') window[KEY_HL_OBSERVER].disconnect()
|
|
839
|
+
if (typeof window[KEY_HL_FRAME] === 'number') {
|
|
840
|
+
cancelAnimationFrame(window[KEY_HL_FRAME])
|
|
841
|
+
}
|
|
842
|
+
// 哨兵 0 = 无在途帧(规范 rAF 句柄自 1 起,cancel 0 为 no-op);新代显式归零,
|
|
843
|
+
// 防 undefined 初始态使在途判定恒真、帧永不排程
|
|
844
|
+
window[KEY_HL_FRAME] = 0
|
|
835
845
|
const observer = new MutationObserver(() => {
|
|
836
|
-
if (sessionHighlightEnabled
|
|
846
|
+
if (!sessionHighlightEnabled || sessionHighlights.size === 0) return
|
|
847
|
+
if (window[KEY_HL_FRAME] !== 0) return
|
|
848
|
+
window[KEY_HL_FRAME] = requestAnimationFrame(() => {
|
|
849
|
+
window[KEY_HL_FRAME] = 0
|
|
850
|
+
if (sessionHighlightEnabled && sessionHighlights.size > 0) applySessionHighlights()
|
|
851
|
+
})
|
|
837
852
|
})
|
|
838
853
|
observer.observe(document.body, { childList: true, subtree: true })
|
|
839
854
|
window[KEY_HL_OBSERVER] = observer
|
|
@@ -994,10 +1009,10 @@ window.__ModuleLoader__.load({
|
|
|
994
1009
|
|
|
995
1010
|
const PERMISSION_LABELS = { granted: '已授权', denied: '已拒绝', default: '未授权' }
|
|
996
1011
|
|
|
997
|
-
// 面板表单占位:GET config 返回前展示;webhookUrl
|
|
1012
|
+
// 面板表单占位:GET config 返回前展示;webhookUrl 原文随响应回显,表单所见即所存;
|
|
998
1013
|
// imAvailable 缺省为假,加载响应后 dsh-im 在场才渲染 IM 投递卡
|
|
999
1014
|
const DEFAULT_CONFIG = {
|
|
1000
|
-
|
|
1015
|
+
webhookUrl: '',
|
|
1001
1016
|
minTurnDurationMs: 5 * 1000,
|
|
1002
1017
|
rootsOnly: true,
|
|
1003
1018
|
suppressSubagentWake: true,
|
|
@@ -1181,6 +1196,7 @@ window.__ModuleLoader__.load({
|
|
|
1181
1196
|
.then((res) => {
|
|
1182
1197
|
setConfig({ ...DEFAULT_CONFIG, ...res })
|
|
1183
1198
|
setMappingState(res.soundMapping || {})
|
|
1199
|
+
setUrlDraft(typeof res.webhookUrl === 'string' ? res.webhookUrl : '')
|
|
1184
1200
|
setConfigLoaded(true)
|
|
1185
1201
|
})
|
|
1186
1202
|
.catch(() => {})
|
|
@@ -1351,14 +1367,12 @@ window.__ModuleLoader__.load({
|
|
|
1351
1367
|
: String(config.minTurnDurationMs)
|
|
1352
1368
|
if (raw.length === 0) throw new Error('最短回合时长不能为空')
|
|
1353
1369
|
const trimmedUrl = urlDraft.trim()
|
|
1354
|
-
//
|
|
1355
|
-
const patchBody = { minTurnDurationMs: Number(raw), rootsOnly: config.rootsOnly, suppressSubagentWake: config.suppressSubagentWake }
|
|
1356
|
-
// 只写语义:输入留空即保持现有 webhook 不变
|
|
1357
|
-
if (trimmedUrl.length > 0) patchBody.webhookUrl = trimmedUrl
|
|
1370
|
+
// 所见即所存:输入框即配置值,清空并保存即禁用 webhook 通道
|
|
1371
|
+
const patchBody = { webhookUrl: trimmedUrl, minTurnDurationMs: Number(raw), rootsOnly: config.rootsOnly, suppressSubagentWake: config.suppressSubagentWake }
|
|
1358
1372
|
const res = await api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify(patchBody) })
|
|
1359
1373
|
setConfig({ ...DEFAULT_CONFIG, ...res })
|
|
1360
1374
|
if (res.soundMapping) setMappingState(res.soundMapping)
|
|
1361
|
-
setUrlDraft('')
|
|
1375
|
+
setUrlDraft(typeof res.webhookUrl === 'string' ? res.webhookUrl : '')
|
|
1362
1376
|
patch('配置已保存,立即生效')
|
|
1363
1377
|
} catch (error) {
|
|
1364
1378
|
patch('保存失败:' + (error && error.message ? error.message : String(error)), 'error')
|
|
@@ -1371,7 +1385,7 @@ window.__ModuleLoader__.load({
|
|
|
1371
1385
|
const res = await api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify({ webhookUrl: '' }) })
|
|
1372
1386
|
setConfig({ ...DEFAULT_CONFIG, ...res })
|
|
1373
1387
|
if (res.soundMapping) setMappingState(res.soundMapping)
|
|
1374
|
-
setUrlDraft('')
|
|
1388
|
+
setUrlDraft(typeof res.webhookUrl === 'string' ? res.webhookUrl : '')
|
|
1375
1389
|
patch('webhook 已清除')
|
|
1376
1390
|
} catch (error) {
|
|
1377
1391
|
patch('清除失败:' + (error && error.message ? error.message : String(error)), 'error')
|
|
@@ -1452,8 +1466,9 @@ window.__ModuleLoader__.load({
|
|
|
1452
1466
|
}
|
|
1453
1467
|
|
|
1454
1468
|
async function testWebhook() {
|
|
1455
|
-
|
|
1456
|
-
|
|
1469
|
+
// 草稿与已保存值不同即存在未保存改动,测试的只能是已保存配置
|
|
1470
|
+
if (urlDraft.trim() !== String(config.webhookUrl || '').trim()) {
|
|
1471
|
+
patch('表单中的 webhook URL 尚未保存,本次测试的是已保存配置;请先保存再测试', 'error')
|
|
1457
1472
|
return
|
|
1458
1473
|
}
|
|
1459
1474
|
try {
|
|
@@ -1484,14 +1499,16 @@ window.__ModuleLoader__.load({
|
|
|
1484
1499
|
// 已绑 bot chips 由 imBoundBotIds 去重(见 LOGIC 段同源函数)
|
|
1485
1500
|
const imBoundBots = imBoundBotIds(config.imTargets)
|
|
1486
1501
|
|
|
1487
|
-
//
|
|
1488
|
-
|
|
1502
|
+
// 勾选即存:与分类开关同模式,列表整体替换,连续操作以最新一次请求为准;
|
|
1503
|
+
// seq 守卫须跨渲染存活(useRef),组件体内 let 声明每次渲染重置使守卫失效
|
|
1504
|
+
const imPersistSeqRef = useRef(0)
|
|
1489
1505
|
async function persistImTargets(next, okMessage) {
|
|
1490
|
-
|
|
1506
|
+
imPersistSeqRef.current += 1
|
|
1507
|
+
const seq = imPersistSeqRef.current
|
|
1491
1508
|
setConfig({ ...config, imTargets: next })
|
|
1492
1509
|
try {
|
|
1493
1510
|
const res = await api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify({ imTargets: next }) })
|
|
1494
|
-
if (seq ===
|
|
1511
|
+
if (seq === imPersistSeqRef.current) {
|
|
1495
1512
|
setConfig({ ...DEFAULT_CONFIG, ...res })
|
|
1496
1513
|
if (res.soundMapping) setMappingState(res.soundMapping)
|
|
1497
1514
|
if (okMessage !== undefined) patch(okMessage)
|
|
@@ -1615,15 +1632,15 @@ window.__ModuleLoader__.load({
|
|
|
1615
1632
|
field('webhook', [
|
|
1616
1633
|
h('input', {
|
|
1617
1634
|
className: 'tn-input tn-fill', type: 'text',
|
|
1618
|
-
title: '通知由 host 直接 POST 到该地址,标签页全关也送达;Slack 兼容 JSON 格式,超时 10
|
|
1619
|
-
placeholder:
|
|
1635
|
+
title: '通知由 host 直接 POST 到该地址,标签页全关也送达;Slack 兼容 JSON 格式,超时 10 秒不重试',
|
|
1636
|
+
placeholder: 'Slack-compatible URL,留空禁用',
|
|
1620
1637
|
value: urlDraft,
|
|
1621
1638
|
onChange: (e) => setUrlDraft(e.target.value),
|
|
1622
1639
|
}),
|
|
1623
|
-
config.
|
|
1640
|
+
String(config.webhookUrl || '').trim().length > 0
|
|
1624
1641
|
? h('button', { className: 'tn-btn tn-btn--danger', disabled: busy, title: '清除已配置的 webhook,清除后该通道禁用', onClick: () => void clearWebhook() }, '清除')
|
|
1625
1642
|
: null,
|
|
1626
|
-
], '
|
|
1643
|
+
], '保存即提交输入框内容,清空并保存即禁用;改动后先保存,测试按钮只测已保存配置'),
|
|
1627
1644
|
field('最短回合时长', [
|
|
1628
1645
|
h('input', {
|
|
1629
1646
|
className: 'tn-input', type: 'number', min: 0, step: 500, style: { width: '90px' },
|
package/src/core.mjs
CHANGED
|
@@ -664,7 +664,7 @@ export function resolvedConfig(settings) {
|
|
|
664
664
|
}
|
|
665
665
|
}
|
|
666
666
|
|
|
667
|
-
// 面板可见配置:webhookUrl
|
|
667
|
+
// 面板可见配置:webhookUrl 原文随响应回显,面板所见即所存。
|
|
668
668
|
export function publicConfig(settings) {
|
|
669
669
|
const resolved = resolvedConfig(settings)
|
|
670
670
|
return {
|
|
@@ -675,6 +675,6 @@ export function publicConfig(settings) {
|
|
|
675
675
|
soundMapping: resolved.soundMapping,
|
|
676
676
|
imTargets: resolved.imTargets,
|
|
677
677
|
kindRoutes: resolved.kindRoutes,
|
|
678
|
-
|
|
678
|
+
webhookUrl: resolved.webhookUrl,
|
|
679
679
|
}
|
|
680
|
-
}
|
|
680
|
+
}
|
package/test/core.test.mjs
CHANGED
|
@@ -679,7 +679,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
|
|
|
679
679
|
assert.equal(resolvedConfig({ suppressSubagentWake: false }).suppressSubagentWake, false)
|
|
680
680
|
})
|
|
681
681
|
|
|
682
|
-
test('面板可见配置:webhookUrl
|
|
682
|
+
test('面板可见配置:webhookUrl 原文回显', () => {
|
|
683
683
|
assert.deepEqual(
|
|
684
684
|
publicConfig({ webhookUrl: 'https://hook.example/service/xxx', enabled: { completed: false }, soundMapping: { completed: 'snd-1' }, kindRoutes: { ask: ['im'] } }),
|
|
685
685
|
{
|
|
@@ -690,12 +690,13 @@ test('面板可见配置:webhookUrl 不出主机,仅回是否已配置', () => {
|
|
|
690
690
|
soundMapping: { completed: 'snd-1' },
|
|
691
691
|
imTargets: [],
|
|
692
692
|
kindRoutes: { ask: ['im'] },
|
|
693
|
-
|
|
693
|
+
webhookUrl: 'https://hook.example/service/xxx',
|
|
694
694
|
},
|
|
695
695
|
)
|
|
696
|
-
assert.equal(publicConfig({}).
|
|
697
|
-
|
|
698
|
-
assert.equal(
|
|
696
|
+
assert.equal(publicConfig({}).webhookUrl, '')
|
|
697
|
+
// 空白串原样回显,不静默归一
|
|
698
|
+
assert.equal(publicConfig({ webhookUrl: ' ' }).webhookUrl, ' ')
|
|
699
|
+
assert.equal('webhookConfigured' in publicConfig({ webhookUrl: 'https://hook.example' }), false)
|
|
699
700
|
})
|
|
700
701
|
|
|
701
702
|
test('会话事件有界累积:标题提取后封账为字符串,内存不再增长', () => {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// client 半区 React hooks 解构静态断言:client.js 为经典 bundle,组件渲染路径
|
|
2
|
+
// 无运行时测试,hooks 未从 React 解构的缺陷(useRef is not a function)只能靠
|
|
3
|
+
// 源码静态比对拦截——组件体内使用的全部 hooks 必须出现在解构清单。
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { join, dirname } from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
11
|
+
const source = readFileSync(join(root, 'src', 'client.js'), 'utf8')
|
|
12
|
+
|
|
13
|
+
test('client.js 组件使用的全部 React hooks 均在解构清单中', () => {
|
|
14
|
+
// Given 解构行(const { ... } = React)与全源码 hooks 调用
|
|
15
|
+
const destructure = /const\s*\{([^}]+)\}\s*=\s*React\b/.exec(source)
|
|
16
|
+
assert.notEqual(destructure, null, 'React 解构行必须存在')
|
|
17
|
+
const declared = new Set(destructure[1].split(',').map((name) => name.trim()).filter(Boolean))
|
|
18
|
+
const used = [...source.matchAll(/\b(use[A-Z]\w*)\s*\(/g)].map((match) => match[1])
|
|
19
|
+
assert.ok(used.length > 0, '至少存在一个 hooks 调用,否则断言失效')
|
|
20
|
+
// Then 每个使用的 hooks 都已声明
|
|
21
|
+
const missing = [...new Set(used)].filter((name) => !declared.has(name))
|
|
22
|
+
assert.deepEqual(missing, [], '未解构的 hooks(渲染即 ReferenceError)')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('client.js 不存在 React.useXxx 直调形态(绕过解构清单)', () => {
|
|
26
|
+
const direct = [...source.matchAll(/\bReact\.(use[A-Z]\w*)\s*\(/g)].map((match) => match[1])
|
|
27
|
+
assert.deepEqual(direct, [], 'hooks 一律经解构清单使用,便于静态断言')
|
|
28
|
+
})
|
package/test/route.test.mjs
CHANGED
|
@@ -79,7 +79,7 @@ function makeCtx(extraServices) {
|
|
|
79
79
|
return { ctx, routes }
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
test('config GET 返回解析后的默认配置,webhookUrl
|
|
82
|
+
test('config GET 返回解析后的默认配置,webhookUrl 原文回显', async () => {
|
|
83
83
|
const { ctx, routes } = makeCtx()
|
|
84
84
|
apply(ctx)
|
|
85
85
|
const handler = routes.get('/api/turn-notify/config')
|
|
@@ -87,8 +87,8 @@ test('config GET 返回解析后的默认配置,webhookUrl 凭据不出主机',
|
|
|
87
87
|
const res = makeRes()
|
|
88
88
|
await handler(makeReq('GET'), res)
|
|
89
89
|
assert.equal(res.status, 200)
|
|
90
|
-
assert.equal(res.body.
|
|
91
|
-
assert.equal('
|
|
90
|
+
assert.equal(res.body.webhookUrl, '')
|
|
91
|
+
assert.equal('webhookConfigured' in res.body, false)
|
|
92
92
|
assert.equal(res.body.minTurnDurationMs, MIN_TURN_MS)
|
|
93
93
|
assert.equal(res.body.rootsOnly, true)
|
|
94
94
|
assert.equal(res.body.enabled.completed, true)
|
|
@@ -105,12 +105,30 @@ test('config POST 非法补丁 400,合法补丁持久化且部分开关不覆盖
|
|
|
105
105
|
const good = makeRes()
|
|
106
106
|
await handler(makeReq('POST', { webhookUrl: 'https://hook.example', enabled: { completed: false } }, JSON_HEADERS), good)
|
|
107
107
|
assert.equal(good.status, 200)
|
|
108
|
-
assert.equal(good.body.
|
|
108
|
+
assert.equal(good.body.webhookUrl, 'https://hook.example')
|
|
109
109
|
assert.equal(good.body.enabled.completed, false)
|
|
110
110
|
assert.equal(good.body.enabled.error, true)
|
|
111
111
|
const readback = makeRes()
|
|
112
112
|
await handler(makeReq('GET'), readback)
|
|
113
|
-
assert.equal(readback.body.
|
|
113
|
+
assert.equal(readback.body.webhookUrl, 'https://hook.example')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('config POST 空串清空 webhookUrl:先存后清双向回环,清后通道禁用', async () => {
|
|
117
|
+
const { ctx, routes } = makeCtx()
|
|
118
|
+
apply(ctx)
|
|
119
|
+
const handler = routes.get('/api/turn-notify/config')
|
|
120
|
+
const save = makeRes()
|
|
121
|
+
await handler(makeReq('POST', { webhookUrl: 'https://hook.example/x' }, JSON_HEADERS), save)
|
|
122
|
+
assert.equal(save.status, 200)
|
|
123
|
+
assert.equal(save.body.webhookUrl, 'https://hook.example/x')
|
|
124
|
+
// 清空并保存=禁用:空串经校验落库,回读为空串而非回退旧值
|
|
125
|
+
const clear = makeRes()
|
|
126
|
+
await handler(makeReq('POST', { webhookUrl: '' }, JSON_HEADERS), clear)
|
|
127
|
+
assert.equal(clear.status, 200)
|
|
128
|
+
assert.equal(clear.body.webhookUrl, '')
|
|
129
|
+
const readback = makeRes()
|
|
130
|
+
await handler(makeReq('GET'), readback)
|
|
131
|
+
assert.equal(readback.body.webhookUrl, '')
|
|
114
132
|
})
|
|
115
133
|
|
|
116
134
|
test('config POST 负路径:跨源 403,非 JSON 400,畸形体 400,空补丁 200', async () => {
|
|
@@ -228,7 +246,7 @@ test('config POST 仅 imTargets 的部分补丁:不触碰其他配置项,响应
|
|
|
228
246
|
const res = makeRes()
|
|
229
247
|
await handler(makeReq('POST', { imTargets: [{ botId: 'wx_a', targetId: 'owner' }, { botId: 'wx_b', targetId: 'owner' }] }, JSON_HEADERS), res)
|
|
230
248
|
assert.equal(res.status, 200)
|
|
231
|
-
assert.equal(res.body.
|
|
249
|
+
assert.equal(res.body.webhookUrl, 'https://hooks.example.com/x')
|
|
232
250
|
assert.equal(res.body.enabled.completed, false)
|
|
233
251
|
assert.deepEqual(res.body.imTargets, [{ botId: 'wx_a', targetId: 'owner' }, { botId: 'wx_b', targetId: 'owner' }])
|
|
234
252
|
// 取消注册:整列表替换为空,其余配置仍原样
|
|
@@ -236,7 +254,7 @@ test('config POST 仅 imTargets 的部分补丁:不触碰其他配置项,响应
|
|
|
236
254
|
await handler(makeReq('POST', { imTargets: [] }, JSON_HEADERS), clear)
|
|
237
255
|
assert.equal(clear.status, 200)
|
|
238
256
|
assert.deepEqual(clear.body.imTargets, [])
|
|
239
|
-
assert.equal(clear.body.
|
|
257
|
+
assert.equal(clear.body.webhookUrl, 'https://hooks.example.com/x')
|
|
240
258
|
assert.equal(clear.body.enabled.completed, false)
|
|
241
259
|
})
|
|
242
260
|
|