From d997d4a4d050b3e0e6807477efc12642b05a15a8 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Tue, 20 Oct 2026 06:21:55 +0000 Subject: [PATCH] agent-exec transport 85c00f1e2274be1a84b73a421987fc69/ba12fd06dc72 --- src/components/adminRdmSections.tsx | 8 +- src/store.ts | 4 +- test/event-time.test.ts | 33 ++++ test/rdm-members-options-gate.test.tsx | 264 +++++++++++++++++++++++++ 4 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 test/event-time.test.ts create mode 100644 test/rdm-members-options-gate.test.tsx diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index f97ceec..d864e6d 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -78,12 +78,18 @@ export function RdmMembersSection({ project }: { project: Project }) { setPicked([]); setPickedItems([]); knownPicks.current.clear() setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('') setRoleOptions(null) + }, [project]) + + // rdm.role.options 受 member.manage 授权;未确认前先不发请求,避免普通成员被 403。 + // 跟着 [project, manage.canManage] 重订:项目切换或权限放开后重发,迟到响应丢弃。 + useEffect(() => { + if (!manage.canManage) return let live = true cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId)) .then(out => { if (live) setRoleOptions(out.roles ?? []) }) .catch(() => { if (live) setRoleOptions(null) }) return () => { live = false } - }, [project]) + }, [project, manage.canManage]) useEffect(() => { setManageActions(null) diff --git a/src/store.ts b/src/store.ts index 572e2da..513d287 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1744,5 +1744,7 @@ export function mergeStartedRun(runs: Run[], run: Run): Run[] { export function eventTime(e: { ts?: string }, now = Date.now()): number { if (!e.ts) return now 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 } diff --git a/test/event-time.test.ts b/test/event-time.test.ts new file mode 100644 index 0000000..8c2dd24 --- /dev/null +++ b/test/event-time.test.ts @@ -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')) + }) +}) diff --git a/test/rdm-members-options-gate.test.tsx b/test/rdm-members-options-gate.test.tsx new file mode 100644 index 0000000..10a0220 --- /dev/null +++ b/test/rdm-members-options-gate.test.tsx @@ -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 = {} + attributes: Record = {} + 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 写 `