Refactor frontend workspaces and centralize API contracts

Organize domain APIs and types under common/apis, shared components under named folders, and private workspace logic alongside each page.

Use Bearer sessions, explicit response envelopes, session-aware routing and streams, and configurable login captcha presentation for the deployed backend contract.

Preserve news, economic calendar, crawler, market, chat, Skills, settings, and demo trading workflows with responsive layouts and explicit configuration saves.

Validate with frontend lint, type checking, 22 test groups, production builds, and deployed desktop/mobile page checks.
This commit is contained in:
eezd committed 2026-10-26 11:39:52 +08:00
1 parent 77b33e5e77
commit bc480fb806
102 files changed
+4716 -1707

No files matched your search

+207
View File
@@ -0,0 +1,207 @@
import assert from "node:assert/strict"
import test from "node:test"
import { setImmediate } from "node:timers/promises"
import { moduleLoader, require } from "./module-loader.ts"
const axios = require("axios").default
const pinia = require("pinia")
const vue = require("vue")
const { createRouter, createMemoryHistory } = require("vue-router")
const ok = (data: unknown) => ({ status: 200, body: { code: 0, data, message: "" } })
const failure = (status: number) => ({ status, body: { code: status, data: null, message: `failure ${status}` } })
function environment(respond: (config: any) => any) {
const messages: string[] = []
const values = new Map<string, string>()
const storage = { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value), removeItem: (key: string) => values.delete(key) }
pinia.setActivePinia(pinia.createPinia())
const routes = [
{ path: "/login", name: "login" },
{ path: "/news", name: "news", meta: { admin: true } },
{ path: "/calendar", name: "calendar", meta: { admin: true } },
{ path: "/403", name: "forbidden" },
{ path: "/connection-error", name: "connection-error", meta: { public: true } }
].map(route => ({ ...route, component: { render: () => null } }))
const router = createRouter({ history: createMemoryHistory(), routes })
const load = moduleLoader({
"@/router": { router },
"element-plus": { ElMessage: { error: (message: string) => messages.push(message) } },
"axios": { __esModule: true, ...axios, default: { ...axios, create: (config: any) => axios.create({
...config,
adapter: async (config: any) => {
const { status, body } = await respond(config)
const response = { status, data: body, statusText: "", headers: {}, config }
if (status >= 400) throw new axios.AxiosError(`HTTP ${status}`, "ERR_BAD_RESPONSE", config, undefined, response)
return response
}
}) } }
}, { localStorage: storage, ...vue })
const { request, ApiError } = load("@/http/axios")
const user = load("@/pinia/stores/user").useUserStore()
const guards = load("@/router/guards")
return { request, ApiError, user, guards, router, messages, load, storage }
}
test("JSON 解包、明确拒绝错误、403 保留身份且只提示一次", async () => {
let result = ok([1])
const env = environment(() => result)
env.user.setToken("current")
assert.deepEqual(await env.request({ url: "/news", method: "get" }), [1])
result = failure(403)
await assert.rejects(env.request({ url: "/news", method: "get" }), (error: any) => error.code === 403)
assert.equal(env.user.token, "current")
assert.equal(env.messages.length, 1)
result = { status: 200, body: { code: 400, data: null, message: "business failure" } }
await assert.rejects(env.request({ url: "/news", method: "get" }), /business failure/)
result = { status: 200, body: { items: [] } } as any
await assert.rejects(env.request({ url: "/news", method: "get" }), /响应格式无效/)
})
test("旧身份恢复响应不能写入新会话;同 Token 重新设置也隔离", async () => {
const pending = Promise.withResolvers<any>()
const env = environment(() => pending.promise)
env.user.setToken("same-token")
const restore = env.user.fetchMe()
const rejected = assert.rejects(restore, (error: any) => axios.isCancel(error))
await setImmediate()
env.user.setToken("same-token")
pending.resolve(ok({ username: "old", is_admin: true }))
await rejected
assert.equal(env.user.identity, undefined)
assert.equal(env.user.token, "same-token")
assert.equal(env.messages.length, 0)
})
test("旧 401 与旧注销结果都不能注销新登录", async () => {
for (const request of ["read", "logout"]) {
const pending = Promise.withResolvers<any>()
const env = environment(() => pending.promise)
env.user.setToken("old")
const operation = request === "logout" ? env.user.logout() : env.request({ url: "/news", method: "get" })
const rejected = assert.rejects(operation, (error: any) => axios.isCancel(error))
await setImmediate()
env.user.setToken("new")
pending.resolve(request === "logout" ? ok(null) : failure(401))
await rejected
assert.equal(env.user.token, "new")
assert.equal(env.messages.length, 0)
}
})
test("注销失败保留会话;成功才清除持久 Token", async () => {
let result: ReturnType<typeof ok> = failure(503)
const env = environment(() => result)
env.user.setToken("current")
await assert.rejects(env.user.logout())
assert.equal(env.user.token, "current")
result = ok(null)
await env.user.logout()
assert.equal(env.user.token, "")
assert.equal(env.storage.getItem("alphapulse.auth.token"), null)
})
test("匿名深链、登录回跳与已登录刷新使用真实身份", async () => {
let count = 0
const env = environment(() => {
count++
return ok({ username: "admin", is_admin: true })
})
env.guards.installGuards(env.router)
await env.router.push("/calendar?date=2026-10-25")
assert.equal(env.router.currentRoute.value.name, "login")
assert.equal(env.router.currentRoute.value.query.redirect, "/calendar?date=2026-10-25")
env.user.setToken("current")
await env.router.push("/news")
await env.router.push("/login?redirect=/calendar%3Fdate%3D2026-10-25")
assert.equal(env.router.currentRoute.value.fullPath, "/calendar?date=2026-10-25")
assert.equal(count, 1)
await env.router.push("/news")
assert.equal(count, 1)
for (const target of ["//evil.test", "https://evil.test", "/\\evil.test", "/login?redirect=/login"])
assert.equal(env.guards.safeRedirect(target), "/news")
})
test("身份恢复 401 保留深链回跳;403 不清身份,网络错误允许重试", async () => {
for (const mode of ["expired", "forbidden", "network"]) {
const env = environment(() => mode === "expired" ? failure(401) : mode === "network" ? failure(503) : ok({ username: "former-admin", is_admin: false }))
env.user.setToken("current")
env.guards.installGuards(env.router)
await env.router.push("/calendar?date=today")
const route = env.router.currentRoute.value
assert.equal(route.name, mode === "expired" ? "login" : mode === "forbidden" ? "forbidden" : "connection-error")
assert.equal(env.user.token, mode === "expired" ? "" : "current")
if (mode === "expired") assert.equal(route.query.redirect, "/calendar?date=today")
if (mode === "forbidden") assert.equal(env.user.identity.username, "former-admin")
}
})
test("共享来源缓存只有一个在途请求,切换会话清空且丢弃旧响应", async () => {
const pending = Promise.withResolvers<any>()
let count = 0
const env = environment(() => {
count++
return pending.promise
})
env.user.setToken("old")
const sources = env.load("@/pinia/stores/sources").useSourcesStore()
const first = sources.load()
assert.equal(first, sources.load())
const rejected = assert.rejects(first, (error: any) => axios.isCancel(error))
await setImmediate()
env.user.setToken("new")
pending.resolve(ok({ sources: [{ code: "private", name: "old" }] }))
await rejected
assert.equal(count, 1)
assert.deepEqual(Object.keys(sources.names), [])
})
test("确认框结束后不能以新会话发布旧页面的 Skill", async () => {
const confirm = Promise.withResolvers<void>()
const user = vue.reactive({ token: "old", sessionVersion: 1 })
let published = 0
const node = { id: 1, kind: "skill", name: "test" }
const load = moduleLoader({
"vue": { ...vue, onBeforeUnmount() {} },
"@/pinia/stores/user": { useUserStore: () => user },
"@@/apis/skills": {
listSkillTreeApi: async () => [node],
listSkillVersionsApi: async () => [{ id: 1, version_no: 1, content: "text" }],
publishSkillVersionApi: async () => {
published++
}
},
"@@/components/SkillTree/index.vue": {},
"@@/components/SkillVersionDrawer/index.vue": {},
"@@/utils/markdown": { renderMarkdown: (text: string) => text }
}, {
...vue,
onMounted() {},
onBeforeUnmount() {},
onBeforeRouteLeave() {},
ElMessage: { success() {} },
ElMessageBox: { confirm: () => confirm.promise }
})
const page = load("@/pages/skills/index.vue").default.setup({}, { expose() {} })
await page.loadTree()
await page.select(node)
const operation = page.publishLatest()
await setImmediate()
user.sessionVersion++
confirm.resolve()
await assert.rejects(operation, (error: any) => axios.isCancel(error))
assert.equal(published, 0)
})
test("OpenAPI 仅显式原样返回;201/202 保留业务数据与 null", async () => {
let result: { status: number, body: unknown } = { status: 200, body: { openapi: "3.1.0", info: { title: "test", version: "1" }, paths: {} } }
const env = environment(() => result)
env.user.setToken("current")
const spec = await env.load("@@/apis/docs").getOpenApiApi()
assert.equal(spec.openapi, "3.1.0")
await assert.rejects(env.request({ url: "/news", method: "get" }), /响应格式无效/)
for (const status of [201, 202]) {
result = { status, body: { code: 0, data: null, message: "" } }
assert.equal(await env.request({ url: "/write", method: "post" }), null)
}
})
+140
View File
@@ -0,0 +1,140 @@
import assert from "node:assert/strict"
import test from "node:test"
import { moduleLoader, require } from "./module-loader.ts"
const vue = require("vue")
test("验证码刷新保留最新响应;登录锁覆盖导航,失败刷新挑战", async () => {
const first = Promise.withResolvers<any>()
const second = Promise.withResolvers<any>()
const signed = Promise.withResolvers<any>()
const navigated = Promise.withResolvers<any>()
let challenges = 0
let logins = 0
let fail = false
const user = { token: "", setToken(value: string) {
this.token = value
} }
const load = moduleLoader({
"@/pinia/stores/user": { useUserStore: () => user },
"@/router/guards": { safeRedirect: () => "/calendar" },
"@@/apis/auth": {
getCaptchaApi: () => ++challenges === 1 ? first.promise : challenges === 2 ? second.promise : Promise.resolve({ enabled: true, captcha_id: "retry", image: "png", expires_in: 120 }),
loginApi: () => {
logins++
return fail ? Promise.reject(new Error("验证码失败")) : signed.promise
}
}
}, {
...vue,
useRoute: () => ({ query: { redirect: "/calendar" } }),
useRouter: () => ({ replace: () => navigated.promise }),
onMounted() {},
onBeforeUnmount() {},
ElMessage: { warning() {} }
})
const page = load("@/pages/login/index.vue").default.setup({}, { expose() {} })
page.formRef.value = { validate: async () => true }
const a = page.refreshCaptcha()
const b = page.refreshCaptcha()
second.resolve({ enabled: true, captcha_id: "new", image: "new-png", expires_in: 120 })
await b
first.resolve({ enabled: true, captcha_id: "old", image: "old-png", expires_in: 120 })
await a
assert.equal(page.form.captcha_id, "new")
assert.equal(page.image.value, "new-png")
Object.assign(page.form, { username: "admin", password: "password", code: "ABCDE" })
const sending = page.submit()
await page.submit()
await Promise.resolve()
assert.equal(logins, 1)
signed.resolve({ token: "new-token" })
await Promise.resolve()
assert.equal(page.loading.value, true)
navigated.resolve(undefined)
await sending
assert.equal(user.token, "new-token")
assert.equal(page.loading.value, false)
fail = true
await assert.rejects(page.submit())
assert.equal(page.form.captcha_id, "retry")
assert.equal(page.form.code, "")
})
test("验证码关闭时只提交账号密码,失败无需刷新挑战", async () => {
let challenges = 0
let fail = false
const bodies: unknown[] = []
const user = { token: "", setToken(value: string) {
this.token = value
} }
const load = moduleLoader({
"@/pinia/stores/user": { useUserStore: () => user },
"@/router/guards": { safeRedirect: () => "/news" },
"@@/apis/auth": {
getCaptchaApi: async () => {
challenges++
return { enabled: false }
},
loginApi: async (body: unknown) => {
bodies.push(body)
if (fail) throw new Error("用户名或密码错误")
return { token: "password-login-token" }
}
}
}, {
...vue,
useRoute: () => ({ query: {} }),
useRouter: () => ({ replace: async () => {} }),
onMounted() {},
onBeforeUnmount() {},
ElMessage: { warning() {
assert.fail("不应要求验证码")
} }
})
const page = load("@/pages/login/index.vue").default.setup({}, { expose() {} })
page.formRef.value = { validate: async () => true }
await page.refreshCaptcha()
assert.equal(page.captchaEnabled.value, false)
Object.assign(page.form, { username: "admin", password: "password" })
await page.submit()
assert.deepEqual(JSON.parse(JSON.stringify(bodies)), [{ username: "admin", password: "password" }])
assert.equal(user.token, "password-login-token")
fail = true
await assert.rejects(page.submit())
assert.equal(challenges, 1)
assert.equal(page.loading.value, false)
})
test("验证配置读取失败阻止登录,重试成功后解除;表单失败不发送请求", async () => {
let fail = true
let logins = 0
const load = moduleLoader({
"@/pinia/stores/user": { useUserStore: () => ({ sessionVersion: 0 }) },
"@/router/guards": { safeRedirect: () => "/news" },
"@@/apis/auth": {
getCaptchaApi: async () => {
if (fail) throw new Error("配置读取失败")
return { enabled: false }
},
loginApi: () => {
logins++
}
}
}, { ...vue, useRoute: () => ({ query: {} }), useRouter: () => ({}), onMounted() {}, onBeforeUnmount() {} })
const page = load("@/pages/login/index.vue").default.setup({}, { expose() {} })
await assert.rejects(page.refreshCaptcha(), /配置读取失败/)
await page.submit()
assert.equal(logins, 0)
assert.equal(page.captchaReady.value, false)
assert.equal(page.captchaError.value, "配置读取失败")
fail = false
await page.refreshCaptcha()
page.formRef.value = { validate: async () => {
throw new Error("表单无效")
} }
await assert.rejects(page.submit(), /表单无效/)
assert.equal(logins, 0)
assert.equal(page.captchaReady.value, true)
assert.equal(page.loading.value, false)
})
+43
View File
@@ -0,0 +1,43 @@
import { existsSync, readFileSync } from "node:fs"
import { createRequire, Module } from "node:module"
import { dirname, resolve } from "node:path"
import { runInNewContext } from "node:vm"
import ts from "typescript"
import { compileScript, parse } from "vue/compiler-sfc"
export const require = createRequire(import.meta.url)
const source = new URL("../src/", import.meta.url).pathname
/** 使用现有 TS/Vue 编译器执行真实模块;只替换 I/O 和浏览器生命周期。 */
export function moduleLoader(mocks: Record<string, unknown>, globals: Record<string, unknown> = {}) {
const cache = new Map<string, Module>()
function load(name: string, parent = source): any {
if (name in mocks) return mocks[name]
if (!name.startsWith("@/") && !name.startsWith("@@/") && !name.startsWith(".")) return require(name)
let filename = name.startsWith("@@/")
? resolve(source, "common", name.slice(3))
: name.startsWith("@/") ? resolve(source, name.slice(2)) : resolve(parent, name)
if (!/\.(?:vue|ts)$/.test(filename)) filename = existsSync(`${filename}.ts`) ? `${filename}.ts` : resolve(filename, "index.ts")
if (cache.has(filename)) return cache.get(filename)!.exports
const mod = new Module(filename)
cache.set(filename, mod)
let script = readFileSync(filename, "utf8")
if (filename.endsWith(".vue")) script = compileScript(parse(script).descriptor, { id: filename }).content
const code = ts.transpileModule(script, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
runInNewContext(code, {
module: mod,
exports: mod.exports,
require: (value: string) => load(value, dirname(filename)),
console,
Error,
setTimeout,
clearTimeout,
AbortController,
TextDecoder,
URL,
...globals
}, { filename })
return mod.exports
}
return load
}
+46 -55
View File
@@ -1,12 +1,6 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { createRequire, Module } from "node:module"
import { runInNewContext } from "node:vm"
import ts from "typescript"
import * as vue from "vue"
import { compileScript, parse } from "vue/compiler-sfc"
const require = createRequire(import.meta.url)
import { moduleLoader } from "./module-loader.ts"
interface Version {
id: number
@@ -15,12 +9,9 @@ interface Version {
}
function setupComponent(component: string, modules: Record<string, unknown>, confirm: () => Promise<unknown> = async () => {}) {
const filename = new URL(`../src/pages/${component}`, import.meta.url)
const { descriptor } = parse(readFileSync(filename, "utf8"))
const script = compileScript(descriptor, { id: component }).content
const code = ts.transpileModule(script, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
const globals = {
ref: vue.ref,
shallowRef: vue.shallowRef,
reactive: vue.reactive,
computed: vue.computed,
watch: vue.watch,
@@ -31,18 +22,16 @@ function setupComponent(component: string, modules: Record<string, unknown>, con
ElMessage: { success() {}, warning() {} },
ElMessageBox: { confirm }
}
const mod = new Module(filename.pathname)
const load = (path: string) => path in modules ? modules[path] : require(path)
runInNewContext(code, { require: load, module: mod, exports: mod.exports, ...globals }, { filename: component })
return mod.exports.default.setup({}, { expose() {} })
const load = moduleLoader({ "@/http/axios": { logRequestError() {} }, "@@/composables/useSessionScope": { useSessionScope: () => ({ isCurrent: () => true, assertCurrent() {} }) }, ...modules }, globals)
return load(`@/pages/${component}`).default.setup({}, { expose() {} })
}
function skills(api: Record<string, unknown>, confirm?: () => Promise<unknown>) {
return setupComponent("skills/index.vue", {
"@/apis/skills": api,
"@/components/SkillTree.vue": {},
"@/components/SkillVersionDrawer.vue": {},
"@/utils/markdown": { renderMarkdown: (text: string) => text }
"@@/apis/skills": api,
"@@/components/SkillTree/index.vue": {},
"@@/components/SkillVersionDrawer/index.vue": {},
"@@/utils/markdown": { renderMarkdown: (text: string) => text }
}, confirm)
}
@@ -52,9 +41,9 @@ async function checkSkillSwitch() {
const drafts: { id: number, content: string }[] = []
const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }]
const page = skills({
listSkillTree: async () => nodes,
listSkillVersions: (id: number) => id === 1 ? a.promise : b.promise,
saveSkillDraft: async (id: number, content: string) => {
listSkillTreeApi: async () => nodes,
listSkillVersionsApi: (id: number) => id === 1 ? a.promise : b.promise,
saveSkillDraftApi: async (id: number, content: string) => {
drafts.push({ id, content })
return { id: 20, version_no: 2, content }
}
@@ -79,8 +68,8 @@ async function checkSkillReload() {
const old = Promise.withResolvers<Version[]>()
let calls = 0
const page = skills({
listSkillTree: async () => [{ id: 1, kind: "skill" }],
listSkillVersions: () => ++calls === 1 ? old.promise : Promise.resolve([{ id: 2, content: "最新" }])
listSkillTreeApi: async () => [{ id: 1, kind: "skill" }],
listSkillVersionsApi: () => ++calls === 1 ? old.promise : Promise.resolve([{ id: 2, content: "最新" }])
})
await page.loadTree()
page.selectedId.value = 1
@@ -96,10 +85,10 @@ async function checkPublishSwitch() {
const published: [number, number][] = []
const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }]
const page = skills({
listSkillTree: async () => nodes,
listSkillVersions: async (id: number) => [{ id, content: `内容 ${id}` }],
saveSkillDraft: () => saved.promise,
publishSkillVersion: async (id: number, version: number) => published.push([id, version])
listSkillTreeApi: async () => nodes,
listSkillVersionsApi: async (id: number) => [{ id, content: `内容 ${id}` }],
saveSkillDraftApi: () => saved.promise,
publishSkillVersionApi: async (id: number, version: number) => published.push([id, version])
})
await page.loadTree()
await page.select(nodes[0])
@@ -115,15 +104,17 @@ async function checkPublishSwitch() {
async function checkTickerSwitch() {
const btc = Promise.withResolvers<Record<string, string>>()
const eth = Promise.withResolvers<Record<string, string>>()
const page = setupComponent("market/index.vue", {
"@/apis/market": { getTicker: (_: string, symbol: string) => symbol === "BTCUSDT" ? btc.promise : eth.promise },
"@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) },
const load = moduleLoader({
"@/http/axios": { logRequestError() {} },
"@@/apis/market": { getTickerApi: (_: string, symbol: string) => symbol === "BTCUSDT" ? btc.promise : eth.promise },
"@@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) },
"./chart": { DEFAULT_FAVORITE_INTERVALS: [], INTERVAL_LABELS: {}, MAIN_INDICATORS: [], SUB_INDICATORS: [] },
"./useMarketSocket": { useMarketSocket: () => ({ connected: vue.ref(true), subscribe() {} }) },
"./components/AddSymbolDialog.vue": {},
"./components/SymbolManager.vue": {},
"klinecharts": {}
})
}, { ...vue, onMounted() {}, onBeforeUnmount() {}, setInterval() {} })
const page = load("@/pages/market/composables/useMarketChart").useMarketChart()
const first = page.loadTicker()
page.selection.value = { market: "um", symbol: "ETHUSDT" }
await vue.nextTick()
@@ -141,9 +132,9 @@ async function checkPublishConfirmationSwitch() {
const published: [number, number][] = []
const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }]
const page = skills({
listSkillTree: async () => nodes,
listSkillVersions: async (id: number) => [{ id, content: `内容 ${id}`, version_no: 1 }],
publishSkillVersion: async (id: number, version: number) => published.push([id, version])
listSkillTreeApi: async () => nodes,
listSkillVersionsApi: async (id: number) => [{ id, content: `内容 ${id}`, version_no: 1 }],
publishSkillVersionApi: async (id: number, version: number) => published.push([id, version])
}, () => confirmation.promise)
await page.loadTree()
await page.select(nodes[0])
@@ -157,21 +148,21 @@ async function checkPublishConfirmationSwitch() {
function contextEditor(api: Record<string, unknown>) {
return setupComponent("chat/components/ContextTab.vue", {
"@/apis/chat": api,
"@/utils/markdown": { renderMarkdown: (text: string) => text },
"@/utils/text": { formatTime: (text: string) => text }
"@@/apis/chat": api,
"@@/utils/markdown": { renderMarkdown: (text: string) => text },
"@@/utils/text": { formatTime: (text: string) => text }
})
}
async function checkContextRefresh() {
const updated = Promise.withResolvers<{ id: number, content: string }>()
const api = {
getChatContext: async () => ({ id: 1, content: "原内容" }),
listChatContextVersions: async () => []
getChatContextApi: async () => ({ id: 1, content: "原内容" }),
listChatContextVersionsApi: async () => []
}
const page = contextEditor(api)
await new Promise(resolve => setImmediate(resolve))
api.getChatContext = () => updated.promise
api.getChatContextApi = () => updated.promise
const refresh = page.refreshFromChat()
page.content.value = "刷新期间的新输入"
updated.resolve({ id: 2, content: "Chat 更新" })
@@ -185,9 +176,9 @@ async function checkContextSave() {
const saved = Promise.withResolvers<{ id: number, content: string }>()
let submitted = ""
const page = contextEditor({
getChatContext: async () => ({ id: 1, content: "原内容" }),
listChatContextVersions: async () => [],
saveChatContext: (content: string) => {
getChatContextApi: async () => ({ id: 1, content: "原内容" }),
listChatContextVersionsApi: async () => [],
saveChatContextApi: (content: string) => {
submitted = content
return saved.promise
}
@@ -207,13 +198,13 @@ async function checkContextSave() {
async function checkContextReloadAfterSave() {
const stale = Promise.withResolvers<{ id: number, content: string }>()
const api = {
getChatContext: async () => ({ id: 1, content: "原内容" }),
listChatContextVersions: async () => [],
saveChatContext: async (content: string) => ({ id: 2, content })
getChatContextApi: async () => ({ id: 1, content: "原内容" }),
listChatContextVersionsApi: async () => [],
saveChatContextApi: async (content: string) => ({ id: 2, content })
}
const page = contextEditor(api)
await new Promise(resolve => setImmediate(resolve))
api.getChatContext = () => stale.promise
api.getChatContextApi = () => stale.promise
const loading = page.load()
page.content.value = "已保存修改"
await page.save()
@@ -227,9 +218,9 @@ async function checkContextReloadAfterSave() {
async function checkContextRefreshDuringSave() {
const saved = Promise.withResolvers<{ id: number, content: string }>()
const page = contextEditor({
getChatContext: async () => ({ id: 1, content: "原内容" }),
listChatContextVersions: async () => [],
saveChatContext: () => saved.promise
getChatContextApi: async () => ({ id: 1, content: "原内容" }),
listChatContextVersionsApi: async () => [],
saveChatContextApi: () => saved.promise
})
await new Promise(resolve => setImmediate(resolve))
page.content.value = "提交的修改"
@@ -245,15 +236,15 @@ async function checkContextFailedSave() {
const saved = Promise.withResolvers<{ id: number, content: string }>()
const stale = Promise.withResolvers<{ id: number, content: string }>()
const api = {
getChatContext: async () => ({ id: 1, content: "原内容" }),
listChatContextVersions: async () => [],
saveChatContext: () => saved.promise
getChatContextApi: async () => ({ id: 1, content: "原内容" }),
listChatContextVersionsApi: async () => [],
saveChatContextApi: () => saved.promise
}
const page = contextEditor(api)
await new Promise(resolve => setImmediate(resolve))
page.content.value = "保存失败后应保留的草稿"
const saving = page.save()
api.getChatContext = () => stale.promise
api.getChatContextApi = () => stale.promise
const loading = page.load()
saved.reject(new Error("保存失败"))
await assert.rejects(saving, /保存失败/)
+190
View File
@@ -0,0 +1,190 @@
import assert from "node:assert/strict"
import test from "node:test"
import { setImmediate } from "node:timers/promises"
import * as vue from "vue"
import { parseEvents } from "../src/http/sse.ts"
import { moduleLoader, require } from "./module-loader.ts"
const axios = require("axios").default
function body(parts: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
return new ReadableStream({ start(controller) {
for (const part of parts) controller.enqueue(encoder.encode(part))
controller.close()
} })
}
test("SSE 分块、CRLF、注释、多行 data 与 EOF", async () => {
const events = []
for await (const event of parseEvents(body([": ping\r", "\nevent: delta\r\ndata: 第一", "行\r\ndata: 第二行\r\n\r", "\nevent: done\ndata: {}"])))
events.push(event)
assert.deepEqual(events, [{ event: "delta", data: "第一行\n第二行" }, { event: "done", data: "{}" }])
})
function environment(fetcher: typeof fetch, websocket?: unknown, post?: () => Promise<unknown>) {
const user = vue.reactive({ token: "old", sessionVersion: 1 })
const messages: string[] = []
const timers: { run: () => void, delay: number }[] = []
const unmount: (() => void)[] = []
class ApiError extends Error {
code: number
constructor(code: number, message: string) {
super(message)
this.code = code
}
}
const load = moduleLoader({
"@/pinia/stores/user": { useUserStore: () => user },
"@/http/axios": {
ApiError,
request: post,
readEnvelope: (value: any) => {
assert.equal(typeof value.code, "number")
return value
},
rejectAuthentication: (version: number) => {
if (version === user.sessionVersion) {
user.sessionVersion++
user.token = ""
}
}
},
"element-plus": { ElMessage: { error: (value: string) => messages.push(value) } }
}, {
...vue,
WebSocket: websocket,
location: { protocol: "https:", host: "preview.test" },
Error,
ReadableStream,
TextEncoder,
fetch: fetcher,
onBeforeUnmount: (callback: () => void) => unmount.push(callback),
setTimeout: (run: () => void, delay: number) => {
timers.push({ run, delay })
return timers.length
},
clearTimeout() {}
})
return { load, user, messages, timers, unmount }
}
function response(text: string) {
return new Response(body([text]), { headers: { "Content-Type": "text/event-stream" } })
}
test("旧流响应不触发回调;Chat POST 失败明确拒绝且不重发", async () => {
const pending = Promise.withResolvers<Response>()
const env = environment(async () => pending.promise)
let called = 0
const operation = env.load("@/http/stream").consumeStream("/api/news/stream", {}, 1, () => called++, () => called++)
const rejected = assert.rejects(operation, (error: any) => axios.isCancel(error))
env.user.sessionVersion++
pending.resolve(response("event: news\ndata: {}\n\n"))
await rejected
assert.equal(called, 0)
let requests = 0
const events: string[] = []
const chatEnv = environment(async (_, init) => {
requests++
assert.equal(init?.method, "POST")
assert.equal((init?.headers as Record<string, string>).Authorization, "Bearer old")
return response("event: error\ndata: {\"message\":\"模型失败\"}\n\n")
})
const chat = chatEnv.load("@/pages/chat/composables/useChatStream").useChatStream()
await assert.rejects(chat.send({ text: "test" }, (event: { name: string }) => events.push(event.name)), /模型失败/)
assert.deepEqual(events, ["error"])
assert.equal(requests, 1)
assert.equal(chatEnv.timers.length, 0)
assert.equal(chat.streaming.value, false)
})
test("只读订阅有限退避重连,403 停止,当前身份保留", async () => {
let calls = 0
const env = environment(async () => {
calls++
if (calls < 3) throw new Error("network unavailable")
return new Response(JSON.stringify({ code: 403, data: null, message: "权限不足" }), { status: 403 })
})
const source = env.load("@/http/useEventSource").useEventSource(env.load("@@/apis/news").subscribeNewsApi, () => {})
await setImmediate()
assert.equal(env.timers[0].delay, 1000)
env.timers[0].run()
await setImmediate()
assert.equal(env.timers[1].delay, 2000)
env.timers[1].run()
await setImmediate()
assert.equal(env.timers.length, 2)
assert.equal(env.user.token, "old")
assert.equal(source.connected.value, false)
assert.deepEqual(env.messages, ["network unavailable", "权限不足"])
})
test("服务端 auth_error 清理当前身份;换会话立即 abort 旧 Chat", async () => {
const env = environment(async () => response("event: auth_error\ndata: {\"code\":401,\"message\":\"已注销\"}\n\n"))
env.load("@/http/useEventSource").useEventSource(env.load("@@/apis/news").subscribeNewsApi, () => assert.fail("不能继续推送"))
await setImmediate()
assert.equal(env.user.token, "")
assert.equal(env.timers.length, 0)
let signal: AbortSignal | undefined
const pending = Promise.withResolvers<Response>()
const chatEnv = environment(async (_, init) => {
signal = init?.signal as AbortSignal
return pending.promise
})
const chat = chatEnv.load("@/pages/chat/composables/useChatStream").useChatStream()
const sending = chat.send({}, () => assert.fail("旧会话不能回调"))
const rejected = assert.rejects(sending, (error: any) => axios.isCancel(error))
chatEnv.user.sessionVersion++
assert.equal(signal?.aborted, true)
pending.resolve(response("event: done\ndata: {}\n\n"))
await rejected
})
test("行情重连申请新票据,丢弃旧连接消息,切换会话关闭连接", async () => {
const sockets: FakeSocket[] = []
class FakeSocket {
static OPEN = 1
readyState = 0
sent: unknown[] = []
onopen = () => {}
onmessage = (_: any) => {}
onclose = (_: any) => {}
constructor(url: string, protocols: string[]) {
assert.equal(url, "wss://preview.test/api/market/ws")
assert.equal(protocols[0], "alphapulse.market.v1")
assert.equal(protocols[1], `ticket.ticket-${sockets.length + 1}`)
sockets.push(this)
}
send(value: string) {
this.sent.push(JSON.parse(value))
}
close() {
this.readyState = 3
}
}
let tickets = 0
const env = environment(async () => assert.fail("不使用 fetch 连接 WS"), FakeSocket, async () => ({ ticket: `ticket-${++tickets}`, expires_in: 30 }))
const prices: unknown[] = []
const stream = env.load("@/pages/market/composables/useMarketSocket").useMarketSocket(() => {}, (price: unknown) => prices.push(price))
stream.subscribe({ market: "um", symbol: "BTCUSDT", interval: "15m" })
await setImmediate()
sockets[0].readyState = 1
sockets[0].onopen()
assert.equal(stream.connected.value, true)
assert.equal((sockets[0].sent[0] as any).symbol, "BTCUSDT")
sockets[0].onclose({ code: 1011 })
env.timers[0].run()
await setImmediate()
assert.equal(tickets, 2)
sockets[0].onmessage({ data: JSON.stringify({ type: "ticker", market: "um", symbol: "BTCUSDT", data: 1 }) })
sockets[1].onmessage({ data: JSON.stringify({ type: "ticker", market: "um", symbol: "BTCUSDT", data: 2 }) })
assert.deepEqual(prices, [2])
env.user.sessionVersion++
assert.equal(sockets[1].readyState, 3)
assert.equal(stream.connected.value, false)
})
+174
View File
@@ -0,0 +1,174 @@
import assert from "node:assert/strict"
import test from "node:test"
import { setImmediate } from "node:timers/promises"
import * as vue from "vue"
import { moduleLoader } from "./module-loader.ts"
const globals = { ...vue, onMounted() {}, onBeforeUnmount() {}, onBeforeRouteLeave() {}, ElMessage: { success() {}, warning() {} } }
const tradingStatus = (limit = 120) => ({ exchange: "bybit", configured: true, provider_id: null, reasoning_effort: null, snapshot: null, today: { analyses: 10, limit, input_tokens: 0, output_tokens: 0 } })
test("交易配置草稿抵抗轮询、仅保存改动字段;失败保留草稿并拒绝重复提交", async () => {
const pending = Promise.withResolvers<unknown>()
const bodies: unknown[] = []
let fail = false
const props = vue.reactive({ status: tradingStatus(), providers: [] })
const load = moduleLoader({ "@@/apis/trading": { patchConfigApi: (body: unknown) => {
bodies.push(body)
return fail ? Promise.reject(new Error("保存失败")) : pending.promise
} } }, globals)
const emitted: unknown[] = []
const page = load("@/pages/trading/components/TradingConfig.vue").default.setup(props, { expose() {}, emit: (...args: unknown[]) => emitted.push(args) })
page.formRef.value = { validate: async () => true, clearValidate() {} }
page.form.daily_analysis_limit = 200
props.status = tradingStatus(150)
await vue.nextTick()
assert.equal(page.form.daily_analysis_limit, 200)
const saving = page.save()
await page.save()
await setImmediate()
assert.equal(bodies.length, 1)
assert.deepEqual(JSON.parse(JSON.stringify(bodies[0])), { daily_analysis_limit: 200 })
pending.resolve(tradingStatus(200))
await saving
assert.equal(page.dirty.value, false)
assert.equal(emitted.length, 1)
fail = true
page.form.provider_id = 2
await assert.rejects(page.save(), /保存失败/)
assert.equal(page.form.provider_id, 2)
assert.equal(page.dirty.value, true)
assert.equal(page.saving.value, false)
page.reset()
assert.equal(page.form.daily_analysis_limit, 150)
})
test("模型表单按请求字段编辑;校验失败不请求,保存失败保留输入和弹窗", async () => {
let calls = 0
const load = moduleLoader({
"@@/apis/settings": { patchProviderApi: async () => {
calls++
throw new Error("保存失败")
} },
"@@/composables/useSessionScope": { useSessionScope: () => ({ assertCurrent() {} }) }
}, globals)
const page = load("@/pages/settings/components/ProviderSettings.vue").default.setup({}, { expose() {} })
page.openEdit({ id: 1, name: "test", protocol: "openai", base_url: "https://example.test", api_key_hint: "abcd", model: "model", max_tokens: 8192, context_window: 200000, reasoning_effort: null, is_default: false, updated_at: "now" })
assert.equal(page.form.value.api_key, "")
assert.equal("api_key_hint" in page.form.value, false)
assert.equal("updated_at" in page.form.value, false)
page.formRef.value = { validate: async () => {
throw new Error("校验失败")
} }
await assert.rejects(page.save(), /校验失败/)
assert.equal(calls, 0)
page.formRef.value = { validate: async () => true }
page.form.value.name = "new-name"
await assert.rejects(page.save(), /保存失败/)
assert.equal(page.dialogVisible.value, true)
assert.equal(page.form.value.name, "new-name")
assert.equal(page.saving.value, false)
})
test("新闻搜索/页容量重置只有一次请求;失败区别于空数据且旧响应不覆盖新查询", async () => {
const queries: { page: number }[] = []
const pending: ReturnType<typeof Promise.withResolvers>[] = []
const load = moduleLoader({
"@@/apis/news": { listNewsApi: (query: { page: number }) => {
queries.push(query)
const item = Promise.withResolvers()
pending.push(item)
return item.promise
} },
"@@/composables/useSourceNames": { useSourceNames: () => vue.ref({}) },
"@@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) },
"@/http/useEventSource": { useEventSource: () => ({ connected: vue.ref(false) }) }
}, globals)
const page = load("@/pages/news/composables/useNewsFeed").useNewsFeed()
const first = page.load()
const next = page.load()
pending[1].resolve({ items: [{ id: 2 }], total: 1 })
await next
pending[0].resolve({ items: [{ id: 1 }], total: 1 })
await first
assert.equal(page.items.value[0].id, 2)
page.page.value = 3
await vue.nextTick()
pending[2].resolve({ items: [], total: 0 })
await setImmediate()
page.search()
await vue.nextTick()
assert.equal(queries.length, 4)
assert.equal(queries[3].page, 1)
pending[3].resolve({ items: [], total: 0 })
await setImmediate()
page.page.value = 3
await vue.nextTick()
pending[4].resolve({ items: [], total: 0 })
await setImmediate()
page.pageSize.value = 50
await vue.nextTick()
assert.equal(queries.length, 6)
assert.equal(queries[5].page, 1)
pending[5].resolve({ items: [], total: 0 })
await setImmediate()
const failing = page.load()
pending[6].reject(new Error("读取失败"))
await assert.rejects(failing, /读取失败/)
assert.equal(page.failed.value, true)
})
test("交易确认绑定发起币种;交易所或会话切换后拒绝旧操作", async () => {
let confirm = Promise.withResolvers<void>()
let active = true
let exchange = "bybit"
const writes: unknown[] = []
const data = {
status: vue.ref(tradingStatus()),
symbols: vue.ref([]),
selected: vue.ref("BTCUSDT"),
events: vue.ref([]),
eventsExhausted: vue.ref(true),
latestAnalysis: vue.ref(null),
livePlan: vue.ref(null),
planOrders: vue.ref([]),
current: vue.ref(null),
loading: vue.ref(false),
failed: vue.ref(false),
refresh: async () => {},
refreshEvents: async () => {},
loadMoreEvents: async () => {}
}
const children = Object.fromEntries(["AddSymbolDialog", "AnalysisDrawer", "NewsSignalDrawer", "PlanCard", "PositionHistoryDrawer", "TradingConfig"].map(name => [`./components/${name}.vue`, {}]))
const load = moduleLoader({
...children,
"./composables/useTradingData": { useTradingData: () => data },
"@@/apis/settings": {},
"@@/apis/trading": {
getStatusApi: async () => ({ ...tradingStatus(), exchange }),
manualActionApi: async (...args: unknown[]) => writes.push(args)
},
"@@/composables/useSessionScope": { useSessionScope: () => ({ assertCurrent() {
if (!active) throw new Error("会话已切换")
} }) }
}, { ...globals, ElMessageBox: { confirm: () => confirm.promise } })
const page = load("@/pages/trading/index.vue").default.setup({}, { expose() {} })
const first = page.clearOrders()
data.selected.value = "ETHUSDT"
confirm.resolve()
await first
assert.deepEqual(JSON.parse(JSON.stringify(writes)), [["BTCUSDT", "cancel"]])
confirm = Promise.withResolvers<void>()
const second = page.clearOrders()
exchange = "binance"
confirm.resolve()
await assert.rejects(second, /交易所已切换/)
assert.equal(writes.length, 1)
exchange = "bybit"
confirm = Promise.withResolvers<void>()
const third = page.closePosition("BTCUSDT")
active = false
confirm.resolve()
await assert.rejects(third, /会话已切换/)
assert.equal(writes.length, 1)
})