@mzzsfy/dsh-cron-board 0.1.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 +55 -0
- package/cordis.patch.yml +5 -0
- package/package.json +44 -0
- package/src/api.mjs +434 -0
- package/src/client.js +949 -0
- package/src/config.mjs +10 -0
- package/src/cron.mjs +50 -0
- package/src/env-expand.mjs +51 -0
- package/src/env-text.mjs +17 -0
- package/src/executor.mjs +139 -0
- package/src/index.js +167 -0
- package/src/logger.mjs +82 -0
- package/src/scheduler.mjs +99 -0
- package/src/session-driver.mjs +82 -0
- package/src/session-window.mjs +37 -0
- package/src/shell-runner.mjs +71 -0
- package/src/status-meta.mjs +16 -0
- package/src/store.mjs +195 -0
- package/test/env-expand.test.mjs +101 -0
- package/test/executor.test.mjs +107 -0
- package/test/index.test.mjs +214 -0
- package/test/logger.test.mjs +98 -0
- package/test/parity.test.mjs +72 -0
- package/test/routes.test.mjs +361 -0
- package/test/scheduler.test.mjs +383 -0
- package/test/session-driver.test.mjs +161 -0
- package/test/shell-runner.test.mjs +87 -0
- package/test/store.test.mjs +92 -0
- package/test/switch-guard.test.mjs +63 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// 装配测试:数据目录解析 + webServer prefix 注册 + settings/timer 接线 + 装配后请求可用。
|
|
2
|
+
|
|
3
|
+
import { test } from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { EventEmitter } from 'node:events'
|
|
6
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
7
|
+
import { tmpdir } from 'node:os'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
|
|
11
|
+
const mod = await import('../src/index.js')
|
|
12
|
+
const { apply, resolveDataDir } = mod
|
|
13
|
+
|
|
14
|
+
test('index:数据目录默认 ~/.dsh/cron-board,env 可覆盖', () => {
|
|
15
|
+
assert.equal(resolveDataDir({}), join(homedir(), '.dsh', 'cron-board'))
|
|
16
|
+
assert.equal(resolveDataDir({ DSH_CRON_BOARD_DATA_DIR: 'X:\\tmp\\cb' }), 'X:\\tmp\\cb')
|
|
17
|
+
// 空白覆盖不生效,回默认
|
|
18
|
+
assert.equal(resolveDataDir({ DSH_CRON_BOARD_DATA_DIR: ' ' }), join(homedir(), '.dsh', 'cron-board'))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('index:apply 注册 prefix 路由且请求走通(列表接口)', async () => {
|
|
22
|
+
// Given webServer 桩:收集注册路由(sessionController 属性桩满足 inject 门控后环境)
|
|
23
|
+
const routes = new Map()
|
|
24
|
+
const ctx = {
|
|
25
|
+
effect(fn) {
|
|
26
|
+
fn()
|
|
27
|
+
},
|
|
28
|
+
inject() {},
|
|
29
|
+
sessionController: { create: async () => ({ sessionId: 's-x' }), prompt: async () => ({ accepted: true }) },
|
|
30
|
+
get: () => undefined,
|
|
31
|
+
webServer: {
|
|
32
|
+
register(route) {
|
|
33
|
+
routes.set(route.path, route.handler)
|
|
34
|
+
return () => {}
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
// When apply(装配)
|
|
39
|
+
apply(ctx)
|
|
40
|
+
// Then prefix 路由已注册
|
|
41
|
+
const handler = routes.get('/api/cron-board')
|
|
42
|
+
assert.ok(handler)
|
|
43
|
+
// When 调用未匹配的子路径(数据目录未初始化也可得到 404 JSON)
|
|
44
|
+
const res = { status: null, payload: null }
|
|
45
|
+
res.writeHead = (status) => { res.status = status }
|
|
46
|
+
res.end = (text) => { res.payload = JSON.parse(text) }
|
|
47
|
+
const req = { method: 'GET', url: 'http://localhost/api/cron-board/__nope__' }
|
|
48
|
+
await handler(req, res)
|
|
49
|
+
assert.equal(res.status, 404)
|
|
50
|
+
assert.ok(res.payload.error)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// 装配桩:webServer + settings + timer + 会话服务,记录注册与 interval 形态
|
|
54
|
+
function makeFullCtx({ timerAvailable = true } = {}) {
|
|
55
|
+
const routes = new Map()
|
|
56
|
+
const registered = []
|
|
57
|
+
let value = {}
|
|
58
|
+
const settingsService = {
|
|
59
|
+
register(ns, schema, options) {
|
|
60
|
+
registered.push(ns)
|
|
61
|
+
return { get: () => value, watch() {} }
|
|
62
|
+
},
|
|
63
|
+
get: () => value,
|
|
64
|
+
}
|
|
65
|
+
const sessions = new Map()
|
|
66
|
+
let nextId = 0
|
|
67
|
+
const sessionController = {
|
|
68
|
+
async create() {
|
|
69
|
+
const sessionId = 's-' + (++nextId)
|
|
70
|
+
sessions.set(sessionId, { id: sessionId, status: 'running' })
|
|
71
|
+
return { sessionId }
|
|
72
|
+
},
|
|
73
|
+
async prompt(args) {
|
|
74
|
+
queueMicrotask(() => {
|
|
75
|
+
const entry = sessions.get(args.sessionId)
|
|
76
|
+
if (entry) entry.status = 'idle'
|
|
77
|
+
})
|
|
78
|
+
return { accepted: true }
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
const intervals = []
|
|
82
|
+
const ctx = {
|
|
83
|
+
effect(fn) {
|
|
84
|
+
fn()
|
|
85
|
+
},
|
|
86
|
+
sessionController,
|
|
87
|
+
get(name) {
|
|
88
|
+
if (name === 'settings') return settingsService
|
|
89
|
+
if (name === 'sessionController') return sessionController
|
|
90
|
+
if (name === 'agents') return { get: (id) => sessions.get(id) }
|
|
91
|
+
if (name === 'sessionQuery') return { listSessions: async () => [...sessions.values()].map((entry) => ({ id: entry.id })) }
|
|
92
|
+
return undefined
|
|
93
|
+
},
|
|
94
|
+
inject(deps, fn) {
|
|
95
|
+
if (deps[0] === 'timer') {
|
|
96
|
+
fn({
|
|
97
|
+
interval(intervalFn, ms) {
|
|
98
|
+
intervals.push({ fn: intervalFn, ms })
|
|
99
|
+
return () => {}
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
if (deps[0] === 'settings') {
|
|
104
|
+
fn({ settings: settingsService })
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
webServer: {
|
|
108
|
+
register(route) {
|
|
109
|
+
routes.set(route.path, route.handler)
|
|
110
|
+
return () => {}
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
return { ctx, routes, registered, intervals, settingsService, sessions }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
test('index:inject 声明 webServer+sessionController(cordis 门控整体禁用的唯一形态)', async () => {
|
|
118
|
+
// Given 宿主会话服务走 cordis 硬依赖门控:缺失时 fiber 不激活,apply 不执行
|
|
119
|
+
// Then 静态锁定 inject 声明(修复 mce 同款「apply 内同步探测误判缺失」事故形态)
|
|
120
|
+
assert.deepEqual(mod.inject, ['webServer', 'sessionController'])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('index:settings 注册 cron-board 命名空间且 timer 承载默认周期 tick', async () => {
|
|
124
|
+
// Given 全服务桩
|
|
125
|
+
const { ctx, registered, intervals } = makeFullCtx()
|
|
126
|
+
// When apply
|
|
127
|
+
apply(ctx)
|
|
128
|
+
// Then 命名空间注册,默认周期 30s 的 interval 建立
|
|
129
|
+
assert.deepEqual(registered, ['cron-board'])
|
|
130
|
+
assert.deepEqual(intervals.map((entry) => entry.ms), [30 * 1000])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('index:tickSeconds 设置变更经 watch 重建 interval', async () => {
|
|
134
|
+
// Given 全服务桩,settings.register 的 watch 回调被捕获
|
|
135
|
+
let watchFn = null
|
|
136
|
+
const { ctx, intervals, settingsService } = makeFullCtx()
|
|
137
|
+
settingsService.register = () => ({ get: () => ({}), watch(fn) { watchFn = fn } })
|
|
138
|
+
apply(ctx)
|
|
139
|
+
assert.deepEqual(intervals.map((entry) => entry.ms), [30 * 1000])
|
|
140
|
+
// When 设置变更触发 watch
|
|
141
|
+
watchFn()
|
|
142
|
+
// Then interval 重建
|
|
143
|
+
assert.equal(intervals.length, 2)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('index:tick 到期任务被调度执行(端到端装配)', async (t) => {
|
|
147
|
+
// Given 全服务桩 + 临时数据目录 + 一个已到期任务
|
|
148
|
+
const dir = await mkdtemp(join(tmpdir(), 'cron-board-idx-'))
|
|
149
|
+
t.after(async () => {
|
|
150
|
+
delete process.env.DSH_CRON_BOARD_DATA_DIR
|
|
151
|
+
await rm(dir, { recursive: true, force: true })
|
|
152
|
+
})
|
|
153
|
+
process.env.DSH_CRON_BOARD_DATA_DIR = dir
|
|
154
|
+
const { ctx, routes, intervals } = makeFullCtx()
|
|
155
|
+
const { apply: applyFresh } = await import('../src/index.js?' + Date.now())
|
|
156
|
+
applyFresh(ctx)
|
|
157
|
+
const handler = routes.get('/api/cron-board')
|
|
158
|
+
const post = (path, body) => {
|
|
159
|
+
const res = { status: null, payload: null }
|
|
160
|
+
res.writeHead = (status) => { res.status = status }
|
|
161
|
+
res.end = (text) => { res.payload = JSON.parse(text) }
|
|
162
|
+
const req = new EventEmitter()
|
|
163
|
+
req.method = 'POST'
|
|
164
|
+
req.url = 'http://localhost' + path
|
|
165
|
+
// 装配层初始化后才挂读体监听:桩事件须延后发射(真实 http 流自带缓冲,无此问题)
|
|
166
|
+
setTimeout(() => {
|
|
167
|
+
req.emit('data', Buffer.from(JSON.stringify(body)))
|
|
168
|
+
req.emit('end')
|
|
169
|
+
}, 80)
|
|
170
|
+
return handler(req, res).then(() => res)
|
|
171
|
+
}
|
|
172
|
+
await post('/api/cron-board/jobs', {
|
|
173
|
+
name: 'idx-e2e', kind: 'shell',
|
|
174
|
+
command: `${process.execPath} -e "console.log('idx-marker')"`,
|
|
175
|
+
schedule: '* * * * *', enabled: true, nextRunAt: Date.now() - 100,
|
|
176
|
+
})
|
|
177
|
+
// When 触发一次 tick(等价 timer 到点回调)
|
|
178
|
+
assert.equal(intervals.length, 1)
|
|
179
|
+
intervals[0].fn()
|
|
180
|
+
// Then cron 触发记录产生且状态终态
|
|
181
|
+
const record = await waitForRecord(() => pollRuns(handler))
|
|
182
|
+
assert.equal(record.trigger, 'cron')
|
|
183
|
+
assert.equal(record.status, 'success')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
async function pollRuns(handler) {
|
|
187
|
+
const res = { status: null, payload: null }
|
|
188
|
+
res.writeHead = (status) => { res.status = status }
|
|
189
|
+
res.end = (text) => { res.payload = JSON.parse(text) }
|
|
190
|
+
const req = { method: 'GET', url: 'http://localhost/api/cron-board/runs' }
|
|
191
|
+
await handler(req, res)
|
|
192
|
+
return res.payload.items
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function waitForRecord(poll, timeoutMs = 10 * 1000) {
|
|
196
|
+
return new Promise((resolve, reject) => {
|
|
197
|
+
const started = Date.now()
|
|
198
|
+
const tick = () => {
|
|
199
|
+
void poll().then((items) => {
|
|
200
|
+
const row = items.find((item) => item.trigger === 'cron')
|
|
201
|
+
if (row && row.status !== 'queued' && row.status !== 'running') {
|
|
202
|
+
resolve(row)
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
if (Date.now() - started > timeoutMs) {
|
|
206
|
+
reject(new Error('waitForRecord 超时'))
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
setTimeout(tick, 50)
|
|
210
|
+
}).catch(reject)
|
|
211
|
+
}
|
|
212
|
+
tick()
|
|
213
|
+
})
|
|
214
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// logger 测试:日志文件按 logs/<jobId>/<runId>.log 落盘。
|
|
2
|
+
|
|
3
|
+
import { test } from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
import { createLogger } from '../src/logger.mjs'
|
|
10
|
+
|
|
11
|
+
async function makeLogger(t) {
|
|
12
|
+
const root = await mkdtemp(join(tmpdir(), 'cron-board-logs-'))
|
|
13
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
14
|
+
return { root, logger: createLogger({ rootDir: root }) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('logger:追加后按任务按次读回,任务间目录隔离', async (t) => {
|
|
18
|
+
// Given 两个任务各一次运行
|
|
19
|
+
const { logger } = await makeLogger(t)
|
|
20
|
+
// When 各写一行输出
|
|
21
|
+
await logger.append('j1', 'r1', 'hello from j1\n')
|
|
22
|
+
await logger.append('j2', 'r9', 'hello from j2\n')
|
|
23
|
+
// Then 各自读回互不串
|
|
24
|
+
assert.equal(await logger.read('j1', 'r1'), 'hello from j1\n')
|
|
25
|
+
assert.equal(await logger.read('j2', 'r9'), 'hello from j2\n')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('logger:tail 只读尾部 N 行', async (t) => {
|
|
29
|
+
// Given 一个 5 行日志
|
|
30
|
+
const { logger } = await makeLogger(t)
|
|
31
|
+
await logger.append('j1', 'r1', 'l1\nl2\nl3\nl4\nl5\n')
|
|
32
|
+
// When 读尾部 2 行
|
|
33
|
+
const text = await logger.read('j1', 'r1', { tailLines: 2 })
|
|
34
|
+
// Then 仅最后两行
|
|
35
|
+
assert.equal(text, 'l4\nl5\n')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('logger:不存在的日志读为空字符串', async (t) => {
|
|
39
|
+
// Given 无任何日志
|
|
40
|
+
const { logger } = await makeLogger(t)
|
|
41
|
+
// When 读
|
|
42
|
+
// Then 空串
|
|
43
|
+
assert.equal(await logger.read('none', 'none'), '')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('logger:清空单条日志后读回为空', async (t) => {
|
|
47
|
+
// Given 已有日志
|
|
48
|
+
const { logger } = await makeLogger(t)
|
|
49
|
+
await logger.append('j1', 'r1', 'content\n')
|
|
50
|
+
// When 清空
|
|
51
|
+
await logger.clear('j1', 'r1')
|
|
52
|
+
// Then 读回空
|
|
53
|
+
assert.equal(await logger.read('j1', 'r1'), '')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('logger:prune 按文件修改时间裁剪,字典序倒挂不误删(runId 为随机 UUID 与时间无关)', async (t) => {
|
|
57
|
+
// Given 三份日志按时间序写入,名字典序与写入序刻意相反;写入间隔保证 mtime 可分
|
|
58
|
+
const { logger } = await makeLogger(t)
|
|
59
|
+
await logger.append('j1', 'z-oldest', 'z-oldest\n')
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
61
|
+
await logger.append('j1', 'a-middle', 'a-middle\n')
|
|
62
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
63
|
+
await logger.append('j1', 'm-newest', 'm-newest\n')
|
|
64
|
+
// When 保留最新 2 份
|
|
65
|
+
await logger.prune('j1', 2)
|
|
66
|
+
// Then 最早写入的 z-oldest 被裁,名字典序最小的 a-middle 保留(旧字典序实现会反着删)
|
|
67
|
+
assert.equal(await logger.read('j1', 'z-oldest'), '')
|
|
68
|
+
assert.equal(await logger.read('j1', 'a-middle'), 'a-middle\n')
|
|
69
|
+
assert.equal(await logger.read('j1', 'm-newest'), 'm-newest\n')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('logger:超出保留数裁掉最旧日志文件', async (t) => {
|
|
73
|
+
// Given 同任务 3 份日志,保留上限 2
|
|
74
|
+
const { logger } = await makeLogger(t)
|
|
75
|
+
for (const runId of ['r1', 'r2', 'r3']) {
|
|
76
|
+
await logger.append('j1', runId, runId + '\n')
|
|
77
|
+
}
|
|
78
|
+
// When 写第 4 份触发裁剪
|
|
79
|
+
await logger.append('j1', 'r4', 'r4\n')
|
|
80
|
+
await logger.prune('j1', 2)
|
|
81
|
+
// Then 最旧两份消失,最新两份保留
|
|
82
|
+
assert.equal(await logger.read('j1', 'r1'), '')
|
|
83
|
+
assert.equal(await logger.read('j1', 'r2'), '')
|
|
84
|
+
assert.equal(await logger.read('j1', 'r3'), 'r3\n')
|
|
85
|
+
assert.equal(await logger.read('j1', 'r4'), 'r4\n')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('logger:删除任务连带删除整个日志目录', async (t) => {
|
|
89
|
+
// Given 两任务各一份日志
|
|
90
|
+
const { logger } = await makeLogger(t)
|
|
91
|
+
await logger.append('j1', 'r1', 'a\n')
|
|
92
|
+
await logger.append('j2', 'r1', 'b\n')
|
|
93
|
+
// When 删除 j1
|
|
94
|
+
await logger.removeJob('j1')
|
|
95
|
+
// Then j1 目录消失,j2 不受影响
|
|
96
|
+
assert.equal(await logger.read('j1', 'r1'), '')
|
|
97
|
+
assert.equal(await logger.read('j2', 'r1'), 'b\n')
|
|
98
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// parity 测试:client.js LOGIC 标记段与 src/status-meta.mjs 同源对照(turn-notify 模式)。
|
|
2
|
+
// 状态元数据双实现同源是规约要求:改一侧必须同步另一侧,测试锁定。
|
|
3
|
+
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
import { STATUS_META as coreStatusMeta, TRIGGER_META as coreTriggerMeta } from '../src/status-meta.mjs'
|
|
11
|
+
|
|
12
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
13
|
+
|
|
14
|
+
// 从 client.js 提取 LOGIC 标记段,构造纯逻辑实现
|
|
15
|
+
function clientLogic() {
|
|
16
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
17
|
+
const begin = source.indexOf('/* LOGIC-BEGIN */')
|
|
18
|
+
const end = source.indexOf('/* LOGIC-END */')
|
|
19
|
+
assert.ok(begin >= 0 && end > begin, 'client.js 缺少 LOGIC 标记段')
|
|
20
|
+
const section = source.slice(begin + '/* LOGIC-BEGIN */'.length, end)
|
|
21
|
+
const factory = new Function(
|
|
22
|
+
section + '; return { STATUS_META, TRIGGER_META, KIND_LABELS, MODE_LABELS, ONMISS_LABELS, relativeTime, formatDateTime, formatDuration, statusMeta };',
|
|
23
|
+
)
|
|
24
|
+
return factory()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const client = clientLogic()
|
|
28
|
+
const MINUTE = 60 * 1000
|
|
29
|
+
const HOUR = 60 * MINUTE
|
|
30
|
+
const NOW = 1750000000000
|
|
31
|
+
|
|
32
|
+
test('parity:STATUS_META 与 core 同源', () => {
|
|
33
|
+
assert.deepEqual(client.STATUS_META, coreStatusMeta)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('parity:TRIGGER_META 与 core 同源', () => {
|
|
37
|
+
assert.deepEqual(client.TRIGGER_META, coreTriggerMeta)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('parity:STATUS_META 覆盖全部落库状态', () => {
|
|
41
|
+
// store/scheduler/executor 可能落库的状态必须都有展示元数据
|
|
42
|
+
for (const status of ['success', 'fail', 'timeout', 'skipped', 'interrupted', 'running']) {
|
|
43
|
+
assert.ok(coreStatusMeta[status], '缺少状态元数据: ' + status)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('parity:relativeTime 未来/过去方向与粒度', () => {
|
|
48
|
+
assert.equal(client.relativeTime(NOW + 3 * HOUR, NOW), '3 小时后')
|
|
49
|
+
assert.equal(client.relativeTime(NOW - 5 * MINUTE, NOW), '5 分钟前')
|
|
50
|
+
assert.equal(client.relativeTime(NOW + 2 * 24 * HOUR, NOW), '2 天后')
|
|
51
|
+
assert.equal(client.relativeTime(undefined, NOW), '-')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('parity:formatDuration 分级呈现', () => {
|
|
55
|
+
assert.equal(client.formatDuration(500), '500ms')
|
|
56
|
+
assert.equal(client.formatDuration(1500), '1.5s')
|
|
57
|
+
assert.equal(client.formatDuration(90 * MINUTE), '90min')
|
|
58
|
+
assert.equal(client.formatDuration(undefined), '-')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('parity:statusMeta 未知状态回退 mute', () => {
|
|
62
|
+
assert.equal(client.statusMeta('whatever').tone, 'mute')
|
|
63
|
+
assert.equal(client.statusMeta(undefined).label, '-')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('parity:maskValue 同源——client LOGIC 段不存在独立实现(API 列表与 prompt 折叠共用 core)', () => {
|
|
67
|
+
// client.js 打码仅经服务端列表返回的已打码值呈现,LOGIC 段不得再出现本地 maskValue 实现
|
|
68
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
69
|
+
const begin = source.indexOf('/* LOGIC-BEGIN */')
|
|
70
|
+
const end = source.indexOf('/* LOGIC-END */')
|
|
71
|
+
assert.doesNotMatch(source.slice(begin, end), /maskValue/, 'LOGIC 段出现独立打码实现,违反双实现同源')
|
|
72
|
+
})
|