Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-frontend/tests/plugins/pluginSse.test.js
T
insistence 4bd83bc7cd feat: 支持 v2 插件、独立前端和签名制品发布 (#130)
* feat: 支持 v2 插件、原生扩展与签名发布

* fix: 修复测试

* feat: 增强宿主 SDK、运行观测与制品维护能力

* feat: 增强文件传输、配置观测与本地开发能力

* feat: 支持 SSE 实时事件流并完善开发脚手架

* feat: 完善长连接鉴权复核与关闭回收

* feat: 增强生命周期与任务关闭可靠性

* feat: 收紧健康检查契约并补齐前端与真实网络验收

* feat: 增加SDK溯源与离线更新工具并验证跨版本兼容

* feat: 增加持久化业务示例与受控 Python 交付

* feat: 增加统一运行诊断与多worker状态观测

* feat: 增加固定目标发布等待与自动验收

* fix: 修复多环境插件部署前缀与代理配置不一致

* fix: 修复测试
2026-10-09 23:49:45 +08:00

170 lines
5.5 KiB
JavaScript

import assert from 'node:assert/strict'
import { readPluginSse, createPluginSseTransport } from '../../src/utils/pluginSse.js'
import {
PLUGIN_BRIDGE_MAX_BYTES,
PLUGIN_BRIDGE_MAX_STREAM_BYTES,
} from '../../src/utils/pluginBridge.js'
const encoder = new TextEncoder()
const collect = async (source) => {
const output = []
for await (const event of source) output.push(event)
return output
}
const chunks = (values, onCancel = () => {}) =>
new ReadableStream({
start(controller) {
for (const value of values) controller.enqueue(value)
controller.close()
},
cancel: onCancel,
})
const input =
'\uFEFF: heartbeat\r\nid: 1\r\nevent: tick\r\ndata: 你好😀\r\ndata: second\r\n\r\n' +
'id: ignored\0id\ndata:\n\n' +
'id:\revent:\rdata: reset\r\r' +
'data: unfinished'
const bytes = encoder.encode(input)
const expected = [
{ id: '1', event: 'tick', data: '你好😀\nsecond' },
{ id: '1', event: 'message', data: '' },
{ id: '', event: 'message', data: 'reset' },
]
for (const size of [1, 2, 7, bytes.length]) {
const split = []
for (let offset = 0; offset < bytes.length; offset += size)
split.push(bytes.slice(offset, offset + size))
assert.deepEqual(await collect(readPluginSse(chunks(split))), expected)
}
await assert.rejects(collect(readPluginSse(chunks([new Uint8Array([0xff])]))))
await assert.rejects(collect(readPluginSse(chunks([new Uint8Array([0xe4, 0xb8])]))))
await assert.rejects(
collect(readPluginSse(chunks([encoder.encode('data: ' + 'a'.repeat(PLUGIN_BRIDGE_MAX_BYTES))]))),
/64 KiB/
)
await assert.rejects(
collect(readPluginSse(chunks([encoder.encode('data: a\n'.repeat(9000))]))),
/64 KiB/
)
await assert.rejects(
collect(readPluginSse(chunks([new Uint8Array(PLUGIN_BRIDGE_MAX_STREAM_BYTES + 1)]))),
/10 MiB/
)
let cancelled = false
const infinite = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: one\n\ndata: two\n\n'))
},
cancel() {
cancelled = true
},
})
for await (const event of readPluginSse(infinite)) {
assert.equal(event.data, 'one')
break
}
assert.equal(cancelled, true)
assert.equal(infinite.locked, false)
// 读取中断不能伪装成正常 EOF;异常结束仍释放字节流锁。
let interrupt
const brokenBody = new ReadableStream({
start(controller) {
interrupt = () => controller.error(new Error('network disconnected'))
controller.enqueue(encoder.encode('data: first\n\n'))
},
})
const brokenStream = readPluginSse(brokenBody)
assert.equal((await brokenStream.next()).value.data, 'first')
interrupt()
await assert.rejects(brokenStream.next(), /network disconnected/)
assert.equal(brokenBody.locked, false)
let fetched = []
let encrypted = false
let response = () =>
new Response('id: 5\ndata: result\n\n', {
headers: { 'Content-Type': 'text/event-stream; charset=utf-8' },
})
const stream = createPluginSseTransport({
loadPolicy: async () => ({ active: encrypted }),
shouldEncryptRequest: (_, policy) => policy.active,
serializeParams: (params) => new URLSearchParams(params).toString(),
getTimezone: () => 'Asia/Shanghai',
origin: 'https://test',
fetchImpl: async (...args) => {
fetched.push(args)
return response()
},
})
const config = {
url: '/prefix/apps/demo/api/events',
params: { count: 5 },
lastEventId: '4',
csrfToken: 'host-only',
signal: new AbortController().signal,
}
assert.deepEqual(await collect(stream(config)), [{ id: '5', event: 'message', data: 'result' }])
assert.equal(fetched[0][0], 'https://test/prefix/apps/demo/api/events?count=5')
assert.deepEqual(fetched[0][1].headers, {
Accept: 'text/event-stream',
'X-Plugin-CSRF': 'host-only',
'X-Timezone': 'Asia/Shanghai',
'Last-Event-ID': '4',
})
assert.equal(fetched[0][1].credentials, 'same-origin')
assert.equal(fetched[0][1].redirect, 'error')
encrypted = true
await assert.rejects(collect(stream(config)), /策略例外/)
assert.equal(fetched.length, 1)
encrypted = false
for (const url of [
'https://evil.test/api',
'//evil.test/api',
'/apps/demo/api?x=1',
'/apps/demo/api#x',
])
await assert.rejects(collect(stream({ ...config, url })), /地址无效/)
const aborted = new AbortController()
aborted.abort()
await assert.rejects(collect(stream({ ...config, signal: aborted.signal })), /取消/)
assert.equal(fetched.length, 1)
for (const value of [
new Response('{}', { headers: { 'Content-Type': 'application/json' } }),
new Response('error', { status: 401 }),
new Response(null, { status: 204 }),
]) {
response = () => value
await assert.rejects(collect(stream(config)), /响应无效/)
}
// 宿主保留的关闭事件必须转换为失败,不能交给业务或当作正常 EOF。
response = () =>
new Response(
'id: 1\ndata: first\n\nevent: ruoyi.plugin.closed\ndata: {"code":403,"reason":"access_revoked"}\n\n',
{
headers: { 'Content-Type': 'text/event-stream' },
}
)
const revoked = stream(config)
assert.equal((await revoked.next()).value.data, 'first')
await assert.rejects(revoked.next(), (error) => error.name === 'PluginStreamClosedError')
for (const partial of [
'data: unfinished\n',
'data: unfinished\r',
'data: unfinished\n\nevent: custom\ndata: partial\n',
]) {
response = () =>
new Response(partial + 'event: ruoyi.plugin.closed\ndata: {"code":403}\n\n', {
headers: { 'Content-Type': 'text/event-stream' },
})
const values = []
await assert.rejects(
async () => {
for await (const event of stream(config)) values.push(event.data)
},
(error) => error.name === 'PluginStreamClosedError'
)
assert.deepEqual(values, partial.includes('\n\n') ? ['unfinished'] : [])
}