profile-inspect 0.1.3

Analyze V8 CPU and heap profiles from Node.js/Chrome DevTools
Documentation
//! Tests for profile parsing

use profile_inspect::ir::{FrameCategory, FrameKind};
use profile_inspect::parser::{CpuProfileParser, HeapProfileParser};
use std::path::Path;

// ============================================================================
// CPU Profile Parser Tests
// ============================================================================

/// Minimal valid CPU profile JSON for testing
const MINIMAL_CPU_PROFILE: &str = r#"{
    "nodes": [
        {
            "id": 1,
            "callFrame": {
                "functionName": "(root)",
                "scriptId": "0",
                "url": "",
                "lineNumber": -1,
                "columnNumber": -1
            },
            "children": [2]
        },
        {
            "id": 2,
            "callFrame": {
                "functionName": "main",
                "scriptId": "1",
                "url": "/src/index.js",
                "lineNumber": 10,
                "columnNumber": 5
            },
            "children": [3],
            "hitCount": 5
        },
        {
            "id": 3,
            "callFrame": {
                "functionName": "compute",
                "scriptId": "2",
                "url": "/src/utils.js",
                "lineNumber": 20,
                "columnNumber": 1
            },
            "hitCount": 10
        }
    ],
    "samples": [2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3],
    "timeDeltas": [1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000],
    "startTime": 0,
    "endTime": 15000
}"#;

/// CPU profile with GC and native frames
const GC_CPU_PROFILE: &str = r#"{
    "nodes": [
        {
            "id": 1,
            "callFrame": {
                "functionName": "(root)",
                "scriptId": "0",
                "url": "",
                "lineNumber": -1,
                "columnNumber": -1
            },
            "children": [2, 3]
        },
        {
            "id": 2,
            "callFrame": {
                "functionName": "main",
                "scriptId": "1",
                "url": "/src/index.js",
                "lineNumber": 10,
                "columnNumber": 5
            },
            "hitCount": 5
        },
        {
            "id": 3,
            "callFrame": {
                "functionName": "(garbage collector)",
                "scriptId": "0",
                "url": "",
                "lineNumber": -1,
                "columnNumber": -1
            },
            "hitCount": 2
        }
    ],
    "samples": [2, 2, 3, 2, 3, 2, 2],
    "timeDeltas": [1000, 1000, 500, 1000, 500, 1000, 1000],
    "startTime": 0,
    "endTime": 6000
}"#;

/// CPU profile with node_modules dependency
const DEPS_CPU_PROFILE: &str = r#"{
    "nodes": [
        {
            "id": 1,
            "callFrame": {
                "functionName": "(root)",
                "scriptId": "0",
                "url": "",
                "lineNumber": -1,
                "columnNumber": -1
            },
            "children": [2]
        },
        {
            "id": 2,
            "callFrame": {
                "functionName": "main",
                "scriptId": "1",
                "url": "/project/src/index.js",
                "lineNumber": 1,
                "columnNumber": 1
            },
            "children": [3]
        },
        {
            "id": 3,
            "callFrame": {
                "functionName": "map",
                "scriptId": "2",
                "url": "/project/node_modules/lodash/map.js",
                "lineNumber": 100,
                "columnNumber": 5
            },
            "hitCount": 10
        }
    ],
    "samples": [3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
    "timeDeltas": [100, 100, 100, 100, 100, 100, 100, 100, 100, 100],
    "startTime": 0,
    "endTime": 1000
}"#;

#[test]
fn test_parse_minimal_cpu_profile() {
    let parser = CpuProfileParser::default();
    let result = parser.parse_str(MINIMAL_CPU_PROFILE, Some("test.cpuprofile".to_string()));

    assert!(result.is_ok(), "Should parse minimal profile");
    let profile = result.unwrap();

    // Check basic structure
    assert_eq!(profile.frames.len(), 3);
    assert!(!profile.samples.is_empty());
    assert_eq!(profile.source_file, Some("test.cpuprofile".to_string()));

    // Check duration
    assert!(profile.duration_us.is_some());
    assert_eq!(profile.duration_us.unwrap(), 15000);

    // Find the main function
    let main_frame = profile.frames.iter().find(|f| f.name == "main");
    assert!(main_frame.is_some());
    let main = main_frame.unwrap();
    assert_eq!(main.file, Some("/src/index.js".to_string()));
    assert_eq!(main.line, Some(11)); // 0-based -> 1-based
    assert_eq!(main.col, Some(6)); // 0-based -> 1-based
    assert_eq!(main.kind, FrameKind::Function);
    assert_eq!(main.category, FrameCategory::App);
}

#[test]
fn test_parse_gc_frames() {
    let parser = CpuProfileParser::default();
    let profile = parser.parse_str(GC_CPU_PROFILE, None).unwrap();

    // Find GC frame
    let gc_frame = profile
        .frames
        .iter()
        .find(|f| f.name == "(garbage collector)");
    assert!(gc_frame.is_some(), "Should have GC frame");

    let gc = gc_frame.unwrap();
    assert_eq!(gc.kind, FrameKind::GC);
    assert_eq!(gc.category, FrameCategory::V8Internal);
}

#[test]
fn test_parse_deps_classification() {
    let parser = CpuProfileParser::default();
    let profile = parser.parse_str(DEPS_CPU_PROFILE, None).unwrap();

    // Find the lodash map function
    let map_frame = profile.frames.iter().find(|f| f.name == "map");
    assert!(map_frame.is_some(), "Should have map frame");

    let map = map_frame.unwrap();
    assert_eq!(map.category, FrameCategory::Deps);
    assert!(map.file.as_ref().unwrap().contains("node_modules"));
}

#[test]
fn test_parse_stack_reconstruction() {
    let parser = CpuProfileParser::default();
    let profile = parser.parse_str(MINIMAL_CPU_PROFILE, None).unwrap();

    // Stacks should be properly constructed
    assert!(!profile.stacks.is_empty());

    // Find a stack that includes compute (should have root -> main -> compute)
    let compute_stack = profile.stacks.iter().find(|s| {
        s.frames
            .iter()
            .any(|&fid| profile.get_frame(fid).is_some_and(|f| f.name == "compute"))
    });
    assert!(compute_stack.is_some(), "Should have stack with compute");

    let stack = compute_stack.unwrap();
    // Root is at index 0, compute (leaf) at the end
    assert!(stack.frames.len() >= 2);
}

#[test]
fn test_parse_invalid_json() {
    let parser = CpuProfileParser::default();
    let result = parser.parse_str("not valid json", None);
    assert!(result.is_err());
}

#[test]
fn test_parse_empty_samples() {
    let json = r#"{
        "nodes": [
            {
                "id": 1,
                "callFrame": {
                    "functionName": "(root)",
                    "scriptId": "0",
                    "url": "",
                    "lineNumber": -1,
                    "columnNumber": -1
                }
            }
        ],
        "samples": [],
        "timeDeltas": [],
        "startTime": 0,
        "endTime": 0
    }"#;

    let parser = CpuProfileParser::default();
    let result = parser.parse_str(json, None);
    assert!(result.is_ok());

    let profile = result.unwrap();
    assert!(profile.samples.is_empty());
    assert_eq!(profile.total_weight(), 0);
}

// ============================================================================
// Heap Profile Parser Tests
// ============================================================================

/// Minimal valid heap profile JSON
const MINIMAL_HEAP_PROFILE: &str = r#"{
    "head": {
        "callFrame": {
            "functionName": "(root)",
            "scriptId": "0",
            "url": "",
            "lineNumber": -1,
            "columnNumber": -1
        },
        "selfSize": 0,
        "id": 1,
        "children": [
            {
                "callFrame": {
                    "functionName": "allocate",
                    "scriptId": "1",
                    "url": "/src/alloc.js",
                    "lineNumber": 10,
                    "columnNumber": 5
                },
                "selfSize": 1024,
                "id": 2,
                "children": []
            },
            {
                "callFrame": {
                    "functionName": "createBuffer",
                    "scriptId": "2",
                    "url": "/src/buffer.js",
                    "lineNumber": 20,
                    "columnNumber": 1
                },
                "selfSize": 4096,
                "id": 3,
                "children": []
            }
        ]
    }
}"#;

#[test]
fn test_parse_minimal_heap_profile() {
    let parser = HeapProfileParser::default();
    let result = parser.parse_str(MINIMAL_HEAP_PROFILE, Some("test.heapprofile".to_string()));

    assert!(result.is_ok(), "Should parse minimal heap profile");
    let profile = result.unwrap();

    // Check basic structure
    assert!(!profile.frames.is_empty());
    assert_eq!(profile.source_file, Some("test.heapprofile".to_string()));

    // No duration for heap profiles
    assert!(profile.duration_us.is_none());

    // Check allocation totals
    assert!(profile.total_weight() > 0);
}

#[test]
fn test_parse_heap_invalid_json() {
    let parser = HeapProfileParser::default();
    let result = parser.parse_str("not valid json", None);
    assert!(result.is_err());
}

// ============================================================================
// Real Profile File Tests (if available)
// ============================================================================

#[test]
fn test_parse_real_cpu_profile() {
    let path = Path::new("test-runner-profile");
    if !path.exists() {
        return; // Skip if test data not available
    }

    // Find the CPU profile file
    let cpu_file = std::fs::read_dir(path)
        .unwrap()
        .filter_map(Result::ok)
        .find(|e| e.path().extension().is_some_and(|ext| ext == "cpuprofile"));

    if let Some(entry) = cpu_file {
        let parser = CpuProfileParser::default();
        let result = parser.parse_file(&entry.path());

        assert!(
            result.is_ok(),
            "Should parse real CPU profile: {:?}",
            entry.path()
        );
        let profile = result.unwrap();

        // Verify basic structure
        assert!(!profile.frames.is_empty(), "Should have frames");
        assert!(!profile.samples.is_empty(), "Should have samples");
        assert!(profile.total_weight() > 0, "Should have non-zero weight");
    }
}

#[test]
fn test_parse_real_heap_profile() {
    let path = Path::new("test-runner-profile");
    if !path.exists() {
        return; // Skip if test data not available
    }

    // Find the heap profile file
    let heap_file = std::fs::read_dir(path)
        .unwrap()
        .filter_map(Result::ok)
        .find(|e| e.path().extension().is_some_and(|ext| ext == "heapprofile"));

    if let Some(entry) = heap_file {
        let parser = HeapProfileParser::default();
        let result = parser.parse_file(&entry.path());

        assert!(
            result.is_ok(),
            "Should parse real heap profile: {:?}",
            entry.path()
        );
        let profile = result.unwrap();

        assert!(!profile.frames.is_empty(), "Should have frames");
        assert!(
            profile.total_weight() > 0,
            "Should have non-zero allocation size"
        );
    }
}