@@ -0,0 +1,264 @@
// RdmMembersSection rdm.role.options 授权门控:rdm.perm.check 给出 member.manage
// 未允许时,不得提前发 rdm.role.options(普通成员被 403);允许后必须发。
//
// 测试在 node 环境下用最小化的 DOM mock 驱动 React 19 createRoot,让 useEffect 正常落槽;
// 不去依赖 happy-dom/jsdom(受网络/包锁约束,allowed_paths 也限制仅 src + test)。
import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
import { createElement , type ReactNode } from 'react'
import { act } from 'react'
import { createRoot , type Root } from 'react-dom/client'
import { RdmMembersSection } from '../src/components/adminRdmSections'
import { api } from '../src/api'
import type { Project } from '../src/contracts'
// 用 vi.mock 隔离 useStore:组件只用到 select(state).me,其它 store 字段触发不到,
// 不会影响此处关注点(避免 host Store 启动监听器、送 SSE 等副作用污染测试)。
vi . mock ( '../src/store' , ( ) = > {
const state = {
me : { owner : 'bob' , name : 'bob' , auth : true } ,
}
return {
useStore : ( select : ( value : typeof state ) = > unknown ) = > select ( state ) ,
eventTime : ( _e : { ts? : string } , now : number ) = > now ,
}
} )
// ---- 自定义 React DOM 桩:让 useEffect 在 node 环境落到 commit 阶段 ----
// 服务端 React 不运行 useEffect; createRoot 需要一个 DOM-shaped container。
// 这里只覆盖 react-dom-client 用到的几个关键入口(addEventListener、tagName、namespaceURI、
// ownerDocument.defaultView.HTMLIFrameElement、activeElement、documentElement)。任何
// 新字段缺失都会在第一处 React 调用时显式爆出,便于修复而不是静默吞下。
class HTMLIFrameElement { }
class MockDocument {
nodeType = 9
defaultView : any
HTMLIFrameElement = HTMLIFrameElement
activeElement : any = null
documentElement : any
constructor ( ) {
this . defaultView = makeWindow ( )
this . defaultView . document = this
this . activeElement = null
this . documentElement = this
}
addEventListener() { }
removeEventListener() { }
dispatchEvent() { }
contains() { return false }
createElement ( tag : string ) {
const el = new MockElement ( )
el . tagName = tag
el . nodeName = tag
el . ownerDocument = this
if ( renderObserver ) renderObserver . append ( tag )
return el
}
createElementNS ( _ns : string , tag : string ) { return this . createElement ( tag ) }
createTextNode() { return new MockElement ( ) }
}
/** 创建 DOM 节点时把它们的 tag 累积下来,替代 innerHTML 读取(Mock 上 innerHTML 为空)。 */
let renderObserver : { tags : string [ ] ; reset ( ) : void ; append ( t : string ) : void } | null = null
class MockElement {
nodeType = 1
style : Record < string , string > = { }
attributes : Record < string , string > = { }
tagName = 'DIV'
nodeName = 'DIV'
namespaceURI = 'http://www.w3.org/1999/xhtml'
ownerDocument : any
childNodes : any [ ] = [ ]
parentNode : any = null
appendChild ( c : any ) { this . childNodes . push ( c ) ; c . parentNode = this ; return c }
removeChild ( c : any ) { const i = this . childNodes . indexOf ( c ) ; if ( i >= 0 ) this . childNodes . splice ( i , 1 ) ; c . parentNode = null ; return c }
insertBefore ( c : any , ref : any ) {
const i = ref ? this . childNodes . indexOf ( ref ) : this . childNodes . length
this . childNodes . splice ( i < 0 ? this . childNodes.length : i , 0 , c )
c . parentNode = this
return c
}
contains ( c : any ) {
for ( let n : any = c ; n ; n = n . parentNode ) if ( n === this ) return true
return false
}
addEventListener() { }
removeEventListener() { }
dispatchEvent() { }
focus() { }
setAttribute ( k : string , v : string ) { this . attributes [ k ] = v }
removeAttribute ( k : string ) { delete this . attributes [ k ] }
cloneNode() { return new MockElement ( ) }
/** React 写 `<select>` 时会读 `select.options`,给一个可索引的 length 让 updateOptions 不崩。 */
get options() {
return this . childNodes
}
/** React 写入 innerHTML;这里读为空即可(断言用别的字段)。 */
get innerHTML() { return '' }
get outerHTML() { return '' }
get textContent() { return '' }
set textContent ( _v : string ) { /* 允许 commit 阶段写入 */ }
get value() { return this . attributes . value ? ? '' }
set value ( v : string ) { this . attributes . value = v }
get firstChild() { return this . childNodes [ 0 ] }
get lastChild() { return this . childNodes [ this . childNodes . length - 1 ] }
get nextSibling() {
if ( ! this . parentNode ) return null
const i = this . parentNode . childNodes . indexOf ( this )
return this . parentNode . childNodes [ i + 1 ] ? ? null
}
get previousSibling() {
if ( ! this . parentNode ) return null
const i = this . parentNode . childNodes . indexOf ( this )
return this . parentNode . childNodes [ i - 1 ] ? ? null
}
}
function makeWindow() {
const w : any = {
event : undefined ,
HTMLIFrameElement ,
document : undefined as any ,
navigator : { userAgent : 'node' } ,
}
return w
}
function makeContainer ( ) : MockElement {
const doc = new MockDocument ( )
const el = doc . createElement ( 'div' ) as MockElement
return el
}
function installDomGlobals() {
const win = makeWindow ( )
const doc = new MockDocument ( )
doc . defaultView = win
win . document = doc
; ( globalThis as any ) . window = win
; ( globalThis as any ) . document = doc
; ( globalThis as any ) . IS_REACT_ACT_ENVIRONMENT = true
; ( globalThis as any ) . MessageChannel = class { port1 = { postMessage() { } } ; port2 = { onmessage : null as any , postMessage() { } } }
; ( globalThis as any ) . setImmediate = ( cb : ( . . . args : any [ ] ) = > void ) = > Promise . resolve ( ) . then ( ( ) = > cb ( ) )
}
let _installed = false
function ensureDomGlobals() {
if ( _installed ) return
installDomGlobals ( )
_installed = true
}
// ---- 用例辅助 ----
const project : Project = {
id : 'host-1' , owner : 'alice' , name : '项目P' , createdAt : '' , updatedAt : '' ,
settings : { 'rdm.projectId' : 'domain-7' , 'rdm.state' : 'ready' } ,
}
type CmdHandler = ( id : string , params : Record < string , unknown > ) = > Promise < unknown > | unknown
/** 直接劫持 api.rdmProjectCommand,用 handlers[id] 分别登记每个命令的响应。 */
function spyCommandBus ( handlers : Record < string , CmdHandler > ) {
const calls : Array < [ string , Record < string , unknown > ] > = [ ]
const original = api . rdmProjectCommand . bind ( api )
const spy = vi . spyOn ( api , 'rdmProjectCommand' ) . mockImplementation ( async ( _proj : Project , id : string , params : Record < string , unknown > = { } ) = > {
const args : [ string , Record < string , unknown > ] = [ id , params ]
calls . push ( args )
if ( ! handlers [ id ] ) throw new Error ( ` unexpected command: ${ id } (no handler registered) ` )
return handlers [ id ] ( id , params )
} )
return {
calls ,
roleOptionsCalls : ( ) = > calls . filter ( ( [ id ] ) = > id === 'rdm.role.options' ) ,
permCheckCalls : ( ) = > calls . filter ( ( [ id ] ) = > id === 'rdm.perm.check' ) ,
memberListCalls : ( ) = > calls . filter ( ( [ id ] ) = > id === 'rdm.project.member.list' ) ,
restore : ( ) = > {
spy . mockRestore ( )
; ( api as any ) . rdmProjectCommand = original
} ,
}
}
let root : Root | null = null
let container : MockElement | null = null
beforeEach ( ( ) = > {
ensureDomGlobals ( )
renderObserver = {
tags : [ ] ,
reset() { this . tags = [ ] } ,
append ( t : string ) { this . tags . push ( t ) } ,
}
} )
afterEach ( async ( ) = > {
if ( root ) {
await act ( async ( ) = > root ! . unmount ( ) )
root = null
}
container = null
renderObserver = null
vi . restoreAllMocks ( )
} )
async function renderAndFlush ( element : ReactNode , flushes : number = 3 ) {
container = makeContainer ( )
await act ( async ( ) = > {
root = createRoot ( container as any )
root ! . render ( element )
} )
// 反复冲一轮 microtask flush,让 perm.check 的 .then 落进 commit。
for ( let i = 0 ; i < flushes ; i ++ ) {
await act ( async ( ) = > { await Promise . resolve ( ) } )
}
}
/** 助记:累计的 render 标签里「候选专业角色筛选」下拉 = 看到标 'select' + 紧邻的 option 角色字符串。 */
function sawCandidateRoleFilter ( ) : boolean {
if ( ! renderObserver ) return false
// React 渲染时按 tag 序列创建 select/option;候选筛选断言只需确保 select 节点创建了。
return renderObserver . tags . includes ( 'select' )
}
describe ( 'RdmMembersSection rdm.role.options 授权门控' , ( ) = > {
it ( 'member.manage 未被允许时不得请求 rdm.role.options(普通成员避免 403) ' , async ( ) = > {
const bus = spyCommandBus ( {
'rdm.perm.check' : ( ) = > ( { actions : [ { action : 'member.manage' , allowed : false } ] } ) ,
'rdm.project.member.list' : ( ) = > ( { members : [ ] , accountDirectoryAvailable : true } ) ,
} )
await renderAndFlush ( createElement ( RdmMembersSection , { project } ) )
expect ( bus . permCheckCalls ( ) . length ) . toBe ( 1 )
expect ( bus . roleOptionsCalls ( ) . length ) . toBe ( 0 )
} )
it ( 'member.manage 被允许后再请求 rdm.role.options,并把结果写回角色筛选' , async ( ) = > {
const bus = spyCommandBus ( {
'rdm.perm.check' : ( ) = > ( { actions : [ { action : 'member.manage' , allowed : true } ] } ) ,
'rdm.role.options' : ( ) = > ( { roles : [
{ roleId : 'r-eng' , name : '工程师' } ,
{ roleId : 'r-qa' , name : '测试' } ,
] } ) ,
'rdm.project.member.list' : ( ) = > ( { members : [ ] , accountDirectoryAvailable : true } ) ,
} )
await renderAndFlush ( createElement ( RdmMembersSection , { project } ) )
expect ( bus . permCheckCalls ( ) . length ) . toBe ( 1 )
expect ( bus . roleOptionsCalls ( ) . length ) . toBe ( 1 )
} )
it ( '门控生效前的初始挂载阶段不出现专业角色筛选下拉(不提供误导选项)' , async ( ) = > {
// 用一个永挂起的 perm.check 模拟「自省中」——此时页面上确实只见核对提示,
// 候选区不出现;这是顺带的渲染回归保单,防止门控未来误把 options 提前注入。
// memberManagementView 在 actions===null 时返回 canManage:false, rdm.role.options 因此不发请求;
// 同时 candRoleId 筛选(占位 select)也不应渲染——manage.canManage=false 时整个 ad-create 块被跳掉。
spyCommandBus ( {
'rdm.perm.check' : ( ) = > new Promise < { actions : { action : string ; allowed : boolean } [ ] } > ( ( ) = > { } ) ,
'rdm.project.member.list' : ( ) = > new Promise < any > ( ( ) = > { } ) ,
} )
await renderAndFlush ( createElement ( RdmMembersSection , { project } ) )
expect ( sawCandidateRoleFilter ( ) ) . toBe ( false )
} )
} )