diff --git a/cspell.json b/cspell.json index 6ff0d7f..629754d 100644 --- a/cspell.json +++ b/cspell.json @@ -46,6 +46,7 @@ "transcoding", "transpiled", "typedoc", + "Unregisters", "untracked", "videostateupdate", "VITE", diff --git a/src/cpu-info.ts b/src/cpu-info.ts index 042dd3f..aed89b2 100644 --- a/src/cpu-info.ts +++ b/src/cpu-info.ts @@ -1,9 +1,11 @@ /** * A class that provides information about the CPU. + * @deprecated Use `SystemInfo` instead. */ export class CpuInfo { /** * Gets the number of logical CPU cores. + * @deprecated Use `SystemInfo.getNumLogicalCores()` instead. * * @returns The number of logical CPU cores, or undefined if not available. */ diff --git a/src/index.ts b/src/index.ts index e5fc96d..ac1bd5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from './browser-info'; -export * from './web-capabilities'; export * from './cpu-info'; +export * from './system-info'; +export * from './web-capabilities'; diff --git a/src/system-info.spec.ts b/src/system-info.spec.ts new file mode 100644 index 0000000..09aaf33 --- /dev/null +++ b/src/system-info.spec.ts @@ -0,0 +1,194 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let pressureObserverCallback: any; + +/** + * Mock implementation of PressureObserver to simulate CPU pressure states. + */ +class MockPressureObserver { + /** + * Mock implementation of PressureObserver to simulate CPU pressure states. + * + * @param callback - The callback to be called with pressure records. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(callback: (list: any) => void) { + pressureObserverCallback = callback; + } + + /** + * Attaches the observer to a source to observe state changes. + */ + // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-empty-function + observe() {} +} + +Object.defineProperty(window, 'PressureObserver', { + writable: true, + configurable: true, + value: MockPressureObserver, +}); + +// Import the SystemInfo class after defining the PressureObserver mock +// This ensures that the mock is available when SystemInfo is imported. +// eslint-disable-next-line import/first +import { SystemInfo } from './system-info'; + +describe('SystemInfo', () => { + describe('isPressureObserverSupported', () => { + it('should return true when PressureObserver is supported', () => { + expect.hasAssertions(); + + Object.defineProperty(window, 'PressureObserver', { + writable: true, + configurable: true, + value: {}, + }); + + expect(SystemInfo.isPressureObserverSupported()).toBe(true); + }); + + it('should return false when PressureObserver is not supported', () => { + expect.hasAssertions(); + + // Ensure that PressureObserver is not defined + delete window.PressureObserver; + + expect(SystemInfo.isPressureObserverSupported()).toBe(false); + }); + }); + + describe('getCpuPressure', () => { + describe('when PressureObserver is supported', () => { + beforeEach(() => { + Object.defineProperty(window, 'PressureObserver', { + writable: true, + configurable: true, + value: MockPressureObserver, + }); + }); + + it('should return undefined when information is not available', () => { + expect.hasAssertions(); + + expect(SystemInfo.getCpuPressure()).toBeUndefined(); + }); + + ['nominal', 'fair', 'serious', 'critical'].forEach((state) => { + it(`should return the last CPU pressure state as ${state}`, () => { + expect.hasAssertions(); + + pressureObserverCallback([{ source: 'cpu', state }]); + + expect(SystemInfo.getCpuPressure()).toBe(state); + }); + }); + }); + + describe('when PressureObserver is not supported', () => { + beforeEach(() => { + // Ensure that PressureObserver is not defined + delete window.PressureObserver; + }); + + it('should return undefined', () => { + expect.hasAssertions(); + + expect(SystemInfo.getCpuPressure()).toBeUndefined(); + }); + }); + }); + + describe('onCpuPressureChange', () => { + describe('when PressureObserver is supported', () => { + beforeEach(() => { + Object.defineProperty(window, 'PressureObserver', { + writable: true, + configurable: true, + value: MockPressureObserver, + }); + }); + + it('should call the callback when CPU pressure state changes', () => { + expect.hasAssertions(); + + const callback = jest.fn(); + SystemInfo.onCpuPressureChange(callback); + + pressureObserverCallback([{ source: 'cpu', state: 'nominal' }]); + expect(callback).toHaveBeenCalledWith('nominal'); + }); + + it('should not call the callback if the CPU pressure state does not change', () => { + expect.hasAssertions(); + + const callback = jest.fn(); + SystemInfo.onCpuPressureChange(callback); + + expect(callback).toHaveBeenCalledWith('nominal'); + + // Call with the same state + pressureObserverCallback([{ source: 'cpu', state: 'nominal' }]); + expect(callback).toHaveBeenCalledTimes(1); // Should not be called again + + // Call with a different state + pressureObserverCallback([{ source: 'cpu', state: 'fair' }]); + expect(callback).toHaveBeenCalledWith('fair'); + }); + + it('should not emit if callback was deregistered', () => { + expect.hasAssertions(); + + const callback = jest.fn(); + SystemInfo.onCpuPressureChange(callback); + expect(callback).toHaveBeenCalledTimes(1); + + // Call with the same state + pressureObserverCallback([{ source: 'cpu', state: 'nominal' }]); + expect(callback).toHaveBeenCalledWith('nominal'); + expect(callback).toHaveBeenCalledTimes(2); + + // Deregister the callback + SystemInfo.offCpuPressureChange(callback); + + // Call with a different state + pressureObserverCallback([{ source: 'cpu', state: 'fair' }]); + expect(callback).toHaveBeenCalledTimes(2); // Should not be called again + }); + }); + + describe('when PressureObserver is not supported', () => { + beforeEach(() => { + // Ensure that PressureObserver is not defined + delete window.PressureObserver; + }); + + it('should not attach listener', () => { + expect.hasAssertions(); + + const callback = jest.fn(); + + SystemInfo.onCpuPressureChange(callback); + + expect(callback).toHaveBeenCalledTimes(0); + }); + }); + }); + + describe('getNumLogicalCores', () => { + it('should return the number of logical CPU cores when the information is available', () => { + expect.assertions(1); + + jest.spyOn(Navigator.prototype, 'hardwareConcurrency', 'get').mockReturnValue(1); + + expect(SystemInfo.getNumLogicalCores()).toBe(1); + }); + + it('should return undefined when the logical CPU cores information is not available', () => { + expect.assertions(1); + + jest.spyOn(Navigator.prototype, 'hardwareConcurrency', 'get').mockImplementation(); + + expect(SystemInfo.getNumLogicalCores()).toBeUndefined(); + }); + }); +}); diff --git a/src/system-info.ts b/src/system-info.ts new file mode 100644 index 0000000..6ddcc14 --- /dev/null +++ b/src/system-info.ts @@ -0,0 +1,185 @@ +/* eslint-disable max-classes-per-file */ + +import { EventEmitter } from 'events'; + +// PressureObserver is a W3C standard API that provides information about the system's pressure state. +// It allows web applications to observe the pressure state of the system, which can help them adapt +// their behavior based on the system's performance and resource availability. +// Pressure API is supported in modern browsers, but it is not available in all environments. +// Currently, it's not supported by TypeScript, so we need to define the types ourselves. +// NOTE: Consider removing this once TypeScript supports PressureObserver. + +// https://w3c.github.io/compute-pressure/#pressure-states +// ⚪ Nominal: Work is minimal and the system is running on lower clock speed to preserve power. +// +// 🟢 Fair: The system is doing fine, everything is smooth and it can take on additional work without issues. +// +// 🟡 Serious: There is some serious pressure on the system, but it is sustainable and the system is doing well, +// but it is getting close to its limits: +// +// Clock speed (depending on AC or DC power) is consistently high +// Thermals are high but system can handle it +// At this point, if you add more work the system may move into critical. +// +// 🔴 Critical: The system is now about to reach its limits, but it hasn’t reached the limit yet. +// Critical doesn’t mean that the system is being actively throttled, +// but this state is not sustainable for the long run and might result in throttling if the workload remains the same. +// This signal is the last call for the web application to lighten its workload. + +export type PressureSource = 'cpu'; + +export type PressureState = 'nominal' | 'fair' | 'serious' | 'critical'; + +interface PressureRecord { + source: string; + state: PressureState; + time: number; +} + +interface PressureObserver { + observe(source: PressureSource): Promise; + unobserve(source: PressureSource): void; + disconnect(): void; + takeRecords(): PressureRecord[]; +} + +declare global { + interface Window { + PressureObserver?: PressureObserver; + } +} + +export enum SystemInfoEvents { + CpuPressureStateChange = 'cpu-pressure-state-change', +} + +/** + * PressureObserverHelper class to wrap the PressureObserver API + * and provide a simple interface for observing pressure state changes. + */ +class PressureObserverHelper extends EventEmitter { + private observer?: PressureObserver; + + private lastCpuPressure?: PressureState = undefined; + + /** + * Creates an instance of PressureObserverHelper. + */ + constructor() { + super(); + + if (PressureObserverHelper.isPressureObserverSupported()) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.observer = new PressureObserver(this.handleStateChange.bind(this)); + if (this.observer) { + this.observer.observe('cpu'); + } + } + } + + /** + * Handles updates from the PressureObserver. + * + * @param records - The records from the PressureObserver. + */ + private handleStateChange(records: PressureRecord[]) { + records.forEach((record: PressureRecord) => { + if (record.source === 'cpu' && record.state !== this.lastCpuPressure) { + this.lastCpuPressure = record.state; + + this.emit(SystemInfoEvents.CpuPressureStateChange, record.state); + } + }); + } + + /** + * Gets the current CPU pressure state. + * + * @returns The current CPU pressure state, or undefined if API is not supported. + */ + getCpuPressure(): PressureState | undefined { + return this.lastCpuPressure; + } + + /** + * Checks if the Compute Pressure API is supported in the current environment. + * + * @returns True if the Compute Pressure API is supported, false otherwise. + */ + static isPressureObserverSupported(): boolean { + return 'PressureObserver' in window; + } +} + +const pressureObserverHelper = new PressureObserverHelper(); + +/** + * SystemInfo class to provide static methods for system information. + */ +export class SystemInfo { + /** + * Checks if the Compute Pressure API is supported in the current environment. + * + * @returns True if the Compute Pressure API is supported, false otherwise. + */ + static isPressureObserverSupported(): boolean { + return PressureObserverHelper.isPressureObserverSupported(); + } + + /** + * Gets the current CPU pressure state. + * + * @returns The current CPU pressure state, or undefined if API is not supported. + */ + static getCpuPressure(): PressureState | undefined { + if (!SystemInfo.isPressureObserverSupported()) { + return undefined; + } + + return pressureObserverHelper.getCpuPressure(); + } + + /** + * Registers a callback to be called when the CPU pressure state changes. + * + * @param callback - Callback to be called when the CPU pressure state changes. + */ + static onCpuPressureChange(callback: (state: PressureState) => void): void { + if (!SystemInfo.isPressureObserverSupported()) { + return; + } + + pressureObserverHelper.on(SystemInfoEvents.CpuPressureStateChange, callback); + + // There might be possibility that the CPU pressure state has already changed + // before the callback was registered, so we check the current state + // and call the callback immediately if the state is available. + const state = SystemInfo.getCpuPressure(); + if (state !== undefined) { + callback(state); + } + } + + /** + * Unregisters a callback that was registered to be called when the CPU pressure state changes. + * + * @param callback - Callback to be called when the CPU pressure state changes. + */ + static offCpuPressureChange(callback: (state: PressureState) => void): void { + if (!SystemInfo.isPressureObserverSupported()) { + return; + } + + pressureObserverHelper.off(SystemInfoEvents.CpuPressureStateChange, callback); + } + + /** + * Gets the number of logical CPU cores. + * + * @returns The number of logical CPU cores, or undefined if not available. + */ + static getNumLogicalCores(): number | undefined { + return navigator.hardwareConcurrency; + } +}