'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 等)写得不完全一致, // 任何形如 ".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`);