Files
NianToB/tests/unit/plugin-install.test.ts

300 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
mockExistsSync,
mockCpSync,
mockCopyFileSync,
mockStatSync,
mockMkdirSync,
mockRmSync,
mockReadFileSync,
mockWriteFileSync,
mockReaddirSync,
mockRealpathSync,
mockLoggerWarn,
mockLoggerInfo,
mockHomedir,
mockApp,
} = vi.hoisted(() => ({
mockExistsSync: vi.fn(),
mockCpSync: vi.fn(),
mockCopyFileSync: vi.fn(),
mockStatSync: vi.fn(() => ({ isDirectory: () => false })),
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,
copyFileSync: mockCopyFileSync,
statSync: mockStatSync,
mkdirSync: mockMkdirSync,
rmSync: mockRmSync,
readFileSync: mockReadFileSync,
writeFileSync: mockWriteFileSync,
readdirSync: mockReaddirSync,
realpathSync: mockRealpathSync,
};
return {
...mocked,
default: mocked,
};
});
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises');
return {
...actual,
readdir: vi.fn(),
stat: vi.fn(),
copyFile: vi.fn(),
mkdir: vi.fn(),
};
});
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) => {
const p = String(input);
return p.includes(sourceManifestSuffix) || p.includes('openclaw-plugins\\wecom\\index.js');
});
// On win32, cpSyncSafe uses _copyDirSyncRecursive (readdirSync) instead of cpSync.
// Simulate copy failure by making readdirSync throw during directory traversal.
mockReaddirSync.mockImplementation((_path: string, opts?: unknown) => {
if (opts && typeof opts === 'object' && 'withFileTypes' in (opts as Record<string, unknown>)) {
const error = new Error('path too long') as NodeJS.ErrnoException;
error.code = 'ENAMETOOLONG';
throw error;
}
return [];
});
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',
});
// On win32, cpSyncSafe walks the directory via readdirSync (with withFileTypes)
const copyAttempts = mockReaddirSync.mock.calls.filter(
(call: unknown[]) => {
const opts = call[1];
return opts && typeof opts === 'object' && 'withFileTypes' in (opts as Record<string, unknown>);
},
);
expect(copyAttempts).toHaveLength(2); // initial + 1 retry
const firstSrcPath = String(copyAttempts[0][0]);
expect(firstSrcPath.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) => {
const p = String(input);
return p.includes(sourceManifestSuffix) || p.includes('openclaw-plugins\\wecom\\index.js');
});
// On win32, cpSyncSafe uses _copyDirSyncRecursive (readdirSync) instead of cpSync.
mockReaddirSync.mockImplementation((_path: string, opts?: unknown) => {
if (opts && typeof opts === 'object' && 'withFileTypes' in (opts as Record<string, unknown>)) {
const error = new Error('access denied') as NodeJS.ErrnoException;
error.code = 'EPERM';
throw error;
}
return [];
});
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' }),
],
}),
);
});
it('recognizes openclaw.extensions JavaScript entries as loadable runtime entries', async () => {
const pluginDir = '/bundle/openclaw-lark';
mockReadFileSync.mockImplementation((input: string) => {
if (String(input).endsWith('/package.json')) {
return JSON.stringify({
main: './dist/index.js',
openclaw: { extensions: ['./index.js'] },
});
}
return '{}';
});
mockExistsSync.mockImplementation((input: string) => String(input) === `${pluginDir}/index.js`);
const { hasPluginRuntimeEntry } = await import('@electron/utils/plugin-install');
expect(hasPluginRuntimeEntry(pluginDir)).toBe(true);
});
it('reinstalls a same-version plugin when the installed copy is missing JS runtime output', async () => {
const sourceDir = '/bundle/openclaw-weixin';
const targetDir = '/home/test/.openclaw/extensions/openclaw-weixin';
let copied = false;
mockExistsSync.mockImplementation((input: string) => {
const p = String(input);
if (p === `${sourceDir}/openclaw.plugin.json`) return true;
if (p === `${targetDir}/openclaw.plugin.json`) return true;
if (p === `${sourceDir}/dist/index.js`) return true;
if (p === `${targetDir}/dist/index.js`) return copied;
return false;
});
mockReadFileSync.mockImplementation((input: string) => {
const p = String(input);
if (p === `${sourceDir}/package.json`) {
return JSON.stringify({
version: '2.1.10',
main: './dist/index.js',
openclaw: { extensions: ['./dist/index.js'] },
});
}
if (p === `${targetDir}/package.json`) {
return copied
? JSON.stringify({
version: '2.1.10',
main: './dist/index.js',
openclaw: { extensions: ['./dist/index.js'] },
})
: JSON.stringify({
version: '2.1.10',
openclaw: { extensions: ['./index.ts'] },
});
}
if (p === `${targetDir}/openclaw.plugin.json`) {
return JSON.stringify({ id: 'openclaw-weixin' });
}
return '{}';
});
mockCpSync.mockImplementation(() => {
copied = true;
});
const { ensurePluginInstalled } = await import('@electron/utils/plugin-install');
const result = ensurePluginInstalled('openclaw-weixin', [sourceDir], 'WeChat');
expect(result).toEqual({ installed: true });
expect(mockRmSync).toHaveBeenCalledWith(targetDir, { recursive: true, force: true });
expect(mockCpSync).toHaveBeenCalledWith(sourceDir, targetDir, { recursive: true, dereference: true });
expect(mockLoggerInfo).toHaveBeenCalledWith(
'[plugin] Reinstalling WeChat plugin: installed copy is missing a loadable runtime entry',
);
});
});