@marsken/dsh-studio 0.0.0-stage → 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/AGENTS.md +148 -0
- package/LICENSE +202 -0
- package/README.md +1356 -3
- package/client.js +952 -0
- package/client.src.js +2466 -0
- package/cordis.patch.yml +9 -0
- package/dist/client.js +952 -0
- package/docs/compatibility.md +99 -0
- package/docs/publish.md +86 -0
- package/docs/quickstart.md +53 -0
- package/icon.svg +6 -0
- package/index.js +2674 -0
- package/locale/en.json +11 -0
- package/locale/zh.json +11 -0
- package/package.json +63 -4
- package/scripts/smoke-store.mjs +76 -0
- package/src/host/cases.js +135 -0
- package/src/host/design.js +87 -0
- package/src/host/dispatcher.js +936 -0
- package/src/host/domain.js +1966 -0
- package/src/host/executor.js +1396 -0
- package/src/host/gitlab-facts.js +22 -0
- package/src/host/impact.js +32 -0
- package/src/host/integrations.js +587 -0
- package/src/host/quality.js +106 -0
- package/src/host/routes/admin.js +100 -0
- package/src/host/routes/agents.js +71 -0
- package/src/host/routes/auth.js +155 -0
- package/src/host/routes/coding.js +203 -0
- package/src/host/routes/connect.js +166 -0
- package/src/host/routes/exports.js +122 -0
- package/src/host/routes/insights.js +97 -0
- package/src/host/routes/releases.js +335 -0
- package/src/host/routes/requirements.js +430 -0
- package/src/host/routes/skills.js +75 -0
- package/src/host/routes/tasks.js +573 -0
- package/src/host/store.js +522 -0
- package/src/host/team-memory.js +46 -0
- package/src/host/tools.js +1211 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { applyFidelityItem, applySpecItem, sanitizeDesign } from '../design.js'
|
|
3
|
+
import { collectCases, groupCaseKeys, loadSuites, resolveSuiteKeys, sanitizeSuite, saveSuites, summarizeRun } from '../cases.js'
|
|
4
|
+
import { clearTeamMemory, loadTeamMemory } from '../team-memory.js'
|
|
5
|
+
import {
|
|
6
|
+
CASE_HTTP_METHODS,
|
|
7
|
+
CASE_PRIORITIES,
|
|
8
|
+
CASE_TYPES,
|
|
9
|
+
ROLE_IDS,
|
|
10
|
+
STATUSES,
|
|
11
|
+
STATUS_SET,
|
|
12
|
+
CASE_RESULTS,
|
|
13
|
+
cleanId,
|
|
14
|
+
defaultColumns,
|
|
15
|
+
sanitizeArtifact,
|
|
16
|
+
sanitizeCaseHttp,
|
|
17
|
+
sanitizeCases,
|
|
18
|
+
text,
|
|
19
|
+
} from '../domain.js'
|
|
20
|
+
import { runSuite } from '../executor.js'
|
|
21
|
+
|
|
22
|
+
const INJECTED = ['sendJson', 'readJsonBody', 'createTask', 'updateTask', 'commentTask', 'runTaskCases', 'syncArtifact', 'getDispatcher', 'applyConfig', 'autoBugFromCase', 'publicState', 'upsertById']
|
|
23
|
+
|
|
24
|
+
let host = {
|
|
25
|
+
sendJson: () => {},
|
|
26
|
+
readJsonBody: async () => ({}),
|
|
27
|
+
createTask: () => ({}),
|
|
28
|
+
updateTask: () => ({}),
|
|
29
|
+
commentTask: () => ({}),
|
|
30
|
+
runTaskCases: async () => ({}),
|
|
31
|
+
syncArtifact: async () => ({}),
|
|
32
|
+
getDispatcher: () => undefined,
|
|
33
|
+
applyConfig: async () => {},
|
|
34
|
+
autoBugFromCase: () => null,
|
|
35
|
+
publicState: () => ({}),
|
|
36
|
+
upsertById: () => [],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 卡片(任务 / 专家 / 列)相关路由。宿主能力由 configureTaskRoutes() 注入。 */
|
|
40
|
+
export function configureTaskRoutes(services) {
|
|
41
|
+
host = { ...host, ...(services ?? {}) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 跑一批用例键:先探环境,再按卡分组一轮一轮跑,最后记一笔「最近回归」。
|
|
46
|
+
* /cases/run 与 /suite/run 共用这段——两处逻辑必须完全一致,否则套件和历史记录会对不上。
|
|
47
|
+
*/
|
|
48
|
+
async function runCaseKeys(store, keys, options = {}) {
|
|
49
|
+
const grouped = groupCaseKeys(keys)
|
|
50
|
+
if (grouped.size === 0) throw new Error('没有可跑的用例')
|
|
51
|
+
const snapshot = store.snapshot()
|
|
52
|
+
const testing = snapshot.config.testing ?? {}
|
|
53
|
+
const envs = Array.isArray(testing.envs) ? testing.envs : []
|
|
54
|
+
const env = envs.find(item => item.id === String(options.env ?? testing.defaultEnv ?? '')) ?? envs[0]
|
|
55
|
+
if (env !== undefined) {
|
|
56
|
+
const probe = await host.probeEnv(env, snapshot.config)
|
|
57
|
+
if (probe.ok !== true) throw new Error(`测试环境「${env.name || env.id}」不可达(${probe.error}):一条用例都没跑`)
|
|
58
|
+
}
|
|
59
|
+
const by = String(options.by ?? '回归').slice(0, 40)
|
|
60
|
+
const results = []
|
|
61
|
+
for (const [taskId, caseIds] of grouped) {
|
|
62
|
+
try {
|
|
63
|
+
const out = await host.runTaskCases(store, taskId, { caseIds, env: options.env, by })
|
|
64
|
+
const card = store.snapshot().tasks.find(entry => entry.id === taskId)
|
|
65
|
+
results.push({
|
|
66
|
+
taskId,
|
|
67
|
+
taskCode: card?.code ?? '',
|
|
68
|
+
taskTitle: card?.title ?? '',
|
|
69
|
+
ok: true,
|
|
70
|
+
total: out.run.total,
|
|
71
|
+
passed: out.run.passed,
|
|
72
|
+
failed: out.run.failed,
|
|
73
|
+
bugs: (out.bugs ?? []).filter(item => item.created === true).length,
|
|
74
|
+
})
|
|
75
|
+
} catch (error) {
|
|
76
|
+
results.push({ taskId, ok: false, error: String(error?.message ?? error).slice(0, 200) })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const record = summarizeRun(results, by)
|
|
80
|
+
try {
|
|
81
|
+
const previous = await store.readSide('regressions')
|
|
82
|
+
const list = Array.isArray(previous) ? previous : []
|
|
83
|
+
await store.writeSide('regressions', [record, ...list].slice(0, 20))
|
|
84
|
+
} catch {
|
|
85
|
+
/* 记不下来不影响这次跑的结果 */
|
|
86
|
+
}
|
|
87
|
+
return { results, regression: record }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function handleTaskRoute({ method, route, req, res, store, url }) {
|
|
91
|
+
if (method === 'POST' && route === '/task/create') {
|
|
92
|
+
const task = await host.createTask(store, await host.readJsonBody(req))
|
|
93
|
+
host.sendJson(res, 200, { ok: true, task, state: host.publicState(store) })
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
if (method === 'POST' && route === '/task/design') {
|
|
97
|
+
// 设计评审:链接 / 状态 / 意见。不走通用 patch,避免被白名单挡掉。
|
|
98
|
+
const body = await host.readJsonBody(req)
|
|
99
|
+
const id = String(body.id ?? '')
|
|
100
|
+
const by = String(body.by ?? '人').slice(0, 40)
|
|
101
|
+
const design = sanitizeDesign({ ...(body.design ?? {}), by, at: Date.now() })
|
|
102
|
+
const saved = await store.mutate(state => {
|
|
103
|
+
const task = state.tasks.find(entry => entry.id === id)
|
|
104
|
+
if (task === undefined) throw new Error(`no task ${id}`)
|
|
105
|
+
task.design = design
|
|
106
|
+
// 还原度顺手写进验收清单:验收时不用再回抽屉里找数字。
|
|
107
|
+
if (design !== null && design.diff !== null) task.checklist = applyFidelityItem(task.checklist, design.diff.percent)
|
|
108
|
+
if (design !== null && design.spec !== null) task.checklist = applySpecItem(task.checklist, design.spec)
|
|
109
|
+
task.updatedAt = Date.now()
|
|
110
|
+
task.history.push({ at: task.updatedAt, by, status: task.status, note: `设计评审:${design.status}` })
|
|
111
|
+
})
|
|
112
|
+
host.sendJson(res, 200, { ok: true, design, state: host.publicState(store) })
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
if (method === 'POST' && route === '/task/update') {
|
|
116
|
+
const body = await host.readJsonBody(req)
|
|
117
|
+
const task = await host.updateTask(store, String(body.id ?? ''), body.patch ?? body)
|
|
118
|
+
host.sendJson(res, 200, { ok: true, task, state: host.publicState(store) })
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
if (method === 'POST' && route === '/task/block') {
|
|
122
|
+
const body = await host.readJsonBody(req)
|
|
123
|
+
const id = String(body.id ?? '')
|
|
124
|
+
const reason = text(body.reason, 500) ?? ''
|
|
125
|
+
if (reason === '') {
|
|
126
|
+
host.sendJson(res, 400, { ok: false, error: '请写清楚卡在哪了,方便之后判断怎么继续' })
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
const task = await host.updateTask(store, id, {
|
|
130
|
+
blocked: true,
|
|
131
|
+
blockedReason: reason,
|
|
132
|
+
by: text(body.by, 60) ?? '人',
|
|
133
|
+
})
|
|
134
|
+
// 卡住就停手:把这个专家正在跑的会话收掉,避免它继续往前走。
|
|
135
|
+
host.getDispatcher()?.abortTask?.(id)
|
|
136
|
+
host.sendJson(res, 200, { ok: true, task, state: host.publicState(store) })
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
if (method === 'POST' && route === '/task/unblock') {
|
|
140
|
+
const body = await host.readJsonBody(req)
|
|
141
|
+
const id = String(body.id ?? '')
|
|
142
|
+
const by = text(body.by, 60) ?? '人'
|
|
143
|
+
const action = String(body.action ?? 'resume')
|
|
144
|
+
let task = await host.updateTask(store, id, { blocked: false, by })
|
|
145
|
+
if (action === 'advance') {
|
|
146
|
+
const index = STATUSES.findIndex(entry => entry.id === task.status)
|
|
147
|
+
const next = STATUSES[Math.min(index + 1, STATUSES.length - 1)]
|
|
148
|
+
task = await host.updateTask(store, id, { status: next.id, by })
|
|
149
|
+
}
|
|
150
|
+
const dispatcher = host.getDispatcher()
|
|
151
|
+
const ran = []
|
|
152
|
+
if (dispatcher !== undefined && action !== 'clear') {
|
|
153
|
+
try {
|
|
154
|
+
await dispatcher.launchNow(id, action === 'assign' ? String(body.expert ?? '') : undefined)
|
|
155
|
+
ran.push(action === 'assign' ? `expert:${String(body.expert ?? '')}` : 'column-owner')
|
|
156
|
+
} catch (error) {
|
|
157
|
+
ran.push(`failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
host.sendJson(res, 200, { ok: true, task, ran, state: host.publicState(store) })
|
|
161
|
+
return true
|
|
162
|
+
}
|
|
163
|
+
if (method === 'POST' && route === '/task/comment') {
|
|
164
|
+
const body = await host.readJsonBody(req)
|
|
165
|
+
const { task, mentions } = await host.commentTask(store, String(body.id ?? ''), body.body, body.author, body.kind)
|
|
166
|
+
const dispatcher = host.getDispatcher()
|
|
167
|
+
const dispatched = []
|
|
168
|
+
if (dispatcher !== undefined) {
|
|
169
|
+
for (const mention of mentions) {
|
|
170
|
+
try {
|
|
171
|
+
await dispatcher.dispatchMention(task.id, mention, String(body.body ?? ''))
|
|
172
|
+
dispatched.push(`${mention.kind}:${mention.id}`)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
dispatched.push(`${mention.kind}:${mention.id} failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
host.sendJson(res, 200, { ok: true, task, mentions, dispatched, state: host.publicState(store) })
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
if (method === 'POST' && route === '/mention') {
|
|
182
|
+
const dispatcher = host.getDispatcher()
|
|
183
|
+
if (dispatcher === undefined) throw new Error('dispatcher not available yet')
|
|
184
|
+
const body = await host.readJsonBody(req)
|
|
185
|
+
const mention = { kind: body.kind === 'team' ? 'team' : 'expert', id: String(body.id ?? '') }
|
|
186
|
+
await dispatcher.dispatchMention(String(body.taskId ?? ''), mention, String(body.instruction ?? ''))
|
|
187
|
+
host.sendJson(res, 200, { ok: true, state: host.publicState(store), agents: dispatcher.status() })
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
if (method === 'POST' && route === '/experts/save') {
|
|
191
|
+
const body = await host.readJsonBody(req)
|
|
192
|
+
await host.applyConfig(store, { experts: host.upsertById(store.snapshot().config.experts, body.expert ?? body) })
|
|
193
|
+
// 把保存后的集合一起返回:脚本 / 前端不用再猜刚存的是哪一条。
|
|
194
|
+
host.sendJson(res, 200, { ok: true, experts: store.snapshot().config.experts, state: host.publicState(store) })
|
|
195
|
+
return true
|
|
196
|
+
}
|
|
197
|
+
if (method === 'POST' && route === '/experts/delete') {
|
|
198
|
+
const body = await host.readJsonBody(req)
|
|
199
|
+
const id = cleanId(body.id, 'expert')
|
|
200
|
+
await host.applyConfig(store, { experts: store.snapshot().config.experts.filter(expert => expert.id !== id) })
|
|
201
|
+
host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
if (method === 'POST' && route === '/teams/save') {
|
|
205
|
+
const body = await host.readJsonBody(req)
|
|
206
|
+
await host.applyConfig(store, { teams: host.upsertById(store.snapshot().config.teams, body.team ?? body) })
|
|
207
|
+
host.sendJson(res, 200, { ok: true, teams: store.snapshot().config.teams, state: host.publicState(store) })
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
if (method === 'POST' && route === '/teams/delete') {
|
|
211
|
+
const body = await host.readJsonBody(req)
|
|
212
|
+
const id = cleanId(body.id, 'team')
|
|
213
|
+
await host.applyConfig(store, { teams: store.snapshot().config.teams.filter(team => team.id !== id) })
|
|
214
|
+
host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
|
|
215
|
+
return true
|
|
216
|
+
}
|
|
217
|
+
if (method === 'POST' && route === '/columns/wip') {
|
|
218
|
+
const body = await host.readJsonBody(req)
|
|
219
|
+
const status = String(body.status ?? '')
|
|
220
|
+
if (!STATUS_SET.has(status)) throw new Error(`unknown status ${status}`)
|
|
221
|
+
const columns = { ...store.snapshot().config.columns }
|
|
222
|
+
const wip = Math.min(50, Math.max(0, Math.trunc(Number(body.wip) || 0)))
|
|
223
|
+
columns[status] = { ...(columns[status] ?? { kind: 'none', id: '' }), wip }
|
|
224
|
+
await host.applyConfig(store, { columns })
|
|
225
|
+
host.sendJson(res, 200, { ok: true, columns: store.snapshot().config.columns, state: host.publicState(store) })
|
|
226
|
+
return true
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (method === 'POST' && route === '/columns/save') {
|
|
230
|
+
const body = await host.readJsonBody(req)
|
|
231
|
+
const status = String(body.status ?? '')
|
|
232
|
+
if (!STATUS_SET.has(status)) throw new Error(`unknown status ${status}`)
|
|
233
|
+
const columns = { ...store.snapshot().config.columns }
|
|
234
|
+
if (Array.isArray(body.members)) {
|
|
235
|
+
columns[status] = body.members.length === 0
|
|
236
|
+
? { kind: 'none', id: '' }
|
|
237
|
+
: {
|
|
238
|
+
kind: 'pool',
|
|
239
|
+
id: '',
|
|
240
|
+
members: body.members.map(entry => ({ kind: entry?.kind === 'team' ? 'team' : entry?.kind === 'role' ? 'role' : 'expert', id: String(entry?.id ?? '') })),
|
|
241
|
+
strategy: body.strategy,
|
|
242
|
+
}
|
|
243
|
+
} else if (body.owner === null) {
|
|
244
|
+
columns[status] = defaultColumns()[status]
|
|
245
|
+
} else {
|
|
246
|
+
const kind = body.kind === 'team' ? 'team' : body.kind === 'none' ? 'none' : 'expert'
|
|
247
|
+
columns[status] = { kind, id: String(body.id ?? '') }
|
|
248
|
+
}
|
|
249
|
+
await host.applyConfig(store, { columns })
|
|
250
|
+
host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
|
|
251
|
+
return true
|
|
252
|
+
}
|
|
253
|
+
if (method === 'POST' && route === '/task/delete') {
|
|
254
|
+
const body = await host.readJsonBody(req)
|
|
255
|
+
const id = String(body.id ?? '')
|
|
256
|
+
await store.mutate(state => {
|
|
257
|
+
state.tasks = state.tasks.filter(t => t.id !== id)
|
|
258
|
+
})
|
|
259
|
+
// 卡没了,替它跑着的会话也要收摊,否则槽位一直被占着。
|
|
260
|
+
host.getDispatcher()?.abortTask?.(id)
|
|
261
|
+
host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
|
|
262
|
+
return true
|
|
263
|
+
}
|
|
264
|
+
if (method === 'POST' && route === '/cases/run') {
|
|
265
|
+
// 跨卡回归:勾中的用例按卡分组,一张卡一轮(执行器本来就是按卡跑的),再汇总。
|
|
266
|
+
const body = await host.readJsonBody(req)
|
|
267
|
+
const keys = (Array.isArray(body.keys) ? body.keys : []).map(String).filter(key => key.length > 0).slice(0, 60)
|
|
268
|
+
if (keys.length === 0) throw new Error('先勾几条用例再跑')
|
|
269
|
+
const out = await runCaseKeys(store, keys, { env: body.env, by: String(body.by ?? '回归') })
|
|
270
|
+
host.sendJson(res, 200, { ok: true, ...out, state: host.publicState(store) })
|
|
271
|
+
return true
|
|
272
|
+
}
|
|
273
|
+
if (method === 'POST' && route === '/suite/list') {
|
|
274
|
+
const suites = await loadSuites(store)
|
|
275
|
+
const cases = collectCases(store.snapshot())
|
|
276
|
+
host.sendJson(res, 200, {
|
|
277
|
+
ok: true,
|
|
278
|
+
suites,
|
|
279
|
+
counts: suites.map(suite => ({ id: suite.id, count: resolveSuiteKeys(suite, cases).length, keys: resolveSuiteKeys(suite, cases) })),
|
|
280
|
+
})
|
|
281
|
+
return true
|
|
282
|
+
}
|
|
283
|
+
if (method === 'POST' && route === '/suite/save') {
|
|
284
|
+
const body = await host.readJsonBody(req)
|
|
285
|
+
const list = await loadSuites(store)
|
|
286
|
+
const incoming = sanitizeSuite({ ...(body.suite ?? body), at: Date.now(), by: String(body.by ?? '人').slice(0, 40) })
|
|
287
|
+
const index = list.findIndex(item => item.id === incoming.id)
|
|
288
|
+
if (index >= 0) list[index] = incoming
|
|
289
|
+
else list.push(incoming)
|
|
290
|
+
const saved = await saveSuites(store, list)
|
|
291
|
+
const cases = collectCases(store.snapshot())
|
|
292
|
+
host.sendJson(res, 200, { ok: true, suites: saved, counts: saved.map(suite => ({ id: suite.id, count: resolveSuiteKeys(suite, cases).length })) })
|
|
293
|
+
return true
|
|
294
|
+
}
|
|
295
|
+
if (method === 'POST' && route === '/suite/delete') {
|
|
296
|
+
const body = await host.readJsonBody(req)
|
|
297
|
+
const list = await loadSuites(store)
|
|
298
|
+
const next = list.filter(item => item.id !== String(body.id ?? ''))
|
|
299
|
+
host.sendJson(res, 200, { ok: true, suites: await saveSuites(store, next) })
|
|
300
|
+
return true
|
|
301
|
+
}
|
|
302
|
+
if (method === 'POST' && route === '/suite/run') {
|
|
303
|
+
const body = await host.readJsonBody(req)
|
|
304
|
+
const suites = await loadSuites(store)
|
|
305
|
+
const suite = suites.find(item => item.id === String(body.id ?? ''))
|
|
306
|
+
if (suite === undefined) throw new Error('套件不存在')
|
|
307
|
+
const keys = resolveSuiteKeys(suite, collectCases(store.snapshot())).slice(0, 60)
|
|
308
|
+
if (keys.length === 0) throw new Error(`套件「${suite.name}」里没有可跑的用例`)
|
|
309
|
+
const out = await runCaseKeys(store, keys, { env: body.env, by: `套件:${suite.name}`.slice(0, 40) })
|
|
310
|
+
host.sendJson(res, 200, { ok: true, ...out, suite: { id: suite.id, name: suite.name }, state: host.publicState(store) })
|
|
311
|
+
return true
|
|
312
|
+
}
|
|
313
|
+
if (method === 'POST' && route === '/team/memory') {
|
|
314
|
+
// 团级记忆是 side 键,单独取;纯读。
|
|
315
|
+
host.sendJson(res, 200, { ok: true, memory: await loadTeamMemory(store) })
|
|
316
|
+
return true
|
|
317
|
+
}
|
|
318
|
+
if (method === 'POST' && route === '/team/memory/clear') {
|
|
319
|
+
const body = await host.readJsonBody(req)
|
|
320
|
+
await clearTeamMemory(store, String(body.teamId ?? ''))
|
|
321
|
+
host.sendJson(res, 200, { ok: true, memory: await loadTeamMemory(store) })
|
|
322
|
+
return true
|
|
323
|
+
}
|
|
324
|
+
if (method === 'POST' && route === '/cases') {
|
|
325
|
+
// 跨卡用例总览:从台账现算,不落第二份数据。纯读。
|
|
326
|
+
const regressions = await store.readSide('regressions').catch(() => null)
|
|
327
|
+
const cases = collectCases(store.snapshot())
|
|
328
|
+
const suites = await loadSuites(store)
|
|
329
|
+
host.sendJson(res, 200, {
|
|
330
|
+
ok: true,
|
|
331
|
+
cases,
|
|
332
|
+
regressions: Array.isArray(regressions) ? regressions : [],
|
|
333
|
+
suites,
|
|
334
|
+
counts: suites.map(suite => ({ id: suite.id, count: resolveSuiteKeys(suite, cases).length, keys: resolveSuiteKeys(suite, cases) })),
|
|
335
|
+
})
|
|
336
|
+
return true
|
|
337
|
+
}
|
|
338
|
+
if (method === 'POST' && route === '/task/parts') {
|
|
339
|
+
// 用例与清单正文不随 /state 下发,抽屉打开时按卡取一次;纯读。
|
|
340
|
+
const body = await host.readJsonBody(req)
|
|
341
|
+
const id = String(body.id ?? '')
|
|
342
|
+
const task = store.snapshot().tasks.find(t => t.id === id)
|
|
343
|
+
if (task === undefined) throw new Error(`no task ${id}`)
|
|
344
|
+
host.sendJson(res, 200, { ok: true, cases: task.cases ?? [], checklist: task.checklist ?? [] })
|
|
345
|
+
return true
|
|
346
|
+
}
|
|
347
|
+
if (method === 'POST' && route === '/task/comments') {
|
|
348
|
+
// 评论正文不随 /state 下发,抽屉打开时按卡取一次;纯读,不改台账。
|
|
349
|
+
const body = await host.readJsonBody(req)
|
|
350
|
+
const id = String(body.id ?? '')
|
|
351
|
+
const task = store.snapshot().tasks.find(t => t.id === id)
|
|
352
|
+
if (task === undefined) throw new Error(`no task ${id}`)
|
|
353
|
+
host.sendJson(res, 200, { ok: true, comments: task.comments ?? [] })
|
|
354
|
+
return true
|
|
355
|
+
}
|
|
356
|
+
if (method === 'POST' && route === '/task/artifact') {
|
|
357
|
+
const body = await host.readJsonBody(req)
|
|
358
|
+
const id = String(body.id ?? '')
|
|
359
|
+
const action = String(body.action ?? 'add')
|
|
360
|
+
// 只读取正文,不动台账:轮询已经把 content 清空,抽屉展开某条交付物时才来这里取。
|
|
361
|
+
if (action === 'get') {
|
|
362
|
+
const task = store.snapshot().tasks.find(t => t.id === id)
|
|
363
|
+
if (task === undefined) throw new Error(`no task ${id}`)
|
|
364
|
+
const artifact = (task.artifacts ?? []).find(item => item.id === String(body.artifactId ?? ''))
|
|
365
|
+
if (artifact === undefined) throw new Error('找不到这条交付物')
|
|
366
|
+
host.sendJson(res, 200, { ok: true, artifact })
|
|
367
|
+
return true
|
|
368
|
+
}
|
|
369
|
+
const snapshot = await store.mutate(state => {
|
|
370
|
+
const task = state.tasks.find(t => t.id === id)
|
|
371
|
+
if (task === undefined) throw new Error(`no task ${id}`)
|
|
372
|
+
if (action === 'add') {
|
|
373
|
+
const created = sanitizeArtifact(body.artifact ?? body, task.artifacts.length)
|
|
374
|
+
if (created === undefined) throw new Error('交付物至少要有标题')
|
|
375
|
+
created.by = String(body.by ?? created.by ?? '')
|
|
376
|
+
created.at = Date.now()
|
|
377
|
+
task.artifacts = [...task.artifacts, created].slice(0, 40)
|
|
378
|
+
} else if (action === 'remove') {
|
|
379
|
+
task.artifacts = task.artifacts.filter(item => item.id !== String(body.artifactId ?? ''))
|
|
380
|
+
} else if (action === 'update') {
|
|
381
|
+
task.artifacts = task.artifacts.map(item => {
|
|
382
|
+
if (item.id !== String(body.artifactId ?? '')) return item
|
|
383
|
+
const merged = sanitizeArtifact({ ...item, ...(body.patch ?? body.artifact ?? {}) }, 0) ?? item
|
|
384
|
+
merged.id = item.id
|
|
385
|
+
return merged
|
|
386
|
+
})
|
|
387
|
+
} else {
|
|
388
|
+
throw new Error(`unknown action ${action}`)
|
|
389
|
+
}
|
|
390
|
+
task.updatedAt = Date.now()
|
|
391
|
+
return task
|
|
392
|
+
})
|
|
393
|
+
const task = snapshot.tasks.find(entry => entry.id === id)
|
|
394
|
+
host.sendJson(res, 200, { ok: true, task, state: host.publicState(store) })
|
|
395
|
+
return true
|
|
396
|
+
}
|
|
397
|
+
if (method === 'POST' && route === '/task/artifact/sync') {
|
|
398
|
+
const body = await host.readJsonBody(req)
|
|
399
|
+
const id = String(body.id ?? '')
|
|
400
|
+
const artifactId = String(body.artifactId ?? '')
|
|
401
|
+
const task = store.snapshot().tasks.find(entry => entry.id === id)
|
|
402
|
+
if (task === undefined) throw new Error(`no task ${id}`)
|
|
403
|
+
const targets = (task.artifacts ?? []).filter(item => artifactId === '' || item.id === artifactId)
|
|
404
|
+
if (targets.length === 0) throw new Error('这张卡还没有可归档的交付物')
|
|
405
|
+
const written = []
|
|
406
|
+
const errors = []
|
|
407
|
+
for (const artifact of targets) {
|
|
408
|
+
try {
|
|
409
|
+
written.push({ id: artifact.id, doc: await host.syncArtifact(store, id, artifact.id) })
|
|
410
|
+
} catch (error) {
|
|
411
|
+
errors.push({ id: artifact.id, title: artifact.title, error: String(error?.message ?? error).slice(0, 200) })
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const fresh = store.snapshot().tasks.find(entry => entry.id === id)
|
|
415
|
+
host.sendJson(res, 200, { ok: errors.length === 0, task: fresh, written, errors, state: host.publicState(store) })
|
|
416
|
+
return true
|
|
417
|
+
}
|
|
418
|
+
if (method === 'POST' && route === '/task/checklist') {
|
|
419
|
+
const body = await host.readJsonBody(req)
|
|
420
|
+
const id = String(body.id ?? '')
|
|
421
|
+
const action = String(body.action ?? 'toggle')
|
|
422
|
+
const by = String(body.by ?? '人').slice(0, 40)
|
|
423
|
+
const snapshot = await store.mutate(state => {
|
|
424
|
+
const task = state.tasks.find(t => t.id === id)
|
|
425
|
+
if (task === undefined) throw new Error(`no task ${id}`)
|
|
426
|
+
if (action === 'add') {
|
|
427
|
+
const items = (Array.isArray(body.items) ? body.items : [body.text])
|
|
428
|
+
.map(item => String(item ?? '').trim().slice(0, 200))
|
|
429
|
+
.filter(item => item.length > 0)
|
|
430
|
+
for (const text of items) {
|
|
431
|
+
task.checklist.push({ id: `c${task.checklist.length + 1}-${randomBytes(2).toString('hex')}`, text, done: false, by: '', at: 0, note: '' })
|
|
432
|
+
}
|
|
433
|
+
task.checklist = task.checklist.slice(0, 30)
|
|
434
|
+
} else if (action === 'remove') {
|
|
435
|
+
task.checklist = task.checklist.filter(item => item.id !== String(body.itemId ?? ''))
|
|
436
|
+
} else {
|
|
437
|
+
const itemId = String(body.itemId ?? '')
|
|
438
|
+
task.checklist = task.checklist.map(item => {
|
|
439
|
+
if (item.id !== itemId) return item
|
|
440
|
+
const done = typeof body.done === 'boolean' ? body.done : !item.done
|
|
441
|
+
return { ...item, done, by: done ? by : '', at: done ? Date.now() : 0, note: String(body.note ?? item.note).slice(0, 400) }
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
task.updatedAt = Date.now()
|
|
445
|
+
return task
|
|
446
|
+
})
|
|
447
|
+
const task = snapshot.tasks.find(entry => entry.id === id)
|
|
448
|
+
host.sendJson(res, 200, { ok: true, task, state: host.publicState(store) })
|
|
449
|
+
return true
|
|
450
|
+
}
|
|
451
|
+
if (method === 'POST' && route === '/task/case') {
|
|
452
|
+
const body = await host.readJsonBody(req)
|
|
453
|
+
const id = String(body.id ?? '')
|
|
454
|
+
const action = String(body.action ?? 'add')
|
|
455
|
+
const by = String(body.by ?? '人').slice(0, 40)
|
|
456
|
+
const snapshot = await store.mutate(state => {
|
|
457
|
+
const task = state.tasks.find(t => t.id === id)
|
|
458
|
+
if (task === undefined) throw new Error(`no task ${id}`)
|
|
459
|
+
const list = Array.isArray(task.cases) ? task.cases : []
|
|
460
|
+
if (action === 'add') {
|
|
461
|
+
const raw = Array.isArray(body.cases) ? body.cases : [body.case ?? body]
|
|
462
|
+
const added = sanitizeCases(raw.map(item => ({
|
|
463
|
+
...item,
|
|
464
|
+
http: item.http ?? (item.url !== undefined && String(item.url).trim().length > 0
|
|
465
|
+
? { method: item.method, url: item.url, body: item.body, expectStatus: item.expectStatus, expectContains: item.expectContains }
|
|
466
|
+
: undefined),
|
|
467
|
+
by,
|
|
468
|
+
at: Date.now(),
|
|
469
|
+
})))
|
|
470
|
+
for (const item of added) {
|
|
471
|
+
if (list.length >= 60) break
|
|
472
|
+
list.push({ ...item, id: `c${list.length + 1}-${randomBytes(2).toString('hex')}` })
|
|
473
|
+
}
|
|
474
|
+
} else if (action === 'remove') {
|
|
475
|
+
task.cases = list.filter(item => item.id !== String(body.caseId ?? ''))
|
|
476
|
+
task.updatedAt = Date.now()
|
|
477
|
+
return task
|
|
478
|
+
} else {
|
|
479
|
+
const caseId = String(body.caseId ?? '')
|
|
480
|
+
task.cases = list.map(item => {
|
|
481
|
+
if (item.id !== caseId) return item
|
|
482
|
+
const merged = { ...item }
|
|
483
|
+
if (body.result !== undefined && CASE_RESULTS.includes(String(body.result))) merged.result = String(body.result)
|
|
484
|
+
if (body.title !== undefined) merged.title = String(body.title).trim().slice(0, 200) || item.title
|
|
485
|
+
if (body.expected !== undefined) merged.expected = String(body.expected).trim().slice(0, 300)
|
|
486
|
+
if (body.note !== undefined) merged.note = String(body.note).trim().slice(0, 400)
|
|
487
|
+
if (body.http !== undefined) merged.http = sanitizeCaseHttp(body.http)
|
|
488
|
+
if (body.pointId !== undefined) merged.pointId = String(body.pointId).trim().slice(0, 24)
|
|
489
|
+
if (body.flaky !== undefined) merged.flaky = body.flaky === true
|
|
490
|
+
if (body.type !== undefined && CASE_TYPES.includes(String(body.type))) merged.type = String(body.type)
|
|
491
|
+
if (body.priority !== undefined && CASE_PRIORITIES.includes(String(body.priority).toLowerCase())) merged.priority = String(body.priority).toLowerCase()
|
|
492
|
+
if (body.preconditions !== undefined) merged.preconditions = String(body.preconditions).slice(0, 800)
|
|
493
|
+
if (body.steps !== undefined) {
|
|
494
|
+
merged.steps = (Array.isArray(body.steps) ? body.steps : [])
|
|
495
|
+
.map(item => String(item ?? '').trim().slice(0, 200))
|
|
496
|
+
.filter(item => item !== '')
|
|
497
|
+
.slice(0, 20)
|
|
498
|
+
}
|
|
499
|
+
merged.by = by
|
|
500
|
+
merged.at = Date.now()
|
|
501
|
+
return merged
|
|
502
|
+
})
|
|
503
|
+
task.updatedAt = Date.now()
|
|
504
|
+
return task
|
|
505
|
+
}
|
|
506
|
+
task.cases = list.slice(0, 60)
|
|
507
|
+
task.updatedAt = Date.now()
|
|
508
|
+
return task
|
|
509
|
+
})
|
|
510
|
+
const task = snapshot.tasks.find(entry => entry.id === id)
|
|
511
|
+
// 用例判失败就自动开缺陷:Bug 不再是人工另建的一种「任务类型」。
|
|
512
|
+
let autoBug = null
|
|
513
|
+
let autoBugError = ''
|
|
514
|
+
let autoBugSkipped = ''
|
|
515
|
+
if (String(body.result ?? '') === 'fail' && task !== undefined) {
|
|
516
|
+
const failed = (Array.isArray(task.cases) ? task.cases : []).find(item => item.id === String(body.caseId ?? ''))
|
|
517
|
+
if (failed !== undefined && failed.flaky !== true) {
|
|
518
|
+
try {
|
|
519
|
+
const outcome = await host.autoBugFromCase(store, task, failed, by)
|
|
520
|
+
autoBug = outcome.bug
|
|
521
|
+
autoBugSkipped = String(outcome.skipped ?? '')
|
|
522
|
+
} catch (error) {
|
|
523
|
+
autoBugError = String(error?.message ?? error)
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const latest = store.snapshot()
|
|
528
|
+
host.sendJson(res, 200, {
|
|
529
|
+
ok: true,
|
|
530
|
+
task: latest.tasks.find(entry => entry.id === id),
|
|
531
|
+
bug: autoBug,
|
|
532
|
+
error: autoBugError,
|
|
533
|
+
skipped: autoBugSkipped,
|
|
534
|
+
state: host.publicState(store),
|
|
535
|
+
})
|
|
536
|
+
return true
|
|
537
|
+
}
|
|
538
|
+
if (method === 'POST' && route === '/task/case/run') {
|
|
539
|
+
const body = await host.readJsonBody(req)
|
|
540
|
+
const id = String(body.id ?? '')
|
|
541
|
+
if (body.diag === true) {
|
|
542
|
+
host.sendJson(res, 200, {
|
|
543
|
+
ok: true,
|
|
544
|
+
storeType: typeof store,
|
|
545
|
+
hasSnapshot: typeof store?.snapshot,
|
|
546
|
+
hasMutate: typeof store?.mutate,
|
|
547
|
+
keys: Object.keys(store ?? {}).slice(0, 16),
|
|
548
|
+
runType: typeof host.runTaskCases,
|
|
549
|
+
})
|
|
550
|
+
return true
|
|
551
|
+
}
|
|
552
|
+
const caseIds = body.caseId !== undefined && String(body.caseId).length > 0 ? [String(body.caseId)] : body.caseIds
|
|
553
|
+
const result = await host.runTaskCases(store, id, {
|
|
554
|
+
caseIds: Array.isArray(caseIds) ? caseIds : undefined,
|
|
555
|
+
env: body.env,
|
|
556
|
+
by: String(body.by ?? '测试').slice(0, 40),
|
|
557
|
+
})
|
|
558
|
+
host.sendJson(res, 200, { ok: true, run: result.run, env: result.env, bugs: result.bugs, state: result.state })
|
|
559
|
+
return true
|
|
560
|
+
}
|
|
561
|
+
if (method === 'POST' && route === '/tests/run') {
|
|
562
|
+
const body = await host.readJsonBody(req)
|
|
563
|
+
const result = await runSuite(store, {
|
|
564
|
+
taskIds: Array.isArray(body.taskIds) ? body.taskIds : undefined,
|
|
565
|
+
env: body.env,
|
|
566
|
+
by: String(body.by ?? '测试').slice(0, 40),
|
|
567
|
+
concurrency: body.concurrency,
|
|
568
|
+
})
|
|
569
|
+
host.sendJson(res, 200, { ok: true, concurrency: result.concurrency, tasks: result.tasks, state: result.state })
|
|
570
|
+
return true
|
|
571
|
+
}
|
|
572
|
+
return false
|
|
573
|
+
}
|