From 0acf2f1526e04aac740c9c8c0e59f5eccf36b070 Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Sun, 10 May 2026 16:16:50 -0700 Subject: [PATCH 1/3] feat(cli): cache resolved editor path; suppress Unity Hub stderr noise Two CLI quality-of-life improvements for `unity-mcp-cli open`: 1. Editor-path cache - New `cli/src/utils/editor-cache.ts` persists the resolved Unity Editor binary path to `~/.unity-mcp-cli-editor-cache.json`, keyed by Unity version (with an `__auto__` slot for the no-version case). Same convention as the existing `~/.unity-mcp-cli-update.json`. - `findEditorPath` (`utils/unity-editor.ts`) checks the cache first and returns immediately on hit, skipping the Unity Hub Electron probe entirely. Every successful resolution writes back to the cache (fast path, hub-resolved, and the highest- installed fallback). - Stale entries auto-evict on read when the cached path no longer exists on disk; corrupted-but-existing binaries are cleared by an `onError` hook in `openProject` (`lib/open.ts`) so the next invocation re-resolves from scratch. - Measured impact on Windows with the editor installed at a non-default location (`C:\UnityEditor\\Editor\Unity.exe`): cold run ~13.0s -> warm runs ~1.2s (~10x speedup; Unity Hub CLI invocation eliminated on the warm path). 2. Quiet Unity Hub stderr - `listInstalledEditors` and `listAvailableReleases` (`utils/unity-hub.ts`) now pipe stderr via `stdio: ['ignore', 'pipe', 'pipe']` instead of inheriting it. Unity Hub is an Electron app and its embedded Chromium routinely logs benign `quota_database.cc` errors to stderr which previously leaked into the terminal during a successful editor probe. Captured stderr is appended to the surfaced error message in the catch path, so real Hub failures stay diagnosable. Full CLI test suite (`npm test` under `cli/`) passes: 370 passed, 1 skipped (pre-existing). --- cli/src/lib/open.ts | 6 +++ cli/src/utils/editor-cache.ts | 90 +++++++++++++++++++++++++++++++++++ cli/src/utils/unity-editor.ts | 17 +++++++ cli/src/utils/unity-hub.ts | 17 ++++++- 4 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 cli/src/utils/editor-cache.ts diff --git a/cli/src/lib/open.ts b/cli/src/lib/open.ts index 484448be8..4dc1462ce 100644 --- a/cli/src/lib/open.ts +++ b/cli/src/lib/open.ts @@ -6,6 +6,7 @@ import { getProjectEditorVersion, launchEditor, } from '../utils/unity-editor.js'; +import { clearCachedEditorPath } from '../utils/editor-cache.js'; import { findUnityProcess } from '../utils/unity-process.js'; import { readConfig, isCloudMode, writeConfig } from '../utils/config.js'; import { @@ -293,6 +294,11 @@ export async function openProject( // Don't throw out of the event listener — the caller can't // catch it. Surface the warning for diagnostics. warnings.push(`Editor spawn reported error: ${err.message}`); + // The resolved path failed to spawn (binary corrupted, + // wrong arch, perms revoked, …). Drop the cache so the + // next invocation re-resolves from Unity Hub instead of + // handing back the same broken path. + clearCachedEditorPath(version); }, }); diff --git a/cli/src/utils/editor-cache.ts b/cli/src/utils/editor-cache.ts new file mode 100644 index 000000000..740ce4210 --- /dev/null +++ b/cli/src/utils/editor-cache.ts @@ -0,0 +1,90 @@ +import { homedir } from 'os'; +import { join } from 'path'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { verbose } from './ui.js'; + +const CACHE_FILE = join(homedir(), '.unity-mcp-cli-editor-cache.json'); + +// Used as the storage key when the caller didn't supply a version +// (i.e. "open whatever editor — pick the highest"). Keeps a single +// well-known key out of the legal Unity-version namespace. +const AUTO_KEY = '__auto__'; + +interface CacheEntry { + path: string; + savedAt: number; +} + +interface EditorCache { + [versionKey: string]: CacheEntry; +} + +function keyFor(version: string | undefined): string { + return version ?? AUTO_KEY; +} + +function readAll(): EditorCache { + try { + const raw = readFileSync(CACHE_FILE, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as EditorCache; + } + } catch { + // Missing or corrupt — treat as empty cache. + } + return {}; +} + +function writeAll(cache: EditorCache): void { + try { + writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2), 'utf-8'); + } catch { + // Best-effort: a read-only home dir or full disk should never break `open`. + } +} + +/** + * Look up a previously-resolved editor binary path for the given + * Unity version (or `undefined` to look up the "auto / highest" slot). + * Returns null when no entry exists OR when the cached path no longer + * exists on disk — in the latter case the stale entry is dropped + * automatically so subsequent reads don't waste a stat call. + */ +export function readCachedEditorPath(version: string | undefined): string | null { + const cache = readAll(); + const k = keyFor(version); + const entry = cache[k]; + if (!entry || typeof entry.path !== 'string') return null; + if (!existsSync(entry.path)) { + verbose(`editor-cache: stale entry for ${k} -> ${entry.path} (file missing), evicting`); + delete cache[k]; + writeAll(cache); + return null; + } + verbose(`editor-cache: hit ${k} -> ${entry.path}`); + return entry.path; +} + +/** Save the resolved editor path under the given version key. */ +export function writeCachedEditorPath(version: string | undefined, editorPath: string): void { + const cache = readAll(); + const k = keyFor(version); + cache[k] = { path: editorPath, savedAt: Date.now() }; + writeAll(cache); + verbose(`editor-cache: stored ${k} -> ${editorPath}`); +} + +/** + * Drop the cache entry for the given version. Called when a cached + * path turned out to be unusable (e.g. spawn failed) so the next + * invocation re-runs the full resolution. + */ +export function clearCachedEditorPath(version: string | undefined): void { + const cache = readAll(); + const k = keyFor(version); + if (cache[k] === undefined) return; + delete cache[k]; + writeAll(cache); + verbose(`editor-cache: cleared ${k}`); +} diff --git a/cli/src/utils/unity-editor.ts b/cli/src/utils/unity-editor.ts index 35e7ea5af..2552426a6 100644 --- a/cli/src/utils/unity-editor.ts +++ b/cli/src/utils/unity-editor.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { spawn } from 'child_process'; import { platform } from 'os'; import { findUnityHub, ensureUnityHub, listInstalledEditors } from './unity-hub.js'; +import { readCachedEditorPath, writeCachedEditorPath } from './editor-cache.js'; import * as ui from './ui.js'; import { verbose } from './ui.js'; @@ -70,12 +71,24 @@ export async function findEditorPath(version?: string): Promise { const startedAt = Date.now(); verbose(`findEditorPath start (version=${version ?? 'auto'})`); + // Cache lookup: if we previously resolved this version and the + // binary still exists, skip every probe below. Stale entries + // (file deleted, editor uninstalled) are evicted inside + // readCachedEditorPath so we always proceed with a fresh resolve + // in that case. + const cached = readCachedEditorPath(version); + if (cached) { + verbose(`findEditorPath cache hit in ${Date.now() - startedAt}ms`); + return cached; + } + // Fast path: if we know the version, check common install locations first (instant filesystem check) if (version) { const fastStartedAt = Date.now(); const fastResult = findEditorPathByCommonLocations(version); verbose(`findEditorPath fast path completed in ${Date.now() - fastStartedAt}ms (${fastResult ? 'hit' : 'miss'})`); if (fastResult) { + writeCachedEditorPath(version, fastResult); verbose(`findEditorPath resolved via common locations in ${Date.now() - startedAt}ms`); return fastResult; } @@ -90,6 +103,7 @@ export async function findEditorPath(version?: string): Promise { const fallback = findEditorPathByCommonLocations(version); verbose(`findEditorPath fallback common-location scan completed in ${Date.now() - fallbackStartedAt}ms (${fallback ? 'hit' : 'miss'})`); verbose(`findEditorPath completed in ${Date.now() - startedAt}ms`); + if (fallback) writeCachedEditorPath(version, fallback); return fallback; } @@ -101,6 +115,7 @@ export async function findEditorPath(version?: string): Promise { const fallback = findEditorPathByCommonLocations(version); verbose(`findEditorPath empty-editor fallback completed in ${Date.now() - fallbackStartedAt}ms (${fallback ? 'hit' : 'miss'})`); verbose(`findEditorPath completed in ${Date.now() - startedAt}ms`); + if (fallback) writeCachedEditorPath(version, fallback); return fallback; } @@ -108,6 +123,7 @@ export async function findEditorPath(version?: string): Promise { const match = editors.find((e) => e.version === version); if (match) { const resolved = getEditorBinary(match.path); + writeCachedEditorPath(version, resolved); verbose(`findEditorPath matched requested version ${version} in ${Date.now() - startedAt}ms`); return resolved; } @@ -116,6 +132,7 @@ export async function findEditorPath(version?: string): Promise { // Return the highest installed editor by version-aware sorting const sorted = [...editors].sort((a, b) => compareUnityVersions(b.version, a.version)); const resolved = getEditorBinary(sorted[0].path); + writeCachedEditorPath(version, resolved); verbose(`findEditorPath selected highest installed version ${sorted[0].version} in ${Date.now() - startedAt}ms`); return resolved; } diff --git a/cli/src/utils/unity-hub.ts b/cli/src/utils/unity-hub.ts index 7c4fbebd0..61ec9ab14 100644 --- a/cli/src/utils/unity-hub.ts +++ b/cli/src/utils/unity-hub.ts @@ -226,9 +226,14 @@ export function listInstalledEditors(hubPath: string): InstalledEditor[] { try { verbose(`listInstalledEditors invoking Unity Hub CLI: ${hubPath} -- --headless editors --installed`); const execStartedAt = Date.now(); + // stdio: pipe stderr instead of inheriting it — Unity Hub is an + // Electron app and Chromium routinely logs benign quota_database + // errors to stderr that would otherwise pollute the CLI output. + // The catch block re-surfaces captured stderr on real failures. const output = execFileSync(hubPath, ['--', '--headless', 'editors', '--installed'], { encoding: 'utf-8', timeout: 120000, + stdio: ['ignore', 'pipe', 'pipe'], }); verbose(`listInstalledEditors Unity Hub CLI returned in ${Date.now() - execStartedAt}ms`); @@ -246,7 +251,10 @@ export function listInstalledEditors(hubPath: string): InstalledEditor[] { verbose(`listInstalledEditors completed in ${Date.now() - startedAt}ms`); return editors; } catch (err) { - spinner.error(`Failed to list installed editors: ${(err as Error).message}`); + const stderr = (err as { stderr?: Buffer | string }).stderr; + const stderrText = stderr ? (Buffer.isBuffer(stderr) ? stderr.toString('utf-8') : stderr).trim() : ''; + const detail = stderrText ? `${(err as Error).message}\n${stderrText}` : (err as Error).message; + spinner.error(`Failed to list installed editors: ${detail}`); verbose(`listInstalledEditors failed after ${Date.now() - startedAt}ms`); return []; } @@ -295,9 +303,11 @@ export function findHighestEditor(editors: InstalledEditor[]): InstalledEditor { export function listAvailableReleases(hubPath: string): AvailableRelease[] { const spinner = ui.startSpinner('Fetching available releases...'); try { + // Same stderr suppression rationale as listInstalledEditors above. const output = execFileSync(hubPath, ['--', '--headless', 'editors', '--releases'], { encoding: 'utf-8', timeout: 120000, + stdio: ['ignore', 'pipe', 'pipe'], }); const releases: AvailableRelease[] = []; @@ -317,7 +327,10 @@ export function listAvailableReleases(hubPath: string): AvailableRelease[] { spinner.success(`Found ${releases.length} available release${releases.length !== 1 ? 's' : ''}`); return releases; } catch (err) { - spinner.error(`Failed to fetch available releases: ${(err as Error).message}`); + const stderr = (err as { stderr?: Buffer | string }).stderr; + const stderrText = stderr ? (Buffer.isBuffer(stderr) ? stderr.toString('utf-8') : stderr).trim() : ''; + const detail = stderrText ? `${(err as Error).message}\n${stderrText}` : (err as Error).message; + spinner.error(`Failed to fetch available releases: ${detail}`); return []; } } From fc8c1540aa8df531d0bd843d67692243251a3625 Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Sun, 10 May 2026 16:19:47 -0700 Subject: [PATCH 2/3] refactor(cli): extract formatExecError helper, tighten comments Review cleanup on top of the editor-cache + Hub-stderr commit: - `utils/unity-hub.ts`: hoist the duplicated stderr-extraction block from `listInstalledEditors` and `listAvailableReleases` catch handlers into a single `formatExecError(err)` helper. Both catch blocks now call it; behaviour unchanged. - `utils/editor-cache.ts`: trim the AUTO_KEY comment to just the why (collision-avoidance with the Unity-version namespace). - `utils/unity-editor.ts`: tighten the cache-lookup preamble in `findEditorPath` from 5 lines to 2. `npm run build` + `npm test` under `cli/`: 370 passed, 1 skipped. --- cli/src/utils/editor-cache.ts | 5 ++--- cli/src/utils/unity-editor.ts | 7 ++----- cli/src/utils/unity-hub.ts | 23 +++++++++++++++-------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/cli/src/utils/editor-cache.ts b/cli/src/utils/editor-cache.ts index 740ce4210..6e594c990 100644 --- a/cli/src/utils/editor-cache.ts +++ b/cli/src/utils/editor-cache.ts @@ -5,9 +5,8 @@ import { verbose } from './ui.js'; const CACHE_FILE = join(homedir(), '.unity-mcp-cli-editor-cache.json'); -// Used as the storage key when the caller didn't supply a version -// (i.e. "open whatever editor — pick the highest"). Keeps a single -// well-known key out of the legal Unity-version namespace. +// Storage key for the version-less "highest installed" lookup. +// Underscored to stay out of the Unity-version namespace (e.g. `6000.3.1f1`). const AUTO_KEY = '__auto__'; interface CacheEntry { diff --git a/cli/src/utils/unity-editor.ts b/cli/src/utils/unity-editor.ts index 2552426a6..59ef34889 100644 --- a/cli/src/utils/unity-editor.ts +++ b/cli/src/utils/unity-editor.ts @@ -71,11 +71,8 @@ export async function findEditorPath(version?: string): Promise { const startedAt = Date.now(); verbose(`findEditorPath start (version=${version ?? 'auto'})`); - // Cache lookup: if we previously resolved this version and the - // binary still exists, skip every probe below. Stale entries - // (file deleted, editor uninstalled) are evicted inside - // readCachedEditorPath so we always proceed with a fresh resolve - // in that case. + // Cache hit short-circuits the Unity Hub Electron probe below + // (~13s cold -> ~instant warm). Stale entries self-evict. const cached = readCachedEditorPath(version); if (cached) { verbose(`findEditorPath cache hit in ${Date.now() - startedAt}ms`); diff --git a/cli/src/utils/unity-hub.ts b/cli/src/utils/unity-hub.ts index 61ec9ab14..4de5f65ac 100644 --- a/cli/src/utils/unity-hub.ts +++ b/cli/src/utils/unity-hub.ts @@ -198,6 +198,19 @@ export interface InstalledEditor { path: string; } +/** + * Pull the `stderr` capture off an `execFileSync` error and merge it + * into the error message. Returns the bare `err.message` when no + * stderr was captured (e.g. spawn failures before the child ran). + */ +function formatExecError(err: unknown): string { + const message = (err as Error).message; + const stderr = (err as { stderr?: Buffer | string }).stderr; + if (!stderr) return message; + const text = (Buffer.isBuffer(stderr) ? stderr.toString('utf-8') : stderr).trim(); + return text ? `${message}\n${text}` : message; +} + /** * List installed Unity editors via Unity Hub CLI. */ @@ -251,10 +264,7 @@ export function listInstalledEditors(hubPath: string): InstalledEditor[] { verbose(`listInstalledEditors completed in ${Date.now() - startedAt}ms`); return editors; } catch (err) { - const stderr = (err as { stderr?: Buffer | string }).stderr; - const stderrText = stderr ? (Buffer.isBuffer(stderr) ? stderr.toString('utf-8') : stderr).trim() : ''; - const detail = stderrText ? `${(err as Error).message}\n${stderrText}` : (err as Error).message; - spinner.error(`Failed to list installed editors: ${detail}`); + spinner.error(`Failed to list installed editors: ${formatExecError(err)}`); verbose(`listInstalledEditors failed after ${Date.now() - startedAt}ms`); return []; } @@ -327,10 +337,7 @@ export function listAvailableReleases(hubPath: string): AvailableRelease[] { spinner.success(`Found ${releases.length} available release${releases.length !== 1 ? 's' : ''}`); return releases; } catch (err) { - const stderr = (err as { stderr?: Buffer | string }).stderr; - const stderrText = stderr ? (Buffer.isBuffer(stderr) ? stderr.toString('utf-8') : stderr).trim() : ''; - const detail = stderrText ? `${(err as Error).message}\n${stderrText}` : (err as Error).message; - spinner.error(`Failed to fetch available releases: ${detail}`); + spinner.error(`Failed to fetch available releases: ${formatExecError(err)}`); return []; } } From f3359e66b14f26bcb9dbf307aa2597f08852e370 Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Sun, 10 May 2026 17:08:27 -0700 Subject: [PATCH 3/3] fix(cli): harden editor-cache against proto pollution; add cache tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #748 review comments: - `utils/editor-cache.ts`: switch the in-memory cache dict to a null-prototype object via `Object.create(null)`, and copy parsed JSON entries through that null-prototype shell. Defends against a `m_EditorVersion: __proto__` payload (or `--unity __proto__`) polluting the cache object's prototype chain. Public API unchanged. - `tests/editor-cache.test.ts` (NEW, 9 tests): cover read-missing → null, write/read round-trip, stale-entry auto-eviction, versioned-vs-`__auto__` key isolation, and `clearCachedEditorPath` precision. - `tests/unity-editor.test.ts` (+1 test): assert `findEditorPath` short-circuits via the cache without calling `ensureUnityHub` or `listInstalledEditors`. `npm test` under `cli/`: 380 passed, 1 skipped. --- cli/src/utils/editor-cache.ts | 8 +- cli/tests/editor-cache.test.ts | 172 +++++++++++++++++++++++++++++++++ cli/tests/unity-editor.test.ts | 80 ++++++++++++++- 3 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 cli/tests/editor-cache.test.ts diff --git a/cli/src/utils/editor-cache.ts b/cli/src/utils/editor-cache.ts index 6e594c990..7e220efea 100644 --- a/cli/src/utils/editor-cache.ts +++ b/cli/src/utils/editor-cache.ts @@ -27,12 +27,16 @@ function readAll(): EditorCache { const raw = readFileSync(CACHE_FILE, 'utf-8'); const parsed = JSON.parse(raw) as unknown; if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as EditorCache; + const safe: EditorCache = Object.create(null) as EditorCache; + for (const k of Object.keys(parsed as object)) { + safe[k] = (parsed as EditorCache)[k]; + } + return safe; } } catch { // Missing or corrupt — treat as empty cache. } - return {}; + return Object.create(null) as EditorCache; } function writeAll(cache: EditorCache): void { diff --git a/cli/tests/editor-cache.test.ts b/cli/tests/editor-cache.test.ts new file mode 100644 index 000000000..bc9a2def8 --- /dev/null +++ b/cli/tests/editor-cache.test.ts @@ -0,0 +1,172 @@ +/** + * Unit tests for editor-cache.ts + * + * The CACHE_FILE path is captured at module-load time via os.homedir(), so + * each test suite re-loads the module after redirecting HOME / USERPROFILE to + * a temporary directory. vi.resetModules() + a dynamic import inside + * beforeEach achieves this cleanly without touching production code. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type EditorCacheModule = typeof import('../src/utils/editor-cache.js'); + +/** Create a fresh temp dir, point HOME/USERPROFILE at it, reload the module. */ +async function loadCacheModuleInTempDir(): Promise<{ + tmpDir: string; + mod: EditorCacheModule; +}> { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'editor-cache-test-')); + process.env['HOME'] = tmpDir; + process.env['USERPROFILE'] = tmpDir; + vi.resetModules(); + const mod = (await import('../src/utils/editor-cache.js')) as EditorCacheModule; + return { tmpDir, mod }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('editor-cache', () => { + let tmpDir: string; + let mod: EditorCacheModule; + + /** Original env values so we can restore them. */ + let origHome: string | undefined; + let origUserProfile: string | undefined; + + beforeEach(async () => { + origHome = process.env['HOME']; + origUserProfile = process.env['USERPROFILE']; + ({ tmpDir, mod } = await loadCacheModuleInTempDir()); + }); + + afterEach(() => { + // Restore env + if (origHome === undefined) { + delete process.env['HOME']; + } else { + process.env['HOME'] = origHome; + } + if (origUserProfile === undefined) { + delete process.env['USERPROFILE']; + } else { + process.env['USERPROFILE'] = origUserProfile; + } + + // Clean up temp dir + fs.rmSync(tmpDir, { recursive: true, force: true }); + + // Reset module registry so next test gets a clean slate + vi.resetModules(); + }); + + // ------------------------------------------------------------------------- + it('readCachedEditorPath returns null when no cache file exists', () => { + const result = mod.readCachedEditorPath('6000.3.1f1'); + expect(result).toBeNull(); + }); + + it('readCachedEditorPath returns null for undefined version when no cache file exists', () => { + const result = mod.readCachedEditorPath(undefined); + expect(result).toBeNull(); + }); + + // ------------------------------------------------------------------------- + it('write + read round-trip preserves the path for a versioned key', () => { + // Create a real file so existsSync passes + const fakeBinary = path.join(tmpDir, 'Unity.exe'); + fs.writeFileSync(fakeBinary, ''); + + mod.writeCachedEditorPath('6000.3.1f1', fakeBinary); + const result = mod.readCachedEditorPath('6000.3.1f1'); + expect(result).toBe(fakeBinary); + }); + + it('write + read round-trip preserves the path for the auto (undefined) key', () => { + const fakeBinary = path.join(tmpDir, 'Unity'); + fs.writeFileSync(fakeBinary, ''); + + mod.writeCachedEditorPath(undefined, fakeBinary); + const result = mod.readCachedEditorPath(undefined); + expect(result).toBe(fakeBinary); + }); + + // ------------------------------------------------------------------------- + it('readCachedEditorPath evicts a stale entry and returns null', () => { + const missingPath = path.join(tmpDir, 'does-not-exist', 'Unity.exe'); + + // Write directly to the cache file — bypass writeCachedEditorPath so we + // can store a path that doesn't exist on disk. + const cacheFile = path.join(tmpDir, '.unity-mcp-cli-editor-cache.json'); + fs.writeFileSync( + cacheFile, + JSON.stringify({ '6000.3.1f1': { path: missingPath, savedAt: Date.now() } }), + 'utf-8', + ); + + const result = mod.readCachedEditorPath('6000.3.1f1'); + expect(result).toBeNull(); + + // Verify the stale key was removed from the cache file + const remaining = JSON.parse(fs.readFileSync(cacheFile, 'utf-8')) as Record; + expect(Object.keys(remaining)).not.toContain('6000.3.1f1'); + }); + + // ------------------------------------------------------------------------- + it('versioned key and auto key are stored in separate slots', () => { + const binaryV = path.join(tmpDir, 'Unity-versioned.exe'); + const binaryAuto = path.join(tmpDir, 'Unity-auto.exe'); + fs.writeFileSync(binaryV, ''); + fs.writeFileSync(binaryAuto, ''); + + mod.writeCachedEditorPath('6000.3.1f1', binaryV); + mod.writeCachedEditorPath(undefined, binaryAuto); + + // Read each slot independently — neither should clobber the other + expect(mod.readCachedEditorPath('6000.3.1f1')).toBe(binaryV); + expect(mod.readCachedEditorPath(undefined)).toBe(binaryAuto); + }); + + it('writing undefined key does not overwrite a versioned key', () => { + const binaryV = path.join(tmpDir, 'Unity-versioned.exe'); + const binaryAuto = path.join(tmpDir, 'Unity-auto.exe'); + fs.writeFileSync(binaryV, ''); + fs.writeFileSync(binaryAuto, ''); + + mod.writeCachedEditorPath('6000.3.1f1', binaryV); + mod.writeCachedEditorPath(undefined, binaryAuto); // should not touch '6000.3.1f1' + + expect(mod.readCachedEditorPath('6000.3.1f1')).toBe(binaryV); + }); + + // ------------------------------------------------------------------------- + it('clearCachedEditorPath removes a specific version entry without touching others', () => { + const binaryA = path.join(tmpDir, 'Unity-A.exe'); + const binaryB = path.join(tmpDir, 'Unity-B.exe'); + fs.writeFileSync(binaryA, ''); + fs.writeFileSync(binaryB, ''); + + mod.writeCachedEditorPath('6000.3.1f1', binaryA); + mod.writeCachedEditorPath('2022.3.62f3', binaryB); + + mod.clearCachedEditorPath('6000.3.1f1'); + + // Cleared entry is gone + expect(mod.readCachedEditorPath('6000.3.1f1')).toBeNull(); + // Other entry is untouched + expect(mod.readCachedEditorPath('2022.3.62f3')).toBe(binaryB); + }); + + it('clearCachedEditorPath is a no-op when the key does not exist', () => { + // Should not throw + expect(() => mod.clearCachedEditorPath('nonexistent.version')).not.toThrow(); + }); +}); diff --git a/cli/tests/unity-editor.test.ts b/cli/tests/unity-editor.test.ts index d64737f1a..6ce614aa1 100644 --- a/cli/tests/unity-editor.test.ts +++ b/cli/tests/unity-editor.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -115,6 +115,84 @@ describe('getProjectEditorVersion', () => { }); }); +// --------------------------------------------------------------------------- +// findEditorPath — cache-hit integration +// --------------------------------------------------------------------------- +// The CACHE_FILE path in editor-cache.ts is captured at module load time, so +// we must redirect HOME/USERPROFILE and then reload the whole module graph +// (editor-cache + unity-editor) via vi.resetModules() + dynamic import. +// Unity Hub helpers are mocked so we can assert they are never invoked on a +// cache hit. +// --------------------------------------------------------------------------- +describe('findEditorPath (cache-hit integration)', () => { + let tmpDir: string; + let origHome: string | undefined; + let origUserProfile: string | undefined; + + beforeEach(() => { + origHome = process.env['HOME']; + origUserProfile = process.env['USERPROFILE']; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'unity-editor-cache-hit-')); + process.env['HOME'] = tmpDir; + process.env['USERPROFILE'] = tmpDir; + }); + + afterEach(() => { + if (origHome === undefined) { + delete process.env['HOME']; + } else { + process.env['HOME'] = origHome; + } + if (origUserProfile === undefined) { + delete process.env['USERPROFILE']; + } else { + process.env['USERPROFILE'] = origUserProfile; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it('returns the cached path without invoking Unity Hub helpers when the cache is warm', async () => { + // Create a fake binary that exists on disk so existsSync passes in the cache + const fakeBinary = path.join(tmpDir, 'Unity.exe'); + fs.writeFileSync(fakeBinary, ''); + + // Pre-populate the cache file directly so readCachedEditorPath finds it + // after the module is reloaded with HOME=tmpDir. + const cacheFile = path.join(tmpDir, '.unity-mcp-cli-editor-cache.json'); + fs.writeFileSync( + cacheFile, + JSON.stringify({ '6000.3.1f1': { path: fakeBinary, savedAt: Date.now() } }), + 'utf-8', + ); + + // Reload module graph so CACHE_FILE is re-computed against our tmpDir. + vi.resetModules(); + + // Mock unity-hub BEFORE importing unity-editor so the mock is in place + // when unity-editor.js loads its static imports. + const ensureUnityHubMock = vi.fn().mockResolvedValue('/fake/hub'); + const listInstalledEditorsMock = vi.fn().mockReturnValue([]); + vi.doMock('../src/utils/unity-hub.js', () => ({ + findUnityHub: vi.fn().mockReturnValue(null), + ensureUnityHub: ensureUnityHubMock, + listInstalledEditors: listInstalledEditorsMock, + })); + + const { findEditorPath } = (await import( + '../src/utils/unity-editor.js' + )) as typeof import('../src/utils/unity-editor.js'); + + const result = await findEditorPath('6000.3.1f1'); + + expect(result).toBe(fakeBinary); + expect(ensureUnityHubMock).not.toHaveBeenCalled(); + expect(listInstalledEditorsMock).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- // Test against the actual test project files in the repo describe('getProjectEditorVersion (real projects)', () => { const __dirname = path.dirname(fileURLToPath(import.meta.url));