import { readFile } from 'node:fs/promises'; import os from 'node:os'; import { getCgroupsVersion } from '@crawlee/utils'; import { getContainerCpuUsage, getCpuPeriod, getCpuQuota, getCurrentCpuTicks, getCurrentCpuTicksV2, getSystemCpuUsage, sampleCpuUsage, } from '../../packages/utils/src/internals/systemInfoV2/cpu-info'; vitest.mock('@crawlee/utils/src/internals/general', async (importActual) => { const original: typeof import('@crawlee/utils') = await importActual(); return { ...original, getCgroupsVersion: vitest.fn(), }; }); vitest.mock('node:fs/promises', async (importActual) => { const originalFs: typeof import('node:fs/promises') = await importActual(); return { ...originalFs, readFile: vitest.fn(originalFs.readFile), }; }); const getCgroupsVersionSpy = vitest.mocked(getCgroupsVersion); const readFileSpy = vitest.mocked(readFile); describe('getCurrentCpuTicks()', () => { test('calculates cpu load based on os.cpus', () => { // For two CPUs, we simulate: // CPU 1: { user: 100, nice: 0, sys: 50, idle: 50, irq: 0 } → total = 200, idle = 50 // CPU 2: { user: 200, nice: 0, sys: 100, idle: 100, irq: 0 } → total = 400, idle = 100 // Combined: total = 600, idle = 150 → load = 1 - (150/600) = 0.75 const cpusMock = vitest .spyOn(os, 'cpus') .mockReturnValue([ { times: { user: 100, nice: 0, sys: 50, idle: 50, irq: 0 } }, { times: { user: 200, nice: 0, sys: 100, idle: 100, irq: 0 } }, ] as os.CpuInfo[]); const load = getCurrentCpuTicks(); expect(load).toBeCloseTo(0.75); cpusMock.mockRestore(); }); }); describe('getCpuQuota()', () => { test('V1: returns null for unlimited quota (-1)', async () => { readFileSpy.mockImplementation(async (path) => { if (path === '/sys/fs/cgroup/cpu/cpu.cfs_quota_us') { return Promise.resolve('-1\n'); } throw new Error(`Unexpected path ${path}`); }); const quota = await getCpuQuota('V1'); expect(quota).toBeNull(); }); test('V1: returns numeric quota', async () => { readFileSpy.mockImplementation(async (path) => { if (path === '/sys/fs/cgroup/cpu/cpu.cfs_quota_us') { return Promise.resolve('200000\n'); } throw new Error(`Unexpected path ${path}`); }); const quota = await getCpuQuota('V1'); expect(quota).toEqual(200000); }); test('V2: returns null for unlimited quota ("max")', async () => { readFileSpy.mockImplementation(async (path) => { if (path === '/sys/fs/cgroup/cpu.max') { return Promise.resolve('max 100000'); } throw new Error(`Unexpected path ${path}`); }); const quota = await getCpuQuota('V2'); expect(quota).toBeNull(); }); test('V2: returns numeric quota', async () => { readFileSpy.mockImplementation(async (path) => { if (path === '/sys/fs/cgroup/cpu.max') { return Promise.resolve('200000 100000'); } throw new Error(`Unexpected path ${path}`); }); const quota = await getCpuQuota('V2'); expect(quota).toBe(200000); }); }); describe('getCpuPeriod()', () => { test('V1: returns period', async () => { readFileSpy.mockImplementation(async (path) => { if (path === '/sys/fs/cgroup/cpu/cpu.cfs_period_us') { return Promise.resolve('100000\n'); } throw new Error(`Unexpected path ${path}`); }); const period = await getCpuPeriod('V1'); expect(period).toBe(100000); }); test('V2: returns period from the second field', async () => { readFileSpy.mockImplementation(async (path) => { if (path === '/sys/fs/cgroup/cpu.max') { return Promise.resolve('200000 100000'); } throw new Error(`Unexpected path ${path}`); }); const period = await getCpuPeriod('V2'); expect(period).toBe(100000); }); }); describe('getContainerCpuUsage()', () => { test('V1: returns container cpu usage', async () => { readFileSpy.mockImplementation(async (path) => { if (path === '/sys/fs/cgroup/cpuacct/cpuacct.usage') { return Promise.resolve('123456789\n'); } throw new Error(`Unexpected path ${path}`); }); const usage = await getContainerCpuUsage('V1'); expect(usage).toBe(123456789); }); test('V2: parses usage_usec and converts to nanoseconds', async () => { // Simulate a file with a line containing "usage_usec" const fileContent = 'other 0\nusage_usec 5000\nmoreinfo 0'; readFileSpy.mockImplementation(async (path) => { if (path === '/sys/fs/cgroup/cpu.stat') { return Promise.resolve(fileContent); } throw new Error(`Unexpected path ${path}`); }); const usage = await getContainerCpuUsage('V2'); expect(usage).toBe(5000 * 1000); }); }); describe('getSystemCpuUsage()', () => { test('parses /proc/stat correctly', async () => { // Provide a fake /proc/stat file with a proper "cpu" line. // Example: "cpu 100 0 50 150 0 0 0" → total ticks = 300, // so systemUsage = (300 * 1e9) / 100 = 3000000000. const statContent = 'cpu 100 0 50 150 0 0 0\notherline'; readFileSpy.mockImplementation(async (path) => { if (path === '/proc/stat') { return Promise.resolve(statContent); } throw new Error(`Unexpected path ${path}`); }); const usage = await getSystemCpuUsage(); expect(usage).toBeCloseTo(3000000000); }); test('throws error if no cpu line is found', async () => { readFileSpy.mockImplementation(async (path) => { if (path === '/proc/stat') { return Promise.resolve('no cpu info'); } throw new Error(`Unexpected path ${path}`); }); await expect(getSystemCpuUsage()).rejects.toThrow('no cpu line'); }); }); describe('sampleCpuUsage()', () => { test('returns a valid CpuSample for V1', async () => { // For V1, getContainerCpuUsage reads from its stat file and // getSystemCpuUsage reads from /proc/stat. readFileSpy.mockImplementation(async (path) => { if (path === '/sys/fs/cgroup/cpuacct/cpuacct.usage') { return Promise.resolve('1000000\n'); } if (path === '/proc/stat') { return Promise.resolve('cpu 100 0 50 150 0 0 0\n'); } throw new Error(`Unexpected path ${path}`); }); const sample = await sampleCpuUsage('V1'); expect(sample).toEqual({ containerUsage: 1000000, systemUsage: 3000000000, // as computed above. }); }); test('returns a valid CpuSample for V2', async () => { // For V1, getContainerCpuUsage reads from its stat file and // getSystemCpuUsage reads from /proc/stat. readFileSpy.mockImplementation(async (path) => { if (path === '/sys/fs/cgroup/cpu.stat') { return Promise.resolve('other 0\nusage_usec 1000\nmoreinfo 0'); } if (path === '/proc/stat') { return Promise.resolve('cpu 100 0 50 150 0 0 0\n'); } throw new Error(`Unexpected path ${path}`); }); const sample = await sampleCpuUsage('V2'); expect(sample).toEqual({ containerUsage: 1000000, systemUsage: 3000000000, // as computed above. }); }); }); describe('getCpuInfo()', () => { test('returns bare metal cpu ticks in AWS Lambda environment', async () => { // Simulate AWS Lambda by setting the env variable. process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE = '128'; const cpusMock = vitest .spyOn(os, 'cpus') .mockReturnValue([{ times: { user: 100, nice: 0, sys: 50, idle: 50, irq: 0 } }] as os.CpuInfo[]); const load = await getCurrentCpuTicksV2(); // For one CPU: total = 100+0+50+50 = 200, idle = 50 → load = 0.75. expect(load).toBeCloseTo(0.75); cpusMock.mockRestore(); delete process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE; }); test('returns bare metal cpu ticks when not containerized', async () => { const cpusMock = vitest .spyOn(os, 'cpus') .mockReturnValue([{ times: { user: 200, nice: 0, sys: 100, idle: 100, irq: 0 } }] as os.CpuInfo[]); const load = await getCurrentCpuTicksV2(); // For one CPU: total = 200+0+100+100 = 400, idle = 100 → load = 0.75. expect(load).toBeCloseTo(0.75); cpusMock.mockRestore(); }); test('returns container-aware cpu usage when containerized with quota set', async () => { getCgroupsVersionSpy.mockResolvedValueOnce('V1'); // For V1: // - getCpuQuota: return 200000 → quota = 200000. // - getCpuPeriod: return 100000 → period = 100000. // cpuAllowance = quota/period = 2. // - sampleCpuUsage: returns container usage and system usage. // For container usage, simulate "1000000000\n" // For system usage, simulate a /proc/stat line with 300 total ticks, // so systemUsage = (300 * 1e9) / 100 = 3000000000. readFileSpy .mockResolvedValueOnce('200000\n') // for getCpuQuota .mockResolvedValueOnce('100000\n') // for getCpuPeriod .mockResolvedValueOnce('1000000000\n') // for getContainerCpuUsage .mockResolvedValueOnce('cpu 300 0 0 0 0 0 0\n'); // for getSystemCpuUsage // Simulate a system with 2 CPUs. const cpusMock = vitest .spyOn(os, 'cpus') .mockReturnValue([ { times: { user: 200, nice: 0, sys: 100, idle: 100, irq: 0 } }, { times: { user: 200, nice: 0, sys: 100, idle: 100, irq: 0 } }, ] as os.CpuInfo[]); // Initially, previousSample is { containerUsage: 0, systemUsage: 0 }. const result = await getCurrentCpuTicksV2(true); // Calculation: // containerDelta = 1000000, systemDelta = 3000000000, numCpus = 2, cpuAllowance = 2. // So: ((1000000000 / 3000000000) * 2) / 2 ≈ 0.3333 expect(result).toBeCloseTo(0.3333, 4); cpusMock.mockRestore(); }); test('returns bare metal cpu ticks when containerized but no cgroup quota', async () => { getCgroupsVersionSpy.mockResolvedValueOnce('V1'); // For V1, a quota of "-1" signals no limit → quota becomes null. readFileSpy.mockResolvedValueOnce('-1\n'); // getCpuQuota returns null // In this branch, getCpuInfo falls back to getCurrentCpuTicks. const cpusMock = vitest .spyOn(os, 'cpus') .mockReturnValue([{ times: { user: 300, nice: 0, sys: 150, idle: 150, irq: 0 } }] as os.CpuInfo[]); const result = await getCurrentCpuTicksV2(true); // For one CPU: total = 300+0+150+150 = 600, idle = 150 → load = 0.75. expect(result).toBeCloseTo(0.75); cpusMock.mockRestore(); }); });