fix(windows): Gateway process install extension failed (#587)
Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
@@ -12,6 +12,43 @@ import { homedir } from 'node:os';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
function normalizeFsPathForWindows(filePath: string): string {
|
||||||
|
if (process.platform !== 'win32') return filePath;
|
||||||
|
if (!filePath) return filePath;
|
||||||
|
if (filePath.startsWith('\\\\?\\')) return filePath;
|
||||||
|
|
||||||
|
const windowsPath = filePath.replace(/\//g, '\\');
|
||||||
|
if (!path.win32.isAbsolute(windowsPath)) return windowsPath;
|
||||||
|
if (windowsPath.startsWith('\\\\')) {
|
||||||
|
return `\\\\?\\UNC\\${windowsPath.slice(2)}`;
|
||||||
|
}
|
||||||
|
return `\\\\?\\${windowsPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fsPath(filePath: string): string {
|
||||||
|
return normalizeFsPathForWindows(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asErrnoException(error: unknown): NodeJS.ErrnoException | null {
|
||||||
|
if (error && typeof error === 'object') {
|
||||||
|
return error as NodeJS.ErrnoException;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toErrorDiagnostic(error: unknown): { code?: string; name?: string; message: string } {
|
||||||
|
const errno = asErrnoException(error);
|
||||||
|
if (!errno) {
|
||||||
|
return { message: String(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: typeof errno.code === 'string' ? errno.code : undefined,
|
||||||
|
name: errno.name,
|
||||||
|
message: errno.message || String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Known plugin-ID corrections ─────────────────────────────────────────────
|
// ── Known plugin-ID corrections ─────────────────────────────────────────────
|
||||||
// Some npm packages ship with an openclaw.plugin.json whose "id" field
|
// Some npm packages ship with an openclaw.plugin.json whose "id" field
|
||||||
// doesn't match the ID the plugin code actually exports. After copying we
|
// doesn't match the ID the plugin code actually exports. After copying we
|
||||||
@@ -29,13 +66,13 @@ export function fixupPluginManifest(targetDir: string): void {
|
|||||||
// 1. Fix openclaw.plugin.json id
|
// 1. Fix openclaw.plugin.json id
|
||||||
const manifestPath = join(targetDir, 'openclaw.plugin.json');
|
const manifestPath = join(targetDir, 'openclaw.plugin.json');
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(manifestPath, 'utf-8');
|
const raw = readFileSync(fsPath(manifestPath), 'utf-8');
|
||||||
const manifest = JSON.parse(raw);
|
const manifest = JSON.parse(raw);
|
||||||
const oldId = manifest.id as string | undefined;
|
const oldId = manifest.id as string | undefined;
|
||||||
if (oldId && MANIFEST_ID_FIXES[oldId]) {
|
if (oldId && MANIFEST_ID_FIXES[oldId]) {
|
||||||
const newId = MANIFEST_ID_FIXES[oldId];
|
const newId = MANIFEST_ID_FIXES[oldId];
|
||||||
manifest.id = newId;
|
manifest.id = newId;
|
||||||
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
writeFileSync(fsPath(manifestPath), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||||
logger.info(`[plugin] Fixed manifest ID: ${oldId} → ${newId}`);
|
logger.info(`[plugin] Fixed manifest ID: ${oldId} → ${newId}`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -45,7 +82,7 @@ export function fixupPluginManifest(targetDir: string): void {
|
|||||||
// 2. Fix package.json fields that Gateway uses as "entry hints"
|
// 2. Fix package.json fields that Gateway uses as "entry hints"
|
||||||
const pkgPath = join(targetDir, 'package.json');
|
const pkgPath = join(targetDir, 'package.json');
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(pkgPath, 'utf-8');
|
const raw = readFileSync(fsPath(pkgPath), 'utf-8');
|
||||||
const pkg = JSON.parse(raw);
|
const pkg = JSON.parse(raw);
|
||||||
let modified = false;
|
let modified = false;
|
||||||
|
|
||||||
@@ -69,7 +106,7 @@ export function fixupPluginManifest(targetDir: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (modified) {
|
if (modified) {
|
||||||
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
|
writeFileSync(fsPath(pkgPath), JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
|
||||||
logger.info(`[plugin] Fixed package.json entry hints in ${targetDir}`);
|
logger.info(`[plugin] Fixed package.json entry hints in ${targetDir}`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -90,7 +127,7 @@ function patchPluginEntryIds(targetDir: string): void {
|
|||||||
const pkgPath = join(targetDir, 'package.json');
|
const pkgPath = join(targetDir, 'package.json');
|
||||||
let pkg: Record<string, unknown>;
|
let pkg: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
pkg = JSON.parse(readFileSync(fsPath(pkgPath), 'utf-8'));
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -99,11 +136,11 @@ function patchPluginEntryIds(targetDir: string): void {
|
|||||||
|
|
||||||
for (const entry of entryFiles) {
|
for (const entry of entryFiles) {
|
||||||
const entryPath = join(targetDir, entry);
|
const entryPath = join(targetDir, entry);
|
||||||
if (!existsSync(entryPath)) continue;
|
if (!existsSync(fsPath(entryPath))) continue;
|
||||||
|
|
||||||
let content: string;
|
let content: string;
|
||||||
try {
|
try {
|
||||||
content = readFileSync(entryPath, 'utf-8');
|
content = readFileSync(fsPath(entryPath), 'utf-8');
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -122,7 +159,7 @@ function patchPluginEntryIds(targetDir: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (patched) {
|
if (patched) {
|
||||||
writeFileSync(entryPath, content, 'utf-8');
|
writeFileSync(fsPath(entryPath), content, 'utf-8');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,7 +177,7 @@ const PLUGIN_NPM_NAMES: Record<string, string> = {
|
|||||||
|
|
||||||
function readPluginVersion(pkgJsonPath: string): string | null {
|
function readPluginVersion(pkgJsonPath: string): string | null {
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(pkgJsonPath, 'utf-8');
|
const raw = readFileSync(fsPath(pkgJsonPath), 'utf-8');
|
||||||
const parsed = JSON.parse(raw) as { version?: string };
|
const parsed = JSON.parse(raw) as { version?: string };
|
||||||
return parsed.version ?? null;
|
return parsed.version ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -163,15 +200,15 @@ function findParentNodeModules(startPath: string): string | null {
|
|||||||
/** List packages inside a node_modules dir (handles @scoped packages). */
|
/** List packages inside a node_modules dir (handles @scoped packages). */
|
||||||
function listPackagesInDir(nodeModulesDir: string): Array<{ name: string; fullPath: string }> {
|
function listPackagesInDir(nodeModulesDir: string): Array<{ name: string; fullPath: string }> {
|
||||||
const result: Array<{ name: string; fullPath: string }> = [];
|
const result: Array<{ name: string; fullPath: string }> = [];
|
||||||
if (!existsSync(nodeModulesDir)) return result;
|
if (!existsSync(fsPath(nodeModulesDir))) return result;
|
||||||
const SKIP = new Set(['.bin', '.package-lock.json', '.modules.yaml', '.pnpm']);
|
const SKIP = new Set(['.bin', '.package-lock.json', '.modules.yaml', '.pnpm']);
|
||||||
for (const entry of readdirSync(nodeModulesDir, { withFileTypes: true })) {
|
for (const entry of readdirSync(fsPath(nodeModulesDir), { withFileTypes: true })) {
|
||||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
||||||
if (SKIP.has(entry.name)) continue;
|
if (SKIP.has(entry.name)) continue;
|
||||||
const entryPath = join(nodeModulesDir, entry.name);
|
const entryPath = join(nodeModulesDir, entry.name);
|
||||||
if (entry.name.startsWith('@')) {
|
if (entry.name.startsWith('@')) {
|
||||||
try {
|
try {
|
||||||
for (const sub of readdirSync(entryPath)) {
|
for (const sub of readdirSync(fsPath(entryPath))) {
|
||||||
result.push({ name: `${entry.name}/${sub}`, fullPath: join(entryPath, sub) });
|
result.push({ name: `${entry.name}/${sub}`, fullPath: join(entryPath, sub) });
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
@@ -190,15 +227,15 @@ function listPackagesInDir(nodeModulesDir: string): Array<{ name: string; fullPa
|
|||||||
export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string, npmName: string): void {
|
export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string, npmName: string): void {
|
||||||
let realPath: string;
|
let realPath: string;
|
||||||
try {
|
try {
|
||||||
realPath = realpathSync(npmPkgPath);
|
realPath = realpathSync(fsPath(npmPkgPath));
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`Cannot resolve real path for ${npmPkgPath}`);
|
throw new Error(`Cannot resolve real path for ${npmPkgPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Copy plugin package itself
|
// 1. Copy plugin package itself
|
||||||
rmSync(targetDir, { recursive: true, force: true });
|
rmSync(fsPath(targetDir), { recursive: true, force: true });
|
||||||
mkdirSync(targetDir, { recursive: true });
|
mkdirSync(fsPath(targetDir), { recursive: true });
|
||||||
cpSync(realPath, targetDir, { recursive: true, dereference: true });
|
cpSync(fsPath(realPath), fsPath(targetDir), { recursive: true, dereference: true });
|
||||||
|
|
||||||
// 2. Collect transitive deps from pnpm virtual store
|
// 2. Collect transitive deps from pnpm virtual store
|
||||||
const rootVirtualNM = findParentNodeModules(realPath);
|
const rootVirtualNM = findParentNodeModules(realPath);
|
||||||
@@ -210,7 +247,7 @@ export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string,
|
|||||||
// Read peer deps to skip (they're provided by the host gateway)
|
// Read peer deps to skip (they're provided by the host gateway)
|
||||||
const SKIP_PACKAGES = new Set(['typescript', '@playwright/test']);
|
const SKIP_PACKAGES = new Set(['typescript', '@playwright/test']);
|
||||||
try {
|
try {
|
||||||
const pluginPkg = JSON.parse(readFileSync(join(targetDir, 'package.json'), 'utf-8'));
|
const pluginPkg = JSON.parse(readFileSync(fsPath(join(targetDir, 'package.json')), 'utf-8'));
|
||||||
for (const peer of Object.keys(pluginPkg.peerDependencies || {})) {
|
for (const peer of Object.keys(pluginPkg.peerDependencies || {})) {
|
||||||
SKIP_PACKAGES.add(peer);
|
SKIP_PACKAGES.add(peer);
|
||||||
}
|
}
|
||||||
@@ -228,7 +265,7 @@ export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string,
|
|||||||
if (SKIP_PACKAGES.has(name) || name.startsWith('@types/')) continue;
|
if (SKIP_PACKAGES.has(name) || name.startsWith('@types/')) continue;
|
||||||
let depRealPath: string;
|
let depRealPath: string;
|
||||||
try {
|
try {
|
||||||
depRealPath = realpathSync(fullPath);
|
depRealPath = realpathSync(fsPath(fullPath));
|
||||||
} catch { continue; }
|
} catch { continue; }
|
||||||
if (collected.has(depRealPath)) continue;
|
if (collected.has(depRealPath)) continue;
|
||||||
collected.set(depRealPath, name);
|
collected.set(depRealPath, name);
|
||||||
@@ -241,15 +278,15 @@ export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string,
|
|||||||
|
|
||||||
// 3. Copy flattened deps into targetDir/node_modules/
|
// 3. Copy flattened deps into targetDir/node_modules/
|
||||||
const outputNM = join(targetDir, 'node_modules');
|
const outputNM = join(targetDir, 'node_modules');
|
||||||
mkdirSync(outputNM, { recursive: true });
|
mkdirSync(fsPath(outputNM), { recursive: true });
|
||||||
const copiedNames = new Set<string>();
|
const copiedNames = new Set<string>();
|
||||||
for (const [depRealPath, pkgName] of collected) {
|
for (const [depRealPath, pkgName] of collected) {
|
||||||
if (copiedNames.has(pkgName)) continue;
|
if (copiedNames.has(pkgName)) continue;
|
||||||
copiedNames.add(pkgName);
|
copiedNames.add(pkgName);
|
||||||
const dest = join(outputNM, pkgName);
|
const dest = join(outputNM, pkgName);
|
||||||
try {
|
try {
|
||||||
mkdirSync(path.dirname(dest), { recursive: true });
|
mkdirSync(fsPath(path.dirname(dest)), { recursive: true });
|
||||||
cpSync(depRealPath, dest, { recursive: true, dereference: true });
|
cpSync(fsPath(depRealPath), fsPath(dest), { recursive: true, dereference: true });
|
||||||
} catch { /* skip individual dep failures */ }
|
} catch { /* skip individual dep failures */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,10 +304,10 @@ export function ensurePluginInstalled(
|
|||||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||||
const targetPkgJson = join(targetDir, 'package.json');
|
const targetPkgJson = join(targetDir, 'package.json');
|
||||||
|
|
||||||
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
|
const sourceDir = candidateSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
|
||||||
|
|
||||||
// If already installed, check whether an upgrade is available
|
// If already installed, check whether an upgrade is available
|
||||||
if (existsSync(targetManifest)) {
|
if (existsSync(fsPath(targetManifest))) {
|
||||||
if (!sourceDir) return { installed: true }; // no bundled source to compare, keep existing
|
if (!sourceDir) return { installed: true }; // no bundled source to compare, keep existing
|
||||||
const installedVersion = readPluginVersion(targetPkgJson);
|
const installedVersion = readPluginVersion(targetPkgJson);
|
||||||
const sourceVersion = readPluginVersion(join(sourceDir, 'package.json'));
|
const sourceVersion = readPluginVersion(join(sourceDir, 'package.json'));
|
||||||
@@ -285,19 +322,47 @@ export function ensurePluginInstalled(
|
|||||||
|
|
||||||
// Fresh install or upgrade — try bundled/build sources first
|
// Fresh install or upgrade — try bundled/build sources first
|
||||||
if (sourceDir) {
|
if (sourceDir) {
|
||||||
try {
|
const extensionsRoot = join(homedir(), '.openclaw', 'extensions');
|
||||||
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
|
const attempts: Array<{ attempt: number; code?: string; name?: string; message: string }> = [];
|
||||||
rmSync(targetDir, { recursive: true, force: true });
|
const maxAttempts = process.platform === 'win32' ? 2 : 1;
|
||||||
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
|
|
||||||
if (!existsSync(join(targetDir, 'openclaw.plugin.json'))) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` };
|
try {
|
||||||
|
mkdirSync(fsPath(extensionsRoot), { recursive: true });
|
||||||
|
rmSync(fsPath(targetDir), { recursive: true, force: true });
|
||||||
|
cpSync(fsPath(sourceDir), fsPath(targetDir), { recursive: true, dereference: true });
|
||||||
|
if (!existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
|
||||||
|
return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` };
|
||||||
|
}
|
||||||
|
fixupPluginManifest(targetDir);
|
||||||
|
logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`);
|
||||||
|
return { installed: true };
|
||||||
|
} catch (error) {
|
||||||
|
const diagnostic = toErrorDiagnostic(error);
|
||||||
|
attempts.push({ attempt, ...diagnostic });
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
try {
|
||||||
|
rmSync(fsPath(targetDir), { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup failures before retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fixupPluginManifest(targetDir);
|
|
||||||
logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`);
|
|
||||||
return { installed: true };
|
|
||||||
} catch {
|
|
||||||
return { installed: false, warning: `Failed to install bundled ${pluginLabel} plugin mirror` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`[plugin] Bundled mirror install failed for ${pluginLabel}`,
|
||||||
|
{
|
||||||
|
pluginDirName,
|
||||||
|
pluginLabel,
|
||||||
|
sourceDir,
|
||||||
|
targetDir,
|
||||||
|
platform: process.platform,
|
||||||
|
attempts,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { installed: false, warning: `Failed to install bundled ${pluginLabel} plugin mirror` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dev mode fallback: copy from node_modules with pnpm-aware dep resolution
|
// Dev mode fallback: copy from node_modules with pnpm-aware dep resolution
|
||||||
@@ -305,8 +370,8 @@ export function ensurePluginInstalled(
|
|||||||
const npmName = PLUGIN_NPM_NAMES[pluginDirName];
|
const npmName = PLUGIN_NPM_NAMES[pluginDirName];
|
||||||
if (npmName) {
|
if (npmName) {
|
||||||
const npmPkgPath = join(process.cwd(), 'node_modules', ...npmName.split('/'));
|
const npmPkgPath = join(process.cwd(), 'node_modules', ...npmName.split('/'));
|
||||||
if (existsSync(join(npmPkgPath, 'openclaw.plugin.json'))) {
|
if (existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) {
|
||||||
const installedVersion = existsSync(targetPkgJson) ? readPluginVersion(targetPkgJson) : null;
|
const installedVersion = existsSync(fsPath(targetPkgJson)) ? readPluginVersion(targetPkgJson) : null;
|
||||||
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
|
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
|
||||||
if (sourceVersion && (!installedVersion || sourceVersion !== installedVersion)) {
|
if (sourceVersion && (!installedVersion || sourceVersion !== installedVersion)) {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -314,16 +379,27 @@ export function ensurePluginInstalled(
|
|||||||
`${installedVersion ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`,
|
`${installedVersion ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
|
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
|
||||||
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
|
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
|
||||||
fixupPluginManifest(targetDir);
|
fixupPluginManifest(targetDir);
|
||||||
if (existsSync(join(targetDir, 'openclaw.plugin.json'))) {
|
if (existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
|
||||||
return { installed: true };
|
return { installed: true };
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`[plugin] Failed to install ${pluginLabel} plugin from node_modules:`, err);
|
logger.warn(
|
||||||
|
`[plugin] Failed to install ${pluginLabel} plugin from node_modules`,
|
||||||
|
{
|
||||||
|
pluginDirName,
|
||||||
|
pluginLabel,
|
||||||
|
npmName,
|
||||||
|
npmPkgPath,
|
||||||
|
targetDir,
|
||||||
|
platform: process.platform,
|
||||||
|
...toErrorDiagnostic(err),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (existsSync(targetManifest)) {
|
} else if (existsSync(fsPath(targetManifest))) {
|
||||||
return { installed: true }; // same version, already installed
|
return { installed: true }; // same version, already installed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawx",
|
"name": "clawx",
|
||||||
"version": "0.2.5",
|
"version": "0.2.6-alpha.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@discordjs/opus",
|
"@discordjs/opus",
|
||||||
|
|||||||
188
tests/unit/plugin-install.test.ts
Normal file
188
tests/unit/plugin-install.test.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockExistsSync,
|
||||||
|
mockCpSync,
|
||||||
|
mockMkdirSync,
|
||||||
|
mockRmSync,
|
||||||
|
mockReadFileSync,
|
||||||
|
mockWriteFileSync,
|
||||||
|
mockReaddirSync,
|
||||||
|
mockRealpathSync,
|
||||||
|
mockLoggerWarn,
|
||||||
|
mockLoggerInfo,
|
||||||
|
mockHomedir,
|
||||||
|
mockApp,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockExistsSync: vi.fn(),
|
||||||
|
mockCpSync: vi.fn(),
|
||||||
|
mockMkdirSync: vi.fn(),
|
||||||
|
mockRmSync: vi.fn(),
|
||||||
|
mockReadFileSync: vi.fn(),
|
||||||
|
mockWriteFileSync: vi.fn(),
|
||||||
|
mockReaddirSync: vi.fn(),
|
||||||
|
mockRealpathSync: vi.fn(),
|
||||||
|
mockLoggerWarn: vi.fn(),
|
||||||
|
mockLoggerInfo: vi.fn(),
|
||||||
|
mockHomedir: vi.fn(() => '/home/test'),
|
||||||
|
mockApp: {
|
||||||
|
isPackaged: true,
|
||||||
|
getAppPath: vi.fn(() => '/mock/app'),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||||
|
|
||||||
|
vi.mock('node:fs', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
||||||
|
const mocked = {
|
||||||
|
...actual,
|
||||||
|
existsSync: mockExistsSync,
|
||||||
|
cpSync: mockCpSync,
|
||||||
|
mkdirSync: mockMkdirSync,
|
||||||
|
rmSync: mockRmSync,
|
||||||
|
readFileSync: mockReadFileSync,
|
||||||
|
writeFileSync: mockWriteFileSync,
|
||||||
|
readdirSync: mockReaddirSync,
|
||||||
|
realpathSync: mockRealpathSync,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...mocked,
|
||||||
|
default: mocked,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('node:os', () => ({
|
||||||
|
homedir: () => mockHomedir(),
|
||||||
|
default: {
|
||||||
|
homedir: () => mockHomedir(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
app: mockApp,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@electron/utils/logger', () => ({
|
||||||
|
logger: {
|
||||||
|
warn: mockLoggerWarn,
|
||||||
|
info: mockLoggerInfo,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function setPlatform(platform: NodeJS.Platform): void {
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
value: platform,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('plugin installer diagnostics', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockApp.isPackaged = true;
|
||||||
|
mockHomedir.mockReturnValue('/home/test');
|
||||||
|
setPlatform('linux');
|
||||||
|
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
mockCpSync.mockImplementation(() => undefined);
|
||||||
|
mockMkdirSync.mockImplementation(() => undefined);
|
||||||
|
mockRmSync.mockImplementation(() => undefined);
|
||||||
|
mockReadFileSync.mockReturnValue('{}');
|
||||||
|
mockWriteFileSync.mockImplementation(() => undefined);
|
||||||
|
mockReaddirSync.mockReturnValue([]);
|
||||||
|
mockRealpathSync.mockImplementation((input: string) => input);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (ORIGINAL_PLATFORM_DESCRIPTOR) {
|
||||||
|
Object.defineProperty(process, 'platform', ORIGINAL_PLATFORM_DESCRIPTOR);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns source-missing warning when bundled mirror cannot be found', async () => {
|
||||||
|
const { ensurePluginInstalled } = await import('@electron/utils/plugin-install');
|
||||||
|
const result = ensurePluginInstalled('wecom', ['/bundle/wecom'], 'WeCom');
|
||||||
|
|
||||||
|
expect(result.installed).toBe(false);
|
||||||
|
expect(result.warning).toContain('Bundled WeCom plugin mirror not found');
|
||||||
|
expect(mockLoggerWarn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries once on Windows and logs diagnostic details when bundled copy fails', async () => {
|
||||||
|
setPlatform('win32');
|
||||||
|
mockHomedir.mockReturnValue('C:\\Users\\test');
|
||||||
|
|
||||||
|
const sourceDir = 'C:\\Program Files\\ClawX\\resources\\openclaw-plugins\\wecom';
|
||||||
|
const sourceManifestSuffix = 'Program Files\\ClawX\\resources\\openclaw-plugins\\wecom\\openclaw.plugin.json';
|
||||||
|
|
||||||
|
mockExistsSync.mockImplementation((input: string) => String(input).includes(sourceManifestSuffix));
|
||||||
|
mockCpSync.mockImplementation(() => {
|
||||||
|
const error = new Error('path too long') as NodeJS.ErrnoException;
|
||||||
|
error.code = 'ENAMETOOLONG';
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ensurePluginInstalled } = await import('@electron/utils/plugin-install');
|
||||||
|
const result = ensurePluginInstalled('wecom', [sourceDir], 'WeCom');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
installed: false,
|
||||||
|
warning: 'Failed to install bundled WeCom plugin mirror',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCpSync).toHaveBeenCalledTimes(2);
|
||||||
|
const [firstSourcePath, firstTargetPath] = mockCpSync.mock.calls[0] as [string, string];
|
||||||
|
expect(firstSourcePath.startsWith('\\\\?\\')).toBe(true);
|
||||||
|
expect(firstTargetPath.startsWith('\\\\?\\')).toBe(true);
|
||||||
|
|
||||||
|
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||||
|
'[plugin] Bundled mirror install failed for WeCom',
|
||||||
|
expect.objectContaining({
|
||||||
|
pluginDirName: 'wecom',
|
||||||
|
pluginLabel: 'WeCom',
|
||||||
|
sourceDir,
|
||||||
|
platform: 'win32',
|
||||||
|
attempts: [
|
||||||
|
expect.objectContaining({ attempt: 1, code: 'ENAMETOOLONG' }),
|
||||||
|
expect.objectContaining({ attempt: 2, code: 'ENAMETOOLONG' }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs EPERM diagnostics with source and target paths', async () => {
|
||||||
|
setPlatform('win32');
|
||||||
|
mockHomedir.mockReturnValue('C:\\Users\\test');
|
||||||
|
|
||||||
|
const sourceDir = 'C:\\Program Files\\ClawX\\resources\\openclaw-plugins\\wecom';
|
||||||
|
const sourceManifestSuffix = 'Program Files\\ClawX\\resources\\openclaw-plugins\\wecom\\openclaw.plugin.json';
|
||||||
|
|
||||||
|
mockExistsSync.mockImplementation((input: string) => String(input).includes(sourceManifestSuffix));
|
||||||
|
mockCpSync.mockImplementation(() => {
|
||||||
|
const error = new Error('access denied') as NodeJS.ErrnoException;
|
||||||
|
error.code = 'EPERM';
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ensurePluginInstalled } = await import('@electron/utils/plugin-install');
|
||||||
|
const result = ensurePluginInstalled('wecom', [sourceDir], 'WeCom');
|
||||||
|
|
||||||
|
expect(result.installed).toBe(false);
|
||||||
|
expect(result.warning).toBe('Failed to install bundled WeCom plugin mirror');
|
||||||
|
|
||||||
|
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||||
|
'[plugin] Bundled mirror install failed for WeCom',
|
||||||
|
expect.objectContaining({
|
||||||
|
sourceDir,
|
||||||
|
targetDir: expect.stringContaining('.openclaw/extensions/wecom'),
|
||||||
|
platform: 'win32',
|
||||||
|
attempts: [
|
||||||
|
expect.objectContaining({ attempt: 1, code: 'EPERM' }),
|
||||||
|
expect.objectContaining({ attempt: 2, code: 'EPERM' }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user