diff --git a/.github/workflows/deno-smoke-test.yml b/.github/workflows/deno-smoke-test.yml new file mode 100644 index 0000000..0d201b5 --- /dev/null +++ b/.github/workflows/deno-smoke-test.yml @@ -0,0 +1,41 @@ +name: Deno Compatibility Smoke Test + +on: + push: + branches: [master, develop, '003-fix-deno-compat'] + pull_request: + branches: [master] + +jobs: + deno-smoke-test: + name: Deno Smoke Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + # 非阻断性:Deno 兼容层问题不应阻止合并,仅作可观测性指标 + continue-on-error: true + + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Run Deno smoke test + run: deno run --allow-read --allow-env --allow-sys test/deno/smoke-test.ts + timeout-minutes: 2 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..f51121c --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,33 @@ +name: Unit Tests + +on: + push: + branches: [master, develop, '003-fix-deno-compat'] + pull_request: + branches: [master] + +jobs: + test: + name: Node.js ${{ matrix.node-version }} + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: ['18', '20', '22'] + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Run unit tests + run: npm run test:unit diff --git a/.gitignore b/.gitignore index 812c848..d49dfbd 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,4 @@ docs/ CLAUDE.md AGENTS.md .claude +specs diff --git a/README-zh.md b/README-zh.md index c7f7a3d..4dd25a7 100644 --- a/README-zh.md +++ b/README-zh.md @@ -1197,6 +1197,42 @@ const config = { - [node-machine-id](https://github.com/automation-stack/node-machine-id) - 唯一机器标识 - [cpu-features](https://github.com/mscdex/cpu-features) - CPU 特性检测 +## 🦕 Deno 兼容性 + +`node-os-utils` 支持在 Deno 的 Node.js 兼容层(`deno run --node-modules-dir`)下运行。当 Deno 的兼容层无法执行原生 shell 命令(如 Windows 上的 PowerShell),库会**优雅降级**而非抛出异常: + +| 操作 | 降级行为 | +|------|---------| +| `cpu.info()` | 降级到 `os.cpus()` 基础数据 | +| `memory.info()` | 降级到 `os.totalmem()` / `os.freemem()` 基础数据 | +| `disk.info()`、`network.stats()`、`process.list()` | 返回 `success: false` 的 `MonitorResult` | + +首次降级时会输出一次性警告: + +``` +[node-os-utils] cpu degraded: Windows PowerShell/WMI unavailable, falling back to os.cpus() data. Some features may not be available in the current runtime environment. +``` + +**示例:** +```ts +// deno run --allow-read --allow-env --allow-sys app.ts +import { createOSUtils } from 'node-os-utils'; + +const utils = createOSUtils(); +const cpu = await utils.cpu.info(); +if (cpu.success) { + console.log(cpu.data.threads); // Deno 下也能正常工作 +} else { + console.log('CPU 信息不可用:', cpu.error.message); +} +``` + +## 特性标志同步说明 + +当监控器启用降级模式时,适配器的 `getSupportedFeatures()` 返回的特性标志可能仍显示 `true`, +但实际上某些功能已降级。建议在捕获到 `MonitorResult.success === false` 时以结果为准, +而非依赖特性标志进行预检查。 + ## ❓ 常见问题 **问:为什么某些功能在 Windows 上不工作?** diff --git a/README.md b/README.md index ec22834..4f70806 100644 --- a/README.md +++ b/README.md @@ -1195,6 +1195,36 @@ const config = { - [node-machine-id](https://github.com/automation-stack/node-machine-id) - Unique machine identification - [cpu-features](https://github.com/mscdex/cpu-features) - CPU feature detection +## 🦕 Deno Compatibility + +`node-os-utils` works under Deno's Node.js compatibility layer (`deno run --node-modules-dir`). When Deno's compat layer cannot execute native shell commands (e.g. PowerShell on Windows), the library **degrades gracefully** rather than throwing: + +| Operation | Degraded Behavior | +|-----------|-------------------| +| `cpu.info()` | Falls back to `os.cpus()` data | +| `memory.info()` | Falls back to `os.totalmem()` / `os.freemem()` | +| `disk.info()`, `network.stats()`, `process.list()` | Returns `MonitorResult` with `success: false` | + +A one-time warning is emitted on first degradation: + +``` +[node-os-utils] cpu degraded: Windows PowerShell/WMI unavailable, falling back to os.cpus() data. Some features may not be available in the current runtime environment. +``` + +**Example:** +```ts +// deno run --allow-read --allow-env --allow-sys app.ts +import { createOSUtils } from 'node-os-utils'; + +const utils = createOSUtils(); +const cpu = await utils.cpu.info(); +if (cpu.success) { + console.log(cpu.data.threads); // works even in Deno +} else { + console.log('CPU info not available:', cpu.error.message); +} +``` + ## ❓ FAQ **Q: Why does some functionality not work on Windows?** diff --git a/package.json b/package.json index 8d06cd6..0233ef3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "node-os-utils", - "version": "2.0.1", + "version": "2.0.2", "description": "Advanced cross-platform operating system monitoring utilities with TypeScript support", + "type": "commonjs", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", "exports": { diff --git a/src/adapters/linux-adapter.ts b/src/adapters/linux-adapter.ts index 2fa9bcc..32735e3 100644 --- a/src/adapters/linux-adapter.ts +++ b/src/adapters/linux-adapter.ts @@ -2,6 +2,7 @@ import os from 'os'; import { promises as fs } from 'fs'; import * as fsSync from 'fs'; import { BasePlatformAdapter } from '../core/platform-adapter'; +import { BaseMonitor } from '../core/base-monitor'; import { CommandExecutor } from '../utils/command-executor'; import { CommandResult, SupportedFeatures } from '../types/platform'; import { ExecuteOptions } from '../types/config'; @@ -118,8 +119,25 @@ export class LinuxAdapter extends BasePlatformAdapter { try { const cpuinfoContent = await this.readFile(this.paths.cpuinfo); return this.parseCPUInfo(cpuinfoContent); - } catch (error) { - throw this.createCommandError('getCPUInfo', error); + } catch { + // /proc/cpuinfo 不可访问(如 Deno 兼容层),降级到 os.cpus() 基础数据 + BaseMonitor.warnDegradation( + 'cpu.command_failed', + 'Linux /proc/cpuinfo unreadable, falling back to os.cpus() data' + ); + const cpus = os.cpus(); + const logicalCores = cpus.length || 1; + return { + model: cpus[0]?.model || 'Unknown', + manufacturer: 'Unknown', + architecture: os.arch(), + cores: Math.max(1, Math.floor(logicalCores / 2)), + threads: logicalCores, + baseFrequency: cpus[0]?.speed || 0, + maxFrequency: cpus[0]?.speed || 0, + cache: {}, + features: [] + }; } } @@ -186,8 +204,23 @@ export class LinuxAdapter extends BasePlatformAdapter { try { const meminfoContent = await this.readFile(this.paths.meminfo); return this.parseMemoryInfo(meminfoContent); - } catch (error) { - throw this.createCommandError('getMemoryInfo', error); + } catch { + // /proc/meminfo 不可访问(如 Deno 兼容层权限限制),降级到 os 模块基础数据 + BaseMonitor.warnDegradation( + 'memory.command_failed', + 'Linux /proc/meminfo unreadable, falling back to os.totalmem()/os.freemem() data' + ); + const total = os.totalmem(); + const free = os.freemem(); + return { + total: Math.round(total / 1024), + free: Math.round(free / 1024), + used: Math.round((total - free) / 1024), + shared: 0, + buffers: 0, + cached: 0, + available: Math.round(free / 1024) + }; } } diff --git a/src/adapters/macos-adapter.ts b/src/adapters/macos-adapter.ts index c3e6ca7..3bae5fa 100644 --- a/src/adapters/macos-adapter.ts +++ b/src/adapters/macos-adapter.ts @@ -1,6 +1,7 @@ import os from 'os'; import { BasePlatformAdapter } from '../core/platform-adapter'; +import { BaseMonitor } from '../core/base-monitor'; import { CommandExecutor } from '../utils/command-executor'; import { CommandResult, SupportedFeatures } from '../types/platform'; import { ExecuteOptions } from '../types/config'; @@ -80,7 +81,25 @@ export class MacOSAdapter extends BasePlatformAdapter { results[3].status === 'fulfilled' ? results[3].value : null ]); - return this.parseCPUInfo(brand?.stdout || '', cores?.stdout || '', threads?.stdout || '', freq?.stdout || ''); + const info = this.parseCPUInfo(brand?.stdout || '', cores?.stdout || '', threads?.stdout || '', freq?.stdout || ''); + + // 若 sysctl 命令全部失败,降级到 os.cpus() 基础数据 + if (!info.cores || !info.threads) { + BaseMonitor.warnDegradation( + 'cpu.command_failed', + 'macOS sysctl unavailable, falling back to os.cpus() data' + ); + const cpus = os.cpus(); + const logicalCores = cpus.length || 1; + info.model = cpus[0]?.model || info.model || 'Unknown'; + info.cores = Math.max(1, Math.floor(logicalCores / 2)); + info.threads = logicalCores; + info.baseFrequency = cpus[0]?.speed || 0; + info.maxFrequency = cpus[0]?.speed || 0; + info.architecture = os.arch(); + } + + return info; } catch (error) { throw this.createCommandError('getCPUInfo', error); } diff --git a/src/adapters/windows-adapter.ts b/src/adapters/windows-adapter.ts index c747695..d061329 100644 --- a/src/adapters/windows-adapter.ts +++ b/src/adapters/windows-adapter.ts @@ -2,6 +2,7 @@ import os from 'os'; import { promises as fs } from 'fs'; import { BasePlatformAdapter } from '../core/platform-adapter'; +import { BaseMonitor } from '../core/base-monitor'; import { CommandExecutor } from '../utils/command-executor'; import { CommandResult, SupportedFeatures } from '../types/platform'; import { ExecuteOptions } from '../types/config'; @@ -100,7 +101,11 @@ export class WindowsAdapter extends BasePlatformAdapter { maxFrequency = this.safeParseInt(info.MaxClockSpeed, maxFrequency); } } catch { - // 忽略 WMI 错误,使用 Node.js 信息 + // PowerShell/WMI 不可用(如 Deno 兼容层),降级到纯 os 模块数据 + BaseMonitor.warnDegradation( + 'cpu.command_failed', + 'Windows PowerShell/WMI unavailable, falling back to os.cpus() data' + ); } return { @@ -176,7 +181,11 @@ export class WindowsAdapter extends BasePlatformAdapter { }; } } catch { - // 兼容性问题时忽略,保留基础信息 + // PowerShell/WMI 不可用(如 Deno 兼容层),降级到纯 os 模块数据 + BaseMonitor.warnDegradation( + 'memory.command_failed', + 'Windows PowerShell/WMI unavailable, falling back to os.totalmem()/os.freemem() data' + ); } return memoryInfo; @@ -269,10 +278,14 @@ export class WindowsAdapter extends BasePlatformAdapter { })); } catch (error: any) { if (error instanceof MonitorError) { - return []; + throw error; } - - return []; + throw new MonitorError( + `getNetworkStats failed: ${error?.message || String(error)}`, + ErrorCode.COMMAND_FAILED, + 'win32', + { originalError: error } + ); } } diff --git a/src/core/base-monitor.ts b/src/core/base-monitor.ts index dc3e730..d2a1daf 100644 --- a/src/core/base-monitor.ts +++ b/src/core/base-monitor.ts @@ -85,6 +85,25 @@ export abstract class BaseMonitor extends EventEmitter { protected cache: CacheManager; protected subscriptions: Set = new Set(); + /** 已输出过降级警告的 key 集合(进程级别去重) */ + private static readonly warnedDegradations = new Set(); + + /** + * 输出首次降级警告(相同 key 仅警告一次) + * 供适配器和监控器在命令执行失败并降级时调用 + * @param key 降级标识,格式 "{monitor}.{type}",如 "cpu.command_failed" + * @param reason 人类可读的降级原因 + */ + static warnDegradation(key: string, reason: string): void { + if (!BaseMonitor.warnedDegradations.has(key)) { + BaseMonitor.warnedDegradations.add(key); + const monitor = key.split('.')[0]; + console.warn( + `[node-os-utils] ${monitor} degraded: ${reason}. Some features may not be available in the current runtime environment.` + ); + } + } + constructor( adapter: PlatformAdapter, config: MonitorConfig = {}, diff --git a/src/monitors/cpu-monitor.ts b/src/monitors/cpu-monitor.ts index 03ac155..f3b0960 100644 --- a/src/monitors/cpu-monitor.ts +++ b/src/monitors/cpu-monitor.ts @@ -502,7 +502,21 @@ export class CPUMonitor extends BaseMonitor { /** * 获取 CPU 平均信息(同步版本,向后兼容) - * @returns CPU 平均信息对象或 'not supported' + * Get CPU average info (sync, for backward compatibility) + * + * @deprecated + * 此方法在 Windows 及 Deno 等环境下 `usage` 固定返回 0,无法反映真实 CPU 使用率。 + * This method always returns `usage: 0` on Windows and Deno-like environments, + * and cannot reflect real-time CPU utilization. + * + * 请迁移到异步方法 / Please migrate to the async API: + * ```ts + * const result = await osUtils.cpu.usage(); + * if (result.success) console.log(result.data); // 实时 CPU 使用率(%) / real-time CPU usage (%) + * ``` + * 此方法将在未来版本中移除。/ This method will be removed in a future release. + * + * @returns CPU 平均信息对象或 'not supported' / CPU average info object or 'not supported' */ average(): any { try { diff --git a/src/utils/command-executor.ts b/src/utils/command-executor.ts index a0af865..825e925 100644 --- a/src/utils/command-executor.ts +++ b/src/utils/command-executor.ts @@ -54,6 +54,16 @@ export class CommandExecutor { } catch (error: any) { const executionTime = Date.now() - startTime; + // 处理非对象异常(如字符串、数字、null、undefined)——Deno 兼容层可能抛出这类值 + if (error === null || error === undefined || typeof error !== 'object') { + throw new MonitorError( + `Command failed: ${String(error)}`, + ErrorCode.COMMAND_FAILED, + this.platform, + { command, executionTime, rawError: String(error) } + ); + } + // 处理不同类型的错误 if (error.killed && error.signal) { // 超时或被杀死的进程 diff --git a/test/deno/smoke-test.ts b/test/deno/smoke-test.ts new file mode 100644 index 0000000..d49126b --- /dev/null +++ b/test/deno/smoke-test.ts @@ -0,0 +1,87 @@ +/** + * Deno 兼容性冒烟测试 + * + * 验证在 Deno 运行时(Node.js 兼容层)下,库的核心功能能在 5 秒内完成降级响应。 + * 运行方式:deno run --allow-read --allow-env --allow-sys test/deno/smoke-test.ts + * + * SC-005: 降级响应时间 < 5000ms + * + * 注意:dist 产物为 CommonJS 格式,需通过 createRequire 加载, + * 否则 Deno 默认将 .js 视为 ESM,导致 "exports is not defined" 错误。 + */ + +import { createRequire } from 'node:module'; + +const MAX_MS = 5000; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +(async () => { + const START = Date.now(); + + // 使用 createRequire 显式以 CommonJS 模式加载 dist 产物 + const require = createRequire(import.meta.url); + // @ts-ignore — 仅在 Deno 运行时下执行 + const { createOSUtils } = require('../../dist/src/index.js'); + + const utils = createOSUtils(); + + const [cpuResult, memResult, overviewResult] = await Promise.allSettled([ + utils.cpu.info(), + utils.memory.info(), + utils.overview() + ]); + + const elapsed = Date.now() - START; + + // CPU info 应成功(os.cpus() 降级路径) + if (cpuResult.status === 'fulfilled' && cpuResult.value.success) { + const data = cpuResult.value.data; + if (!data.threads || data.threads <= 0) { + console.error('[FAIL] CPU info: threads must be > 0, got', data.threads); + // @ts-ignore + Deno.exit(1); + } + console.log('[PASS] CPU info: threads =', data.threads); + } else if (cpuResult.status === 'rejected') { + console.error('[FAIL] CPU info threw unexpectedly:', cpuResult.reason); + // @ts-ignore + Deno.exit(1); + } else { + // 可接受的 MonitorResult failure(部分平台不支持) + console.log('[SKIP] CPU info not supported on this platform'); + } + + // Memory info 应成功(os.totalmem()/os.freemem() 降级路径或正常路径) + if (memResult.status === 'fulfilled' && memResult.value.success) { + const data = memResult.value.data; + if (!data.total || data.total <= 0) { + console.error('[FAIL] Memory info: total must be > 0, got', data.total); + // @ts-ignore + Deno.exit(1); + } + console.log('[PASS] Memory info: total =', data.total); + } else if (memResult.status === 'rejected') { + console.error('[FAIL] Memory info threw unexpectedly:', memResult.reason); + // @ts-ignore + Deno.exit(1); + } else { + console.log('[SKIP] Memory info not supported on this platform'); + } + + // overview 不应整体 crash + if (overviewResult.status === 'rejected') { + console.error('[FAIL] overview() threw:', overviewResult.reason); + // @ts-ignore + Deno.exit(1); + } + console.log('[PASS] overview() completed without crash'); + + // SC-005 性能约束 + if (elapsed > MAX_MS) { + console.error(`[FAIL] SC-005: elapsed ${elapsed}ms exceeds limit ${MAX_MS}ms`); + // @ts-ignore + Deno.exit(1); + } + console.log(`[PASS] SC-005: elapsed ${elapsed}ms < ${MAX_MS}ms`); + console.log('[DONE] Deno smoke test passed'); +})(); diff --git a/test/unit/adapters/linux-adapter.test.ts b/test/unit/adapters/linux-adapter.test.ts index a6f6661..db7f7fc 100644 --- a/test/unit/adapters/linux-adapter.test.ts +++ b/test/unit/adapters/linux-adapter.test.ts @@ -177,6 +177,22 @@ describe('LinuxAdapter 内部解析逻辑', () => { } }); + it('T022: getCPUInfo() 在读取 /proc/cpuinfo 失败时应降级到 os.cpus() 数据而非抛出异常', async () => { + const adapter = new LinuxAdapter(); + const internal = adapter as any; + + // stub readFile 使 /proc/cpuinfo 读取失败 + internal.readFile = async () => { + throw new MonitorError('/proc/cpuinfo 不可访问', ErrorCode.COMMAND_FAILED, 'linux'); + }; + + const result = await adapter.getCPUInfo(); + expect(result).to.be.an('object'); + expect(result.cores).to.be.a('number').and.to.be.greaterThan(0); + expect(result.threads).to.be.a('number').and.to.be.greaterThan(0); + expect(result.model).to.be.a('string').and.to.have.length.greaterThan(0); + }); + it('应在 ss 不可用时回退到 netstat 解析连接', async () => { const adapter = new LinuxAdapter(); const internal = adapter as any; diff --git a/test/unit/adapters/macos-adapter.test.ts b/test/unit/adapters/macos-adapter.test.ts index 5271c66..d62f6ba 100644 --- a/test/unit/adapters/macos-adapter.test.ts +++ b/test/unit/adapters/macos-adapter.test.ts @@ -185,6 +185,22 @@ describe('MacOSAdapter 内部解析逻辑', () => { expect(result).to.deep.equal({ gateway: '192.168.1.1', interface: 'en0' }); }); + it('T022: getCPUInfo() 在 sysctl 命令全部失败时应降级到 os.cpus() 数据而非抛出异常', async () => { + const adapter = new MacOSAdapter(); + const internal = adapter as any; + + // stub executeCommand 使所有 sysctl 命令失败 + internal.executeCommand = async () => { + throw new MonitorError('sysctl 不可用', ErrorCode.COMMAND_FAILED, 'darwin'); + }; + + const result = await adapter.getCPUInfo(); + expect(result).to.be.an('object'); + expect(result.cores).to.be.a('number').and.to.be.greaterThan(0); + expect(result.threads).to.be.a('number').and.to.be.greaterThan(0); + expect(result.model).to.be.a('string').and.to.have.length.greaterThan(0); + }); + it('在 top 失败时应回退到 iostat 获取 CPU 使用率', async () => { const adapter = new MacOSAdapter(); const internal = adapter as any; diff --git a/test/unit/adapters/windows-adapter.test.ts b/test/unit/adapters/windows-adapter.test.ts index bfdc5c4..f48204d 100644 --- a/test/unit/adapters/windows-adapter.test.ts +++ b/test/unit/adapters/windows-adapter.test.ts @@ -32,7 +32,7 @@ describe('WindowsAdapter 内部行为', () => { expect(disks[0].usagePercentage).to.equal(0); }); - it('在网络统计命令失败时应回退为空结果', async () => { + it('在网络统计命令失败时应抛出 MonitorError', async () => { const adapter = new WindowsAdapter(); const internal = adapter as any; @@ -40,7 +40,83 @@ describe('WindowsAdapter 内部行为', () => { throw new MonitorError('stats failed', ErrorCode.COMMAND_FAILED, 'win32'); }; - const stats = await adapter.getNetworkStats(); - expect(stats).to.deep.equal([]); + try { + await adapter.getNetworkStats(); + expect.fail('应该抛出 MonitorError'); + } catch (error: any) { + expect(error).to.be.instanceOf(MonitorError); + } + }); +}); + +describe('WindowsAdapter — Deno 兼容性降级', () => { + describe('T007: getCPUInfo() — PowerShell 失败时降级到 os.cpus()', () => { + it('应返回基于 os.cpus()/os.loadavg() 的有效非零 CPU 数据,而非抛出异常', async () => { + const adapter = new WindowsAdapter(); + const internal = adapter as any; + + // stub executePowerShell 使其抛出异常(模拟 Deno 中 PowerShell 不可用) + internal.executePowerShell = async () => { + throw new MonitorError('PowerShell 不可用', ErrorCode.COMMAND_FAILED, 'win32'); + }; + + // 不应抛出,应返回基于 os.cpus() 的数据 + const result = await adapter.getCPUInfo(); + + expect(result).to.be.an('object'); + expect(result.cores).to.be.a('number').and.to.be.greaterThan(0); + expect(result.model).to.be.a('string').and.to.have.length.greaterThan(0); + expect(result.threads).to.be.a('number').and.to.be.greaterThan(0); + }); + }); +}); + +describe('WindowsAdapter — Deno 兼容性降级 (T021: US2)', () => { + let adapter: WindowsAdapter; + let internal: any; + + beforeEach(() => { + adapter = new WindowsAdapter(); + internal = adapter as any; + // stub executePowerShell 使所有 PowerShell 命令失败 + internal.executePowerShell = async () => { + throw new MonitorError('PowerShell 不可用', ErrorCode.COMMAND_FAILED, 'win32'); + }; + }); + + it('T021: getMemoryInfo() 应返回基于 os.totalmem()/os.freemem() 的有效内存数据', async () => { + const result = await adapter.getMemoryInfo(); + expect(result).to.be.an('object'); + expect(result.total).to.be.a('number').and.to.be.greaterThan(0); + expect(result.available).to.be.a('number').and.to.be.greaterThan(0); + expect(result.used).to.be.a('number').and.to.be.at.least(0); + }); + + it('T021: getNetworkStats() 在命令失败时应抛出 MonitorError(COMMAND_FAILED),不返回静默空数组', async () => { + try { + await adapter.getNetworkStats(); + expect.fail('应该抛出 MonitorError'); + } catch (error: any) { + expect(error).to.be.instanceOf(MonitorError); + expect(error.code).to.equal(ErrorCode.COMMAND_FAILED); + } + }); + + it('T021: getDiskInfo() 在命令失败时应抛出 MonitorError,不静默返回数据', async () => { + try { + await adapter.getDiskInfo(); + expect.fail('应该抛出 MonitorError'); + } catch (error: any) { + expect(error).to.be.instanceOf(MonitorError); + } + }); + + it('T021: getProcessList() 在命令失败时应抛出 MonitorError(COMMAND_FAILED),不静默返回数据', async () => { + try { + await adapter.getProcessList(); + expect.fail('应该抛出 MonitorError'); + } catch (error: any) { + expect(error).to.be.instanceOf(MonitorError); + } }); }); diff --git a/test/unit/core/base-monitor.test.ts b/test/unit/core/base-monitor.test.ts index 7d9b9cf..ca41eed 100644 --- a/test/unit/core/base-monitor.test.ts +++ b/test/unit/core/base-monitor.test.ts @@ -183,3 +183,40 @@ describe('BaseMonitor', () => { expect(monitor.getCacheStats()?.size).to.equal(0); }); }); + +describe('BaseMonitor.warnDegradation — Deno 兼容性降级警告', () => { + let originalWarn: typeof console.warn; + let warnCalls: string[]; + + beforeEach(() => { + // 重置静态 Set(访问私有静态属性) + (BaseMonitor as any).warnedDegradations?.clear(); + originalWarn = console.warn; + warnCalls = []; + console.warn = (...args: any[]) => { warnCalls.push(args.join(' ')); }; + }); + + afterEach(() => { + console.warn = originalWarn; + (BaseMonitor as any).warnedDegradations?.clear(); + }); + + it('T024: 相同 key 首次调用时应触发 console.warn', () => { + (BaseMonitor as any).warnDegradation('cpu.command_failed', 'PowerShell WMI 不可用'); + expect(warnCalls).to.have.lengthOf(1); + expect(warnCalls[0]).to.include('[node-os-utils]'); + expect(warnCalls[0]).to.include('cpu'); + }); + + it('T024: 相同 key 第二次调用时不应重复触发 console.warn', () => { + (BaseMonitor as any).warnDegradation('cpu.command_failed', 'PowerShell WMI 不可用'); + (BaseMonitor as any).warnDegradation('cpu.command_failed', 'PowerShell WMI 不可用'); + expect(warnCalls).to.have.lengthOf(1); + }); + + it('T024: 不同 key 应各自独立触发一次 console.warn', () => { + (BaseMonitor as any).warnDegradation('cpu.command_failed', 'CPU 降级'); + (BaseMonitor as any).warnDegradation('memory.command_failed', 'Memory 降级'); + expect(warnCalls).to.have.lengthOf(2); + }); +}); diff --git a/test/unit/monitors/cpu-monitor.test.ts b/test/unit/monitors/cpu-monitor.test.ts index c51cb0c..3c231a4 100644 --- a/test/unit/monitors/cpu-monitor.test.ts +++ b/test/unit/monitors/cpu-monitor.test.ts @@ -178,3 +178,84 @@ describe('CPUMonitor', () => { expect(data).to.deep.equal({ load1: 0.5, load5: 0.8, load15: 1.1 }); }); }); + +describe('CPUMonitor — Deno 兼容性降级', () => { + function createFailingAdapter(): PlatformAdapter { + const base = { + getPlatform: () => 'win32', + isSupported: (_: string) => true, + executeCommand: async () => ({ stdout: '', stderr: '', exitCode: 0, platform: 'win32', executionTime: 0, command: '' }), + readFile: async () => '', + fileExists: async () => false, + getCPUInfo: async () => { + // 降级数据:PowerShell 失败,返回 os.cpus() 基础数据 + const cpus = require('os').cpus(); + return { + model: cpus[0]?.model || 'Unknown', + manufacturer: 'Unknown', + architecture: require('os').arch(), + cores: Math.max(1, Math.floor(cpus.length / 2)), + threads: cpus.length, + baseFrequency: cpus[0]?.speed || 0, + maxFrequency: cpus[0]?.speed || 0, + cache: {}, + features: [] + }; + }, + getCPUUsage: async () => { + // 降级数据:从 os.cpus() 计算 + const cpus = require('os').cpus(); + const total = cpus.reduce((sum: number, c: any) => sum + (Object.values(c.times) as number[]).reduce((a: number, b: number) => a + b, 0), 0); + const idle = cpus.reduce((sum: number, c: any) => sum + c.times.idle, 0); + const usage = total > 0 ? ((total - idle) / total) * 100 : 0; + return { overall: usage.toFixed(1), cores: [], user: '0', system: '0', idle: (100 - usage).toFixed(1), iowait: '0', irq: '0', softirq: '0' }; + }, + getMemoryInfo: async () => ({}), + getMemoryUsage: async () => ({}), + getDiskInfo: async () => ({}), + getDiskIO: async () => ({}), + getNetworkInterfaces: async () => ({}), + getNetworkStats: async () => ({}), + getProcesses: async () => ([]), + getProcessInfo: async () => ({}), + getSystemInfo: async () => ({}), + getSystemLoad: async () => ({ load1: require('os').loadavg()[0], load5: require('os').loadavg()[1], load15: require('os').loadavg()[2] }), + getDiskUsage: async () => ({}), + getDiskStats: async () => ({}), + getMounts: async () => ({}), + getFileSystems: async () => ({}), + getNetworkConnections: async () => ({}), + getDefaultGateway: async () => ({}), + getProcessList: async () => ([]), + killProcess: async () => true, + getProcessOpenFiles: async () => ([]), + getProcessEnvironment: async () => ({}), + getSystemUptime: async () => ({}), + getSystemUsers: async () => ([]), + getSystemServices: async () => ([]), + getSupportedFeatures: () => ({ + cpu: { info: true, usage: true, temperature: false, frequency: true, cache: false, perCore: false, cores: true }, + memory: { info: true, usage: true, swap: false, pressure: false, detailed: false, virtual: false }, + disk: { info: true, io: false, health: false, smart: false, filesystem: true, usage: true, stats: false, mounts: true, filesystems: true }, + network: { interfaces: true, stats: true, connections: false, bandwidth: false, gateway: false }, + process: { list: true, details: false, tree: false, monitor: false, info: false, kill: false, openFiles: false, environment: false }, + system: { info: true, load: true, uptime: true, users: false, services: false } + }) + }; + return base as unknown as PlatformAdapter; + } + + it('T020: average() 应在适配器返回降级数据时不因格式差异二次失败', async () => { + const monitor = new CPUMonitor(createFailingAdapter()); + const result = await monitor.loadAverage(); + expect(result.success).to.be.true; + }); + + it('T020: usage() 应在适配器返回降级数据时正确传递使用率', async () => { + const monitor = new CPUMonitor(createFailingAdapter()); + const result = await monitor.usage(); + expect(result.success).to.be.true; + if (!result.success) return; + expect(result.data).to.be.a('number'); + }); +}); diff --git a/test/unit/utils/command-executor.test.ts b/test/unit/utils/command-executor.test.ts index 5639358..b3ce150 100644 --- a/test/unit/utils/command-executor.test.ts +++ b/test/unit/utils/command-executor.test.ts @@ -5,6 +5,7 @@ import { expect } from 'chai' import { CommandExecutor } from '../../../src/utils/command-executor' +import { MonitorError, ErrorCode } from '../../../src/types/errors' describe('CommandExecutor Unit Tests', function() { let executor: CommandExecutor @@ -177,3 +178,43 @@ describe('CommandExecutor Unit Tests', function() { }) }) }) + +describe('CommandExecutor — Deno 兼容性:非标准异常处理', function() { + it('T023: 应将字符串类型的非标准异常统一捕获为 MonitorError(COMMAND_FAILED)', async function() { + const executor = new CommandExecutor('test-platform') + const internal = executor as any + + // 模拟 Deno 兼容层抛出非 Error 实例(字符串) + internal.executeWithTimeout = async () => { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw 'Deno compat layer: exec failed unexpectedly' + } + + try { + await executor.execute('echo test') + expect.fail('应该抛出错误') + } catch (error: any) { + expect(error).to.be.instanceOf(MonitorError) + expect(error.code).to.equal(ErrorCode.COMMAND_FAILED) + } + }) + + it('T023: 应将自定义对象类型的非标准异常统一捕获为 MonitorError(COMMAND_FAILED)', async function() { + const executor = new CommandExecutor('test-platform') + const internal = executor as any + + // 模拟 Deno 兼容层抛出普通对象(非 Error 实例) + internal.executeWithTimeout = async () => { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw { message: 'DENO_EXEC_ERROR', code: 'ERR_DENO_COMPAT' } + } + + try { + await executor.execute('echo test') + expect.fail('应该抛出错误') + } catch (error: any) { + expect(error).to.be.instanceOf(MonitorError) + expect(error.code).to.equal(ErrorCode.COMMAND_FAILED) + } + }) +}) diff --git a/tsconfig.json b/tsconfig.json index f6a3ebe..9651f49 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,7 @@ ], "exclude": [ "node_modules", - "dist" + "dist", + "test/deno" ] } \ No newline at end of file