Files
Clipboard/test/ipc-wiring.test.js
2026-09-12 13:59:12 +08:00

249 lines
11 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.
'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`);