feat(robustness+ui): отказоустойчивость main, тесты, a11y-полировка, лицензия
Надёжность main-процесса: - глобальные uncaughtException/unhandledRejection (лог + flushNow) - safeHandle/safeOn вокруг всех IPC-хендлеров (не падаем молча, generic-ошибка наружу) - таймаут 4s на tasklist, Atomics.wait вместо busy-spin на exit-записи - единый log.error для фоновых сбоев вместо console.error/тишины Тесты (178 -> 203): meeting-detect, scheduler-gating, store (миграции/карантин/cap). UI/UX: - prefers-reduced-motion через MotionConfig + CSS media-блок - Spinner/Skeleton примитивы, loading-состояния вместо пустых заглушек - aria-live анонсы достижений и выполнения (useAnnounce) - оформленные пустые состояния, клавиатура в меню ExerciseCard Лицензия: проприетарный LICENSE + правка README/CLAUDE.md, счётчик тестов. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,9 +13,26 @@ import { broadcastState } from './state-actions'
|
||||
import { startGamesRegistry, stopGamesRegistry } from './games/registry'
|
||||
import { initUpdater, stopUpdater } from './updater'
|
||||
import { IPC } from '@shared/ipc'
|
||||
import { log } from './logger'
|
||||
|
||||
const APP_ID = 'com.anril.exercise-reminder'
|
||||
|
||||
// Глобальная сеть безопасности: без этих обработчиков необработанное
|
||||
// исключение/rejection в main-процессе валит приложение молча — пользователь
|
||||
// видит, что окно просто исчезло, а в логах пусто. Логируем всё в latest.log.
|
||||
// uncaughtException дополнительно флашит state, чтобы не потерять данные.
|
||||
process.on('uncaughtException', (err) => {
|
||||
log.error('[fatal] uncaughtException', err)
|
||||
try {
|
||||
flushNow()
|
||||
} catch {
|
||||
// flush сам может бросить (диск/AV) — мы уже в аварийном пути, глушим.
|
||||
}
|
||||
})
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log.error('[fatal] unhandledRejection', reason)
|
||||
})
|
||||
|
||||
// Must be set BEFORE app.whenReady() for Windows toasts to show
|
||||
// the correct app name / icon in Action Center.
|
||||
app.setAppUserModelId(APP_ID)
|
||||
@@ -38,7 +55,7 @@ if (!gotLock) {
|
||||
|
||||
startScheduler()
|
||||
startGamesRegistry().catch((err) =>
|
||||
console.error('games registry failed:', err)
|
||||
log.error('[index] games registry failed', err)
|
||||
)
|
||||
initUpdater()
|
||||
|
||||
@@ -88,7 +105,7 @@ if (!gotLock) {
|
||||
try {
|
||||
await stopGamesRegistry()
|
||||
} catch (err) {
|
||||
console.error('[index] stopGamesRegistry threw:', err)
|
||||
log.error('[index] stopGamesRegistry threw', err)
|
||||
}
|
||||
flushNow()
|
||||
app.exit(0)
|
||||
|
||||
133
src/main/ipc.ts
133
src/main/ipc.ts
@@ -7,6 +7,7 @@ import {
|
||||
dialog,
|
||||
shell
|
||||
} from 'electron'
|
||||
import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { IPC } from '@shared/ipc'
|
||||
import type { Exercise, GameId, Settings } from '@shared/types'
|
||||
@@ -60,9 +61,57 @@ import {
|
||||
validateSettingsPatch,
|
||||
validateSnoozeMinutes
|
||||
} from './validate'
|
||||
import { log } from './logger'
|
||||
|
||||
/**
|
||||
* Враппер вокруг `ipcMain.handle`: ловит любое исключение в обработчике,
|
||||
* пишет его в лог и отдаёт renderer'у обобщённую ошибку. Без этого один
|
||||
* упавший хендлер молча обрывает invoke (renderer висит на await) и в проде
|
||||
* не остаётся следов. Generic-сообщение наружу — не утекают внутренние детали.
|
||||
*
|
||||
* Констрейнт `...args: never[]` делает любую сигнатуру хендлера присваиваемой
|
||||
* (контравариантность параметров), поэтому типы на call-site сохраняются.
|
||||
*/
|
||||
function safeHandle<
|
||||
F extends (event: IpcMainInvokeEvent, ...args: never[]) => unknown
|
||||
>(channel: string, fn: F): void {
|
||||
ipcMain.handle(channel, async (event, ...args) => {
|
||||
try {
|
||||
const call = fn as unknown as (
|
||||
e: IpcMainInvokeEvent,
|
||||
...a: unknown[]
|
||||
) => unknown
|
||||
return await call(event, ...args)
|
||||
} catch (err) {
|
||||
log.error(`[ipc] ${channel} threw`, err)
|
||||
throw new Error('ipc-failed')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Аналог для `ipcMain.on` (fire-and-forget). Ошибку логируем, но не
|
||||
* пробрасываем — у sender'а нет канала для ответа.
|
||||
*/
|
||||
function safeOn<F extends (event: IpcMainEvent, ...args: never[]) => void>(
|
||||
channel: string,
|
||||
fn: F
|
||||
): void {
|
||||
ipcMain.on(channel, (event, ...args) => {
|
||||
try {
|
||||
const call = fn as unknown as (
|
||||
e: IpcMainEvent,
|
||||
...a: unknown[]
|
||||
) => void
|
||||
call(event, ...args)
|
||||
} catch (err) {
|
||||
log.error(`[ipc] ${channel} (on) threw`, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function registerIpc(): void {
|
||||
ipcMain.handle(IPC.getState, () => {
|
||||
safeHandle(IPC.getState, () => {
|
||||
// Без history (см. getStateForRenderer) и с актуальным значением
|
||||
// autostart из OS — мутацию делаем по копии, не по cache.
|
||||
const state = getStateForRenderer()
|
||||
@@ -73,7 +122,7 @@ export function registerIpc(): void {
|
||||
return state
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.addExercise, (_e, input: unknown) => {
|
||||
safeHandle(IPC.addExercise, (_e, input: unknown) => {
|
||||
const safe = validateExerciseInput(input)
|
||||
if (!safe) return null
|
||||
const ex = addExercise(safe)
|
||||
@@ -81,7 +130,7 @@ export function registerIpc(): void {
|
||||
return ex
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
safeHandle(
|
||||
IPC.updateExercise,
|
||||
(_e, idRaw: unknown, patchRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
@@ -93,7 +142,7 @@ export function registerIpc(): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.deleteExercise, (_e, idRaw: unknown) => {
|
||||
safeHandle(IPC.deleteExercise, (_e, idRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
if (!id) return false
|
||||
const ok = deleteExercise(id)
|
||||
@@ -101,7 +150,7 @@ export function registerIpc(): void {
|
||||
return ok
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
safeHandle(
|
||||
IPC.toggleExercise,
|
||||
(_e, idRaw: unknown, enabledRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
@@ -117,7 +166,7 @@ export function registerIpc(): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.markDone, (_e, idRaw: unknown, repsRaw?: unknown) => {
|
||||
safeHandle(IPC.markDone, (_e, idRaw: unknown, repsRaw?: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
if (!id) return null
|
||||
const ex = markDone(id, validateActualReps(repsRaw))
|
||||
@@ -126,7 +175,7 @@ export function registerIpc(): void {
|
||||
return ex
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.snooze, (_e, idRaw: unknown, minRaw: unknown) => {
|
||||
safeHandle(IPC.snooze, (_e, idRaw: unknown, minRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
const minutes = validateSnoozeMinutes(minRaw)
|
||||
if (!id || minutes === null) return null
|
||||
@@ -136,7 +185,7 @@ export function registerIpc(): void {
|
||||
return ex
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.skip, (_e, idRaw: unknown) => {
|
||||
safeHandle(IPC.skip, (_e, idRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
if (!id) return null
|
||||
const ex = skip(id)
|
||||
@@ -145,7 +194,7 @@ export function registerIpc(): void {
|
||||
return ex
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.updateSettings, (_e, patchRaw: unknown) => {
|
||||
safeHandle(IPC.updateSettings, (_e, patchRaw: unknown) => {
|
||||
const patch = validateSettingsPatch(patchRaw)
|
||||
if (!patch) return null
|
||||
if (patch.startWithWindows !== undefined) {
|
||||
@@ -165,19 +214,19 @@ export function registerIpc(): void {
|
||||
return settings
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.pauseAll, () => {
|
||||
safeHandle(IPC.pauseAll, () => {
|
||||
updateSettings({ globalEnabled: false })
|
||||
broadcastState()
|
||||
refreshMenu()
|
||||
})
|
||||
ipcMain.handle(IPC.resumeAll, () => {
|
||||
safeHandle(IPC.resumeAll, () => {
|
||||
updateSettings({ globalEnabled: true })
|
||||
broadcastState()
|
||||
forceCheck()
|
||||
refreshMenu()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.getAccentColor, () => {
|
||||
safeHandle(IPC.getAccentColor, () => {
|
||||
try {
|
||||
return '#' + systemPreferences.getAccentColor()
|
||||
} catch {
|
||||
@@ -185,45 +234,45 @@ export function registerIpc(): void {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.getOsTheme, () =>
|
||||
safeHandle(IPC.getOsTheme, () =>
|
||||
nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.getAppVersion, () => app.getVersion())
|
||||
safeHandle(IPC.getAppVersion, () => app.getVersion())
|
||||
|
||||
ipcMain.handle(IPC.getMeetingActive, () => isMeetingActiveSync())
|
||||
safeHandle(IPC.getMeetingActive, () => isMeetingActiveSync())
|
||||
|
||||
ipcMain.handle(IPC.quit, () => app.quit())
|
||||
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
|
||||
safeHandle(IPC.quit, () => app.quit())
|
||||
safeHandle(IPC.reminderClose, () => hideReminderWindow())
|
||||
|
||||
ipcMain.on(IPC.minimizeMain, (event) => {
|
||||
safeOn(IPC.minimizeMain, (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||
})
|
||||
|
||||
ipcMain.on(IPC.toggleMaximizeMain, (event) => {
|
||||
safeOn(IPC.toggleMaximizeMain, (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
if (win.isMaximized()) win.unmaximize()
|
||||
else win.maximize()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.isMaximizedMain, (event) => {
|
||||
safeHandle(IPC.isMaximizedMain, (event) => {
|
||||
return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false
|
||||
})
|
||||
|
||||
ipcMain.on(IPC.closeMain, () => {
|
||||
safeOn(IPC.closeMain, () => {
|
||||
const main = getMainWindow()
|
||||
if (!main) return
|
||||
if (getState().settings.minimizeToTray) main.hide()
|
||||
else main.close()
|
||||
})
|
||||
|
||||
ipcMain.on(IPC.hideMain, () => getMainWindow()?.hide())
|
||||
safeOn(IPC.hideMain, () => getMainWindow()?.hide())
|
||||
|
||||
// Games
|
||||
ipcMain.handle(IPC.gamesList, async () => listGamesStatus())
|
||||
safeHandle(IPC.gamesList, async () => listGamesStatus())
|
||||
|
||||
ipcMain.handle(IPC.gameInstall, async (_e, id: GameId) => {
|
||||
safeHandle(IPC.gameInstall, async (_e, id: GameId) => {
|
||||
const status = await installGame(id)
|
||||
setGameEnabled(id, true)
|
||||
await toggleGame(id, true)
|
||||
@@ -233,7 +282,7 @@ export function registerIpc(): void {
|
||||
return status
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.gameUninstall, async (_e, id: GameId) => {
|
||||
safeHandle(IPC.gameUninstall, async (_e, id: GameId) => {
|
||||
const status = await uninstallGame(id)
|
||||
setGameEnabled(id, false)
|
||||
const all = await listGamesStatus()
|
||||
@@ -242,7 +291,7 @@ export function registerIpc(): void {
|
||||
return status
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => {
|
||||
safeHandle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => {
|
||||
setGameEnabled(id, enabled)
|
||||
await toggleGame(id, enabled)
|
||||
const all = await listGamesStatus()
|
||||
@@ -250,20 +299,20 @@ export function registerIpc(): void {
|
||||
broadcastState()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => {
|
||||
safeHandle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => {
|
||||
// Opens Steam's library; user manually adds launch options.
|
||||
shell.openExternal('steam://nav/games/details/570')
|
||||
})
|
||||
|
||||
// Challenges
|
||||
ipcMain.handle(IPC.addChallenge, (_e, input: unknown) => {
|
||||
safeHandle(IPC.addChallenge, (_e, input: unknown) => {
|
||||
const safe = validateChallengeInput(input)
|
||||
if (!safe) return null
|
||||
const c = addChallenge(safe)
|
||||
broadcastState()
|
||||
return c
|
||||
})
|
||||
ipcMain.handle(
|
||||
safeHandle(
|
||||
IPC.updateChallenge,
|
||||
(_e, idRaw: unknown, patchRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
@@ -274,14 +323,14 @@ export function registerIpc(): void {
|
||||
return c
|
||||
}
|
||||
)
|
||||
ipcMain.handle(IPC.deleteChallenge, (_e, idRaw: unknown) => {
|
||||
safeHandle(IPC.deleteChallenge, (_e, idRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
if (!id) return false
|
||||
const ok = deleteChallenge(id)
|
||||
broadcastState()
|
||||
return ok
|
||||
})
|
||||
ipcMain.handle(
|
||||
safeHandle(
|
||||
IPC.toggleChallenge,
|
||||
(_e, idRaw: unknown, enabledRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
@@ -292,9 +341,9 @@ export function registerIpc(): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.closeMatchSummary, () => hideReminderWindow())
|
||||
safeHandle(IPC.closeMatchSummary, () => hideReminderWindow())
|
||||
|
||||
ipcMain.handle(
|
||||
safeHandle(
|
||||
IPC.markChallengeDone,
|
||||
(_e, idRaw: unknown, repsRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
@@ -311,7 +360,7 @@ export function registerIpc(): void {
|
||||
// packaged builds — a compromised renderer (XSS, malicious npm dep) could
|
||||
// otherwise fabricate arbitrary match-end events at will.
|
||||
if (!app.isPackaged) {
|
||||
ipcMain.handle(
|
||||
safeHandle(
|
||||
IPC.devSimulateMatchEnd,
|
||||
(_e, id: GameId, stats: Record<string, number>) => {
|
||||
simulateMatchEnd(id, stats)
|
||||
@@ -320,19 +369,19 @@ export function registerIpc(): void {
|
||||
}
|
||||
|
||||
// Auto-updater
|
||||
ipcMain.handle(IPC.updaterStatus, () => getUpdaterStatus())
|
||||
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates())
|
||||
safeHandle(IPC.updaterStatus, () => getUpdaterStatus())
|
||||
safeHandle(IPC.updaterCheck, () => checkForUpdates())
|
||||
// download/install — fire-and-forget. Прогресс и завершение приходят в
|
||||
// renderer через evtUpdaterStatus, ждать promise бессмысленно — renderer
|
||||
// только зря держал бы `busy=true` весь download (минуты на медленной сети).
|
||||
ipcMain.on(IPC.updaterDownload, () => {
|
||||
safeOn(IPC.updaterDownload, () => {
|
||||
void downloadUpdate()
|
||||
})
|
||||
ipcMain.on(IPC.updaterInstall, () => quitAndInstall())
|
||||
safeOn(IPC.updaterInstall, () => quitAndInstall())
|
||||
|
||||
// History
|
||||
ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
|
||||
ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) => {
|
||||
safeHandle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
|
||||
safeHandle(IPC.clearHistory, (_e, beforeTs?: number) => {
|
||||
const removed = clearHistory(beforeTs)
|
||||
if (removed > 0) broadcastHistoryChanged()
|
||||
return removed
|
||||
@@ -340,7 +389,7 @@ export function registerIpc(): void {
|
||||
|
||||
// Export / Import. Используем native save/open dialogs Electron'а —
|
||||
// renderer не получает прямого доступа к ФС.
|
||||
ipcMain.handle(IPC.exportState, async (event) => {
|
||||
safeHandle(IPC.exportState, async (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender) ?? undefined
|
||||
const stamp = new Date()
|
||||
.toISOString()
|
||||
@@ -369,7 +418,7 @@ export function registerIpc(): void {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.importState, async (event) => {
|
||||
safeHandle(IPC.importState, async (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender) ?? undefined
|
||||
const lang = getState().settings.language ?? 'ru'
|
||||
const result = await dialog.showOpenDialog(win!, {
|
||||
|
||||
109
src/main/meeting-detect.test.ts
Normal file
109
src/main/meeting-detect.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
/**
|
||||
* Тесты эвристики «человек на ВКС». Мокаем `node:child_process.exec`
|
||||
* (через него идёт `tasklist`), electron BrowserWindow (broadcast no-op) и
|
||||
* logger. resetModules + dynamic import в каждом тесте — чтобы сбросить
|
||||
* module-level кэш (`cachedActive`, `lastCheckAt`).
|
||||
*/
|
||||
|
||||
type ExecCb = (err: Error | null, res?: { stdout: string }) => void
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
// Текущая реализация exec для конкретного теста.
|
||||
execImpl: ((_cmd: string, _opts: unknown, cb: ExecCb) =>
|
||||
cb(null, { stdout: '' })) as (
|
||||
cmd: string,
|
||||
opts: unknown,
|
||||
cb: ExecCb
|
||||
) => void,
|
||||
calls: 0,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
exec: (cmd: string, opts: unknown, cb: ExecCb) => {
|
||||
h.calls += 1
|
||||
h.execImpl(cmd, opts, cb)
|
||||
}
|
||||
}))
|
||||
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }))
|
||||
vi.mock('./logger', () => ({ log: h.log }))
|
||||
|
||||
/** CSV-строка tasklist для заданного набора .exe. */
|
||||
function csv(...procs: string[]): string {
|
||||
return procs.map((p) => `"${p}","1234","Console","1","85,432 K"`).join('\r\n')
|
||||
}
|
||||
|
||||
async function load(): Promise<typeof import('./meeting-detect')> {
|
||||
return import('./meeting-detect')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
h.calls = 0
|
||||
h.execImpl = (_cmd, _opts, cb) => cb(null, { stdout: '' })
|
||||
h.log.info.mockClear()
|
||||
h.log.warn.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('isMeetingActive', () => {
|
||||
it('детектит zoom.exe', async () => {
|
||||
h.execImpl = (_c, _o, cb) => cb(null, { stdout: csv('zoom.exe') })
|
||||
const { isMeetingActive } = await load()
|
||||
expect(await isMeetingActive()).toBe(true)
|
||||
})
|
||||
|
||||
it('детектит новые Teams (ms-teams.exe)', async () => {
|
||||
h.execImpl = (_c, _o, cb) =>
|
||||
cb(null, { stdout: csv('explorer.exe', 'ms-teams.exe') })
|
||||
const { isMeetingActive } = await load()
|
||||
expect(await isMeetingActive()).toBe(true)
|
||||
})
|
||||
|
||||
it('возвращает false когда ВКС-процессов нет', async () => {
|
||||
h.execImpl = (_c, _o, cb) =>
|
||||
cb(null, { stdout: csv('explorer.exe', 'code.exe', 'chrome.exe') })
|
||||
const { isMeetingActive } = await load()
|
||||
expect(await isMeetingActive()).toBe(false)
|
||||
})
|
||||
|
||||
it('кэширует результат в пределах CACHE_MS (exec вызывается один раз)', async () => {
|
||||
h.execImpl = (_c, _o, cb) => cb(null, { stdout: csv('discord.exe') })
|
||||
const { isMeetingActive } = await load()
|
||||
await isMeetingActive()
|
||||
await isMeetingActive()
|
||||
expect(h.calls).toBe(1)
|
||||
})
|
||||
|
||||
it('при падении tasklist возвращает false и логирует warn', async () => {
|
||||
h.execImpl = (_c, _o, cb) => cb(new Error('ETIMEDOUT'))
|
||||
const { isMeetingActive } = await load()
|
||||
expect(await isMeetingActive()).toBe(false)
|
||||
expect(h.log.warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('isMeetingActiveSync отражает последний известный результат', async () => {
|
||||
h.execImpl = (_c, _o, cb) => cb(null, { stdout: csv('webex.exe') })
|
||||
const mod = await load()
|
||||
expect(mod.isMeetingActiveSync()).toBe(false) // до первого запроса
|
||||
await mod.isMeetingActive()
|
||||
expect(mod.isMeetingActiveSync()).toBe(true)
|
||||
})
|
||||
|
||||
it('на не-Windows возвращает false без вызова tasklist', async () => {
|
||||
const original = process.platform
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
try {
|
||||
const { isMeetingActive } = await load()
|
||||
expect(await isMeetingActive()).toBe(false)
|
||||
expect(h.calls).toBe(0)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: original })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -64,7 +64,12 @@ export async function isMeetingActive(): Promise<boolean> {
|
||||
// CSV без заголовков (/NH), скрытое окно.
|
||||
const { stdout } = await execAsync('tasklist /FO CSV /NH', {
|
||||
windowsHide: true,
|
||||
maxBuffer: 4 * 1024 * 1024 // tasklist бывает большой
|
||||
maxBuffer: 4 * 1024 * 1024, // tasklist бывает большой
|
||||
// Если tasklist подвис (повреждённый WMI, загруженная система) — exec
|
||||
// сам прибьёт процесс и уйдёт в catch. Без таймаута зависшие child
|
||||
// накапливались бы при каждом refresh.
|
||||
timeout: 4000,
|
||||
killSignal: 'SIGKILL'
|
||||
})
|
||||
const lower = stdout.toLowerCase()
|
||||
for (const proc of MEETING_PROCESSES) {
|
||||
|
||||
167
src/main/scheduler.test.ts
Normal file
167
src/main/scheduler.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type {
|
||||
Exercise,
|
||||
HistoryEntry,
|
||||
QuietHours,
|
||||
Settings
|
||||
} from '@shared/types'
|
||||
import { DEFAULT_SETTINGS } from '@shared/types'
|
||||
|
||||
/**
|
||||
* Тесты gating-логики scheduler'а. Дёргаем публичный `forceCheck()` (он
|
||||
* сбрасывает lastCheckAt и прогоняет tick → checkDueExercises) и проверяем,
|
||||
* вызвался ли `fireReminder`. Стор/нотификации/meeting/adaptive замоканы.
|
||||
*/
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
settings: null as Settings | null,
|
||||
exercises: [] as Exercise[],
|
||||
history: [] as HistoryEntry[],
|
||||
meetingActive: false,
|
||||
fireReminder: vi.fn(),
|
||||
updateExercise: vi.fn(),
|
||||
broadcastState: vi.fn(),
|
||||
refreshMeetingState: vi.fn(),
|
||||
adjustNextFireAt: vi.fn((_ex: Exercise, candidate: number) => candidate)
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
powerMonitor: { on: vi.fn() },
|
||||
BrowserWindow: { getAllWindows: () => [] }
|
||||
}))
|
||||
vi.mock('./store', () => ({
|
||||
getSettings: () => h.settings,
|
||||
getExercises: () => h.exercises,
|
||||
getHistory: () => h.history,
|
||||
updateExercise: (id: string, patch: Partial<Exercise>) => {
|
||||
h.updateExercise(id, patch)
|
||||
const ex = h.exercises.find((e) => e.id === id)
|
||||
return ex ? { ...ex, ...patch } : undefined
|
||||
}
|
||||
}))
|
||||
vi.mock('./notifications', () => ({ fireReminder: h.fireReminder }))
|
||||
vi.mock('./state-actions', () => ({ broadcastState: h.broadcastState }))
|
||||
vi.mock('./meeting-detect', () => ({
|
||||
isMeetingActiveSync: () => h.meetingActive,
|
||||
refreshMeetingState: h.refreshMeetingState
|
||||
}))
|
||||
vi.mock('./adaptive', () => ({ adjustNextFireAt: h.adjustNextFireAt }))
|
||||
|
||||
function makeExercise(over: Partial<Exercise> = {}): Exercise {
|
||||
return {
|
||||
id: 'ex1',
|
||||
name: 'Приседания',
|
||||
reps: 10,
|
||||
icon: 'Activity',
|
||||
intervalMinutes: 30,
|
||||
enabled: true,
|
||||
nextFireAt: Date.now() - 1000, // due by default
|
||||
...over
|
||||
}
|
||||
}
|
||||
|
||||
/** Тихие часы, гарантированно покрывающие текущий момент (без wrap). */
|
||||
function quietWindowAroundNow(): QuietHours {
|
||||
const now = new Date()
|
||||
const cur = now.getHours() * 60 + now.getMinutes()
|
||||
const fromMin = Math.max(0, cur - 60)
|
||||
const toMin = Math.min(1439, cur + 60)
|
||||
const fmt = (m: number): string =>
|
||||
`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(
|
||||
2,
|
||||
'0'
|
||||
)}`
|
||||
return { enabled: true, from: fmt(fromMin), to: fmt(toMin), days: [] }
|
||||
}
|
||||
|
||||
async function loadScheduler(): Promise<typeof import('./scheduler')> {
|
||||
return import('./scheduler')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
h.settings = { ...DEFAULT_SETTINGS }
|
||||
h.exercises = []
|
||||
h.history = []
|
||||
h.meetingActive = false
|
||||
h.fireReminder.mockClear()
|
||||
h.updateExercise.mockClear()
|
||||
h.broadcastState.mockClear()
|
||||
h.refreshMeetingState.mockClear()
|
||||
h.adjustNextFireAt.mockClear()
|
||||
})
|
||||
|
||||
describe('checkDueExercises gating', () => {
|
||||
it('не fire-ит когда globalEnabled=false', async () => {
|
||||
h.settings = { ...DEFAULT_SETTINGS, globalEnabled: false }
|
||||
h.exercises = [makeExercise()]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
forceCheck()
|
||||
expect(h.fireReminder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('не fire-ит внутри тихих часов', async () => {
|
||||
h.settings = { ...DEFAULT_SETTINGS, quietHours: quietWindowAroundNow() }
|
||||
h.exercises = [makeExercise()]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
forceCheck()
|
||||
expect(h.fireReminder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('не fire-ит когда активна ВКС (meetingAutoPause)', async () => {
|
||||
h.settings = { ...DEFAULT_SETTINGS, meetingAutoPause: true }
|
||||
h.meetingActive = true
|
||||
h.exercises = [makeExercise()]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
forceCheck()
|
||||
expect(h.refreshMeetingState).toHaveBeenCalled()
|
||||
expect(h.fireReminder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fire-ит готовое к срабатыванию упражнение и шлёт broadcastState', async () => {
|
||||
h.exercises = [makeExercise()]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
forceCheck()
|
||||
expect(h.fireReminder).toHaveBeenCalledTimes(1)
|
||||
expect(h.broadcastState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('пропускает выключенные упражнения', async () => {
|
||||
h.exercises = [makeExercise({ enabled: false })]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
forceCheck()
|
||||
expect(h.fireReminder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('не fire-ит упражнение, чьё время ещё не пришло', async () => {
|
||||
h.exercises = [makeExercise({ nextFireAt: Date.now() + 60_000 })]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
forceCheck()
|
||||
expect(h.fireReminder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('soft-cap: при закрытой dailyGoal переносит fire, но не показывает', async () => {
|
||||
h.exercises = [makeExercise({ dailyGoal: 20 })]
|
||||
h.history = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
exerciseId: 'ex1',
|
||||
action: 'done',
|
||||
actualReps: 25
|
||||
}
|
||||
]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
forceCheck()
|
||||
expect(h.fireReminder).not.toHaveBeenCalled()
|
||||
// nextFireAt перенесён (на завтра) — updateExercise вызван.
|
||||
expect(h.updateExercise).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adaptive: применяет adjustNextFireAt к кандидату', async () => {
|
||||
h.exercises = [makeExercise({ adaptive: true })]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
forceCheck()
|
||||
expect(h.adjustNextFireAt).toHaveBeenCalled()
|
||||
expect(h.fireReminder).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
198
src/main/store.test.ts
Normal file
198
src/main/store.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
readdirSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Тесты persistence-слоя. Мокаем electron.app.getPath на временную директорию
|
||||
* (новую на каждый тест) и logger. resetModules + dynamic import сбрасывают
|
||||
* module-level `cache`/`storePath`, чтобы тесты не текли друг в друга.
|
||||
*/
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
userData: '',
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => h.userData,
|
||||
getVersion: () => '0.0.0-test'
|
||||
}
|
||||
}))
|
||||
vi.mock('./logger', () => ({ log: h.log }))
|
||||
|
||||
async function load(): Promise<typeof import('./store')> {
|
||||
return import('./store')
|
||||
}
|
||||
|
||||
function statePath(): string {
|
||||
return join(h.userData, 'app-state.json')
|
||||
}
|
||||
|
||||
function corruptFiles(): string[] {
|
||||
return readdirSync(h.userData).filter((f) => f.includes('.corrupt-'))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
h.userData = mkdtempSync(join(tmpdir(), 'laude-store-'))
|
||||
h.log.error.mockClear()
|
||||
h.log.warn.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(h.userData, { recursive: true, force: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
|
||||
describe('store · cold start', () => {
|
||||
it('создаёт app-state.json с примерами упражнений на первом запуске', async () => {
|
||||
const { getState } = await load()
|
||||
const state = getState()
|
||||
expect(state.exercises.length).toBeGreaterThan(0)
|
||||
expect(existsSync(statePath())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('store · corrupt file quarantine', () => {
|
||||
it('битый JSON уносится в .corrupt-* и стартует чистый state', async () => {
|
||||
writeFileSync(statePath(), '{ this is : not json', 'utf-8')
|
||||
const { getState } = await load()
|
||||
const state = getState()
|
||||
expect(state.exercises.length).toBeGreaterThan(0) // initial
|
||||
expect(corruptFiles().length).toBe(1)
|
||||
expect(h.log.error).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('валидный JSON, но не объект (массив) — тоже карантин', async () => {
|
||||
writeFileSync(statePath(), '[1,2,3]', 'utf-8')
|
||||
const { getState } = await load()
|
||||
expect(getState().exercises.length).toBeGreaterThan(0)
|
||||
expect(corruptFiles().length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('store · coerce / migrations', () => {
|
||||
it('подставляет дефолтные settings когда их нет в файле', async () => {
|
||||
writeFileSync(
|
||||
statePath(),
|
||||
JSON.stringify({ exercises: [], challenges: [], history: [] }),
|
||||
'utf-8'
|
||||
)
|
||||
const { getSettings } = await load()
|
||||
const s = getSettings()
|
||||
expect(s.globalEnabled).toBeDefined()
|
||||
expect(s.notificationMode).toBeDefined()
|
||||
expect(s.snoozeMinutes).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('файл без __schemaVersion грузится без потери данных', async () => {
|
||||
const ex = {
|
||||
id: 'x1',
|
||||
name: 'Тест',
|
||||
reps: 10,
|
||||
icon: 'Dumbbell',
|
||||
intervalMinutes: 30,
|
||||
enabled: true,
|
||||
nextFireAt: Date.now() + 1000
|
||||
}
|
||||
writeFileSync(
|
||||
statePath(),
|
||||
JSON.stringify({ exercises: [ex], challenges: [], history: [] }),
|
||||
'utf-8'
|
||||
)
|
||||
const { getExercises } = await load()
|
||||
const list = getExercises()
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0].name).toBe('Тест')
|
||||
})
|
||||
})
|
||||
|
||||
describe('store · history cap', () => {
|
||||
it('обрезает историю когда превышен HISTORY_MAX', async () => {
|
||||
const ex = {
|
||||
id: 'x1',
|
||||
name: 'Приседания',
|
||||
reps: 10,
|
||||
icon: 'Activity',
|
||||
intervalMinutes: 30,
|
||||
enabled: true,
|
||||
nextFireAt: Date.now()
|
||||
}
|
||||
const big = Array.from({ length: 10_005 }, (_unused, i) => ({
|
||||
ts: i,
|
||||
exerciseId: 'x1',
|
||||
action: 'done' as const,
|
||||
reps: 10
|
||||
}))
|
||||
writeFileSync(
|
||||
statePath(),
|
||||
JSON.stringify({ exercises: [ex], challenges: [], history: big }),
|
||||
'utf-8'
|
||||
)
|
||||
const { markDone, getHistory } = await load()
|
||||
markDone('x1')
|
||||
// 10005 + 1 = 10006 > 10000 → slice(-9000)
|
||||
expect(getHistory().length).toBe(9000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('store · clearHistory', () => {
|
||||
it('удаляет записи старше границы и возвращает количество', async () => {
|
||||
const ex = {
|
||||
id: 'x1',
|
||||
name: 'A',
|
||||
reps: 5,
|
||||
icon: 'Activity',
|
||||
intervalMinutes: 10,
|
||||
enabled: true,
|
||||
nextFireAt: Date.now()
|
||||
}
|
||||
const history = [
|
||||
{ ts: 100, exerciseId: 'x1', action: 'done' as const },
|
||||
{ ts: 200, exerciseId: 'x1', action: 'done' as const },
|
||||
{ ts: 300, exerciseId: 'x1', action: 'done' as const }
|
||||
]
|
||||
writeFileSync(
|
||||
statePath(),
|
||||
JSON.stringify({ exercises: [ex], challenges: [], history }),
|
||||
'utf-8'
|
||||
)
|
||||
const { clearHistory, getHistory } = await load()
|
||||
const removed = clearHistory(250)
|
||||
expect(removed).toBe(2)
|
||||
expect(getHistory().map((e) => e.ts)).toEqual([300])
|
||||
})
|
||||
|
||||
it('отказывается чистить без явной границы (защита от полного wipe)', async () => {
|
||||
const { clearHistory } = await load()
|
||||
expect(clearHistory()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('store · export / import', () => {
|
||||
it('export даёт валидный JSON со схемой; import парсит его обратно', async () => {
|
||||
const { exportState, importState } = await load()
|
||||
const json = exportState()
|
||||
const parsed = JSON.parse(json)
|
||||
expect(typeof parsed.__schemaVersion).toBe('number')
|
||||
expect(parsed.__schemaVersion).toBeGreaterThanOrEqual(1)
|
||||
expect(importState(json)).toBe(true)
|
||||
})
|
||||
|
||||
it('import отклоняет мусор', async () => {
|
||||
const { importState } = await load()
|
||||
expect(importState('not json at all')).toBe(false)
|
||||
expect(importState('42')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -303,11 +303,11 @@ function atomicWriteSync(path: string, contents: string): void {
|
||||
}
|
||||
const delay = WRITE_RETRY_DELAYS[i]
|
||||
if (delay === undefined) break
|
||||
// Event-loop остановлен, async sleep не вернётся — приходится spin.
|
||||
const until = Date.now() + delay
|
||||
while (Date.now() < until) {
|
||||
/* spin */
|
||||
}
|
||||
// Event-loop остановлен (exit-path), async sleep не вернётся — нужен
|
||||
// блокирующий sync sleep. Atomics.wait на «свежем» буфере всегда уходит
|
||||
// в таймаут (значение совпадает с ожидаемым 0), т.е. честно спит delay мс
|
||||
// без сжигания CPU — в отличие от старого busy-loop.
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay)
|
||||
}
|
||||
}
|
||||
log.error('[store] atomic sync write failed after retries', lastErr)
|
||||
|
||||
@@ -127,7 +127,15 @@ async function bootCheckWithRetry(): Promise<void> {
|
||||
return // success
|
||||
}
|
||||
const delay = BOOT_RETRY_DELAYS[attempt]
|
||||
if (delay === undefined) return // exhausted retries
|
||||
if (delay === undefined) {
|
||||
// Исчерпали ретраи — раньше сдавались молча. Логируем, чтобы при
|
||||
// диагностике было видно «boot-check так и не достучался». Следующая
|
||||
// попытка — на ближайшем hourly-тике.
|
||||
log.warn(
|
||||
'[updater] boot check exhausted retries — will retry on hourly tick'
|
||||
)
|
||||
return
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, delay))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user