// 归档会话视图回归(纯 DOM 实现,归档行注入官方列表流)。 // 验证:工作区行 + 未分组行按钮注入、点击后切换视图(正常会话行隐藏、 // 归档行容器注入官方分组容器顶替其位置,无独立滚动条,随官方列表整体 // 滚动;退出后正常会话行恢复)、渲染归档行(初始 5 行 + 渐进展开:每批 // +5、剩余不足为剩余数、全部展开后变「收起」)、分组收起时归档行一并 // 隐藏、归档行三点菜单(重命名/分叉会话/取消归档/删除会话,Escape 与 // 外部点击只关菜单)、子代理/blank 会话不列入、行点击静默取消归档并 // 打开且已打开行原位保留(外观不变、可再点、列表零扰动)、空归档集合 // 空状态、Escape 退出、外部点击不退出、新建会话按钮退出、相对时间刷新 // 定时器、卸载清理。 // 由 tsconfig.tests.json 编译为根目录 verify-archive.cjs,npm test 执行。 'use strict' const fs = require('fs') // ---------- 最小 DOM mock ---------- function attrSelectorOf(sel) { const re = /\[([a-zA-Z-]+)(?:([*^$|]?=)"([^"]*)")?\]/g const tests = [] let m while ((m = re.exec(sel)) !== null) { const name = m[1] const op = m[2] const expect = m[3] tests.push(function (el) { const actual = el.getAttribute(name) if (actual === null) return false if (op === undefined) return true if (op === '*=') return actual.indexOf(expect) !== -1 if (op === '^=') return actual.startsWith(expect) return actual === expect }) } return tests } function matchSel(el, sel) { if (sel === undefined || sel === null || el === null || el === undefined || el.nodeType !== 1) return false const parts = String(sel).trim().split(/\s+/) let node = el let index = parts.length - 1 while (index >= 0 && node !== null) { if (!matchSingle(node, parts[index])) return false index -= 1 node = node.parentElement } return index < 0 } function matchSingle(el, single) { let tag = null let rest = single const tagMatch = single.match(/^([a-zA-Z]+)/) if (tagMatch) { tag = tagMatch[1].toUpperCase(); rest = single.slice(tagMatch[1].length) } if (tag !== null && el.tagName !== tag) return false // 单个 class 选择器(.name):按 class 属性空格分词匹配。 const classMatch = rest.match(/^\.([a-zA-Z0-9_-]+)/) if (classMatch) { const cls = classMatch[1] const attr = typeof el.getAttribute === 'function' ? el.getAttribute('class') : null if (attr === null || attr === undefined || (' ' + attr + ' ').indexOf(' ' + cls + ' ') === -1) return false rest = rest.slice(classMatch[0].length) } // 伪类 :first-child / :last-child:按父元素的元素子节点位置匹配。 const firstMatch = rest.match(/^:first-child/) const lastMatch = rest.match(/^:last-child/) if (firstMatch !== null || lastMatch !== null) { const pseudo = firstMatch !== null ? firstMatch[0] : lastMatch[0] const pseudoParent = el.parentElement const siblings = pseudoParent !== null && pseudoParent !== undefined && pseudoParent.children !== undefined ? pseudoParent.children.filter(c => c.nodeType === 1) : [] if (firstMatch !== null && siblings[0] !== el) return false if (lastMatch !== null && siblings[siblings.length - 1] !== el) return false rest = rest.slice(pseudo.length) } const tests = attrSelectorOf(rest) for (const test of tests) { if (!test(el)) return false } return true } class FakeEl { constructor(tag, attrs = {}) { this.nodeType = 1 this.tagName = tag.toUpperCase() this.children = [] this.childNodes = [] this.parentElement = null this.parentNode = null this.style = { display: '', cssText: '', left: '', top: '', width: '', height: '', setProperty() {}, removeProperty() {} } this._attrs = {} this._handlers = {} this.firstChild = null this.firstElementChild = null this.nextSibling = null this.nextElementSibling = null this.textContent = '' this.title = '' this.value = '' for (const [k, v] of Object.entries(attrs)) this.setAttribute(k, v) } setAttribute(k, v) { this._attrs[k] = String(v) } getAttribute(k) { return Object.prototype.hasOwnProperty.call(this._attrs, k) ? this._attrs[k] : null } removeAttribute(k) { delete this._attrs[k] } hasAttribute(k) { return Object.prototype.hasOwnProperty.call(this._attrs, k) } appendChild(node) { node.parentNode = node.parentElement = this this.childNodes.push(node) this.children.push(node) this.rechain() return node } insertBefore(node, ref) { const i = this.children.indexOf(ref) if (i === -1) return this.appendChild(node) node.parentNode = node.parentElement = this this.childNodes.splice(i, 0, node) this.children.splice(i, 0, node) this.rechain() return node } removeChild(node) { const i = this.children.indexOf(node) if (i !== -1) { this.children.splice(i, 1); this.childNodes.splice(i, 1) } node.parentNode = node.parentElement = null this.rechain() return node } rechain() { this.firstChild = this.children.length > 0 ? this.children[0] : null this.firstElementChild = this.firstChild for (let j = 0; j < this.children.length; j += 1) { this.children[j].nextSibling = this.children[j + 1] || null this.children[j].nextElementSibling = this.children[j + 1] || null } } querySelector(sel) { // 逗号选择器:任一组命中即返回(官方代码里有 'button[a],button[b]' 形态)。 for (const part of String(sel).split(',')) { const hit = this._query(part.trim(), false) if (hit !== null) return hit } return null } querySelectorAll(sel) { const out = [] for (const part of String(sel).split(',')) { for (const hit of this._query(part.trim(), true)) { if (out.indexOf(hit) === -1) out.push(hit) } } return out } _query(sel, all) { const out = [] const walk = (el) => { for (const c of el.children) { if (matchSel(c, sel)) out.push(c) walk(c) } } walk(this) return all ? out : (out[0] || null) } contains(node) { let el = node while (el !== null) { if (el === this) return true el = el.parentElement } return false } closest(sel) { let el = this while (el !== null) { if (matchSel(el, sel)) return el el = el.parentElement } return null } matches(sel) { return matchSel(this, sel) } addEventListener(t, fn) { this._handlers[t] = fn } removeEventListener(t) { delete this._handlers[t] } get isConnected() { let el = this while (el !== null) { if (el.parentElement === null && el.parentNode === null) { return el.tagName === 'BODY' || el.tagName === 'HTML' } el = el.parentElement } return false } click(type) { const fn = this._handlers[type || 'click']; if (typeof fn === 'function') fn({ preventDefault() {}, stopPropagation() {}, currentTarget: this, target: this, key: undefined }) } getBoundingClientRect() { return this._rect || { left: 0, right: 300, top: 0, bottom: 600, width: 300, height: 600 } } cloneNode(deep = true) { const clone = new FakeEl(this.tagName) for (const k of Object.keys(this._attrs)) clone._attrs[k] = this._attrs[k] clone.textContent = this.textContent clone.title = this.title // 深克隆:菜单项注入依赖克隆出的子结构(button > icon span + label span)。 if (deep !== false) { for (const c of this.children) clone.appendChild(c.cloneNode(true)) } return clone } } // ---------- 装载 client.js ---------- const windowObj = { __ModuleLoader__: { load(entry) { captured = entry } }, innerWidth: 1280, innerHeight: 800, addEventListener() {}, removeEventListener() {}, } let captured = null globalThis.window = windowObj const src = fs.readFileSync(__dirname + '/lib/client.js', 'utf8') eval(src) if (captured === null || captured.id !== 'deepseek-harness-zh_pro') { console.error('FAIL: client.js 未通过 __ModuleLoader__.load 注册') process.exit(1) } // ---------- mock React(设置分区等其它模块仍用;归档视图已不用 React) ---------- const fakeObs = [] const timeouts = [] globalThis.setTimeout = function (fn, ms) { timeouts.push({ fn: fn, ms: ms }); return timeouts.length } globalThis.clearTimeout = function (id) { timeouts[id - 1] = null } const intervals = [] globalThis.setInterval = function (fn, ms) { intervals.push({ fn: fn, ms: ms }); return intervals.length } globalThis.clearInterval = function (id) { intervals[id - 1] = null } const rafQueue = [] globalThis.requestAnimationFrame = function (fn) { rafQueue.push({ fn: fn }); return rafQueue.length } globalThis.cancelAnimationFrame = function (id) { rafQueue[id - 1] = null } const reactMock = { Fragment: 'fragment', useSyncExternalStore(sub, get) { return get() }, useState(initial) { return [initial, function () {}] }, useRef(initial) { return { current: initial } }, useLayoutEffect(fn) { const r = fn(); if (typeof r === 'function') disposers.push(r) }, useEffect(fn) { const r = fn(); if (typeof r === 'function') disposers.push(r) }, createElement(type, props) { const children = Array.prototype.slice.call(arguments, 2) const nextProps = Object.assign({}, props, { children: children }) if (typeof type === 'function') return type(nextProps) if (typeof type === 'string') return { type: type, props: nextProps } return { type: type, props: nextProps } }, } const pluginExports = captured.factory(function (name) { if (name === 'react') return reactMock throw new Error('unexpected require: ' + name) }) // ---------- DOM 夹具 ---------- // 固定时间基准:行签名含 updatedAt(毫秒级),若每次快照重新取 // Date.now(),签名会持续漂移导致每次同步都重建行(真实环境的 // updatedAt 是稳定快照数据)。 const MOCK_NOW = Date.now() globalThis.MutationObserver = class { constructor(cb) { this.cb = cb; this.disconnected = false; fakeObs.push(this) } observe() {} disconnect() { this.disconnected = true } } const fakeDoc = { readyState: 'complete', documentElement: new FakeEl('html'), body: null, head: new FakeEl('head'), createElement(tag) { return new FakeEl(tag) }, createElementNS(_ns, tag) { return new FakeEl(tag) }, // document 级查询委托给 body(documentElement 不承载业务节点)。 querySelector(sel) { return body.querySelector(sel) }, querySelectorAll(sel) { return body.querySelectorAll(sel) }, // 真实 DOM 的同一事件类型可以有多个监听器(pointerdown 上同时挂着 // archive-view 与 session-menu 两家)。这里按类型收集成数组,并让 // `_handlers[type]` 保持可直接调用的形态(逐个转发),这样既支持 // 「直接调 handler 模拟事件」的既有用法,也不会让后注册者覆盖先注册者。 _listeners: {}, _handlers: {}, addEventListener(type, fn) { if (this._listeners[type] === undefined) { this._listeners[type] = [] const self = this this._handlers[type] = function (event) { for (const listener of self._listeners[type].slice()) listener(event) } } this._listeners[type].push(fn) }, removeEventListener(type, fn) { const list = this._listeners[type] if (list === undefined) return const i = list.indexOf(fn) if (i !== -1) list.splice(i, 1) if (list.length === 0) { delete this._listeners[type] delete this._handlers[type] } }, } const body = new FakeEl('body') fakeDoc.body = body globalThis.document = fakeDoc globalThis.window = windowObj // 官方列表 DOM:data-slot="sidebar.workspaces" > role=tree > 分组 > 工作区行 + 会话行 const region = new FakeEl('div', { 'data-slot': 'sidebar.workspaces' }) const tree = new FakeEl('div', { role: 'tree' }) const group = new FakeEl('div') region.appendChild(tree) tree.appendChild(group) const wsSpan = new FakeEl('span') const wsRow = new FakeEl('div', { role: 'treeitem', 'aria-expanded': 'true' }) wsRow._rect = { left: 0, right: 300, top: 80, bottom: 114, width: 300, height: 34 } const fiber = { memoizedProps: { group: { workspaceId: 'ws-1', label: '项目A', key: 'ws-1' } }, return: null } wsRow['__reactFiber$abc'] = fiber const wsActions = new FakeEl('span') const addBtn = new FakeEl('button', { 'aria-label': '在“项目A”中新建会话' }) wsActions.appendChild(addBtn) wsRow.appendChild(wsActions) wsSpan.appendChild(wsRow) group.appendChild(wsSpan) const sessionSpans = [] for (let i = 1; i <= 3; i += 1) { const sSpan = new FakeEl('span') const sRow = new FakeEl('div', { role: 'treeitem' }) const slot = new FakeEl('span') const title = new FakeEl('span') title.textContent = '会话' + i sRow.appendChild(slot); sRow.appendChild(title) sSpan.appendChild(sRow) sessionSpans.push(sSpan) group.appendChild(sSpan) } tree._rect = { left: 0, right: 300, top: 40, bottom: 700, width: 300, height: 660 } // 未分组桶行:无「新建会话」按钮,fiber group.workspaceId === undefined。 // 它位于 ws-1 分组之后:面板底部应止于它的行顶部(400),不遮住它。 const ungroupedGroup = new FakeEl('div') tree.appendChild(ungroupedGroup) const ungroupedSpan = new FakeEl('span') const ungroupedRow = new FakeEl('div', { role: 'treeitem', 'aria-expanded': 'true' }) ungroupedRow._rect = { left: 0, right: 300, top: 400, bottom: 434, width: 300, height: 34 } ungroupedRow['__reactFiber$ungrp'] = { memoizedProps: { group: { workspaceId: undefined, label: 'Ungrouped', key: '', expanded: true } }, return: null, } const ungroupedTitle = new FakeEl('span') ungroupedTitle.textContent = '未分组' ungroupedRow.appendChild(ungroupedTitle) ungroupedSpan.appendChild(ungroupedRow) ungroupedGroup.appendChild(ungroupedSpan) body.appendChild(region) // 列表外区域元素(模拟主内容区等面板外点击目标)。 const outsideEl = new FakeEl('div') body.appendChild(outsideEl) // ---------- 同步 thenable(立即 resolve/reject),测试可同步断言完整请求链 ---------- function syncPromise(value) { return { then(fn) { try { if (!fn) return syncPromise(value) const out = fn(value) // thenable 返回值透传(模拟原生 Promise 的 assimilation), // 让下一级拿到解包后的值而不是包装对象。 if (out !== null && typeof out === 'object' && typeof out.then === 'function') return out return syncPromise(out) } catch (error) { return syncRejected(error) } }, catch() { return syncPromise(value) }, } } function syncRejected(error) { return { then() { return syncRejected(error) }, catch(fn) { return syncPromise(fn ? fn(error) : undefined) }, } } // ---------- mock ctx / services ---------- // 会话摘要表(模块级可变:新用例预置已删除驻留/对照组会话)。 const sessionsById = { 'a1': { id: 'a1', displayTitle: '归档会话1', updatedAt: MOCK_NOW - 86400000 }, 'a2': { id: 'a2', displayTitle: '归档会话2', updatedAt: MOCK_NOW - 172800000 }, 'a3': { id: 'a3', displayTitle: '归档会话3', updatedAt: MOCK_NOW - 259200000 }, 'a4': { id: 'a4', displayTitle: '归档会话4', updatedAt: MOCK_NOW - 345600000 }, 'a5': { id: 'a5', displayTitle: '归档会话5', updatedAt: MOCK_NOW - 432000000 }, 'a6': { id: 'a6', displayTitle: '归档会话6', updatedAt: MOCK_NOW - 518400000 }, 'a7': { id: 'a7', displayTitle: '归档会话7', updatedAt: MOCK_NOW - 604800000 }, 'a8': { id: 'a8', displayTitle: '归档会话8', updatedAt: MOCK_NOW - 648000000 }, 'a9': { id: 'a9', displayTitle: '归档会话2', updatedAt: MOCK_NOW - 691200000 }, 'a10': { id: 'a10', displayTitle: '归档会话10', updatedAt: MOCK_NOW - 734400000 }, 'a11': { id: 'a11', displayTitle: '归档会话11', updatedAt: MOCK_NOW - 777600000 }, 'a12': { id: 'a12', displayTitle: '归档会话12', updatedAt: MOCK_NOW - 820800000 }, 'u1': { id: 'u1', displayTitle: '未分组归档', updatedAt: MOCK_NOW - 700000000 }, // 已删除但驻留内存的会话(回归:删除后 id 仍留在归档集合,归档视图 // 必须按主机已删除集合把它排除)与未删除的对照组。 'dead1': { id: 'dead1', displayTitle: '已删除驻留', updatedAt: MOCK_NOW - 500000000 }, 'dead2': { id: 'dead2', displayTitle: '未删除对照', updatedAt: MOCK_NOW - 510000000 }, // 恢复后官方列表永不可见的两类:不应出现在归档视图。 'sub1': { id: 'sub1', displayTitle: '子代理归档', updatedAt: MOCK_NOW - 800000000, origin: 'subagent' }, 'blank1': { id: 'blank1', displayTitle: '', updatedAt: MOCK_NOW - 810000000, blank: true }, } const sessionsService = { list: { getSnapshot() { return { current: undefined, byId: sessionsById, ids: Object.keys(sessionsById), } }, subscribe() { return function () {} }, }, binding(id) { bindingIds.push(id) return { session: { rename(title) { renameCalls.push({ sessionId: id, title: title }) return syncPromise({ ok: true, value: { title: title, seq: 1 } }) }, }, } }, fork(opts) { forkCalls.push(opts) return syncPromise('forked-1') }, open(id) { openedSessions.push(id) }, clear() { openedSessions = [] }, refresh() { return Promise.resolve() }, } let bindingIds = [] const archiveSessionCalls = [] // 官方取消归档 API 调用记录(真实运行时优先走它,见 unarchiveRemote)。 let officialUnarchiveCalls = [] // 置真时把 workspaces.unarchiveSession 摘掉,模拟旧版宿主(走插件回退路由)。 let hideOfficialUnarchive = false let renameCalls = [] let forkCalls = [] let wsSnapshot = { items: [{ workspaceId: 'ws-1', title: '项目A', path: '/proj/a', sessionIds: ['s1', 's2', 's3', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'sub1', 'blank1'] }], archivedSessionIds: ['a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'u1', 'sub1', 'blank1'], phase: 'ready', } const workspacesService = { list: { getSnapshot() { return wsSnapshot }, subscribe() { return function () {} }, }, archiveSession(id) { archiveSessionCalls.push(id) wsSnapshot = { ...wsSnapshot, archivedSessionIds: [...wsSnapshot.archivedSessionIds, id] } return Promise.resolve() }, // 官方 IWorkspaces.unarchiveSession:走官方 RPC,并把返回的**完整归档集合** // install 进客户端快照(workspace-controller client model 的 // installArchived)。mock 里快照更新受 holdUnarchiveSnapshot 控制——置真 // 时只记调用、不改快照,精确模拟「RPC 已成功、快照 install 尚未到达」的 // 滞后窗口(7e 用例)。 unarchiveSession(id) { if (hideOfficialUnarchive) return undefined officialUnarchiveCalls.push(id) if (officialUnarchiveFails) return Promise.reject(new Error('unarchive rejected')) if (!holdUnarchiveSnapshot) { wsSnapshot = { ...wsSnapshot, archivedSessionIds: wsSnapshot.archivedSessionIds.filter(x => x !== id), } } return Promise.resolve() }, refresh() { return Promise.resolve() }, } let officialUnarchiveFails = false const registeredDicts = {} const localeListeners = [] // 模拟真实 DSH locale:中文补全包装 translate(模板解析时先查 ZH['*'] 通用词 // 兜底)。真实环境里 dom-enhance 覆盖 locale.translate(DSH 0.1.2 起 lookup 不再 // 公开),dsh-zh-archive 命名空间必须跳过兜底(词典自备完整译文,expand/empty // 等键不能被 ZH['*'] 的「展开」「空」拦截)。 const zhStar = { more: '更多', expand: '展开', collapse: '收起', empty: '空', delete: '删除', rename: '重命名' } const localeService = { getLocale() { return { active: 'zh' } }, register(ns, dicts) { registeredDicts[ns] = dicts return function () { delete registeredDicts[ns] } }, bind(ns) { return function (key, params) { const dict = registeredDicts[ns] let template = dict && dict.zh ? dict.zh[key] : undefined // 模拟中文补全兜底:非 dsh-zh-archive 命名空间先查 ZH['*']。 if (template === undefined && ns !== 'dsh-zh-archive') { template = zhStar[key] } if (template === undefined) return key if (params && typeof params === 'object') { for (const k of Object.keys(params)) template = template.split('{' + k + '}').join(String(params[k])) } return template } }, subscribe(fn) { localeListeners.push(fn) return function () { const i = localeListeners.indexOf(fn) if (i !== -1) localeListeners.splice(i, 1) } }, } let openedSessions = [] let fetchCalls = [] // 模拟「主机写入尚未回灌到客户端快照」的滞后窗口(7e 用例置真)。 let holdUnarchiveSnapshot = false // 已删除会话集合(模拟主机进程内 deletedSessionIds ∪ 回收站清单)。 const mockDeletedIds = new Set() // fetch 返回同步 thenable(立即 resolve),测试可同步断言完整请求链。 globalThis.fetch = function (url, opts) { fetchCalls.push({ url: url, opts: opts }) if (url === '/dsh-zh/api/session.delete') { // 模拟真实主机行为:日志移入回收站 + 从工作区账本移除(无恢复位); // 对仍驻留内存的会话,删除后加入官方归档集合隐藏(上游无按 id 卸载 // live agent 的 API)——因此**已删除的 id 会留在归档集合里**,归档 // 视图必须靠已删除集合过滤,而不能指望集合里没有它;响应全量返回 // 最新已删除集合。 try { const body = JSON.parse(opts.body) mockDeletedIds.add(body.sessionId) wsSnapshot = { ...wsSnapshot, archivedSessionIds: wsSnapshot.archivedSessionIds.includes(body.sessionId) ? wsSnapshot.archivedSessionIds : [...wsSnapshot.archivedSessionIds, body.sessionId], items: wsSnapshot.items.map(function (it) { return { ...it, sessionIds: it.sessionIds.filter(function (id) { return id !== body.sessionId }) } }), } } catch { /* 忽略 */ } return syncPromise({ json: function () { return syncPromise({ ok: true, value: { deletedIds: Array.from(mockDeletedIds) } }) }, }) } if (url === '/dsh-zh/api/session.deleted') { return syncPromise({ json: function () { return syncPromise({ ok: true, value: { ids: Array.from(mockDeletedIds) } }) }, }) } // 模拟主机行为:取消归档只移出归档集合(账本席位保留,恢复原位)。 // holdUnarchiveSnapshot 为真时**不改快照**:模拟真实环境里「主机写入 // 尚未经 follow 流回灌到客户端」的滞后窗口(7e 用例)。 if (url === '/dsh-zh/api/session.unarchive') { if (!holdUnarchiveSnapshot) { try { const body = JSON.parse(opts.body) wsSnapshot = { ...wsSnapshot, archivedSessionIds: wsSnapshot.archivedSessionIds.filter(id => id !== body.sessionId), } } catch { /* 忽略 */ } } } return syncPromise({ json: function () { return syncPromise({ ok: true, value: { unarchived: true } }) }, }) } const ctx = { locale: localeService, get(name) { if (name === 'sessions') return sessionsService if (name === 'workspaces') return workspacesService if (name === 'locale') return localeService return undefined }, effect(fn) { const d = fn(); if (typeof d === 'function') disposers.push(d) }, on() {}, off() {}, } const disposers = [] let fail = 0 let total = 0 function check(actual, expected, label) { total++ if (JSON.stringify(actual) !== JSON.stringify(expected)) { fail++ console.error('MISMATCH ' + label) console.error(' got: ' + JSON.stringify(actual)) console.error(' expected: ' + JSON.stringify(expected)) } } // 归档行容器查询助手(容器注入官方分组容器内,从 body 深度查询)。 const panelOf = function () { return body.querySelector('[data-dsh-zh-archive-section]') } const panelRowsOf = function () { const panel = panelOf() return panel === null ? [] : panel.querySelectorAll('[data-dsh-zh-archive-row]') } // 手动执行排队的 rAF 回调(mock 环境无帧循环)。 const flushRaf = function () { const pending = rafQueue.splice(0) for (const item of pending) if (item !== null) item.fn() } // 等待微任务队列排空(原生 Promise 同步 thenable 会产生多级微任务, // 单次 await 的续体会插队,这里让出足够多轮)。 const flushMicrotasks = async function () { for (let i = 0; i < 10; i += 1) await null } // ---------- 运行插件与断言(async:个别断言点需等待微任务) ---------- const runTests = async function () { pluginExports.apply(ctx) // 1) 词典注册(不再注册槽位) check(registeredDicts['dsh-zh-archive'] !== undefined, true, '注册了 dsh-zh-archive 词典') // 2) 初始无归档行容器 check(panelOf(), null, '未打开时无归档行容器') // 3) 归档按钮注入(真实工作区行 + 未分组行都要有) const lastObs = fakeObs[fakeObs.length - 1] lastObs.cb(undefined) const archiveBtn = wsActions.children.find(c => c.getAttribute('data-dsh-zh-ws-archive') !== null) check(archiveBtn !== undefined, true, '工作区行注入了归档按钮') check(archiveBtn.getAttribute('aria-label'), '查看已归档会话', '归档按钮中文文案') // 操作区顺序:全选按钮在最前,查看归档按钮第二位(全选在归档之前)。 check(wsActions.children[0] !== undefined && wsActions.children[0].getAttribute('data-dsh-zh-ws-selectall') !== null, true, '全选按钮位于操作区最前') check(wsActions.children[1] === archiveBtn, true, '归档按钮位于全选按钮之后') const ungroupedArchiveBtn = ungroupedRow.children.find(c => c.getAttribute('data-dsh-zh-ws-archive') !== null) check(ungroupedArchiveBtn !== undefined, true, '未分组行注入了归档按钮') check(ungroupedRow.getAttribute('data-dsh-zh-ws-row-standalone'), '', '未分组行标记 standalone') // 4) 点击归档按钮 → 归档行注入官方分组容器末尾,渲染 5 行 + 展开按钮 archiveBtn.click('click') let panel = panelOf() check(panel !== null, true, '点击后创建归档行容器(DOM)') check(panel !== null && panel.parentNode === group, true, '归档行容器注入官方分组容器末尾(列表流内,无独立滚动条)') check(panel.querySelector('[data-dsh-zh-archive-label]'), null, '容器无「工作区.已归档会话」标签行') // 4a-0) 菜单卡片样式:半透明底色必须与 backdrop-filter 成对,否则菜单下方 // 内容直接透出(样式写错是静默失败,用断言钉住官方契约)。 const styleTexts = fakeDoc.head.children .filter(c => c.tagName === 'STYLE') .map(c => String(c.textContent)) const viewCss = styleTexts.find(t => t.indexOf('[data-dsh-zh-archive-menu]{') !== -1) || '' const menuRule = viewCss.slice(viewCss.indexOf('[data-dsh-zh-archive-menu]{')) .slice(0, viewCss.slice(viewCss.indexOf('[data-dsh-zh-archive-menu]{')).indexOf('}') + 1) check(menuRule.indexOf('background:var(--dsw-specific-menu)') !== -1, true, '菜单卡片用官方半透明菜单底色') check(menuRule.indexOf('backdrop-filter:var(--dsw-menu-backdrop-filter)') !== -1, true, '菜单卡片配背景模糊(半透明底色的可读性前提)') check(menuRule.indexOf('box-shadow:var(--dsw-elevation-prominent)') !== -1, true, '菜单卡片用官方 elevation-prominent 投影') check(menuRule.indexOf('border:0') !== -1, true, '菜单卡片无边框(官方高层级表面描边走 box-shadow)') check(viewCss.indexOf('[data-dsh-zh-archive-menu-icon] svg{width:14px;height:14px;}') !== -1, true, '菜单图标按官方 .itemIcon svg 收到 14px') // 4a-0b) 归档行读作「不活跃」:标题与时间降到 caption 灰,对齐官方 // Rows.module.css `.sessionRow.archived .title/.time`(样式写错是静默失败)。 check(viewCss.indexOf( '[data-dsh-zh-archive-title],[data-dsh-zh-archive-time]{color:var(--dsw-alias-label-caption)}') !== -1, true, '归档行标题与时间用官方 caption 灰(与官方归档行同款)') // 4a) 切换视图:该工作区的正常会话行被隐藏(归档行顶替其位置), // 工作区行本身保持显示。 check(wsSpan.getAttribute('data-dsh-zh-archive-hides-row'), null, '工作区行不被隐藏') check(sessionSpans[0].getAttribute('data-dsh-zh-archive-hides-row') !== null, true, '正常会话行容器被标记隐藏') check(sessionSpans[0].style.display, 'none', '正常会话行隐藏(查看归档 = 切换视图)') check(sessionSpans.every(s => s.style.display === 'none'), true, '全部正常会话行隐藏') let rows = panelRowsOf() check(rows.length, 5, '默认显示 5 个归档行') let more = panel.querySelectorAll('[data-dsh-zh-archive-more]') check(more.length, 1, '超出时显示展开按钮') // 展开按钮文案必须来自自家词典(zhStar 兜底的「展开」会吞掉 {n} 参数)。 check(more[0].textContent, '再展开 2 个归档', '展开按钮文案(剩余 2 个,不被通用词兜底拦截)') const firstTitle = rows[0].querySelector('[data-dsh-zh-archive-title]') check(firstTitle !== null && firstTitle.textContent === '归档会话1', true, '首行为最近活动的会话(updatedAt 降序)') const shownIds = [] for (const r of rows) shownIds.push(r.getAttribute('data-dsh-zh-archive-id')) check(shownIds, ['a1', 'a2', 'a3', 'a4', 'a5'], '收起态按活动时间降序显示 5 行') // 4b) 子代理/blank 会话不列入归档视图(归档集合 10 个,视图只列 7 个 // 普通会话——恢复后官方列表对这两类永不可见,列出必然「点击即消失」)。 const rowTitles = [] for (const r of rows) { const t = r.querySelector('[data-dsh-zh-archive-title]') if (t !== null) rowTitles.push(t.textContent) } check(rowTitles.includes('子代理归档'), false, '归档视图不列子代理会话') check(rowTitles.includes('blank1'), false, '归档视图不列 blank 会话') // 5) 点展开按钮 → 全部显示 + 按钮变「收起」 more[0].click('click') rows = panelRowsOf() check(rows.length, 7, '展开后显示全部 7 行') more = panel.querySelectorAll('[data-dsh-zh-archive-more]') check(more[0].textContent, '收起', '展开后按钮变「收起」') more[0].click('click') rows = panelRowsOf() check(rows.length, 5, '收起后回到 5 行') // 6) 行尾三点按钮:与官方会话行同位置(hover 显示、时间列让位)。 check(panelRowsOf().some(r => r.querySelectorAll('[data-dsh-zh-archive-actions-button]').length === 1), true, '归档行行尾有三点按钮') check(panelRowsOf().some(r => r.querySelectorAll('[data-dsh-zh-archive-actions]').length === 1), true, '归档行行尾有操作区') // 7) 行点击 → 静默取消归档 + 打开会话;已打开的行**原位保留、外观 // 不变**(标题 + 相对时间,无标记),列表零扰动,可继续点击浏览。 rows = panelRowsOf() const beforeOfficial = officialUnarchiveCalls.length const beforeCalls = fetchCalls.length rows[0].click('click') check(panelOf() !== null, true, '点击归档行后归档视图保持显示') check(officialUnarchiveCalls.length, beforeOfficial + 1, '行点击走官方 unarchiveSession(不经插件路由)') check(officialUnarchiveCalls[officialUnarchiveCalls.length - 1], 'a1', '取消归档目标会话(首行为最近活动的 a1)') check(fetchCalls.length, beforeCalls, '行点击不再走插件 /dsh-zh/api/session.unarchive 路由') // 集合已更新(官方 mock 同步移除 a1):触发重渲染,行原位保留且无 // 「已恢复」之类的标记。 lastObs.cb(undefined) flushRaf() const rowsAfter = panelRowsOf() check(rowsAfter.length, 5, '打开后行原位保留(收起态仍 5 行)') check(rowsAfter[0].getAttribute('data-dsh-zh-archive-id'), 'a1', '已打开行仍在原位置(首位)') check(rowsAfter[0].getAttribute('data-dsh-zh-archive-restored'), null, '已打开行无任何恢复标记') const timeAfter = rowsAfter[0].querySelector('[data-dsh-zh-archive-time]') check(timeAfter !== null && timeAfter.textContent, '1天', '时间列仍显示相对时间(外观不变)') // 展开后总数不变(7 行,已打开行保留在总数中)。 const moreAfter = panelOf().querySelector('[data-dsh-zh-archive-more]') if (moreAfter !== null) { moreAfter.click('click') check(panelRowsOf().length, 7, '展开后仍 7 行(已打开行保留在总数中)') } // 已打开的行再点一次 = 重新打开(幂等取消归档,行仍保留)。 const beforeRecall = officialUnarchiveCalls.length panelRowsOf()[0].click('click') check(officialUnarchiveCalls.length, beforeRecall + 1, '再点已打开行发起(幂等)取消归档请求') lastObs.cb(undefined) flushRaf() check(panelRowsOf().length, 7, '再点后行仍保留') archiveBtn.click('click') check(panelOf(), null, '再点归档按钮退出归档视图') check(sessionSpans.every(s => s.style.display === ''), true, '退出后正常会话行恢复显示') check(sessionSpans.every(s => s.getAttribute('data-dsh-zh-archive-hides-row') === null), true, '退出后隐藏标记清除') // 7b) 渐进展开:每次多展开 5 个,全部展开后变「收起」(大批量归档 // 一次性全展开会卡;此时 ws-1 归档 = a2..a7 + a8..a12 共 11 个)。 for (let i = 8; i <= 12; i += 1) { wsSnapshot.items[0].sessionIds.push('a' + i) wsSnapshot.archivedSessionIds.push('a' + i) } archiveBtn.click('click') let gradualPanel = panelOf() check(gradualPanel !== null, true, '渐进展开测试:进入归档视图') check(panelRowsOf().length, 5, '渐进展开初始 5 行') let gradualMore = gradualPanel.querySelector('[data-dsh-zh-archive-more]') check(gradualMore.textContent, '再展开 5 个归档', '第一批提示再展开 5 个') gradualMore.click('click') check(panelRowsOf().length, 10, '第一次展开后 10 行') gradualMore = panelOf().querySelector('[data-dsh-zh-archive-more]') check(gradualMore.textContent, '再展开 1 个归档', '第二批提示剩余 1 个') gradualMore.click('click') check(panelRowsOf().length, 11, '第二次展开后全部 11 行') gradualMore = panelOf().querySelector('[data-dsh-zh-archive-more]') check(gradualMore.textContent, '收起', '全部展开后按钮变「收起」') gradualMore.click('click') check(panelRowsOf().length, 5, '收起后回到 5 行') archiveBtn.click('click') check(panelOf(), null, '渐进展开测试退出归档视图') // 7c) 归档行跟随官方分组收起/展开:工作区行收起时归档行一并隐藏, // 展开时恢复显示(不退出归档视图)。 archiveBtn.click('click') check(panelOf() !== null, true, '跟随测试:进入归档视图') wsRow.setAttribute('aria-expanded', 'false') lastObs.cb(undefined) flushRaf() let followPanel = panelOf() check(followPanel !== null && followPanel.style.display, 'none', '工作区分组收起时归档行一并隐藏') wsRow.setAttribute('aria-expanded', 'true') lastObs.cb(undefined) flushRaf() followPanel = panelOf() check(followPanel !== null && followPanel.style.display, '', '工作区分组展开时归档行恢复显示') archiveBtn.click('click') check(panelOf(), null, '跟随测试:退出归档视图') // 7d) 归档行三点菜单:重命名 / 分叉会话 / 取消归档 / 删除会话。 archiveBtn.click('click') check(panelOf() !== null, true, '菜单测试:进入归档视图') const menuOf = function () { return body.querySelector('[data-dsh-zh-archive-menu]') } const menuLabelsOf = function (menu) { const out = [] const items = menu === null ? [] : menu.querySelectorAll('[data-dsh-zh-archive-menu-item]') for (const it of items) { const label = it.querySelector('[data-dsh-zh-archive-menu-label]') out.push(label !== null ? label.textContent : '') } return out } let menuRows = panelRowsOf() const actionsOf = function (id) { const row = panelRowsOf().find(r => r.getAttribute('data-dsh-zh-archive-id') === id) return row !== undefined ? row.querySelector('[data-dsh-zh-archive-actions-button]') : null } check(menuRows[0].querySelector('[data-dsh-zh-archive-actions]') !== null, true, '归档行行尾有操作区') check(actionsOf('a2') !== null, true, '归档行行尾有三点按钮') actionsOf('a2').click('click') let menu1 = menuOf() check(menu1 !== null, true, '点击三点打开菜单') // 真实环境:打开菜单触发 observer → rAF 同步 → renderSectionContent // (内容未变跳过重建)——不能把刚打开的菜单关掉。手动放一次同步验证。 lastObs.cb(undefined) flushRaf() menu1 = menuOf() check(menu1 !== null, true, '数据同步(内容未变)不误关菜单') check(menuLabelsOf(menu1), ['重命名', '分叉会话', '取消归档', '删除会话'], '菜单 4 项文案与顺序(归档会话反转为取消归档)') check(menu1.querySelectorAll('[data-dsh-zh-archive-menu-item]')[3].getAttribute('data-dsh-zh-archive-menu-danger'), 'true', '删除会话为危险项(红色)') // Escape 只关菜单,不退出归档视图。 fakeDoc._handlers.keydown({ key: 'Escape' }) check(menuOf(), null, 'Escape 关闭菜单') check(panelOf() !== null, true, 'Escape 关菜单不退出归档视图') // 重命名:输入新标题保存 → per-session rename 通道。 actionsOf('a2').click('click') menuOf().querySelectorAll('[data-dsh-zh-archive-menu-item]')[0].click('click') let renameMask = body.querySelector('[data-dsh-zh-archive-dialog-mask]') check(renameMask !== null, true, '重命名对话框打开') const renameInput = renameMask !== null ? renameMask.querySelector('[data-dsh-zh-archive-rename-input]') : null check(renameInput !== null && renameInput.value, '归档会话2', '重命名输入框预填当前标题') if (renameInput !== null) renameInput.value = '新标题X' if (renameMask !== null) renameMask.querySelectorAll('button')[1].click('click') await flushMicrotasks() // submit 成功关闭对话框在微任务中执行 check(renameCalls.length >= 1 && renameCalls[renameCalls.length - 1].title, '新标题X', '重命名走 per-session rename 通道') check(renameCalls.length >= 1 && renameCalls[renameCalls.length - 1].sessionId, 'a2', '重命名目标会话正确') check(body.querySelector('[data-dsh-zh-archive-dialog-mask]'), null, '重命名成功后对话框关闭') // 分叉会话:fork + increaseTitle,成功后打开副本。 actionsOf('a2').click('click') menuOf().querySelectorAll('[data-dsh-zh-archive-menu-item]')[1].click('click') await flushMicrotasks() // fork 打开副本在微任务中执行 check(forkCalls.length >= 1 && forkCalls[forkCalls.length - 1].sessionId, 'a2', '分叉会话调用 fork') check(forkCalls[forkCalls.length - 1].increaseTitle, true, '分叉自动递增标题(与官方一致)') check(openedSessions.includes('forked-1'), true, '分叉后打开副本会话') // 取消归档(菜单项):行从归档列表消失 + 走官方 API。 actionsOf('a3').click('click') menuOf().querySelectorAll('[data-dsh-zh-archive-menu-item]')[2].click('click') check(menuOf(), null, '选择菜单项后菜单关闭') check(panelRowsOf().some(r => r.getAttribute('data-dsh-zh-archive-id') === 'a3'), false, '取消归档后行从归档列表消失') check(officialUnarchiveCalls.includes('a3'), true, '取消归档走官方 unarchiveSession') check(fetchCalls.some(c => c.url === '/dsh-zh/api/session.unarchive'), false, '取消归档不经插件回退路由(官方 API 在场)') check(panelOf() !== null, true, '取消归档不退出归档视图') // 7e) 快照回灌滞后:真实环境里主机写入经 follow 流回灌前,客户端 // archivedSessionIds 仍含该 id。取消归档必须立刻生效,不能被 // archivedRowsOf 分支重新加回(回归:此前只从 orderedIds 剔除, // 表现为「点取消归档没反应」)。 // 构造:holdUnarchiveSnapshot 让 unarchive 路由**不改快照**, // 精确模拟「主机已确认成功、回灌尚未到达」的窗口。 // // **必须展开到全部行再断言**:被取消归档的行在旧实现里只是「掉出收起态 // 的 5 行窗口」而仍留在列表尾部,只看窗口内会得到假绿(旧 bundle 实测 // 确认过这一点)。展开后行是否真的不在列表里才可判定。 const expandAllRows = function () { for (let i = 0; i < 20; i += 1) { const p = panelOf() const btn = p === null ? null : p.querySelector('[data-dsh-zh-archive-more]') if (btn === null || btn.getAttribute('aria-expanded') === 'true') break btn.click('click') } } holdUnarchiveSnapshot = true expandAllRows() check(panelRowsOf().some(r => r.getAttribute('data-dsh-zh-archive-id') === 'a6'), true, '滞后用例:a6 在取消归档前可见(已展开全部行)') actionsOf('a6').click('click') menuOf().querySelectorAll('[data-dsh-zh-archive-menu-item]')[2].click('click') // 乐观隐藏是同步的(不等 Promise),因此这里先断言「立即消失」。 check(panelRowsOf().some(r => r.getAttribute('data-dsh-zh-archive-id') === 'a6'), false, '滞后用例:取消归档后行立即从列表消失(不等任何异步)') check(wsSnapshot.archivedSessionIds.includes('a6'), true, '滞后用例:此时客户端快照仍含 a6(证明断言确实覆盖滞后窗口)') // 官方 API 的成功回调(dropRow)在微任务里跑:跑完后行仍不在。 await flushMicrotasks() expandAllRows() check(panelRowsOf().some(r => r.getAttribute('data-dsh-zh-archive-id') === 'a6'), false, '滞后用例:成功回调后行仍不回来') // 手动触发一次同步(快照仍含 a6)也不能把行加回来。 lastObs.cb(undefined) flushRaf() expandAllRows() check(panelRowsOf().some(r => r.getAttribute('data-dsh-zh-archive-id') === 'a6'), false, '滞后用例:重新同步后行仍不回来') // 快照更新到达(集合移除 a6)→ 账本自愈清空,行依然不在。 wsSnapshot = { ...wsSnapshot, archivedSessionIds: wsSnapshot.archivedSessionIds.filter(id => id !== 'a6'), } holdUnarchiveSnapshot = false lastObs.cb(undefined) flushRaf() expandAllRows() check(panelRowsOf().some(r => r.getAttribute('data-dsh-zh-archive-id') === 'a6'), false, '滞后用例:快照更新后行仍不在') // 账本不长期遮蔽:a6 重新被归档后应重新出现(自愈已清掉旧账本)。 wsSnapshot = { ...wsSnapshot, archivedSessionIds: [...wsSnapshot.archivedSessionIds, 'a6'] } lastObs.cb(undefined) flushRaf() expandAllRows() check(panelRowsOf().some(r => r.getAttribute('data-dsh-zh-archive-id') === 'a6'), true, '滞后用例:重新归档后 a6 重新出现(账本不长期遮蔽)') // 退出并重进,让收起态与行集合回到基线,供后续用例使用。 archiveBtn.click('click') check(panelOf(), null, '滞后用例:退出归档视图') archiveBtn.click('click') check(panelOf() !== null, true, '滞后用例:重新进入归档视图(收起态)') // 7f) 官方 API 拒绝时撤销乐观隐藏并提示,不制造「行消失但会话没回来」 // 的假成功(2026-09 审计 D3 同类)。 // 用 a7(7e 之后仍可见;失败会撤销,不消耗该行,供后续断言继续使用)。 officialUnarchiveFails = true expandAllRows() check(panelRowsOf().some(r => r.getAttribute('data-dsh-zh-archive-id') === 'a7'), true, '失败用例:a7 在取消归档前可见') actionsOf('a7').click('click') menuOf().querySelectorAll('[data-dsh-zh-archive-menu-item]')[2].click('click') check(panelRowsOf().some(r => r.getAttribute('data-dsh-zh-archive-id') === 'a7'), false, '失败用例:点击后先乐观隐藏') await flushMicrotasks() expandAllRows() check(panelRowsOf().some(r => r.getAttribute('data-dsh-zh-archive-id') === 'a7'), true, '失败用例:官方 API 拒绝后行恢复显示(不做假成功)') const failToast = body.querySelector('.dsh-zh-archive-toast') check(failToast !== null && failToast.textContent.indexOf('取消归档失败') === 0, true, '失败用例:显示取消归档失败提示') officialUnarchiveFails = false // 7g) 旧版宿主回退:官方 unarchiveSession 缺席时走插件主机路由。 // 用 a5(后续用例只用 a4 与 a2,a5 被消耗无影响)。 hideOfficialUnarchive = true expandAllRows() const officialCallsBeforeFallback = officialUnarchiveCalls.length check(wsSnapshot.archivedSessionIds.includes('a5'), true, '回退用例:a5 仍在归档集合') actionsOf('a5').click('click') menuOf().querySelectorAll('[data-dsh-zh-archive-menu-item]')[2].click('click') check(officialUnarchiveCalls.length, officialCallsBeforeFallback, '回退用例:未调用官方 API(缺席)') check(fetchCalls.some(c => c.url === '/dsh-zh/api/session.unarchive' && JSON.parse(c.opts.body).sessionId === 'a5'), true, '回退用例:改走插件主机路由') await flushMicrotasks() expandAllRows() check(panelRowsOf().some(r => r.getAttribute('data-dsh-zh-archive-id') === 'a5'), false, '回退用例:路由成功后行消失') hideOfficialUnarchive = false // 删除会话:确认框 → 确认 → 主机回收站路由 + 行消失。 actionsOf('a4').click('click') menuOf().querySelectorAll('[data-dsh-zh-archive-menu-item]')[3].click('click') const deleteMask = body.querySelector('[data-dsh-zh-archive-dialog-mask]') check(deleteMask !== null, true, '删除会话确认框打开') if (deleteMask !== null) deleteMask.querySelectorAll('button')[1].click('click') check(fetchCalls.some(c => c.url === '/dsh-zh/api/session.delete' && JSON.parse(c.opts.body).sessionId === 'a4'), true, '删除会话调用回收站路由') check(panelRowsOf().some(r => r.getAttribute('data-dsh-zh-archive-id') === 'a4'), false, '删除后行从归档列表消失') check(body.querySelector('.dsh-zh-archive-toast') !== null, true, '删除后显示提示条') // 外部点击关菜单(不退出归档视图)。 actionsOf('a2').click('click') check(menuOf() !== null, true, '重新打开菜单(外部点击测试)') fakeDoc._handlers.pointerdown({ target: outsideEl }) check(menuOf(), null, '外部点击关闭菜单') check(panelOf() !== null, true, '外部点击不退出归档视图') archiveBtn.click('click') check(panelOf(), null, '菜单测试:退出归档视图') // 8) 未分组归档按钮 → 只显示未分组归档会话(u1) ungroupedArchiveBtn.click('click') panel = panelOf() check(panel !== null, true, '未分组归档视图容器创建') check(panel !== null && panel.parentNode === ungroupedGroup, true, '未分组归档行容器注入未分组分组容器') let ungroupedRows = panelRowsOf() check(ungroupedRows.length, 1, '未分组归档视图只显示未分组归档会话') const ungroupedRowTitle = ungroupedRows[0].querySelector('[data-dsh-zh-archive-title]') check(ungroupedRowTitle !== null && ungroupedRowTitle.textContent === '未分组归档', true, '未分组归档行标题正确') ungroupedArchiveBtn.click('click') check(panelOf(), null, '未分组归档视图可再点退出') // 8a) 已删除驻留会话不进入归档视图(回归:批量删除后「未分组」的 // 归档会话里不应看到已删除项)。dead1 曾被打开过(byId 有摘要,模拟 // 驻留内存)、已从账本移除(未分组桶)且删除后仍留在归档集合(主机 // 删除驻留会话时会归档隐藏);归档视图必须按主机已删除集合把它排除, // dead2 对照组正常显示。 wsSnapshot = { items: [{ workspaceId: 'ws-1', title: '项目A', path: '/proj/a', sessionIds: ['s1'] }], archivedSessionIds: ['dead1', 'dead2', 'u1'], phase: 'ready', } mockDeletedIds.clear() mockDeletedIds.add('dead1') ungroupedArchiveBtn.click('click') check(panelOf() !== null, true, '已删除过滤 进入未分组归档视图') const filteredRows = panelRowsOf() check(filteredRows.length, 2, '已删除驻留会话被过滤,仅剩 dead2 与 u1') check(filteredRows.some(r => r.getAttribute('data-dsh-zh-archive-id') === 'dead1'), false, '已删除的 dead1 不显示') check(filteredRows.some(r => r.getAttribute('data-dsh-zh-archive-id') === 'dead2'), true, '未删除的 dead2 正常显示') check(fetchCalls.some(c => c.url === '/dsh-zh/api/session.deleted'), true, '进入归档视图拉取已删除集合') ungroupedArchiveBtn.click('click') check(panelOf(), null, '已删除过滤 退出归档视图') // dead1 从已删除集合移除(如恢复)后重新可见:过滤只针对已删除集合。 mockDeletedIds.clear() ungroupedArchiveBtn.click('click') check(panelRowsOf().some(r => r.getAttribute('data-dsh-zh-archive-id') === 'dead1'), true, '不在已删除集合时恢复显示(对照组反向验证)') ungroupedArchiveBtn.click('click') check(panelOf(), null, '已删除过滤 反向验证后退出') // 9) 空归档集合 → 空状态 wsSnapshot = { items: [{ workspaceId: 'ws-1', title: '项目A', path: '/proj/a', sessionIds: ['s1'] }], archivedSessionIds: [], phase: 'ready', } archiveBtn.click('click') panel = panelOf() const emptyEls = panel.querySelectorAll('[data-dsh-zh-archive-empty]') check(emptyEls.length, 1, '空归档集合显示空状态') check(emptyEls[0].textContent, '暂无归档会话', '空状态文案') archiveBtn.click('click') check(panelOf(), null, '再点归档按钮退出视图') // 10) Escape 退出 archiveBtn.click('click') check(panelOf() !== null, true, '重新进入归档视图') if (fakeDoc._handlers.keydown) { fakeDoc._handlers.keydown({ key: 'Escape' }) check(panelOf(), null, 'Escape 退出归档视图') } // 10b) 相对时间刷新定时器:进入时启动 60s 定时器,退出时停止。 const intervalsBefore = intervals.filter(i => i !== null).length archiveBtn.click('click') check(intervals.filter(i => i !== null).length, intervalsBefore + 1, '进入归档视图启动相对时间刷新定时器') archiveBtn.click('click') check(intervals.filter(i => i !== null).length, intervalsBefore, '退出归档视图停止相对时间刷新定时器') // 10c) 外部点击不退出:点击归档行容器外、非「新建会话」按钮的任意 // 区域,归档视图保持(只有再点归档按钮才切回默认列表)。 archiveBtn.click('click') check(panelOf() !== null, true, '重新进入归档视图(外部点击测试)') if (fakeDoc._handlers.pointerdown) { fakeDoc._handlers.pointerdown({ target: outsideEl }) check(panelOf() !== null, true, '点击列表外区域不退出归档视图') // 点击归档行容器自身区域(如行间隙)同样不退出。 const panelBody = panelOf() if (panelBody !== null) { fakeDoc._handlers.pointerdown({ target: panelBody }) check(panelOf() !== null, true, '点击归档行容器区域不退出归档视图') } } archiveBtn.click('click') check(panelOf(), null, '再点归档按钮退出归档视图') // 10d) 点击「新建会话」按钮退出归档视图(按钮位于工作区行操作区, // 退出逻辑在文档级 pointerdown 捕获里,命中官方新建会话按钮即退出)。 archiveBtn.click('click') check(panelOf() !== null, true, '重新进入归档视图(新建会话测试)') if (fakeDoc._handlers.pointerdown) { fakeDoc._handlers.pointerdown({ target: addBtn }) check(panelOf(), null, '点击新建会话按钮退出归档视图') } // 11) 查看已归档开关:关闭 = 完全不注册(不注入按钮/样式/词典/observer // 副作用),开启 = 重新注册;运行时翻转立即生效。 const settingsStoreUnderTest = pluginExports.settingsStore check(settingsStoreUnderTest.getSnapshot().archiveViewEnabled, true, '查看已归档 默认开启') // 11a) 关闭开关:已注入的按钮、样式、词典、监听器全部卸载。 settingsStoreUnderTest.set('archiveViewEnabled', false) check(wsActions.children.find(c => c.getAttribute('data-dsh-zh-ws-archive') !== null), undefined, '关闭开关 工作区行归档按钮移除') check(ungroupedRow.children.find(c => c.getAttribute('data-dsh-zh-ws-archive') !== null), undefined, '关闭开关 未分组行归档按钮移除') check(ungroupedRow.getAttribute('data-dsh-zh-ws-row-standalone'), null, '关闭开关 standalone 标记清除') check(registeredDicts['dsh-zh-archive'], undefined, '关闭开关 归档词典注销') check(panelOf(), null, '关闭开关 无归档行容器') // 归档样式按 `data-plugin-css` 识别(head 里还可能有本插件其它模块的样式, // 如「已删除会话行隐藏」的 deleted-rows 样式——它与归档视图开关无关)。 const archiveStylesOf = function () { return fakeDoc.head.children.filter(c => c.tagName === 'STYLE' && String(c.getAttribute('data-plugin-css') || '').indexOf('dsh-zh/archive') === 0) } const stylesAfterOff = archiveStylesOf() check(stylesAfterOff.length, 0, '关闭开关 归档样式移除') // 11b) 关闭状态下点击归档按钮不生效(按钮已不存在,直接验证无残留副作用): // 归档视图自己的 observer 已全部 disconnect(真实环境不再注入按钮), // 且 ARCHIVE_ROW_MARK 标记已清除。 check(ungroupedRow.getAttribute('data-dsh-zh-ws-archive-row'), null, '关闭开关 ARCHIVE_ROW_MARK 标记清除') // 关闭状态下即便手动触发(模拟残留回调),也不应注入新的归档按钮。 lastObs.cb(undefined) check(wsActions.children.find(c => c.getAttribute('data-dsh-zh-ws-archive') !== null), undefined, '关闭状态 observer 不再注入归档按钮') // 11c) 重新开启:按钮、词典、样式恢复,功能可用。 settingsStoreUnderTest.set('archiveViewEnabled', true) lastObs.cb(undefined) const archiveBtnRe = wsActions.children.find(c => c.getAttribute('data-dsh-zh-ws-archive') !== null) check(archiveBtnRe !== undefined, true, '重新开启 工作区行归档按钮恢复') check(registeredDicts['dsh-zh-archive'] !== undefined, true, '重新开启 归档词典恢复') check(archiveStylesOf().length, 2, '重新开启 归档样式恢复(2 个)') archiveBtnRe.click('click') check(panelOf() !== null, true, '重新开启 归档视图可用') archiveBtnRe.click('click') check(panelOf(), null, '重新开启 归档视图可退出') // 11d) 会话多选:行首空图标位复选框 + 多选 + 批量菜单项 + 批量删除/归档。 // 全量扫描走 bundle 导出的确定性 pass(archive-view 内部还有多个 observer, // 按索引定位不可靠)。 check(typeof pluginExports.sessionBatch === 'object' && typeof pluginExports.sessionBatch.pass === 'function', true, '批量操作模块已安装并暴露测试入口') const batchList = new FakeEl('div') body.appendChild(batchList) function makeBatchRow(id, opts) { const row = new FakeEl('div', { role: 'treeitem', class: 'Rows_sessionRow__x' }) if (!opts.noSlot) { const slot = new FakeEl('span', { class: 'Rows_slot__y' }) if (opts.icon === 'ongoing') { slot.appendChild(new FakeEl('svg', { 'data-state': 'ongoing' })) } else if (opts.icon === 'done') { slot.appendChild(new FakeEl('span', { 'data-state': 'done' })) } row.appendChild(slot) } const title = new FakeEl('span', { class: 'Rows_title__z' }) title.textContent = opts.title row.appendChild(title) if (id !== null) row['__reactFiber$batch'] = { memoizedProps: { node: { id: id } }, return: null } batchList.appendChild(row) return row } const r1 = makeBatchRow('a1', { title: '会话1' }) const r2 = makeBatchRow('a2', { title: '会话2', icon: 'ongoing' }) const r3 = makeBatchRow('a3', { title: '会话3', icon: 'done' }) const r4 = makeBatchRow('blank1', { title: '' }) const r5 = makeBatchRow('a4', { title: '会话4', noSlot: true }) const projRow = new FakeEl('div', { role: 'treeitem', class: 'Rows_projectRow__x' }) batchList.appendChild(projRow) const batchChecksOf = function () { return batchList.querySelectorAll('input[data-dsh-zh-batch-check]') } pluginExports.sessionBatch.pass() let batchChecks = batchChecksOf() check(batchChecks.length, 1, '批量 只有空闲会话行注入复选框') check(batchChecks[0].parentNode === r1.children[0], true, '批量 复选框位于行首 slot') check(batchChecks[0].checked, false, '批量 初始未勾选') // 勾选 → 多选非空 → 三点菜单追加批量项(portal 菜单节点直接交给菜单 observer)。 r1.children[0].children[0].checked = true r1.children[0].children[0].click('change') // **官方菜单项夹具必须复刻真实 DOM 的两个要素**(否则测试会假绿): // 1. 文案在 `span[class*=itemLabel]` 里,按钮不是「只有一个 span」; // 2. 带快捷键的项还挂一个 `span[aria-hidden=true]` 徽标 // (`Ctrl+Alt+A`),因此真实 DOM 的 // `button.textContent` 是「归档会话Ctrl+Alt+A」。 // 2026-09-24 真实 GUI 回归:官方给会话菜单项加了快捷键徽标后,「删除会话」 // 与批量项全部消失——锚点用 `button.textContent === '归档会话'` 等值匹配, // 徽标一进 textContent 就失配。夹具必须如实拼出这个派生结果。 function makeOfficialMenuItem(menu, label, shortcutKeys) { const itemWrap = new FakeEl('div', { class: '_itemWrap_gzo7u_90' }) const btn = new FakeEl('button', { role: 'menuitem' }) if (shortcutKeys !== undefined) btn.setAttribute('aria-keyshortcuts', shortcutKeys.join('+')) const icon = new FakeEl('span', { class: '_itemIcon_gzo7u_148' }) const lbl = new FakeEl('span', { class: '_itemLabel_gzo7u_190' }) lbl.textContent = label btn.appendChild(icon) btn.appendChild(lbl) if (shortcutKeys !== undefined) { const badge = new FakeEl('span', { class: '_shortcut_gzo7u_198', 'aria-hidden': 'true' }) const keys = new FakeEl('span', { class: '_keys_38b9q_1' }) for (const key of shortcutKeys) keys.appendChild(new FakeEl('kbd', { class: '_key_38b9q_1' })) badge.appendChild(keys) btn.appendChild(badge) } // FakeEl 的 textContent 不从子节点派生:显式拼出真实 DOM 的派生结果。 btn.textContent = shortcutKeys === undefined ? label : label + shortcutKeys.join('+') itemWrap.appendChild(btn) menu.appendChild(itemWrap) return itemWrap } function makeOfficialMenu() { const menu = new FakeEl('div', { role: 'menu' }) makeOfficialMenuItem(menu, '置顶会话') makeOfficialMenuItem(menu, '重命名', ['Ctrl', 'Shift', 'R']) makeOfficialMenuItem(menu, '分叉会话', ['Ctrl', 'Shift', 'F']) makeOfficialMenuItem(menu, '归档会话', ['Ctrl', 'Alt', 'A']) return menu } // 菜单 observer 按行为探测:各模块 observer 的数量与顺序脆弱, // 用「注入后给菜单打 data-dsh-zh-delete-session 标记」这一确定事实定位。 // **必须快照 fakeObs 再遍历**:archive-view 的保活回调会在 cb 内创建新 // observer,直接遍历活数组会无限循环——而锚点失配时(正是本用例要抓的 // 回归)探针永远找不到目标、必然走完整个数组,于是挂死而不是报错。 let menuObs = null for (const obs of fakeObs.slice()) { const probe = makeOfficialMenu() obs.cb([{ addedNodes: [probe], target: body }]) if (probe.getAttribute('data-dsh-zh-delete-session') !== null) { menuObs = obs; break } } check(menuObs !== null, true, '定位会话菜单 observer') const officialMenu = makeOfficialMenu() menuObs.cb([{ addedNodes: [officialMenu], target: body }]) const batchItems = officialMenu.querySelectorAll('button[data-dsh-zh-batch-menuitem]') check(batchItems.length, 2, '批量 多选后菜单注入两个批量项') check(batchItems[0].querySelector('span:last-child').textContent, '批量删除(1)', '批量删除文案带计数') check(batchItems[1].querySelector('span:last-child').textContent, '批量归档(1)', '批量归档文案带计数') // 官方四项原样保留,批量项排在「归档会话」之后。 check(officialMenu.querySelectorAll('[role="menuitem"]').length, 6, '批量 官方菜单项不被替换') check(officialMenu.querySelectorAll('button[data-dsh-zh-batch-menuitem]').length, 2, '带快捷键徽标的官方菜单仍注入批量项(锚点只读 itemLabel)') // 注:「删除会话」项需要行上下文(lastEllipsisRow),在下面的归档行用例里 // 连同快捷键徽标一起验证。 // 11d-2) 官方「显示已归档」视图的归档行:菜单锚点是「取消归档」而不是 // 「归档会话」,此前匹配落空 → 已归档会话拿不到「删除会话」(官方明确不做 // 删除,这是唯一盲区)。用与官方归档行同构的菜单验证锚点已扩展。 function makeArchivedOfficialMenu() { const menu = new FakeEl('div', { role: 'menu' }) makeOfficialMenuItem(menu, '置顶会话') makeOfficialMenuItem(menu, '重命名', ['Ctrl', 'Shift', 'R']) makeOfficialMenuItem(menu, '分叉会话', ['Ctrl', 'Shift', 'F']) makeOfficialMenuItem(menu, '取消归档', ['Ctrl', 'Alt', 'U']) return menu } // 归档行上也要能解析出会话 id:行 fiber 与普通行同构。 const archivedRowForMenu = makeBatchRow('a1', { title: '已归档会话' }) archivedRowForMenu.setAttribute('class', 'Rows_sessionRow__x Rows_archived__y') pluginExports.sessionBatch.pass() const archivedRowActions = new FakeEl('button', { 'aria-label': '会话“已归档会话”的操作' }) archivedRowForMenu.appendChild(archivedRowActions) // fakeDoc 的 pointerdown 现在会转发给**全部**注册的监听器(archive-view 与 // session-menu 两家),与真实 DOM 一致。 fakeDoc._handlers.pointerdown({ target: archivedRowActions }) const archivedOfficialMenu = makeArchivedOfficialMenu() menuObs.cb([{ addedNodes: [archivedOfficialMenu], target: body }]) const archivedDeleteItem = archivedOfficialMenu.querySelector('button[data-dsh-zh-delete-session]') check(archivedDeleteItem !== null, true, '官方归档行菜单注入「删除会话」(锚点扩展到「取消归档」)') // 注入项紧随「取消归档」之后(锚点即插入位置),且官方四项原样保留。 // 按 label span 读文案(不是 span:last-child——带快捷键的项最后一个是徽标)。 const menuItemLabelOf = function (btn) { const lbl = btn.querySelector('span[class*="itemLabel"]') if (lbl !== null) return String(lbl.textContent) return String(btn.textContent) } const archivedMenuLabels = [] for (const it of archivedOfficialMenu.querySelectorAll('[role="menuitem"]')) { archivedMenuLabels.push(menuItemLabelOf(it)) } check(archivedMenuLabels.slice(0, 5), ['置顶会话', '重命名', '分叉会话', '取消归档', '删除会话'], '官方归档行菜单顺序:官方四项 + 紧随其后的删除项') // 插件自建归档菜单(data-dsh-zh-archive-menu)自带取消归档与删除项, // 不能再被本 observer 注入一遍(否则出现重复「删除会话」)。 const ownMenu = new FakeEl('div', { role: 'menu', 'data-dsh-zh-archive-menu': '' }) const ownItem = new FakeEl('div') const ownBtn = new FakeEl('button', { role: 'menuitem' }) ownBtn.textContent = '取消归档' ownItem.appendChild(ownBtn) ownMenu.appendChild(ownItem) menuObs.cb([{ addedNodes: [ownMenu], target: body }]) check(ownMenu.querySelector('button[data-dsh-zh-delete-session]'), null, '自建归档菜单不被重复注入删除项') check(ownMenu.getAttribute('data-dsh-zh-delete-session'), null, '自建归档菜单不打注入标记') batchList.removeChild(archivedRowForMenu) // 批量删除:确认框 → 确认 → 逐个调用主机删除路由 → 清空多选。 const deleteCallsBefore = fetchCalls.filter(f => f.url === '/dsh-zh/api/session.delete').length batchItems[0].click('click') const confirmOverlaysOf = function () { return body.children.filter(c => c.tagName === 'DIV' && c.style && String(c.style.cssText).indexOf('z-index:1200') !== -1) } check(confirmOverlaysOf().length, 1, '批量删除 弹确认框') const confirmButtons = confirmOverlaysOf()[0].querySelectorAll('button') confirmButtons[confirmButtons.length - 1].click('click') await flushMicrotasks() const deleteCallsAfter = fetchCalls.filter(f => f.url === '/dsh-zh/api/session.delete') check(deleteCallsAfter.length, deleteCallsBefore + 1, '批量删除 发出删除请求') check(JSON.parse(deleteCallsAfter[deleteCallsAfter.length - 1].opts.body).sessionId, 'a1', '批量删除 会话 id 正确') check(batchChecksOf().length === 1 && batchChecksOf()[0].checked, false, '批量删除完成后清空多选(勾选态还原)') check(confirmOverlaysOf().length, 0, '批量删除 确认框已关闭') // 批量归档:此时多选里的 a1 **已归档**(前面「批量取消归档」用例把它重新 // 归档了),归档方向项因此是「批量取消归档」——先验证该方向,再验证未归档 // 多选给出「批量归档」。 r1.children[0].children[0].checked = true r1.children[0].children[0].click('change') const archiveMenu = makeOfficialMenu() menuObs.cb([{ addedNodes: [archiveMenu], target: body }]) const archiveBatchItems = archiveMenu.querySelectorAll('button[data-dsh-zh-batch-menuitem]') check(archiveBatchItems.length, 2, '批量 重新打开菜单仍注入批量项') check((archiveBatchItems[1].querySelector('span:last-child') || {}).textContent, '批量取消归档(1)', '已归档多选给出「批量取消归档」(归档构成判定)') const officialUnarchiveBeforeMenu = officialUnarchiveCalls.length archiveBatchItems[1].click('click') check(confirmOverlaysOf().length, 1, '批量取消归档 弹确认框') const archiveConfirmButtons = confirmOverlaysOf()[0].querySelectorAll('button') archiveConfirmButtons[archiveConfirmButtons.length - 1].click('click') await flushMicrotasks() check(officialUnarchiveCalls.length, officialUnarchiveBeforeMenu + 1, '批量取消归档 走官方 unarchiveSession') check(officialUnarchiveCalls[officialUnarchiveCalls.length - 1], 'a1', '批量取消归档 目标会话正确') check(batchChecksOf()[0].checked, false, '批量取消归档完成后清空多选') check(confirmOverlaysOf().length, 0, '批量取消归档 确认框已关闭') // 批量归档:a1 现已取消归档(上一段的效果),归档方向项翻转为「批量归档」, // 确认后走官方 archiveSession。 r1.children[0].children[0].checked = true r1.children[0].children[0].click('change') const reArchiveMenu = makeOfficialMenu() menuObs.cb([{ addedNodes: [reArchiveMenu], target: body }]) const reArchiveItems = reArchiveMenu.querySelectorAll('button[data-dsh-zh-batch-menuitem]') check((reArchiveItems[1].querySelector('span:last-child') || {}).textContent, '批量归档(1)', '未归档多选给出「批量归档」(归档构成判定)') reArchiveItems[1].click('click') check(confirmOverlaysOf().length, 1, '批量归档 弹确认框') const reArchiveConfirmButtons = confirmOverlaysOf()[0].querySelectorAll('button') reArchiveConfirmButtons[reArchiveConfirmButtons.length - 1].click('click') await flushMicrotasks() check(archiveSessionCalls.indexOf('a1') !== -1, true, '批量归档 调用官方 archiveSession') check(batchChecksOf()[0].checked, false, '批量归档完成后清空多选') check(confirmOverlaysOf().length, 0, '批量归档 确认框已关闭') // 混选(一个已归档 + 一个未归档)→ 归档方向项消失,只剩「批量删除」: // 归档/取消归档都只对一半选中项成立,提供任一项都会误导。 // a1 刚被批量归档回已归档态;再加一个未归档的 n1 一起勾选。 // 注意 makeBatchRow 自身已把行 append 进 batchList,不要再 append 一次 // (FakeEl 的 children 是数组、appendChild 不去重,重复 append 会让 // removeChild 只摘掉一份、残留的副本继续被扫描到)。 const n1Batch = makeBatchRow('n1', { title: '未归档会话' }) pluginExports.sessionBatch.pass() const mixedChecks = batchChecksOf() check(mixedChecks.length, 2, '混选 两个空闲行都有复选框') for (const box of mixedChecks) { box.checked = true box.click('change') } check(pluginExports.sessionBatch.selectionSize(), 2, '混选 勾选两个会话') const mixedMenu = makeOfficialMenu() menuObs.cb([{ addedNodes: [mixedMenu], target: body }]) const mixedItems = mixedMenu.querySelectorAll('button[data-dsh-zh-batch-menuitem]') check(mixedItems.length, 1, '混选 只注入一个批量项') check((mixedItems[0].querySelector('span:last-child') || {}).textContent, '批量删除(2)', '混选 只保留「批量删除」(归档/取消归档都只对一半成立)') // 清理:取消混选并移除 n1 行,避免影响后续用例。 for (const box of batchChecksOf()) { box.checked = false box.click('change') } batchList.removeChild(n1Batch) pluginExports.sessionBatch.pass() // 行首出现官方图标(如会话开始运行)→ 复选框被移除,选择一并丢弃。 r1.children[0].children[0].checked = true r1.children[0].children[0].click('change') const runningSvg = new FakeEl('svg', { 'data-state': 'ongoing' }) r1.children[0].appendChild(runningSvg) pluginExports.sessionBatch.pass() check(batchChecksOf().length, 0, '批量 slot 出现官方图标后复选框被移除') // 回归保护:observer 回调的行内变化路径(target=slot、addedNodes=图标)。 // 全量 pass 走不到这条路径——回调必须 target.closest(BATCH_ROW_SELECTOR) // 向上找行扫描,否则真实页面里「图标出现 → 摘除复选框」永远不触发 // (2026-08-29 真实 GUI 验收发现并修复)。 // 先移除 r1 图标让其回到空闲(复选框恢复),勾选后再经回调路径注入图标。 r1.children[0].removeChild(runningSvg) pluginExports.sessionBatch.pass() check(batchChecksOf().length, 1, '批量 图标移除后空闲行复选框恢复') r1.children[0].children[0].checked = true r1.children[0].children[0].click('change') const iconSvg = new FakeEl('svg', { 'data-state': 'ongoing' }) r1.children[0].appendChild(iconSvg) // 快照后遍历:archive-view 的保活回调会在 cb 内创建新 observer,直接 // for...of 活数组会无限循环。 for (const obs of fakeObs.slice()) { obs.cb([{ type: 'childList', addedNodes: [iconSvg], target: r1.children[0] }]) } check(batchChecksOf().length, 0, '批量 回调行内路径同样摘除复选框(回归保护)') // 开关关闭:移除全部复选框并清空选择;重新开启:重新注入。 const r7 = makeBatchRow('a5', { title: '会话5' }) settingsStoreUnderTest.set('batchOpsEnabled', false) check(batchChecksOf().length, 0, '关闭批量开关后移除全部复选框') settingsStoreUnderTest.set('batchOpsEnabled', true) pluginExports.sessionBatch.pass() batchChecks = batchChecksOf() check(batchChecks.length, 1, '重新开启批量开关后空闲行恢复复选框') check(batchChecks[0].closest('div[class*="sessionRow"][role="treeitem"]') === r7, true, '批量 复选框位于新空闲行') // 「批量删除」跟随「会话删除按钮」开关:删除入口整体隐藏时,多选菜单 // 只保留「批量归档」,不单独出现删除入口。 r7.children[0].children[0].checked = true r7.children[0].children[0].click('change') settingsStoreUnderTest.set('deleteSessionEnabled', false) const deleteOffMenu = makeOfficialMenu() for (const obs of fakeObs.slice()) { obs.cb([{ type: 'childList', addedNodes: [deleteOffMenu], target: body }]) } const deleteOffBatch = deleteOffMenu.querySelectorAll('button[data-dsh-zh-batch-menuitem]') check(deleteOffBatch.length, 1, '关闭删除开关后多选菜单只剩批量归档') check((deleteOffBatch[0].querySelector('span:last-child') || {}).textContent, '批量归档(1)', '关闭删除开关后批量归档仍在') check(deleteOffMenu.querySelector('button[data-dsh-zh-delete-session]'), null, '关闭删除开关后单项删除也不注入') settingsStoreUnderTest.set('deleteSessionEnabled', true) const deleteOnMenu = makeOfficialMenu() for (const obs of fakeObs.slice()) { obs.cb([{ type: 'childList', addedNodes: [deleteOnMenu], target: body }]) } check(deleteOnMenu.querySelectorAll('button[data-dsh-zh-batch-menuitem]').length, 2, '重新开启删除开关后批量删除恢复') // 单项「删除会话」依赖 lastEllipsisRow 解析 sessionId(mock 无 fiber 链), // 该项注入在真实 GUI 验收覆盖,这里不断言。 // 11e) 归档视图多选:归档行出现复选框(与官方行多选共用 data-dsh-zh-batch-check // 标记与多选状态),勾选不触发「取消归档 + 打开」;三点菜单追加「批量取消 // 归档 / 批量删除」,执行后批量路由正确、多选清空、行消失。 // (归档集合在 10d 后为空:先为 ws-1 补充归档会话;并清掉 11d 段残留的 // 官方行勾选,从干净的多选状态开始。) // (另外清空已删除集合:11d 批量删除的 a1 已被新 mock 记入,不清会让 // 本段 a1 归档行被已删除过滤隐藏。) mockDeletedIds.clear() settingsStoreUnderTest.set('batchOpsEnabled', false) settingsStoreUnderTest.set('batchOpsEnabled', true) pluginExports.sessionBatch.pass() check(pluginExports.sessionBatch.selectionSize(), 0, '归档多选 初始多选为空') wsSnapshot = { items: [{ workspaceId: 'ws-1', title: '项目A', path: '/proj/a', sessionIds: ['a1', 'a2', 'a3'] }], archivedSessionIds: ['a1', 'a2', 'a3'], phase: 'ready', } // 11a/11c 开关重启会移除并重新注入归档按钮:必须动态获取当前活动按钮 // (旧按钮属于已卸载的实例,其 settings 订阅已注销)。 const liveWsArchiveBtn = function () { return wsActions.children.find(c => c.getAttribute('data-dsh-zh-ws-archive') !== null) } liveWsArchiveBtn().click('click') check(panelOf() !== null, true, '归档多选 进入归档视图') check(panelRowsOf().length, 3, '归档多选 渲染 3 行') const archiveSlotChecks = function () { const out = [] for (const r of panelRowsOf()) out.push(r.querySelector('input[data-dsh-zh-batch-check]')) return out } check(archiveSlotChecks().every(b => b !== null), true, '归档多选 每行槽位都有复选框') const checkOfArchived = function (id) { const row = panelRowsOf().find(r => r.getAttribute('data-dsh-zh-archive-id') === id) return row !== undefined && row !== null ? row.querySelector('input[data-dsh-zh-batch-check]') : null } // 归档行多选复选框计数(panelRowsOf 逐行查;FakeEl 的 matchSel 不支持 // `[..] input[..]` 后代选择器,不能在 body 上一次查)。 const archiveCheckCount = function () { let n = 0 for (const r of panelRowsOf()) { if (r.querySelector('input[data-dsh-zh-batch-check]') !== null) n += 1 } return n } // 勾选第一行(a1):多选生效且不打开会话(点击阻止冒泡)。 const openCountBefore = openedSessions.length const a1Check = checkOfArchived('a1') a1Check.checked = true a1Check.click('change') check(pluginExports.sessionBatch.selectionSize(), 1, '归档多选 勾选进入多选状态') check(openedSessions.length, openCountBefore, '归档多选 勾选不打开会话') // 三点菜单在多选非空时追加「批量取消归档(1)/ 批量删除(1)」(排在 // 现有 4 项之后)。同一行三点按钮的习惯是 toggle:再点一次是关闭菜单。 const a1Actions = panelRowsOf().find(r => r.getAttribute('data-dsh-zh-archive-id') === 'a1') .querySelector('[data-dsh-zh-archive-actions-button]') a1Actions.click('click') check(menuOf() !== null, true, '归档多选 打开三点菜单') check(menuLabelsOf(menuOf()), ['重命名', '分叉会话', '取消归档', '删除会话', '批量取消归档(1)', '批量删除(1)'], '归档多选 批量项文案与顺序') a1Actions.click('click') check(menuOf() === null, true, '归档多选 再点三点关闭菜单') // 取消勾选 → 多选为空 → 打开 a2 的菜单不再出现批量项(每行第一次点击 // 打开,不依赖同行 toggle 的菜单关闭)。 a1Check.checked = false a1Check.click('change') check(pluginExports.sessionBatch.selectionSize(), 0, '归档多选 取消勾选清空多选') const a2MenuRow = panelRowsOf().find(r => r.getAttribute('data-dsh-zh-archive-id') === 'a2') a2MenuRow.querySelector('[data-dsh-zh-archive-actions-button]').click('click') check(menuLabelsOf(menuOf()), ['重命名', '分叉会话', '取消归档', '删除会话'], '归档多选 多选为空时无批量项') // 恢复勾选 a1 → 打开 a1 的菜单(a1 行已关闭过,点击即重开),批量项恢复。 const a1CheckAgain = checkOfArchived('a1') a1CheckAgain.checked = true a1CheckAgain.click('change') check(pluginExports.sessionBatch.selectionSize(), 1, '归档多选 重新勾选') const a1Actions3 = panelRowsOf().find(r => r.getAttribute('data-dsh-zh-archive-id') === 'a1') .querySelector('[data-dsh-zh-archive-actions-button]') a1Actions3.click('click') check(menuLabelsOf(menuOf()), ['重命名', '分叉会话', '取消归档', '删除会话', '批量取消归档(1)', '批量删除(1)'], '归档多选 重新勾选后批量项恢复') // 批量取消归档:确认框 → 确认 → 官方 unarchiveSession → 行消失 → 清空多选。 const unarchiveOfficialBeforeBatch = officialUnarchiveCalls.length const archiveBatchItem = menuOf().querySelectorAll('[data-dsh-zh-archive-menu-item]')[4] archiveBatchItem.click('click') const batchUnarchiveMask = body.querySelector('[data-dsh-zh-archive-dialog-mask]') check(batchUnarchiveMask !== null, true, '归档 批量取消归档弹确认框') batchUnarchiveMask.querySelectorAll('button')[1].click('click') await flushMicrotasks() check(officialUnarchiveCalls.length, unarchiveOfficialBeforeBatch + 1, '归档 批量取消归档走官方 unarchiveSession') check(officialUnarchiveCalls[officialUnarchiveCalls.length - 1], 'a1', '归档 批量取消归档目标为 a1') check(panelRowsOf().some(r => r.getAttribute('data-dsh-zh-archive-id') === 'a1'), false, '归档 批量取消归档后 a1 行消失') check(pluginExports.sessionBatch.selectionSize(), 0, '归档 批量取消归档后清空多选') // 批量删除:勾选 a2 → 打开 a2 菜单(跨行切换不关闭旧菜单)→「批量删除(1)」 // 确认框 → 主机删除路由(回收站)→ 行消失 → 清空多选。 const a2Check = checkOfArchived('a2') a2Check.checked = true a2Check.click('change') check(pluginExports.sessionBatch.selectionSize(), 1, '归档 批量删除前勾选 a2') const deleteCallsBeforeBatch = fetchCalls.filter(c => c.url === '/dsh-zh/api/session.delete' && JSON.parse(c.opts.body).sessionId === 'a2').length const a2Actions = panelRowsOf().find(r => r.getAttribute('data-dsh-zh-archive-id') === 'a2') .querySelector('[data-dsh-zh-archive-actions-button]') a2Actions.click('click') check(menuLabelsOf(menuOf()), ['重命名', '分叉会话', '取消归档', '删除会话', '批量取消归档(1)', '批量删除(1)'], '归档 批量删除 批量项文案与顺序') const batchDeleteItem = menuOf().querySelectorAll('[data-dsh-zh-archive-menu-item]')[5] batchDeleteItem.click('click') const batchDeleteMask = body.querySelector('[data-dsh-zh-archive-dialog-mask]') check(batchDeleteMask !== null, true, '归档 批量删除弹确认框') batchDeleteMask.querySelectorAll('button')[1].click('click') await flushMicrotasks() check(fetchCalls.filter(c => c.url === '/dsh-zh/api/session.delete' && JSON.parse(c.opts.body).sessionId === 'a2').length, deleteCallsBeforeBatch + 1, '归档 批量删除调用删除路由(a2)') check(pluginExports.sessionBatch.selectionSize(), 0, '归档 批量删除后清空多选') check(panelRowsOf().some(r => r.getAttribute('data-dsh-zh-archive-id') === 'a2'), false, '归档 批量删除后 a2 行消失') // 「批量删除」跟随「会话删除按钮」开关:关闭后只留「批量取消归档」。 // 开关关闭时单项「删除会话」同样隐藏(与官方行菜单一致)。 // (settings 订阅会重建归档行;勾选态从多选状态同步,重建后仍保留。) const a3Check = checkOfArchived('a3') a3Check.checked = true a3Check.click('change') settingsStoreUnderTest.set('deleteSessionEnabled', false) const a3Actions = panelRowsOf().find(r => r.getAttribute('data-dsh-zh-archive-id') === 'a3') .querySelector('[data-dsh-zh-archive-actions-button]') a3Actions.click('click') check(menuLabelsOf(menuOf()), ['重命名', '分叉会话', '取消归档', '批量取消归档(1)'], '归档多选 关闭删除开关后只留批量取消归档') settingsStoreUnderTest.set('deleteSessionEnabled', true) // 开关关闭 batchOpsEnabled:复选框移除、多选清空;重新开启:复选框恢复。 settingsStoreUnderTest.set('batchOpsEnabled', false) check(pluginExports.sessionBatch.selectionSize(), 0, '归档多选 关闭批量开关清空多选') check(archiveCheckCount(), 0, '归档多选 关闭批量开关移除全部复选框') settingsStoreUnderTest.set('batchOpsEnabled', true) flushRaf() check(archiveCheckCount(), 1, '归档多选 重新开启批量开关后复选框恢复') liveWsArchiveBtn().click('click') check(panelOf(), null, '归档多选 退出归档视图') // 11f) 工作区行「全选」按钮:点击勾选该工作区当前视图下所有可勾选的会话, // 再点取消勾选(toggle);会话多选开关关闭时按钮移除、开启时恢复。 // (清空已删除集合:11e 批量删除的 a2 已被记入,不清会让 a2 行被过滤。) mockDeletedIds.clear() wsSnapshot = { items: [{ workspaceId: 'ws-1', title: '项目A', path: '/proj/a', sessionIds: ['a1', 'a2', 'a3'] }], archivedSessionIds: ['a1', 'a2', 'a3'], phase: 'ready', } pluginExports.sessionBatch.pass() const selectAllBtnOf = function (row) { try { return row.querySelector('button[data-dsh-zh-ws-selectall]') } catch { return null } } let selAllBtn = selectAllBtnOf(wsRow) check(selAllBtn !== null, true, '全选 工作区行注入了全选按钮') check(wsActions.children[0] === selAllBtn, true, '全选 按钮位于操作区最前(查看归档之前)') check(selAllBtn.getAttribute('aria-label'), '全选', '全选 中文文案') // 归档视图:全选 → 3 个归档行全部勾选 + 按钮高亮;再点 → 全部取消。 liveWsArchiveBtn().click('click') check(panelOf() !== null, true, '全选 进入归档视图') check(archiveSlotChecks().length, 3, '全选 归档视图 3 行可勾选') selAllBtn.click('click') check(pluginExports.sessionBatch.selectionSize(), 3, '全选 点击后勾选 3 个会话') check(archiveSlotChecks().every(c => c.checked), true, '全选 归档行复选框全部勾选') check(selAllBtn.getAttribute('data-dsh-zh-selectall-active'), 'true', '全选 已全选时按钮高亮') selAllBtn.click('click') check(pluginExports.sessionBatch.selectionSize(), 0, '全选 再点取消勾选全部') check(archiveSlotChecks().every(c => c.checked === false), true, '全选 归档行复选框全部取消') check(selAllBtn.getAttribute('data-dsh-zh-selectall-active'), 'false', '全选 取消后按钮不高亮') liveWsArchiveBtn().click('click') check(panelOf(), null, '全选 退出归档视图') // 正常列表:给 ws-1 分组注入一个带 fiber 的空闲行,全选应勾选它。 const n1Row = new FakeEl('div', { role: 'treeitem', class: 'Rows_sessionRow__x' }) const n1Slot = new FakeEl('span', { class: 'Rows_slot__y' }) n1Row.appendChild(n1Slot) const n1Title = new FakeEl('span', { class: 'Rows_title__z' }) n1Title.textContent = '可勾选会话' n1Row.appendChild(n1Title) n1Row['__reactFiber$sel'] = { memoizedProps: { node: { id: 'n1' } }, return: null } group.appendChild(n1Row) pluginExports.sessionBatch.pass() check(n1Row.querySelector('input[data-dsh-zh-batch-check]') !== null, true, '全选 正常列表 n1 行注入了复选框') let selAllBtn2 = selectAllBtnOf(wsRow) selAllBtn2.click('click') check(pluginExports.sessionBatch.selectionSize(), 1, '全选 正常列表点击后勾选 n1') check(n1Row.querySelector('input[data-dsh-zh-batch-check]').checked, true, '全选 n1 复选框勾选') selAllBtn2.click('click') check(pluginExports.sessionBatch.selectionSize(), 0, '全选 正常列表再点取消') selAllBtn2 = selectAllBtnOf(wsRow) check(selAllBtn2.getAttribute('data-dsh-zh-selectall-active'), 'false', '全选 正常列表取消后不高亮') // 会话多选开关关闭:全选按钮移除(没有复选框可勾选);重新开启恢复。 settingsStoreUnderTest.set('batchOpsEnabled', false) pluginExports.sessionBatch.pass() check(selectAllBtnOf(wsRow), null, '全选 关闭批量开关后移除全选按钮') settingsStoreUnderTest.set('batchOpsEnabled', true) pluginExports.sessionBatch.pass() check(selectAllBtnOf(wsRow) !== null, true, '全选 重新开启批量开关后恢复全选按钮') // 11g) 官方列表/搜索里的已删除会话行隐藏(2026-09-25 真实 GUI 回归): // 删除驻留内存的会话只能靠官方归档集合隐藏,而归档集合只作用于「隐藏已归档」 // 视图。用户切到「全部对话(显示已归档)」或「仅显示已归档」时,被删会话会 // 作为灰色归档行重新出现(最典型是掉进「未分组」桶——账本席位已随删除移除); // 官方内容搜索同样会把它列出来。本模块给命中已删除集合的官方行打标记 + // 样式隐藏,并把因此整体变空的分组一并收起。 check(typeof pluginExports.sessionDeleteRows === 'object' && typeof pluginExports.sessionDeleteRows.pass === 'function' && typeof pluginExports.sessionDeleteRows.refresh === 'function', true, '已删除行隐藏模块已安装并暴露测试入口') const deletedRowsPass = pluginExports.sessionDeleteRows.pass // 集合真值来自主机路由(fetch mock 读 mockDeletedIds);改集合后重新拉取, // 与真实链路一致(安装时拉一次、删除回包时同步一次)。 const syncDeletedSet = function () { pluginExports.sessionDeleteRows.refresh(); deletedRowsPass() } // 夹具复刻真实 DOM:官方会话行带 `data-row-key="session:"`(Rows.tsx 稳定 // 输出),搜索结果行无该属性、id 在 fiber 的 `result.id` 上。 const dlRowA = new FakeEl('div', { role: 'treeitem', class: 'Rows_sessionRow__x', 'data-row-key': 'session:a5', }) const dlRowB = new FakeEl('div', { role: 'treeitem', class: 'Rows_sessionRow__x', 'data-row-key': 'session:a6', }) batchList.appendChild(dlRowA) batchList.appendChild(dlRowB) // 集合为空:不打标记、不建样式(没删过任何会话时零副作用)。 mockDeletedIds.clear() syncDeletedSet() check(dlRowA.getAttribute('data-dsh-zh-deleted-row'), null, '已删除行 集合为空时不打标记') check(fakeDoc.head.children.filter(c => c.getAttribute('data-dsh-zh') === 'deleted-rows').length, 0, '已删除行 集合为空时不建样式(零副作用)') // a5 进入已删除集合 → 只有 a5 被标记(a6 是对照组)。 mockDeletedIds.add('a5') syncDeletedSet() check(dlRowA.getAttribute('data-dsh-zh-deleted-row'), '', '已删除行 命中集合的行打标记') check(dlRowB.getAttribute('data-dsh-zh-deleted-row'), null, '已删除行 未命中集合的行不打标记(对照)') const deletedRowStyles = fakeDoc.head.children.filter(c => c.getAttribute('data-dsh-zh') === 'deleted-rows') check(deletedRowStyles.length, 1, '已删除行 建一个样式标签') check(String(deletedRowStyles[0].textContent).indexOf('display:none!important') !== -1, true, '已删除行 样式为 display:none!important(不摘节点,避免 React reconcile 找不到节点)') // 幂等:重复 pass 不改变结果(observer 高频触发下的稳定性)。 deletedRowsPass() check(dlRowA.getAttribute('data-dsh-zh-deleted-row'), '', '已删除行 重复 pass 幂等') // 搜索行:id 来自 fiber 上的 `result.id`(官方 SearchResultItem 的 props)。 const dlSearchRow = new FakeEl('div', { role: 'treeitem', class: 'Rows_searchResultRow__x' }) dlSearchRow['__reactFiber$srch'] = { memoizedProps: { result: { id: 'a5' } }, return: null } batchList.appendChild(dlSearchRow) deletedRowsPass() check(dlSearchRow.getAttribute('data-dsh-zh-deleted-row'), '', '已删除行 搜索结果行按 result.id 命中并打标记') // 恢复(集合里移除)→ 标记撤销、样式移除、界面回到官方原生形态。 mockDeletedIds.clear() syncDeletedSet() check(dlRowA.getAttribute('data-dsh-zh-deleted-row'), null, '已删除行 集合清空后撤销标记') check(dlSearchRow.getAttribute('data-dsh-zh-deleted-row'), null, '已删除行 搜索行标记同样撤销') check(fakeDoc.head.children.filter(c => c.getAttribute('data-dsh-zh') === 'deleted-rows').length, 0, '已删除行 集合清空后移除样式') // 分组收拾:官方「仅显示已归档」视图里,一个分组只剩被删会话时整个收起。 // 夹具挂在**官方分组容器所在的同一棵 tree** 上(分组容器的定义是「其父级 // 正是官方滚动容器 role=tree 的那一层」,另起一棵树不会被识别为分组)。 const dlGroupEmpty = new FakeEl('div', { class: 'Rows_groupSection__e' }) const dlGroupKept = new FakeEl('div', { class: 'Rows_groupSection__k' }) tree.appendChild(dlGroupEmpty) tree.appendChild(dlGroupKept) const dlOnlyDeleted = new FakeEl('div', { role: 'treeitem', class: 'Rows_sessionRow__x', 'data-row-key': 'session:a7', }) const dlKeptRow = new FakeEl('div', { role: 'treeitem', class: 'Rows_sessionRow__x', 'data-row-key': 'session:a8', }) dlGroupEmpty.appendChild(dlOnlyDeleted) dlGroupKept.appendChild(dlKeptRow) mockDeletedIds.clear() mockDeletedIds.add('a7') syncDeletedSet() check(dlOnlyDeleted.getAttribute('data-dsh-zh-deleted-row'), '', '已删除行 分组内已删除行打标记') check(dlGroupEmpty.getAttribute('data-dsh-zh-deleted-group'), '', '已删除行 只剩已删除行的分组整体收起') check(dlGroupKept.getAttribute('data-dsh-zh-deleted-group'), null, '已删除行 仍有可见行的分组不收起') // 分组收拾的反向:已删除集合清空后分组标记一并撤销(切回视图要复原)。 mockDeletedIds.clear() syncDeletedSet() check(dlGroupEmpty.getAttribute('data-dsh-zh-deleted-group'), null, '已删除行 集合清空后分组标记撤销') tree.removeChild(dlGroupEmpty) tree.removeChild(dlGroupKept) // 卸载前重新打上标记(验证卸载把标记与样式一并清掉)。 mockDeletedIds.add('a5') syncDeletedSet() check(dlRowA.getAttribute('data-dsh-zh-deleted-row'), '', '已删除行 卸载前标记存在(卸载清理断言的前提)') // 12) 卸载清理 for (const d of disposers) d() check(registeredDicts['dsh-zh-archive'], undefined, '卸载后词典注销') check(panelOf(), null, '卸载后归档行容器移除') check(sessionSpans.every(s => s.getAttribute('data-dsh-zh-archive-hides-row') === null), true, '卸载后无残留隐藏标记') check(wsActions.children.find(c => c.getAttribute('data-dsh-zh-ws-archive') !== null), undefined, '卸载后工作区行归档按钮移除') check(ungroupedRow.children.find(c => c.getAttribute('data-dsh-zh-ws-archive') !== null), undefined, '卸载后未分组行归档按钮移除') check(body.querySelectorAll('input[data-dsh-zh-batch-check]').length, 0, '卸载后批量复选框全部移除') check(intervals.filter(i => i !== null).length, intervalsBefore, '卸载后无残留定时器') // 卸载后样式标签全部移除(含「已删除行隐藏」样式;mock 的 querySelector // 不支持逗号选择器,直接查 head 子元素)。 const remainingStyles = fakeDoc.head.children.filter(c => c.tagName === 'STYLE') check(remainingStyles.length, 0, '卸载后样式移除') check(body.querySelectorAll('[data-dsh-zh-deleted-row]').length, 0, '卸载后已删除行标记全部清除') console.log(fail > 0 ? `FAIL: ${fail}/${total} 项不符` : `OK: 归档视图回归 ${total} 项通过`) process.exit(fail > 0 ? 1 : 0) } void runTests()