desktop/electron/mainShell.test.cjs
Rama main · réplica de lectura del repositorio original.
'use strict'
/**
* main.cjs 桌面壳(P2/P3)集成回归测试:托盘 / 通知 / 快捷键 / 自启 / 单实例锁 /
* 关窗拦截 / LOCAL 附件 IPC。用 mainShellHarness.cjs 的假 electron 在纯 node 下
* 加载真实 main.cjs——该文件驱动 Tray / Notification / globalShortcut 等真实 API,
* 此前没有任何可执行测试,这里是唯一的行为守护。
*
* 覆盖第 1 轮 code review 确认并修复的三个 bug 的回归:
* 1. second-instance 先于 app ready 到达时不得尝试建窗(真实 Electron 会抛未捕获异常);
* 2. 退出模式下窗口销毁重建后,新窗口的显示态变化仍要刷新托盘菜单文案;
* 3. 快捷键换键注册失败时旧键必须保持生效(先注册新键、成功后再摘旧键)。
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('fs')
const path = require('path')
const os = require('os')
const { loadMain } = require('./mainShellHarness.cjs')
// ─── Bug 1 回归:second-instance 先于 app ready 到达 ───
test('回归1:second-instance 先于 app ready 到达时不建窗、不抛异常', async () => {
const h = await loadMain({ holdReady: true })
assert.equal(h.state.appReady, false, 'harness 应尚未 ready')
assert.equal(h.windows().length, 0, '尚未创建窗口')
// 修复前:走 createWindow() 分支 → new BrowserWindow 抛未捕获异常,主实例启动期崩溃
assert.doesNotThrow(() => h.emitApp('second-instance'))
assert.equal(h.windows().length, 0, '未 ready 时不应创建窗口')
})
test('基线:second-instance 在窗口隐藏时唤起并聚焦', async () => {
const h = await loadMain({})
h.win().hide()
h.emitApp('second-instance')
assert.equal(h.win().isVisible(), true)
assert.equal(h.win().isFocused(), true)
})
// ─── Bug 2 回归:窗口销毁重建后托盘文案仍跟随显示态 ───
test('回归2:退出模式下窗口销毁重建后,新窗口最小化仍刷新托盘文案', async () => {
const h = await loadMain({ prefs: { closeToTray: 'quit' }, platform: 'darwin' })
// 1) 退出模式:关窗真正销毁窗口
h.win().close()
assert.equal(h.win().isDestroyed(), true)
// 2) dock 点击 → activate → getAllWindows()===0 → createWindow 新窗口
h.emitApp('activate')
await h.tick()
const w2 = h.win()
assert.equal(w2.isDestroyed(), false, 'activate 应重建窗口')
assert.equal(w2.isVisible(), true, '新窗口应已显示')
assert.equal(h.trayMenuLabel(), '隐藏主窗')
// 3) 用户最小化新窗口:文案必须翻成「显示主窗」,且点击动作与文案一致(把窗显示出来)
w2.minimize()
assert.equal(w2.isVisible(), false)
assert.equal(h.trayMenuLabel(), '显示主窗', '窗口不可见时托盘文案应为「显示主窗」')
h.tray().click()
assert.equal(w2.isVisible(), true, '文案为「显示主窗」时点击应把窗口显示出来')
assert.equal(h.trayMenuLabel(), '隐藏主窗', '点击后文案翻回「隐藏主窗」')
})
test('基线:activate 在窗口隐藏时恢复显示(托盘模式)', async () => {
const h = await loadMain({})
h.win().hide()
h.emitApp('activate')
assert.equal(h.win().isVisible(), true)
assert.equal(h.trayMenuLabel(), '隐藏主窗')
})
// ─── Bug 4/5 回归:「窗口已销毁但应用仍在运行」(退出模式 + macOS)───
test('回归4:退出模式关窗销毁后托盘文案翻「显示主窗」,点击重建且文案跟随', async () => {
const h = await loadMain({ prefs: { closeToTray: 'quit' }, platform: 'darwin' })
h.win().close()
assert.equal(h.win().isDestroyed(), true, '退出模式关窗即销毁')
assert.equal(
h.trayMenuLabel(), '显示主窗',
'窗口销毁后文案应翻为「显示主窗」(修复前停留在「隐藏主窗」,与动作相反)'
)
h.tray().click()
await h.tick() // 新窗口等 ready-to-show 才 show
assert.equal(h.win().isDestroyed(), false, '点击后重建窗口')
assert.equal(h.win().isVisible(), true)
assert.equal(h.trayMenuLabel(), '隐藏主窗')
})
test('回归5:窗口销毁后按全局快捷键重建窗口而非空转', async () => {
const h = await loadMain({ prefs: { closeToTray: 'quit' }, platform: 'darwin' })
h.win().close()
assert.equal(h.windows().filter((w) => !w.isDestroyed()).length, 0, '窗口已全部销毁')
h.state.shortcutRegs[0].cb()
await h.tick() // 新窗口等 ready-to-show 才 show
assert.equal(
h.windows().filter((w) => !w.isDestroyed()).length, 1,
'快捷键语义「否则唤起」:无窗口时应重建(修复前直接 return 空转)'
)
assert.equal(h.win().isVisible(), true)
})
test('对照组:托盘模式关窗只隐藏不销毁,文案与快捷键均正常', async () => {
const h = await loadMain({ prefs: { closeToTray: 'tray' }, platform: 'darwin' })
h.win().close()
assert.equal(h.win().isDestroyed(), false, '托盘模式关窗不销毁')
assert.equal(h.trayMenuLabel(), '显示主窗')
h.state.shortcutRegs[0].cb()
assert.equal(h.win().isVisible(), true, '隐藏态按快捷键唤起')
})
// ─── Bug 3 回归:快捷键换键失败时旧键保持生效 ───
test('回归3:新快捷键注册失败时旧快捷键仍生效且偏好不写', async () => {
const h = await loadMain({})
const ok = await h.invoke('desktop-shortcut-set', 'Ctrl+Alt+K')
assert.equal(ok.ok, true)
assert.deepEqual(h.state.shortcutRegs.map((r) => r.accel), ['Ctrl+Alt+K'])
// 换键但注册失败(被其他程序占用)
h.failNextShortcutRegistration()
const failed = await h.invoke('desktop-shortcut-set', 'Ctrl+Alt+Y')
assert.equal(failed.ok, false, '注册失败应返回 ok:false')
// 修复前:unregisterAll 先清空 → 旧键已失效;现在旧键必须原样可用
assert.deepEqual(
h.state.shortcutRegs.map((r) => r.accel), ['Ctrl+Alt+K'],
'注册失败后旧快捷键应仍然可用'
)
assert.equal(h.readPrefs().globalShortcut, 'Ctrl+Alt+K', '偏好保持旧键,UI 与实际一致')
assert.equal(
h.state.sent.filter((s) => s.channel === 'desktop-shortcut-failed').length, 1,
'失败应回传设置页提示'
)
})
test('基线:快捷键换键成功后只保留新键', async () => {
const h = await loadMain({})
await h.invoke('desktop-shortcut-set', 'Ctrl+Alt+K')
const ok = await h.invoke('desktop-shortcut-set', 'Ctrl+Alt+J')
assert.equal(ok.ok, true)
assert.deepEqual(h.state.shortcutRegs.map((r) => r.accel), ['Ctrl+Alt+J'])
assert.equal(h.readPrefs().globalShortcut, 'Ctrl+Alt+J')
})
test('基线:清除快捷键后无键生效且偏好置空', async () => {
const h = await loadMain({})
await h.invoke('desktop-shortcut-set', 'Ctrl+Alt+K')
const ok = await h.invoke('desktop-shortcut-set', null)
assert.equal(ok.ok, true)
assert.deepEqual(h.state.shortcutRegs, [])
assert.equal(h.readPrefs().globalShortcut, null)
})
test('基线:重设为相同快捷键直接成功(不重复注册、不误报占用)', async () => {
const h = await loadMain({})
await h.invoke('desktop-shortcut-set', 'Ctrl+Alt+K')
const again = await h.invoke('desktop-shortcut-set', 'Ctrl+Alt+K')
assert.equal(again.ok, true)
assert.deepEqual(h.state.shortcutRegs.map((r) => r.accel), ['Ctrl+Alt+K'])
assert.equal(h.state.sent.filter((s) => s.channel === 'desktop-shortcut-failed').length, 0)
})
test('基线:快捷键回调——可见且聚焦时隐藏,否则唤起', async () => {
const h = await loadMain({})
const w = h.win()
w.focus()
assert.equal(w.isFocused(), true)
h.state.shortcutRegs[0].cb()
assert.equal(w.isVisible(), false, '可见且聚焦 → 隐藏')
h.state.shortcutRegs[0].cb()
assert.equal(w.isVisible(), true, '隐藏 → 唤起')
assert.equal(w.isFocused(), true)
})
// ─── 基线:托盘 / 关窗 / 退出路径 ───
test('基线:托盘模式关窗 → 隐藏 + 一次性提示 + 偏好持久化', async () => {
const h = await loadMain({ prefs: { closeToTray: 'tray' } })
h.win().close()
assert.equal(h.win().isDestroyed(), false)
assert.equal(h.win().isVisible(), false)
assert.equal(h.state.sent.filter((s) => s.channel === 'tray-first-close-hint').length, 1)
assert.equal(h.readPrefs().trayHintShown, true)
})
test('基线:托盘提示只发一次(二次关窗不再提示)', async () => {
const h = await loadMain({ prefs: { closeToTray: 'tray' } })
h.win().close()
h.win().show()
h.win().close()
assert.equal(h.state.sent.filter((s) => s.channel === 'tray-first-close-hint').length, 1)
})
test('回归:托盘模式下重启安装先放行关窗,窗口被销毁而不是藏起来', async () => {
const h = await loadMain({ prefs: { closeToTray: 'tray' }, platform: 'darwin' })
// 不在启动时置 packaged,避免 whenReady 里 3 秒后的自动检查拖住事件循环
h.state.packaged = true
await h.invoke('check-for-update')
h.emitUpdater('update-downloaded', { version: '9.9.9' })
const result = await h.invoke('install-update')
assert.equal(result.success, true)
assert.equal(h.state.quitAndInstallCalls, 1)
assert.equal(h.win().isDestroyed(), true, 'quitAndInstall 先关窗时不得被收成托盘隐藏')
assert.equal(h.state.sent.filter((s) => s.channel === 'tray-first-close-hint').length, 0)
})
test('未下载完成时重启安装不放行关窗', async () => {
const h = await loadMain({ prefs: { closeToTray: 'tray' }, platform: 'darwin' })
const result = await h.invoke('install-update')
assert.equal(result.success, false)
assert.equal(h.state.quitAndInstallCalls, 0)
h.win().close()
assert.equal(h.win().isDestroyed(), false)
assert.equal(h.win().isVisible(), false)
})
test('基线:托盘退出走 app.quit + before-quit 全量清理', async () => {
const h = await loadMain({})
h.tray().menuTemplate.find((i) => i.label === '退出').click()
assert.equal(h.state.quitCalls, 1)
assert.equal(h.win().isDestroyed(), true, 'quitting 后 close 不再被拦截')
})
test('基线:托盘单击切换显示/隐藏且文案跟随刷新', async () => {
const h = await loadMain({})
const w = h.win()
assert.equal(w.isVisible(), true)
h.tray().click()
assert.equal(w.isVisible(), false)
assert.equal(h.trayMenuLabel(), '显示主窗')
h.tray().click()
assert.equal(w.isVisible(), true)
assert.equal(h.trayMenuLabel(), '隐藏主窗')
})
test('基线:托盘「新建任务」先唤起窗口再发事件', async () => {
const h = await loadMain({})
h.win().hide()
h.tray().menuTemplate.find((i) => i.label === '新建任务').click()
assert.equal(h.win().isVisible(), true)
assert.equal(h.state.sent.filter((s) => s.channel === 'tray-new-task').length, 1)
})
// ─── 基线:通知 / 角标 ───
test('基线:通知点击 → 唤起窗口 + 回传跳转事件', async () => {
const h = await loadMain({})
h.win().hide()
await h.invoke('desktop-notify', { title: '任务完成', body: '摘要', tag: 'mao-inbox-2', itemId: 2, sessionId: 42 })
const n = h.state.notifications[0]
assert.equal(n.shown, true)
n.emit('click')
assert.equal(h.win().isVisible(), true)
const clickEvents = h.state.sent.filter((s) => s.channel === 'desktop-notification-click')
assert.deepEqual(clickEvents[0].data, { itemId: 2, sessionId: 42 })
})
test('基线:通知标题为空即拒', async () => {
const h = await loadMain({})
const r = await h.invoke('desktop-notify', { title: '', body: 'x' })
assert.equal(r.ok, false)
assert.equal(h.state.notifications.length, 0)
})
test('基线:通知 failed 事件回传失败结果', async () => {
const h = await loadMain({})
await h.invoke('desktop-notify', { title: '任务完成', tag: 'mao-inbox-3' })
h.state.notifications[0].emit('failed')
const failed = h.state.sent.filter((s) => s.channel === 'desktop-notify-result')
assert.equal(failed.length, 1)
assert.equal(failed[0].data.ok, false)
})
test('回归6:窗口销毁后点击系统通知重建窗口,跳转事件等页面加载后补发', async () => {
const h = await loadMain({ prefs: { closeToTray: 'quit' }, platform: 'darwin' })
await h.invoke('desktop-notify', { title: '任务完成', sessionId: 42, tag: 'mao-inbox-9' })
h.win().close()
assert.equal(h.win().isDestroyed(), true, '窗口已销毁')
h.state.notifications[0].emit('click')
await h.tick() // 新窗口等 ready-to-show 才 show
assert.equal(h.win().isDestroyed(), false, '点击通知应重建窗口')
assert.equal(h.win().isVisible(), true)
assert.equal(
h.state.sent.filter((s) => s.channel === 'desktop-notification-click').length, 0,
'页面未加载完时同步发跳转会丢(渲染层 ipcRenderer.on 尚未注册),不得提前发'
)
// did-finish-load 后补发
h.win().emitWebContents('did-finish-load')
assert.equal(
h.state.sent.filter((s) => s.channel === 'desktop-notification-click').length, 1,
'加载完成后应补发跳转事件'
)
assert.deepEqual(
h.state.sent.find((s) => s.channel === 'desktop-notification-click').data,
{ itemId: null, sessionId: 42 }
)
})
test('回归7:before-quit 注销快捷键后清掉跟踪值,重设同一键能真正注册', async () => {
const h = await loadMain({})
assert.deepEqual(h.state.shortcutRegs.map((x) => x.accel), ['Alt+Shift+M'], '启动注册默认键')
// 模拟「退出被中止」:直接派发 before-quit(注销全部快捷键 + 清跟踪值)
h.emitApp('before-quit')
assert.deepEqual(h.state.shortcutRegs, [], 'before-quit 后快捷键已全部注销')
// 重设与启动时同一个键:必须真正 register,不能因同键早退而跳过
const r = await h.invoke('desktop-shortcut-set', 'Alt+Shift+M')
assert.equal(r.ok, true)
assert.deepEqual(
h.state.shortcutRegs.map((x) => x.accel), ['Alt+Shift+M'],
'退出中止后重设同一键应重新注册生效(修复前命中早退、键保持未注册)'
)
})
test('基线:角标仅 macOS 生效且计数归一化', async () => {
const mac = await loadMain({ platform: 'darwin' })
await mac.invoke('desktop-badge', { count: 3 })
await mac.invoke('desktop-badge', { count: -5 })
assert.deepEqual(mac.state.badgeCounts, [3, 0])
const win = await loadMain({ platform: 'win32' })
await win.invoke('desktop-badge', { count: 3 })
assert.deepEqual(win.state.badgeCounts, [])
})
// ─── 基线:自启 / 偏好 ───
test('基线:dev 模式拒绝开启自启且不落盘', async () => {
const h = await loadMain({ packaged: false })
const r = await h.invoke('desktop-prefs-set', { autoStart: true })
assert.equal(r.ok, false)
assert.equal(h.hasPrefsFile(), false)
assert.deepEqual(h.state.loginItemSettings, [])
})
test('基线:dev 模式清空自启仍落盘(关闭不拒绝)', async () => {
const h = await loadMain({ packaged: false, prefs: { autoStart: true } })
const r = await h.invoke('desktop-prefs-set', { autoStart: false })
assert.equal(r.ok, true)
assert.equal(h.readPrefs().autoStart, false)
})
test('基线:打包后开启自启写入登录项(openAsHidden)', async () => {
const h = await loadMain({ packaged: true })
const r = await h.invoke('desktop-prefs-set', { autoStart: true })
assert.equal(r.ok, true)
assert.deepEqual(h.state.loginItemSettings, [{ openAtLogin: true, openAsHidden: true }])
})
test('基线:desktop-prefs-get 回传 packaged 与 platform', async () => {
const h = await loadMain({ packaged: true, platform: 'darwin' })
const r = await h.invoke('desktop-prefs-get')
assert.equal(r.ok, true)
assert.equal(r.packaged, true)
assert.equal(r.platform, 'darwin')
assert.equal(r.prefs.closeToTray, 'tray', '缺省偏好为托盘化')
assert.equal(r.prefs.globalShortcut, 'Alt+Shift+M', '缺省快捷键')
})
// ─── 基线:LOCAL 附件 IPC ───
test('基线:local-copy-attachment 落点由主进程推导(home + host + sid),恶意名被 sanitize', async () => {
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mao-harness-home-'))
const prevHome = process.env.HOME
process.env.HOME = fakeHome
try {
const h = await loadMain({})
const src = path.join(fakeHome, 'src.txt')
fs.writeFileSync(src, 'hello')
const r = await h.invoke('local-copy-attachment', { sourcePath: src, sessionId: 9, maxSizeMb: 10 })
assert.equal(r.ok, true, JSON.stringify(r))
assert.ok(
r.absolutePath.startsWith(fs.realpathSync(fakeHome) + path.sep + '.mao' + path.sep + 'attachments' + path.sep),
r.absolutePath
)
assert.equal(fs.readFileSync(r.absolutePath, 'utf8'), 'hello')
} finally {
process.env.HOME = prevHome
fs.rmSync(fakeHome, { recursive: true, force: true })
}
})
test('回归:local-write-text-file 回传 { ok, absolutePath },同名粘贴追加副本且不覆盖', async () => {
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mao-harness-home-'))
const prevHome = process.env.HOME
process.env.HOME = fakeHome
try {
const h = await loadMain({})
const first = await h.invoke('local-write-text-file', {
fileName: 'pasted-text-1.txt',
content: '第一批 14084181',
sessionId: 9,
maxSizeMb: 10,
})
assert.equal(first.ok, true, JSON.stringify(first))
assert.equal(first.success, undefined, '渲染层契约不应混入工具协议的 success')
assert.equal(path.basename(first.absolutePath), 'pasted-text-1.txt')
assert.ok(
first.absolutePath.startsWith(fs.realpathSync(fakeHome) + path.sep + '.mao' + path.sep + 'attachments' + path.sep),
first.absolutePath
)
assert.equal(fs.readFileSync(first.absolutePath, 'utf8'), '第一批 14084181')
const second = await h.invoke('local-write-text-file', {
fileName: 'pasted-text-1.txt',
content: '第二批 14090001',
sessionId: 9,
maxSizeMb: 10,
})
assert.equal(second.ok, true, JSON.stringify(second))
assert.equal(path.basename(second.absolutePath), 'pasted-text-1-1.txt')
assert.notEqual(second.absolutePath, first.absolutePath)
assert.equal(fs.readFileSync(first.absolutePath, 'utf8'), '第一批 14084181', '已有附件不得被后一次粘贴覆盖')
assert.equal(fs.readFileSync(second.absolutePath, 'utf8'), '第二批 14090001')
const bad = await h.invoke('local-write-text-file', { fileName: '', content: 'x', sessionId: 9 })
assert.deepEqual(bad, { ok: false, error: '文件名无效' })
} finally {
process.env.HOME = prevHome
fs.rmSync(fakeHome, { recursive: true, force: true })
}
})
// ─── 通知点击深链的待派发队列(P2 修复:点击早于渲染层监听注册时跳转丢失) ───
test('通知点击入待派发队列:窗口存活立即派发,consume 可取到,ack 后清空', async () => {
const h = await loadMain({})
await h.invoke('desktop-notify', { title: '任务完成', sessionId: 42, itemId: 2, tag: 'mao-inbox-9' })
h.state.notifications[0].emit('click')
const immediate = h.state.sent.filter((s) => s.channel === 'desktop-notification-click')
assert.equal(immediate.length, 1, '窗口存活时立即派发(低延迟路径)')
assert.deepEqual(immediate[0].data, { itemId: 2, sessionId: 42 })
// 渲染层监听可能尚未注册(应用刚启动/页面加载中):主进程必须留有积存供就绪后拉取
assert.deepEqual(
await h.invoke('desktop-notification-click-consume'),
{ itemId: 2, sessionId: 42 },
'consume 应取到积存的点击深链'
)
// 取出即清空,不重复派发
assert.equal(await h.invoke('desktop-notification-click-consume'), null)
})
test('ack 清除待派发队列:页面重载后桥接重新就绪不会拿陈旧点击重复跳转', async () => {
const h = await loadMain({})
await h.invoke('desktop-notify', { title: '任务完成', sessionId: 42 })
h.state.notifications[0].emit('click')
await h.invoke('desktop-notification-click-ack')
assert.equal(await h.invoke('desktop-notification-click-consume'), null)
})
test('窗口销毁后点击:consume 队列与 did-finish-load 补发互斥,不双重跳转', async () => {
const h = await loadMain({ prefs: { closeToTray: 'quit' }, platform: 'darwin' })
await h.invoke('desktop-notify', { title: '任务完成', sessionId: 42 })
h.win().close()
h.state.notifications[0].emit('click')
await h.tick()
assert.equal(h.win().isDestroyed(), false, '点击通知应重建窗口')
// 渲染层先就绪并拉取(页面加载慢、监听后注册的真实时序)
assert.deepEqual(
await h.invoke('desktop-notification-click-consume'),
{ itemId: null, sessionId: 42 }
)
// 已被拉取:did-finish-load 不再补发,避免同一深链跳转两次
h.win().emitWebContents('did-finish-load')
assert.equal(
h.state.sent.filter((s) => s.channel === 'desktop-notification-click').length, 0,
'已被 consume 的点击不得再补发'
)
})