fix(opencode): harden Windows junction retry
This commit is contained in:
parent
b12106d8f4
commit
8abf4ea7dd
4 changed files with 251 additions and 46 deletions
|
|
@ -1092,20 +1092,22 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
|
||||||
if (process.platform === 'win32' && isOpenCodeNodeModulesSymlinkError(failure.message)) {
|
if (process.platform === 'win32' && isOpenCodeNodeModulesSymlinkError(failure.message)) {
|
||||||
const profileId = extractProfileIdFromSymlinkError(failure.message);
|
const profileId = extractProfileIdFromSymlinkError(failure.message);
|
||||||
if (profileId) {
|
if (profileId) {
|
||||||
ensureOpenCodeProfileNodeModulesJunction(profileId, failure.message);
|
const junctionReady = ensureOpenCodeProfileNodeModulesJunction(profileId, failure.message);
|
||||||
try {
|
if (junctionReady) {
|
||||||
const retryResult = await execCli(
|
try {
|
||||||
binaryPath,
|
const retryResult = await execCli(
|
||||||
args,
|
binaryPath,
|
||||||
runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath)
|
args,
|
||||||
);
|
runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath)
|
||||||
return extractJsonObjectWithContext<RuntimeProviderManagementViewResponse>(
|
);
|
||||||
retryResult.stdout,
|
return extractJsonObjectWithContext<RuntimeProviderManagementViewResponse>(
|
||||||
context,
|
retryResult.stdout,
|
||||||
retryResult.stderr
|
context,
|
||||||
);
|
retryResult.stderr
|
||||||
} catch {
|
);
|
||||||
// Retry also failed; fall through to return the original error.
|
} catch {
|
||||||
|
// Retry also failed; fall through to return the original error.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1170,20 +1172,22 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
|
||||||
if (process.platform === 'win32' && isOpenCodeNodeModulesSymlinkError(failure.message)) {
|
if (process.platform === 'win32' && isOpenCodeNodeModulesSymlinkError(failure.message)) {
|
||||||
const profileId = extractProfileIdFromSymlinkError(failure.message);
|
const profileId = extractProfileIdFromSymlinkError(failure.message);
|
||||||
if (profileId) {
|
if (profileId) {
|
||||||
ensureOpenCodeProfileNodeModulesJunction(profileId, failure.message);
|
const junctionReady = ensureOpenCodeProfileNodeModulesJunction(profileId, failure.message);
|
||||||
try {
|
if (junctionReady) {
|
||||||
const retryResult = await execCli(
|
try {
|
||||||
binaryPath,
|
const retryResult = await execCli(
|
||||||
args,
|
binaryPath,
|
||||||
runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath)
|
args,
|
||||||
);
|
runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath)
|
||||||
return extractJsonObjectWithContext<RuntimeProviderManagementDirectoryResponse>(
|
);
|
||||||
retryResult.stdout,
|
return extractJsonObjectWithContext<RuntimeProviderManagementDirectoryResponse>(
|
||||||
context,
|
retryResult.stdout,
|
||||||
retryResult.stderr
|
context,
|
||||||
);
|
retryResult.stderr
|
||||||
} catch {
|
);
|
||||||
// Retry also failed; fall through to return the original error.
|
} catch {
|
||||||
|
// Retry also failed; fall through to return the original error.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,17 @@ const OPENCODE_PROFILES_BASE_RELATIVE = path.join(
|
||||||
'opencode',
|
'opencode',
|
||||||
'profiles'
|
'profiles'
|
||||||
);
|
);
|
||||||
|
const OPENCODE_SHARED_CACHE_SUFFIX_PARTS = [
|
||||||
|
'Cache',
|
||||||
|
'opencode',
|
||||||
|
'shared-cache',
|
||||||
|
'config-node_modules',
|
||||||
|
];
|
||||||
|
const OPENCODE_PROFILE_NODE_MODULES_SUFFIX_TAIL = [
|
||||||
|
'config',
|
||||||
|
'opencode',
|
||||||
|
'node_modules',
|
||||||
|
];
|
||||||
|
|
||||||
function getLocalAppDataPath(): string {
|
function getLocalAppDataPath(): string {
|
||||||
return process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
|
return process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
|
||||||
|
|
@ -47,10 +58,61 @@ export function isOpenCodeNodeModulesSymlinkError(message: string): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeErrorPathSeparators(value: string): string {
|
||||||
|
return value.replace(/\\\\/g, '\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePathForComparison(value: string): string {
|
||||||
|
return normalizeErrorPathSeparators(value).replace(/[\\/]+/g, '/').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbsolutePath(candidate: string): boolean {
|
||||||
|
const normalized = normalizeErrorPathSeparators(candidate);
|
||||||
|
return path.win32.isAbsolute(normalized) || path.posix.isAbsolute(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExpectedProfileSuffixParts(profileId: string): string[] {
|
||||||
|
return ['Data', 'opencode', 'profiles', profileId, ...OPENCODE_PROFILE_NODE_MODULES_SUFFIX_TAIL];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPathBaseBeforeSuffix(candidate: string, suffixParts: readonly string[]): string | null {
|
||||||
|
const normalized = normalizePathForComparison(candidate);
|
||||||
|
const suffix = suffixParts.join('/').toLowerCase();
|
||||||
|
if (!normalized.endsWith(`/${suffix}`)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalized.slice(0, -suffix.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpectedProfileNodeModulesPath(candidate: string, profileId: string): boolean {
|
||||||
|
return Boolean(
|
||||||
|
profileId &&
|
||||||
|
isAbsolutePath(candidate) &&
|
||||||
|
getPathBaseBeforeSuffix(candidate, getExpectedProfileSuffixParts(profileId))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpectedSharedCacheNodeModulesPath(candidate: string): boolean {
|
||||||
|
return Boolean(
|
||||||
|
isAbsolutePath(candidate) &&
|
||||||
|
getPathBaseBeforeSuffix(candidate, OPENCODE_SHARED_CACHE_SUFFIX_PARTS)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractedPathsShareBase(
|
||||||
|
source: string,
|
||||||
|
target: string,
|
||||||
|
profileId: string
|
||||||
|
): boolean {
|
||||||
|
const sourceBase = getPathBaseBeforeSuffix(source, OPENCODE_SHARED_CACHE_SUFFIX_PARTS);
|
||||||
|
const targetBase = getPathBaseBeforeSuffix(target, getExpectedProfileSuffixParts(profileId));
|
||||||
|
return Boolean(sourceBase && targetBase && sourceBase === targetBase);
|
||||||
|
}
|
||||||
|
|
||||||
export function extractProfileIdFromSymlinkError(message: string): string | null {
|
export function extractProfileIdFromSymlinkError(message: string): string | null {
|
||||||
const profilePathPattern =
|
const profilePathPattern =
|
||||||
/profiles[\\/]([0-9a-f]+)[\\/]config[\\/]opencode[\\/]node_modules/i;
|
/profiles[\\/]([0-9a-f]+)[\\/]config[\\/]opencode[\\/]node_modules/i;
|
||||||
const match = profilePathPattern.exec(message);
|
const match = profilePathPattern.exec(normalizeErrorPathSeparators(message));
|
||||||
return match ? match[1] : null;
|
return match ? match[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,12 +121,12 @@ const SYMLINK_TARGET_PATTERN = /->\s+'([^']+)'/i;
|
||||||
|
|
||||||
export function extractSymlinkSourcePath(message: string): string | null {
|
export function extractSymlinkSourcePath(message: string): string | null {
|
||||||
const match = SYMLINK_SOURCE_PATTERN.exec(message);
|
const match = SYMLINK_SOURCE_PATTERN.exec(message);
|
||||||
return match ? match[1] : null;
|
return match ? normalizeErrorPathSeparators(match[1]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractSymlinkTargetPath(message: string): string | null {
|
export function extractSymlinkTargetPath(message: string): string | null {
|
||||||
const match = SYMLINK_TARGET_PATTERN.exec(message);
|
const match = SYMLINK_TARGET_PATTERN.exec(message);
|
||||||
return match ? match[1] : null;
|
return match ? normalizeErrorPathSeparators(match[1]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureOpenCodeProfileNodeModulesJunction(
|
export function ensureOpenCodeProfileNodeModulesJunction(
|
||||||
|
|
@ -75,23 +137,22 @@ export function ensureOpenCodeProfileNodeModulesJunction(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let source: string;
|
let source = getSharedCacheNodeModulesPath();
|
||||||
let target: string;
|
let target = getProfileNodeModulesPath(profileId);
|
||||||
|
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
const extractedSource = extractSymlinkSourcePath(errorMessage);
|
const extractedSource = extractSymlinkSourcePath(errorMessage);
|
||||||
const extractedTarget = extractSymlinkTargetPath(errorMessage);
|
const extractedTarget = extractSymlinkTargetPath(errorMessage);
|
||||||
|
|
||||||
if (extractedTarget) {
|
if (
|
||||||
|
extractedTarget &&
|
||||||
|
isExpectedProfileNodeModulesPath(extractedTarget, profileId) &&
|
||||||
|
(!extractedSource || isExpectedSharedCacheNodeModulesPath(extractedSource)) &&
|
||||||
|
(!extractedSource || extractedPathsShareBase(extractedSource, extractedTarget, profileId))
|
||||||
|
) {
|
||||||
target = extractedTarget;
|
target = extractedTarget;
|
||||||
source = extractedSource ?? getSharedCacheNodeModulesPath();
|
source = extractedSource ?? source;
|
||||||
} else {
|
|
||||||
target = getProfileNodeModulesPath(profileId);
|
|
||||||
source = getSharedCacheNodeModulesPath();
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
target = getProfileNodeModulesPath(profileId);
|
|
||||||
source = getSharedCacheNodeModulesPath();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -83,11 +83,10 @@ vi.mock(
|
||||||
);
|
);
|
||||||
|
|
||||||
import { AgentTeamsRuntimeProviderManagementCliClient } from '../../../../src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient';
|
import { AgentTeamsRuntimeProviderManagementCliClient } from '../../../../src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isOpenCodeNodeModulesSymlinkError as isOpenCodeNodeModulesSymlinkErrorMock,
|
|
||||||
extractProfileIdFromSymlinkError as extractProfileIdFromSymlinkErrorMock,
|
|
||||||
ensureOpenCodeProfileNodeModulesJunction as ensureOpenCodeProfileNodeModulesJunctionMock,
|
ensureOpenCodeProfileNodeModulesJunction as ensureOpenCodeProfileNodeModulesJunctionMock,
|
||||||
|
extractProfileIdFromSymlinkError as extractProfileIdFromSymlinkErrorMock,
|
||||||
|
isOpenCodeNodeModulesSymlinkError as isOpenCodeNodeModulesSymlinkErrorMock,
|
||||||
} from '../../../../src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction';
|
} from '../../../../src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction';
|
||||||
|
|
||||||
describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
|
describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
|
||||||
|
|
@ -1009,6 +1008,45 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not retry when junction pre-seed fails in loadView', async () => {
|
||||||
|
const runtimeMessage = [
|
||||||
|
'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(' ');
|
||||||
|
const error = new Error('Command failed: /repo/cli-dev runtime providers view');
|
||||||
|
Object.assign(error, {
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
runtimeId: 'opencode',
|
||||||
|
error: { code: 'runtime-unhealthy', message: runtimeMessage, recoverable: true },
|
||||||
|
}),
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
execCliMock.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||||
|
(isOpenCodeNodeModulesSymlinkErrorMock as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||||
|
(extractProfileIdFromSymlinkErrorMock as ReturnType<typeof vi.fn>).mockReturnValue('abc123');
|
||||||
|
(ensureOpenCodeProfileNodeModulesJunctionMock as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new AgentTeamsRuntimeProviderManagementCliClient();
|
||||||
|
const response = await client.loadView({ runtimeId: 'opencode' });
|
||||||
|
|
||||||
|
expect(ensureOpenCodeProfileNodeModulesJunctionMock).toHaveBeenCalledWith('abc123', expect.any(String));
|
||||||
|
expect(execCliMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(response.error?.message).toBe(runtimeMessage);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||||
|
vi.mocked(isOpenCodeNodeModulesSymlinkErrorMock).mockRestore();
|
||||||
|
vi.mocked(extractProfileIdFromSymlinkErrorMock).mockRestore();
|
||||||
|
vi.mocked(ensureOpenCodeProfileNodeModulesJunctionMock).mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('does not attempt junction retry on non-Windows platforms in loadView', async () => {
|
it('does not attempt junction retry on non-Windows platforms in loadView', async () => {
|
||||||
const runtimeMessage = [
|
const runtimeMessage = [
|
||||||
'Runtime provider management command failed unexpectedly:',
|
'Runtime provider management command failed unexpectedly:',
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,17 @@ describe('openCodeWindowsNodeModulesJunction', () => {
|
||||||
expect(extractProfileIdFromSymlinkError(message)).toBe('abc123def456');
|
expect(extractProfileIdFromSymlinkError(message)).toBe('abc123def456');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('extracts the profile hash from JSON-escaped Windows paths', () => {
|
||||||
|
const runtimeMessage =
|
||||||
|
"EPERM: 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'";
|
||||||
|
const message = JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
runtimeId: 'opencode',
|
||||||
|
error: { message: runtimeMessage },
|
||||||
|
});
|
||||||
|
expect(extractProfileIdFromSymlinkError(message)).toBe('abc123');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns null when no profile path pattern is found', () => {
|
it('returns null when no profile path pattern is found', () => {
|
||||||
const message = 'EPERM: some other error without a profile path';
|
const message = 'EPERM: some other error without a profile path';
|
||||||
expect(extractProfileIdFromSymlinkError(message)).toBeNull();
|
expect(extractProfileIdFromSymlinkError(message)).toBeNull();
|
||||||
|
|
@ -93,6 +104,15 @@ describe('openCodeWindowsNodeModulesJunction', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes JSON-escaped Windows separators in the source path', () => {
|
||||||
|
const runtimeMessage =
|
||||||
|
"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'";
|
||||||
|
const message = JSON.stringify({ error: { message: runtimeMessage } });
|
||||||
|
expect(extractSymlinkSourcePath(message)).toBe(
|
||||||
|
'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns null when no source path is found', () => {
|
it('returns null when no source path is found', () => {
|
||||||
const message = 'EPERM: some error without paths';
|
const message = 'EPERM: some error without paths';
|
||||||
expect(extractSymlinkSourcePath(message)).toBeNull();
|
expect(extractSymlinkSourcePath(message)).toBeNull();
|
||||||
|
|
@ -108,6 +128,15 @@ describe('openCodeWindowsNodeModulesJunction', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes JSON-escaped Windows separators in the target path', () => {
|
||||||
|
const runtimeMessage =
|
||||||
|
"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'";
|
||||||
|
const message = JSON.stringify({ error: { message: runtimeMessage } });
|
||||||
|
expect(extractSymlinkTargetPath(message)).toBe(
|
||||||
|
'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\e8e2eadb00beea6c\\config\\opencode\\node_modules'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns null when no target path is found', () => {
|
it('returns null when no target path is found', () => {
|
||||||
const message = "EPERM: operation not permitted, symlink '/some/path'";
|
const message = "EPERM: operation not permitted, symlink '/some/path'";
|
||||||
expect(extractSymlinkTargetPath(message)).toBeNull();
|
expect(extractSymlinkTargetPath(message)).toBeNull();
|
||||||
|
|
@ -229,6 +258,79 @@ describe('openCodeWindowsNodeModulesJunction', () => {
|
||||||
symlinkSyncSpy.mockRestore();
|
symlinkSyncSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses validated error-derived junction paths instead of the local process env', () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||||
|
const originalEnv = process.env.LOCALAPPDATA;
|
||||||
|
process.env.LOCALAPPDATA = 'C:\\fallback\\local';
|
||||||
|
const source =
|
||||||
|
'D:\\runtime-root\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules';
|
||||||
|
const target =
|
||||||
|
'D:\\runtime-root\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\abc123\\config\\opencode\\node_modules';
|
||||||
|
const message = JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: `EPERM: operation not permitted, symlink '${source}' -> '${target}'`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation(
|
||||||
|
(...args: Parameters<typeof fs.statSync>) => {
|
||||||
|
if (String(args[0]) === target) {
|
||||||
|
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);
|
||||||
|
try {
|
||||||
|
const result = ensureOpenCodeProfileNodeModulesJunction('abc123', message);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(symlinkSyncSpy).toHaveBeenCalledWith(source, target, 'junction');
|
||||||
|
} finally {
|
||||||
|
process.env.LOCALAPPDATA = originalEnv;
|
||||||
|
statSyncSpy.mockRestore();
|
||||||
|
mkdirSyncSpy.mockRestore();
|
||||||
|
symlinkSyncSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to computed paths when error-derived paths fail validation', () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||||
|
const originalEnv = process.env.LOCALAPPDATA;
|
||||||
|
process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local';
|
||||||
|
const computedSource = getSharedCacheNodeModulesPath();
|
||||||
|
const computedTarget = getProfileNodeModulesPath('abc123');
|
||||||
|
const message =
|
||||||
|
"EPERM: operation not permitted, symlink 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules' -> 'C:\\Temp\\outside\\node_modules'";
|
||||||
|
const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation(
|
||||||
|
(...args: Parameters<typeof fs.statSync>) => {
|
||||||
|
if (String(args[0]) === computedTarget) {
|
||||||
|
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);
|
||||||
|
try {
|
||||||
|
const result = ensureOpenCodeProfileNodeModulesJunction('abc123', message);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(symlinkSyncSpy).toHaveBeenCalledWith(
|
||||||
|
computedSource,
|
||||||
|
computedTarget,
|
||||||
|
'junction'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
process.env.LOCALAPPDATA = originalEnv;
|
||||||
|
statSyncSpy.mockRestore();
|
||||||
|
mkdirSyncSpy.mockRestore();
|
||||||
|
symlinkSyncSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('returns false when junction creation fails', () => {
|
it('returns false when junction creation fails', () => {
|
||||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||||
let callCount2 = 0;
|
let callCount2 = 0;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue