319 lines
14 KiB
JavaScript
319 lines
14 KiB
JavaScript
// 第二轮深度审计回归:
|
||
//
|
||
// 在第一轮修复 (_dragJustEnded) 之后,又排查出多个潜在的「按钮没反应 /
|
||
// 反应错」根因。本脚本一次性覆盖所有第二轮修复点,避免再次回归。
|
||
//
|
||
// 涵盖:
|
||
// H1 - focus-trap.js 必须用 getClientRects / visibility 检查,
|
||
// 不能继续用 offsetParent(position: fixed 永远 null)
|
||
// H2 - checkbox 点击:input 上的合成 click 必须跳过
|
||
// (label 触发一次已 toggle,input 的合成 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); |