d3ro-voice/apps/desktop/tests/main/services/local-llm-service.test.ts
Yun Chan 5c11ee2fde
Some checks failed
deploy-site / deploy (push) Failing after 33s
portable-unsigned / portable-windows (push) Failing after 4m7s
release / release-windows (push) Failing after 3m16s
release: ship v1.5.0 with on-device writing suggestions
Adds next-sentence suggestions while typing, weekly input insights and a
personal phrase memory to the desktop app, and fixes custom instructions so
they process the text instead of inserting the instruction's own wording.
Local model requests are now bounded and individually cancellable.

Bumps the product version to 1.5.0 (Android/iOS build 1050000), refreshes the
landing and web download links, and records the new INPUT feature rows and the
open verification gaps in the infrastructure map.
2026-09-23 16:04:27 +09:00

181 lines
7.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import {
getLocalLLMService,
resetLocalLLMServiceForTests,
} from '../../../src/main/services/LocalLLMService'
const encoder = new TextEncoder()
function completedStream(lines: string[]): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(lines.join('\n')))
controller.close()
},
})
}
function completedGenerateResponse(): Response {
return new Response(JSON.stringify({
model: 'gemma4:e4b',
response: 'ok',
done: true,
}), { status: 200 })
}
function markAvailable(): void {
const service = getLocalLLMService() as unknown as { _available: boolean }
service._available = true
}
describe('LocalLLMService request lifecycle', () => {
beforeEach(() => {
initInMemoryConfig()
resetLocalLLMServiceForTests()
markAvailable()
})
afterEach(() => {
resetLocalLLMServiceForTests()
resetInMemoryConfig()
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('sends the bounded default num_predict to chat requests', async () => {
const cancel = vi.fn()
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as { options: { num_predict: number } }
expect(body.options.num_predict).toBe(512)
return new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode([
JSON.stringify({ message: { content: 'hello' }, done: false }),
JSON.stringify({ message: { content: '' }, done: true }),
].join('\n') + '\n'))
},
cancel,
}), { status: 200 })
})
vi.stubGlobal('fetch', fetchMock)
const output: string[] = []
for await (const token of getLocalLLMService().chatStream([{ role: 'user', content: 'hello' }])) {
output.push(token)
}
expect(output).toEqual(['hello'])
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(cancel).toHaveBeenCalledTimes(1)
})
it('propagates external cancellation and request deadlines to fetch', async () => {
const signals: AbortSignal[] = []
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
const signal = init?.signal as AbortSignal
signals.push(signal)
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true })
}))
vi.stubGlobal('fetch', fetchMock)
const external = new AbortController()
const externalRequest = getLocalLLMService().generate('first', { signal: external.signal })
const externalExpectation = expect(externalRequest).rejects.toMatchObject({ code: ErrorCode.LLMProcessingCancelled })
external.abort()
await externalExpectation
expect(signals[0].aborted).toBe(true)
vi.useFakeTimers()
const timeoutRequest = getLocalLLMService().generate('second', { timeoutMs: 25 })
const timeoutExpectation = expect(timeoutRequest).rejects.toMatchObject({ code: ErrorCode.LLMProcessingTimeout })
await vi.advanceTimersByTimeAsync(25)
await timeoutExpectation
expect(signals[1].aborted).toBe(true)
})
it('keeps other active requests cancellable after one request completes', async () => {
const signals: AbortSignal[] = []
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
const signal = init?.signal as AbortSignal
signals.push(signal)
if (signals.length === 1) return Promise.resolve(completedGenerateResponse())
return new Promise<Response>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true })
})
})
vi.stubGlobal('fetch', fetchMock)
const first = getLocalLLMService().generate('first')
const second = getLocalLLMService().generate('second')
const third = getLocalLLMService().generate('third')
await expect(first).resolves.toMatchObject({ text: 'ok' })
const secondExpectation = expect(second).rejects.toMatchObject({ code: ErrorCode.LLMProcessingCancelled })
const thirdExpectation = expect(third).rejects.toMatchObject({ code: ErrorCode.LLMProcessingCancelled })
getLocalLLMService().cancelGeneration()
await secondExpectation
await thirdExpectation
expect(signals[1].aborted).toBe(true)
expect(signals[2].aborted).toBe(true)
})
it('rejects a stream that reaches EOF without a done frame', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(completedStream([
JSON.stringify({ model: 'gemma4:e4b', response: 'partial', done: false }),
]), { status: 200 })))
const stream = getLocalLLMService().streamGenerate('hello')
await expect(stream.next()).resolves.toMatchObject({ value: 'partial', done: false })
await expect(stream.next()).rejects.toMatchObject({ code: ErrorCode.LLMProcessingFailed })
})
it('finishes on a done frame without waiting for the server to close the stream', async () => {
const cancel = vi.fn()
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode([
JSON.stringify({ model: 'gemma4:e4b', response: 'partial', done: false }),
JSON.stringify({ model: 'gemma4:e4b', response: '', done: true }),
].join('\n') + '\n'))
},
cancel,
})
vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status: 200 })))
const stream = getLocalLLMService().streamGenerate('hello', { timeoutMs: 500 })
await expect(stream.next()).resolves.toMatchObject({ value: 'partial', done: false })
await expect(stream.next()).resolves.toMatchObject({ value: expect.objectContaining({ text: 'partial' }), done: true })
expect(cancel).toHaveBeenCalledTimes(1)
})
it('deduplicates concurrent ensureRunning calls and polling startup', async () => {
const service = getLocalLLMService()
const privateService = service as unknown as {
_ensureRunning: () => Promise<'running' | 'starting' | 'not-installed' | 'failed'>
_checkAvailability: () => Promise<void>
}
let release: ((value: 'running') => void) | undefined
const pending = new Promise<'running'>((resolve) => {
release = resolve
})
const ensure = vi.spyOn(privateService, '_ensureRunning').mockReturnValue(pending)
const first = service.ensureRunning()
const second = service.ensureRunning()
expect(ensure).toHaveBeenCalledTimes(1)
release?.('running')
await expect(Promise.all([first, second])).resolves.toEqual(['running', 'running'])
vi.useFakeTimers()
const availability = vi.spyOn(privateService, '_checkAvailability').mockResolvedValue()
service.startPolling()
service.startPolling()
expect(availability).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(5000)
expect(availability).toHaveBeenCalledTimes(2)
service.stopPolling()
})
})