agent-ecosystem/test/main/features/runtime-provider-management/openCodeWindowsNodeModulesJunction.test.ts
ComradeSwarog 597c690dbc fix(opencode): add Windows junction fallback for node_modules EPERM symlink error (#187)
On Windows 10 without Developer Mode, the OpenCode runtime fails to create
a symlink from shared-cache/config-node_modules to the profile's
node_modules directory. The EPERM error blocks the entire OpenCode provider
catalog, leaving it unavailable.

Changes:
- New openCodeWindowsNodeModulesJunction module that pre-creates a Windows
  directory junction (no Developer Mode required) before the runtime call
  when an EPERM symlink error is detected
- On Windows, loadView and loadProviderDirectory now detect EPERM symlink
  errors, extract the profile ID, create the junction, and retry the
  runtime command once before falling back to the error response
- Updated diagnostic hints to accurately reflect that the runtime does not
  yet include junction fallback, and that the next runtime update will
  include it
- Added unit tests for the junction module and retry behavior
2026-05-28 13:08:54 +03:00

212 lines
No EOL
8.3 KiB
TypeScript

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
ensureOpenCodeProfileNodeModulesJunction,
extractProfileIdFromSymlinkError,
getProfileNodeModulesPath,
getSharedCacheNodeModulesPath,
isOpenCodeNodeModulesSymlinkError,
} from '../../../../src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction';
describe('openCodeWindowsNodeModulesJunction', () => {
describe('isOpenCodeNodeModulesSymlinkError', () => {
it('matches EPERM symlink errors containing opencode and node_modules', () => {
const message = [
'Runtime provider management command failed unexpectedly:',
"EPERM: operation not permitted, symlink 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules'",
"-> 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\abc123\\config\\opencode\\node_modules'",
].join(' ');
expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(true);
});
it('matches EACCES symlink errors containing opencode and node_modules', () => {
const message =
"EACCES: access denied, symlink 'opencode' -> 'node_modules'";
expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(true);
});
it('is case-insensitive', () => {
const message =
"eperm: operation not permitted, SYMLINK 'OpenCode' -> 'NODE_MODULES'";
expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(true);
});
it('does not match errors missing symlink keyword', () => {
const message =
"EPERM: operation not permitted, open 'opencode' -> 'node_modules'";
expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(false);
});
it('does not match errors missing opencode keyword', () => {
const message =
"EPERM: operation not permitted, symlink '/some/path' -> 'node_modules'";
expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(false);
});
it('does not match errors missing node_modules keyword', () => {
const message =
"EPERM: operation not permitted, symlink 'opencode' -> '/some/path'";
expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(false);
});
});
describe('extractProfileIdFromSymlinkError', () => {
it('extracts the profile hash from a Windows path', () => {
const message = [
"EPERM: operation not permitted, symlink 'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules'",
"-> 'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\e8e2eadb00beea6c\\config\\opencode\\node_modules'",
].join(' ');
expect(extractProfileIdFromSymlinkError(message)).toBe('e8e2eadb00beea6c');
});
it('extracts the profile hash from a Unix-style path', () => {
const message =
"EPERM: symlink '/home/user/.cache/opencode/shared-cache/config-node_modules' -> '/home/user/.data/opencode/profiles/abc123def456/config/opencode/node_modules'";
expect(extractProfileIdFromSymlinkError(message)).toBe('abc123def456');
});
it('returns null when no profile path pattern is found', () => {
const message = 'EPERM: some other error without a profile path';
expect(extractProfileIdFromSymlinkError(message)).toBeNull();
});
});
describe('getSharedCacheNodeModulesPath', () => {
it('uses LOCALAPPDATA environment variable when set', () => {
const originalEnv = process.env.LOCALAPPDATA;
process.env.LOCALAPPDATA = 'X:\\custom\\local';
try {
const result = getSharedCacheNodeModulesPath();
expect(result).toBe(
path.join('X:\\custom\\local', 'claude-multimodel-nodejs', 'Cache', 'opencode', 'shared-cache', 'config-node_modules')
);
} finally {
process.env.LOCALAPPDATA = originalEnv;
}
});
it('falls back to homedir AppData Local when LOCALAPPDATA is unset', () => {
const originalEnv = process.env.LOCALAPPDATA;
delete process.env.LOCALAPPDATA;
try {
const result = getSharedCacheNodeModulesPath();
expect(result).toContain('AppData');
expect(result).toContain('Local');
expect(result).toContain('claude-multimodel-nodejs');
} finally {
process.env.LOCALAPPDATA = originalEnv;
}
});
});
describe('getProfileNodeModulesPath', () => {
it('constructs the correct profile node_modules path', () => {
const originalEnv = process.env.LOCALAPPDATA;
process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local';
try {
const result = getProfileNodeModulesPath('abc123');
expect(result).toBe(
path.join(
'C:\\Users\\test\\AppData\\Local',
'claude-multimodel-nodejs',
'Data',
'opencode',
'profiles',
'abc123',
'config',
'opencode',
'node_modules'
)
);
} finally {
process.env.LOCALAPPDATA = originalEnv;
}
});
});
describe('ensureOpenCodeProfileNodeModulesJunction', () => {
const originalPlatform = process.platform;
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('returns false on non-Windows platforms', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
const result = ensureOpenCodeProfileNodeModulesJunction('abc123');
expect(result).toBe(false);
});
it('returns false on Windows when shared cache does not exist', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation(() => {
throw new Error('ENOENT');
});
const result = ensureOpenCodeProfileNodeModulesJunction('abc123');
expect(result).toBe(false);
statSyncSpy.mockRestore();
});
it('returns true on Windows when target node_modules already exists', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation(() => {
return {} as fs.Stats;
});
const result = ensureOpenCodeProfileNodeModulesJunction('abc123');
expect(result).toBe(true);
statSyncSpy.mockRestore();
});
it('creates junction on Windows when shared cache exists and target is missing', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
let callCount = 0;
const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation(() => {
callCount++;
// First call: target does not exist (throw)
// Second call: source exists (return stats)
if (callCount === 1) {
const err = new Error('ENOENT') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
}
return {} as fs.Stats;
});
const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => '');
const symlinkSyncSpy = vi.spyOn(fs, 'symlinkSync').mockImplementation(() => undefined);
const result = ensureOpenCodeProfileNodeModulesJunction('abc123');
expect(result).toBe(true);
expect(symlinkSyncSpy).toHaveBeenCalledTimes(1);
expect(symlinkSyncSpy.mock.calls[0][2]).toBe('junction');
statSyncSpy.mockRestore();
mkdirSyncSpy.mockRestore();
symlinkSyncSpy.mockRestore();
});
it('returns false when junction creation fails', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
let callCount2 = 0;
const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation(() => {
callCount2++;
if (callCount2 === 1) {
const err = new Error('ENOENT') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
}
return {} as fs.Stats;
});
const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => '');
const symlinkSyncSpy = vi.spyOn(fs, 'symlinkSync').mockImplementation(() => {
throw new Error('EPERM');
});
const result = ensureOpenCodeProfileNodeModulesJunction('abc123');
expect(result).toBe(false);
statSyncSpy.mockRestore();
mkdirSyncSpy.mockRestore();
symlinkSyncSpy.mockRestore();
});
});
});