This commit is contained in:
2026-09-12 13:55:57 +08:00
commit 30f1cedd39
95 changed files with 42057 additions and 0 deletions

View File

@@ -0,0 +1,319 @@
// 第二轮深度审计回归:
//
// 在第一轮修复 (_dragJustEnded) 之后,又排查出多个潜在的「按钮没反应 /
// 反应错」根因。本脚本一次性覆盖所有第二轮修复点,避免再次回归。
//
// 涵盖:
// H1 - focus-trap.js 必须用 getClientRects / visibility 检查,
// 不能继续用 offsetParentposition: fixed 永远 null
// H2 - checkbox 点击input 上的合成 click 必须跳过
// label 触发一次已 toggleinput 的合成 click 二次 toggle 会抵消)
// H3 - Space / F2 / Delete keydown 必须有 isImeComposing 守卫
// M8 - feedback.js 提供 registerBeforeModalShow
// confirmDialog / inputDialog 打开前调用 hooks
// task-list.js 注册 _cancelAllDrags、sidebar.js 注册 _abortAllSidebarDrags
// L4 - _exitSelectionMode 必须立即同步 body.dataset.selectionMode = 'false'
// M1 - render() 内 _commitActiveEdit 加 isConnected 守卫
import { readFileSync } from 'node:fs';
import { ok, bad, printSummary } from './_lib/check.mjs';
const TASK_LIST = readFileSync('src/ui/task-list.js', 'utf8');
const FOCUS_TRAP = readFileSync('src/utils/focus-trap.js', 'utf8');
const FEEDBACK = readFileSync('src/ui/feedback.js', 'utf8');
const SIDEBAR = readFileSync('src/ui/sidebar.js', 'utf8');
let pass = 0, fail = 0;
function expect(name, cond, info) {
if (cond) { ok(name); pass++; } else { bad(name, info); fail++; }
}
function section(name) { console.log(`\n[${name}]`); }
// ─────────────────────────────────────────────────────────────────────────────
// H1: focus-trap 不再依赖 offsetParent
// ─────────────────────────────────────────────────────────────────────────────
section('H1: focus-trap.js 用 getClientRects / visibility不用 offsetParent');
// 注释里允许提到 offsetParent 作为历史说明,但要确保代码里不再使用。
// 用「去掉所有注释后再检查」的策略:先去掉 /* ... */ 块注释,再剥 // 行注释。
const stripBlockComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '');
const codeOnly = stripBlockComments(FOCUS_TRAP).split('\n').map(line => {
const i = line.indexOf('//');
if (i >= 0 && line.slice(0, i).trim() !== '') return line.slice(0, i);
return line;
}).join('\n');
expect(
'focus-trap.js 代码里不再用 offsetParent注释中允许提到历史 bug',
!/offsetParent/.test(codeOnly),
'offsetParent 对 position:fixed 元素恒为 null会把 modal 内所有 focusable 误判为不可见'
);
expect(
'focus-trap.js 使用 getClientRects() 判定可见性',
/getClientRects\(\)/.test(FOCUS_TRAP),
'需要 getClientRects().length === 0 判断零尺寸 / 不可见'
);
expect(
'focus-trap.js 检查 computed visibility / display',
/visibility\s*===\s*['"]hidden['"]/.test(FOCUS_TRAP) &&
/display\s*===\s*['"]none['"]/.test(FOCUS_TRAP),
'需要兜底 display:none / visibility:hidden'
);
// ─────────────────────────────────────────────────────────────────────────────
// H2: checkbox 双触发修复
// ─────────────────────────────────────────────────────────────────────────────
section('H2: checkbox 点击 → 跳过 input 上的合成 click');
// 抓取 this.listEl.addEventListener('click', ...) 整段(用括号计数法定位尾部 `});`
{
const start = TASK_LIST.indexOf("this.listEl.addEventListener('click'");
expect(
'click handler 可被静态解析',
start >= 0,
'没找到 click handler'
);
if (start >= 0) {
const openIdx = TASK_LIST.indexOf('{', start);
let depth = 1, i = openIdx + 1;
while (i < TASK_LIST.length && depth > 0) {
const ch = TASK_LIST[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
const body = TASK_LIST.slice(openIdx + 1, i - 1);
expect(
'checkbox 分支包含 task-checkbox-wrapper / task-checkbox-custom / task-checkbox 三选一匹配',
/task-checkbox-wrapper/.test(body) && /task-checkbox-custom/.test(body) && /task-checkbox/.test(body),
'checkbox 分支必须仍然能命中三种 className不破坏现有逻辑'
);
expect(
'checkbox 分支包含「input 上的合成 click 跳过」守卫',
/tagName\s*===\s*['"]INPUT['"][\s\S]{0,200}task-checkbox/.test(body) ||
/tagName\s*===\s*['"]INPUT['"][\s\S]{0,200}return/.test(body),
'必须检测 e.target.tagName === "INPUT" 且 class 含 task-checkbox 时 return'
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// H3: Space / F2 / Delete IME 守卫
// ─────────────────────────────────────────────────────────────────────────────
section('H3: keydown handler 的 Space / F2 / Delete 加 isImeComposing 守卫');
// 抓取 docKeydown 整段:括号计数法定位尾部
{
const start = TASK_LIST.indexOf('const docKeydown = ');
expect(
'docKeydown handler 可被静态解析',
start >= 0,
'没找到 docKeydown 命名函数'
);
if (start >= 0) {
const openIdx = TASK_LIST.indexOf('{', start);
let depth = 1, i = openIdx + 1;
while (i < TASK_LIST.length && depth > 0) {
const ch = TASK_LIST[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
const body = TASK_LIST.slice(openIdx + 1, i - 1);
// 验证每个键在分支条件里出现 isImeComposing(e)
const checks = [
{ key: 'Space', pattern: /e\.key\s*===\s*['"] ['"][\s\S]{0,80}!isImeComposing/ },
{ key: 'F2', pattern: /e\.key\s*===\s*['"]F2['"][\s\S]{0,80}!isImeComposing/ },
{ key: 'Delete', pattern: /e\.key\s*===\s*['"]Delete['"][\s\S]{0,80}!isImeComposing/ }
];
for (const c of checks) {
expect(
`${c.key} 分支带 isImeComposing 守卫`,
c.pattern.test(body),
`${c.key} 必须用 !isImeComposing(e) 与 selectedTaskId 一起判断`
);
}
// Enter 仍然有守卫(防止被改坏)
expect(
'Enter 分支带 isImeComposing 守卫(防止重构时弄丢)',
/e\.key\s*===\s*['"]Enter['"][\s\S]{0,80}!isImeComposing/.test(body),
'Enter 的 IME 守卫必须保留'
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// M8: modal 打开前清掉拖拽
// ─────────────────────────────────────────────────────────────────────────────
section('M8: feedback.js 注册式 hook + confirm/inputDialog 调用 + 组件注册');
expect(
'feedback.js 导出 registerBeforeModalShow',
/export\s+function\s+registerBeforeModalShow/.test(FEEDBACK),
'必须导出注册入口供组件挂载清理逻辑'
);
expect(
'feedback.js 内部维护 beforeModalShowHooks Set',
/beforeModalShowHooks[\s\S]{0,40}=[\s\S]{0,20}new\s+Set\(\)/.test(FEEDBACK),
'需要 Set 而非 Array避免同一 hook 重复注册后被多次调用'
);
expect(
'feedback.js 有 runBeforeModalShow 内部调用函数',
/function\s+runBeforeModalShow\(/.test(FEEDBACK),
'需要统一的调用入口,便于 try/catch 隔离单个 hook 抛错'
);
// confirmDialog 与 inputDialog 在挂载 overlay 之前必须调用 runBeforeModalShow
const confirmMatch = FEEDBACK.match(
/export\s+function\s+confirmDialog\([\s\S]*?return\s+new\s+Promise\([\s\S]*?runBeforeModalShow\(\);/
);
const inputMatch = FEEDBACK.match(
/export\s+function\s+inputDialog\([\s\S]*?return\s+new\s+Promise\([\s\S]*?runBeforeModalShow\(\);/
);
expect(
'confirmDialog 在 overlay 创建前调 runBeforeModalShow()',
!!confirmMatch,
'confirmDialog 必须先调 hook 再 document.body.appendChild(overlay)'
);
expect(
'inputDialog 在 overlay 创建前调 runBeforeModalShow()',
!!inputMatch,
'inputDialog 必须先调 hook 再 document.body.appendChild(overlay)'
);
// 其他 4 个 modal 也必须调 runBeforeModalShow —— 否则用户在这些 dialog
// 打开时拖拽状态会残留pointerup 重排任务。
const otherModals = [
{ name: 'openImportDialog (import-dialog.js)', file: readFileSync('src/ui/import-dialog.js', 'utf8') },
{ name: 'openSettingsDialog (settings-dialog.js)', file: readFileSync('src/ui/settings-dialog.js', 'utf8') },
{ name: 'openConflictDialog (conflict-dialog.js)', file: readFileSync('src/ui/conflict-dialog.js', 'utf8') },
{ name: 'openDiffPreview (diff-preview-dialog.js)', file: readFileSync('src/ui/diff-preview-dialog.js', 'utf8') },
{ name: '_openCommandPaletteInner (command-palette.js)', file: readFileSync('src/ui/command-palette.js', 'utf8') }
];
for (const m of otherModals) {
expect(
`${m.name} import runBeforeModalShow`,
/import\s*\{[^}]*runBeforeModalShow[^}]*\}\s*from\s*['"]\.\/feedback\.js['"]/.test(m.file),
`${m.name} 必须从 feedback.js 导入 runBeforeModalShow`
);
expect(
`${m.name} 在 Promise 体内调 runBeforeModalShow()`,
/return\s+new\s+Promise\([\s\S]*?runBeforeModalShow\(\);/.test(m.file) ||
/return\s+new\s+Promise\([\s\S]*?runBeforeModalShow\(\)/.test(m.file),
`${m.name} 必须先调 hook 再 document.body.appendChild(overlay)`
);
}
// task-list.js 注册 _cancelAllDrags 到 hook
expect(
'task-list.js 注册 _cancelAllDrags 作为 modal 打开前 hook',
/registerBeforeModalShow\(\(\)\s*=>\s*this\._cancelAllDrags\(\)\)/.test(TASK_LIST),
'task-list.js 必须注册 hook 调 _cancelAllDrags'
);
expect(
'task-list.js 在 destroy() 里注销 hook',
/destroy\(\)\s*\{[\s\S]*?_unregisterModalHook[\s\S]*?delete\(|this\._unregisterModalHook\s*=\s*null/.test(TASK_LIST),
'destroy 必须调反注册函数并清引用,防止热重载 / 多实例化累加 hook'
);
// sidebar.js 注册 _abortAllSidebarDrags
expect(
'sidebar.js 注册 _abortAllSidebarDrags 作为 modal 打开前 hook',
/registerBeforeModalShow\(\(\)\s*=>\s*this\._abortAllSidebarDrags\(\)\)/.test(SIDEBAR),
'sidebar.js 必须注册 hook 调 _abortAllSidebarDrags'
);
expect(
'sidebar.js 在 destroy() 里注销 hook',
/destroy\(\)\s*\{[\s\S]*?_unregisterModalHook[\s\S]*?delete\(|this\._unregisterModalHook\s*=\s*null/.test(SIDEBAR),
'sidebar.destroy 必须注销 hook'
);
// ─────────────────────────────────────────────────────────────────────────────
// L4: _exitSelectionMode 立即同步 body data 属性
// ─────────────────────────────────────────────────────────────────────────────
section('L4: _exitSelectionMode 立即同步 body[data-selection-mode] = false');
const exitMatch = TASK_LIST.match(
/_exitSelectionMode\(\{[\s\S]*?\}\s*=\s*\{\}\)\s*\{([\s\S]*?)\n \}/
);
expect(
'_exitSelectionMode 可被静态解析',
!!exitMatch,
'没找到 _exitSelectionMode 函数'
);
if (exitMatch) {
const body = exitMatch[1];
const setIdx = body.indexOf("document.body.dataset.selectionMode = 'false'");
const renderIdx = body.indexOf('this.render();');
expect(
'body 里包含 body.dataset.selectionMode = "false" 同步赋值',
setIdx >= 0,
'必须在 render() 之前同步设 data 属性,杜绝中间态'
);
expect(
'data 属性赋值在 render() 之前',
setIdx >= 0 && renderIdx >= 0 && setIdx < renderIdx,
`set 在 ${setIdx}render 在 ${renderIdx} —— 必须前者在前`
);
}
// ─────────────────────────────────────────────────────────────────────────────
// M1: render() 中 _commitActiveEdit 加 isConnected 守卫
// ─────────────────────────────────────────────────────────────────────────────
section('M1: render() 内 _commitActiveEdit 加 isConnected 守卫');
// 用括号计数法精确抓取 render() 函数体(不用 regex避免复杂的非贪婪匹配
{
// 找第一个 `render() {`
const start = TASK_LIST.search(/^\s*render\(\)\s*\{/m);
expect(
'render() 可被静态解析',
start >= 0,
'没找到 render() 方法'
);
if (start >= 0) {
const openIdx = TASK_LIST.indexOf('{', start);
let depth = 1, i = openIdx + 1;
while (i < TASK_LIST.length && depth > 0) {
const ch = TASK_LIST[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
const body = TASK_LIST.slice(openIdx + 1, i - 1);
expect(
'render() 用 isConnected 守卫 _commitActiveEdit',
/_editInput[\s\S]{0,40}isConnected/.test(body) && /_commitActiveEdit\(\)/.test(body),
'必须有 if (this._editInput && this._editInput.isConnected) this._commitActiveEdit();'
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 收口
// ─────────────────────────────────────────────────────────────────────────────
printSummary('check-second-pass-audit', true);