refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes

This commit is contained in:
Yun Chan 2026-09-28 00:53:43 +09:00
parent 0adedf5e7b
commit 83cf9133ac
39 changed files with 4787 additions and 1061 deletions

View file

@ -0,0 +1,232 @@
// tests/main/services/update-service-redteam-r1-3.test.ts
// 자동 업데이트 회귀 테스트 (가짜 포트 주입):
// - 원격 정책을 체크마다 다시 받는다 (킬 스위치·rollout 이 실행 중 클라이언트에 반영)
// - '나중에' 이후에도 새 버전·필수 격상·간격 경과 시 다시 묻는다
// - evaluateUpdateOffer 게이팅 순서
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
DEFAULT_UPDATE_POLICY,
evaluateUpdateOffer,
shouldPromptForUpdate,
UPDATE_REPROMPT_INTERVAL_MS,
type UpdatePolicy,
} from '../../../src/main/update-policy'
import type {
ConsentAnswer,
PolicySource,
UpdatePrompter,
UpdaterEventHandlers,
UpdaterPort,
} from '../../../src/main/services/UpdateService'
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
const store = vi.hoisted(() => ({ values: {} as Record<string, unknown> }))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn((key: string) => store.values[key]),
configSet: vi.fn((key: string, value: unknown) => {
store.values[key] = value
}),
configGetAll: vi.fn(() => store.values),
}))
type ServiceModule = typeof import('../../../src/main/services/UpdateService')
let UpdateService: ServiceModule['UpdateService']
function policy(overrides: Partial<UpdatePolicy> = {}): UpdatePolicy {
return { ...DEFAULT_UPDATE_POLICY, channels: { ...DEFAULT_UPDATE_POLICY.channels }, ...overrides }
}
function harness(opts: { answers?: ConsentAnswer[]; policies?: UpdatePolicy[] } = {}) {
const answers = [...(opts.answers ?? [])]
const policies = [...(opts.policies ?? [policy()])]
let clock = 1_000_000
let handlers: UpdaterEventHandlers | null = null
const updater: UpdaterPort & { checks: number; downloads: number } = {
checks: 0,
downloads: 0,
checkForUpdates: vi.fn(async () => {
updater.checks++
}),
downloadUpdate: vi.fn(async () => {
updater.downloads++
}),
quitAndInstall: vi.fn(),
setChannel: vi.fn(),
setDifferentialDisabled: vi.fn(),
}
const policySource: PolicySource & { loads: number } = {
loads: 0,
load: vi.fn(async () => {
policySource.loads++
return policies.length > 1 ? (policies.shift() as UpdatePolicy) : policies[0]
}),
}
const consents: string[] = []
const prompter: UpdatePrompter = {
askConsent: vi.fn(async ({ version }) => {
consents.push(version)
return answers.shift() ?? 'later'
}),
askRestart: vi.fn(async () => 'later' as const),
}
const service = new UpdateService({
createUpdater: (h) => {
handlers = h
return updater
},
policySource,
prompter,
currentVersion: () => '1.9.0',
now: () => clock,
})
service.initialize()
service.dispose() // 타이머는 쓰지 않는다 — 테스트가 직접 호출
return {
service,
updater,
policySource,
consents,
handlers: () => handlers as UpdaterEventHandlers,
advance: (ms: number) => {
clock += ms
},
}
}
beforeEach(async () => {
vi.resetModules()
store.values = { updateDeviceId: 'device-1' }
UpdateService = (await import('../../../src/main/services/UpdateService')).UpdateService
})
describe('UpdateService — 원격 정책 갱신', () => {
it('체크할 때마다 정책을 다시 받고, 킬 스위치가 켜지면 확인을 멈춘다', async () => {
const h = harness({ policies: [policy(), policy({ killSwitch: true })] })
await h.service.checkForUpdates()
expect(h.updater.checks).toBe(1)
await h.service.checkForUpdates()
expect(h.policySource.loads).toBe(2)
expect(h.updater.checks).toBe(1)
})
it('킬 스위치가 켜진 뒤 도착한 update-available 도 무시한다', async () => {
const h = harness({ policies: [policy({ killSwitch: true })] })
await h.service.checkForUpdates()
await h.service.handleUpdateAvailable({ version: '1.9.1' })
expect(h.consents).toEqual([])
})
})
describe('UpdateService — 다시 묻기', () => {
it("'나중에' 이후 새 버전이 나오면 다시 묻는다", async () => {
const h = harness({ answers: ['later', 'later'] })
await h.service.checkForUpdates()
await h.service.handleUpdateAvailable({ version: '1.9.1' })
await h.service.handleUpdateAvailable({ version: '1.9.2' })
expect(h.consents).toEqual(['1.9.1', '1.9.2'])
})
it('같은 버전은 간격 안에서는 다시 묻지 않고, 간격이 지나면 다시 묻는다', async () => {
const h = harness({ answers: ['later', 'later'] })
await h.service.checkForUpdates()
await h.service.handleUpdateAvailable({ version: '1.9.1' })
await h.service.handleUpdateAvailable({ version: '1.9.1' })
expect(h.consents).toEqual(['1.9.1'])
h.advance(UPDATE_REPROMPT_INTERVAL_MS)
await h.service.handleUpdateAvailable({ version: '1.9.1' })
expect(h.consents).toEqual(['1.9.1', '1.9.1'])
})
it('필수로 격상되면 간격과 무관하게 다시 묻는다', async () => {
const h = harness({
answers: ['later', 'download'],
policies: [policy(), policy({ minimumSupportedVersion: '1.9.1' })],
})
await h.service.checkForUpdates()
await h.service.handleUpdateAvailable({ version: '1.9.1' })
await h.service.checkForUpdates()
await h.service.handleUpdateAvailable({ version: '1.9.1' })
expect(h.consents).toEqual(['1.9.1', '1.9.1'])
expect(h.updater.downloads).toBe(1)
})
it('수락한 다운로드가 실패하면 다음 체크에서 다시 묻는다', async () => {
const h = harness({ answers: ['download', 'later'] })
await h.service.checkForUpdates()
await h.service.handleUpdateAvailable({ version: '1.9.1' })
expect(h.updater.downloads).toBe(1)
h.handlers().onError('network down')
await h.service.handleUpdateAvailable({ version: '1.9.1' })
expect(h.consents).toEqual(['1.9.1', '1.9.1'])
})
it("'이 버전 건너뛰기' 는 그 버전을 다시 묻지 않는다", async () => {
const h = harness({ answers: ['skip'] })
await h.service.checkForUpdates()
await h.service.handleUpdateAvailable({ version: '1.9.1' })
h.advance(UPDATE_REPROMPT_INTERVAL_MS * 2)
await h.service.handleUpdateAvailable({ version: '1.9.1' })
expect(h.consents).toEqual(['1.9.1'])
expect(store.values.skippedUpdateVersion).toBe('1.9.1')
})
})
describe('evaluateUpdateOffer 게이팅 순서', () => {
const base = {
channel: 'latest' as const,
currentVersion: '1.9.0',
skippedVersion: null,
deviceId: 'device-1',
}
it('킬 스위치가 가장 먼저다', () => {
const offer = evaluateUpdateOffer({ ...base, policy: policy({ killSwitch: true }), targetVersion: '1.9.1' })
expect(offer).toMatchObject({ action: 'ignore', reason: 'kill-switch' })
})
it('stable 채널은 prerelease 를 거부한다', () => {
const offer = evaluateUpdateOffer({ ...base, policy: policy(), targetVersion: '1.10.0-beta.1' })
expect(offer).toMatchObject({ action: 'ignore', reason: 'channel' })
})
it('필수 업데이트는 건너뛰기·rollout 을 무시한다', () => {
const offer = evaluateUpdateOffer({
...base,
policy: policy({ minimumSupportedVersion: '1.9.1', stagingPercentage: 0 }),
targetVersion: '1.9.1',
skippedVersion: '1.9.1',
})
expect(offer).toMatchObject({ action: 'prompt', decision: { mandatory: true } })
})
it('건너뛴 버전 / rollout 밖은 무시한다', () => {
expect(
evaluateUpdateOffer({ ...base, policy: policy(), targetVersion: '1.9.1', skippedVersion: '1.9.1' }),
).toMatchObject({ action: 'ignore', reason: 'skipped' })
expect(
evaluateUpdateOffer({ ...base, policy: policy({ stagingPercentage: 0 }), targetVersion: '1.9.1' }),
).toMatchObject({ action: 'ignore', reason: 'rollout' })
})
it('강제 설치 하한 미만이면 대화상자 없이 다운로드한다', () => {
const offer = evaluateUpdateOffer({ ...base, policy: policy({ forceInstallBelow: '1.9.1' }), targetVersion: '2.0.0' })
expect(offer).toMatchObject({ action: 'force-download', isMajorUpgrade: true })
})
it('shouldPromptForUpdate: 기록 없음 / 새 버전 / 필수 격상 / 간격 경과', () => {
const last = { version: '1.9.1', mandatory: false, at: 0 }
expect(shouldPromptForUpdate(null, { version: '1.9.1', mandatory: false }, 0)).toBe(true)
expect(shouldPromptForUpdate(last, { version: '1.9.2', mandatory: false }, 1)).toBe(true)
expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: true }, 1)).toBe(true)
expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: false }, 1)).toBe(false)
expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: false }, UPDATE_REPROMPT_INTERVAL_MS)).toBe(true)
})
})