Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-frontend/tests/plugins/pluginBridge.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

951 lines
29 KiB
JavaScript

import assert from 'node:assert/strict'
import {
createPluginClient,
createPluginHostBridge,
normalizePluginBase,
PLUGIN_BRIDGE_MAX_BYTES,
PLUGIN_BRIDGE_MAX_FILE_BYTES,
validatePluginApiPath,
validatePluginRoute,
validatePluginSession,
} from '../../src/utils/pluginBridge.js'
import { normalizeTransportPath } from '../../src/utils/transportPath.js'
// 文件通过真实结构化克隆进入宿主,表单由宿主重建且仍使用受限会话。
async function checkFileTransfers() {
const requests = []
const events = []
const pair = await setup({
request: async (config) => {
requests.push(config)
if (config.responseType === 'blob') {
config.onDownloadProgress({ loaded: 6, total: 6 })
return new Blob(['报告'], { type: 'text/plain' })
}
assert.equal(config.data.get('category'), 'test')
const file = config.data.get('attachment')
assert.equal(file.name, '报告.txt')
assert.equal(await file.text(), '报告')
config.onUploadProgress({ loaded: 3, total: 6 })
config.onUploadProgress({ loaded: 6, total: 6 })
return { received: file.size }
},
})
try {
const result = await pair.child.upload(
{
path: 'files/upload',
file: new Blob(['报告']),
filename: '报告.txt',
fieldName: 'attachment',
fields: { category: 'test' },
},
{
onProgress: (event) => {
events.push(event)
throw new Error('插件回调错误')
},
}
)
assert.deepEqual(result, { received: 6 })
assert.deepEqual(events.at(-1), { phase: 'upload', loaded: 6, total: 6 })
const blob = await pair.child.download(
{ path: 'files/report', params: { month: '10' } },
{ onProgress: (event) => events.push(event) }
)
assert.equal(await blob.text(), '报告')
assert.equal(blob.type, 'text/plain')
assert.deepEqual(events.at(-1), { phase: 'download', loaded: 6, total: 6 })
for (const config of requests) {
assert.equal(config.headers.isToken, false)
assert.equal(config.headers['X-Plugin-CSRF'], sessionData.csrfToken)
assert.equal(config.headers.Authorization, undefined)
assert.equal(config.headers.encrypt, false)
assert.equal(config.timeout, 120000)
assert.equal(config.pluginBridge, true)
}
assert.equal(requests[0].headers['Content-Type'], undefined)
assert.equal(requests[0].url, '/apps/demo/api/files/upload')
assert.equal(pair.child.context.capabilities.files.maxBytes, PLUGIN_BRIDGE_MAX_FILE_BYTES)
} finally {
pair.destroy()
}
}
const ORIGIN = 'https://example.test'
const sessionData = {
pluginId: 'demo',
bridgeVersion: 1,
uiBase: '/apps/demo/ui/',
apiBase: '/apps/demo/api/',
csrfToken: 'csrf-token-for-tests-only',
expiresIn: 300,
}
const deferred = () => {
let resolve
let reject
const promise = new Promise((a, b) => {
resolve = a
reject = b
})
return { promise, resolve, reject }
}
const flush = () => new Promise((resolve) => setImmediate(resolve))
function windowPair() {
const makeWindow = () => {
const listeners = new Set()
return {
messages: [],
listeners,
addEventListener(type, listener) {
if (type === 'message') listeners.add(listener)
},
removeEventListener(type, listener) {
if (type === 'message') listeners.delete(listener)
},
dispatch(event) {
for (const listener of [...listeners]) listener(event)
},
}
}
const hostWindow = makeWindow()
const childWindow = makeWindow()
for (const [target, source] of [
[hostWindow, childWindow],
[childWindow, hostWindow],
]) {
target.postMessage = (data, targetOrigin) => {
assert.equal(targetOrigin, ORIGIN, 'all messages must name an exact origin')
data = structuredClone(data)
target.messages.push(data)
queueMicrotask(() => target.dispatch({ data, source, origin: ORIGIN }))
}
}
return { hostWindow, childWindow }
}
async function setup({
request = async () => ({ code: 200, data: 'ok' }),
now = Date.now,
timeoutMs = 15000,
transferTimeoutMs = 120000,
stream,
streamTimeoutMs = 300000,
} = {}) {
const { hostWindow, childWindow } = windowPair()
let target = childWindow
let theme = 'light'
const routes = []
const host = createPluginHostBridge({
pluginId: 'demo',
session: validatePluginSession(sessionData, 'demo', '', now()),
getTarget: () => target,
request,
stream,
getContext: () => ({
theme: { mode: theme },
language: 'zh-CN',
timeZone: 'Asia/Shanghai',
route: '/',
}),
onRoute: (route) => routes.push(route),
eventTarget: hostWindow,
origin: ORIGIN,
now,
timeoutMs,
transferTimeoutMs,
streamTimeoutMs,
})
const child = createPluginClient({
pluginId: 'demo',
eventTarget: childWindow,
parentWindow: hostWindow,
origin: ORIGIN,
transferTimeoutMs,
streamTimeoutMs,
})
await child.ready
return {
hostWindow,
childWindow,
host,
child,
routes,
replaceTarget: () => {
target = {}
},
changeTheme: () => {
theme = 'dark'
host.updatePreferences()
},
destroy: () => {
host.destroy()
child.destroy()
},
}
}
assert.equal(normalizePluginBase('/deployment/'), '/deployment')
for (const base of ['//evil.test', 'https://evil.test', '/a/../b', '/%2e', '/x\\y', '/x//y']) {
assert.throws(() => normalizePluginBase(base))
}
assert.equal(validatePluginSession(sessionData, 'demo', '', 100).expiresAt, 300100)
assert.equal(
validatePluginSession(
{ ...sessionData, uiBase: '/prefix/apps/demo/ui/', apiBase: '/prefix/apps/demo/api/' },
'demo',
'/prefix'
).apiBase,
'/prefix/apps/demo/api/'
)
for (const changes of [
{ uiBase: '//evil.test/apps/demo/ui/' },
{ uiBase: 'https://example.test/apps/demo/ui/' },
{ apiBase: '/apps/other/api/' },
{ apiBase: '/prefix/apps/demo/api/' },
{ apiBase: '/apps/demo/api/../' },
{ pluginId: 'other' },
{ expiresIn: 0 },
{ expiresIn: 5000 },
{ bridgeVersion: 2 },
{ csrfToken: 'bad\nheader' },
])
assert.throws(() => validatePluginSession({ ...sessionData, ...changes }, 'demo'))
for (const path of ['', 'status', 'model/v1.2', 'reports/2026-10/'])
assert.equal(validatePluginApiPath(path), path)
for (const path of [
'/status',
'//evil.test',
'https://evil.test',
'..',
'a/../b',
'a/./b',
'a//b',
'a\\b',
'%2e%2e/admin',
'%252e%252e',
'a%2fb',
'a?x=1',
'a#hash',
'a\n',
]) {
assert.throws(() => validatePluginApiPath(path), path)
}
assert.equal(validatePluginRoute('/reports/today'), '/reports/today')
assert.throws(() => validatePluginRoute('//outside'))
assert.throws(() => validatePluginRoute('/../admin'))
assert.equal(
normalizeTransportPath('/root/apps/demo/api/status', '/dev-api', '/root'),
'/apps/demo/api/status'
)
assert.equal(
normalizeTransportPath('/root/plugin/runtime/demo/session', '/dev-api', '/root'),
'/plugin/runtime/demo/session'
)
assert.equal(normalizeTransportPath('/dev-api/system/user', '/dev-api'), '/system/user')
assert.equal(normalizeTransportPath('/dev-api-other/user', '/dev-api'), '/dev-api-other/user')
assert.equal(
normalizeTransportPath('/rooted/apps/demo/api/status', '/dev-api', '/root'),
'/rooted/apps/demo/api/status'
)
// 验证握手、接口请求和上下文同步,不向子页面传递宿主令牌或 CSRF 令牌。
{
const requests = []
const pair = await setup({
request: async (config) => {
requests.push(config)
return { code: 200, data: [1, 2] }
},
})
assert.equal(pair.child.context.apiBase, '/apps/demo/api/')
assert.equal(pair.child.context.csrfToken, undefined)
const result = await pair.child.request({
method: 'POST',
path: 'status',
data: { value: 3 },
params: { page: 1 },
})
assert.deepEqual(result, { code: 200, data: [1, 2] })
assert.equal(requests[0].url, '/apps/demo/api/status')
assert.equal(requests[0].baseURL, '')
assert.deepEqual(requests[0].headers, {
isToken: false,
'X-Plugin-CSRF': sessionData.csrfToken,
repeatSubmit: false,
})
assert.equal(requests[0].headers.Authorization, undefined)
assert.equal(requests[0].pluginBridge, true)
pair.changeTheme()
await flush()
assert.equal(pair.child.context.theme.mode, 'dark')
pair.child.navigate('/details')
await flush()
assert.deepEqual(pair.routes, ['/details'])
pair.host.updateRoute('/back')
await flush()
assert.equal(pair.child.context.route, '/back')
const events = []
pair.child.subscribe((event) => events.push(event.type))
pair.host.refresh()
await flush()
assert.deepEqual(events, ['refresh'])
pair.destroy()
assert.equal(pair.hostWindow.listeners.size, 0)
assert.equal(pair.childWindow.listeners.size, 0)
}
// 拒绝伪造来源、旧页面随机标识、无效字段和接口路径越界。
{
let calls = 0
const pair = await setup({
request: async () => {
calls += 1
return {}
},
})
const initialization = pair.childWindow.messages.find((message) => message.type === 'initialize')
const message = {
namespace: 'ruoyi.plugin',
version: 1,
pluginId: 'demo',
type: 'request',
instance: initialization.instance,
id: 'test',
payload: { method: 'GET', path: 'status' },
}
const emit = (data, origin = ORIGIN, source = pair.childWindow) =>
pair.hostWindow.dispatch({ data, origin, source })
emit(message, 'https://evil.test')
emit(message, ORIGIN, {})
emit({ ...message, instance: 'previous-frame' })
emit({ ...message, version: 2 })
emit({ ...message, pluginId: 'other' })
emit({ ...message, headers: { Authorization: 'bad' } })
await flush()
assert.equal(calls, 0)
for (const payload of [
{ method: 'GET', path: '/admin' },
{ method: 'GET', path: 'status', headers: { Authorization: 'bad' } },
{ method: 'GET', path: 'status', baseURL: 'https://evil.test' },
{ method: 'GET', path: 'status', data: {} },
{ method: 'TRACE', path: 'status' },
{ method: 'POST', path: 'status', data: 'x'.repeat(PLUGIN_BRIDGE_MAX_BYTES + 1) },
{ method: 'POST', path: 'status', data: JSON.parse('{"__proto__":{"bad":true}}') },
]) {
emit({ ...message, payload })
await flush()
assert.equal(pair.childWindow.messages.at(-1).payload.ok, false)
}
assert.equal(calls, 0)
pair.replaceTarget()
emit(message)
await flush()
assert.equal(calls, 0)
pair.destroy()
}
// 页面超时、销毁或会话过期后,不再返回旧请求的结果。
{
let now = 0
const pending = deferred()
let captured
const pair = await setup({
now: () => now,
request: (config) => {
captured = config
return pending.promise
},
})
const promise = pair.child.request({ method: 'GET', path: 'slow' })
const rejected = assert.rejects(promise, /失效/)
await flush()
pair.host.destroy({ logout: true })
await rejected
assert.equal(captured.signal.aborted, true)
pending.resolve({ secret: 'late-result' })
await flush()
assert.equal(
pair.childWindow.messages.some((message) => message.type === 'result'),
false
)
assert.equal(pair.childWindow.listeners.size, 0)
pair.destroy()
}
{
const pending = deferred()
let captured
const pair = await setup({
request: (config) => {
captured = config
return pending.promise
},
timeoutMs: 10,
})
await assert.rejects(pair.child.request({ method: 'GET', path: 'slow' }), /超时/)
assert.equal(captured.signal.aborted, true)
pending.resolve({ late: true })
await flush()
assert.equal(pair.childWindow.messages.filter((message) => message.type === 'result').length, 1)
pair.destroy()
}
{
let now = 0
let calls = 0
const pair = await setup({
now: () => now,
request: async () => {
calls += 1
return {}
},
})
now = 301000
const controller = new AbortController()
const promise = pair.child.request(
{ method: 'GET', path: 'status' },
{ signal: controller.signal }
)
const rejected = assert.rejects(promise, /取消/)
await flush()
assert.equal(calls, 0)
controller.abort()
await rejected
pair.destroy()
}
// 会话续期只更新宿主侧会话和 CSRF 令牌,不重新启动子页面 SDK。
{
let captured
const pair = await setup({
request: async (config) => {
captured = config
return {}
},
})
pair.host.setSession(
validatePluginSession({ ...sessionData, csrfToken: 'renewed-csrf-token-test' }, 'demo')
)
await pair.child.request({ method: 'GET', path: 'status' })
assert.equal(captured.headers['X-Plugin-CSRF'], 'renewed-csrf-token-test')
assert.equal(
pair.childWindow.messages.filter((message) => message.type === 'initialize').length,
1
)
assert.throws(() => pair.host.setSession({ ...sessionData, apiBase: '/apps/other/api/' }))
pair.destroy()
}
// iframe 重载会保留 WindowProxy,但必须更换文档随机标识并取消旧请求。
// 旧文档迟到的 ready 消息不能恢复旧连接。
{
const pending = deferred()
const captured = []
const pair = await setup({
request: (config) => {
captured.push(config)
return config.url.endsWith('/slow') ? pending.promise : Promise.resolve({ code: 200 })
},
})
const oldReady = pair.hostWindow.messages.find((message) => message.type === 'ready')
const oldInit = pair.childWindow.messages.find((message) => message.type === 'initialize')
const oldPromise = pair.child.request({ method: 'GET', path: 'slow' })
const oldRejected = assert.rejects(oldPromise, /关闭/)
await flush()
// 在同一个子窗口 WindowProxy 上模拟新文档的 SDK。
const fresh = createPluginClient({
pluginId: 'demo',
eventTarget: pair.childWindow,
parentWindow: pair.hostWindow,
origin: ORIGIN,
})
await fresh.ready
assert.equal(captured[0].signal.aborted, true)
const newInit = pair.childWindow.messages
.filter((message) => message.type === 'initialize')
.at(-1)
assert.notEqual(newInit.instance, oldInit.instance)
pair.hostWindow.dispatch({ data: oldReady, origin: ORIGIN, source: pair.childWindow })
pending.resolve({ data: 'from-old-page' })
await flush()
assert.equal(
pair.childWindow.messages.filter((message) => message.type === 'initialize').length,
2
)
assert.equal(
pair.childWindow.messages.some((message) => message.type === 'result'),
false
)
pair.child.destroy()
await oldRejected
assert.deepEqual(await fresh.request({ method: 'GET', path: 'status' }), { code: 200 })
fresh.destroy()
pair.destroy()
}
// 验证并发上限和取消行为,服务端异常只返回可展示的错误文本。
{
const requests = []
const pair = await setup({
request: (config) => {
requests.push(config)
return new Promise(() => {})
},
})
const promises = Array.from({ length: 8 }, () =>
pair.child.request({ method: 'GET', path: 'wait' })
)
const rejected = promises.map((promise) => assert.rejects(promise, /关闭/))
await flush()
await assert.rejects(pair.child.request({ method: 'GET', path: 'extra' }), /过多/)
pair.child.destroy()
await Promise.all(rejected)
await flush()
assert.equal(requests.length, 8)
assert.equal(
requests.every((config) => config.signal.aborted),
true
)
pair.host.destroy()
}
{
const pair = await setup({
request: async () => {
throw new Error('secret master token stack trace')
},
})
await assert.rejects(
pair.child.request({ method: 'GET', path: 'failure' }),
/^Error: 插件请求失败,请重试$/
)
assert.equal(JSON.stringify(pair.childWindow.messages).includes('secret master'), false)
pair.destroy()
}
// 宿主无响应时,SDK 应在握手超时后移除监听。
{
const pair = windowPair()
const child = createPluginClient({
pluginId: 'demo',
eventTarget: pair.childWindow,
parentWindow: pair.hostWindow,
origin: ORIGIN,
timeoutMs: 10,
})
await assert.rejects(child.ready, /初始化超时/)
assert.equal(pair.childWindow.listeners.size, 0)
}
// 首次握手期间支持取消请求,握手前后的排队请求使用相同并发上限。
{
const pair = windowPair()
const child = createPluginClient({
pluginId: 'demo',
eventTarget: pair.childWindow,
parentWindow: pair.hostWindow,
origin: ORIGIN,
})
const controller = new AbortController()
const promise = child.request({ method: 'GET', path: 'status' }, { signal: controller.signal })
const cancelled = assert.rejects(promise, /已取消/)
controller.abort()
await cancelled
assert.equal(
pair.hostWindow.messages.some((message) => message.type === 'request'),
false
)
const pending = Array.from({ length: 8 }, () => child.request({ method: 'GET', path: 'status' }))
const rejected = pending.map((entry) => assert.rejects(entry, /关闭/))
await assert.rejects(child.request({ method: 'GET', path: 'overflow' }), /过多/)
child.destroy()
await Promise.all(rejected)
assert.equal(pair.childWindow.listeners.size, 0)
}
await checkFileTransfers()
// 无效文件在子页面和宿主两侧分别拒绝,下载也不能返回错误信封或过大响应。
{
let calls = 0
const pair = await setup({
request: async () => {
calls += 1
return {}
},
})
const valid = { path: 'upload', file: new Blob(['test']) }
for (const changes of [
{ file: 'not a file' },
{ file: new Blob([new Uint8Array(PLUGIN_BRIDGE_MAX_FILE_BYTES + 1)]) },
{ filename: '../secret' },
{ filename: 'bad\nname' },
{ fieldName: 'a\r\nb' },
{ fields: { file: 'duplicate' } },
{ fields: { number: 1 } },
{ params: null },
{ path: '//outside.test' },
{ method: 'GET' },
{ headers: { Authorization: 'forged' } },
])
await assert.rejects(pair.child.upload({ ...valid, ...changes }))
await assert.rejects(pair.child.download({ path: '../outside' }))
await assert.rejects(pair.child.download({ path: 'ok' }, { onProgress: true }))
const init = pair.childWindow.messages.find((message) => message.type === 'initialize')
pair.hostWindow.dispatch({
origin: ORIGIN,
source: pair.childWindow,
data: {
namespace: 'ruoyi.plugin',
version: 1,
pluginId: 'demo',
instance: init.instance,
type: 'upload',
id: 'forged-file',
payload: {
method: 'POST',
path: 'upload',
file: 'bad',
fieldName: 'file',
filename: 'x',
fields: {},
},
},
})
await flush()
assert.equal(calls, 0)
assert.equal(
pair.childWindow.messages.find((message) => message.id === 'forged-file').payload.ok,
false
)
pair.destroy()
}
for (const body of [
new Blob([new Uint8Array(PLUGIN_BRIDGE_MAX_FILE_BYTES + 1)]),
new Blob(['{"code":401,"msg":"secret token"}'], { type: 'application/json' }),
{ headers: { authorization: 'secret' } },
]) {
const pair = await setup({ request: async () => body })
await assert.rejects(pair.child.download({ path: 'report' }), /插件请求失败/)
assert.equal(JSON.stringify(pair.childWindow.messages).includes('secret'), false)
pair.destroy()
}
{
const pair = await setup({
request: async () => new Blob(['{"report":true}'], { type: 'application/json' }),
})
assert.equal(await (await pair.child.download({ path: 'report' })).text(), '{"report":true}')
pair.destroy()
}
// 超限进度立即取消传输;取消和超时之后,迟到进度及响应均被丢弃。
for (const mode of ['oversize', 'cancel', 'timeout', 'reload']) {
const delayed = deferred()
let config
const events = []
const pair = await setup({
transferTimeoutMs: mode === 'timeout' ? 10 : 120000,
request: (value) => {
config = value
return delayed.promise
},
})
const controller = new AbortController()
const promise = pair.child.download(
{ path: 'report' },
{ signal: controller.signal, onProgress: (event) => events.push(event) }
)
const rejected = assert.rejects(promise)
await flush()
if (mode === 'oversize') config.onDownloadProgress({ loaded: PLUGIN_BRIDGE_MAX_FILE_BYTES + 1 })
if (mode === 'cancel') controller.abort()
if (mode === 'reload') {
pair.hostWindow.dispatch({
origin: ORIGIN,
source: pair.childWindow,
data: {
namespace: 'ruoyi.plugin',
version: 1,
pluginId: 'demo',
instance: '',
type: 'ready',
payload: { clientId: 'new-file-document' },
},
})
pair.child.destroy()
}
await rejected
await flush()
assert.equal(config.signal.aborted, true)
config.onDownloadProgress({ loaded: 3, total: 3 })
delayed.resolve(new Blob(['old']))
await flush()
assert.equal(events.length, 0)
pair.destroy()
}
// 未公布文件能力的旧宿主立即报兼容错误;握手前调用仍可正常排队。
for (const filesSupported of [false, true]) {
const pair = windowPair()
const child = createPluginClient({
pluginId: 'demo',
eventTarget: pair.childWindow,
parentWindow: pair.hostWindow,
origin: ORIGIN,
})
const pending = child.download({ path: 'report' })
const clientId = pair.hostWindow.messages[0].payload.clientId
pair.childWindow.dispatch({
origin: ORIGIN,
source: pair.hostWindow,
data: {
namespace: 'ruoyi.plugin',
version: 1,
pluginId: 'demo',
type: 'initialize',
instance: 'host-instance',
payload: { clientId, ...(filesSupported ? { capabilities: { files: { version: 1 } } } : {}) },
},
})
if (filesSupported) {
await flush()
const request = pair.hostWindow.messages.find((message) => message.type === 'download')
assert.ok(request)
const rejected = assert.rejects(pending, /关闭/)
child.destroy()
await rejected
} else {
await assert.rejects(pending, /不支持文件传输/)
child.destroy()
}
}
// 实时事件按回调 Promise 确认逐条传递,查询、游标与会话信息只交给宿主适配器。
{
const gate = deferred()
const events = []
let produced = 0
let config
const pair = await setup({
stream: async function* (value) {
config = value
for (let i = 1; i <= 3; i++) {
produced++
yield { event: 'tick', data: String(i), id: String(i) }
}
},
})
const result = pair.child.stream(
{ path: 'events', params: { count: 3 }, lastEventId: '0' },
{
onEvent: async (event) => {
events.push(event)
if (event.id === '1') await gate.promise
},
}
)
await flush()
assert.equal(produced, 1)
assert.equal(events.length, 1)
assert.equal(config.url, '/apps/demo/api/events')
assert.deepEqual(config.params, { count: 3 })
assert.equal(config.lastEventId, '0')
assert.equal(config.csrfToken, sessionData.csrfToken)
assert.equal(pair.child.context.capabilities.streams.maxConcurrent, 2)
const streamMessage = pair.hostWindow.messages.find((message) => message.type === 'stream')
for (const payload of [{ sequence: 2 }, { sequence: 1, extra: true }]) {
pair.hostWindow.dispatch({
origin: ORIGIN,
source: pair.childWindow,
data: { ...streamMessage, type: 'stream-ack', payload },
})
}
await flush()
assert.equal(produced, 1, '无效确认不能提前读取下一条事件')
gate.resolve()
assert.equal(await result, undefined)
await flush()
assert.deepEqual(
events.map((event) => event.id),
['1', '2', '3']
)
assert.equal(config.signal.aborted, true)
pair.destroy()
}
// 绕过 SDK 直接发送消息仍受宿主路径、字段和连接数校验。
{
let opened = 0
const pair = await setup({
stream: async function* ({ signal }) {
opened++
await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true }))
},
})
const initialize = pair.childWindow.messages.find((message) => message.type === 'initialize')
const sendStream = (id, payload) =>
pair.hostWindow.dispatch({
origin: ORIGIN,
source: pair.childWindow,
data: { ...initialize, type: 'stream', id, payload },
})
sendStream('malformed-stream', { path: 'events', headers: { Authorization: 'forbidden' } })
await flush()
assert.equal(opened, 0)
assert.equal(pair.childWindow.messages.at(-1).payload.ok, false)
for (const id of ['stream-one', 'stream-two', 'stream-three']) sendStream(id, { path: 'events' })
await flush()
assert.equal(opened, 2)
assert.match(
pair.childWindow.messages.find((message) => message.id === 'stream-three').payload.message,
/连接过多/
)
pair.destroy()
}
// 初始化尚未完成时也计入实时连接数;取消排队请求后可重新排队。
{
const pair = windowPair()
const child = createPluginClient({
pluginId: 'demo',
eventTarget: pair.childWindow,
parentWindow: pair.hostWindow,
origin: ORIGIN,
})
const controller = new AbortController()
const first = child.stream({ path: 'events' }, { onEvent() {}, signal: controller.signal })
const second = child.stream({ path: 'events' }, { onEvent() {} })
const firstRejected = assert.rejects(first, /取消/)
const secondRejected = assert.rejects(second, /关闭/)
await assert.rejects(child.stream({ path: 'events' }, { onEvent() {} }), /连接过多/)
controller.abort()
await firstRejected
const replacement = assert.rejects(child.stream({ path: 'events' }, { onEvent() {} }), /关闭/)
child.destroy()
await Promise.all([secondRejected, replacement])
}
// 宿主主动关闭使用固定提示,不泄露鉴权异常,也不交付保留的控制消息。
{
const pair = await setup({
stream: async function* () {
yield { id: '1', event: 'tick', data: 'first' }
const error = new Error('secret authentication details')
error.name = 'PluginStreamClosedError'
throw error
},
})
const events = []
await assert.rejects(
pair.child.stream({ path: 'events' }, { onEvent: (event) => events.push(event) }),
/宿主关闭/
)
assert.equal(events.length, 1)
assert.equal(JSON.stringify(pair.childWindow.messages).includes('secret'), false)
pair.destroy()
}
// 无能力协商的旧宿主不接收流请求;接口路径、任意请求头与游标注入均被拒绝。
{
const pair = await setup()
await assert.rejects(pair.child.stream({ path: 'events' }, { onEvent() {} }), /不支持实时/)
for (const payload of [
{ path: '../other' },
{ path: 'events', headers: {} },
{ path: 'events', method: 'POST' },
{ path: 'events', lastEventId: 'bad\nvalue' },
])
await assert.rejects(pair.child.stream(payload, { onEvent() {} }))
await assert.rejects(pair.child.stream({ path: 'events' }), /回调/)
pair.destroy()
}
// 第三个实时连接被拒绝,普通 JSON 请求仍可进行;取消会关闭底层迭代器。
{
const controllers = [new AbortController(), new AbortController()]
let closed = 0
const pair = await setup({
stream: async function* ({ signal }) {
try {
yield { event: 'tick', data: 'one', id: '1' }
await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true }))
} finally {
closed++
}
},
})
const requests = controllers.map((controller) =>
pair.child.stream({ path: 'events' }, { signal: controller.signal, onEvent() {} })
)
const rejected = requests.map((promise) => assert.rejects(promise, /取消/))
await flush()
await assert.rejects(pair.child.stream({ path: 'events' }, { onEvent() {} }), /实时连接过多/)
assert.deepEqual(await pair.child.request({ method: 'GET', path: 'ok' }), {
code: 200,
data: 'ok',
})
for (const controller of controllers) controller.abort()
await Promise.all(rejected)
await flush()
assert.equal(closed, 2)
pair.destroy()
}
// 处理失败、超时、页面重载和退出均取消旧流,迟到事件不能更新当前页面。
for (const mode of ['callback', 'timeout', 'reload', 'logout', 'oversize']) {
const delivered = []
let signal
let closed = false
const pair = await setup({
streamTimeoutMs: mode === 'timeout' ? 20 : 300000,
stream: async function* (config) {
signal = config.signal
try {
yield {
event: 'tick',
id: '1',
data: mode === 'oversize' ? 'x'.repeat(PLUGIN_BRIDGE_MAX_BYTES) : 'one',
}
if (!signal.aborted)
await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true }))
yield { event: 'tick', id: '2', data: 'late' }
} finally {
closed = true
}
},
})
const request = pair.child.stream(
{ path: 'events' },
{
onEvent: (event) => {
delivered.push(event.id)
if (mode === 'callback') return Promise.reject(new Error('plugin callback failed'))
},
}
)
const rejected = assert.rejects(request)
await flush()
if (mode === 'logout') pair.host.destroy({ logout: true })
if (mode === 'reload') {
pair.hostWindow.dispatch({
origin: ORIGIN,
source: pair.childWindow,
data: {
namespace: 'ruoyi.plugin',
version: 1,
pluginId: 'demo',
instance: '',
type: 'ready',
payload: { clientId: 'new-stream-document' },
},
})
pair.child.destroy()
}
await rejected
await flush()
assert.equal(signal.aborted, true)
assert.equal(closed, true)
assert.deepEqual(delivered, mode === 'oversize' ? [] : ['1'])
pair.destroy()
}