// tests/main/services/runtime-provisioner-redteam-r3-5.test.ts // 런타임 인덱스의 플랫폼 구분과 "feed 가 앱보다 늦은" 경우의 회귀 테스트. // // 회귀 대상: // 1) runtime.json 에 platform/arch 가 없어 macOS 앱이 Windows 엔진(sidecar.exe)을 받아 풀고 // 검증에서 떨어진 뒤, ensure 할 때마다 ~160MB 를 다시 받던 문제 // 2) feed 버전이 앱의 RUNTIME_MIN_VERSION 보다 낮으면 설치된 엔진까지 버리고 로컬 STT 가 // 완전히 멈추던 문제 import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { RuntimeIndexRejectedError, isRuntimeIndexRejected, parseRuntimeIndex, } from '../../../src/main/services/runtime/runtime-index' import type { RuntimeFetch, RuntimeFetchResponse } from '../../../src/main/services/runtime/download-part' import { RuntimeProvisioner } from '../../../src/main/services/RuntimeProvisioner' const FEED = 'https://feed.test/runtime-latest' const SHA = 'c'.repeat(64) const PART = 'd3ro-runtime-sidecar.tar.gz.001' function index(version: string, target?: { platform: string; arch: string }): Record { return { schemaVersion: 1, version, ...(target ?? {}), components: { sidecar: { archive: 'd3ro-runtime-sidecar.tar.gz', sha256: SHA, totalSize: 10, parts: [{ name: PART, size: 10, sha256: SHA, url: `${FEED}/${PART}` }], }, }, } } function feed(body: Record): { fetchImpl: RuntimeFetch; calls: string[] } { const calls: string[] = [] const fetchImpl: RuntimeFetch = async (url) => { calls.push(url) if (url === `${FEED}/runtime.json`) { return new Response(JSON.stringify(body), { status: 200 }) as RuntimeFetchResponse } return new Response(new Uint8Array(10), { status: 200 }) as RuntimeFetchResponse } return { fetchImpl, calls } } let workDir: string beforeEach(() => { workDir = mkdtempSync(join(tmpdir(), 'd3ro-runtime-r3-5-')) }) afterEach(() => { rmSync(workDir, { recursive: true, force: true }) }) function provisioner( fetchImpl: RuntimeFetch, platform: NodeJS.Platform = 'win32', arch = 'x64', ): RuntimeProvisioner { return new RuntimeProvisioner({ userDataDir: () => workDir, fetchImpl, feedUrl: FEED, platform, arch, stallTimeoutMs: 1000, partAttempts: 1, }) } /** 예전에 받아 둔 엔진이 설치돼 있는 상태 (마커 버전 지정) */ function installOldSidecar(p: RuntimeProvisioner, markerVersion: string): void { const dir = p.componentDir('sidecar') mkdirSync(join(dir, '_internal'), { recursive: true }) writeFileSync(p.binaryPath('sidecar'), 'old engine') writeFileSync(join(dir, '.runtime-version'), markerVersion) } describe('parseRuntimeIndex — 플랫폼 대상', () => { const WIN = { platform: 'win32', arch: 'x64' } it('인덱스가 밝힌 플랫폼과 앱 플랫폼이 다르면 platform-mismatch 로 거부한다', () => { let caught: unknown try { parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0', { platform: 'darwin', arch: 'arm64' }) } catch (err) { caught = err } expect(caught).toBeInstanceOf(RuntimeIndexRejectedError) expect(isRuntimeIndexRejected(caught, 'platform-mismatch')).toBe(true) expect((caught as Error).message).toMatch(/darwin-arm64/) }) it('아키텍처만 달라도 거부한다', () => { expect(() => parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0', { platform: 'win32', arch: 'arm64' }), ).toThrow(RuntimeIndexRejectedError) }) it('같은 플랫폼이면 통과하고, 대상을 넘기지 않으면 검사하지 않는다', () => { expect(parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0', WIN).version).toBe('1.9.0') expect(parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0').version).toBe('1.9.0') }) it('platform/arch 가 없는 예전 인덱스는 그대로 읽는다 (하위 호환)', () => { expect(parseRuntimeIndex(index('1.9.0'), 'sidecar', '1.7.0', { platform: 'darwin', arch: 'arm64' }).version).toBe( '1.9.0', ) }) it('platform/arch 형식이 깨졌으면 형식 오류로 거부한다', () => { const broken = { ...index('1.9.0'), platform: 'win32', arch: 7 } let caught: unknown try { parseRuntimeIndex(broken, 'sidecar', '1.7.0', WIN) } catch (err) { caught = err } expect((caught as Error).message).toMatch(/platform\/arch/) expect(isRuntimeIndexRejected(caught)).toBe(false) }) it('최소 버전 미만 feed 는 feed-outdated 로 구분된다', () => { let caught: unknown try { parseRuntimeIndex(index('1.6.0', WIN), 'sidecar', '1.7.0', WIN) } catch (err) { caught = err } expect(isRuntimeIndexRejected(caught, 'feed-outdated')).toBe(true) expect((caught as Error).message).toMatch(/최소 요구 버전/) }) }) describe('RuntimeProvisioner — 다른 플랫폼 feed', () => { it('macOS 앱은 Windows 런타임 인덱스를 보면 부품을 받기 전에 실패한다', async () => { const f = feed(index('1.9.0', { platform: 'win32', arch: 'x64' })) const p = provisioner(f.fetchImpl, 'darwin', 'arm64') await expect(p.ensure('sidecar')).rejects.toThrow(/이 플랫폼\(darwin-arm64\)용 런타임이 feed에 없습니다/) expect(f.calls).toEqual([`${FEED}/runtime.json`]) expect(existsSync(join(workDir, 'runtime', '.download-sidecar'))).toBe(false) // 다시 불러도 인덱스만 읽는다 — 대용량 재다운로드 루프가 없다 f.calls.length = 0 await expect(p.ensure('sidecar')).rejects.toThrow(RuntimeIndexRejectedError) expect(f.calls).toEqual([`${FEED}/runtime.json`]) }) }) describe('RuntimeProvisioner — feed 가 앱의 최소 버전보다 낮을 때', () => { it('설치된 (낡은) 엔진이 있으면 그 경로를 돌려주고 부품을 받지 않는다', async () => { const f = feed(index('1.6.0')) const p = provisioner(f.fetchImpl) installOldSidecar(p, '1.6.0') await expect(p.ensure('sidecar')).resolves.toBe(p.binaryPath('sidecar')) expect(f.calls).toEqual([`${FEED}/runtime.json`]) expect(p.isInstalled('sidecar')).toBe(true) }) it('"다시 설치"(force)는 조용히 넘어가지 않고 실패를 알린다', async () => { const f = feed(index('1.6.0')) const p = provisioner(f.fetchImpl) installOldSidecar(p, '1.6.0') await expect(p.ensure('sidecar', { force: true })).rejects.toThrow(/최소 요구 버전/) expect(p.isInstalled('sidecar')).toBe(true) }) it('설치된 엔진이 없으면 실패한다', async () => { const f = feed(index('1.6.0')) await expect(provisioner(f.fetchImpl).ensure('sidecar')).rejects.toThrow(/최소 요구 버전/) }) it('다른 이유의 실패(인덱스 404)는 설치된 엔진으로 덮지 않는다', async () => { const calls: string[] = [] const fetchImpl: RuntimeFetch = async (url) => { calls.push(url) return new Response(null, { status: 404 }) as RuntimeFetchResponse } const p = provisioner(fetchImpl) installOldSidecar(p, '1.6.0') await expect(p.ensure('sidecar')).rejects.toThrow(/HTTP 404/) }) })