agent-exec transport 85c00f1e2274be1a84b73a421987fc69/ba12fd06dc72
This commit is contained in:
@@ -78,12 +78,18 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
|||||||
setPicked([]); setPickedItems([]); knownPicks.current.clear()
|
setPicked([]); setPickedItems([]); knownPicks.current.clear()
|
||||||
setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
|
setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
|
||||||
setRoleOptions(null)
|
setRoleOptions(null)
|
||||||
|
}, [project])
|
||||||
|
|
||||||
|
// rdm.role.options 受 member.manage 授权;未确认前先不发请求,避免普通成员被 403。
|
||||||
|
// 跟着 [project, manage.canManage] 重订:项目切换或权限放开后重发,迟到响应丢弃。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!manage.canManage) return
|
||||||
let live = true
|
let live = true
|
||||||
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
|
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
|
||||||
.then(out => { if (live) setRoleOptions(out.roles ?? []) })
|
.then(out => { if (live) setRoleOptions(out.roles ?? []) })
|
||||||
.catch(() => { if (live) setRoleOptions(null) })
|
.catch(() => { if (live) setRoleOptions(null) })
|
||||||
return () => { live = false }
|
return () => { live = false }
|
||||||
}, [project])
|
}, [project, manage.canManage])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setManageActions(null)
|
setManageActions(null)
|
||||||
|
|||||||
+3
-1
@@ -1744,5 +1744,7 @@ export function mergeStartedRun(runs: Run[], run: Run): Run[] {
|
|||||||
export function eventTime(e: { ts?: string }, now = Date.now()): number {
|
export function eventTime(e: { ts?: string }, now = Date.now()): number {
|
||||||
if (!e.ts) return now
|
if (!e.ts) return now
|
||||||
const t = Date.parse(e.ts)
|
const t = Date.parse(e.ts)
|
||||||
return Number.isNaN(t) ? now : t
|
// 后端 0 值占位(0001-01-01T00:00:00Z 等)会让状态栏读到巨大耗时,回落 now 让「已工作」保持真实时钟。
|
||||||
|
if (Number.isNaN(t) || t <= 0) return now
|
||||||
|
return t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// eventTime 取事件上的服务端时间;后端 0 值占位(0001-01-01T00:00:00Z)
|
||||||
|
// 不能让状态栏把「已工作」读成几百年的耗时——非正 epoch 一律回落到墙上时钟。
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { eventTime } from '../src/store'
|
||||||
|
|
||||||
|
describe('eventTime', () => {
|
||||||
|
it('returns now when the timestamp is missing', () => {
|
||||||
|
const now = 1_700_000_000_000
|
||||||
|
expect(eventTime({}, now)).toBe(now)
|
||||||
|
expect(eventTime({ ts: '' }, now)).toBe(now)
|
||||||
|
expect(eventTime({ ts: undefined }, now)).toBe(now)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns now for unparseable timestamps', () => {
|
||||||
|
const now = 1_700_000_000_000
|
||||||
|
expect(eventTime({ ts: 'not-a-date' }, now)).toBe(now)
|
||||||
|
expect(eventTime({ ts: '2026-13-40T99:99:99Z' }, now)).toBe(now)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns now when the parsed time is not a positive epoch (zero-value placeholder)', () => {
|
||||||
|
// 0001-01-01T00:00:00Z is the Go/Unix zero time;过去不能解释为有效事件时间。
|
||||||
|
const now = 1_700_000_000_000
|
||||||
|
expect(eventTime({ ts: '0001-01-01T00:00:00Z' }, now)).toBe(now)
|
||||||
|
// Date.parse('1970-01-01T00:00:00Z') === 0,应被一并回落,否则状态栏会显示从 epoch 至今。
|
||||||
|
expect(eventTime({ ts: '1970-01-01T00:00:00Z' }, now)).toBe(now)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the parsed epoch for a real, parseable timestamp', () => {
|
||||||
|
const now = 1_700_000_000_000
|
||||||
|
expect(eventTime({ ts: '2024-01-02T03:04:05Z' }, now)).toBe(Date.parse('2024-01-02T03:04:05Z'))
|
||||||
|
expect(eventTime({ ts: '2026-10-12T10:00:00Z' }, now)).toBe(Date.parse('2026-10-12T10:00:00Z'))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user