Files
cc-web/scripts/task-board-frontend-unit.js

1023 lines
46 KiB
JavaScript

'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const REPO_DIR = path.resolve(__dirname, '..');
const SCRIPT_PATH = path.join(REPO_DIR, 'public', 'task-board.js');
const STYLE_PATH = path.join(REPO_DIR, 'public', 'task-board.css');
class FakeStyle {
constructor() {
this.values = new Map();
}
setProperty(name, value) {
this.values.set(String(name), String(value));
}
getPropertyValue(name) {
return this.values.get(String(name)) || '';
}
}
class FakeClassList {
constructor(element) {
this.element = element;
}
values() {
return String(this.element.className || '').split(/\s+/).filter(Boolean);
}
contains(name) {
return this.values().includes(name);
}
add(...names) {
const values = new Set(this.values());
names.forEach((name) => values.add(name));
this.element.className = Array.from(values).join(' ');
}
remove(...names) {
const removed = new Set(names);
this.element.className = this.values().filter((name) => !removed.has(name)).join(' ');
}
toggle(name, force) {
const present = this.contains(name);
const enabled = force === undefined ? !present : !!force;
if (enabled) this.add(name);
else this.remove(name);
return enabled;
}
}
function dataPropertyName(attributeName) {
return attributeName
.slice(5)
.split('-')
.map((part, index) => (index ? `${part.slice(0, 1).toUpperCase()}${part.slice(1)}` : part))
.join('');
}
function selectorAttributeValue(element, name) {
if (name === 'class') return element.className;
if (name === 'id') return element.id;
if (name === 'name') return element.name;
if (name === 'disabled') return element.disabled ? '' : null;
if (name === 'tabindex') return element.tabIndex === undefined ? null : String(element.tabIndex);
if (name.startsWith('data-')) {
const key = dataPropertyName(name);
return Object.prototype.hasOwnProperty.call(element.dataset, key) ? String(element.dataset[key]) : null;
}
return element.getAttribute(name);
}
function matchesSelector(element, rawSelector) {
let selector = String(rawSelector || '').trim();
if (!selector) return false;
const notAttributes = [];
selector = selector.replace(/:not\(\[([^\]=]+)(?:="([^"]*)")?\]\)/g, (_, name, value) => {
notAttributes.push({ name, value });
return '';
});
for (const condition of notAttributes) {
const actual = selectorAttributeValue(element, condition.name);
if (actual !== null && (condition.value === undefined || actual === condition.value)) return false;
}
const tagMatch = selector.match(/^[A-Za-z][A-Za-z0-9-]*/);
if (tagMatch && element.tagName !== tagMatch[0].toUpperCase()) return false;
const idMatches = Array.from(selector.matchAll(/#([A-Za-z0-9_-]+)/g));
if (idMatches.some((match) => element.id !== match[1])) return false;
const classMatches = Array.from(selector.matchAll(/\.([A-Za-z0-9_-]+)/g));
if (classMatches.some((match) => !element.classList.contains(match[1]))) return false;
const attributeMatches = Array.from(selector.matchAll(/\[([^\]=]+)(?:="([^"]*)")?\]/g));
for (const match of attributeMatches) {
const actual = selectorAttributeValue(element, match[1]);
if (actual === null) return false;
if (match[2] !== undefined && actual !== match[2]) return false;
}
return true;
}
class FakeElement {
constructor(tagName, ownerDocument) {
this.nodeType = 1;
this.tagName = String(tagName).toUpperCase();
this.ownerDocument = ownerDocument;
this.parentNode = null;
this.children = [];
this.dataset = {};
this.style = new FakeStyle();
this.attributes = new Map();
this.listeners = new Map();
this.className = '';
this.id = '';
this.name = '';
this.value = '';
this.type = '';
this.checked = false;
this.selected = false;
this.disabled = false;
this.hidden = false;
this.required = false;
this.tabIndex = undefined;
this._textContent = '';
this.animations = [];
this.classList = new FakeClassList(this);
}
get textContent() {
return `${this._textContent}${this.children.map((child) => child.textContent).join('')}`;
}
set textContent(value) {
this._textContent = value === null || value === undefined ? '' : String(value);
this.children.forEach((child) => { child.parentNode = null; });
this.children = [];
}
get firstElementChild() {
return this.children[0] || null;
}
get parentElement() {
return this.parentNode instanceof FakeElement ? this.parentNode : null;
}
appendChild(child) {
assert(child instanceof FakeElement, 'MiniDOM 只接受元素节点');
if (child.parentNode) child.remove();
child.parentNode = this;
this.children.push(child);
return child;
}
append(...children) {
children.forEach((child) => this.appendChild(child));
}
prepend(...children) {
const normalized = children.filter(Boolean);
normalized.forEach((child) => {
if (child.parentNode) child.remove();
child.parentNode = this;
});
this.children.unshift(...normalized);
}
replaceChildren(...children) {
this.children.forEach((child) => { child.parentNode = null; });
this.children = [];
this._textContent = '';
children.forEach((child) => this.appendChild(child));
}
remove() {
if (!this.parentNode || !Array.isArray(this.parentNode.children)) return;
const index = this.parentNode.children.indexOf(this);
if (index >= 0) this.parentNode.children.splice(index, 1);
this.parentNode = null;
}
setAttribute(name, value) {
const normalizedName = String(name);
const normalizedValue = String(value);
this.attributes.set(normalizedName, normalizedValue);
if (normalizedName === 'class') this.className = normalizedValue;
if (normalizedName === 'id') this.id = normalizedValue;
if (normalizedName === 'tabindex') this.tabIndex = Number(normalizedValue);
if (normalizedName.startsWith('data-')) this.dataset[dataPropertyName(normalizedName)] = normalizedValue;
}
getAttribute(name) {
const normalizedName = String(name);
if (normalizedName === 'class') return this.className || null;
if (normalizedName === 'id') return this.id || null;
if (normalizedName === 'tabindex' && this.tabIndex !== undefined) return String(this.tabIndex);
if (normalizedName.startsWith('data-')) {
const key = dataPropertyName(normalizedName);
return Object.prototype.hasOwnProperty.call(this.dataset, key) ? String(this.dataset[key]) : null;
}
return this.attributes.has(normalizedName) ? this.attributes.get(normalizedName) : null;
}
hasAttribute(name) {
return this.getAttribute(name) !== null;
}
removeAttribute(name) {
const normalizedName = String(name);
this.attributes.delete(normalizedName);
if (normalizedName === 'class') this.className = '';
if (normalizedName === 'id') this.id = '';
if (normalizedName === 'tabindex') this.tabIndex = undefined;
if (normalizedName.startsWith('data-')) delete this.dataset[dataPropertyName(normalizedName)];
}
addEventListener(type, listener) {
const listeners = this.listeners.get(type) || [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type, listener) {
const listeners = this.listeners.get(type) || [];
this.listeners.set(type, listeners.filter((candidate) => candidate !== listener));
}
dispatchEvent(event) {
const normalized = event || {};
normalized.type = String(normalized.type || '');
if (!normalized.target) normalized.target = this;
normalized.currentTarget = this;
normalized.defaultPrevented = !!normalized.defaultPrevented;
normalized.preventDefault ||= function preventDefault() { this.defaultPrevented = true; };
normalized.stopPropagation ||= function stopPropagation() { this.propagationStopped = true; };
for (const listener of (this.listeners.get(normalized.type) || []).slice()) listener.call(this, normalized);
if (normalized.bubbles && !normalized.propagationStopped && this.parentNode?.dispatchEvent) {
this.parentNode.dispatchEvent(normalized);
}
return !normalized.defaultPrevented;
}
matches(selector) {
return String(selector).split(',').some((part) => matchesSelector(this, part));
}
closest(selector) {
let current = this;
while (current instanceof FakeElement) {
if (current.matches(selector)) return current;
current = current.parentNode;
}
return null;
}
querySelectorAll(selector) {
const selectors = String(selector).split(',').map((part) => part.trim()).filter(Boolean);
const matches = [];
const visit = (node) => {
for (const child of node.children) {
if (selectors.some((part) => matchesSelector(child, part))) matches.push(child);
visit(child);
}
};
visit(this);
return matches;
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
contains(candidate) {
if (candidate === this) return true;
return this.children.some((child) => child.contains(candidate));
}
focus() {
this.ownerDocument.activeElement = this;
}
getBoundingClientRect() {
const column = this.closest('.ccweb-task-board__column');
const lanes = column?.parentNode;
const columns = lanes?.children?.filter((node) => node.classList.contains('ccweb-task-board__column')) || [];
const columnIndex = Math.max(0, columns.indexOf(column));
const cards = this.parentNode?.children?.filter((node) => node.classList.contains('ccweb-task-board__card')) || [];
const cardIndex = Math.max(0, cards.indexOf(this));
const left = 16 + (columnIndex * 352);
const top = 72 + (cardIndex * 176);
const width = 320;
const height = 160;
return { x: left, y: top, left, top, right: left + width, bottom: top + height, width, height };
}
animate(keyframes, options = {}) {
let timer = null;
const animation = {
keyframes,
options,
onfinish: null,
oncancel: null,
finished: false,
canceled: false,
finish() {
if (this.finished || this.canceled) return;
this.finished = true;
if (timer !== null) clearTimeout(timer);
timer = null;
if (typeof this.onfinish === 'function') this.onfinish();
},
cancel() {
if (this.finished || this.canceled) return;
this.canceled = true;
if (timer !== null) clearTimeout(timer);
timer = null;
if (typeof this.oncancel === 'function') this.oncancel();
},
};
this.animations.push(animation);
timer = setTimeout(() => animation.finish(), 0);
return animation;
}
}
class FakeDocument {
constructor() {
this.listeners = new Map();
this.activeElement = null;
this.documentElement = new FakeElement('html', this);
this.body = new FakeElement('body', this);
this.documentElement.appendChild(this.body);
}
createElement(tagName) {
return new FakeElement(tagName, this);
}
querySelector(selector) {
if (this.documentElement.matches(selector)) return this.documentElement;
return this.documentElement.querySelector(selector);
}
addEventListener(type, listener) {
const listeners = this.listeners.get(type) || [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type, listener) {
const listeners = this.listeners.get(type) || [];
this.listeners.set(type, listeners.filter((candidate) => candidate !== listener));
}
dispatchEvent(event) {
const normalized = event || {};
normalized.preventDefault ||= function preventDefault() { this.defaultPrevented = true; };
for (const listener of (this.listeners.get(normalized.type) || []).slice()) listener.call(this, normalized);
}
}
function walk(root) {
const nodes = [];
const visit = (node) => {
nodes.push(node);
node.children.forEach(visit);
};
visit(root);
return nodes;
}
function byClass(root, className) {
return walk(root).filter((node) => node.classList?.contains(className));
}
function byAction(root, action) {
return walk(root).filter((node) => node.dataset?.action === action);
}
function emit(element, type, extra = {}) {
element.dispatchEvent({ type, bubbles: true, ...extra });
}
function loadTaskBoard(source, options = {}) {
const document = new FakeDocument();
let uuidSequence = 0;
const window = {
document,
setTimeout,
clearTimeout,
Date,
Intl,
Promise,
matchMedia(query) {
return {
media: String(query),
matches: options.reducedMotion === true && String(query).includes('prefers-reduced-motion'),
};
},
crypto: { randomUUID: () => `uuid-${++uuidSequence}` },
CSS: {
supports(property, value) {
if (property !== 'color') return true;
return /^#[0-9a-f]{3,8}$/i.test(String(value)) || /^[a-z]+$/i.test(String(value));
},
},
};
window.window = window;
const context = vm.createContext({
window,
document,
setTimeout,
clearTimeout,
Date,
Intl,
Promise,
console,
});
vm.runInContext(source, context, { filename: SCRIPT_PATH });
return { api: window.CcwebTaskBoard, document };
}
function cloneMessage(message) {
return JSON.parse(JSON.stringify(message));
}
async function flush() {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function main() {
const source = fs.readFileSync(SCRIPT_PATH, 'utf8');
const css = fs.readFileSync(STYLE_PATH, 'utf8');
// 静态契约:独立全局 API、协议前缀和样式能力必须可由集成层直接识别。
assert(source.includes('global.CcwebTaskBoard = api'), '应导出 window.CcwebTaskBoard');
assert(!/(?:window|global)\.(?:ws|sessions|currentSessionId)\b/.test(source), '模块不得读取 app.js 私有全局状态');
assert(!source.includes('.innerHTML'), '服务端文本必须通过 textContent 渲染');
for (const type of [
'task_board_query',
'task_tracking_set',
'task_status_set',
'task_status_definition_upsert',
'task_status_definition_remove',
'task_archive_set',
]) {
assert(source.includes(`'${type}'`), `缺少协议类型 ${type}`);
}
for (const statusId of ['unassigned', 'in_progress', 'waiting_user', 'blocked', 'completed']) {
assert(!source.includes(`'${statusId}'`), `前端不应硬编码系统状态 ${statusId}`);
}
assert(css.includes('.ccweb-task-board__lanes'), 'CSS 应包含横向看板工作区');
assert(/overflow-x:\s*auto/.test(css), '看板应允许横向滚动');
assert(/grid-auto-columns:/.test(css), '看板列数和宽度应动态生成');
assert(css.includes(':focus-visible'), 'CSS 应提供键盘焦点样式');
assert(css.includes('@media (prefers-reduced-motion: reduce)'), 'CSS 应尊重减少动态效果偏好');
assert(
css.includes("html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running']::before")
&& css.includes("html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running']::after"),
'暗金荒野运行卡片应保留左侧竖条和活动轨迹',
);
assert(
!/html\[data-theme='wasteland'\] \.ccweb-task-board__card\[data-runtime-state='running'\]\s*\{/.test(css)
&& !css.includes('@keyframes ccweb-task-board-wasteland-runtime-card'),
'暗金荒野不得再给整张运行卡片添加背景、边框或呼吸泛光',
);
assert(
css.includes('@keyframes ccweb-task-board-wasteland-runtime-tracer'),
'暗金荒野运行卡片应提供独立的边缘活动轨迹',
);
assert(
/@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.ccweb-task-board__card\[data-runtime-state='running'\]::after\s*\{[^}]*animation:\s*none;/.test(css),
'减少动态效果时左侧活动点必须停止移动',
);
assert(css.includes('.ccweb-task-tracking'), 'CSS 应覆盖可复用任务跟踪开关');
assert(!source.includes('refs.board.replaceChildren'), '数据更新不得清空并重建整个看板');
assert(source.includes('getBoundingClientRect'), '卡片移动应记录更新前后的几何位置');
assert(source.includes('card.animate(keyframes'), '卡片动效应使用原生 Web Animations API');
assert(source.includes('ccweb-task-board-card-move'), '应提供跨列 FLIP 位移动画');
assert(source.includes('ccweb-task-board-card-enter'), '应提供新增卡片展开动画');
assert(source.includes('ccweb-task-board-card-leave'), '应提供移除卡片收缩动画');
assert(source.includes("matchMedia('(prefers-reduced-motion: reduce)')"), '脚本动效应尊重减少动态效果偏好');
assert(css.includes('.ccweb-task-board__card[data-motion="moving"]'), 'CSS 应为移动中的卡片提供独立层级');
assert(css.includes('.ccweb-task-board__card[data-motion="leaving"]'), 'CSS 应定义卡片离场状态');
assert(!source.includes('baseStatus'), '生产前端不得保留基础状态映射语义');
assert(!source.includes('reportingStatus'), '生产前端不得保留轮次上报审计字段');
assert(!source.includes('本轮未上报'), '生产前端不得渲染轮次上报缺失文案');
assert(!source.includes('facets'), '生产前端不得保留 Agent facet 状态');
assert(!source.includes('agentSelect'), '生产前端不得创建 Agent 筛选控件');
assert(!source.includes('filters.agent'), '生产前端不得保留 Agent 筛选字段');
assert(!source.includes('按 Agent 筛选'), '生产前端不得出现 Agent 筛选文案');
assert(!source.includes('Agent ·'), '任务卡不得渲染 Agent 运行器署名');
assert(!source.includes('任务:'), '任务卡不得在状态选择器之外重复显示业务状态');
assert(!css.includes('.ccweb-task-board__business-state'), 'CSS 不得保留重复业务状态胶囊样式');
assert(!css.includes('.ccweb-task-board__state-dot'), 'CSS 不得保留重复业务状态圆点样式');
assert(!/\bprogress\b/i.test(source), '生产看板脚本不得保留百分比进度语义');
assert(!css.includes('ccweb-task-board__progress'), '生产看板样式不得保留进度条组件');
assert(!/\bdescription\b/.test(source), '状态定义不得再用 description 代替分类提示词');
assert(source.includes("promptControl.name = 'prompt'"), '状态定义请求应读取 prompt 字段');
assert(source.includes("createField('分类提示词'"), '状态管理表单必须明确标注分类提示词');
assert(css.includes('.ccweb-task-board__prompt'), 'CSS 应提供分类提示词编辑区样式');
assert(!source.includes("'create-task'"), '看板不得提供伪“新建任务”动作');
assert(!source.includes('createSession'), '前端看板不得把新建会话包装成新建任务');
assert(!source.includes("dataset.action = 'refresh'"), '看板不得渲染刷新按钮');
assert(!source.includes('开启后,该会话会进入独立任务看板并接受状态跟踪。'), '跟踪控件不得重复长说明');
assert(!/\bpriority\b/i.test(source), '生产前端不得保留伪会话优先级字段');
assert(!source.includes('优先级'), '生产前端不得出现优先级文案');
assert(!css.includes('ccweb-task-board__priority'), 'CSS 不得保留优先级标签样式');
assert(!css.includes('--tb-'), '看板不得维护独立主题变量体系');
assert(!css.includes('color-scheme: dark'), '看板不得强制深色模式');
assert(!/(?:#[0-9a-f]{3,8}|rgba?\()/i.test(css), '看板常规颜色不得硬编码');
for (const themeVariable of [
'--bg-primary',
'--bg-secondary',
'--bg-tertiary',
'--surface-strong',
'--text-primary',
'--text-secondary',
'--text-muted',
'--border-color',
'--accent',
'--accent-hover',
'--accent-light',
'--danger',
'--success',
'--accent-ink',
]) {
assert(css.includes(`var(${themeVariable}`), `CSS 应继承主题变量 ${themeVariable}`);
}
assert(/\.ccweb-task-tracking\s*\{[^}]*background:\s*var\(--bg-tertiary\);/.test(css), '跟踪控件应复用控制栏按钮表面');
assert(/\.ccweb-task-tracking\s*\{[\s\S]*?display:\s*inline-flex;/.test(css), '跟踪根节点应为内联 flex 控件');
assert(/\.ccweb-task-tracking\s*\{[\s\S]*?width:\s*auto;/.test(css), '跟踪根节点不得要求全宽布局');
assert(/\.ccweb-task-tracking\s*\{[^}]*border:\s*1px solid var\(--border-color\);/.test(css), '跟踪控件应复用控制栏按钮边框');
assert(/\.task-tracking-control\s*\{[\s\S]*?display:\s*inline-flex;[\s\S]*?width:\s*auto;/.test(css), '挂载容器不得占据独立整行');
assert(css.includes('.chat-controls > .task-tracking-control'), '集成挂载点应明确作为会话控制栏尾项');
assert(css.includes('.ccweb-task-tracking__dot'), '跟踪按钮应包含二态圆点');
assert(/\.ccweb-task-tracking__status\s*\{[^}]*position:\s*absolute;[^}]*width:\s*1px;/.test(css), '状态文案应仅供无障碍读取而不占视觉空间');
assert(!source.includes('ccweb-task-tracking__switch'), '跟踪控件不得再渲染开关容器');
assert(!source.includes('ccweb-task-tracking__track'), '跟踪控件不得再渲染开关轨道');
assert(!source.includes('ccweb-task-tracking__thumb'), '跟踪控件不得再渲染开关滑块');
assert(css.includes('@media (max-width: 820px)'), 'CSS 应提供窄屏工具条布局');
const { api, document } = loadTaskBoard(source);
assert(api && Object.isFrozen(api), 'CcwebTaskBoard API 应存在且不可变');
for (const method of ['mount', 'renderTrackingControl', 'mountTrackingToggle', 'createTrackingControl']) {
assert.strictEqual(typeof api[method], 'function', `缺少公共方法 ${method}`);
}
const definitions = [
{ id: 'doing-z', label: '执行区', prompt: '任务正在实际推进时归入此列。', color: '#268cff', order: 10, system: true, enabled: true },
{ id: 'review-custom', label: '人工复核', prompt: '任务需要人工检查结果时归入此列。', color: '#f2aa3b', order: 20, system: false, enabled: true },
{ id: 'queue-x', label: '入口', prompt: '任务尚未开始处理时归入此列。', color: '#91a6bb', order: 30, system: true, enabled: true },
{ id: 'disabled-hold', label: '历史暂停', prompt: '仅保留历史任务,不再接收新任务。', color: 'url(javascript:bad)', order: 40, system: false, enabled: false },
];
const tasks = [
{
sessionId: 'session-one',
title: '<img src=x onerror=alert(1)>定位协议边界',
agent: 'Agent Alpha',
isRunning: true,
project: 'cc-web',
taskTracking: {
enabled: true,
statusId: 'queue-x',
summary: '验证动态列与运行态分离',
progress: 42,
reportingStatus: 'reported',
version: 5,
statusUpdatedAt: '2026-08-11T04:00:00.000Z',
},
},
{
sessionId: 'session-two',
title: '复核自定义状态',
agent: 'Agent Beta',
runtimeState: '等待进程',
taskTracking: { enabled: true, statusId: 'review-custom', version: 2 },
},
{
sessionId: 'session-three',
title: '停用状态仍需可见',
agent: 'Agent Alpha',
taskTracking: { enabled: true, statusId: 'disabled-hold', version: 1 },
},
{
sessionId: 'session-archived',
title: '已归档但未删除',
agent: 'Agent Beta',
taskTracking: {
enabled: true,
statusId: 'queue-x',
archivedAt: '2026-08-11T05:00:00.000Z',
version: 4,
},
},
];
const sent = [];
const opened = [];
let requestSequence = 0;
const root = document.createElement('div');
document.body.appendChild(root);
const board = api.mount(root, {
document,
autoLoad: false,
initialData: {
statusDefinitions: definitions,
definitionsVersion: 7,
tasks,
},
send(message) {
sent.push(cloneMessage(message));
},
openSession(sessionId) {
opened.push(sessionId);
},
createRequestId(prefix) {
requestSequence += 1;
return `${prefix}-${requestSequence}`;
},
});
assert.strictEqual(byClass(root, 'ccweb-task-board__column').length, 4, '列数必须由状态定义动态决定');
assert.deepStrictEqual(
byClass(root, 'ccweb-task-board__column').map((column) => column.dataset.statusId),
['doing-z', 'review-custom', 'queue-x', 'disabled-hold'],
'状态列应按服务端 order 排序',
);
const lanesNode = byClass(root, 'ccweb-task-board__lanes')[0];
const originalColumns = new Map(
byClass(root, 'ccweb-task-board__column').map((column) => [column.dataset.statusId, column]),
);
assert.strictEqual(byClass(root, 'ccweb-task-board__card').length, 3, '默认视图只显示未归档任务');
const toolbarControls = byClass(root, 'ccweb-task-board__controls')[0];
assert.strictEqual(walk(toolbarControls).filter((node) => node.tagName === 'BUTTON').length, 2, '工具条只能保留归档和状态管理两个按钮');
assert.strictEqual(byClass(toolbarControls, 'ccweb-task-board__input').length, 1, '工具条应保留搜索');
assert.strictEqual(byClass(toolbarControls, 'ccweb-task-board__select').length, 0, '工具条不得保留 Agent 筛选');
assert.strictEqual(toolbarControls.textContent.includes('Agent'), false, '工具条 DOM 不得出现 Agent 筛选');
assert.strictEqual(toolbarControls.textContent.includes('列管理'), true, '工具条应使用明确的列管理文案');
assert.strictEqual(toolbarControls.textContent.includes('优先级'), false, '工具条 DOM 不得出现优先级筛选');
assert.strictEqual(Object.prototype.hasOwnProperty.call(board.getState(), 'facets'), false, '公开状态不得暴露 Agent facets');
assert.strictEqual(Object.prototype.hasOwnProperty.call(board.getState().filters, 'agent'), false, '筛选状态不得暴露 Agent 字段');
assert.strictEqual(Object.prototype.hasOwnProperty.call(board.getState().filters, 'priority'), false, '筛选状态不得暴露 priority');
assert.strictEqual(byAction(root, 'create-task').length, 0, '工具条不得出现新建任务按钮');
assert.strictEqual(byAction(root, 'refresh').length, 0, '工具条不得出现刷新按钮');
assert.strictEqual(byAction(root, 'toggle-archive').length, 1, '工具条应保留归档切换');
assert.strictEqual(byAction(root, 'manage-statuses').length, 1, '工具条应保留状态管理');
const firstCard = byClass(root, 'ccweb-task-board__card').find((card) => card.dataset.sessionId === 'session-one');
assert(firstCard, '应渲染首个任务卡');
const firstTaskState = board.getState().tasks.find((task) => task.sessionId === 'session-one');
assert.strictEqual(Object.prototype.hasOwnProperty.call(firstTaskState, 'progress'), false, '历史百分比字段不得进入前端公开任务状态');
assert.strictEqual(byClass(firstCard, 'ccweb-task-board__progress').length, 0, '任务卡不得渲染进度条');
assert.strictEqual(firstCard.textContent.includes('42%'), false, '任务卡不得把历史百分比渲染为文案');
assert.strictEqual(firstCard.textContent.includes('任务:入口'), false, '卡片不得重复显示只读业务状态');
assert.strictEqual(byClass(firstCard, 'ccweb-task-board__business-state').length, 0, '卡片不得渲染业务状态胶囊');
assert.strictEqual(byAction(firstCard, 'set-status')[0].value, 'queue-x', '状态选择器应作为业务状态的唯一卡片入口');
assert(firstCard.textContent.includes('运行:运行中'), '卡片应独立显示运行态');
assert.strictEqual(firstTaskState.isRunning, true, '前端公开任务状态应保留服务端明确下发的运行布尔值');
assert.strictEqual(firstCard.dataset.runtimeState, 'running', '运行卡片应暴露稳定的 data-runtime-state 契约');
const idleCard = byClass(root, 'ccweb-task-board__card').find((card) => card.dataset.sessionId === 'session-three');
assert.strictEqual(idleCard.dataset.runtimeState, 'idle', '未运行卡片应保持静态 idle 契约');
assert.strictEqual(firstCard.textContent.includes('Agent Alpha'), false, '任务卡不得显示 Agent 运行器名称');
assert(firstCard.textContent.includes('<img src=x onerror=alert(1)>'), '标题应作为纯文本保留');
assert.strictEqual(walk(firstCard).some((node) => node.tagName === 'IMG'), false, '恶意标题不得解析成 DOM');
assert.strictEqual(firstCard.parentNode.parentNode.dataset.statusId, 'queue-x', '运行态不得改变业务状态列');
const unsafeColumn = byClass(root, 'ccweb-task-board__column').find((column) => column.dataset.statusId === 'disabled-hold');
assert.strictEqual(
unsafeColumn.style.getPropertyValue('--task-board-status-color'),
'',
'非法服务端颜色不应覆盖主题回退色',
);
board.setFilters({ agent: 'Agent Alpha', archived: false });
assert.strictEqual(byClass(root, 'ccweb-task-board__card').length, 3, '未知的 Agent 筛选输入应被忽略');
assert.strictEqual(Object.prototype.hasOwnProperty.call(board.getState().filters, 'agent'), false, 'setFilters 不得重新引入 Agent 字段');
board.setFilters({ archived: true });
assert.strictEqual(byClass(root, 'ccweb-task-board__card').length, 1, '归档视图应只显示软归档任务');
const archivedCard = byClass(root, 'ccweb-task-board__card')[0];
assert.strictEqual(byAction(archivedCard, 'set-status')[0].value, 'queue-x', '归档不应改写任务业务状态');
board.setFilters({ archived: false, search: '' });
const activeFirstCard = byClass(root, 'ccweb-task-board__card').find((card) => card.dataset.sessionId === 'session-one');
const openButton = byAction(activeFirstCard, 'open-session')[0];
emit(openButton, 'click');
assert.deepStrictEqual(opened, ['session-one'], '卡片主操作应准确打开原会话一次');
const statusSelect = byAction(activeFirstCard, 'set-status')[0];
statusSelect.value = 'review-custom';
emit(statusSelect, 'change');
const statusRequest = sent.at(-1);
assert.strictEqual(statusRequest.type, 'task_status_set');
assert.strictEqual(statusRequest.sessionId, 'session-one');
assert.strictEqual(statusRequest.statusId, 'review-custom');
assert.strictEqual(Object.prototype.hasOwnProperty.call(statusRequest, 'progress'), false, '状态写请求不得发送百分比进度');
assert.strictEqual(statusRequest.expectedVersion, 5, '写请求应携带期望任务版本');
assert(statusRequest.requestId, '写请求应携带 requestId');
const archiveButton = byAction(activeFirstCard, 'set-archive')[0];
emit(archiveButton, 'click');
const archiveRequest = sent.at(-1);
assert.strictEqual(archiveRequest.type, 'task_archive_set');
assert.strictEqual(archiveRequest.archived, true);
assert.strictEqual(archiveRequest.expectedVersion, 5);
board.openStatusManager();
assert.strictEqual(board.getState().statusManagerOpen, true, '应能打开状态管理面板');
assert.strictEqual(byClass(root, 'ccweb-task-board__status-layer')[0].hidden, false);
assert(byClass(root, 'ccweb-task-board__toolbar')[0].hasAttribute('inert'), '模态面板打开时背景应 inert');
const definitionForms = byClass(root, 'ccweb-task-board__definition');
assert.strictEqual(definitionForms.length, definitions.length + 1, '面板应渲染全部状态列及新增列表单');
const addDefinitionForm = definitionForms.find((form) => form.dataset.definitionId === '');
const promptControl = addDefinitionForm.querySelector('[name="prompt"]');
assert(promptControl, '新增列表单必须包含 prompt 控件');
assert.strictEqual(promptControl.tagName, 'TEXTAREA');
assert(addDefinitionForm.textContent.includes('分类提示词'), 'prompt 控件必须使用“分类提示词”标签');
assert.strictEqual(addDefinitionForm.querySelector('[name="baseStatus"]'), null, '新增列不得选择基础状态');
assert.strictEqual(addDefinitionForm.querySelector('[name="description"]'), null, '新增列不得用说明代替分类提示词');
const reviewForm = definitionForms.find((form) => form.dataset.definitionId === 'review-custom');
assert.strictEqual(reviewForm.querySelector('[name="prompt"]').value, definitions[1].prompt, '已有列应直接编辑服务端 prompt');
addDefinitionForm.querySelector('[name="id"]').value = 'ready-release';
addDefinitionForm.querySelector('[name="label"]').value = '等待发布';
addDefinitionForm.querySelector('[name="prompt"]').value = '任务实现完成,但仍需等待发布窗口时归入此列。';
addDefinitionForm.querySelector('[name="order"]').value = '45';
emit(addDefinitionForm, 'submit');
const definitionRequest = sent.at(-1);
assert.strictEqual(definitionRequest.type, 'task_status_definition_upsert');
assert.strictEqual(definitionRequest.definition.id, 'ready-release');
assert.strictEqual(definitionRequest.definition.prompt, '任务实现完成,但仍需等待发布窗口时归入此列。');
assert.strictEqual(Object.prototype.hasOwnProperty.call(definitionRequest.definition, 'baseStatus'), false, 'definition 不得发送 baseStatus');
assert.strictEqual(Object.prototype.hasOwnProperty.call(definitionRequest.definition, 'description'), false, 'definition 不得发送 description');
assert.strictEqual(definitionRequest.expectedVersion, 7);
board.closeStatusManager();
assert.strictEqual(byClass(root, 'ccweb-task-board__toolbar')[0].hasAttribute('inert'), false, '关闭后应解除 inert');
const moveAnimationCount = activeFirstCard.animations.length;
statusSelect.focus();
board.handleMessage({
type: 'task_board_event',
task: { sessionId: 'session-one', statusId: 'review-custom', trackingEnabled: true, version: 4 },
});
assert.strictEqual(
board.getState().tasks.find((task) => task.sessionId === 'session-one').statusId,
'queue-x',
'低版本广播不得覆盖新任务状态',
);
assert.strictEqual(
byClass(root, 'ccweb-task-board__card').find((card) => card.dataset.sessionId === 'session-one'),
activeFirstCard,
'无效广播也不得替换原任务卡节点',
);
assert.strictEqual(document.activeElement, statusSelect, '无可见变化的数据事件不得打断卡片内键盘焦点');
board.handleMessage({
type: 'task_board_event',
task: { sessionId: 'session-one', statusId: 'review-custom', trackingEnabled: true, version: 6 },
});
assert.strictEqual(
board.getState().tasks.find((task) => task.sessionId === 'session-one').statusId,
'review-custom',
'高版本广播应更新任务状态',
);
const movedFirstCard = byClass(root, 'ccweb-task-board__card').find((card) => card.dataset.sessionId === 'session-one');
const movedStatusSelect = byAction(movedFirstCard, 'set-status')[0];
assert.strictEqual(movedFirstCard, activeFirstCard, '跨列更新必须移动原卡片节点,而非销毁后新建');
assert.strictEqual(movedFirstCard.parentNode.parentNode.dataset.statusId, 'review-custom', '卡片应进入服务端指定状态列');
assert.strictEqual(document.activeElement, movedStatusSelect, '卡片内容更新后应把键盘焦点恢复到状态控件');
assert.strictEqual(byClass(root, 'ccweb-task-board__lanes')[0], lanesNode, '数据事件不得替换看板滚动节点');
for (const [statusId, originalColumn] of originalColumns) {
const currentColumn = byClass(root, 'ccweb-task-board__column').find((column) => column.dataset.statusId === statusId);
assert.strictEqual(currentColumn, originalColumn, `数据事件不得替换状态列 ${statusId}`);
}
const moveAnimation = movedFirstCard.animations
.slice(moveAnimationCount)
.find((animation) => animation.options.id === 'ccweb-task-board-card-move');
assert(moveAnimation, '跨列更新应触发 FLIP 位移动画');
assert.notStrictEqual(
moveAnimation.keyframes[0].transform,
moveAnimation.keyframes.at(-1).transform,
'FLIP 动画起点应包含真实位置差值',
);
board.handleMessage({
type: 'task_board_event',
task: {
sessionId: 'session-motion-new',
title: '动效契约任务',
statusId: 'doing-z',
trackingEnabled: true,
version: 1,
},
});
const enteringCard = byClass(root, 'ccweb-task-board__card')
.find((card) => card.dataset.sessionId === 'session-motion-new');
assert(enteringCard, '新增任务应立即进入目标列');
assert.strictEqual(enteringCard.dataset.motion, 'entering', '新增任务应处于展开入场状态');
assert(enteringCard.animations.some((animation) => animation.options.id === 'ccweb-task-board-card-enter'), '新增任务应触发展开动画');
board.handleMessage({ type: 'task_board_event', removedSessionId: 'session-motion-new' });
assert.strictEqual(enteringCard.dataset.motion, 'leaving', '移除任务应先进入收缩离场状态');
assert(enteringCard.animations.some((animation) => animation.options.id === 'ccweb-task-board-card-leave'), '移除任务应触发收缩动画');
await flush();
assert.strictEqual(
byClass(root, 'ccweb-task-board__card').some((card) => card.dataset.sessionId === 'session-motion-new'),
false,
'离场动画完成后才应移除任务节点',
);
board.handleMessage({
type: 'task_board_event',
task: {
sessionId: 'session-one',
taskTracking: {
enabled: true,
archivedAt: '2026-08-11T06:00:00.000Z',
archivedBy: 'ccweb-ui',
version: 7,
},
},
});
board.setFilters({ archived: true });
assert(byClass(root, 'ccweb-task-board__card').some((card) => card.dataset.sessionId === 'session-one'), '归档事件应把目标卡片放入归档视图');
board.handleMessage({
type: 'task_board_event',
task: {
sessionId: 'session-one',
taskTracking: {
enabled: true,
archivedAt: null,
archivedBy: null,
version: 8,
},
},
});
const restoredTask = board.getState().tasks.find((task) => task.sessionId === 'session-one');
assert.strictEqual(restoredTask.archivedAt, null, '显式 archivedAt:null 必须清除旧归档时间');
assert.strictEqual(restoredTask.archivedBy, '', '恢复归档必须清除旧归档操作者');
const restoringCard = byClass(root, 'ccweb-task-board__card').find((card) => card.dataset.sessionId === 'session-one');
assert.strictEqual(restoringCard?.dataset.motion, 'leaving', '恢复后的卡片应先在归档视图中收缩');
await flush();
assert.strictEqual(byClass(root, 'ccweb-task-board__card').some((card) => card.dataset.sessionId === 'session-one'), false, '恢复后的卡片应在离场动画完成后离开归档视图');
board.setFilters({ archived: false });
assert(byClass(root, 'ccweb-task-board__card').some((card) => card.dataset.sessionId === 'session-one'), '恢复后的卡片应回到活动视图');
board.handleMessage({
type: 'task_board_event',
definitionsVersion: 6,
statusDefinitions: definitions.map((definition) => ({ ...definition, label: '过期名称' })),
});
assert.strictEqual(board.getState().statusDefinitions[0].label, '执行区', '旧状态集合版本不得覆盖新定义');
board.refresh({ query: '协议', archived: false });
const oldQuery = sent.at(-1);
board.refresh({ query: '最新', archived: false });
const latestQuery = sent.at(-1);
assert.strictEqual(latestQuery.type, 'task_board_query');
assert.strictEqual(latestQuery.query, '最新');
assert.strictEqual(latestQuery.filters.search, '最新');
assert.strictEqual(latestQuery.archived, false);
assert.strictEqual(Object.prototype.hasOwnProperty.call(latestQuery, 'agent'), false, '查询顶层不得发送 Agent 字段');
assert.strictEqual(Object.prototype.hasOwnProperty.call(latestQuery.filters, 'agent'), false, '查询 filters 不得发送 Agent 字段');
assert.strictEqual(Object.prototype.hasOwnProperty.call(latestQuery, 'priority'), false, '查询顶层不得发送 priority');
assert.strictEqual(Object.prototype.hasOwnProperty.call(latestQuery.filters, 'priority'), false, '查询 filters 不得发送 priority');
assert(latestQuery.requestId && latestQuery.requestId !== oldQuery.requestId, '查询应生成递增 requestId');
board.handleMessage({
type: 'task_board_result',
requestId: oldQuery.requestId,
tasks: [],
statusDefinitions: [],
definitionsVersion: 99,
});
assert.strictEqual(board.getState().tasks.length, 4, '过期查询结果必须忽略');
board.handleMessage({
type: 'task_board_result',
requestId: latestQuery.requestId,
tasks: [tasks[0]],
statusDefinitions: definitions,
definitionsVersion: 8,
});
assert.strictEqual(board.getState().tasks.length, 1, '最新查询结果应替换当前快照');
const reducedEnvironment = loadTaskBoard(source, { reducedMotion: true });
const reducedRoot = reducedEnvironment.document.createElement('div');
reducedEnvironment.document.body.appendChild(reducedRoot);
const reducedBoard = reducedEnvironment.api.mount(reducedRoot, {
document: reducedEnvironment.document,
autoLoad: false,
initialData: { statusDefinitions: definitions, tasks: [tasks[0]] },
});
const reducedCard = byClass(reducedRoot, 'ccweb-task-board__card')[0];
reducedBoard.handleMessage({
type: 'task_board_event',
task: { sessionId: 'session-one', statusId: 'review-custom', trackingEnabled: true, version: 6 },
});
const reducedMovedCard = byClass(reducedRoot, 'ccweb-task-board__card')[0];
assert.strictEqual(reducedMovedCard, reducedCard, '减少动态效果时仍应复用并移动原卡片节点');
assert.strictEqual(reducedMovedCard.parentNode.parentNode.dataset.statusId, 'review-custom');
assert.strictEqual(reducedMovedCard.animations.length, 0, '减少动态效果偏好下不得启动脚本动画');
reducedBoard.destroy();
const secondRoot = document.createElement('div');
document.body.appendChild(secondRoot);
const secondBoard = api.mount(secondRoot, {
document,
autoLoad: false,
initialData: { statusDefinitions: definitions.slice(0, 1), tasks: [] },
});
assert.strictEqual(secondBoard.getState().statusDefinitions.length, 1, '不同挂载实例应保持状态隔离');
secondBoard.destroy();
const openedBeforeDestroy = opened.length;
board.destroy();
emit(openButton, 'click');
assert.strictEqual(opened.length, openedBeforeDestroy, 'destroy 后应卸载看板事件');
assert.strictEqual(root.children.length, 0, 'destroy 后应清理挂载 DOM');
const toggleContainer = document.createElement('div');
document.body.appendChild(toggleContainer);
const trackingMessages = [];
const trackingErrors = [];
let trackingRequest = 0;
const toggle = api.mountTrackingToggle(toggleContainer, {
document,
sessionId: 'toggle-session',
enabled: false,
version: 3,
send(message) {
trackingMessages.push(cloneMessage(message));
},
createRequestId(prefix) {
trackingRequest += 1;
return `${prefix}-${trackingRequest}`;
},
onError(error) {
trackingErrors.push(error);
},
});
assert.strictEqual(byClass(toggle.element, 'ccweb-task-tracking__description').length, 0, '跟踪控件不得渲染长说明');
assert.strictEqual(byClass(toggle.element, 'ccweb-task-tracking__dot').length, 1, '跟踪按钮应只渲染一个二态圆点');
assert.strictEqual(byClass(toggle.element, 'ccweb-task-tracking__switch').length, 0, '跟踪按钮不得显示开关');
assert.strictEqual(byClass(toggle.element, 'ccweb-task-tracking__track').length, 0, '跟踪按钮不得显示开关轨道');
assert.strictEqual(byClass(toggle.element, 'ccweb-task-tracking__thumb').length, 0, '跟踪按钮不得显示开关滑块');
assert.strictEqual(byClass(toggle.element, 'ccweb-task-tracking__title')[0].textContent, '看板');
assert.strictEqual(byClass(toggle.element, 'ccweb-task-tracking__status')[0].textContent, '未加入');
assert.strictEqual(toggle.element.dataset.enabled, 'false', '未加入状态应暴露给圆点和文字配色');
assert.strictEqual(toggle.input.getAttribute('aria-label'), '加入任务看板', '未加入时应提供明确的无障碍动作名称');
toggle.input.checked = true;
emit(toggle.input, 'change');
assert.strictEqual(toggle.getState().pending, true, 'WebSocket 写入后应等待对应响应');
assert.strictEqual(toggle.input.disabled, true, '写入 pending 时应禁用开关');
assert.strictEqual(trackingMessages[0].type, 'task_tracking_set');
assert.strictEqual(trackingMessages[0].expectedVersion, 3);
assert.strictEqual(toggle.handleMessage({
type: 'task_tracking_result',
requestId: 'stale-request',
sessionId: 'toggle-session',
ok: true,
enabled: true,
}), false, '不匹配的 requestId 不应结束 pending');
assert.strictEqual(toggle.getState().pending, true);
assert.strictEqual(toggle.handleMessage({
type: 'task_tracking_result',
requestId: trackingMessages[0].requestId,
sessionId: 'toggle-session',
ok: true,
taskTracking: { enabled: true, version: 4 },
}), true);
assert.strictEqual(toggle.getState().enabled, true);
assert.strictEqual(toggle.getState().version, 4);
assert.strictEqual(toggle.getState().pending, false);
assert.strictEqual(toggle.element.dataset.enabled, 'true', '已加入状态应切换圆点和文字配色');
assert.strictEqual(toggle.input.getAttribute('aria-label'), '移出任务看板', '已加入时应提供明确的无障碍动作名称');
toggle.input.checked = false;
emit(toggle.input, 'change');
const failedTrackingRequest = trackingMessages.at(-1);
toggle.handleMessage({
type: 'task_tracking_result',
requestId: failedTrackingRequest.requestId,
sessionId: 'toggle-session',
ok: false,
code: 'task_version_conflict',
message: '状态版本已变化',
});
assert.strictEqual(toggle.getState().enabled, true, '跟踪开关失败时应回滚');
assert.strictEqual(toggle.input.checked, true);
assert.strictEqual(trackingErrors.length, 1, '稳定错误应交给集成层呈现');
const directToggle = api.createTrackingControl({
document,
sessionId: 'direct-session',
enabled: false,
setTracking: async () => ({ ok: true, enabled: true, version: 2 }),
});
directToggle.input.checked = true;
emit(directToggle.input, 'change');
await flush();
assert.strictEqual(directToggle.getState().enabled, true, 'Promise 适配器应自动提交成功状态');
assert.strictEqual(directToggle.getState().pending, false);
directToggle.destroy();
toggle.destroy();
assert.strictEqual(toggleContainer.children.length, 0, '开关 destroy 后应清理 DOM');
console.log('Task board frontend unit checks passed.');
}
main().catch((error) => {
console.error(error.stack || error);
process.exitCode = 1;
});