ini 里 []GEOIP,CN,no-resolve 的附加参数原来被并进类别名,生成字段错位 的规则;现在拆出来放在目标之后。表里已有 MATCH 时配方的 []FINAL 不再 追加成死行,改为丢弃并在成功提示里注明——已有兜底是用户自己安排的 分流终点。 遥测流里偶发的坏 JSON 行改为跳过,原来一行坏数据会杀掉整个流进 5 秒 重试循环。请求页:跟随底部的阈值从 24px 降到 8px(原值比行高还大, 向上滚不足一行就被新日志拽回);silent 级别下的空状态不再说「没有流量 经过」。logs.test 的 localStorage 桩用完删掉,bun test 单进程跑,全局 残留会影响之后的测试文件。api.ts 的 format 注释照 provider.go:37 纠正。 请求页和连接页包进 KeepAlive:切走不销毁,筛选词、排序、滚动位置留住。 KeepAlive 常驻、子组件自己 v-if 进出;连接页的轮询改挂 activated—— 挂载时它也触发,继续用 onMounted 会叠两个定时器,切走时停掉轮询。
113 lines
3.4 KiB
TypeScript
113 lines
3.4 KiB
TypeScript
import { afterAll, afterEach, describe, expect, it, mock } from 'bun:test'
|
|
import { LOG_CAP, parseLogLine, pushCapped, streamLogs } from './logs.ts'
|
|
import { LogStreamError } from './logs.ts'
|
|
|
|
describe('解析日志行', () => {
|
|
it('形状对的行解出级别和内容', () => {
|
|
expect(parseLogLine('{"type":"info","payload":"[TCP] dial example.com"}')).toEqual({
|
|
type: 'info',
|
|
payload: '[TCP] dial example.com',
|
|
})
|
|
})
|
|
|
|
it('不是 JSON 的脏行返回 null 而不是抛出去', () => {
|
|
expect(parseLogLine('not json at all')).toBeNull()
|
|
expect(parseLogLine('{"type":"info","payl')).toBeNull()
|
|
})
|
|
|
|
it('是 JSON 但形状不对的也跳过', () => {
|
|
expect(parseLogLine('null')).toBeNull()
|
|
expect(parseLogLine('[1,2]')).toBeNull()
|
|
expect(parseLogLine('{"type":"info"}')).toBeNull()
|
|
expect(parseLogLine('{"type":"info","payload":42}')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('成批追加上限', () => {
|
|
it('超出上限丢最旧的,留下最新的', () => {
|
|
const list = [1, 2, 3]
|
|
pushCapped(list, [4, 5, 6], 4)
|
|
expect(list).toEqual([3, 4, 5, 6])
|
|
})
|
|
|
|
it('一批比上限还大时只留下限条', () => {
|
|
const list: number[] = []
|
|
pushCapped(list, [1, 2, 3, 4, 5], 3)
|
|
expect(list).toEqual([3, 4, 5])
|
|
})
|
|
})
|
|
|
|
function streamOf(chunks: string[]): ReadableStream<Uint8Array> {
|
|
const encoder = new TextEncoder()
|
|
return new ReadableStream({
|
|
start(controller) {
|
|
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
|
|
controller.close()
|
|
},
|
|
})
|
|
}
|
|
|
|
const realFetch = globalThis.fetch
|
|
|
|
// bun 的测试环境没有 localStorage,而 streamLogs 要从里面取令牌。
|
|
// 桩只在本文件生效:bun test 是单进程,全局残留会影响之后跑的其它测试文件
|
|
const hadStorage = typeof globalThis.localStorage !== 'undefined'
|
|
if (!hadStorage) {
|
|
;(globalThis as Record<string, unknown>).localStorage = {
|
|
getItem: () => '',
|
|
setItem: () => {},
|
|
removeItem: () => {},
|
|
}
|
|
}
|
|
|
|
afterAll(() => {
|
|
if (!hadStorage) delete (globalThis as Record<string, unknown>).localStorage
|
|
})
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = realFetch
|
|
})
|
|
|
|
describe('读日志流', () => {
|
|
it('脏行被跳过,不会中断后面的行', async () => {
|
|
globalThis.fetch = mock(() =>
|
|
Promise.resolve(
|
|
new Response(
|
|
streamOf([
|
|
'{"type":"info","payload":"第一条"}\n',
|
|
'这不是 JSON\n',
|
|
'{"type":"warning","payload":"第',
|
|
'二条"}\n',
|
|
]),
|
|
),
|
|
),
|
|
) as unknown as typeof fetch
|
|
|
|
const lines: string[] = []
|
|
for await (const line of streamLogs('info', new AbortController().signal)) {
|
|
lines.push(`${line.type}: ${line.payload}`)
|
|
}
|
|
expect(lines).toEqual(['info: 第一条', 'warning: 第二条'])
|
|
})
|
|
|
|
it('接口报错时用服务端给的 message 抛出', async () => {
|
|
globalThis.fetch = mock(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ message: '内核未连接' }), { status: 502 }),
|
|
),
|
|
) as unknown as typeof fetch
|
|
|
|
const run = async () => {
|
|
for await (const _ of streamLogs('info', new AbortController().signal)) {
|
|
// 不该有产出
|
|
}
|
|
}
|
|
await expect(run()).rejects.toThrow(new LogStreamError('内核未连接'))
|
|
})
|
|
|
|
it('LOG_CAP 是个有限值,无限流不会把内存吃光', () => {
|
|
expect(LOG_CAP).toBeGreaterThan(0)
|
|
expect(LOG_CAP).toBeLessThanOrEqual(10000)
|
|
})
|
|
})
|