@dijkspicy/opencode-stats-reporter 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.
@@ -0,0 +1,378 @@
1
+ /**
2
+ * session-usage-reporter 自包含 mock 测试
3
+ *
4
+ * 运行方式(无网络、无需真实 opencode):
5
+ * bun test reporter.test.ts
6
+ * bun run reporter.test.ts
7
+ *
8
+ * 说明:不使用 bun:test 断言库,自带极简断言与汇总输出,
9
+ * 因此「bun test」与「bun run」两种方式行为一致(失败时退出码为 1)。
10
+ */
11
+
12
+ import { randomUUID } from "node:crypto"
13
+ import { rm } from "node:fs/promises"
14
+ import { tmpdir } from "node:os"
15
+ import { join } from "node:path"
16
+ import {
17
+ aggregateSession,
18
+ buildPayload,
19
+ computeBackoffMs,
20
+ createQueue,
21
+ createReporterCore,
22
+ loadOrCreateInstallId,
23
+ } from "./session-usage-reporter.ts"
24
+ import type {
25
+ ReporterClient,
26
+ ReporterCoreOptions,
27
+ ReporterScheduler,
28
+ SessionSnapshot,
29
+ UsageMessage,
30
+ } from "./session-usage-reporter.ts"
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // 极简断言与汇总
34
+ // ---------------------------------------------------------------------------
35
+
36
+ type TestCase = { name: string; ok: boolean; error?: string }
37
+ const results: TestCase[] = []
38
+
39
+ const runTest = async (name: string, fn: () => void | Promise<void>): Promise<void> => {
40
+ try {
41
+ await fn()
42
+ results.push({ name, ok: true })
43
+ } catch (error) {
44
+ results.push({ name, ok: false, error: error instanceof Error ? error.message : String(error) })
45
+ }
46
+ }
47
+
48
+ const assert = (condition: boolean, message: string): void => {
49
+ if (!condition) throw new Error(message)
50
+ }
51
+
52
+ const assertEqual = (actual: unknown, expected: unknown, label = ""): void => {
53
+ const a = JSON.stringify(actual)
54
+ const e = JSON.stringify(expected)
55
+ if (a !== e) throw new Error(`${label} 期望 ${e},实际 ${a}`)
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // mock 构造器
60
+ // ---------------------------------------------------------------------------
61
+
62
+ const assistantMessage = (over: Partial<NonNullable<UsageMessage["info"]>> = {}): UsageMessage => ({
63
+ info: {
64
+ id: `msg_${Math.random().toString(36).slice(2)}`,
65
+ role: "assistant",
66
+ modelID: "glm-4.6",
67
+ providerID: "zhipu",
68
+ mode: "build",
69
+ tokens: { input: 10, output: 20, reasoning: 5, cache: { read: 7, write: 3 } },
70
+ time: { completed: 1_000 },
71
+ ...over,
72
+ },
73
+ })
74
+
75
+ const makeClient = (
76
+ messages: Record<string, UsageMessage[]>,
77
+ children: Record<string, string[]> = {},
78
+ ): ReporterClient => ({
79
+ session: {
80
+ messages: async (args: { path: { id: string } }) => ({ data: messages[args.path.id] ?? [] }),
81
+ children: async (args: { path: { id: string } }) => ({
82
+ data: (children[args.path.id] ?? []).map((id) => ({ id })),
83
+ }),
84
+ },
85
+ })
86
+
87
+ const makeFetch = (behavior: "ok" | "fail") => {
88
+ const calls: Array<{ url: string; init: { method?: string; headers?: Record<string, string>; body?: string } }> = []
89
+ const fetchFn = (async (url: unknown, init?: unknown) => {
90
+ calls.push({ url: String(url), init: (init ?? {}) as { method?: string; headers?: Record<string, string>; body?: string } })
91
+ if (behavior === "fail") throw new Error("boom: network down")
92
+ return { ok: true, status: 200 }
93
+ }) as unknown as typeof fetch
94
+ return { fetchFn, calls }
95
+ }
96
+
97
+ /** 手动调度器:不创建真实定时器,作业可在需要时人工触发 */
98
+ const makeCore = (client: ReporterClient, overrides: Partial<ReporterCoreOptions> = {}) => {
99
+ const jobs: Array<{ fn: () => void; delayMs: number }> = []
100
+ const scheduler: ReporterScheduler = (fn, delayMs) => {
101
+ jobs.push({ fn, delayMs })
102
+ return () => {}
103
+ }
104
+ const core = createReporterCore({ client, installId: "install_test", scheduler, ...overrides })
105
+ return { core, jobs }
106
+ }
107
+
108
+ let snapSeq = 0
109
+ const snap = (sessionID: string, over: Partial<SessionSnapshot> = {}): SessionSnapshot => {
110
+ snapSeq += 1
111
+ return {
112
+ session_id: sessionID,
113
+ revision: 1,
114
+ model: null,
115
+ agent: null,
116
+ tokens_input: 0,
117
+ tokens_output: 0,
118
+ tokens_reasoning: 0,
119
+ tokens_cache_read: 0,
120
+ tokens_cache_write: 0,
121
+ message_count: 1,
122
+ session_created_at: null,
123
+ last_activity_at: 1_000 + snapSeq,
124
+ ...over,
125
+ }
126
+ }
127
+
128
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
129
+
130
+ const SESSION_KEYS = [
131
+ "agent",
132
+ "last_activity_at",
133
+ "message_count",
134
+ "model",
135
+ "revision",
136
+ "session_created_at",
137
+ "session_id",
138
+ "tokens_cache_read",
139
+ "tokens_cache_write",
140
+ "tokens_input",
141
+ "tokens_output",
142
+ "tokens_reasoning",
143
+ ].sort()
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // 用例
147
+ // ---------------------------------------------------------------------------
148
+
149
+ await runTest("aggregateSession 只聚合已完成的助手消息,口径原样分列", () => {
150
+ const messages: UsageMessage[] = [
151
+ { info: { id: "m1", role: "user", time: { completed: 900 } } },
152
+ assistantMessage({ id: "m2", tokens: { input: 10, output: 20, reasoning: 5, cache: { read: 7, write: 3 } }, time: { completed: 1_000 } }),
153
+ { info: { id: "m3", role: "assistant", tokens: { input: 999 }, time: { completed: null } } }, // 未完成:忽略
154
+ assistantMessage({ id: "m4", tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } }, time: { completed: 2_000 } }),
155
+ ]
156
+ const s = aggregateSession("ses_x", messages)
157
+ assert(!!s, "应产出快照")
158
+ assertEqual(s!.message_count, 2, "message_count")
159
+ assertEqual(s!.revision, 2, "revision=message_count")
160
+ assertEqual(s!.tokens_input, 11, "tokens_input")
161
+ assertEqual(s!.tokens_output, 22, "tokens_output")
162
+ assertEqual(s!.tokens_reasoning, 8, "tokens_reasoning")
163
+ assertEqual(s!.tokens_cache_read, 11, "tokens_cache_read")
164
+ assertEqual(s!.tokens_cache_write, 8, "tokens_cache_write")
165
+ assertEqual(s!.last_activity_at, 2_000, "last_activity_at")
166
+ assertEqual(s!.model, "zhipu/glm-4.6", "model")
167
+ assertEqual(s!.agent, "build", "agent")
168
+ })
169
+
170
+ await runTest("aggregateSession 无已完成助手消息时返回 null", () => {
171
+ assertEqual(aggregateSession("ses_empty", []), null)
172
+ assertEqual(aggregateSession("ses_partial", [{ info: { role: "assistant", time: { completed: null } } }]), null)
173
+ })
174
+
175
+ await runTest("父子会话分别成快照:父快照不含子会话用量", async () => {
176
+ const client = makeClient(
177
+ {
178
+ ses_parent: [
179
+ assistantMessage({ tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 20, write: 5 } } }),
180
+ ],
181
+ ses_child: [assistantMessage({ tokens: { input: 7, output: 3, reasoning: 1, cache: { read: 2, write: 1 } }, mode: "plan" })],
182
+ },
183
+ { ses_parent: ["ses_child"] },
184
+ )
185
+ const { core } = makeCore(client, { endpoint: "http://endpoint.test/usage" })
186
+ await core.collectSession("ses_parent")
187
+
188
+ assertEqual(core.queue.size, 2, "父与子各自一条快照")
189
+ const drained = core.queue.drainAll()
190
+ const parent = drained.find((s) => s.session_id === "ses_parent")
191
+ const child = drained.find((s) => s.session_id === "ses_child")
192
+ assert(!!parent && !!child, "父/子快照都应存在")
193
+ assertEqual(parent!.tokens_input, 100, "父 input")
194
+ assertEqual(parent!.tokens_output, 50, "父 output")
195
+ assertEqual(parent!.tokens_reasoning, 10, "父 reasoning")
196
+ assertEqual(parent!.tokens_cache_read, 20, "父 cache_read")
197
+ assertEqual(parent!.tokens_cache_write, 5, "父 cache_write")
198
+ assertEqual(child!.tokens_input, 7, "子 input")
199
+ assertEqual(child!.agent, "plan", "子 agent")
200
+ })
201
+
202
+ await runTest("session.idle 与 session.status(idle) 都触发采集,其余事件不触发", async () => {
203
+ const client = makeClient(
204
+ { ses_p: [assistantMessage()], ses_child: [assistantMessage()] },
205
+ { ses_p: ["ses_child"] },
206
+ )
207
+
208
+ // 信号一:session.idle
209
+ const { core: core1 } = makeCore(client, { endpoint: "http://e" })
210
+ await core1.handleEvent({ type: "session.idle", properties: { sessionID: "ses_p" } })
211
+ assertEqual(core1.queue.size, 2, "session.idle 触发父+子采集")
212
+
213
+ // 信号二:session.status 且 status.type === "idle"
214
+ const { core: core2 } = makeCore(client, { endpoint: "http://e" })
215
+ await core2.handleEvent({ type: "session.status", properties: { sessionID: "ses_p", status: { type: "busy" } } })
216
+ assertEqual(core2.queue.size, 0, "非 idle 状态不采集")
217
+ await core2.handleEvent({ type: "session.error", properties: { sessionID: "ses_p" } })
218
+ assertEqual(core2.queue.size, 0, "无关事件不采集")
219
+ await core2.handleEvent({ type: "session.status", properties: { sessionID: "ses_p", status: { type: "idle" } } })
220
+ assertEqual(core2.queue.size, 2, "session.status(idle) 触发采集")
221
+ })
222
+
223
+ await runTest("buildPayload 结构完整、无身份字段、重复会话按最高修订合并", () => {
224
+ const payload = buildPayload("install_123", "0.1.0-test", [
225
+ snap("ses_a", { revision: 2, last_activity_at: 111 }),
226
+ snap("ses_b"),
227
+ snap("ses_a", { revision: 5, last_activity_at: 222 }),
228
+ ])
229
+ assertEqual(payload.schema_version, 1, "schema_version")
230
+ assertEqual(payload.install_id, "install_123", "install_id")
231
+ assertEqual(payload.reporter.client, "opencode", "reporter.client")
232
+ assertEqual(payload.reporter.version, "0.1.0-test", "reporter.version")
233
+ assertEqual(payload.sessions.length, 2, "同会话合并为一条")
234
+ const a = payload.sessions.find((s) => s.session_id === "ses_a")
235
+ assert(!!a && a.revision === 5, "保留最高修订")
236
+ assertEqual(a!.last_activity_at, 222, "保留最新活动时间")
237
+ assertEqual(Object.keys(payload).sort(), ["install_id", "reporter", "schema_version", "sessions"], "顶层键")
238
+ assertEqual(Object.keys(payload.sessions[0]).sort(), SESSION_KEYS, "会话条目键")
239
+ assert(!/consumer|identity|tenant|forward[-_]?(user|consumer)/i.test(JSON.stringify(payload)), "绝不携带消费者/身份字段")
240
+ })
241
+
242
+ await runTest("createQueue 有界,溢出丢最旧并计数", () => {
243
+ const q = createQueue(3)
244
+ for (const id of ["s1", "s2", "s3", "s4", "s5"]) q.push(snap(id))
245
+ assertEqual(q.size, 3, "容量封顶")
246
+ assertEqual(q.dropped, 2, "丢弃计数")
247
+ assertEqual(
248
+ q.drainAll().map((s) => s.session_id),
249
+ ["s3", "s4", "s5"],
250
+ "最旧的 s1/s2 被丢弃",
251
+ )
252
+ })
253
+
254
+ await runTest("flushOnce 失败时批次原样保留在队列并调度退避重试", async () => {
255
+ const q = createQueue(50)
256
+ q.push(snap("ses_1"))
257
+ q.push(snap("ses_2"))
258
+ const { fetchFn } = makeFetch("fail")
259
+ const { core, jobs } = makeCore(makeClient({}), { endpoint: "http://e", queue: q, fetchFn })
260
+
261
+ const ok = await core.flushOnce()
262
+ assertEqual(ok, false, "上报失败")
263
+ assertEqual(q.size, 2, "失败批次无丢失")
264
+ assertEqual(
265
+ q.drainAll().map((s) => s.session_id),
266
+ ["ses_1", "ses_2"],
267
+ "保留原顺序",
268
+ )
269
+ assertEqual(jobs.length, 1, "已调度一次重试")
270
+ assert(jobs[0]!.delayMs >= 500 && jobs[0]!.delayMs <= 1_500, `首次退避应在 [500,1500]ms,实际 ${jobs[0]!.delayMs}`)
271
+ })
272
+
273
+ await runTest("flushOnce 成功时清空队列并 POST 正确载荷", async () => {
274
+ const q = createQueue(50)
275
+ q.push(snap("ses_1", { tokens_input: 11, revision: 3 }))
276
+ q.push(snap("ses_2"))
277
+ const { fetchFn, calls } = makeFetch("ok")
278
+ const { core } = makeCore(makeClient({}), { endpoint: "http://endpoint.test/usage", queue: q, fetchFn })
279
+
280
+ const ok = await core.flushOnce()
281
+ assertEqual(ok, true, "上报成功")
282
+ assertEqual(q.size, 0, "队列清空")
283
+ assertEqual(calls.length, 1, "恰好一次请求")
284
+ assertEqual(calls[0]!.url, "http://endpoint.test/usage", "端点")
285
+ assertEqual(calls[0]!.init.method, "POST", "方法")
286
+ assertEqual(calls[0]!.init.headers?.["content-type"], "application/json", "Content-Type")
287
+ const body = JSON.parse(calls[0]!.init.body ?? "{}")
288
+ assertEqual(body.install_id, "install_test", "install_id")
289
+ assertEqual(body.reporter.client, "opencode", "reporter.client")
290
+ assertEqual(body.sessions.length, 2, "两条会话")
291
+ })
292
+
293
+ await runTest("配置 authToken 时带 Authorization 头;未配置则不带", async () => {
294
+ const q1 = createQueue(50)
295
+ q1.push(snap("ses_auth"))
296
+ const { fetchFn: f1, calls: c1 } = makeFetch("ok")
297
+ const { core: core1 } = makeCore(makeClient({}), {
298
+ endpoint: "http://endpoint.test/usage", queue: q1, fetchFn: f1, authToken: "gw-key-123",
299
+ })
300
+ await core1.flushOnce()
301
+ assertEqual(c1[0]!.init.headers?.["authorization"], "Bearer gw-key-123", "Authorization 头")
302
+
303
+ const q2 = createQueue(50)
304
+ q2.push(snap("ses_noauth"))
305
+ const { fetchFn: f2, calls: c2 } = makeFetch("ok")
306
+ const { core: core2 } = makeCore(makeClient({}), {
307
+ endpoint: "http://endpoint.test/usage", queue: q2, fetchFn: f2,
308
+ })
309
+ await core2.flushOnce()
310
+ assertEqual(c2[0]!.init.headers?.["authorization"], undefined, "未配置则无 Authorization 头")
311
+ })
312
+
313
+ await runTest("endpoint 未配置时不发送且队列保留", async () => {
314
+ const q = createQueue(50)
315
+ q.push(snap("ses_1"))
316
+ const { fetchFn, calls } = makeFetch("ok")
317
+ const { core } = makeCore(makeClient({}), { queue: q, fetchFn })
318
+ const ok = await core.flushOnce()
319
+ assertEqual(ok, true, "无事可做视为成功")
320
+ assertEqual(calls.length, 0, "未发送")
321
+ assertEqual(q.size, 1, "数据保留")
322
+ })
323
+
324
+ await runTest("computeBackoffMs 指数增长、带抖动并封顶", () => {
325
+ assertEqual(computeBackoffMs(0, 0.5), 1_000, "attempt=0")
326
+ assertEqual(computeBackoffMs(2, 0), 2_000, "attempt=2 下界")
327
+ assertEqual(computeBackoffMs(2, 1), 6_000, "attempt=2 上界")
328
+ assertEqual(computeBackoffMs(30, 0.5), 300_000, "封顶 5 分钟")
329
+ assertEqual(computeBackoffMs(-3, 0.5), 1_000, "负数按 0 处理")
330
+ const jittered = computeBackoffMs(1, Math.random())
331
+ assert(jittered >= 1_000 && jittered <= 3_000, `attempt=1 应在 [1000,3000],实际 ${jittered}`)
332
+ })
333
+
334
+ await runTest("disposeFlush:成功清空队列;失败保留且立即止损只试一次", async () => {
335
+ // 成功路径
336
+ const qOk = createQueue(50)
337
+ qOk.push(snap("ses_1"))
338
+ qOk.push(snap("ses_2"))
339
+ const okFetch = makeFetch("ok")
340
+ const coreOk = makeCore(makeClient({}), { endpoint: "http://e", queue: qOk, fetchFn: okFetch.fetchFn }).core
341
+ await coreOk.disposeFlush(1_000)
342
+ assertEqual(qOk.size, 0, "退出前成功清空")
343
+ assertEqual(okFetch.calls.length, 1, "一次请求发完")
344
+
345
+ // 失败路径:失败后 break,不空转烧预算
346
+ const qFail = createQueue(50)
347
+ qFail.push(snap("ses_1"))
348
+ const failFetch = makeFetch("fail")
349
+ const coreFail = makeCore(makeClient({}), { endpoint: "http://e", queue: qFail, fetchFn: failFetch.fetchFn }).core
350
+ await coreFail.disposeFlush(10_000)
351
+ assertEqual(qFail.size, 1, "失败数据保留")
352
+ assertEqual(failFetch.calls.length, 1, "失败后立即止损")
353
+ })
354
+
355
+ await runTest("loadOrCreateInstallId 首次生成、之后稳定复用同一 UUID", async () => {
356
+ const stateFile = join(tmpdir(), `session-usage-reporter-test-${randomUUID()}.txt`)
357
+ try {
358
+ const first = await loadOrCreateInstallId({ stateFile })
359
+ assert(UUID_RE.test(first), `应为合法 UUID,实际 ${first}`)
360
+ const second = await loadOrCreateInstallId({ stateFile })
361
+ assertEqual(second, first, "两次读取应相同")
362
+ } finally {
363
+ await rm(stateFile, { force: true })
364
+ }
365
+ })
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // 汇总
369
+ // ---------------------------------------------------------------------------
370
+
371
+ let failed = 0
372
+ for (const result of results) {
373
+ if (!result.ok) failed += 1
374
+ const line = `${result.ok ? "✓ 通过" : "✗ 失败"} ${result.name}`
375
+ console.log(result.ok ? line : `${line}\n ${result.error}`)
376
+ }
377
+ console.log(`\n${failed === 0 ? "PASS" : "FAIL"}: ${results.length - failed}/${results.length} 项测试通过`)
378
+ if (failed > 0) process.exitCode = 1