Files
catbus/tests/runtime.test.ts
cv-cat dc078b248b catbus:一个命令操作小红书、抖音、TikTok、B 站、快手、微博、闲鱼、淘宝、京东、X
10 个平台的 web 端全部用 TypeScript 移植自 cv-cat 的开源项目,请求与上游逐字节对拍。
统一的命令语法与 JSON 输出、按 平台 × 端 × 账号 隔离的登录态、给 AI Agent 用的技能与 llms.txt。
2026-10-06 19:24:55 +08:00

267 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { newCredential, readCredential, setCurrent, writeCredential } from '../src/core/auth-store.js'
import type { HandlerContext } from '../src/core/registry.js'
import { withRaw } from '../src/core/schemas.js'
import { cli, useTempHome } from './helpers.js'
// 在真实注册表后面加一个带 handler 的演示平台,测试 core 的执行流程:身份、确认、分页、长连接、--raw。
vi.mock('../src/platforms/index.js', async (importOriginal) => {
const real = await importOriginal<typeof import('../src/platforms/index.js')>()
const { definePlatform } = await import('../src/core/registry.js')
const { reconnecting } = await import('../src/core/stream.js')
const { CatbusError } = await import('../src/core/errors.js')
const h = (fn: (ctx: HandlerContext) => unknown) => async () => fn as any
const demo = definePlatform({
id: 'demo',
name: '演示',
aliases: ['dm0'],
item: '条目',
pageInterval: 0,
endpoints: {
web: {
login: { methods: ['cookie'], default: 'cookie' },
// 演示游客态(真实平台的 web 端都不支持,留给 app / pc 端)
guest: true,
commands: {
'user get': {
upstream: 'full',
handler: h(async (ctx) => ({ id: ctx.args.user, account: ctx.account, cookie: ctx.credential?.scopes.main?.cookies[0]?.value ?? null })),
},
'user followers': { upstream: 'full', handler: h(async () => ({ data: [], page: { cursor: null, has_more: false } })) },
'item get': { upstream: 'full', handler: h(async (ctx) => withRaw({ id: ctx.args.item }, { raw_id: ctx.args.item })) },
'item list': { upstream: 'full', handler: h(async () => ({ data: [], page: { cursor: null, has_more: false } })) },
'item search': {
upstream: 'full',
// 每页 2 条,共 3 页
handler: h(async (ctx) => {
const n = Number(ctx.cursor ?? 0)
return { data: [n * 2, n * 2 + 1], page: { cursor: String(n + 1), has_more: n < 2 } }
}),
},
// 服务端把同一个游标又返回一遍(真实平台遇到过),--all 不能死循环
'feed list': { upstream: 'full', handler: h(async () => ({ data: ['x'], page: { cursor: 'same', has_more: true } })) },
'item delete': { upstream: 'full', handler: h(async (ctx) => ({ id: ctx.args.item })) },
'item publish': { upstream: 'full', supports: ['text', 'image'], handler: h(async (ctx) => ({ text: ctx.options.text })) },
// 重连解决不了的错误(登录态失效)不能无限重连
'msg listen': {
upstream: 'full',
handler: h((ctx) => reconnecting(ctx, async function* () {
yield* []
throw new CatbusError('AUTH_EXPIRED', '登录态失效')
})),
},
'live listen': {
upstream: 'full',
handler: h(async function* (ctx) {
for (let i = 0; ; i++) {
if (ctx.options.duration == null && i === 3) return
yield { type: 'chat', text: String(i) }
await new Promise((r) => setTimeout(r, 5))
}
}),
},
'auth status': {
upstream: 'full',
handler: h(async (ctx) => {
const c = ctx.credential!
c.extra.touched = true
await ctx.saveCredential(c)
return { account: ctx.account }
}),
},
},
},
app: 'planned',
pc: 'planned',
},
})
// 不支持游客态的端(真实平台的 web 端都这样):auth status 没有账号时用内存里的空凭证,不写 guest.json
const demo2 = definePlatform({
id: 'demo2',
name: '演示 2',
aliases: [],
item: '条目',
endpoints: {
web: {
login: { methods: ['cookie'], default: 'cookie' },
commands: {
'auth status': {
upstream: 'full',
handler: h(async (ctx) => {
ctx.credential.extra.device = 'generated'
return { logged_in: false, user: null, method: null, expires_at: null }
}),
},
},
},
app: 'planned',
pc: 'planned',
},
})
const PLATFORMS = [...real.PLATFORMS, demo, demo2]
return { PLATFORMS, findPlatform: (n: string) => PLATFORMS.find((p) => p.id === n || p.aliases.includes(n)) }
})
const home = useTempHome()
const PROMPT = '[catbus] 未登录 demo (web),本次以游客身份访问,结果可能不完整;很多操作需要登录。登录:catbus demo auth login\n'
async function login(account = 'work', current = true) {
const c = newCredential({ platform: 'demo', endpoint: 'web', account, method: 'cookie' })
c.scopes.main!.cookies.push({ name: 'sid', value: `fake-${account}`, domain: '.demo.test', path: '/', expires: null })
await writeCredential(c)
if (current) await setCurrent('demo', 'web', account)
}
describe('身份与游客态', () => {
it('optional 命令以游客执行,stderr 提示登录', async () => {
const r = await cli('demo', 'user', 'get', 'u1')
expect(r.code).toBe(0)
expect(r.env).toMatchObject({ ok: true, platform: 'demo', account: 'guest', data: { id: 'u1', account: 'guest' } })
expect(r.stderr).toBe(PROMPT)
})
it('-q 或 -a guest 时不提示', async () => {
expect((await cli('demo', 'user', 'get', 'u1', '-q')).stderr).toBe('')
expect((await cli('demo', 'user', 'get', 'u1', '-a', 'guest')).stderr).toBe('')
})
it('required 命令、me、不存在的 -a 账号都报 AUTH_REQUIRED(退出码 3)', async () => {
const required = await cli('demo', 'item', 'list')
expect(required.code).toBe(3)
expect(required.env.error).toMatchObject({ code: 'AUTH_REQUIRED', hint: 'catbus demo auth login' })
expect((await cli('demo', 'user', 'get', 'me')).code).toBe(3)
expect((await cli('demo', 'user', 'followers')).env.error.message).toContain('me')
const missing = await cli('demo', 'user', 'get', 'u1', '-a', 'nobody')
expect(missing.env.error.hint).toBe('catbus demo auth login -a nobody')
})
it('-a > _current > 游客', async () => {
await login('work')
await login('alt', false)
const current = await cli('demo', 'user', 'get', 'me')
expect(current.env.account).toBe('work')
expect(current.env.data.cookie).toBe('fake-work')
expect(current.stderr).toBe('')
expect((await cli('demo', 'user', 'get', 'x', '-a', 'alt')).env.data.cookie).toBe('fake-alt')
expect((await cli('demo', 'user', 'get', 'x', '-a', 'guest')).env.account).toBe('guest')
})
it('凭证由 core 落盘', async () => {
await login('work')
expect((await cli('demo', 'auth', 'status')).env.data).toEqual({ account: 'work' })
expect((await readCredential('demo', 'web', 'work'))!.extra).toEqual({ touched: true })
})
})
describe('危险操作确认', () => {
it('非 TTY 且没有 -y → CONFIRM_REQUIRED(退出码 2)', async () => {
await login()
const r = await cli('demo', 'item', 'delete', 'i1')
expect(r.code).toBe(2)
expect(r.env.error.code).toBe('CONFIRM_REQUIRED')
expect((await cli('demo', 'item', 'delete', 'i1', '-y')).env.data).toEqual({ id: 'i1' })
})
})
describe('分页', () => {
it('默认只取一页,page 带 cursor', async () => {
const r = await cli('demo', 'item', 'search', 'kw', '-q')
expect(r.env.data).toEqual([0, 1])
expect(r.env.page).toEqual({ cursor: '1', has_more: true })
})
it('--all:游标没有变化时停止翻页,page.has_more 为 false', async () => {
const r = await cli('demo', 'feed', 'list', '-q', '--all')
expect(r.env.data).toEqual(['x', 'x'])
expect(r.env.page).toEqual({ cursor: 'same', has_more: false })
})
it('--limit 翻到取满 N 条;--all 翻到没有更多;--cursor 继续', async () => {
expect((await cli('demo', 'item', 'search', 'kw', '-q', '--limit', '3')).env.data).toEqual([0, 1, 2])
const all = await cli('demo', 'item', 'search', 'kw', '-q', '--all')
expect(all.env.data).toEqual([0, 1, 2, 3, 4, 5])
expect(all.env.page).toEqual({ cursor: '3', has_more: false })
expect((await cli('demo', 'item', 'search', 'kw', '-q', '--cursor', '2')).env.data).toEqual([4, 5])
})
it('--limit 截在一页中间:page.cursor 指回这一页,续翻时跳过已输出的,不丢数据', async () => {
const first = await cli('demo', 'item', 'search', 'kw', '-q', '--limit', '3')
expect(first.env.data).toEqual([0, 1, 2])
expect(first.env.page).toEqual({ cursor: '1#skip=1', has_more: true })
const next = await cli('demo', 'item', 'search', 'kw', '-q', '--limit', '3', '--cursor', first.env.page.cursor)
expect(next.env.data).toEqual([3, 4, 5])
expect(next.env.page).toEqual({ cursor: '3', has_more: false })
// 截在第一页:游标是 #skip=N
const one = await cli('demo', 'item', 'search', 'kw', '-q', '--limit', '1')
expect(one.env.page).toEqual({ cursor: '#skip=1', has_more: true })
expect((await cli('demo', 'item', 'search', 'kw', '-q', '--cursor', '#skip=1')).env.data).toEqual([1])
})
it('jsonl:stdout 每行一条,stderr 一行摘要信封', async () => {
const r = await cli('demo', 'item', 'search', 'kw', '-q', '--all', '-o', 'jsonl')
expect(r.stdout).toBe('0\n1\n2\n3\n4\n5\n')
expect(JSON.parse(r.stderr)).toMatchObject({ ok: true, data: null, page: { cursor: '3', has_more: false }, error: null })
})
})
describe('长连接', () => {
it('重连解决不了的错误(登录态失效)直接报错退出,不无限重连', async () => {
await login()
const r = await cli('demo', 'msg', 'listen', '-q')
expect(r.code).toBe(3)
expect(JSON.parse(r.stderr)).toMatchObject({ ok: false, error: { code: 'AUTH_EXPIRED' } })
})
it('总是输出 jsonl,结束时 stderr 写摘要', async () => {
const r = await cli('demo', 'live', 'listen', 'room', '-q')
expect(r.code).toBe(0)
expect(r.stdout.trim().split('\n').map((l) => JSON.parse(l).text)).toEqual(['0', '1', '2'])
expect(JSON.parse(r.stderr)).toMatchObject({ ok: true, resource: 'live', action: 'listen', data: null })
})
it('--duration 到期正常退出(退出码 0)', async () => {
const r = await cli('demo', 'live', 'listen', 'room', '-q', '--duration', '0.1')
expect(r.code).toBe(0)
expect(r.stdout.trim().split('\n').length).toBeGreaterThan(1)
expect((await cli('demo', 'live', 'listen', 'room', '--duration', '10x')).env.error.code).toBe('USAGE')
})
})
describe('输出', () => {
it('--raw 用原始对象代替归一化对象', async () => {
expect((await cli('demo', 'item', 'get', 'i1', '-q')).env.data).toEqual({ id: 'i1' })
expect((await cli('demo', 'item', 'get', 'i1', '-q', '--raw')).env.data).toEqual({ raw_id: 'i1' })
})
it('--text @file 从文件读取', async () => {
await login()
const { writeFileSync } = await import('node:fs')
const { join } = await import('node:path')
const file = join(process.env.CATBUS_HOME!, 'post.txt')
writeFileSync(file, '正文')
expect((await cli('demo', 'item', 'publish', '--text', `@${file}`)).env.data).toEqual({ text: '正文' })
})
})
describe('发布选项(supports)', () => {
it('没声明支持的标准选项报 UNSUPPORTED,hint 列出可用的;声明了的照常执行', async () => {
await login()
const r = await cli('demo', 'item', 'publish', '--text', 'hi', '--visibility', 'private')
expect(r.code).toBe(2)
expect(r.env.error).toMatchObject({ code: 'UNSUPPORTED', message: 'demo 的 item publish 不支持 --visibility', hint: '可用的选项:--text、--image' })
expect((await cli('demo', 'item', 'publish', '--text', 'hi')).env.data).toEqual({ text: 'hi' })
})
})
describe('不支持游客态的端', () => {
it('auth status 没有账号时凭证只在内存里,不写 guest.json', async () => {
const r = await cli('demo2', 'auth', 'status')
expect(r.env.data).toMatchObject({ logged_in: false })
expect(existsSync(join(home.dir, 'auth', 'demo2', 'web', 'guest.json'))).toBe(false)
})
})