diff --git a/packages/desktop/src/main/runtime-manager.ts b/packages/desktop/src/main/runtime-manager.ts index 04bab97a..2390e151 100644 --- a/packages/desktop/src/main/runtime-manager.ts +++ b/packages/desktop/src/main/runtime-manager.ts @@ -3,6 +3,7 @@ import { createReadStream, createWriteStream, existsSync, + mkdtempSync, mkdirSync, readFileSync, renameSync, @@ -12,6 +13,7 @@ import { } from 'node:fs' import { get as httpGet } from 'node:http' import { get as httpsGet } from 'node:https' +import { tmpdir } from 'node:os' import { basename, dirname, join, relative } from 'node:path' import { app } from 'electron' import { @@ -382,7 +384,8 @@ export async function ensureDesktopRuntime( if (cachedRuntimeMatches(runtimeRoot, descriptor) && !process.env.HERMES_DESKTOP_RUNTIME_FORCE_UPDATE) return - const archive = join(dirname(runtimeRoot), `${descriptor.name}.download`) + const downloadTempDir = mkdtempSync(join(tmpdir(), 'hermes-runtime-download-')) + const archive = join(downloadTempDir, `${descriptor.name}.download`) console.log(`[runtime] downloading Hermes runtime ${descriptor.name}`) onProgress?.({ stage: 'download', message: t('runtime.downloadingPackage', { name: descriptor.name }) }) let archiveSize = 0 @@ -401,6 +404,7 @@ export async function ensureDesktopRuntime( await extractRuntimeArchive(archive, runtimeRoot) } finally { rmSync(archive, { force: true }) + rmSync(downloadTempDir, { recursive: true, force: true }) } const manifestPath = join(runtimeRoot, RUNTIME_MANIFEST_NAME) diff --git a/tests/desktop/runtime-manager.test.ts b/tests/desktop/runtime-manager.test.ts new file mode 100644 index 00000000..33ddf518 --- /dev/null +++ b/tests/desktop/runtime-manager.test.ts @@ -0,0 +1,125 @@ +import { createReadStream, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, type Server } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as tar from 'tar' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mockElectronApp = vi.hoisted(() => ({ + isPackaged: false, + getAppPath: () => process.cwd(), + getVersion: () => '0.6.21', + getLocale: () => 'en', +})) + +vi.mock('electron', () => ({ + app: mockElectronApp, +})) + +const originalEnv = { ...process.env } +const tempDirs: string[] = [] +const servers: Server[] = [] + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +function createRuntimeFiles(root: string) { + if (process.platform === 'win32') { + mkdirSync(join(root, 'python', 'Scripts'), { recursive: true }) + mkdirSync(join(root, 'node'), { recursive: true }) + mkdirSync(join(root, 'git', 'cmd'), { recursive: true }) + writeFileSync(join(root, 'python', 'python.exe'), '') + writeFileSync(join(root, 'python', 'Scripts', 'hermes.exe'), '') + writeFileSync(join(root, 'node', 'node.exe'), '') + writeFileSync(join(root, 'git', 'cmd', 'git.exe'), '') + } else { + mkdirSync(join(root, 'python', 'bin'), { recursive: true }) + mkdirSync(join(root, 'node', 'bin'), { recursive: true }) + writeFileSync(join(root, 'python', 'bin', 'python3'), '') + writeFileSync(join(root, 'python', 'bin', 'hermes'), '') + writeFileSync(join(root, 'node', 'bin', 'node'), '') + } + writeFileSync(join(root, 'runtime-manifest.json'), JSON.stringify({ + schema: 1, + platform: process.platform, + hermesAgentVersion: '0.15.2', + asset: { name: 'hermes-runtime-test.tar.gz' }, + })) +} + +async function createRuntimeArchive(): Promise { + const source = tempDir('hermes-runtime-source-') + const archive = join(tempDir('hermes-runtime-archive-'), 'hermes-runtime-test.tar.gz') + createRuntimeFiles(source) + await tar.c({ gzip: true, cwd: source, file: archive }, ['.']) + return archive +} + +async function serveFile(file: string): Promise { + const server = createServer((request, response) => { + if (request.url !== '/hermes-runtime-test.tar.gz') { + response.writeHead(404) + response.end() + return + } + response.writeHead(200, { 'content-type': 'application/gzip' }) + createReadStream(file).pipe(response) + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('test server did not bind to a port') + return `http://127.0.0.1:${address.port}/hermes-runtime-test.tar.gz` +} + +describe('desktop runtime manager', () => { + beforeEach(() => { + vi.resetModules() + process.env = { ...originalEnv } + process.env.HERMES_WEB_UI_HOME = tempDir('hermes-runtime-home-') + process.env.HERMES_DESKTOP_RUNTIME_RELEASE_TAG = 'hermes-0.15.2-runtime' + mockElectronApp.isPackaged = false + mockElectronApp.getAppPath = () => process.cwd() + }) + + afterEach(async () => { + process.env = { ...originalEnv } + vi.resetModules() + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(() => resolve())))) + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('downloads through a unique temp file instead of reusing a stale runtime .download path', async () => { + const archive = await createRuntimeArchive() + process.env.HERMES_DESKTOP_RUNTIME_URL = await serveFile(archive) + + const { runtimePlatformKey } = await import('../../packages/desktop/src/main/runtime-paths') + const staleDownloadPath = join( + process.env.HERMES_WEB_UI_HOME!, + 'desktop-runtime', + 'hermes', + '0.15.2', + 'hermes-runtime-test.tar.gz.download', + ) + mkdirSync(staleDownloadPath, { recursive: true }) + + const { ensureDesktopRuntime } = await import('../../packages/desktop/src/main/runtime-manager') + await ensureDesktopRuntime() + + const runtimeRoot = join( + process.env.HERMES_WEB_UI_HOME!, + 'desktop-runtime', + 'hermes', + '0.15.2', + runtimePlatformKey(), + ) + expect(existsSync(staleDownloadPath)).toBe(true) + expect(existsSync(join(runtimeRoot, 'runtime-manifest.json'))).toBe(true) + expect(existsSync(join(process.env.HERMES_WEB_UI_HOME!, 'desktop-runtime', 'active-version.json'))).toBe(true) + }) +}) diff --git a/tests/mocks/electron.ts b/tests/mocks/electron.ts new file mode 100644 index 00000000..a619a9b2 --- /dev/null +++ b/tests/mocks/electron.ts @@ -0,0 +1,6 @@ +export const app = { + isPackaged: false, + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0', + getLocale: () => 'en', +} diff --git a/vitest.config.ts b/vitest.config.ts index 0868870e..b7ad4741 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ resolve: { alias: { '@': resolve(__dirname, 'packages/client/src'), + electron: resolve(__dirname, 'tests/mocks/electron.ts'), '/logo.png': resolve(__dirname, 'packages/client/public/logo.png'), }, },