Files
Todo-List/scripts/check-second-pass-audit.mjs
2026-09-12 13:55:57 +08:00

319 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 第二轮深度审计回归:
//
// 在第一轮修复 (_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);