Ctrl K
ComunidadGuías…

desktop/electron/mainShell.test.cjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
'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 的点击不得再补发'
  )
})