Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
env:
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ name: Node CI

on: [push, pull_request]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
env:
Expand All @@ -11,7 +15,7 @@ jobs:
strategy:
matrix:
include:
# - node-version: '20'
- node-version: '20'
- node-version: '22'
- node-version: '24'
steps:
Expand All @@ -20,5 +24,9 @@ jobs:
- uses: ./.github/actions/prepare
with:
node-version: ${{ matrix.node-version }}
- name: Test (Node 20)
if: matrix.node-version == '20'
run: node scripts/run-tests-v20.js 'test/**/*.test.ts'
- name: Test
if: matrix.node-version != '20'
run: npm test
25 changes: 25 additions & 0 deletions scripts/run-tests-v20.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env node
import { globSync } from 'glob';
import { execFileSync } from 'node:child_process';

/**
* Test runner for Node.js v20
*
* Node.js v20's `node --test` command doesn't support glob patterns. This
* script uses the `glob` package to find test files and passes them to `node
* --test`.
*/

const pattern = process.argv[2] || 'test/**/*.test.ts';
const files = globSync(pattern);

if (files.length === 0) {
console.error(`No files matched pattern: ${pattern}`);
process.exit(1);
}

execFileSync(
process.execPath,
['--import', 'tsx', '--test', '--test-reporter=spec', ...files],
{ stdio: 'inherit' },
);
15 changes: 15 additions & 0 deletions src/services/profiler/profile-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,32 @@ interface RunOptions {
timeout?: number;
}

/**
* Node.js major version number
*/
const nodeMajorVersion = Number(process.versions.node.split('.')[0]);

/**
* Run a command with Node.js profiling enabled
*
* @param command - Command to run (e.g., "npm test")
* @param options - Execution options
* @returns Path to generated *.cpuprofile file
* @throws Error if running on Node.js 20 (--cpu-prof not allowed in
* NODE_OPTIONS)
*/
export const runWithProfiling = async (
command: string,
options: RunOptions = {},
): Promise<string> => {
// Node.js 20 blocks --cpu-prof in NODE_OPTIONS for security reasons
if (nodeMajorVersion === 20) {
throw new Error(
'CPU profiling requires Node.js 22 or later. ' +
'Node.js 20 blocks --cpu-prof in NODE_OPTIONS for security reasons.',
);
}

const cwd = options.cwd || process.cwd();

// Create profiles directory
Expand Down
215 changes: 134 additions & 81 deletions test/integration/profile-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,99 +1,152 @@
import assert from 'node:assert/strict';
import { execSync } from 'node:child_process';
import { execSync, spawnSync } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';

import { isNode20 } from '../util.js';
import { fixtures } from './fixture-paths.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// Node.js v20 doesn't allow --cpu-prof in NODE_OPTIONS for security reasons
// Tests are split into two groups: Node 20 tests and Node 22+ tests
const node20Test = isNode20 ? it : it.skip;
const node22PlusTest = isNode20 ? it.skip : it;

describe('Profile Command Integration', () => {
it('should profile a simple script and generate report', async () => {
const tmpDir = await mkdtemp(join(tmpdir(), 'modestbench-profile-'));

try {
// Create package.json
await writeFile(
join(tmpDir, 'package.json'),
JSON.stringify({ name: 'test-project' }),
);

// Run profile command using fixture
const output = execSync(
`node ${join(__dirname, '../../dist/cli/index.js')} analyze "node ${fixtures.profileHotFunction}"`,
{
cwd: tmpDir,
encoding: 'utf-8',
},
);

// Verify output contains expected sections
assert.ok(output.includes('Profile Analysis'), 'Should contain header');
assert.ok(
output.includes('Benchmark Candidates'),
'Should contain candidates section',
);
assert.ok(
output.includes('hotFunction') || output.includes('ticks'),
'Should contain function data',
);
} finally {
await rm(tmpDir, { force: true, recursive: true });
}
});
describe('on Node.js 20', () => {
node20Test(
'should show helpful error when profiling is unavailable',
async () => {
const tmpDir = await mkdtemp(join(tmpdir(), 'modestbench-profile-'));

try {
await writeFile(
join(tmpDir, 'package.json'),
JSON.stringify({ name: 'test-project' }),
);

// Use process.execPath to ensure we run with the same Node version
const result = spawnSync(
process.execPath,
[
join(__dirname, '../../dist/cli/index.js'),
'analyze',
`node ${fixtures.profileHotFunction}`,
],
{
cwd: tmpDir,
encoding: 'utf-8',
},
);

it('should support --group-by-file option', async () => {
const tmpDir = await mkdtemp(join(tmpdir(), 'modestbench-profile-'));

try {
await writeFile(
join(tmpDir, 'package.json'),
JSON.stringify({ name: 'test-project' }),
);

const output = execSync(
`node ${join(__dirname, '../../dist/cli/index.js')} analyze "node ${fixtures.profileMultiFunction}" --group-by-file`,
{
cwd: tmpDir,
encoding: 'utf-8',
},
);

assert.ok(
output.includes('Grouped by File'),
'Should contain grouped by file header',
);
} finally {
await rm(tmpDir, { force: true, recursive: true });
}
assert.notEqual(result.status, 0, 'Should exit with non-zero code');
assert.ok(
result.stderr.includes('Node.js 22 or later') ||
result.stderr.includes('CPU profiling requires'),
'Should show helpful error message about Node.js version',
);
} finally {
await rm(tmpDir, { force: true, recursive: true });
}
},
);
});

it('should respect --min-percent threshold', async () => {
const tmpDir = await mkdtemp(join(tmpdir(), 'modestbench-profile-'));

try {
await writeFile(
join(tmpDir, 'package.json'),
JSON.stringify({ name: 'test-project' }),
);

// Run with high threshold - should filter out most functions
const output = execSync(
`node ${join(__dirname, '../../dist/cli/index.js')} analyze "node ${fixtures.profileHotFunction}" --min-percent 50`,
{
cwd: tmpDir,
encoding: 'utf-8',
},
);

// Should still have header but fewer functions
assert.ok(output.includes('Profile Analysis'), 'Should have header');
} finally {
await rm(tmpDir, { force: true, recursive: true });
}
describe('on Node.js 22+', () => {
node22PlusTest(
'should profile a simple script and generate report',
async () => {
const tmpDir = await mkdtemp(join(tmpdir(), 'modestbench-profile-'));

try {
// Create package.json
await writeFile(
join(tmpDir, 'package.json'),
JSON.stringify({ name: 'test-project' }),
);

// Run profile command using fixture
const output = execSync(
`node ${join(__dirname, '../../dist/cli/index.js')} analyze "node ${fixtures.profileHotFunction}"`,
{
cwd: tmpDir,
encoding: 'utf-8',
},
);

// Verify output contains expected sections
assert.ok(
output.includes('Profile Analysis'),
'Should contain header',
);
assert.ok(
output.includes('Benchmark Candidates'),
'Should contain candidates section',
);
assert.ok(
output.includes('hotFunction') || output.includes('ticks'),
'Should contain function data',
);
} finally {
await rm(tmpDir, { force: true, recursive: true });
}
},
);

node22PlusTest('should support --group-by-file option', async () => {
const tmpDir = await mkdtemp(join(tmpdir(), 'modestbench-profile-'));

try {
await writeFile(
join(tmpDir, 'package.json'),
JSON.stringify({ name: 'test-project' }),
);

const output = execSync(
`node ${join(__dirname, '../../dist/cli/index.js')} analyze "node ${fixtures.profileMultiFunction}" --group-by-file`,
{
cwd: tmpDir,
encoding: 'utf-8',
},
);

assert.ok(
output.includes('Grouped by File'),
'Should contain grouped by file header',
);
} finally {
await rm(tmpDir, { force: true, recursive: true });
}
});

node22PlusTest('should respect --min-percent threshold', async () => {
const tmpDir = await mkdtemp(join(tmpdir(), 'modestbench-profile-'));

try {
await writeFile(
join(tmpDir, 'package.json'),
JSON.stringify({ name: 'test-project' }),
);

// Run with high threshold - should filter out most functions
const output = execSync(
`node ${join(__dirname, '../../dist/cli/index.js')} analyze "node ${fixtures.profileHotFunction}" --min-percent 50`,
{
cwd: tmpDir,
encoding: 'utf-8',
},
);

// Should still have header but fewer functions
assert.ok(output.includes('Profile Analysis'), 'Should have header');
} finally {
await rm(tmpDir, { force: true, recursive: true });
}
});
});
});
Loading