// 归档会话视图回归(纯 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()