This commit is contained in:
2026-09-12 13:59:12 +08:00
commit 941ce4baab
71 changed files with 13037 additions and 0 deletions

248
test/ipc-wiring.test.js Normal file
View File

@@ -0,0 +1,248 @@
'use strict';
/**
* 接线测试:把"存在但没接上"这一类 bug 挡住。
*
* 这次审查里同时出现了三个同类问题:
* 1. notify-fanout 有 addTheme(),但没有任何 theme:subscribe 处理器往里加订阅者,
* 于是 broadcastTheme() 永远在往空集合发消息 —— "跟随系统"静默失效。
* 2. package.json 的 build.files 漏了 lib/,打出来的包一启动就 MODULE_NOT_FOUND。
* 3. preload 暴露了 listLite但没有任何调用方。
*
* 这些都不是逻辑错误,靠读单个文件看不出来,只有跨文件对照才能发现。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
const mainSrc = read('main.js');
const preloadSrc = read('preload.js');
const pkg = JSON.parse(read('package.json'));
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
// ---------- IPC 通道两端必须对齐 ----------
const handled = new Set(
[...mainSrc.matchAll(/ipcMain\.handle\(\s*'([^']+)'/g)].map((m) => m[1])
);
const invoked = new Set(
[...preloadSrc.matchAll(/ipcRenderer\.invoke\(\s*'([^']+)'/g)].map((m) => m[1])
);
check('preload 调用的每个通道,主进程都有处理器', () => {
const missing = [...invoked].filter((c) => !handled.has(c));
assert.deepStrictEqual(missing, [], `preload 调用了未注册的通道: ${missing.join(', ')}`);
});
check('主进程注册的每个通道,都有 preload 出口', () => {
const unreachable = [...handled].filter((c) => !invoked.has(c));
assert.deepStrictEqual(unreachable, [], `注册了但渲染进程够不着: ${unreachable.join(', ')}`);
});
// ---------- 推送通道:发送方与订阅方必须成对 ----------
const sentChannels = new Set(
[...mainSrc.matchAll(/notify(?:Clip|Fav|Theme)\(\s*'([^']+)'/g)].map((m) => m[1])
);
const listened = new Set(
[...preloadSrc.matchAll(/ipcRenderer\.on\(\s*'([^']+)'/g)].map((m) => m[1])
);
check('主进程广播的每个事件preload 都在监听', () => {
const deaf = [...sentChannels].filter((c) => !listened.has(c));
assert.deepStrictEqual(deaf, [], `广播了但没人听: ${deaf.join(', ')}`);
});
check('每个 fanout 集合都有对应的 subscribe 处理器', () => {
// addClip / addFav / addTheme 各自都必须被某个 ipcMain.handle 调用到
for (const adder of ['addClip', 'addFav', 'addTheme']) {
assert.ok(
new RegExp(`subscribers\\.${adder}\\(`).test(mainSrc),
`subscribers.${adder}() 从未被调用 —— 对应的广播会发进空集合`
);
}
});
check('theme:subscribe 存在(跟随系统主题依赖它)', () => {
assert.ok(handled.has('theme:subscribe'), '主进程缺少 theme:subscribe');
assert.ok(invoked.has('theme:subscribe'), 'preload 未暴露 theme:subscribe');
});
// ---------- 打包清单必须覆盖所有本地 require ----------
check('build.files 覆盖了 main/favorites 本地 require 的每个目录', () => {
const files = pkg.build.files;
const needed = new Set();
for (const src of [mainSrc, read('favorites.js')]) {
for (const m of src.matchAll(/require\(\s*'\.\/([^']+)'/g)) {
const spec = m[1];
if (spec.includes('/')) {
needed.add(spec.split('/')[0] + '/'); // 目录,如 lib/
} else {
needed.add(spec.endsWith('.js') ? spec : spec + '.js'); // 单文件,如 config -> config.js
}
}
}
for (const entry of needed) {
const covered = entry.endsWith('/')
? files.some((f) => f === entry.slice(0, -1) || f.startsWith(entry))
: files.includes(entry);
assert.ok(covered, `${entry} 被 require 但不在 build.files 里 —— 打包后会 MODULE_NOT_FOUND`);
}
});
check('lib/ 显式在打包清单里', () => {
assert.ok(
pkg.build.files.some((f) => f === 'lib' || f.startsWith('lib/')),
'build.files 缺少 lib/'
);
});
// ---------- 依赖不能是死重量 ----------
check('每个 dependency 都真的被 require 了', () => {
const srcFiles = ['main.js', 'preload.js', 'favorites.js', 'config.js', 'tray-menu.js']
.map(read)
.concat(fs.readdirSync(path.join(root, 'lib')).map((f) => read(path.join('lib', f))))
.join('\n');
const unused = Object.keys(pkg.dependencies).filter(
(dep) => !new RegExp(`require\\(\\s*'${dep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}'`).test(srcFiles)
);
assert.deepStrictEqual(unused, [], `声明了但没用到的依赖: ${unused.join(', ')}`);
});
// ---------- 渲染进程只能用 preload 真的暴露了的东西 ----------
check('renderer 从 window.* 解构的字段都存在于 preload主窗口 + 设置窗口)', () => {
const exposed = {};
for (const m of preloadSrc.matchAll(/exposeInMainWorld\('(\w+)',\s*\{([\s\S]*?)\n\}\)/g)) {
exposed[m[1]] = new Set([...m[2].matchAll(/^\s{2}(\w+):/gm)].map((x) => x[1]));
}
// 两个渲染窗口都要查settings.js 的 openExternal 解构曾经漏在这张网外面
for (const file of ['renderer/renderer.js', 'renderer/settings.js']) {
const rendererSrc = read(file);
for (const m of rendererSrc.matchAll(/const\s*\{([\s\S]*?)\}\s*=\s*window\.(\w+);/g)) {
const bridge = m[2];
if (!exposed[bridge]) continue;
for (const entry of m[1].split(',')) {
const name = entry.split(':')[0].trim();
if (!name || name.startsWith('//')) continue;
assert.ok(
exposed[bridge].has(name),
`${file} 解构了 window.${bridge}.${name},但 preload 没暴露它`
);
}
}
}
});
// ---------- 自动粘贴已移除2026-07-30界面任何时候都不隐藏 ----------
// 曾用 keyhelper 发 Ctrl+V需要先隐藏窗口交出焦点会有"缩一下"的动画,
// 用户明确不要。现在只写系统剪贴板,用户自己切窗口 Ctrl+V。
check('main.js 不再 require keyhelper自动粘贴已移除', () => {
assert.ok(
!/require\(\s*'\.\/keyhelper'\s*\)/.test(mainSrc),
'main.js 仍在 require keyhelper —— 自动粘贴应已整体移除'
);
});
check('clips:movetop 处理器里不再调用 keyHelper.sendCtrlV', () => {
const handler = mainSrc.match(/ipcMain\.handle\(\s*'clips:movetop'[\s\S]*?\n\s*\}\s*\);/);
assert.ok(handler, '找不到 clips:movetop 处理器');
assert.ok(
!/sendCtrlV/.test(handler[0]),
'clips:movetop 仍在调 sendCtrlV —— 自动粘贴应已移除'
);
});
check('clips:movetop 处理器里不再隐藏/最小化窗口', () => {
const handler = mainSrc.match(/ipcMain\.handle\(\s*'clips:movetop'[\s\S]*?\n\s*\}\s*\);/);
assert.ok(handler, '找不到 clips:movetop 处理器');
assert.ok(
!/mainWindow\s*\.\s*(hide|minimize)\s*\(/.test(handler[0]),
'clips:movetop 仍在 hide()/minimize() 窗口 —— 界面应始终保持可见'
);
});
// ---------- 两个 id 空间 / 两份缓存:容易"复用错"的地方 ----------
check('settings:clear-history 作废行数缓存并通知窗口刷新', () => {
// 不回源 clipCount设了 maxItems 时下次入库会按过期行数误删新记录;
// 不 notifyRefresh开着的主窗口停在已删除的项上。
const handler = mainSrc.match(/ipcMain\.handle\(\s*'settings:clear-history'[\s\S]*?\n\s*\}\s*\);/);
assert.ok(handler, '找不到 settings:clear-history 处理器');
assert.ok(
/clipCount\s*=\s*null/.test(handler[0]),
'settings:clear-history 没作废 clipCount —— enforceLimit 会按过期行数误删'
);
assert.ok(
/notifyRefresh\(\)/.test(handler[0]),
'settings:clear-history 没通知窗口刷新'
);
});
check('favorites:copy 两端接线(收藏复制不能复用 clips 的 id 空间)', () => {
// favId 与历史 id 分属两个库、自增数会撞号 —— 收藏 Tab 的复制
// 必须有自己从收藏行取内容的通道,不能走 clips:movetop。
assert.ok(handled.has('favorites:copy'), '主进程缺少 favorites:copy 处理器');
assert.ok(invoked.has('favorites:copy'), 'preload 未暴露 favorites:copy');
});
// ---------- settings:choose-dir动数据库的处理器坏一次就丢数据 ----------
// choose-dir 是 registerIpc 里最后一个 handler从它起捕获到 registerIpc 结尾
// (列 0 的 `}`handler 内部的闭合括号都有缩进,不会被误截)。
// 仓库文件是 CRLF 行尾,锚点要容忍 \r其余检查靠 \s* 天然兼容)。
const chooseDir = mainSrc.match(/ipcMain\.handle\(\s*'settings:choose-dir'[\s\S]*?\r?\n\}\r?\n/);
check('settings:choose-dir 存在', () => {
assert.ok(chooseDir, '找不到 settings:choose-dir 处理器');
});
check('settings:choose-dir 用 isSameDir 判"已是当前目录"Windows 大小写不敏感)', () => {
assert.ok(chooseDir, '找不到 settings:choose-dir 处理器');
// 比 effectiveDataDir()(含 session 兜底),不是裸 appConfig.dataDir
// 启动时盘符不可用会切到 session 兜底目录,此时用户重选这个目录就不
// 该弹迁移对话框自己迁自己。
assert.ok(
/isSameDir\(\s*newDir\s*,\s*effectiveDataDir\(\s*\)\s*\)/.test(chooseDir[0]),
'settings:choose-dir 未走 isSameDir —— Windows 上大小写不一致的重选会绕过' +
'"已经是当前数据位置"提示,弹出迁移对话框自己迁自己'
);
});
check('settings:choose-dir 先停轮询再关数据库', () => {
assert.ok(chooseDir, '找不到 settings:choose-dir 处理器');
const stopAt = chooseDir[0].indexOf('stopPolling()');
// db.close() 调用名可能因局部变量d.close / favDb.close 等)写得不完全一致,
// 任何形如 "<ident>.close()" 且 stopPolling 在它之前就足以说明顺序正确。
const closeAt = chooseDir[0].search(/[a-zA-Z_$][\w$]*\.close\(\)/);
assert.ok(stopAt >= 0, 'settings:choose-dir 没有 stopPolling —— 迁移期间 tick 会打中已关闭的连接');
assert.ok(closeAt >= 0 && stopAt < closeAt, 'stopPolling() 必须在关闭数据库之前');
});
// ---------- npm scripts终端环境差异不能让应用起不来 ----------
check('start / test:ui 脚本隔离 ELECTRON_RUN_AS_NODE', () => {
// VS Code及任何 Electron 派生)终端会注入 ELECTRON_RUN_AS_NODE=1
// 不加防护时 `electron .` 退化成纯 Node 跑 main.jsrequire('electron')
// 解构即抛 TypeError —— npm start / npm run test:ui 在这些终端里必挂。
// 注意必须带引号:`set X= && ...` 会把值设成一个空格(尾随空格进了值),
// 变量仍在、Electron 照样退化;`set "X=" && ...` 才是真的删除。
// npm scripts 在 Windows 上一律经 cmd.exe与调用方终端无关。
for (const name of ['start', 'test:ui']) {
assert.ok(
/set\s+"ELECTRON_RUN_AS_NODE="\s*&&/.test(pkg.scripts[name] || ''),
`scripts.${name} 未正确清掉 ELECTRON_RUN_AS_NODE —— VS Code 终端里会崩`
);
}
});
console.log(`\n${passed} checks passed`);