249 lines
11 KiB
JavaScript
249 lines
11 KiB
JavaScript
'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.js,require('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`);
|