Implement V120 (#11)
This commit is contained in:
@@ -0,0 +1,639 @@
|
||||
# Implementation Plan for v1.2.0
|
||||
|
||||
**Target Release**: 1.2.0
|
||||
**Status**: Planning Phase
|
||||
**Last Updated**: 2026-05-01
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the implementation plan for version 1.2.0 of the PlatformIO VSCode Debug extension. The four major features planned are:
|
||||
|
||||
1. Enhanced peripheral viewer with SVD file support
|
||||
2. Improved memory editor with data visualization
|
||||
3. RTOS thread awareness
|
||||
4. Better error messages and diagnostics
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Enhanced Peripheral Viewer with SVD File Support
|
||||
|
||||
### Current State
|
||||
- Basic SVD parsing exists at `src/frontend/peripheral.ts`
|
||||
- Uses `fast-xml-parser` for XML processing
|
||||
- Supports peripherals, clusters, registers, and fields
|
||||
- Current value display with hex/binary/decimal formatting
|
||||
|
||||
### Planned Enhancements
|
||||
|
||||
| Priority | Task | Effort | Files to Modify |
|
||||
|----------|------|--------|-----------------|
|
||||
| High | Add SVD file search/discovery from common paths | 2d | `peripheral.ts`, `extension.ts` |
|
||||
| High | Implement peripheral search/filter UI | 2d | `peripheral.ts` |
|
||||
| High | Add register change highlighting (diff from reset) | 3d | `peripheral.ts` |
|
||||
| Medium | Support for SVD `<derivedFrom>` attribute | 2d | `peripheral.ts` |
|
||||
| Medium | Add peripheral register bit-field tooltip documentation | 1d | `peripheral.ts` |
|
||||
| Low | Export peripheral register map to JSON/Markdown | 2d | New file |
|
||||
|
||||
### Technical Details
|
||||
|
||||
#### SVD File Discovery
|
||||
```typescript
|
||||
// New method in PeripheralTreeProvider
|
||||
private findSVDFile(deviceName: string): string | undefined {
|
||||
const searchPaths = [
|
||||
`${workspaceRoot}/.vscode/*.svd`,
|
||||
`${workspaceRoot}/*.svd`,
|
||||
`${platformioPackages}/framework-*/svd/*.svd`,
|
||||
`${platformioPackages}/tool-openocd/svd/*.svd`,
|
||||
];
|
||||
// Search logic here
|
||||
}
|
||||
```
|
||||
|
||||
#### Change Tracking
|
||||
- Store `previousValue` alongside `currentValue` in `RegisterNode`
|
||||
- Compare on each update to detect changes
|
||||
- Apply VSCode decoration (e.g., colored background) to changed registers
|
||||
|
||||
#### Search/Filter UI
|
||||
- Use VSCode `QuickPick` API with `canPickMany: false`
|
||||
- Filter peripherals by name or base address
|
||||
- Keyboard shortcut: `Ctrl+Shift+P` → "Peripherals: Search"
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Improved Memory Editor with Data Visualization
|
||||
|
||||
### Current State
|
||||
- Read-only hex dump at `src/frontend/memory_content_provider.ts`
|
||||
- ASCII view on the right side
|
||||
- Basic selection highlighting
|
||||
- History tracking in `src/frontend/memory_tree_provider.ts`
|
||||
|
||||
### Planned Enhancements
|
||||
|
||||
| Priority | Task | Effort | Files to Modify |
|
||||
|----------|------|--------|-----------------|
|
||||
| High | Add editable memory cells (write support) | 3d | `memory_content_provider.ts`, `adapter.ts` |
|
||||
| High | Add data type interpretation (u8/16/32/64, float, double) | 3d | `memory_content_provider.ts` |
|
||||
| High | Add ASCII/string view toggle | 1d | `memory_content_provider.ts` |
|
||||
| Medium | Add memory diff/highlighting capabilities | 2d | `memory_content_provider.ts` |
|
||||
| Medium | Add endianness toggle (little/big) | 1d | `memory_content_provider.ts` |
|
||||
| Low | Add memory bookmarking/named regions | 2d | `memory_tree_provider.ts` |
|
||||
|
||||
### Technical Details
|
||||
|
||||
#### Memory Write Implementation
|
||||
```typescript
|
||||
// New command in extension.ts
|
||||
private async writeMemory(address: number, data: Uint8Array): Promise<void> {
|
||||
const session = vscode.debug.activeDebugSession;
|
||||
if (!session) return;
|
||||
|
||||
await session.customRequest('write-memory', {
|
||||
address,
|
||||
data: Buffer.from(data).toString('hex')
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### Data Type Visualization
|
||||
- Add toolbar dropdown to memory view
|
||||
- Support: u8, u16, u32, u64, i8, i16, i32, i64, float, double
|
||||
- Re-interpret bytes according to selected type and endianness
|
||||
- Display in separate column alongside hex view
|
||||
|
||||
#### Editing Workflow
|
||||
1. User clicks on hex byte in editor
|
||||
2. Input box appears for new value
|
||||
3. Validate input (hex format)
|
||||
4. Call `write-memory` debug request
|
||||
5. Refresh view on success
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: RTOS Thread Awareness
|
||||
|
||||
### Current State
|
||||
- No RTOS support exists
|
||||
- Basic thread handling in `adapter.ts` (GDB thread events)
|
||||
- Uses `ThreadEvent` from `@vscode/debugadapter`
|
||||
|
||||
### Planned Enhancements
|
||||
|
||||
| Priority | Task | Effort | Files to Modify |
|
||||
|----------|------|--------|-----------------|
|
||||
| High | Create RTOS detection mechanism | 3d | New file `rtos.ts` |
|
||||
| High | Implement FreeRTOS thread parser | 3d | `rtos.ts` |
|
||||
| High | Add thread-aware stack frame mapping | 4d | `adapter.ts` |
|
||||
| Medium | Implement ThreadX support | 2d | `rtos.ts` |
|
||||
| Medium | Implement Zephyr support | 2d | `rtos.ts` |
|
||||
| Medium | Add thread state display (blocked, ready, running) | 2d | `adapter.ts` |
|
||||
| Low | Add thread priority display | 1d | `adapter.ts` |
|
||||
|
||||
### Technical Details
|
||||
|
||||
#### RTOS Detection
|
||||
```typescript
|
||||
// New file: src/backend/rtos.ts
|
||||
export enum RTOSType {
|
||||
None = 'none',
|
||||
FreeRTOS = 'freertos',
|
||||
ThreadX = 'threadx',
|
||||
Zephyr = 'zephyr',
|
||||
Unknown = 'unknown'
|
||||
}
|
||||
|
||||
export class RTOSDetector {
|
||||
async detect(miDebugger: MI2): Promise<RTOSType> {
|
||||
// Check for FreeRTOS: look for pxCurrentTCB symbol
|
||||
// Check for ThreadX: look for _tx_thread_current_ptr
|
||||
// Check for Zephyr: look for _kernel.current
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Thread Parsing (FreeRTOS Example)
|
||||
```typescript
|
||||
interface RTOSThread {
|
||||
id: number;
|
||||
name: string;
|
||||
state: 'running' | 'ready' | 'blocked' | 'suspended';
|
||||
priority: number;
|
||||
stackPointer: number;
|
||||
stackInfo?: { base: number; size: number; used: number };
|
||||
}
|
||||
|
||||
class FreeRTOSThreadParser {
|
||||
async parseThreads(miDebugger: MI2): Promise<ROSThread[]> {
|
||||
// Read pxCurrentTCB to get current task
|
||||
// Walk ready/blocked/suspended lists
|
||||
// Parse TCB structures from memory
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Configuration
|
||||
```json
|
||||
{
|
||||
"name": "PIO Debug",
|
||||
"type": "platformio-debug",
|
||||
"request": "launch",
|
||||
"rtos": {
|
||||
"type": "auto",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Thread State Mapping
|
||||
| FreeRTOS State | VSCode Thread State |
|
||||
|----------------|---------------------|
|
||||
| Running | `running` |
|
||||
| Ready | `ready` |
|
||||
| Blocked | `paused` |
|
||||
| Suspended | `paused` |
|
||||
| Deleted | `exited` |
|
||||
|
||||
---
|
||||
|
||||
## Feature 4: Better Error Messages and Diagnostics
|
||||
|
||||
### Current State
|
||||
- Basic error messages in `src/extension.ts`
|
||||
- Generic GDB/MI error handling
|
||||
- No structured diagnostic system
|
||||
|
||||
### Planned Enhancements
|
||||
|
||||
| Priority | Task | Effort | Files to Modify |
|
||||
|----------|------|--------|-----------------|
|
||||
| High | Create centralized error message system | 2d | New file `diagnostics.ts` |
|
||||
| High | Add connection troubleshooting wizard | 3d | `diagnostics.ts` |
|
||||
| High | Improve GDB/MI error parsing | 2d | `mi2/mi2.ts` |
|
||||
| Medium | Add diagnostic logging panel | 2d | `extension.ts` |
|
||||
| Medium | Add SVD parse error recovery with suggestions | 1d | `peripheral.ts` |
|
||||
| Low | Add "Report Issue" command with context collection | 2d | `extension.ts` |
|
||||
|
||||
### Technical Details
|
||||
|
||||
#### Centralized Error System
|
||||
```typescript
|
||||
// New file: src/diagnostics.ts
|
||||
export interface ErrorAction {
|
||||
label: string;
|
||||
callback: () => void;
|
||||
}
|
||||
|
||||
export class DiagnosticsManager {
|
||||
showError(message: string, actions?: ErrorAction[]): void {
|
||||
if (actions && actions.length > 0) {
|
||||
vscode.window.showErrorMessage(message, ...actions.map(a => a.label))
|
||||
.then(selected => {
|
||||
const action = actions.find(a => a.label === selected);
|
||||
action?.callback();
|
||||
});
|
||||
} else {
|
||||
vscode.window.showErrorMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Predefined error patterns with solutions
|
||||
handleGDBConnectionError(error: string): void {
|
||||
// Suggest checking OpenOCD/GDB server status
|
||||
// Offer to restart debug session
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Common Error Patterns
|
||||
| Error Pattern | Suggested Action |
|
||||
|---------------|------------------|
|
||||
| "Connection refused" | Check OpenOCD status, port availability |
|
||||
| "No such file or directory" | Verify SVD file path in launch.json |
|
||||
| "Cannot access memory" | Target may not be halted; try pausing |
|
||||
| "Remote replied with error" | GDB server protocol mismatch |
|
||||
|
||||
#### Diagnostic Logging
|
||||
- Add output channel: "PlatformIO Debug Diagnostics"
|
||||
- Log all GDB/MI commands and responses when `showDevDebugOutput: true`
|
||||
- Log peripheral/memory operations
|
||||
- Export diagnostic log for bug reports
|
||||
|
||||
---
|
||||
|
||||
## pioarduino-vscode-ide Integration Changes
|
||||
|
||||
The `pioarduino-vscode-ide` extension ([`debug_120` branch](https://github.com/Jason2866/pioarduino-vscode-ide/tree/debug_120)) defines the UI layer (commands, views, menus) while this debug extension provides the implementation. The following changes are needed in the IDE extension:
|
||||
|
||||
### package.json Changes Required
|
||||
|
||||
#### New Commands to Add
|
||||
```json
|
||||
{
|
||||
"command": "platformio-debug.peripherals.search",
|
||||
"title": "Search Peripherals",
|
||||
"category": "PlatformIO Debug",
|
||||
"icon": "$(search)"
|
||||
},
|
||||
{
|
||||
"command": "platformio-debug.memory.edit",
|
||||
"title": "Edit Memory",
|
||||
"category": "PlatformIO Debug",
|
||||
"icon": "$(edit)"
|
||||
},
|
||||
{
|
||||
"command": "platformio-debug.memory.setDataType",
|
||||
"title": "Set Data Type",
|
||||
"category": "PlatformIO Debug"
|
||||
},
|
||||
{
|
||||
"command": "platformio-debug.memory.toggleEndianness",
|
||||
"title": "Toggle Endianness",
|
||||
"category": "PlatformIO Debug"
|
||||
},
|
||||
{
|
||||
"command": "platformio-debug.rtos.refreshThreads",
|
||||
"title": "Refresh RTOS Threads",
|
||||
"category": "PlatformIO Debug",
|
||||
"icon": "$(refresh)"
|
||||
},
|
||||
{
|
||||
"command": "platformio-debug.diagnostics.showLog",
|
||||
"title": "Show Debug Diagnostics",
|
||||
"category": "PlatformIO Debug"
|
||||
}
|
||||
```
|
||||
|
||||
#### New Configuration Properties
|
||||
```json
|
||||
"platformio-debug.rtos.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable RTOS thread awareness (FreeRTOS, ThreadX, Zephyr)"
|
||||
},
|
||||
"platformio-debug.rtos.type": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "FreeRTOS", "ThreadX", "Zephyr", "none"],
|
||||
"default": "auto",
|
||||
"description": "RTOS type for thread awareness (auto-detect if not specified)"
|
||||
},
|
||||
"platformio-debug.memory.defaultDataType": {
|
||||
"type": "string",
|
||||
"enum": ["u8", "u16", "u32", "u64", "i8", "i16", "i32", "i64", "float", "double"],
|
||||
"default": "u8",
|
||||
"description": "Default data type for memory view"
|
||||
},
|
||||
"platformio-debug.memory.defaultEndianness": {
|
||||
"type": "string",
|
||||
"enum": ["little", "big"],
|
||||
"default": "little",
|
||||
"description": "Default endianness for memory view"
|
||||
},
|
||||
"platformio-debug.diagnostics.showDevDebugOutput": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Show detailed diagnostic logs for troubleshooting"
|
||||
}
|
||||
```
|
||||
|
||||
#### New Views (Optional)
|
||||
- **RTOS Threads Panel**: Add to debug view container alongside peripherals/registers
|
||||
- **Diagnostics Panel**: Output channel for diagnostic messages
|
||||
|
||||
### File Locations in IDE Extension
|
||||
```text
|
||||
https://github.com/Jason2866/pioarduino-vscode-ide/tree/debug_120
|
||||
├── package.json (modified: commands, config, views, rtos launch property)
|
||||
├── syntaxes/ (existing: language definitions)
|
||||
└── src/ (if UI logic needed, typically just package.json)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Proposed File Structure (Debug Extension)
|
||||
|
||||
```text
|
||||
src/
|
||||
├── backend/
|
||||
│ ├── adapter.ts (modify: RTOS thread awareness)
|
||||
│ ├── mi2/
|
||||
│ │ ├── mi2.ts (modify: error parsing)
|
||||
│ │ └── types.ts (may need RTOS types)
|
||||
│ ├── rtos.ts (NEW: RTOS parsers)
|
||||
│ └── symbols.ts (existing)
|
||||
├── frontend/
|
||||
│ ├── peripheral.ts (modify: SVD enhancements)
|
||||
│ ├── memory_content_provider.ts (modify: editing/visualization)
|
||||
│ ├── memory_tree_provider.ts (modify: bookmarks)
|
||||
│ └── diagnostics.ts (NEW: error handling)
|
||||
├── common.ts (modify: add types)
|
||||
└── extension.ts (modify: register commands)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Phases
|
||||
|
||||
### Phase 1: Foundation
|
||||
- [x] Create `diagnostics.ts` with error handling framework
|
||||
- [x] Implement diagnostic logging panel
|
||||
- [x] Add centralized error message system
|
||||
- [x] `__tests__/diagnostics/troubleshooting-wizard.test.ts` created
|
||||
- [x] **IDE Extension**: Add diagnostic configuration to `package.json`
|
||||
|
||||
### Phase 2: Memory Editor
|
||||
- [x] Add `write-memory` support in `adapter.ts`
|
||||
- [x] Implement editable hex view in `memory_content_provider.ts`
|
||||
- [x] Add ASCII/string view toggle in `memory_content_provider.ts`
|
||||
- [x] Add memory diff/highlighting in `memory_content_provider.ts`
|
||||
- [x] Add data type interpretation panel
|
||||
- [x] Add endianness toggle
|
||||
- [x] `__tests__/backend/adapter-write-memory.test.ts` created
|
||||
- [x] **IDE Extension**: Add memory edit commands and configuration to `package.json`
|
||||
|
||||
### Phase 3: SVD Enhancements
|
||||
- [x] Implement SVD file discovery
|
||||
- [x] Add peripheral search/filter UI
|
||||
- [x] Implement change highlighting
|
||||
- [x] Add `<derivedFrom>` support
|
||||
- [x] Add peripheral register bit-field tooltip documentation
|
||||
- [x] **IDE Extension**: Add peripheral search command to `package.json`
|
||||
|
||||
### Phase 4: RTOS Support
|
||||
- [x] Create RTOS detection mechanism
|
||||
- [x] Implement FreeRTOS parser
|
||||
- [x] Add thread-aware stack mapping
|
||||
- [x] Add ThreadX and Zephyr support
|
||||
- [x] **IDE Extension**: Add RTOS configuration to `package.json`
|
||||
|
||||
### Phase 5: Polish & Testing
|
||||
- [x] Integration testing for all features (`__tests__/integration/feature-integration.test.ts`)
|
||||
- [x] **IDE Extension**: Test all new commands and configurations
|
||||
- [ ] Documentation updates
|
||||
- [ ] Bug fixes and edge cases
|
||||
|
||||
---
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
| Feature | Unit Tests | Integration Tests | Manual Tests |
|
||||
|---------|------------|-------------------|--------------|
|
||||
| SVD Enhancements | SVD parsing edge cases | Search/filter UI | Real device SVD load |
|
||||
| Memory Editor | Data type conversions | Write operations | Various memory regions |
|
||||
| RTOS Awareness | Mock RTOS structures | Thread switching | FreeRTOS/ThreadX/Zephyr targets |
|
||||
| Diagnostics | Error pattern matching | Troubleshooting wizard | Various error scenarios |
|
||||
|
||||
---
|
||||
|
||||
## Test Generation Plan
|
||||
|
||||
### New Test Files to Create
|
||||
|
||||
#### 1. SVD Enhancements Tests
|
||||
```
|
||||
__tests__/
|
||||
├── frontend/
|
||||
│ ├── svd-file-discovery.test.ts (NEW: SVD path resolution)
|
||||
│ ├── peripheral-search.test.ts (NEW: search/filter logic)
|
||||
│ ├── register-change-tracking.test.ts (NEW: value diff detection)
|
||||
│ └── svd-derivedfrom.test.ts (NEW: inheritance parsing)
|
||||
```
|
||||
|
||||
**Test Coverage Targets:**
|
||||
- SVD file discovery: Test all search path patterns
|
||||
- Peripheral search: Test fuzzy matching, case sensitivity
|
||||
- Change tracking: Verify highlighting triggers on value change
|
||||
- `<derivedFrom>`: Test circular references, nested inheritance
|
||||
|
||||
#### 2. Memory Editor Tests
|
||||
```
|
||||
__tests__/
|
||||
├── frontend/
|
||||
│ ├── memory-write.test.ts (NEW: write-memory request)
|
||||
│ ├── memory-data-types.test.ts (NEW: type interpretation)
|
||||
│ ├── memory-endianness.test.ts (NEW: byte order handling)
|
||||
│ └── memory-bookmarks.test.ts (NEW: named regions)
|
||||
├── backend/
|
||||
│ └── adapter-write-memory.test.ts (NEW: write-memory handler)
|
||||
```
|
||||
|
||||
**Test Coverage Targets:**
|
||||
- Write operations: Test byte alignment, partial writes
|
||||
- Data types: Test u8/u16/u32/u64/i8/i16/i32/i64/float/double
|
||||
- Endianness: Verify little/big endian conversion
|
||||
- Input validation: Test invalid hex, out-of-bounds addresses
|
||||
|
||||
#### 3. RTOS Awareness Tests
|
||||
```
|
||||
__tests__/
|
||||
├── backend/
|
||||
│ ├── rtos-detector.test.ts (NEW: auto-detection logic)
|
||||
│ ├── freertos-parser.test.ts (NEW: FreeRTOS TCB parsing)
|
||||
│ ├── threadx-parser.test.ts (NEW: ThreadX thread parsing)
|
||||
│ ├── zephyr-parser.test.ts (NEW: Zephyr kernel parsing)
|
||||
│ └── rtos-thread-mapping.test.ts (NEW: thread-to-frame mapping)
|
||||
```
|
||||
|
||||
**Test Coverage Targets:**
|
||||
- Detection: Mock GDB symbol table responses
|
||||
- FreeRTOS: Test TCB structure parsing, state mapping
|
||||
- ThreadX: Test thread list walking
|
||||
- Zephyr: Test kernel thread table access
|
||||
- Thread mapping: Verify frame ID assignment
|
||||
|
||||
#### 4. Diagnostics Tests
|
||||
```
|
||||
__tests__/
|
||||
├── diagnostics/
|
||||
│ ├── error-pattern-matching.test.ts (NEW: error classification)
|
||||
│ ├── diagnostics-manager.test.ts (NEW: error actions)
|
||||
│ └── troubleshooting-wizard.test.ts (NEW: diagnostic flow)
|
||||
```
|
||||
|
||||
**Test Coverage Targets:**
|
||||
- Error patterns: Test regex matching for common GDB errors
|
||||
- Actions: Verify callback execution for error actions
|
||||
- Logging: Test output channel formatting
|
||||
|
||||
### Modified Existing Tests
|
||||
|
||||
Files requiring updates for new functionality:
|
||||
|
||||
| File | Changes Needed |
|
||||
|------|----------------|
|
||||
| `__tests__/frontend/device-defaults.test.ts` | Add tests for SVD inheritance |
|
||||
| `__tests__/mi2/breakpoint-parsing.test.ts` | Verify still passes (regression check) |
|
||||
| `__tests__/backend/breakpoint-error-handling.test.ts` | Add error classification tests |
|
||||
|
||||
### Test Infrastructure
|
||||
|
||||
#### Mock Data Files
|
||||
```
|
||||
__tests__/
|
||||
├── mocks/
|
||||
│ ├── rtos/
|
||||
│ │ ├── freertos-tcb.bin (Mock TCB structures)
|
||||
│ │ ├── threadx-thread.bin (Mock ThreadX thread)
|
||||
│ │ └── zephyr-kernel.bin (Mock Zephyr kernel)
|
||||
│ ├── svd/
|
||||
│ │ ├── test-device.svd (Test SVD with derivedFrom)
|
||||
│ │ └── search-test/ (Directory for discovery tests)
|
||||
│ └── memory/
|
||||
│ └── test-regions.json (Memory test configurations)
|
||||
```
|
||||
|
||||
#### Test Utilities (New)
|
||||
```typescript
|
||||
// __tests__/utils/rtos-mocks.ts
|
||||
export function createMockTCB(state: string, priority: number): Buffer;
|
||||
export function createMockThreadList(count: number): Buffer;
|
||||
|
||||
// __tests__/utils/memory-mocks.ts
|
||||
export function createMockMemoryBuffer(size: number, pattern: string): number[];
|
||||
export function validateMemoryWrite(address: number, data: string): boolean;
|
||||
```
|
||||
|
||||
### Regression Test Suite
|
||||
|
||||
All existing tests must pass:
|
||||
```bash
|
||||
npm test
|
||||
# Expected: 133 tests passing (current) + new tests
|
||||
```
|
||||
|
||||
New features should not break:
|
||||
- MI2/MI3/MI4 protocol compatibility
|
||||
- Existing peripheral viewer functionality
|
||||
- Current memory read operations
|
||||
- Breakpoint handling
|
||||
|
||||
### Test-Driven Development Order
|
||||
|
||||
1. **Write failing tests first** for each feature
|
||||
2. **Implement feature** to make tests pass
|
||||
3. **Refactor** while maintaining test coverage
|
||||
4. **Add edge case tests** after initial implementation
|
||||
|
||||
### Coverage Requirements by Feature
|
||||
|
||||
| Feature | Minimum Coverage | Critical Paths |
|
||||
|---------|------------------|----------------|
|
||||
| SVD Enhancements | 85% | File discovery, change detection |
|
||||
| Memory Editor | 90% | Write operations, type conversion |
|
||||
| RTOS Awareness | 80% | Detection, thread parsing |
|
||||
| Diagnostics | 85% | Error classification, actions |
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- No new runtime dependencies expected
|
||||
- May require `@types/node` updates for buffer operations
|
||||
- Development dependencies: jest for testing (already present)
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| RTOS parsing fragile across versions | High | Implement version detection, graceful fallback |
|
||||
| Memory write safety | High | Add confirmation dialogs, validate addresses |
|
||||
| SVD file discovery performance | Low | Cache search results, async loading |
|
||||
| GDB/MI version differences | Medium | Test with MI2/MI3/MI4 (already supported) |
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Debug Extension (pioarduino-vscode-debug)
|
||||
- [ ] All 4 feature areas have measurable improvements
|
||||
- [ ] Test coverage > 80% for new code
|
||||
- [ ] No regressions in existing functionality (353 tests passing)
|
||||
- [ ] Documentation updated in README.md
|
||||
- [ ] CHANGELOG.md updated with detailed entries
|
||||
|
||||
### IDE Extension (pioarduino-vscode-ide)
|
||||
- [ ] Diagnostic configuration added and verifiable in `package.json`
|
||||
- [ ] All new commands registered in `package.json`
|
||||
- [ ] New configuration properties added and functional
|
||||
- [ ] No conflicts with existing debug commands
|
||||
- [ ] Updated extension README with new features
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep backward compatibility with MI2 protocol
|
||||
- Maintain support for existing launch.json configurations
|
||||
- Follow existing code style (no semicolons, single quotes, 4-space indent)
|
||||
- Reuse existing utility functions from `src/utils.ts`
|
||||
|
||||
## Release Coordination
|
||||
|
||||
The debug extension and IDE extension releases should be coordinated:
|
||||
|
||||
1. **Debug Extension Release**: Must be published first (contains implementation)
|
||||
2. **IDE Extension Release**: Published after with updated `package.json` (contains UI definitions)
|
||||
|
||||
Both extensions can be developed in parallel, but the IDE extension's package.json changes should reference commands/features that exist in the debug extension version it depends on.
|
||||
|
||||
### Version Compatibility
|
||||
|
||||
| IDE Extension | Debug Extension | Notes |
|
||||
|---------------|-----------------|-------|
|
||||
| 1.3.x | 1.1.x | Current stable |
|
||||
| 1.4.x | 1.2.0 | With v1.2.0 features |
|
||||
|
||||
### Files to Modify in IDE Extension
|
||||
|
||||
Location: [`package.json`](https://github.com/Jason2866/pioarduino-vscode-ide/blob/debug_120/package.json)
|
||||
|
||||
Key sections to update:
|
||||
- `contributes.commands` - Add new command definitions
|
||||
- `contributes.menus` - Add menu items for new commands
|
||||
- `contributes.configuration` - Add new configuration properties
|
||||
- `contributes.views` - Add new RTOS/diagnostics views if applicable
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Mock for vscode module used in tests
|
||||
*/
|
||||
|
||||
const mockOutputChannel = {
|
||||
appendLine: jest.fn(),
|
||||
append: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
show: jest.fn(),
|
||||
hide: jest.fn(),
|
||||
dispose: jest.fn(),
|
||||
replace: jest.fn()
|
||||
}
|
||||
|
||||
const mockClipboard = {
|
||||
writeText: jest.fn().mockResolvedValue(undefined),
|
||||
readText: jest.fn().mockResolvedValue('')
|
||||
}
|
||||
|
||||
const mockUri = {
|
||||
parse: jest.fn((uri: string) => ({ fsPath: uri, toString: () => uri })),
|
||||
file: jest.fn((path: string) => ({ fsPath: path, toString: () => `file://${path}` }))
|
||||
}
|
||||
|
||||
// Helper to create a proper Thenable mock backed by a real Promise so that
|
||||
// callers get standard async scheduling, chaining and error semantics.
|
||||
const createMockThenable = (resolvedValue?: any): Promise<any> => {
|
||||
return Promise.resolve(resolvedValue)
|
||||
}
|
||||
|
||||
export const window = {
|
||||
createOutputChannel: jest.fn().mockReturnValue(mockOutputChannel),
|
||||
showErrorMessage: jest.fn().mockImplementation(() => createMockThenable(undefined)),
|
||||
showWarningMessage: jest.fn().mockImplementation(() => createMockThenable(undefined)),
|
||||
showInformationMessage: jest.fn().mockImplementation(() => createMockThenable(undefined)),
|
||||
showOpenDialog: jest.fn().mockImplementation(() => Promise.resolve(undefined)),
|
||||
showQuickPick: jest.fn().mockImplementation(() => Promise.resolve(undefined)),
|
||||
showInputBox: jest.fn().mockImplementation(() => Promise.resolve(undefined)),
|
||||
activeTextEditor: undefined,
|
||||
visibleTextEditors: [],
|
||||
onDidChangeActiveTextEditor: jest.fn().mockReturnValue({ dispose: jest.fn() })
|
||||
}
|
||||
|
||||
export const env = {
|
||||
clipboard: mockClipboard,
|
||||
openExternal: jest.fn().mockResolvedValue(true),
|
||||
appName: 'vscode-test',
|
||||
appRoot: '/test',
|
||||
appHost: 'desktop',
|
||||
uiKind: 1
|
||||
}
|
||||
|
||||
export const Uri = mockUri
|
||||
|
||||
export const commands = {
|
||||
executeCommand: jest.fn().mockResolvedValue(undefined),
|
||||
registerCommand: jest.fn().mockReturnValue({ dispose: jest.fn() }),
|
||||
getCommands: jest.fn().mockResolvedValue([])
|
||||
}
|
||||
|
||||
export const debug = {
|
||||
activeDebugSession: undefined,
|
||||
onDidChangeActiveDebugSession: jest.fn().mockReturnValue({ dispose: jest.fn() }),
|
||||
onDidStartDebugSession: jest.fn().mockReturnValue({ dispose: jest.fn() }),
|
||||
onDidTerminateDebugSession: jest.fn().mockReturnValue({ dispose: jest.fn() })
|
||||
}
|
||||
|
||||
export const workspace = {
|
||||
workspaceFolders: undefined,
|
||||
onDidChangeWorkspaceFolders: jest.fn().mockReturnValue({ dispose: jest.fn() }),
|
||||
getConfiguration: jest.fn().mockReturnValue({
|
||||
get: jest.fn(),
|
||||
has: jest.fn(),
|
||||
update: jest.fn().mockResolvedValue(undefined)
|
||||
}),
|
||||
registerTextDocumentContentProvider: jest.fn().mockReturnValue({ dispose: jest.fn() })
|
||||
}
|
||||
|
||||
export const EventEmitter = jest.fn().mockImplementation(() => ({
|
||||
event: jest.fn(),
|
||||
fire: jest.fn(),
|
||||
dispose: jest.fn()
|
||||
}))
|
||||
|
||||
export const TreeItemCollapsibleState = {
|
||||
None: 0,
|
||||
Collapsed: 1,
|
||||
Expanded: 2
|
||||
}
|
||||
|
||||
export const OverviewRulerLane = {
|
||||
Left: 1,
|
||||
Center: 2,
|
||||
Right: 4,
|
||||
Full: 7
|
||||
}
|
||||
|
||||
export const Position = jest.fn().mockImplementation((line: number, character: number) => ({
|
||||
line,
|
||||
character,
|
||||
compareTo: jest.fn(),
|
||||
isAfter: jest.fn(),
|
||||
isAfterOrEqual: jest.fn(),
|
||||
isBefore: jest.fn(),
|
||||
isBeforeOrEqual: jest.fn(),
|
||||
isEqual: jest.fn(),
|
||||
translate: jest.fn(),
|
||||
with: jest.fn()
|
||||
}))
|
||||
|
||||
export const Range = jest.fn().mockImplementation((startOrLine: any, startOrChar: any, endLine?: number, endChar?: number) => {
|
||||
const isPositionLike = (value: any) =>
|
||||
value && typeof value.line === 'number' && typeof value.character === 'number'
|
||||
|
||||
const start = isPositionLike(startOrLine) && isPositionLike(startOrChar)
|
||||
? { line: startOrLine.line, character: startOrLine.character }
|
||||
: { line: startOrLine, character: startOrChar }
|
||||
const end = isPositionLike(startOrLine) && isPositionLike(startOrChar)
|
||||
? { line: startOrChar.line, character: startOrChar.character }
|
||||
: { line: endLine, character: endChar }
|
||||
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
isEmpty: jest.fn(),
|
||||
isSingleLine: jest.fn(),
|
||||
contains: jest.fn(),
|
||||
intersection: jest.fn(),
|
||||
union: jest.fn(),
|
||||
with: jest.fn()
|
||||
}
|
||||
})
|
||||
|
||||
export const ThemeIcon = jest.fn().mockImplementation((id: string, color?: any) => ({
|
||||
id,
|
||||
color
|
||||
}))
|
||||
|
||||
export const ThemeColor = jest.fn().mockImplementation((id: string) => ({ id }))
|
||||
|
||||
export class TreeItem {
|
||||
public label: any
|
||||
public collapsibleState: any
|
||||
public command: any
|
||||
public tooltip: any
|
||||
public iconPath: any
|
||||
public contextValue: any
|
||||
public description: any
|
||||
constructor(label: any, collapsibleState?: any) {
|
||||
this.label = label
|
||||
this.collapsibleState = collapsibleState
|
||||
}
|
||||
}
|
||||
|
||||
// Default export
|
||||
export default {
|
||||
window,
|
||||
env,
|
||||
Uri,
|
||||
commands,
|
||||
debug,
|
||||
workspace,
|
||||
EventEmitter,
|
||||
TreeItemCollapsibleState,
|
||||
OverviewRulerLane,
|
||||
Position,
|
||||
Range,
|
||||
ThemeIcon,
|
||||
ThemeColor,
|
||||
TreeItem
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Unit tests for the write-memory dispatch path in adapter.ts.
|
||||
*
|
||||
* Tests that:
|
||||
* 1. The 'write-memory' custom-request case calls customWriteMemoryRequest
|
||||
* with the correct address and data arguments.
|
||||
* 2. customWriteMemoryRequest constructs the right GDB/MI command:
|
||||
* `data-write-memory-bytes <hexAddr> <data>`
|
||||
* 3. On MI success, sendResponse is called once.
|
||||
* 4. On MI failure, sendErrorResponse is called with code 114.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal stubs for heavyweight dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
jest.mock('@vscode/debugadapter', () => {
|
||||
class FakeHandles {
|
||||
private map = new Map<number, any>();
|
||||
private nextId = 1;
|
||||
create(v: any): number {
|
||||
const id = this.nextId++;
|
||||
this.map.set(id, v);
|
||||
return id;
|
||||
}
|
||||
get(id: number): any {
|
||||
return this.map.get(id);
|
||||
}
|
||||
reset(): void {
|
||||
this.map.clear();
|
||||
this.nextId = 1;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDebugSession {
|
||||
sendResponse(_response: any) {}
|
||||
sendErrorResponse(_response: any, _code: number, _message: string) {}
|
||||
sendEvent(_event: any) {}
|
||||
}
|
||||
|
||||
class FakeEvent {
|
||||
constructor(public event: string, public body?: any) {}
|
||||
}
|
||||
|
||||
return {
|
||||
DebugSession: FakeDebugSession,
|
||||
Event: FakeEvent,
|
||||
Handles: FakeHandles,
|
||||
InitializedEvent: class extends FakeEvent { constructor() { super('initialized'); } },
|
||||
OutputEvent: class extends FakeEvent {},
|
||||
TerminatedEvent: class extends FakeEvent {},
|
||||
ThreadEvent: class extends FakeEvent {},
|
||||
Thread: class {},
|
||||
StackFrame: class {},
|
||||
Scope: class {},
|
||||
Source: class {},
|
||||
ContinuedEvent: class extends FakeEvent {},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../src/backend/mi2/mi2', () => ({
|
||||
MI2: class {
|
||||
sendCommand = jest.fn();
|
||||
on = jest.fn();
|
||||
emit = jest.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../src/backend/symbols', () => ({
|
||||
SymbolTable: class {
|
||||
getFunctionSymbols() { return []; }
|
||||
getFunctionAtAddress() { return null; }
|
||||
getSourceLines() { return []; }
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../src/backend/rtos', () => ({
|
||||
RTOSManager: class {
|
||||
detectRTOS() { return Promise.resolve(undefined); }
|
||||
getThreads() { return []; }
|
||||
},
|
||||
RTOSType: { None: 'none' },
|
||||
}));
|
||||
|
||||
jest.mock('../../src/common', () => ({
|
||||
StoppedEvent: class { constructor(public event: string, public body?: any) {} },
|
||||
AdapterOutputEvent: class { constructor(public event: string, public body?: any) {} },
|
||||
}));
|
||||
|
||||
jest.mock('../../src/backend/expand_value', () => ({
|
||||
expandValue: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/backend/mi_parse', () => ({
|
||||
MINode: class {},
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import under test
|
||||
// ---------------------------------------------------------------------------
|
||||
import { GDBDebugSession } from '../../src/backend/adapter';
|
||||
import { MI2 } from '../../src/backend/mi2/mi2';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createSession(): any {
|
||||
const session = new GDBDebugSession() as any;
|
||||
// Inject a mock MI2 instance so customWriteMemoryRequest has a debugger
|
||||
session.miDebugger = new (MI2 as any)();
|
||||
// Spy on sendResponse / sendErrorResponse
|
||||
session.sendResponse = jest.fn();
|
||||
session.sendErrorResponse = jest.fn();
|
||||
return session;
|
||||
}
|
||||
|
||||
function makeResponse(): any {
|
||||
return { seq: 1, type: 'response', request_seq: 1, success: false, command: 'custom' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('adapter.ts — write-memory dispatch (customWriteMemoryRequest)', () => {
|
||||
let session: any;
|
||||
|
||||
beforeEach(() => {
|
||||
session = createSession();
|
||||
jest.clearAllMocks();
|
||||
session.miDebugger.sendCommand = jest.fn();
|
||||
});
|
||||
|
||||
describe('MI command construction', () => {
|
||||
test('sends data-write-memory-bytes with zero-padded hex address', async () => {
|
||||
session.miDebugger.sendCommand.mockReturnValue({ then: (cb: any) => { cb({}); return { catch: jest.fn() }; } });
|
||||
|
||||
session.customWriteMemoryRequest(makeResponse(), 0x20000000, 'aabbccdd');
|
||||
|
||||
expect(session.miDebugger.sendCommand).toHaveBeenCalledWith(
|
||||
'data-write-memory-bytes 0x20000000 aabbccdd'
|
||||
);
|
||||
});
|
||||
|
||||
test('formats address with full 8-digit hex padding', () => {
|
||||
session.miDebugger.sendCommand.mockReturnValue({
|
||||
then: (cb: any) => { cb({}); return { catch: jest.fn() }; }
|
||||
});
|
||||
|
||||
session.customWriteMemoryRequest(makeResponse(), 0x100, 'ff');
|
||||
|
||||
expect(session.miDebugger.sendCommand).toHaveBeenCalledWith(
|
||||
'data-write-memory-bytes 0x00000100 ff'
|
||||
);
|
||||
});
|
||||
|
||||
test('passes data string unmodified to the MI command', () => {
|
||||
session.miDebugger.sendCommand.mockReturnValue({
|
||||
then: (cb: any) => { cb({}); return { catch: jest.fn() }; }
|
||||
});
|
||||
|
||||
session.customWriteMemoryRequest(makeResponse(), 0x20000004, '01020304050607');
|
||||
|
||||
expect(session.miDebugger.sendCommand).toHaveBeenCalledWith(
|
||||
'data-write-memory-bytes 0x20000004 01020304050607'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('success path', () => {
|
||||
test('calls sendResponse on MI success', () => {
|
||||
const response = makeResponse();
|
||||
session.miDebugger.sendCommand.mockReturnValue({
|
||||
then: (onFulfilled: any) => {
|
||||
onFulfilled({});
|
||||
return { catch: jest.fn() };
|
||||
},
|
||||
});
|
||||
|
||||
session.customWriteMemoryRequest(response, 0x20000000, 'ab');
|
||||
|
||||
expect(session.sendResponse).toHaveBeenCalledWith(response);
|
||||
expect(session.sendErrorResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('failure path', () => {
|
||||
test('calls sendErrorResponse with code 114 on MI failure', () => {
|
||||
const response = makeResponse();
|
||||
const error = new Error('target memory error');
|
||||
session.miDebugger.sendCommand.mockReturnValue({
|
||||
then: (_onFulfilled: any, onRejected: any) => {
|
||||
onRejected(error);
|
||||
return { catch: jest.fn() };
|
||||
},
|
||||
});
|
||||
|
||||
session.customWriteMemoryRequest(response, 0x20000000, 'ab');
|
||||
|
||||
expect(session.sendErrorResponse).toHaveBeenCalledWith(
|
||||
response,
|
||||
114,
|
||||
expect.stringContaining('Unable to write memory')
|
||||
);
|
||||
expect(session.sendResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('error response message includes the MI error description', () => {
|
||||
const response = makeResponse();
|
||||
const error = new Error('cannot access memory at 0x20000000');
|
||||
session.miDebugger.sendCommand.mockReturnValue({
|
||||
then: (_onFulfilled: any, onRejected: any) => {
|
||||
onRejected(error);
|
||||
return { catch: jest.fn() };
|
||||
},
|
||||
});
|
||||
|
||||
session.customWriteMemoryRequest(response, 0x20000000, 'ff');
|
||||
|
||||
const errorCall = (session.sendErrorResponse as jest.Mock).mock.calls[0];
|
||||
expect(errorCall[2]).toMatch(/cannot access memory/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('write-memory custom request dispatch', () => {
|
||||
test('customRequest routes write-memory to customWriteMemoryRequest', () => {
|
||||
// Spy on the private method via reflection
|
||||
const spy = jest.spyOn(session as any, 'customWriteMemoryRequest');
|
||||
session.miDebugger.sendCommand.mockReturnValue({
|
||||
then: (cb: any) => { cb({}); return { catch: jest.fn() }; }
|
||||
});
|
||||
|
||||
const response = makeResponse();
|
||||
// customRequest signature: (command, response, args)
|
||||
session.customRequest('write-memory', response, {
|
||||
address: 0x20000000,
|
||||
data: 'deadbeef',
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(response, 0x20000000, 'deadbeef');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,17 @@ import { parseMI } from '../../src/backend/mi_parse';
|
||||
|
||||
describe('Breakpoint Error Handling', () => {
|
||||
describe('addBreakPoint null return handling', () => {
|
||||
let mi2: MI2;
|
||||
|
||||
beforeEach(() => {
|
||||
mi2 = new MI2('gdb', []);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mi2.removeAllListeners();
|
||||
});
|
||||
|
||||
test('should return null when breakpoint number parsing fails', async () => {
|
||||
const mi2 = new MI2('gdb', []);
|
||||
|
||||
// Mock sendCommand to return invalid response
|
||||
mi2.sendCommand = jest.fn().mockResolvedValue({
|
||||
resultRecords: { resultClass: 'done' },
|
||||
@@ -25,8 +33,6 @@ describe('Breakpoint Error Handling', () => {
|
||||
});
|
||||
|
||||
test('should return null when GDB returns error', async () => {
|
||||
const mi2 = new MI2('gdb', []);
|
||||
|
||||
// Mock sendCommand to return error response
|
||||
mi2.sendCommand = jest.fn().mockResolvedValue({
|
||||
resultRecords: { resultClass: 'error' },
|
||||
@@ -42,8 +48,6 @@ describe('Breakpoint Error Handling', () => {
|
||||
});
|
||||
|
||||
test('should return breakpoint object when successful', async () => {
|
||||
const mi2 = new MI2('gdb', []);
|
||||
|
||||
// Mock sendCommand to return valid response
|
||||
mi2.sendCommand = jest.fn().mockResolvedValue({
|
||||
resultRecords: { resultClass: 'done' },
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Bug Condition Exploration Tests — Task 1
|
||||
*
|
||||
* These tests surface the two leaks that cause Jest to force-exit its worker:
|
||||
*
|
||||
* Cause A — Unref'd timer: MI2.onOutput() creates a setTimeout without .unref(),
|
||||
* keeping the Node.js event loop alive after tests finish.
|
||||
*
|
||||
* Cause B — Dangling listeners: MI2 instances created in tests without afterEach
|
||||
* cleanup leave EventEmitter listeners registered on live objects.
|
||||
*
|
||||
* EXPECTED OUTCOME ON UNFIXED CODE:
|
||||
* - Cause A test FAILS → hasRef() returns true (timer keeps event loop alive)
|
||||
* - Cause B test PASSES → MI2 constructor adds no listeners itself
|
||||
* (the leak pattern is about not having afterEach, not about constructor listeners)
|
||||
*
|
||||
* DO NOT modify mi2.ts or fix these tests when they fail.
|
||||
* Failure of Cause A confirms the bug exists.
|
||||
*
|
||||
* Validates: Requirements 1.1, 1.2, 1.3
|
||||
*/
|
||||
|
||||
import { MI2 } from '../../src/backend/mi2/mi2';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cause A — Timer leak
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Cause A — Timer leak: debugReadyTimeout should be unref\'d', () => {
|
||||
let mi2: MI2;
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up the timer so it doesn't fire during other tests
|
||||
if (mi2['debugReadyTimeout']) {
|
||||
clearTimeout(mi2['debugReadyTimeout']);
|
||||
}
|
||||
mi2.removeAllListeners();
|
||||
});
|
||||
|
||||
test(
|
||||
'hasRef() should be false after onOutput() triggers the PlatformIO init branch',
|
||||
() => {
|
||||
mi2 = new MI2('gdb', []);
|
||||
|
||||
// A valid GDB console stream record: ~"<content>\n"
|
||||
// The ~ prefix makes parseMI produce isStream: true with type 'console'
|
||||
const triggerLine = '~"PlatformIO: Initialization completed\\n"';
|
||||
|
||||
mi2.onOutput(triggerLine);
|
||||
|
||||
// The timer must have been created
|
||||
expect(mi2['debugReadyTimeout']).toBeDefined();
|
||||
|
||||
// On UNFIXED code this assertion FAILS because .unref() is never called.
|
||||
// hasRef() returns true → the timer keeps the event loop alive.
|
||||
// On FIXED code this assertion PASSES because .unref() is called.
|
||||
expect(mi2['debugReadyTimeout'].hasRef()).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cause B — Listener leak
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Cause B — Listener leak: MI2 instance should have zero listeners after construction', () => {
|
||||
/**
|
||||
* NOTE: The MI2 constructor does NOT add any listeners itself.
|
||||
* The leak pattern described in the bug report is about tests that create MI2
|
||||
* instances without an afterEach cleanup block — those instances accumulate
|
||||
* listeners added during the test body and are never cleaned up.
|
||||
*
|
||||
* This test verifies the baseline: a freshly constructed MI2 instance has
|
||||
* zero 'msg' listeners. If this passes, it documents that Cause B is about
|
||||
* the *pattern* of missing afterEach, not about listeners added in the constructor.
|
||||
*/
|
||||
test(
|
||||
'a freshly constructed MI2 instance has zero msg listeners',
|
||||
() => {
|
||||
const mi2 = new MI2('gdb', []);
|
||||
|
||||
// No afterEach cleanup is intentionally omitted here to mirror the
|
||||
// pattern in breakpoint-error-handling.test.ts.
|
||||
// The constructor itself adds no listeners, so this should be 0.
|
||||
expect(mi2.listenerCount('msg')).toBe(0);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Preservation Property Tests — Task 2
|
||||
*
|
||||
* These tests establish the baseline behavior that MUST be preserved after the fix.
|
||||
* They MUST PASS on unfixed code.
|
||||
*
|
||||
* Property 2: Preservation — `debug-ready` Emission and Breakpoint Test Assertions Unchanged
|
||||
*
|
||||
* Validates: Requirements 3.1, 3.2, 3.3, 3.4
|
||||
*/
|
||||
|
||||
import { MI2 } from '../../src/backend/mi2/mi2';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test A — debug-ready emitted after 200ms
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Test A — debug-ready emitted after 200ms', () => {
|
||||
let mi2: MI2;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mi2 = new MI2('gdb', []);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up the timer if it's still pending
|
||||
if (mi2['debugReadyTimeout']) {
|
||||
clearTimeout(mi2['debugReadyTimeout']);
|
||||
}
|
||||
mi2.removeAllListeners();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates: Requirement 3.1
|
||||
* When onOutput() detects 'PlatformIO: Initialization completed' and the timeout
|
||||
* fires normally, 'debug-ready' MUST be emitted after 200ms.
|
||||
*/
|
||||
test('emits debug-ready exactly once after 200ms when trigger line is processed', () => {
|
||||
let debugReadyCount = 0;
|
||||
mi2.on('debug-ready', () => {
|
||||
debugReadyCount++;
|
||||
});
|
||||
|
||||
// A valid GDB console stream record containing the trigger string
|
||||
mi2.onOutput('~"PlatformIO: Initialization completed\\n"');
|
||||
|
||||
// Timer should be set but not yet fired
|
||||
expect(debugReadyCount).toBe(0);
|
||||
|
||||
// Advance fake timers by 200ms — the timeout should fire
|
||||
jest.advanceTimersByTime(200);
|
||||
|
||||
expect(debugReadyCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test B — debug-ready emitted immediately on generic-stopped
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Test B — debug-ready emitted immediately on generic-stopped', () => {
|
||||
let mi2: MI2;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mi2 = new MI2('gdb', []);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mi2.removeAllListeners();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates: Requirement 3.2
|
||||
* When 'generic-stopped' fires before the 200ms timeout, 'debug-ready' MUST be
|
||||
* emitted immediately and the timer MUST be cancelled (no second emission).
|
||||
*/
|
||||
test('emits debug-ready immediately when generic-stopped fires before 200ms', () => {
|
||||
let debugReadyCount = 0;
|
||||
mi2.on('debug-ready', () => {
|
||||
debugReadyCount++;
|
||||
});
|
||||
|
||||
// Set up the timer by processing the trigger line
|
||||
mi2.onOutput('~"PlatformIO: Initialization completed\\n"');
|
||||
|
||||
// Timer is pending but has not fired yet
|
||||
expect(debugReadyCount).toBe(0);
|
||||
|
||||
// Emit generic-stopped BEFORE advancing timers — should trigger immediate emission
|
||||
mi2.emit('generic-stopped', {});
|
||||
|
||||
// debug-ready should have been emitted immediately
|
||||
expect(debugReadyCount).toBe(1);
|
||||
|
||||
// Advance timers by 200ms — the original timeout should have been cancelled,
|
||||
// so no second emission should occur
|
||||
jest.advanceTimersByTime(200);
|
||||
|
||||
expect(debugReadyCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test C — non-trigger lines produce no timer
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Test C — non-trigger lines produce no timer', () => {
|
||||
let mi2: MI2;
|
||||
|
||||
beforeEach(() => {
|
||||
mi2 = new MI2('gdb', []);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mi2.removeAllListeners();
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates: Requirement 3.1 (by contrapositive)
|
||||
* Lines that do NOT contain 'PlatformIO: Initialization completed' MUST NOT
|
||||
* create a debugReadyTimeout.
|
||||
*/
|
||||
test('empty string does not create a debugReadyTimeout', () => {
|
||||
mi2.onOutput('');
|
||||
expect(mi2['debugReadyTimeout']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('plain stdout line does not create a debugReadyTimeout', () => {
|
||||
mi2.onOutput('~"Some other output\\n"');
|
||||
expect(mi2['debugReadyTimeout']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a different stream record does not create a debugReadyTimeout', () => {
|
||||
// @ prefix = target stream record (not console)
|
||||
mi2.onOutput('@"target output\\n"');
|
||||
expect(mi2['debugReadyTimeout']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a GDB result record does not create a debugReadyTimeout', () => {
|
||||
// A result record (^done) — not a stream record
|
||||
mi2.onOutput('^done');
|
||||
expect(mi2['debugReadyTimeout']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test D — removeAllListeners() zeroes listener count
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Test D — removeAllListeners() zeroes listener count', () => {
|
||||
/**
|
||||
* Validates: Requirement 3.4 (cleanup pattern)
|
||||
* After calling removeAllListeners(), all listener counts MUST be 0.
|
||||
*/
|
||||
test('listener counts are 0 after removeAllListeners()', () => {
|
||||
const mi2 = new MI2('gdb', []);
|
||||
|
||||
// Add some listeners manually
|
||||
mi2.on('msg', () => {});
|
||||
mi2.on('debug-ready', () => {});
|
||||
|
||||
// Confirm listeners were added
|
||||
expect(mi2.listenerCount('msg')).toBeGreaterThan(0);
|
||||
expect(mi2.listenerCount('debug-ready')).toBeGreaterThan(0);
|
||||
|
||||
// Remove all listeners
|
||||
mi2.removeAllListeners();
|
||||
|
||||
// All listener counts must be 0
|
||||
expect(mi2.listenerCount('msg')).toBe(0);
|
||||
expect(mi2.listenerCount('debug-ready')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
FreeRTOSThreadParser,
|
||||
RTOSDetector,
|
||||
RTOSManager,
|
||||
RTOSType,
|
||||
ThreadXThreadParser,
|
||||
ZephyrThreadParser,
|
||||
} from '../../src/backend/rtos'
|
||||
|
||||
function createReader(values: Record<string, any>) {
|
||||
return {
|
||||
evalExpression: jest.fn().mockImplementation((expression: string) => {
|
||||
if (!(expression in values)) {
|
||||
return Promise.reject(new Error(`missing ${expression}`))
|
||||
}
|
||||
return Promise.resolve({
|
||||
result: (path: string) => (path === 'value' ? values[expression] : undefined),
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('RTOSDetector', () => {
|
||||
test('detects FreeRTOS using pxCurrentTCB symbol', async () => {
|
||||
const detector = new RTOSDetector()
|
||||
const reader = createReader({ '&pxCurrentTCB': '0x20000000' })
|
||||
|
||||
await expect(detector.detect(reader as any)).resolves.toBe(RTOSType.FreeRTOS)
|
||||
})
|
||||
|
||||
test('detects ThreadX using _tx_thread_current_ptr symbol', async () => {
|
||||
const detector = new RTOSDetector()
|
||||
const reader = createReader({ '&_tx_thread_current_ptr': '0x24000000' })
|
||||
|
||||
await expect(detector.detect(reader as any)).resolves.toBe(RTOSType.ThreadX)
|
||||
})
|
||||
|
||||
test('detects Zephyr using _kernel.current symbol', async () => {
|
||||
const detector = new RTOSDetector()
|
||||
const reader = createReader({ '_kernel.current': '0x20003000' })
|
||||
|
||||
await expect(detector.detect(reader as any)).resolves.toBe(RTOSType.Zephyr)
|
||||
})
|
||||
|
||||
test('returns none when no RTOS markers are available', async () => {
|
||||
const detector = new RTOSDetector()
|
||||
const reader = {
|
||||
evalExpression: jest.fn().mockRejectedValue(new Error('missing')),
|
||||
}
|
||||
|
||||
await expect(detector.detect(reader as any)).resolves.toBe(RTOSType.None)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RTOS parsers', () => {
|
||||
test('parses the current FreeRTOS task metadata', async () => {
|
||||
const parser = new FreeRTOSThreadParser()
|
||||
const reader = createReader({
|
||||
pxCurrentTCB: '0x20000000',
|
||||
'((TCB_t *)pxCurrentTCB)->pcTaskName': '"IdleTask"',
|
||||
'((TCB_t *)pxCurrentTCB)->uxPriority': '3',
|
||||
'((TCB_t *)pxCurrentTCB)->eCurrentState': '0',
|
||||
'((TCB_t *)pxCurrentTCB)->pxTopOfStack': '0x20001000',
|
||||
'((TCB_t *)pxCurrentTCB)->pxStack': '0x20000000',
|
||||
'((TCB_t *)pxCurrentTCB)->pxEndOfStack': '0x20002000',
|
||||
})
|
||||
|
||||
const threads = await parser.parseThreads(reader as any, { currentGdbThreadId: 7 })
|
||||
|
||||
expect(threads).toHaveLength(1)
|
||||
expect(threads[0]).toMatchObject({
|
||||
id: 7,
|
||||
gdbThreadId: 7,
|
||||
name: 'IdleTask',
|
||||
priority: 3,
|
||||
state: 'running',
|
||||
source: RTOSType.FreeRTOS,
|
||||
})
|
||||
expect(threads[0].stackInfo).toMatchObject({
|
||||
base: 0x20000000,
|
||||
size: 0x2000,
|
||||
used: 0x1000,
|
||||
})
|
||||
})
|
||||
|
||||
test('parses ThreadX current thread metadata', async () => {
|
||||
const parser = new ThreadXThreadParser()
|
||||
const reader = createReader({
|
||||
_tx_thread_current_ptr: '0x24000000',
|
||||
'_tx_thread_current_ptr->tx_thread_name': '"control"',
|
||||
'_tx_thread_current_ptr->tx_thread_priority': '9',
|
||||
'_tx_thread_current_ptr->tx_thread_state': '4',
|
||||
'_tx_thread_current_ptr->tx_thread_stack_ptr': '0x24001000',
|
||||
'_tx_thread_current_ptr->tx_thread_stack_start': '0x24000000',
|
||||
'_tx_thread_current_ptr->tx_thread_stack_end': '0x24002000',
|
||||
})
|
||||
|
||||
const threads = await parser.parseThreads(reader as any, { currentGdbThreadId: 2 })
|
||||
|
||||
expect(threads[0]).toMatchObject({
|
||||
id: 2,
|
||||
name: 'control',
|
||||
priority: 9,
|
||||
state: 'blocked',
|
||||
source: RTOSType.ThreadX,
|
||||
})
|
||||
})
|
||||
|
||||
test('parses Zephyr current thread metadata', async () => {
|
||||
const parser = new ZephyrThreadParser()
|
||||
const reader = createReader({
|
||||
'_kernel.current': '0x20003000',
|
||||
'((struct k_thread *)_kernel.current)->name': '"shell"',
|
||||
'((struct k_thread *)_kernel.current)->base.prio': '-1',
|
||||
'((struct k_thread *)_kernel.current)->base.thread_state': '0',
|
||||
'((struct k_thread *)_kernel.current)->callee_saved.psp': '0x20003100',
|
||||
})
|
||||
|
||||
const threads = await parser.parseThreads(reader as any, { currentGdbThreadId: 4 })
|
||||
|
||||
expect(threads[0]).toMatchObject({
|
||||
id: 4,
|
||||
name: 'shell',
|
||||
priority: -1,
|
||||
state: 'running',
|
||||
source: RTOSType.Zephyr,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('RTOSManager', () => {
|
||||
test('respects explicit RTOS type selection', async () => {
|
||||
const manager = new RTOSManager()
|
||||
const reader = createReader({
|
||||
_tx_thread_current_ptr: '0x24000000',
|
||||
'_tx_thread_current_ptr->tx_thread_name': '"worker"',
|
||||
'_tx_thread_current_ptr->tx_thread_priority': '2',
|
||||
'_tx_thread_current_ptr->tx_thread_state': '3',
|
||||
'_tx_thread_current_ptr->tx_thread_stack_ptr': '0x24000100',
|
||||
'_tx_thread_current_ptr->tx_thread_stack_start': '0x24000000',
|
||||
'_tx_thread_current_ptr->tx_thread_stack_end': '0x24001000',
|
||||
})
|
||||
|
||||
const result = await manager.load(reader as any, {
|
||||
requestedType: 'threadx',
|
||||
currentGdbThreadId: 5,
|
||||
})
|
||||
|
||||
expect(result.type).toBe(RTOSType.ThreadX)
|
||||
expect(result.threads[0]).toMatchObject({ id: 5, name: 'worker' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { GDBDebugSession } from '../../src/backend/adapter'
|
||||
|
||||
function makeThreadInfo(id: number, label: string) {
|
||||
return [
|
||||
[
|
||||
'id',
|
||||
String(id),
|
||||
],
|
||||
[
|
||||
'target-id',
|
||||
`Thread ${id}`,
|
||||
],
|
||||
[
|
||||
'details',
|
||||
label,
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
describe('RTOS thread mapping', () => {
|
||||
test('threadsRequest enriches thread labels with RTOS metadata', async () => {
|
||||
const session = new GDBDebugSession() as any
|
||||
session.stopped = true
|
||||
session.currentThreadId = 2
|
||||
session.args = { rtos: { enabled: true, type: 'auto' } }
|
||||
session.miDebugger = {
|
||||
sendCommand: jest.fn().mockImplementation((command: string) => {
|
||||
if (command === 'thread-list-ids') {
|
||||
return Promise.resolve({
|
||||
result: (path: string) => {
|
||||
if (path === 'thread-ids') return [['id', '1'], ['id', '2']]
|
||||
if (path === 'current-thread-id') return '2'
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (command === 'thread-info 1') {
|
||||
return Promise.resolve({ result: (path: string) => (path === 'threads' ? [makeThreadInfo(1, 'main')] : undefined) })
|
||||
}
|
||||
|
||||
if (command === 'thread-info 2') {
|
||||
return Promise.resolve({ result: (path: string) => (path === 'threads' ? [makeThreadInfo(2, 'worker')] : undefined) })
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`unexpected ${command}`))
|
||||
}),
|
||||
}
|
||||
session.rtosManager = {
|
||||
load: jest.fn().mockResolvedValue({
|
||||
type: 'freertos',
|
||||
threads: [
|
||||
{
|
||||
id: 2,
|
||||
gdbThreadId: 2,
|
||||
name: 'IdleTask',
|
||||
state: 'running',
|
||||
priority: 1,
|
||||
source: 'freertos',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
session.sendResponse = jest.fn()
|
||||
session.sendErrorResponse = jest.fn()
|
||||
|
||||
const response: any = {}
|
||||
await session.threadsRequest(response)
|
||||
|
||||
expect(response.body.threads).toHaveLength(2)
|
||||
|
||||
// Order-independent: locate threads by id
|
||||
const mainThread = response.body.threads.find((t: any) => t.id === 1)
|
||||
const idleThread = response.body.threads.find((t: any) => t.id === 2)
|
||||
|
||||
expect(mainThread).toMatchObject({ id: 1, name: 'main' })
|
||||
expect(idleThread).toBeDefined()
|
||||
expect(idleThread.name).toContain('IdleTask')
|
||||
expect(idleThread.name).toContain('running')
|
||||
expect(idleThread.name).toContain('prio 1')
|
||||
})
|
||||
|
||||
test('stackTraceRequest resolves DAP thread ids through the RTOS map', async () => {
|
||||
const session = new GDBDebugSession() as any
|
||||
session.miDebugger = {
|
||||
getStack: jest.fn().mockResolvedValue([
|
||||
{
|
||||
level: '0',
|
||||
address: '0x1000',
|
||||
function: 'main',
|
||||
fileName: 'main.c',
|
||||
file: '/tmp/main.c',
|
||||
line: 42,
|
||||
},
|
||||
]),
|
||||
}
|
||||
session.checkFileExists = jest.fn().mockResolvedValue(true)
|
||||
session.symbolTable = { getFunctionByName: jest.fn().mockReturnValue(undefined) }
|
||||
session.dapThreadIdMap = new Map([[1001, 2]])
|
||||
session.sendResponse = jest.fn()
|
||||
session.sendErrorResponse = jest.fn()
|
||||
|
||||
const response: any = {}
|
||||
await session.stackTraceRequest(response, { threadId: 1001, startFrame: 0, levels: 20 })
|
||||
|
||||
expect(session.miDebugger.getStack).toHaveBeenCalledWith(2, 0, 20)
|
||||
expect(response.body.stackFrames).toHaveLength(1)
|
||||
expect(response.body.stackFrames[0].name).toBe('main@0x1000')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,354 @@
|
||||
import {
|
||||
DiagnosticsManager,
|
||||
ErrorAction,
|
||||
LogEntry,
|
||||
getDiagnosticsManager,
|
||||
resetDiagnosticsManager
|
||||
} from '../../src/frontend/diagnostics'
|
||||
|
||||
// Mock vscode - factory function to avoid hoisting issues
|
||||
jest.mock('vscode', () => {
|
||||
const mockThenable = {
|
||||
then: jest.fn(function(this: any, callback?: (value: any) => any) {
|
||||
if (callback) callback(undefined)
|
||||
return this
|
||||
}),
|
||||
catch: jest.fn().mockReturnThis()
|
||||
}
|
||||
|
||||
const mockOutputChannel = {
|
||||
appendLine: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
show: jest.fn()
|
||||
}
|
||||
|
||||
return {
|
||||
window: {
|
||||
createOutputChannel: jest.fn().mockReturnValue(mockOutputChannel),
|
||||
showErrorMessage: jest.fn().mockReturnValue(mockThenable),
|
||||
showWarningMessage: jest.fn().mockReturnValue(mockThenable),
|
||||
showInformationMessage: jest.fn().mockReturnValue(mockThenable),
|
||||
showOpenDialog: jest.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
env: {
|
||||
clipboard: {
|
||||
writeText: jest.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
openExternal: jest.fn().mockResolvedValue(true)
|
||||
},
|
||||
Uri: {
|
||||
parse: jest.fn()
|
||||
},
|
||||
commands: {
|
||||
executeCommand: jest.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
__mockOutputChannel: mockOutputChannel
|
||||
}
|
||||
})
|
||||
|
||||
import * as vscode from 'vscode'
|
||||
|
||||
describe('DiagnosticsManager', () => {
|
||||
let manager: DiagnosticsManager
|
||||
|
||||
beforeEach(() => {
|
||||
resetDiagnosticsManager()
|
||||
manager = getDiagnosticsManager()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Singleton Pattern', () => {
|
||||
test('getDiagnosticsManager should return same instance', () => {
|
||||
const instance1 = getDiagnosticsManager()
|
||||
const instance2 = getDiagnosticsManager()
|
||||
expect(instance1).toBe(instance2)
|
||||
})
|
||||
|
||||
test('resetDiagnosticsManager should create new instance on next get', () => {
|
||||
const instance1 = getDiagnosticsManager()
|
||||
resetDiagnosticsManager()
|
||||
const instance2 = getDiagnosticsManager()
|
||||
expect(instance1).not.toBe(instance2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Logging', () => {
|
||||
test('should log error message', () => {
|
||||
manager.error('TestSource', 'Test error message', 'Details')
|
||||
const entries = manager.getLogEntries('error')
|
||||
expect(entries.length).toBe(1)
|
||||
expect(entries[0].source).toBe('TestSource')
|
||||
expect(entries[0].message).toBe('Test error message')
|
||||
expect(entries[0].details).toBe('Details')
|
||||
expect(entries[0].level).toBe('error')
|
||||
})
|
||||
|
||||
test('should log warning message', () => {
|
||||
manager.warn('TestSource', 'Test warning')
|
||||
const entries = manager.getLogEntries('warn')
|
||||
expect(entries.length).toBe(1)
|
||||
expect(entries[0].level).toBe('warn')
|
||||
})
|
||||
|
||||
test('should log info message', () => {
|
||||
manager.info('TestSource', 'Test info')
|
||||
const entries = manager.getLogEntries('info')
|
||||
expect(entries.length).toBe(1)
|
||||
expect(entries[0].level).toBe('info')
|
||||
})
|
||||
|
||||
test('should log debug message when dev output enabled', () => {
|
||||
manager.setShowDevDebugOutput(true)
|
||||
manager.debug('TestSource', 'Test debug')
|
||||
const entries = manager.getLogEntries('debug')
|
||||
expect(entries.length).toBe(1)
|
||||
expect(entries[0].level).toBe('debug')
|
||||
})
|
||||
|
||||
test('should not log debug message when dev output disabled', () => {
|
||||
manager.setShowDevDebugOutput(false)
|
||||
manager.debug('TestSource', 'Test debug')
|
||||
const entries = manager.getLogEntries('debug')
|
||||
expect(entries.length).toBe(1) // Still stored, just not displayed
|
||||
})
|
||||
|
||||
test('should maintain log size limit', () => {
|
||||
// Add 1001 entries (limit is 1000)
|
||||
for (let i = 0; i < 1001; i++) {
|
||||
manager.info('Test', `Message ${i}`)
|
||||
}
|
||||
const entries = manager.getLogEntries()
|
||||
expect(entries.length).toBe(1000)
|
||||
// First entry should be removed
|
||||
expect(entries[0].message).toBe('Message 1')
|
||||
})
|
||||
|
||||
test('clearLog should remove all entries', () => {
|
||||
manager.info('Test', 'Message 1')
|
||||
manager.info('Test', 'Message 2')
|
||||
manager.clearLog()
|
||||
const entries = manager.getLogEntries()
|
||||
expect(entries.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Log Filtering', () => {
|
||||
beforeEach(() => {
|
||||
manager.error('Source1', 'Error message')
|
||||
manager.warn('Source2', 'Warning message')
|
||||
manager.info('Source3', 'Info message')
|
||||
manager.debug('Source4', 'Debug message')
|
||||
})
|
||||
|
||||
test('should filter by error level', () => {
|
||||
const entries = manager.getLogEntries('error')
|
||||
expect(entries.length).toBe(1)
|
||||
expect(entries[0].level).toBe('error')
|
||||
})
|
||||
|
||||
test('should return all entries when no filter', () => {
|
||||
const entries = manager.getLogEntries()
|
||||
expect(entries.length).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Log Export', () => {
|
||||
test('should export log in correct format', () => {
|
||||
manager.error('TestSource', 'Test message', 'Test details')
|
||||
const exported = manager.exportLog()
|
||||
|
||||
expect(exported).toContain('PlatformIO Debug Diagnostic Log')
|
||||
expect(exported).toContain('Total Entries: 1')
|
||||
expect(exported).toContain('[ERROR]')
|
||||
expect(exported).toContain('TestSource')
|
||||
expect(exported).toContain('Test message')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Display', () => {
|
||||
test('showError should call vscode.window.showErrorMessage', () => {
|
||||
manager.showError('Test error')
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Test error')
|
||||
})
|
||||
|
||||
test('showError with actions should include action labels', () => {
|
||||
const actions: ErrorAction[] = [
|
||||
{ label: 'Action 1', callback: jest.fn() },
|
||||
{ label: 'Action 2', callback: jest.fn() }
|
||||
]
|
||||
manager.showError('Test error', actions)
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
|
||||
'Test error',
|
||||
'Action 1',
|
||||
'Action 2'
|
||||
)
|
||||
})
|
||||
|
||||
test('showWarning should call vscode.window.showWarningMessage', () => {
|
||||
manager.showWarning('Test warning')
|
||||
expect(vscode.window.showWarningMessage).toHaveBeenCalledWith('Test warning')
|
||||
})
|
||||
|
||||
test('showInfo should call vscode.window.showInformationMessage', () => {
|
||||
manager.showInfo('Test info')
|
||||
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith('Test info')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GDB Error Pattern Matching', () => {
|
||||
test('should handle connection refused error', () => {
|
||||
const handled = manager.handleGDBError('Connection refused')
|
||||
expect(handled).toBe(true)
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should handle connection timeout error', () => {
|
||||
const handled = manager.handleGDBError('Connection timed out')
|
||||
expect(handled).toBe(true)
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should handle file not found error', () => {
|
||||
const handled = manager.handleGDBError('No such file or directory')
|
||||
expect(handled).toBe(true)
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should handle memory access error', () => {
|
||||
const handled = manager.handleGDBError('Cannot access memory')
|
||||
expect(handled).toBe(true)
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should handle remote replied with error', () => {
|
||||
const handled = manager.handleGDBError('Remote replied with error')
|
||||
expect(handled).toBe(true)
|
||||
// This is a warning, not error
|
||||
expect(vscode.window.showWarningMessage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should handle unrecognized command', () => {
|
||||
const handled = manager.handleGDBError('Unrecognized command')
|
||||
expect(handled).toBe(true)
|
||||
expect(vscode.window.showWarningMessage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should show generic error for unmatched pattern', () => {
|
||||
const handled = manager.handleGDBError('Some random error')
|
||||
expect(handled).toBe(false)
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalled()
|
||||
// Should include diagnostic actions
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
expect(call.length).toBeGreaterThan(1) // Has action labels
|
||||
})
|
||||
})
|
||||
|
||||
describe('Connection Error Handling', () => {
|
||||
test('should show connection error with troubleshooting', () => {
|
||||
manager.handleConnectionError('Connection refused', 'localhost', 3333)
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalled()
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
expect(call[0]).toContain('localhost:3333')
|
||||
// Should have multiple actions
|
||||
expect(call.length).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
test('should show connection error without host/port', () => {
|
||||
manager.handleConnectionError('Connection failed')
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalled()
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
expect(call[0]).not.toContain('undefined')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SVD Error Handling', () => {
|
||||
test('should show SVD error with path', () => {
|
||||
manager.handleSVDError('Parse error', '/path/to/device.svd')
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalled()
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
expect(call[0]).toContain('/path/to/device.svd')
|
||||
})
|
||||
|
||||
test('should show SVD error without path', () => {
|
||||
manager.handleSVDError('Parse error')
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalled()
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
expect(call[0]).toBe('Failed to load SVD file')
|
||||
})
|
||||
|
||||
test('SVD error should have locate file action', () => {
|
||||
manager.handleSVDError('Parse error')
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
// Check that action labels are present
|
||||
const labels = call.slice(1)
|
||||
expect(labels).toContain('Locate SVD File')
|
||||
expect(labels).toContain('Skip SVD Load')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memory Error Handling', () => {
|
||||
test('should show memory error with address', () => {
|
||||
manager.handleMemoryError('Access denied', 0x20000000)
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalled()
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
expect(call[0]).toContain('0x20000000')
|
||||
})
|
||||
|
||||
test('should show memory error without address', () => {
|
||||
manager.handleMemoryError('Access denied')
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalled()
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
expect(call[0]).toBe('Cannot access memory')
|
||||
})
|
||||
|
||||
test('memory error should have pause target action', () => {
|
||||
manager.handleMemoryError('Access denied', 0x20000000)
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
const labels = call.slice(1)
|
||||
expect(labels).toContain('Target May Not Be Halted')
|
||||
expect(labels).toContain('Check Address')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Output Channel', () => {
|
||||
test('showOutputChannel should call output channel show', () => {
|
||||
const mockOutputChannel = (vscode as any).__mockOutputChannel
|
||||
manager.showOutputChannel()
|
||||
expect(mockOutputChannel.show).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Action Callbacks', () => {
|
||||
test('error action callback should be executed when selected', async () => {
|
||||
const callback = jest.fn()
|
||||
const actions: ErrorAction[] = [
|
||||
{ label: 'Test Action', callback }
|
||||
]
|
||||
|
||||
// Mock the promise resolution
|
||||
;(vscode.window.showErrorMessage as jest.Mock).mockResolvedValue('Test Action')
|
||||
|
||||
manager.showError('Test', actions)
|
||||
|
||||
// Wait for promise to resolve
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(callback).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('info action should show documentation link', async () => {
|
||||
// Mock the promise resolution for connection troubleshooting
|
||||
;(vscode.window.showErrorMessage as jest.Mock).mockResolvedValue('Check Connection')
|
||||
|
||||
manager.handleConnectionError('Error', 'host', 1234)
|
||||
|
||||
// Wait for promise chain
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(vscode.window.showInformationMessage).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import { getDiagnosticsManager, resetDiagnosticsManager } from '../../src/frontend/diagnostics'
|
||||
|
||||
// Mock vscode - factory function to avoid hoisting issues
|
||||
jest.mock('vscode', () => {
|
||||
const mockThenable = {
|
||||
then: jest.fn(function(this: any, callback?: (value: any) => any) {
|
||||
if (callback) callback(undefined)
|
||||
return this
|
||||
}),
|
||||
catch: jest.fn().mockReturnThis()
|
||||
}
|
||||
|
||||
return {
|
||||
window: {
|
||||
createOutputChannel: jest.fn().mockReturnValue({
|
||||
appendLine: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
show: jest.fn()
|
||||
}),
|
||||
showErrorMessage: jest.fn().mockReturnValue(mockThenable),
|
||||
showWarningMessage: jest.fn().mockReturnValue(mockThenable),
|
||||
showInformationMessage: jest.fn().mockReturnValue(mockThenable)
|
||||
},
|
||||
env: {
|
||||
clipboard: {
|
||||
writeText: jest.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
openExternal: jest.fn().mockResolvedValue(true)
|
||||
},
|
||||
Uri: {
|
||||
parse: jest.fn()
|
||||
},
|
||||
commands: {
|
||||
executeCommand: jest.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import * as vscode from 'vscode'
|
||||
|
||||
describe('Error Pattern Matching', () => {
|
||||
let manager: ReturnType<typeof getDiagnosticsManager>
|
||||
|
||||
beforeEach(() => {
|
||||
resetDiagnosticsManager()
|
||||
manager = getDiagnosticsManager()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Connection Errors', () => {
|
||||
const connectionErrors = [
|
||||
'Connection refused',
|
||||
'Connection failed',
|
||||
'Connection timed out',
|
||||
'connection refused by peer',
|
||||
'CONNECTION FAILED'
|
||||
]
|
||||
|
||||
connectionErrors.forEach(error => {
|
||||
test(`should match "${error}"`, () => {
|
||||
const handled = manager.handleGDBError(error)
|
||||
expect(handled).toBe(true)
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Not Found Errors', () => {
|
||||
const fileErrors = [
|
||||
'No such file or directory'
|
||||
]
|
||||
|
||||
fileErrors.forEach(error => {
|
||||
test(`should match "${error}"`, () => {
|
||||
const handled = manager.handleGDBError(error)
|
||||
expect(handled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memory Access Errors', () => {
|
||||
const memoryErrors = [
|
||||
'Cannot access memory',
|
||||
'cannot access memory at address 0x20000000'
|
||||
]
|
||||
|
||||
memoryErrors.forEach(error => {
|
||||
test(`should match "${error}"`, () => {
|
||||
const handled = manager.handleGDBError(error)
|
||||
expect(handled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remote Protocol Errors', () => {
|
||||
const remoteErrors = [
|
||||
'Remote replied with error'
|
||||
]
|
||||
|
||||
remoteErrors.forEach(error => {
|
||||
test(`should match "${error}"`, () => {
|
||||
const handled = manager.handleGDBError(error)
|
||||
expect(handled).toBe(true)
|
||||
// These should show as warnings
|
||||
expect(vscode.window.showWarningMessage).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Command Errors', () => {
|
||||
const commandErrors = [
|
||||
'Unrecognized command'
|
||||
]
|
||||
|
||||
commandErrors.forEach(error => {
|
||||
test(`should match "${error}"`, () => {
|
||||
const handled = manager.handleGDBError(error)
|
||||
expect(handled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unmatched Errors', () => {
|
||||
const unmatchedErrors = [
|
||||
'Something unexpected happened',
|
||||
'Random error message',
|
||||
'Custom user error'
|
||||
]
|
||||
|
||||
unmatchedErrors.forEach(error => {
|
||||
test(`should not match "${error}" and show generic error`, () => {
|
||||
const handled = manager.handleGDBError(error)
|
||||
expect(handled).toBe(false)
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalled()
|
||||
// Should include actions
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
expect(call.length).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Severity Classification', () => {
|
||||
test('connection errors should be classified as error severity', () => {
|
||||
manager.handleGDBError('Connection refused')
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalled()
|
||||
expect(vscode.window.showWarningMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('remote protocol errors should be classified as warning severity', () => {
|
||||
manager.handleGDBError('Remote replied with error')
|
||||
expect(vscode.window.showWarningMessage).toHaveBeenCalled()
|
||||
expect(vscode.window.showErrorMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Message Content', () => {
|
||||
test('should include user-friendly message for connection errors', () => {
|
||||
manager.handleGDBError('Connection refused')
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
expect(call[0]).toContain('target')
|
||||
expect(call[0]).toContain('connected')
|
||||
})
|
||||
|
||||
test('should include user-friendly message for file errors', () => {
|
||||
manager.handleGDBError('No such file or directory')
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
expect(call[0]).toContain('configuration')
|
||||
})
|
||||
|
||||
test('should include user-friendly message for memory errors', () => {
|
||||
manager.handleGDBError('Cannot access memory')
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
expect(call[0]).toContain('halted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Action Labels by Error Type', () => {
|
||||
test('connection errors should have Check Connection action', () => {
|
||||
manager.handleGDBError('Connection refused')
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
const labels = call.slice(1)
|
||||
expect(labels).toContain('Check Connection')
|
||||
})
|
||||
|
||||
test('memory errors should have Pause Target action', () => {
|
||||
manager.handleGDBError('Cannot access memory')
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
const labels = call.slice(1)
|
||||
expect(labels).toContain('Pause Target')
|
||||
})
|
||||
|
||||
test('connection errors should have Retry action', () => {
|
||||
manager.handleGDBError('Connection refused')
|
||||
const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
const labels = call.slice(1)
|
||||
expect(labels).toContain('Retry')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Case Insensitivity', () => {
|
||||
test('should match errors regardless of case', () => {
|
||||
const variations = [
|
||||
'CONNECTION REFUSED',
|
||||
'connection refused',
|
||||
'Connection Refused',
|
||||
'CoNnEcTiOn ReFuSeD'
|
||||
]
|
||||
|
||||
variations.forEach(error => {
|
||||
const handled = manager.handleGDBError(error)
|
||||
expect(handled).toBe(true)
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Tests for the connection troubleshooting wizard diagnostic flow.
|
||||
*
|
||||
* Verifies that the domain-specific error handlers produce the right
|
||||
* error messages and actionable recovery steps, and that action callbacks
|
||||
* trigger the expected VS Code commands.
|
||||
*/
|
||||
|
||||
import { getDiagnosticsManager, resetDiagnosticsManager } from '../../src/frontend/diagnostics'
|
||||
|
||||
// Capture the action callbacks passed to showErrorMessage so we can invoke them
|
||||
let capturedActions: Array<{ label: string; callback: () => void }> = []
|
||||
|
||||
jest.mock('vscode', () => {
|
||||
// Track the last set of ErrorAction labels that were shown
|
||||
const capturedLabels: string[] = []
|
||||
|
||||
return {
|
||||
window: {
|
||||
createOutputChannel: jest.fn().mockReturnValue({
|
||||
appendLine: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
show: jest.fn(),
|
||||
}),
|
||||
// Resolve with the first action label to simulate the user clicking it
|
||||
showErrorMessage: jest.fn().mockImplementation((_msg: string, ...labels: string[]) => {
|
||||
capturedLabels.splice(0, capturedLabels.length, ...labels)
|
||||
return Promise.resolve(labels[0])
|
||||
}),
|
||||
showWarningMessage: jest.fn().mockImplementation((_msg: string, ...labels: string[]) =>
|
||||
Promise.resolve(labels[0])
|
||||
),
|
||||
showInformationMessage: jest.fn().mockImplementation((_msg: string, ...labels: string[]) =>
|
||||
Promise.resolve(labels[0])
|
||||
),
|
||||
showOpenDialog: jest.fn().mockResolvedValue([{ fsPath: '/path/to/file.svd' }]),
|
||||
},
|
||||
env: {
|
||||
clipboard: { writeText: jest.fn().mockResolvedValue(undefined) },
|
||||
openExternal: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
Uri: {
|
||||
parse: jest.fn((s: string) => ({ toString: () => s })),
|
||||
},
|
||||
commands: {
|
||||
executeCommand: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import * as vscode from 'vscode'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function getManager() {
|
||||
return getDiagnosticsManager()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test suites
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('Troubleshooting Wizard — handleConnectionError', () => {
|
||||
beforeEach(() => {
|
||||
resetDiagnosticsManager()
|
||||
jest.clearAllMocks()
|
||||
capturedActions = []
|
||||
})
|
||||
|
||||
test('shows an error message mentioning the host:port when provided', () => {
|
||||
getManager().handleConnectionError('refused', 'localhost', 3333)
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining('localhost:3333'),
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
test('shows an error message without host:port when omitted', () => {
|
||||
getManager().handleConnectionError('refused')
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining('debug server'),
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
test('offers "Check Connection", "Restart Debug" and "Show Diagnostics" actions', () => {
|
||||
getManager().handleConnectionError('timed out', '192.168.1.1', 2331)
|
||||
const callArgs = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
const actionLabels: string[] = callArgs.slice(1)
|
||||
expect(actionLabels).toContain('Check Connection')
|
||||
expect(actionLabels).toContain('Restart Debug')
|
||||
expect(actionLabels).toContain('Show Diagnostics')
|
||||
})
|
||||
|
||||
test('"Restart Debug" action triggers workbench.action.debug.restart command', async () => {
|
||||
// The mock resolves with the first label; simulate selecting "Restart Debug" by
|
||||
// overriding the mock for this specific call.
|
||||
;(vscode.window.showErrorMessage as jest.Mock).mockResolvedValueOnce('Restart Debug')
|
||||
getManager().handleConnectionError('refused', 'localhost', 3333)
|
||||
// Allow the promise chain to settle
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
|
||||
'workbench.action.debug.restart'
|
||||
)
|
||||
})
|
||||
|
||||
test('logs the error at "error" level', () => {
|
||||
const mgr = getManager()
|
||||
mgr.handleConnectionError('refused', 'localhost', 3333)
|
||||
const errors = mgr.getLogEntries('error')
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors[0].source).toBe('Connection')
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('Troubleshooting Wizard — handleSVDError', () => {
|
||||
beforeEach(() => {
|
||||
resetDiagnosticsManager()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('includes the SVD path in the error message when provided', () => {
|
||||
getManager().handleSVDError('parse failed', '/project/device.svd')
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining('device.svd'),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
test('shows a generic message when no path is provided', () => {
|
||||
getManager().handleSVDError('not found')
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SVD file'),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
test('offers "Locate SVD File" and "Skip SVD Load" actions', () => {
|
||||
getManager().handleSVDError('invalid', '/bad.svd')
|
||||
const callArgs = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
const actionLabels: string[] = callArgs.slice(1)
|
||||
expect(actionLabels).toContain('Locate SVD File')
|
||||
expect(actionLabels).toContain('Skip SVD Load')
|
||||
})
|
||||
|
||||
test('"Locate SVD File" action opens a file dialog and reloads SVD', async () => {
|
||||
;(vscode.window.showErrorMessage as jest.Mock).mockResolvedValueOnce('Locate SVD File')
|
||||
getManager().handleSVDError('bad', '/bad.svd')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(vscode.window.showOpenDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ canSelectFiles: true })
|
||||
)
|
||||
})
|
||||
|
||||
test('logs the error at "error" level with SVD source', () => {
|
||||
const mgr = getManager()
|
||||
mgr.handleSVDError('corrupt', '/a.svd')
|
||||
const errors = mgr.getLogEntries('error')
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors[0].source).toBe('SVD')
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('Troubleshooting Wizard — handleMemoryError', () => {
|
||||
beforeEach(() => {
|
||||
resetDiagnosticsManager()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('includes the hex address in the message when provided', () => {
|
||||
getManager().handleMemoryError('fault', 0x20000000)
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/20000000/i),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
test('shows a generic message when address is omitted', () => {
|
||||
getManager().handleMemoryError('fault')
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining('memory'),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
test('offers "Target May Not Be Halted" and "Check Address" actions', () => {
|
||||
getManager().handleMemoryError('access denied', 0x00)
|
||||
const callArgs = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
const actionLabels: string[] = callArgs.slice(1)
|
||||
expect(actionLabels).toContain('Target May Not Be Halted')
|
||||
expect(actionLabels).toContain('Check Address')
|
||||
})
|
||||
|
||||
test('logs the error at "error" level with Memory source', () => {
|
||||
const mgr = getManager()
|
||||
mgr.handleMemoryError('bus error', 0x40000000)
|
||||
const errors = mgr.getLogEntries('error')
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors[0].source).toBe('Memory')
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('Troubleshooting Wizard — handleGDBError generic fallback', () => {
|
||||
beforeEach(() => {
|
||||
resetDiagnosticsManager()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('returns false and offers "Show Diagnostics" and "Copy Error" for unknown errors', () => {
|
||||
const handled = getManager().handleGDBError('some unknown gdb error xyz')
|
||||
expect(handled).toBe(false)
|
||||
const callArgs = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0]
|
||||
const actionLabels: string[] = callArgs.slice(1)
|
||||
expect(actionLabels).toContain('Show Diagnostics')
|
||||
expect(actionLabels).toContain('Copy Error')
|
||||
})
|
||||
|
||||
test('"Copy Error" action copies the error message to the clipboard', async () => {
|
||||
;(vscode.window.showErrorMessage as jest.Mock).mockResolvedValueOnce('Copy Error')
|
||||
getManager().handleGDBError('unknown fatal error')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(vscode.env.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('unknown fatal error')
|
||||
)
|
||||
})
|
||||
|
||||
test('"Show Diagnostics" action opens the output channel', async () => {
|
||||
;(vscode.window.showErrorMessage as jest.Mock).mockResolvedValueOnce('Show Diagnostics')
|
||||
// Create the manager first so createOutputChannel has been called
|
||||
const mgr = getManager()
|
||||
const mockChannel = (vscode.window.createOutputChannel as jest.Mock).mock.results[0].value
|
||||
mgr.handleGDBError('unknown error')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(mockChannel.show).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('Troubleshooting Wizard — exportLog', () => {
|
||||
beforeEach(() => {
|
||||
resetDiagnosticsManager()
|
||||
})
|
||||
|
||||
test('exported log contains all logged entries', () => {
|
||||
const mgr = getManager()
|
||||
mgr.error('SrcA', 'msg1')
|
||||
mgr.warn('SrcB', 'msg2')
|
||||
mgr.info('SrcC', 'msg3')
|
||||
const log = mgr.exportLog()
|
||||
expect(log).toContain('msg1')
|
||||
expect(log).toContain('msg2')
|
||||
expect(log).toContain('msg3')
|
||||
})
|
||||
|
||||
test('exported log contains a header with entry count', () => {
|
||||
const mgr = getManager()
|
||||
mgr.error('S', 'e1')
|
||||
mgr.error('S', 'e2')
|
||||
const log = mgr.exportLog()
|
||||
expect(log).toContain('Total Entries: 2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,287 @@
|
||||
import { MemoryContentProvider, MemoryDataType, Endianness } from '../../src/frontend/memory_content_provider'
|
||||
|
||||
// Mock vscode
|
||||
jest.mock('vscode', () => ({
|
||||
window: {
|
||||
createTextEditorDecorationType: jest.fn().mockReturnValue({
|
||||
dispose: jest.fn()
|
||||
}),
|
||||
showErrorMessage: jest.fn().mockResolvedValue(undefined),
|
||||
showInformationMessage: jest.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
debug: {
|
||||
activeDebugSession: undefined
|
||||
},
|
||||
EventEmitter: jest.fn().mockImplementation(() => ({
|
||||
event: jest.fn(),
|
||||
fire: jest.fn()
|
||||
})),
|
||||
Position: jest.fn().mockImplementation((line: number, character: number) => ({
|
||||
line,
|
||||
character
|
||||
})),
|
||||
Range: jest.fn().mockImplementation((start: any, end: any) => {
|
||||
// Handle both Position objects and (line, char) numbers
|
||||
const startLine = typeof start === 'number' ? start : start?.line ?? 0
|
||||
const startChar = typeof start === 'number' ? end : start?.character ?? 0
|
||||
const endLine = typeof end === 'number' ? end : end?.line ?? 0
|
||||
const endChar = typeof end === 'number' ? 0 : end?.character ?? 0
|
||||
|
||||
return {
|
||||
start: { line: startLine, character: startChar },
|
||||
end: { line: endLine, character: endChar }
|
||||
}
|
||||
}),
|
||||
OverviewRulerLane: {
|
||||
Right: 4
|
||||
},
|
||||
Uri: {
|
||||
parse: jest.fn()
|
||||
},
|
||||
workspace: {
|
||||
textDocuments: []
|
||||
},
|
||||
env: {
|
||||
clipboard: {
|
||||
writeText: jest.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
describe('MemoryContentProvider', () => {
|
||||
let provider: MemoryContentProvider
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new MemoryContentProvider()
|
||||
})
|
||||
|
||||
describe('Display-setting isolation between provider instances', () => {
|
||||
test('setting dataType on providerA does not affect providerB', () => {
|
||||
const providerA = new MemoryContentProvider()
|
||||
const providerB = new MemoryContentProvider()
|
||||
|
||||
const allTypes = [
|
||||
MemoryDataType.U8,
|
||||
MemoryDataType.U16,
|
||||
MemoryDataType.U32,
|
||||
MemoryDataType.U64,
|
||||
MemoryDataType.I8,
|
||||
MemoryDataType.I16,
|
||||
MemoryDataType.I32,
|
||||
MemoryDataType.I64,
|
||||
MemoryDataType.Float,
|
||||
MemoryDataType.Double,
|
||||
]
|
||||
|
||||
for (const type of allTypes) {
|
||||
providerA.setDataType(type)
|
||||
// providerB must remain at its own default regardless of providerA
|
||||
expect(providerB.getDataType()).toBe(MemoryDataType.U8)
|
||||
}
|
||||
|
||||
// After resetting providerA to Float, providerB still at U8
|
||||
providerA.setDataType(MemoryDataType.Float)
|
||||
expect(providerB.getDataType()).toBe(MemoryDataType.U8)
|
||||
})
|
||||
|
||||
test('endianness changes on providerA do not affect providerB', () => {
|
||||
const providerA = new MemoryContentProvider()
|
||||
const providerB = new MemoryContentProvider()
|
||||
|
||||
// Both default to Little
|
||||
expect(providerA.getEndianness()).toBe(Endianness.Little)
|
||||
expect(providerB.getEndianness()).toBe(Endianness.Little)
|
||||
|
||||
providerA.setEndianness(Endianness.Big)
|
||||
expect(providerA.getEndianness()).toBe(Endianness.Big)
|
||||
expect(providerB.getEndianness()).toBe(Endianness.Little)
|
||||
|
||||
providerA.toggleEndianness()
|
||||
expect(providerA.getEndianness()).toBe(Endianness.Little)
|
||||
expect(providerB.getEndianness()).toBe(Endianness.Little)
|
||||
|
||||
providerB.setEndianness(Endianness.Big)
|
||||
expect(providerA.getEndianness()).toBe(Endianness.Little)
|
||||
expect(providerB.getEndianness()).toBe(Endianness.Big)
|
||||
})
|
||||
|
||||
test('per-URI settings are isolated: changing URI A settings does not affect URI B fallback', () => {
|
||||
const p = new MemoryContentProvider()
|
||||
const uriA = 'examinememory://mem?address=0x20000000&length=0x10'
|
||||
const uriB = 'examinememory://mem?address=0x30000000&length=0x10'
|
||||
|
||||
// Set URI A to U32 / Big
|
||||
p.setDataTypeForUri(uriA, MemoryDataType.U32)
|
||||
p.toggleEndiannessForUri(uriA)
|
||||
|
||||
// URI B must still return the global defaults (U8 / Little)
|
||||
expect(p.getDataTypeForUri(uriB)).toBe(MemoryDataType.U8)
|
||||
expect(p.getEndiannessForUri(uriB)).toBe(Endianness.Little)
|
||||
|
||||
// URI A has its own values
|
||||
expect(p.getDataTypeForUri(uriA)).toBe(MemoryDataType.U32)
|
||||
expect(p.getEndiannessForUri(uriA)).toBe(Endianness.Big)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Type Settings', () => {
|
||||
test('should default to U8 data type', () => {
|
||||
expect(provider.getDataType()).toBe(MemoryDataType.U8)
|
||||
})
|
||||
|
||||
test('should set and get data type', () => {
|
||||
provider.setDataType(MemoryDataType.U32)
|
||||
expect(provider.getDataType()).toBe(MemoryDataType.U32)
|
||||
|
||||
provider.setDataType(MemoryDataType.Float)
|
||||
expect(provider.getDataType()).toBe(MemoryDataType.Float)
|
||||
})
|
||||
|
||||
test('should support all data types', () => {
|
||||
const allTypes = [
|
||||
MemoryDataType.U8,
|
||||
MemoryDataType.U16,
|
||||
MemoryDataType.U32,
|
||||
MemoryDataType.U64,
|
||||
MemoryDataType.I8,
|
||||
MemoryDataType.I16,
|
||||
MemoryDataType.I32,
|
||||
MemoryDataType.I64,
|
||||
MemoryDataType.Float,
|
||||
MemoryDataType.Double
|
||||
]
|
||||
|
||||
allTypes.forEach(type => {
|
||||
provider.setDataType(type)
|
||||
expect(provider.getDataType()).toBe(type)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Endianness Settings', () => {
|
||||
test('should default to little endian', () => {
|
||||
expect(provider.getEndianness()).toBe(Endianness.Little)
|
||||
})
|
||||
|
||||
test('should set and get endianness', () => {
|
||||
provider.setEndianness(Endianness.Big)
|
||||
expect(provider.getEndianness()).toBe(Endianness.Big)
|
||||
|
||||
provider.setEndianness(Endianness.Little)
|
||||
expect(provider.getEndianness()).toBe(Endianness.Little)
|
||||
})
|
||||
|
||||
test('should toggle endianness', () => {
|
||||
// Start with little
|
||||
expect(provider.getEndianness()).toBe(Endianness.Little)
|
||||
|
||||
// Toggle to big
|
||||
provider.toggleEndianness()
|
||||
expect(provider.getEndianness()).toBe(Endianness.Big)
|
||||
|
||||
// Toggle back to little
|
||||
provider.toggleEndianness()
|
||||
expect(provider.getEndianness()).toBe(Endianness.Little)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Position/Offset Mapping', () => {
|
||||
test('should map byte offset to position', () => {
|
||||
// First row, first byte (hex column)
|
||||
const pos1 = provider.getPosition(0, false)
|
||||
expect(pos1.line).toBe(2)
|
||||
expect(pos1.character).toBe(10) // firstBytePos
|
||||
|
||||
// First row, second byte
|
||||
const pos2 = provider.getPosition(1, false)
|
||||
expect(pos2.line).toBe(2)
|
||||
expect(pos2.character).toBe(13) // 10 + 3
|
||||
|
||||
// Second row, first byte
|
||||
const pos3 = provider.getPosition(16, false)
|
||||
expect(pos3.line).toBe(3)
|
||||
expect(pos3.character).toBe(10)
|
||||
})
|
||||
|
||||
test('should map byte offset to ASCII position', () => {
|
||||
const firstAsciiPos = (provider as any).firstAsciiPos as number
|
||||
|
||||
// First row, first byte (ASCII column)
|
||||
const pos1 = provider.getPosition(0, true)
|
||||
expect(pos1.line).toBe(2)
|
||||
expect(pos1.character).toBe(firstAsciiPos)
|
||||
|
||||
// Second row, first byte in ASCII
|
||||
const pos2 = provider.getPosition(16, true)
|
||||
expect(pos2.line).toBe(3)
|
||||
expect(pos2.character).toBe(firstAsciiPos)
|
||||
})
|
||||
|
||||
test('should return undefined for invalid positions', () => {
|
||||
// Header lines return undefined (clicking the header has no byte meaning)
|
||||
const offset = provider.getOffset({ line: 0, character: 15 } as any)
|
||||
expect(offset).toBeUndefined()
|
||||
|
||||
// Character before first byte position
|
||||
const offset2 = provider.getOffset({ line: 2, character: 5 } as any)
|
||||
expect(offset2).toBeUndefined()
|
||||
})
|
||||
|
||||
test('should calculate offset for hex column positions', () => {
|
||||
// First byte position in hex column
|
||||
const offset1 = provider.getOffset({ line: 2, character: 10 } as any)
|
||||
expect(offset1).toBe(0)
|
||||
|
||||
// Second byte (each byte takes 3 chars: "00 ")
|
||||
const offset2 = provider.getOffset({ line: 2, character: 13 } as any)
|
||||
expect(offset2).toBe(1)
|
||||
|
||||
// Third byte
|
||||
const offset3 = provider.getOffset({ line: 2, character: 16 } as any)
|
||||
expect(offset3).toBe(2)
|
||||
})
|
||||
|
||||
test('should treat the separator between hex and ASCII columns as invalid', () => {
|
||||
expect(provider.getOffset({ line: 2, character: 58 } as any)).toBeUndefined()
|
||||
expect(provider.getOffset({ line: 2, character: 59 } as any)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Range Building', () => {
|
||||
test('should build ranges for single byte', () => {
|
||||
const ranges = provider.getRanges(0, 0, false)
|
||||
expect(ranges.length).toBeGreaterThan(0)
|
||||
expect(ranges[0].start.line).toBe(2)
|
||||
})
|
||||
|
||||
test('should build ranges spanning multiple lines', () => {
|
||||
// Bytes 0-20 span 2 lines (16 bytes per line)
|
||||
const ranges = provider.getRanges(0, 20, false)
|
||||
expect(ranges.length).toBe(2)
|
||||
expect(ranges[0].start.line).toBe(2)
|
||||
expect(ranges[1].start.line).toBe(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('MemoryDataType Enum', () => {
|
||||
test('should have correct values', () => {
|
||||
expect(MemoryDataType.U8).toBe('u8')
|
||||
expect(MemoryDataType.U16).toBe('u16')
|
||||
expect(MemoryDataType.U32).toBe('u32')
|
||||
expect(MemoryDataType.U64).toBe('u64')
|
||||
expect(MemoryDataType.I8).toBe('i8')
|
||||
expect(MemoryDataType.I16).toBe('i16')
|
||||
expect(MemoryDataType.I32).toBe('i32')
|
||||
expect(MemoryDataType.I64).toBe('i64')
|
||||
expect(MemoryDataType.Float).toBe('float')
|
||||
expect(MemoryDataType.Double).toBe('double')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Endianness Enum', () => {
|
||||
test('should have correct values', () => {
|
||||
expect(Endianness.Little).toBe('little')
|
||||
expect(Endianness.Big).toBe('big')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Tests for memory data type interpretation and endianness handling.
|
||||
* These tests verify that values are correctly read from byte arrays
|
||||
* with various data types and endianness settings.
|
||||
*/
|
||||
|
||||
import { MemoryContentProvider, MemoryDataType, Endianness } from '../../src/frontend/memory_content_provider'
|
||||
|
||||
// Mock vscode
|
||||
jest.mock('vscode', () => ({
|
||||
window: {
|
||||
createTextEditorDecorationType: jest.fn().mockReturnValue({
|
||||
dispose: jest.fn()
|
||||
}),
|
||||
showErrorMessage: jest.fn().mockResolvedValue(undefined),
|
||||
showInformationMessage: jest.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
debug: {
|
||||
activeDebugSession: undefined
|
||||
},
|
||||
EventEmitter: jest.fn().mockImplementation(() => ({
|
||||
event: jest.fn(),
|
||||
fire: jest.fn()
|
||||
})),
|
||||
Position: jest.fn().mockImplementation((line: number, character: number) => ({
|
||||
line,
|
||||
character
|
||||
})),
|
||||
Range: jest.fn(),
|
||||
OverviewRulerLane: {
|
||||
Right: 4
|
||||
},
|
||||
Uri: {
|
||||
parse: jest.fn()
|
||||
},
|
||||
workspace: {
|
||||
textDocuments: []
|
||||
}
|
||||
}))
|
||||
|
||||
describe('Memory Data Type Interpretation', () => {
|
||||
let provider: MemoryContentProvider
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new MemoryContentProvider()
|
||||
})
|
||||
|
||||
describe('Unsigned Integer Types', () => {
|
||||
test('U8: should read single byte value', () => {
|
||||
provider.setDataType(MemoryDataType.U8)
|
||||
provider.setEndianness(Endianness.Little)
|
||||
|
||||
// Single byte 0xAB = 171
|
||||
expect((provider as any).readValue([0xAB], 0, MemoryDataType.U8, Endianness.Little)).toBe(171)
|
||||
// Single byte 0x00 = 0
|
||||
expect((provider as any).readValue([0x00], 0, MemoryDataType.U8, Endianness.Little)).toBe(0)
|
||||
// Single byte 0xFF = 255
|
||||
expect((provider as any).readValue([0xFF], 0, MemoryDataType.U8, Endianness.Little)).toBe(255)
|
||||
})
|
||||
|
||||
test('U16: should read 16-bit value little endian', () => {
|
||||
provider.setDataType(MemoryDataType.U16)
|
||||
provider.setEndianness(Endianness.Little)
|
||||
|
||||
// Bytes [0x12, 0x34] in LE = 0x3412 = 13330
|
||||
expect((provider as any).readValue([0x12, 0x34], 0, MemoryDataType.U16, Endianness.Little)).toBe(0x3412)
|
||||
|
||||
// Bytes [0x01, 0x00] in LE = 0x0001 = 1
|
||||
expect((provider as any).readValue([0x01, 0x00], 0, MemoryDataType.U16, Endianness.Little)).toBe(1)
|
||||
|
||||
// Bytes [0x00, 0xFF] in LE = 0xFF00 = 65280
|
||||
expect((provider as any).readValue([0x00, 0xFF], 0, MemoryDataType.U16, Endianness.Little)).toBe(0xFF00)
|
||||
})
|
||||
|
||||
test('U16: should read 16-bit value big endian', () => {
|
||||
provider.setDataType(MemoryDataType.U16)
|
||||
provider.setEndianness(Endianness.Big)
|
||||
|
||||
// 0x1234 in BE: bytes [0x12, 0x34] = 0x1234 = 4660
|
||||
expect((provider as any).readValue([0x12, 0x34], 0, MemoryDataType.U16, Endianness.Big)).toBe(0x1234)
|
||||
|
||||
// 0x0100 in BE: bytes [0x01, 0x00] = 0x0100 = 256
|
||||
expect((provider as any).readValue([0x01, 0x00], 0, MemoryDataType.U16, Endianness.Big)).toBe(0x0100)
|
||||
})
|
||||
|
||||
test('U32: should read 32-bit value little endian', () => {
|
||||
provider.setDataType(MemoryDataType.U32)
|
||||
|
||||
// 0x78563412 in LE: bytes [0x12, 0x34, 0x56, 0x78]
|
||||
const value = (provider as any).readValue([0x12, 0x34, 0x56, 0x78], 0, MemoryDataType.U32, Endianness.Little)
|
||||
expect(value).toBe(0x78563412)
|
||||
})
|
||||
|
||||
test('U32: should read 32-bit value big endian', () => {
|
||||
provider.setDataType(MemoryDataType.U32)
|
||||
|
||||
// 0x12345678 in BE: bytes [0x12, 0x34, 0x56, 0x78]
|
||||
const value = (provider as any).readValue([0x12, 0x34, 0x56, 0x78], 0, MemoryDataType.U32, Endianness.Big)
|
||||
expect(value).toBe(0x12345678)
|
||||
})
|
||||
|
||||
test('U64: should read 64-bit value little endian', () => {
|
||||
provider.setDataType(MemoryDataType.U64)
|
||||
|
||||
// 0xEFCDAB8967452301 in LE
|
||||
const bytes = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]
|
||||
const value = (provider as any).readValue(bytes, 0, MemoryDataType.U64, Endianness.Little)
|
||||
expect(value).toBe(BigInt('0xEFCDAB8967452301'))
|
||||
})
|
||||
|
||||
test('U64: should read 64-bit value big endian', () => {
|
||||
provider.setDataType(MemoryDataType.U64)
|
||||
|
||||
// 0x0123456789ABCDEF in BE
|
||||
const bytes = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]
|
||||
const value = (provider as any).readValue(bytes, 0, MemoryDataType.U64, Endianness.Big)
|
||||
expect(value).toBe(BigInt('0x0123456789ABCDEF'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Signed Integer Types', () => {
|
||||
test('I8: should read signed byte value', () => {
|
||||
provider.setDataType(MemoryDataType.I8)
|
||||
|
||||
// 0x7F = 127 (positive max)
|
||||
expect((provider as any).readValue([0x7F], 0, MemoryDataType.I8, Endianness.Little)).toBe(127)
|
||||
|
||||
// 0x80 = -128 (negative)
|
||||
expect((provider as any).readValue([0x80], 0, MemoryDataType.I8, Endianness.Little)).toBe(-128)
|
||||
|
||||
// 0xFF = -1
|
||||
expect((provider as any).readValue([0xFF], 0, MemoryDataType.I8, Endianness.Little)).toBe(-1)
|
||||
|
||||
// 0x00 = 0
|
||||
expect((provider as any).readValue([0x00], 0, MemoryDataType.I8, Endianness.Little)).toBe(0)
|
||||
})
|
||||
|
||||
test('I16: should read signed 16-bit value', () => {
|
||||
provider.setDataType(MemoryDataType.I16)
|
||||
|
||||
// 0x7FFF = 32767 (positive max)
|
||||
expect((provider as any).readValue([0xFF, 0x7F], 0, MemoryDataType.I16, Endianness.Little)).toBe(32767)
|
||||
|
||||
// 0x8000 = -32768 (negative)
|
||||
expect((provider as any).readValue([0x00, 0x80], 0, MemoryDataType.I16, Endianness.Little)).toBe(-32768)
|
||||
|
||||
// 0xFFFF = -1
|
||||
expect((provider as any).readValue([0xFF, 0xFF], 0, MemoryDataType.I16, Endianness.Little)).toBe(-1)
|
||||
})
|
||||
|
||||
test('I32: should read signed 32-bit value', () => {
|
||||
provider.setDataType(MemoryDataType.I32)
|
||||
|
||||
// 0x7FFFFFFF = 2147483647 (positive max)
|
||||
expect((provider as any).readValue([0xFF, 0xFF, 0xFF, 0x7F], 0, MemoryDataType.I32, Endianness.Little))
|
||||
.toBe(2147483647)
|
||||
|
||||
// 0x80000000 = -2147483648 (negative)
|
||||
expect((provider as any).readValue([0x00, 0x00, 0x00, 0x80], 0, MemoryDataType.I32, Endianness.Little))
|
||||
.toBe(-2147483648)
|
||||
|
||||
// 0xFFFFFFFF = -1
|
||||
expect((provider as any).readValue([0xFF, 0xFF, 0xFF, 0xFF], 0, MemoryDataType.I32, Endianness.Little))
|
||||
.toBe(-1)
|
||||
})
|
||||
|
||||
test('I64: should read signed 64-bit value', () => {
|
||||
provider.setDataType(MemoryDataType.I64)
|
||||
|
||||
// 0x7FFFFFFFFFFFFFFF = max positive
|
||||
const maxBytes = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F]
|
||||
const maxValue = (provider as any).readValue(maxBytes, 0, MemoryDataType.I64, Endianness.Little)
|
||||
expect(maxValue).toBe(BigInt('9223372036854775807'))
|
||||
|
||||
// 0xFFFFFFFFFFFFFFFF = -1
|
||||
const negBytes = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]
|
||||
const negValue = (provider as any).readValue(negBytes, 0, MemoryDataType.I64, Endianness.Little)
|
||||
expect(negValue).toBe(BigInt(-1))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Floating Point Types', () => {
|
||||
test('Float: should read 32-bit float little endian', () => {
|
||||
provider.setDataType(MemoryDataType.Float)
|
||||
|
||||
// IEEE 754: 0x3F800000 = 1.0
|
||||
const value1 = (provider as any).readValue([0x00, 0x00, 0x80, 0x3F], 0, MemoryDataType.Float, Endianness.Little)
|
||||
expect(value1).toBeCloseTo(1.0, 6)
|
||||
|
||||
// IEEE 754: 0x40400000 = 3.0
|
||||
const value2 = (provider as any).readValue([0x00, 0x00, 0x40, 0x40], 0, MemoryDataType.Float, Endianness.Little)
|
||||
expect(value2).toBeCloseTo(3.0, 6)
|
||||
|
||||
// IEEE 754: 0x00000000 = 0.0
|
||||
const value3 = (provider as any).readValue([0x00, 0x00, 0x00, 0x00], 0, MemoryDataType.Float, Endianness.Little)
|
||||
expect(value3).toBeCloseTo(0.0, 6)
|
||||
})
|
||||
|
||||
test('Float: should read 32-bit float big endian', () => {
|
||||
provider.setDataType(MemoryDataType.Float)
|
||||
|
||||
// IEEE 754: 0x3F800000 = 1.0 in BE
|
||||
const value = (provider as any).readValue([0x3F, 0x80, 0x00, 0x00], 0, MemoryDataType.Float, Endianness.Big)
|
||||
expect(value).toBeCloseTo(1.0, 6)
|
||||
})
|
||||
|
||||
test('Double: should read 64-bit double little endian', () => {
|
||||
provider.setDataType(MemoryDataType.Double)
|
||||
|
||||
// IEEE 754: 0x3FF0000000000000 = 1.0
|
||||
const bytes = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x3F]
|
||||
const value = (provider as any).readValue(bytes, 0, MemoryDataType.Double, Endianness.Little)
|
||||
expect(value).toBeCloseTo(1.0, 10)
|
||||
|
||||
// IEEE 754: 0x4008000000000000 = 3.0
|
||||
const bytes3 = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x40]
|
||||
const value3 = (provider as any).readValue(bytes3, 0, MemoryDataType.Double, Endianness.Little)
|
||||
expect(value3).toBeCloseTo(3.0, 10)
|
||||
})
|
||||
|
||||
test('Double: should read 64-bit double big endian', () => {
|
||||
provider.setDataType(MemoryDataType.Double)
|
||||
|
||||
// IEEE 754: 0x3FF0000000000000 = 1.0 in BE
|
||||
const bytes = [0x3F, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
|
||||
const value = (provider as any).readValue(bytes, 0, MemoryDataType.Double, Endianness.Big)
|
||||
expect(value).toBeCloseTo(1.0, 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type Size Helper', () => {
|
||||
test('should return correct type sizes', () => {
|
||||
expect((provider as any).getTypeSize(MemoryDataType.U8)).toBe(1)
|
||||
expect((provider as any).getTypeSize(MemoryDataType.I8)).toBe(1)
|
||||
expect((provider as any).getTypeSize(MemoryDataType.U16)).toBe(2)
|
||||
expect((provider as any).getTypeSize(MemoryDataType.I16)).toBe(2)
|
||||
expect((provider as any).getTypeSize(MemoryDataType.U32)).toBe(4)
|
||||
expect((provider as any).getTypeSize(MemoryDataType.I32)).toBe(4)
|
||||
expect((provider as any).getTypeSize(MemoryDataType.Float)).toBe(4)
|
||||
expect((provider as any).getTypeSize(MemoryDataType.U64)).toBe(8)
|
||||
expect((provider as any).getTypeSize(MemoryDataType.I64)).toBe(8)
|
||||
expect((provider as any).getTypeSize(MemoryDataType.Double)).toBe(8)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Tests for memory write operations.
|
||||
*/
|
||||
|
||||
import { MemoryContentProvider } from '../../src/frontend/memory_content_provider'
|
||||
|
||||
// Mock vscode with controllable debug session
|
||||
const mockCustomRequest = jest.fn()
|
||||
let hasActiveDebugSession = false
|
||||
|
||||
jest.mock('vscode', () => ({
|
||||
window: {
|
||||
createTextEditorDecorationType: jest.fn().mockReturnValue({
|
||||
dispose: jest.fn()
|
||||
}),
|
||||
showErrorMessage: jest.fn().mockResolvedValue(undefined),
|
||||
showInformationMessage: jest.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
debug: {
|
||||
get activeDebugSession() {
|
||||
return hasActiveDebugSession ? {
|
||||
customRequest: mockCustomRequest
|
||||
} : undefined
|
||||
}
|
||||
},
|
||||
EventEmitter: jest.fn().mockImplementation(() => ({
|
||||
event: jest.fn(),
|
||||
fire: jest.fn()
|
||||
})),
|
||||
Position: jest.fn().mockImplementation((line: number, character: number) => ({
|
||||
line,
|
||||
character
|
||||
})),
|
||||
Range: jest.fn(),
|
||||
OverviewRulerLane: {
|
||||
Right: 4
|
||||
},
|
||||
Uri: {
|
||||
parse: jest.fn()
|
||||
},
|
||||
workspace: {
|
||||
textDocuments: []
|
||||
}
|
||||
}))
|
||||
|
||||
describe('Memory Write Operations', () => {
|
||||
let provider: MemoryContentProvider
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new MemoryContentProvider()
|
||||
hasActiveDebugSession = true
|
||||
mockCustomRequest.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
hasActiveDebugSession = false
|
||||
})
|
||||
|
||||
describe('writeByte', () => {
|
||||
test('should write single byte successfully', async () => {
|
||||
mockCustomRequest.mockResolvedValue({ success: true })
|
||||
|
||||
const success = await provider.writeByte(0x20000000, 0xAB)
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', {
|
||||
address: 0x20000000,
|
||||
data: 'ab'
|
||||
})
|
||||
})
|
||||
|
||||
test('should write byte 0x00', async () => {
|
||||
mockCustomRequest.mockResolvedValue({ success: true })
|
||||
|
||||
const success = await provider.writeByte(0x20000000, 0x00)
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', {
|
||||
address: 0x20000000,
|
||||
data: '00'
|
||||
})
|
||||
})
|
||||
|
||||
test('should write byte 0xFF', async () => {
|
||||
mockCustomRequest.mockResolvedValue({ success: true })
|
||||
|
||||
const success = await provider.writeByte(0x20000000, 0xFF)
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', {
|
||||
address: 0x20000000,
|
||||
data: 'ff'
|
||||
})
|
||||
})
|
||||
|
||||
test('should reject value > 0xFF with an error', async () => {
|
||||
const success = await provider.writeByte(0x20000000, 0x1AB)
|
||||
|
||||
expect(success).toBe(false)
|
||||
expect(mockCustomRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should reject negative values', async () => {
|
||||
const success = await provider.writeByte(0x20000000, -1)
|
||||
|
||||
expect(success).toBe(false)
|
||||
expect(mockCustomRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should reject fractional values', async () => {
|
||||
const successA = await provider.writeByte(0x20000000, 1.5)
|
||||
expect(successA).toBe(false)
|
||||
expect(mockCustomRequest).not.toHaveBeenCalled()
|
||||
|
||||
const successB = await provider.writeByte(0x20000000, 0.5)
|
||||
expect(successB).toBe(false)
|
||||
expect(mockCustomRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should return false when no debug session', async () => {
|
||||
hasActiveDebugSession = false
|
||||
|
||||
const success = await provider.writeByte(0x20000000, 0xAB)
|
||||
|
||||
expect(success).toBe(false)
|
||||
expect(mockCustomRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should return false on write error', async () => {
|
||||
mockCustomRequest.mockRejectedValue(new Error('Memory access denied'))
|
||||
|
||||
const success = await provider.writeByte(0x20000000, 0xAB)
|
||||
|
||||
expect(success).toBe(false)
|
||||
})
|
||||
|
||||
test('should write to different addresses', async () => {
|
||||
mockCustomRequest.mockResolvedValue({ success: true })
|
||||
|
||||
// Test various addresses
|
||||
const addresses = [
|
||||
0x00000000,
|
||||
0x20000000,
|
||||
0x40000000,
|
||||
0x08000000,
|
||||
0xFFFFFFFF
|
||||
]
|
||||
|
||||
for (const address of addresses) {
|
||||
mockCustomRequest.mockClear()
|
||||
const success = await provider.writeByte(address, 0x42)
|
||||
expect(success).toBe(true)
|
||||
expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', {
|
||||
address,
|
||||
data: '42'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeBytes', () => {
|
||||
test('should write multiple bytes successfully', async () => {
|
||||
mockCustomRequest.mockResolvedValue({ success: true })
|
||||
|
||||
const success = await provider.writeBytes(0x20000000, [0x12, 0x34, 0x56, 0x78])
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', {
|
||||
address: 0x20000000,
|
||||
data: '12345678'
|
||||
})
|
||||
})
|
||||
|
||||
test('should write single byte array', async () => {
|
||||
mockCustomRequest.mockResolvedValue({ success: true })
|
||||
|
||||
const success = await provider.writeBytes(0x20000000, [0xAB])
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', {
|
||||
address: 0x20000000,
|
||||
data: 'ab'
|
||||
})
|
||||
})
|
||||
|
||||
test('should write empty array', async () => {
|
||||
mockCustomRequest.mockResolvedValue({ success: true })
|
||||
|
||||
const success = await provider.writeBytes(0x20000000, [])
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', {
|
||||
address: 0x20000000,
|
||||
data: ''
|
||||
})
|
||||
})
|
||||
|
||||
test('should reject array containing values > 0xFF with an error', async () => {
|
||||
const success = await provider.writeBytes(0x20000000, [0x1FF, 0x2AB, 0x3CD])
|
||||
|
||||
expect(success).toBe(false)
|
||||
expect(mockCustomRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should return false when no debug session', async () => {
|
||||
hasActiveDebugSession = false
|
||||
|
||||
const success = await provider.writeBytes(0x20000000, [0x12, 0x34])
|
||||
|
||||
expect(success).toBe(false)
|
||||
expect(mockCustomRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should return false on write error', async () => {
|
||||
mockCustomRequest.mockRejectedValue(new Error('Target not halted'))
|
||||
|
||||
const success = await provider.writeBytes(0x20000000, [0x12, 0x34])
|
||||
|
||||
expect(success).toBe(false)
|
||||
})
|
||||
|
||||
test('should write large byte arrays', async () => {
|
||||
mockCustomRequest.mockResolvedValue({ success: true })
|
||||
|
||||
const largeArray = new Array(256).fill(0).map((_, i) => i)
|
||||
const success = await provider.writeBytes(0x20000000, largeArray)
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', {
|
||||
address: 0x20000000,
|
||||
data: largeArray.map(v => v.toString(16).padStart(2, '0')).join('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hex Formatting', () => {
|
||||
test('should format bytes as lowercase hex', async () => {
|
||||
mockCustomRequest.mockResolvedValue({ success: true })
|
||||
|
||||
await provider.writeByte(0x20000000, 0xAB)
|
||||
|
||||
const callArgs = mockCustomRequest.mock.calls[0]
|
||||
expect(callArgs[1].data).toBe('ab')
|
||||
})
|
||||
|
||||
test('should pad single hex digit with zero', async () => {
|
||||
mockCustomRequest.mockResolvedValue({ success: true })
|
||||
|
||||
await provider.writeByte(0x20000000, 0x5)
|
||||
|
||||
const callArgs = mockCustomRequest.mock.calls[0]
|
||||
expect(callArgs[1].data).toBe('05')
|
||||
})
|
||||
|
||||
test('should handle 0x00 correctly', async () => {
|
||||
mockCustomRequest.mockResolvedValue({ success: true })
|
||||
|
||||
await provider.writeByte(0x20000000, 0x00)
|
||||
|
||||
const callArgs = mockCustomRequest.mock.calls[0]
|
||||
expect(callArgs[1].data).toBe('00')
|
||||
})
|
||||
|
||||
test('should handle 0xFF correctly', async () => {
|
||||
mockCustomRequest.mockResolvedValue({ success: true })
|
||||
|
||||
await provider.writeByte(0x20000000, 0xFF)
|
||||
|
||||
const callArgs = mockCustomRequest.mock.calls[0]
|
||||
expect(callArgs[1].data).toBe('ff')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Tests for the peripheral search/filter QuickPick (Phase 3 SVD Enhancements).
|
||||
*
|
||||
* Verifies that PeripheralTreeProvider.search builds QuickPick items from the
|
||||
* loaded peripherals, expands the chosen entry, and refreshes the tree view.
|
||||
*/
|
||||
|
||||
jest.mock('vscode', () => ({
|
||||
...jest.requireActual('../../__mocks__/vscode'),
|
||||
}))
|
||||
|
||||
import * as vscode from 'vscode'
|
||||
import {
|
||||
PeripheralTreeProvider,
|
||||
PeripheralNode,
|
||||
} from '../../src/frontend/peripheral'
|
||||
|
||||
function makePeripheral(name: string, baseAddress: number, description = ''): PeripheralNode {
|
||||
return new PeripheralNode({
|
||||
name,
|
||||
baseAddress,
|
||||
description,
|
||||
totalLength: 0x100,
|
||||
size: 32,
|
||||
resetValue: 0n,
|
||||
})
|
||||
}
|
||||
|
||||
describe('Peripheral Search/Filter', () => {
|
||||
let provider: PeripheralTreeProvider
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new PeripheralTreeProvider()
|
||||
// Inject peripherals via the parsing pipeline test seam.
|
||||
;(provider as any).peripherials = [
|
||||
makePeripheral('TIMER1', 0x40000000, 'General-purpose timer'),
|
||||
makePeripheral('GPIO_A', 0x50000000, 'GPIO Port A'),
|
||||
makePeripheral('UART2', 0x60000000, 'Universal Async RX/TX'),
|
||||
]
|
||||
;(vscode.window.showQuickPick as jest.Mock).mockReset()
|
||||
;(vscode.window.showInformationMessage as jest.Mock).mockReset()
|
||||
})
|
||||
|
||||
test('passes peripheral metadata as QuickPick items', async () => {
|
||||
;(vscode.window.showQuickPick as jest.Mock).mockResolvedValue(undefined)
|
||||
|
||||
await provider.search()
|
||||
|
||||
const call = (vscode.window.showQuickPick as jest.Mock).mock.calls[0]
|
||||
const items = call[0] as Array<{ label: string; description: string; detail: string }>
|
||||
expect(items.map((i) => i.label)).toEqual(['TIMER1', 'GPIO_A', 'UART2'])
|
||||
expect(items[0].description).toMatch(/0x40000000/i)
|
||||
expect(items[1].detail).toBe('GPIO Port A')
|
||||
|
||||
const options = call[1]
|
||||
expect(options).toMatchObject({
|
||||
matchOnDescription: true,
|
||||
matchOnDetail: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('expands the chosen peripheral and refreshes the tree', async () => {
|
||||
;(vscode.window.showQuickPick as jest.Mock).mockResolvedValue({
|
||||
label: 'GPIO_A',
|
||||
description: '0x50000000',
|
||||
detail: 'GPIO Port A',
|
||||
})
|
||||
|
||||
const refreshSpy = jest.spyOn(provider as any, 'refresh')
|
||||
await provider.search()
|
||||
|
||||
const target = (provider as any).peripherials.find(
|
||||
(p: PeripheralNode) => p.name === 'GPIO_A'
|
||||
)
|
||||
expect(target.expanded).toBe(true)
|
||||
expect(refreshSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('handles user cancellation without expanding', async () => {
|
||||
;(vscode.window.showQuickPick as jest.Mock).mockResolvedValue(undefined)
|
||||
|
||||
await provider.search()
|
||||
|
||||
for (const p of (provider as any).peripherials as PeripheralNode[]) {
|
||||
expect(p.expanded).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('shows an info message when no peripherals are loaded', async () => {
|
||||
;(provider as any).peripherials = []
|
||||
await provider.search()
|
||||
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No peripherals')
|
||||
)
|
||||
expect(vscode.window.showQuickPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('reveals the selected peripheral when a TreeView is attached', async () => {
|
||||
const reveal = jest.fn().mockResolvedValue(undefined)
|
||||
provider.setTreeView({ reveal } as any)
|
||||
|
||||
;(vscode.window.showQuickPick as jest.Mock).mockResolvedValue({
|
||||
label: 'UART2',
|
||||
description: '0x60000000',
|
||||
detail: 'Universal Async RX/TX',
|
||||
})
|
||||
|
||||
await provider.search()
|
||||
|
||||
expect(reveal).toHaveBeenCalledTimes(1)
|
||||
const [, options] = reveal.mock.calls[0]
|
||||
expect(options).toEqual({ select: true, focus: true, expand: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Tests for register value change highlighting (Phase 3 SVD Enhancements).
|
||||
*
|
||||
* Verifies that RegisterNode tracks previousValue/valueChanged across reads
|
||||
* and exposes a tree icon when the register differs from its reset value.
|
||||
*/
|
||||
|
||||
jest.mock('vscode', () => ({
|
||||
...jest.requireActual('../../__mocks__/vscode'),
|
||||
}))
|
||||
|
||||
import {
|
||||
PeripheralNode,
|
||||
RegisterNode,
|
||||
AccessType,
|
||||
} from '../../src/frontend/peripheral'
|
||||
|
||||
function makeRegister(reset: bigint = 0n): {
|
||||
peripheral: PeripheralNode
|
||||
register: RegisterNode
|
||||
} {
|
||||
const peripheral = new PeripheralNode({
|
||||
name: 'P',
|
||||
baseAddress: 0,
|
||||
description: '',
|
||||
totalLength: 16,
|
||||
size: 32,
|
||||
resetValue: 0n,
|
||||
})
|
||||
const register = new RegisterNode(peripheral, {
|
||||
name: 'R',
|
||||
addressOffset: 0,
|
||||
size: 32,
|
||||
resetValue: reset,
|
||||
accessType: AccessType.ReadWrite,
|
||||
})
|
||||
return { peripheral, register }
|
||||
}
|
||||
|
||||
function setBytes(peripheral: PeripheralNode, bytes: number[]): void {
|
||||
peripheral.currentValue = bytes
|
||||
}
|
||||
|
||||
describe('Register Change Tracking', () => {
|
||||
test('initialises with currentValue and previousValue equal to resetValue', () => {
|
||||
const { register } = makeRegister(0xDEADBEEFn)
|
||||
expect(register.currentValue).toBe(0xDEADBEEFn)
|
||||
expect(register.previousValue).toBe(0xDEADBEEFn)
|
||||
expect(register.valueChanged).toBe(false)
|
||||
})
|
||||
|
||||
test('flags valueChanged when the read produces a new value', async () => {
|
||||
const { peripheral, register } = makeRegister(0n)
|
||||
|
||||
setBytes(peripheral, [0x78, 0x56, 0x34, 0x12])
|
||||
await register.update()
|
||||
|
||||
expect(register.currentValue).toBe(0x12345678n)
|
||||
expect(register.previousValue).toBe(0n)
|
||||
expect(register.valueChanged).toBe(true)
|
||||
})
|
||||
|
||||
test('clears valueChanged when consecutive reads yield the same value', async () => {
|
||||
const { peripheral, register } = makeRegister(0n)
|
||||
|
||||
setBytes(peripheral, [0x01, 0x00, 0x00, 0x00])
|
||||
await register.update()
|
||||
expect(register.valueChanged).toBe(true)
|
||||
|
||||
// Second read produces the same value — change flag must clear.
|
||||
await register.update()
|
||||
expect(register.currentValue).toBe(1n)
|
||||
expect(register.previousValue).toBe(1n)
|
||||
expect(register.valueChanged).toBe(false)
|
||||
})
|
||||
|
||||
test('tree node carries an icon when current value differs from reset', () => {
|
||||
const { peripheral, register } = makeRegister(0n)
|
||||
register.currentValue = 0x42n
|
||||
|
||||
const node = register.getTreeNode()
|
||||
expect(node.iconPath).toBeDefined()
|
||||
expect((node.iconPath as any).id).toBe('circle-filled')
|
||||
expect(typeof node.tooltip).toBe('string')
|
||||
expect(node.tooltip as string).toMatch(/Reset:/)
|
||||
expect(node.tooltip as string).toMatch(/Current:/)
|
||||
})
|
||||
|
||||
test('tree node has no change icon when value matches reset value', () => {
|
||||
const { register } = makeRegister(0xCAFEn)
|
||||
// currentValue still equals resetValue from constructor
|
||||
const node = register.getTreeNode()
|
||||
expect(node.iconPath).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Tests for SVD <derivedFrom> inheritance (Phase 3 SVD Enhancements).
|
||||
*
|
||||
* Verifies that PeripheralTreeProvider correctly resolves derivedFrom
|
||||
* references on peripherals, registers, clusters, and fields, including
|
||||
* transitive chains and circular-reference protection.
|
||||
*/
|
||||
|
||||
jest.mock('vscode', () => ({
|
||||
...jest.requireActual('../../__mocks__/vscode'),
|
||||
}))
|
||||
|
||||
import { PeripheralTreeProvider } from '../../src/frontend/peripheral'
|
||||
|
||||
describe('SVD derivedFrom', () => {
|
||||
let provider: PeripheralTreeProvider
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new PeripheralTreeProvider()
|
||||
})
|
||||
|
||||
test('peripheral inherits registers and properties from base', () => {
|
||||
const map: Record<string, any> = {
|
||||
UART1: {
|
||||
name: 'UART1',
|
||||
baseAddress: 0x40000000,
|
||||
size: 32,
|
||||
registers: {
|
||||
register: [
|
||||
{ name: 'CR', addressOffset: 0x0, size: 32 },
|
||||
],
|
||||
},
|
||||
},
|
||||
UART2: {
|
||||
name: 'UART2',
|
||||
baseAddress: 0x40001000,
|
||||
'@_derivedFrom': 'UART1',
|
||||
},
|
||||
}
|
||||
|
||||
;(provider as any)._resolvePeripheralDerivedFrom(map)
|
||||
|
||||
expect(map.UART2.registers.register[0].name).toBe('CR')
|
||||
// Derived peripheral keeps its own baseAddress
|
||||
expect(map.UART2.baseAddress).toBe(0x40001000)
|
||||
// Marker is removed after resolution
|
||||
expect(map.UART2['@_derivedFrom']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('derived peripheral with extra register keeps all base registers', () => {
|
||||
const map: Record<string, any> = {
|
||||
UART1: {
|
||||
name: 'UART1',
|
||||
baseAddress: 0x40000000,
|
||||
registers: {
|
||||
register: [
|
||||
{ name: 'CR', addressOffset: 0x0, size: 32 },
|
||||
{ name: 'DR', addressOffset: 0x4, size: 32 },
|
||||
],
|
||||
},
|
||||
},
|
||||
UART2: {
|
||||
name: 'UART2',
|
||||
baseAddress: 0x40001000,
|
||||
'@_derivedFrom': 'UART1',
|
||||
// Derived peripheral overrides one register and adds a new one
|
||||
registers: {
|
||||
register: [
|
||||
{ name: 'CR', addressOffset: 0x0, size: 32, access: 'read-write' },
|
||||
{ name: 'SR', addressOffset: 0x8, size: 32 },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
;(provider as any)._resolvePeripheralDerivedFrom(map)
|
||||
|
||||
const names = map.UART2.registers.register.map((r: any) => r.name)
|
||||
// Base register DR must be preserved
|
||||
expect(names).toContain('DR')
|
||||
// Overridden register CR must be present with derived properties
|
||||
expect(names).toContain('CR')
|
||||
const cr = map.UART2.registers.register.find((r: any) => r.name === 'CR')
|
||||
expect(cr.access).toBe('read-write')
|
||||
// New register from derived
|
||||
expect(names).toContain('SR')
|
||||
})
|
||||
|
||||
test('resolves transitive derivedFrom chains', () => {
|
||||
const map: Record<string, any> = {
|
||||
A: { name: 'A', baseAddress: 0x100, value: 'fromA' },
|
||||
B: { name: 'B', baseAddress: 0x200, '@_derivedFrom': 'A' },
|
||||
C: { name: 'C', baseAddress: 0x300, '@_derivedFrom': 'B' },
|
||||
}
|
||||
|
||||
;(provider as any)._resolvePeripheralDerivedFrom(map)
|
||||
|
||||
expect(map.C.value).toBe('fromA')
|
||||
expect(map.C.baseAddress).toBe(0x300)
|
||||
})
|
||||
|
||||
test('throws on circular peripheral derivedFrom references', () => {
|
||||
const map: Record<string, any> = {
|
||||
A: { name: 'A', '@_derivedFrom': 'B' },
|
||||
B: { name: 'B', '@_derivedFrom': 'A' },
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
(provider as any)._resolvePeripheralDerivedFrom(map)
|
||||
).toThrow(/Circular derivedFrom/)
|
||||
})
|
||||
|
||||
test('register-level derivedFrom inherits fields from base register', () => {
|
||||
const periph = {
|
||||
name: 'PERIPH',
|
||||
registers: {
|
||||
register: [
|
||||
{
|
||||
name: 'BASE',
|
||||
addressOffset: 0x0,
|
||||
fields: {
|
||||
field: [
|
||||
{ name: 'EN', bitOffset: 0, bitWidth: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'DERIVED',
|
||||
addressOffset: 0x4,
|
||||
'@_derivedFrom': 'BASE',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
;(provider as any)._resolveInnerDerivedFrom(periph)
|
||||
|
||||
const derived = periph.registers.register.find(
|
||||
(r: any) => r.name === 'DERIVED'
|
||||
)
|
||||
expect(derived.fields.field[0].name).toBe('EN')
|
||||
expect(derived.addressOffset).toBe(0x4)
|
||||
expect(derived['@_derivedFrom']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('cluster-level derivedFrom inherits registers from base cluster', () => {
|
||||
const periph = {
|
||||
name: 'PERIPH',
|
||||
registers: {
|
||||
cluster: [
|
||||
{
|
||||
name: 'CHAN_BASE',
|
||||
addressOffset: 0x0,
|
||||
register: [
|
||||
{ name: 'CFG', addressOffset: 0x0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'CHAN1',
|
||||
addressOffset: 0x10,
|
||||
'@_derivedFrom': 'CHAN_BASE',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
;(provider as any)._resolveInnerDerivedFrom(periph)
|
||||
|
||||
const derived = periph.registers.cluster.find(
|
||||
(c: any) => c.name === 'CHAN1'
|
||||
)
|
||||
expect(derived.register[0].name).toBe('CFG')
|
||||
expect(derived.addressOffset).toBe(0x10)
|
||||
})
|
||||
|
||||
test('field-level derivedFrom inherits properties from base field', () => {
|
||||
const periph = {
|
||||
name: 'PERIPH',
|
||||
registers: {
|
||||
register: [
|
||||
{
|
||||
name: 'CTRL',
|
||||
addressOffset: 0x0,
|
||||
fields: {
|
||||
field: [
|
||||
{
|
||||
name: 'EN',
|
||||
bitOffset: 0,
|
||||
bitWidth: 1,
|
||||
description: 'Enable',
|
||||
},
|
||||
{
|
||||
name: 'EN2',
|
||||
bitOffset: 1,
|
||||
'@_derivedFrom': 'EN',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
;(provider as any)._resolveInnerDerivedFrom(periph)
|
||||
|
||||
const en2 = periph.registers.register[0].fields.field.find(
|
||||
(f: any) => f.name === 'EN2'
|
||||
)
|
||||
expect(en2.bitWidth).toBe(1)
|
||||
expect(en2.description).toBe('Enable')
|
||||
expect(en2.bitOffset).toBe(1)
|
||||
})
|
||||
|
||||
test('throws on circular register derivedFrom references', () => {
|
||||
const periph = {
|
||||
registers: {
|
||||
register: [
|
||||
{ name: 'A', addressOffset: 0, '@_derivedFrom': 'B' },
|
||||
{ name: 'B', addressOffset: 4, '@_derivedFrom': 'A' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
(provider as any)._resolveInnerDerivedFrom(periph)
|
||||
).toThrow(/Circular derivedFrom/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Tests for SVD file discovery (Phase 3 SVD Enhancements).
|
||||
*
|
||||
* Verifies that PeripheralTreeProvider.findSVDFile searches the configured
|
||||
* locations in the expected order and prefers files matching a device name.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
|
||||
jest.mock('vscode', () => ({
|
||||
...jest.requireActual('../../__mocks__/vscode'),
|
||||
}))
|
||||
|
||||
let mockHome: string | undefined
|
||||
jest.mock('os', () => {
|
||||
const actual = jest.requireActual('os')
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => mockHome ?? actual.homedir(),
|
||||
}
|
||||
})
|
||||
|
||||
import * as vscode from 'vscode'
|
||||
import { PeripheralTreeProvider } from '../../src/frontend/peripheral'
|
||||
|
||||
describe('SVD File Discovery', () => {
|
||||
let tmpRoot: string
|
||||
let workspaceDir: string
|
||||
let fakeHome: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'svd-discovery-'))
|
||||
workspaceDir = path.join(tmpRoot, 'workspace')
|
||||
fs.mkdirSync(workspaceDir, { recursive: true })
|
||||
|
||||
// Redirect homedir so PlatformIO discovery does not touch a real install
|
||||
fakeHome = path.join(tmpRoot, 'fakehome')
|
||||
fs.mkdirSync(fakeHome, { recursive: true })
|
||||
mockHome = fakeHome
|
||||
|
||||
;(vscode.workspace as any).workspaceFolders = [
|
||||
{ uri: { fsPath: workspaceDir }, name: 'ws', index: 0 },
|
||||
]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockHome = undefined
|
||||
;(vscode.workspace as any).workspaceFolders = undefined
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('returns undefined when no SVD files exist', () => {
|
||||
const provider = new PeripheralTreeProvider()
|
||||
expect(provider.findSVDFile()).toBeUndefined()
|
||||
})
|
||||
|
||||
test('discovers .svd file at the workspace root', () => {
|
||||
const svd = path.join(workspaceDir, 'device.svd')
|
||||
fs.writeFileSync(svd, '<device/>')
|
||||
const provider = new PeripheralTreeProvider()
|
||||
expect(provider.findSVDFile()).toBe(svd)
|
||||
})
|
||||
|
||||
test('prefers .vscode/*.svd over workspace root', () => {
|
||||
const vscodeDir = path.join(workspaceDir, '.vscode')
|
||||
fs.mkdirSync(vscodeDir)
|
||||
const dotSvd = path.join(vscodeDir, 'a.svd')
|
||||
const rootSvd = path.join(workspaceDir, 'b.svd')
|
||||
fs.writeFileSync(dotSvd, '<device/>')
|
||||
fs.writeFileSync(rootSvd, '<device/>')
|
||||
const provider = new PeripheralTreeProvider()
|
||||
expect(provider.findSVDFile()).toBe(dotSvd)
|
||||
})
|
||||
|
||||
test('discovers SVDs under ~/.platformio/packages/<pkg>/svd', () => {
|
||||
const pkgSvdDir = path.join(
|
||||
fakeHome,
|
||||
'.platformio',
|
||||
'packages',
|
||||
'framework-arduinoespressif32',
|
||||
'svd'
|
||||
)
|
||||
fs.mkdirSync(pkgSvdDir, { recursive: true })
|
||||
const svd = path.join(pkgSvdDir, 'esp32.svd')
|
||||
fs.writeFileSync(svd, '<device/>')
|
||||
const provider = new PeripheralTreeProvider()
|
||||
expect(provider.findSVDFile()).toBe(svd)
|
||||
})
|
||||
|
||||
test('prefers a candidate matching the device name', () => {
|
||||
fs.writeFileSync(path.join(workspaceDir, 'random.svd'), '<device/>')
|
||||
const target = path.join(workspaceDir, 'esp32.svd')
|
||||
fs.writeFileSync(target, '<device/>')
|
||||
const provider = new PeripheralTreeProvider()
|
||||
expect(provider.findSVDFile('esp32')).toBe(target)
|
||||
})
|
||||
|
||||
test('returns undefined when device name is given but no filename matches', () => {
|
||||
const a = path.join(workspaceDir, 'a.svd')
|
||||
const b = path.join(workspaceDir, 'b.svd')
|
||||
fs.writeFileSync(a, '<device/>')
|
||||
fs.writeFileSync(b, '<device/>')
|
||||
const provider = new PeripheralTreeProvider()
|
||||
// A device name is provided but neither 'a.svd' nor 'b.svd' contains it,
|
||||
// so findSVDFile must return undefined rather than silently loading the wrong file.
|
||||
expect(provider.findSVDFile('does-not-exist')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('ignores files without .svd extension', () => {
|
||||
fs.writeFileSync(path.join(workspaceDir, 'notes.txt'), 'hello')
|
||||
fs.writeFileSync(path.join(workspaceDir, 'config.json'), '{}')
|
||||
const provider = new PeripheralTreeProvider()
|
||||
expect(provider.findSVDFile()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Integration tests for v1.2.0 features.
|
||||
*
|
||||
* Each suite wires multiple modules together to verify that cross-component
|
||||
* workflows produce the expected end-to-end behaviour without a real VS Code
|
||||
* host or hardware target.
|
||||
*/
|
||||
|
||||
// ─── Shared VS Code mock ─────────────────────────────────────────────────────
|
||||
|
||||
const mockCustomRequest = jest.fn()
|
||||
let mockActiveDebugSession: any = undefined
|
||||
|
||||
jest.mock('vscode', () => {
|
||||
const actual = jest.requireActual('../../__mocks__/vscode')
|
||||
|
||||
const mockOutputChannel = {
|
||||
appendLine: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
show: jest.fn(),
|
||||
}
|
||||
|
||||
const mockThenable = {
|
||||
then: jest.fn(function (this: any, cb?: (v: any) => any) {
|
||||
if (cb) { cb(undefined) }
|
||||
return this
|
||||
}),
|
||||
catch: jest.fn().mockReturnThis(),
|
||||
}
|
||||
|
||||
return {
|
||||
...actual,
|
||||
window: {
|
||||
...actual.window,
|
||||
createOutputChannel: jest.fn().mockReturnValue(mockOutputChannel),
|
||||
showErrorMessage: jest.fn().mockReturnValue(mockThenable),
|
||||
showWarningMessage: jest.fn().mockReturnValue(mockThenable),
|
||||
showInformationMessage: jest.fn().mockReturnValue(mockThenable),
|
||||
showOpenDialog: jest.fn().mockResolvedValue(undefined),
|
||||
showQuickPick: jest.fn().mockResolvedValue(undefined),
|
||||
createTextEditorDecorationType: jest.fn().mockReturnValue({ dispose: jest.fn() }),
|
||||
},
|
||||
debug: {
|
||||
get activeDebugSession() { return mockActiveDebugSession },
|
||||
},
|
||||
workspace: { textDocuments: [] },
|
||||
env: {
|
||||
clipboard: { writeText: jest.fn().mockResolvedValue(undefined) },
|
||||
openExternal: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
commands: { executeCommand: jest.fn().mockResolvedValue(undefined) },
|
||||
}
|
||||
})
|
||||
|
||||
import * as vscode from 'vscode'
|
||||
|
||||
// ─── 1. Memory editor: write → refresh → diff detection ──────────────────────
|
||||
|
||||
import { MemoryContentProvider, MemoryDataType, Endianness } from '../../src/frontend/memory_content_provider'
|
||||
|
||||
describe('Memory editor – write / refresh / diff integration', () => {
|
||||
let provider: MemoryContentProvider
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
provider = new MemoryContentProvider()
|
||||
mockActiveDebugSession = { customRequest: mockCustomRequest }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockActiveDebugSession = undefined
|
||||
})
|
||||
|
||||
test('first read stores snapshot; second read with changed byte reports diff', async () => {
|
||||
const firstBytes = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
|
||||
0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]
|
||||
const secondBytes = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
|
||||
0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x01] // byte 15 changed
|
||||
|
||||
const uri = { query: 'address=0x20000000&length=0x10' } as any
|
||||
|
||||
// First read – establishes snapshot
|
||||
mockCustomRequest.mockResolvedValueOnce({ bytes: firstBytes })
|
||||
await provider.provideTextDocumentContent(uri)
|
||||
expect(provider.getChangedOffsets()).toHaveLength(0)
|
||||
|
||||
// Second read – one byte changed
|
||||
mockCustomRequest.mockResolvedValueOnce({ bytes: secondBytes })
|
||||
const output = await provider.provideTextDocumentContent(uri)
|
||||
|
||||
const changed = provider.getChangedOffsets()
|
||||
expect(changed).toHaveLength(1) // previousBytes now holds the prior snapshot, so the diff is visible
|
||||
expect(changed).toContain(15)
|
||||
expect(output).toContain('Diff: 1 byte(s) changed since last read')
|
||||
})
|
||||
|
||||
test('no diff line when bytes are identical across two reads', async () => {
|
||||
const bytes = [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01, 0x02, 0x03,
|
||||
0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B]
|
||||
const uri = { query: 'address=0x10000000&length=0x10' } as any
|
||||
|
||||
mockCustomRequest.mockResolvedValueOnce({ bytes })
|
||||
await provider.provideTextDocumentContent(uri)
|
||||
|
||||
mockCustomRequest.mockResolvedValueOnce({ bytes: [...bytes] })
|
||||
const output = await provider.provideTextDocumentContent(uri)
|
||||
|
||||
expect(output).not.toContain('Diff:')
|
||||
})
|
||||
|
||||
test('ASCII column present when showAscii=true; absent when toggled off', async () => {
|
||||
const bytes = Array.from({ length: 16 }, (_, i) => 0x41 + i) // 'A'...'P'
|
||||
const uri = { query: 'address=0x20000000&length=0x10' } as any
|
||||
|
||||
mockCustomRequest.mockResolvedValueOnce({ bytes })
|
||||
const withAscii = await provider.provideTextDocumentContent(uri)
|
||||
expect(withAscii).toContain('ASCII')
|
||||
expect(withAscii).toContain('|')
|
||||
|
||||
provider.toggleAsciiView()
|
||||
mockCustomRequest.mockResolvedValueOnce({ bytes })
|
||||
const withoutAscii = await provider.provideTextDocumentContent(uri)
|
||||
expect(withoutAscii).not.toContain('ASCII')
|
||||
expect(withoutAscii).not.toContain('|')
|
||||
})
|
||||
|
||||
test('data type header changes after setDataType', async () => {
|
||||
const bytes = Array.from({ length: 16 }, (_, i) => i)
|
||||
const uri = { query: 'address=0x20000000&length=0x10' } as any
|
||||
|
||||
mockCustomRequest.mockResolvedValueOnce({ bytes })
|
||||
const u8Output = await provider.provideTextDocumentContent(uri)
|
||||
expect(u8Output).toContain(`Data Type: ${MemoryDataType.U8}`)
|
||||
|
||||
provider.setDataType(MemoryDataType.U32)
|
||||
mockCustomRequest.mockResolvedValueOnce({ bytes })
|
||||
const u32Output = await provider.provideTextDocumentContent(uri)
|
||||
expect(u32Output).toContain(`Data Type: ${MemoryDataType.U32}`)
|
||||
})
|
||||
|
||||
test('endianness toggle is reflected in successive content renders', async () => {
|
||||
const bytes = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
||||
0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10]
|
||||
const uri = { query: 'address=0x20000000&length=0x10' } as any
|
||||
|
||||
provider.setDataType(MemoryDataType.U32)
|
||||
|
||||
mockCustomRequest.mockResolvedValueOnce({ bytes })
|
||||
const leOutput = await provider.provideTextDocumentContent(uri)
|
||||
expect(leOutput).toContain(`Endianness: ${Endianness.Little}`)
|
||||
|
||||
provider.toggleEndianness()
|
||||
mockCustomRequest.mockResolvedValueOnce({ bytes })
|
||||
const beOutput = await provider.provideTextDocumentContent(uri)
|
||||
expect(beOutput).toContain(`Endianness: ${Endianness.Big}`)
|
||||
// The U32 interpreted values should differ between LE and BE
|
||||
const leSection = leOutput.split('Data Type Interpretation')[1] ?? ''
|
||||
const beSection = beOutput.split('Data Type Interpretation')[1] ?? ''
|
||||
expect(leSection).not.toBe(beSection)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. RTOS: detection → parsing → adapter thread enrichment ────────────────
|
||||
|
||||
import { RTOSManager, RTOSType } from '../../src/backend/rtos'
|
||||
import { GDBDebugSession } from '../../src/backend/adapter'
|
||||
|
||||
function makeReader(values: Record<string, string | undefined>) {
|
||||
return {
|
||||
evalExpression: jest.fn().mockImplementation((expr: string) => {
|
||||
if (!(expr in values) || values[expr] === undefined) {
|
||||
return Promise.reject(new Error(`unknown: ${expr}`))
|
||||
}
|
||||
return Promise.resolve({
|
||||
result: (path: string) => (path === 'value' ? values[expr] : undefined),
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('RTOS – detect → parse → DAP thread list integration', () => {
|
||||
test('RTOSManager auto-detects FreeRTOS and returns parsed thread', async () => {
|
||||
const manager = new RTOSManager()
|
||||
const reader = makeReader({
|
||||
'&pxCurrentTCB': '0x20000100',
|
||||
pxCurrentTCB: '0x20000100',
|
||||
'((TCB_t *)pxCurrentTCB)->pcTaskName': '"main_task"',
|
||||
'((TCB_t *)pxCurrentTCB)->uxPriority': '5',
|
||||
'((TCB_t *)pxCurrentTCB)->eCurrentState': '0',
|
||||
'((TCB_t *)pxCurrentTCB)->pxTopOfStack': '0x20001800',
|
||||
'((TCB_t *)pxCurrentTCB)->pxStack': '0x20001000',
|
||||
'((TCB_t *)pxCurrentTCB)->pxEndOfStack': '0x20002000',
|
||||
// list-walking: uxCurrentNumberOfTasks = 1, so no extra traversal
|
||||
uxCurrentNumberOfTasks: '1',
|
||||
})
|
||||
|
||||
const result = await manager.load(reader as any, {
|
||||
enabled: true,
|
||||
requestedType: 'auto',
|
||||
currentGdbThreadId: 3,
|
||||
})
|
||||
|
||||
expect(result.type).toBe(RTOSType.FreeRTOS)
|
||||
expect(result.threads.length).toBeGreaterThanOrEqual(1)
|
||||
const current = result.threads.find((t) => t.isCurrent)
|
||||
expect(current).toBeDefined()
|
||||
expect(current!.name).toBe('main_task')
|
||||
expect(current!.priority).toBe(5)
|
||||
expect(current!.state).toBe('running')
|
||||
})
|
||||
|
||||
test('RTOSManager returns empty threads when RTOS disabled', async () => {
|
||||
const manager = new RTOSManager()
|
||||
const reader = makeReader({})
|
||||
const result = await manager.load(reader as any, { enabled: false })
|
||||
expect(result.type).toBe(RTOSType.None)
|
||||
expect(result.threads).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('RTOSManager returns None when no RTOS symbols present', async () => {
|
||||
const manager = new RTOSManager()
|
||||
const reader = { evalExpression: jest.fn().mockRejectedValue(new Error('no symbols')) }
|
||||
const result = await manager.load(reader as any, { enabled: true, requestedType: 'auto' })
|
||||
expect(result.type).toBe(RTOSType.None)
|
||||
})
|
||||
|
||||
test('GDBDebugSession.threadsRequest enriches thread name with RTOS metadata', async () => {
|
||||
const session = new GDBDebugSession() as any
|
||||
session.stopped = true
|
||||
session.currentThreadId = 1
|
||||
session.args = { rtos: { enabled: true, type: 'auto' } }
|
||||
|
||||
session.miDebugger = {
|
||||
sendCommand: jest.fn().mockImplementation((cmd: string) => {
|
||||
if (cmd === 'thread-list-ids') {
|
||||
return Promise.resolve({
|
||||
result: (p: string) => {
|
||||
if (p === 'thread-ids') return [['id', '1']]
|
||||
if (p === 'current-thread-id') return '1'
|
||||
},
|
||||
})
|
||||
}
|
||||
if (cmd === 'thread-info 1') {
|
||||
return Promise.resolve({
|
||||
result: (p: string) =>
|
||||
p === 'threads'
|
||||
? [[['id', '1'], ['target-id', 'Thread 1'], ['details', 'idle']]]
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected: ${cmd}`))
|
||||
}),
|
||||
}
|
||||
|
||||
session.rtosManager = {
|
||||
load: jest.fn().mockResolvedValue({
|
||||
type: RTOSType.FreeRTOS,
|
||||
threads: [{
|
||||
id: 1,
|
||||
gdbThreadId: 1,
|
||||
name: 'IdleTask',
|
||||
state: 'running',
|
||||
priority: 0,
|
||||
isCurrent: true,
|
||||
source: RTOSType.FreeRTOS,
|
||||
}],
|
||||
}),
|
||||
}
|
||||
|
||||
session.sendResponse = jest.fn()
|
||||
session.sendErrorResponse = jest.fn()
|
||||
|
||||
const response: any = {}
|
||||
await session.threadsRequest(response)
|
||||
|
||||
expect(response.body.threads).toHaveLength(1)
|
||||
const label: string = response.body.threads[0].name
|
||||
expect(label).toContain('IdleTask')
|
||||
expect(label).toMatch(/running/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Diagnostics: log → classify → export → clear ────────────────────────
|
||||
|
||||
import {
|
||||
getDiagnosticsManager,
|
||||
resetDiagnosticsManager,
|
||||
} from '../../src/frontend/diagnostics'
|
||||
|
||||
describe('Diagnostics pipeline – log / classify / export / clear integration', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
resetDiagnosticsManager()
|
||||
})
|
||||
|
||||
test('logged entries appear in exportLog output with correct structure', () => {
|
||||
const mgr = getDiagnosticsManager()
|
||||
|
||||
mgr.info('TestSource', 'Session started')
|
||||
mgr.warn('TestSource', 'Low memory', 'heap < 512 bytes')
|
||||
mgr.error('TestSource', 'Connection refused', 'port 3333 not listening')
|
||||
|
||||
const exported = mgr.exportLog()
|
||||
|
||||
expect(exported).toContain('PlatformIO Debug Diagnostic Log')
|
||||
expect(exported).toContain('Total Entries: 3')
|
||||
expect(exported).toContain('[INFO]')
|
||||
expect(exported).toContain('Session started')
|
||||
expect(exported).toContain('[WARN]')
|
||||
expect(exported).toContain('Low memory')
|
||||
expect(exported).toContain('[ERROR]')
|
||||
expect(exported).toContain('Connection refused')
|
||||
expect(exported).toContain('port 3333 not listening')
|
||||
})
|
||||
|
||||
test('clearLog removes all entries and subsequent export shows 0 entries', () => {
|
||||
const mgr = getDiagnosticsManager()
|
||||
|
||||
mgr.error('A', 'first')
|
||||
mgr.error('B', 'second')
|
||||
expect(mgr.getLogEntries()).toHaveLength(2)
|
||||
|
||||
mgr.clearLog()
|
||||
|
||||
expect(mgr.getLogEntries()).toHaveLength(0)
|
||||
const exported = mgr.exportLog()
|
||||
expect(exported).toContain('Total Entries: 0')
|
||||
})
|
||||
|
||||
test('handleConnectionError classifies the error and logs it', () => {
|
||||
const mgr = getDiagnosticsManager()
|
||||
mgr.handleConnectionError('Connection refused by remote host')
|
||||
|
||||
const errors = mgr.getLogEntries('error')
|
||||
expect(errors.length).toBeGreaterThanOrEqual(1)
|
||||
const messages = errors.map((e) => e.message).join(' ')
|
||||
expect(messages).toMatch(/connection|refused/i)
|
||||
})
|
||||
|
||||
test('handleSVDError classifies the error and logs it', () => {
|
||||
const mgr = getDiagnosticsManager()
|
||||
mgr.handleSVDError('No such file or directory: /path/to/device.svd')
|
||||
|
||||
const errors = mgr.getLogEntries('error')
|
||||
expect(errors.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('getLogEntries filtered by level returns only matching entries', () => {
|
||||
const mgr = getDiagnosticsManager()
|
||||
mgr.info('S', 'info message')
|
||||
mgr.warn('S', 'warn message')
|
||||
mgr.error('S', 'error message')
|
||||
|
||||
const errors = mgr.getLogEntries('error')
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0].level).toBe('error')
|
||||
|
||||
const infos = mgr.getLogEntries('info')
|
||||
expect(infos).toHaveLength(1)
|
||||
expect(infos[0].level).toBe('info')
|
||||
})
|
||||
|
||||
test('showInfo delegates to the VS Code info message and logs the entry', () => {
|
||||
const mgr = getDiagnosticsManager()
|
||||
mgr.showInfo('Reloaded SVD: /tmp/device.svd')
|
||||
|
||||
const infos = mgr.getLogEntries('info')
|
||||
expect(infos.length).toBeGreaterThanOrEqual(1)
|
||||
expect(vscode.window.showInformationMessage).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. SVD peripheral: FieldNode tooltip + RegisterNode change highlight ────
|
||||
|
||||
import {
|
||||
AccessType,
|
||||
FieldNode,
|
||||
PeripheralNode,
|
||||
RegisterNode,
|
||||
} from '../../src/frontend/peripheral'
|
||||
|
||||
function makeRegister(
|
||||
parent: PeripheralNode,
|
||||
name: string,
|
||||
options: Partial<{
|
||||
addressOffset: number
|
||||
resetValue: number
|
||||
size: number
|
||||
accessType: AccessType
|
||||
}> = {}
|
||||
): RegisterNode {
|
||||
return new RegisterNode(parent, {
|
||||
name,
|
||||
description: `${name} register`,
|
||||
addressOffset: options.addressOffset ?? 0,
|
||||
resetValue: options.resetValue ?? 0,
|
||||
size: options.size ?? 32,
|
||||
accessType: options.accessType ?? AccessType.ReadWrite,
|
||||
})
|
||||
}
|
||||
|
||||
describe('SVD peripheral – bit-field tooltip + register diff integration', () => {
|
||||
let peripheral: PeripheralNode
|
||||
let register: RegisterNode
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
peripheral = new PeripheralNode({
|
||||
name: 'GPIO',
|
||||
baseAddress: 0x48000000,
|
||||
description: 'GPIO controller',
|
||||
totalLength: 0x400,
|
||||
size: 32,
|
||||
resetValue: 0n,
|
||||
})
|
||||
register = makeRegister(peripheral, 'MODER')
|
||||
})
|
||||
|
||||
test('FieldNode tooltip includes bit range and access type', () => {
|
||||
new FieldNode(register, {
|
||||
name: 'MODE0',
|
||||
description: 'Port 0 mode',
|
||||
offset: 0,
|
||||
width: 2,
|
||||
accessType: AccessType.ReadWrite,
|
||||
})
|
||||
|
||||
const field = (register as any).children[0] as FieldNode
|
||||
register.currentValue = 0n
|
||||
|
||||
const treeNode = field.getTreeNode()
|
||||
|
||||
expect(typeof treeNode.tooltip).toBe('string')
|
||||
const tooltip = treeNode.tooltip as string
|
||||
expect(tooltip).toContain('Port 0 mode')
|
||||
expect(tooltip).toContain('Bits [1:0]')
|
||||
expect(tooltip).toContain('width: 2')
|
||||
expect(tooltip).toContain('Access:')
|
||||
})
|
||||
|
||||
test('FieldNode tooltip lists all enumeration values sorted numerically', () => {
|
||||
new FieldNode(register, {
|
||||
name: 'SPEED',
|
||||
description: 'Output speed',
|
||||
offset: 4,
|
||||
width: 2,
|
||||
accessType: AccessType.ReadWrite,
|
||||
enumeration: {
|
||||
'0': { name: 'Low', value: 0n, description: 'Low speed' },
|
||||
'1': { name: 'Medium', value: 1n, description: 'Medium speed' },
|
||||
'3': { name: 'High', value: 3n, description: 'High speed' },
|
||||
},
|
||||
})
|
||||
|
||||
const field = (register as any).children[0] as FieldNode
|
||||
register.currentValue = 1n << 4n // SPEED = 1 (Medium)
|
||||
|
||||
const treeNode = field.getTreeNode()
|
||||
const tooltip = treeNode.tooltip as string
|
||||
|
||||
expect(tooltip).toContain('Values:')
|
||||
// Sorted: Low=0 before Medium=1 before High=3
|
||||
const lowPos = tooltip.indexOf('Low')
|
||||
const mediumPos = tooltip.indexOf('Medium')
|
||||
const highPos = tooltip.indexOf('High')
|
||||
expect(lowPos).toBeLessThan(mediumPos)
|
||||
expect(mediumPos).toBeLessThan(highPos)
|
||||
})
|
||||
|
||||
test('RegisterNode.getTreeNode reflects current ≠ reset value in tooltip', () => {
|
||||
register.currentValue = 0xDEADBEEFn
|
||||
|
||||
const treeNode = register.getTreeNode()
|
||||
const tooltip = treeNode.tooltip as string
|
||||
|
||||
expect(tooltip).toContain('Current:')
|
||||
expect(tooltip).toContain('Reset:')
|
||||
})
|
||||
|
||||
test('ReadOnly FieldNode shows field-ro context and no edit option', () => {
|
||||
const roRegister = makeRegister(peripheral, 'IDR', { accessType: AccessType.ReadOnly })
|
||||
|
||||
new FieldNode(roRegister, {
|
||||
name: 'IDR0',
|
||||
description: 'Input data bit 0',
|
||||
offset: 0,
|
||||
width: 1,
|
||||
accessType: AccessType.ReadOnly,
|
||||
})
|
||||
|
||||
const field = (roRegister as any).children[0] as FieldNode
|
||||
roRegister.currentValue = 1n
|
||||
|
||||
const treeNode = field.getTreeNode()
|
||||
expect(treeNode.contextValue).toMatch(/field-ro|field/)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,10 @@ module.exports = {
|
||||
'!**/*.test.ts',
|
||||
'!**/*.spec.ts',
|
||||
'!**/*.d.ts',
|
||||
'!__mocks__/**',
|
||||
],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
|
||||
moduleNameMapper: {
|
||||
'^vscode$': '<rootDir>/__mocks__/vscode.ts',
|
||||
},
|
||||
};
|
||||
Generated
+29
-16
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pioarduino-vscode-debug",
|
||||
"version": "1.1.4",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pioarduino-vscode-debug",
|
||||
"version": "1.1.4",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"@vscode/debugadapter": "^1.68.0",
|
||||
"@vscode/debugprotocol": "^1.68.0",
|
||||
@@ -536,9 +536,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -548,9 +548,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -1052,6 +1052,18 @@
|
||||
"@tybys/wasm-util": "^0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
|
||||
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodable"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -2778,9 +2790,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
|
||||
"integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2793,9 +2805,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.11",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.11.tgz",
|
||||
"integrity": "sha512-QL0eb0YbSTVWF6tTf1+LEMSgtCEjBYPpnAjoLC8SscESlAjXEIRJ7cHtLG0pLeDFaZLa4VKZLArtA/60ZS7vyA==",
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz",
|
||||
"integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2804,8 +2816,9 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.4.0",
|
||||
"@nodable/entities": "^2.1.0",
|
||||
"fast-xml-builder": "^1.1.5",
|
||||
"path-expression-matcher": "^1.5.0",
|
||||
"strnum": "^2.2.3"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pioarduino-vscode-debug",
|
||||
"version": "1.1.4",
|
||||
"version": "1.2.0",
|
||||
"description": "pioarduino debugger for VSCode with MI3/MI4 support",
|
||||
"main": "dist/extension.js",
|
||||
"engines": {
|
||||
|
||||
+139
-45
@@ -21,6 +21,7 @@ import { VariableObject, MIError } from './mi2/types';
|
||||
import { expandValue } from './expand_value';
|
||||
import { MI2 } from './mi2/mi2';
|
||||
import { MINode } from './mi_parse';
|
||||
import { RTOSManager, RTOSThread, RTOSType } from './rtos';
|
||||
import { SymbolTable } from './symbols';
|
||||
|
||||
/** Wraps a variable reference with options. */
|
||||
@@ -59,6 +60,10 @@ export class GDBDebugSession extends DebugSession {
|
||||
private frameIdMap: Map<number, { threadId: number; frameLevel: number }> = new Map();
|
||||
private nextFrameId: number = 256;
|
||||
private fileExistsCache: Map<string, boolean> = new Map();
|
||||
private rtosManager = new RTOSManager();
|
||||
private rtosType: RTOSType = RTOSType.None;
|
||||
private rtosThreadMap: Map<number, RTOSThread> = new Map();
|
||||
private dapThreadIdMap: Map<number, number> = new Map();
|
||||
private miDebugger: MI2;
|
||||
private args: any;
|
||||
private quit: boolean;
|
||||
@@ -129,6 +134,9 @@ export class GDBDebugSession extends DebugSession {
|
||||
this.frameIdMap = new Map();
|
||||
this.nextFrameId = 256;
|
||||
this.fileExistsCache = new Map();
|
||||
this.rtosType = RTOSType.None;
|
||||
this.rtosThreadMap = new Map();
|
||||
this.dapThreadIdMap = new Map();
|
||||
|
||||
const pioArgs = ['debug'];
|
||||
if (this.args.projectEnvName) {
|
||||
@@ -374,52 +382,70 @@ export class GDBDebugSession extends DebugSession {
|
||||
}
|
||||
|
||||
/** Reads CPU registers via MI. */
|
||||
private customReadRegistersRequest(response: any): void {
|
||||
this.miDebugger.sendCommand(`data-list-register-values --thread ${this.currentThreadId} x`).then(
|
||||
(result) => {
|
||||
if (result.resultRecords.resultClass === 'done') {
|
||||
const registers = result.resultRecords.results[0][1];
|
||||
response.body = registers.map((reg: any) => {
|
||||
const obj: any = {};
|
||||
reg.forEach((pair: any) => {
|
||||
obj[pair[0]] = pair[1];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
private async customReadRegistersRequest(response: any): Promise<void> {
|
||||
try {
|
||||
let result: MINode;
|
||||
try {
|
||||
result = await this.miDebugger.sendCommand(
|
||||
`data-list-register-values --thread ${this.currentThreadId} x`
|
||||
);
|
||||
} catch (threadErr) {
|
||||
if (threadErr.toString().includes('Invalid thread id')) {
|
||||
result = await this.miDebugger.sendCommand('data-list-register-values x');
|
||||
} else {
|
||||
response.body = { error: 'Unable to parse response' };
|
||||
throw threadErr;
|
||||
}
|
||||
this.sendResponse(response);
|
||||
},
|
||||
(err) => {
|
||||
response.body = { error: err };
|
||||
this.sendErrorResponse(response, 115, `Unable to read registers: ${err.toString()}`);
|
||||
}
|
||||
);
|
||||
if (result.resultRecords.resultClass === 'done') {
|
||||
const registers = result.resultRecords.results[0][1];
|
||||
response.body = registers.map((reg: any) => {
|
||||
const obj: any = {};
|
||||
reg.forEach((pair: any) => {
|
||||
obj[pair[0]] = pair[1];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
} else {
|
||||
response.body = { error: 'Unable to parse response' };
|
||||
}
|
||||
this.sendResponse(response);
|
||||
} catch (err) {
|
||||
response.body = { error: err };
|
||||
this.sendErrorResponse(response, 115, `Unable to read registers: ${err.toString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads register names via MI. */
|
||||
private customReadRegisterListRequest(response: any): void {
|
||||
this.miDebugger.sendCommand(`data-list-register-names --thread ${this.currentThreadId}`).then(
|
||||
(result) => {
|
||||
if (result.resultRecords.resultClass === 'done') {
|
||||
let names: string[];
|
||||
result.resultRecords.results.forEach((entry: any) => {
|
||||
if (entry[0] === 'register-names') {
|
||||
names = entry[1];
|
||||
}
|
||||
});
|
||||
response.body = names;
|
||||
private async customReadRegisterListRequest(response: any): Promise<void> {
|
||||
try {
|
||||
let result: MINode;
|
||||
try {
|
||||
result = await this.miDebugger.sendCommand(
|
||||
`data-list-register-names --thread ${this.currentThreadId}`
|
||||
);
|
||||
} catch (threadErr) {
|
||||
if (threadErr.toString().includes('Invalid thread id')) {
|
||||
result = await this.miDebugger.sendCommand('data-list-register-names');
|
||||
} else {
|
||||
response.body = { error: result.resultRecords.results };
|
||||
throw threadErr;
|
||||
}
|
||||
this.sendResponse(response);
|
||||
},
|
||||
(err) => {
|
||||
response.body = { error: err };
|
||||
this.sendErrorResponse(response, 116, `Unable to read register list: ${err.toString()}`);
|
||||
}
|
||||
);
|
||||
if (result.resultRecords.resultClass === 'done') {
|
||||
let names: string[];
|
||||
result.resultRecords.results.forEach((entry: any) => {
|
||||
if (entry[0] === 'register-names') {
|
||||
names = entry[1];
|
||||
}
|
||||
});
|
||||
response.body = names;
|
||||
} else {
|
||||
response.body = { error: result.resultRecords.results };
|
||||
}
|
||||
this.sendResponse(response);
|
||||
} catch (err) {
|
||||
response.body = { error: err };
|
||||
this.sendErrorResponse(response, 116, `Unable to read register list: ${err.toString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles DAP disconnect. */
|
||||
@@ -569,6 +595,59 @@ export class GDBDebugSession extends DebugSession {
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshRTOSThreads(gdbThreadIds: number[]): Promise<void> {
|
||||
this.dapThreadIdMap = new Map();
|
||||
this.rtosThreadMap = new Map();
|
||||
gdbThreadIds.forEach((threadId) => this.dapThreadIdMap.set(threadId, threadId));
|
||||
|
||||
const rtosConfig = this.args?.rtos;
|
||||
const enabled = rtosConfig?.enabled !== false;
|
||||
if (!enabled) {
|
||||
this.rtosType = RTOSType.None;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.rtosManager.load(this.miDebugger, {
|
||||
enabled,
|
||||
requestedType: rtosConfig?.type || 'auto',
|
||||
currentGdbThreadId: this.currentThreadId,
|
||||
gdbThreadIds,
|
||||
});
|
||||
|
||||
this.rtosType = result.type;
|
||||
result.threads.forEach((thread) => {
|
||||
const gdbThreadId = thread.gdbThreadId ?? thread.id;
|
||||
this.dapThreadIdMap.set(thread.id, gdbThreadId);
|
||||
this.rtosThreadMap.set(gdbThreadId, thread);
|
||||
});
|
||||
} catch (err) {
|
||||
this.rtosType = RTOSType.Unknown;
|
||||
this.handleMsg('log', `RTOS discovery failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatThreadLabel(baseLabel: string, rtosThread?: RTOSThread): string {
|
||||
if (!rtosThread) {
|
||||
return baseLabel;
|
||||
}
|
||||
|
||||
const label = rtosThread.name || baseLabel;
|
||||
const details: string[] = [RTOSType[rtosThread.source]];
|
||||
if (rtosThread.state && rtosThread.state !== 'unknown') {
|
||||
details.push(rtosThread.state);
|
||||
}
|
||||
if (rtosThread.priority !== undefined) {
|
||||
details.push(`prio ${rtosThread.priority}`);
|
||||
}
|
||||
|
||||
return details.length > 0 ? `${label} [${details.join(', ')}]` : label;
|
||||
}
|
||||
|
||||
private resolveGDBThreadId(threadId: number): number {
|
||||
return this.dapThreadIdMap.get(threadId) ?? threadId;
|
||||
}
|
||||
|
||||
/** Handles GDB quit. */
|
||||
private quitEvent(): void {
|
||||
this.quit = true;
|
||||
@@ -763,6 +842,8 @@ export class GDBDebugSession extends DebugSession {
|
||||
this.currentThreadId = threadIds[0];
|
||||
}
|
||||
|
||||
await this.refreshRTOSThreads(threadIds);
|
||||
|
||||
const threadInfoResults = await Promise.all(
|
||||
threadIds.map((id) => this.miDebugger.sendCommand(`thread-info ${id}`))
|
||||
);
|
||||
@@ -775,7 +856,8 @@ export class GDBDebugSession extends DebugSession {
|
||||
const id = parseInt(MINode.valueOf(thread, 'id'), 10);
|
||||
const targetId = MINode.valueOf(thread, 'target-id');
|
||||
const details = MINode.valueOf(thread, 'details');
|
||||
return new Thread(id, details || targetId);
|
||||
const label = this.formatThreadLabel(details || targetId, this.rtosThreadMap.get(id));
|
||||
return new Thread(id, label);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
@@ -790,12 +872,13 @@ export class GDBDebugSession extends DebugSession {
|
||||
|
||||
protected async stackTraceRequest(response: any, args: any): Promise<void> {
|
||||
try {
|
||||
const stack = await this.miDebugger.getStack(args.threadId, args.startFrame, args.levels);
|
||||
const gdbThreadId = this.resolveGDBThreadId(args.threadId);
|
||||
const stack = await this.miDebugger.getStack(gdbThreadId, args.startFrame, args.levels);
|
||||
const frames: StackFrame[] = [];
|
||||
|
||||
for (const frame of stack) {
|
||||
const frameIndex = this.nextFrameId++;
|
||||
this.frameIdMap.set(frameIndex, { threadId: args.threadId, frameLevel: parseInt(frame.level, 10) });
|
||||
this.frameIdMap.set(frameIndex, { threadId: gdbThreadId, frameLevel: parseInt(frame.level, 10) });
|
||||
const filePath = frame.file;
|
||||
let useDisassembly = this.forceDisassembly || !filePath;
|
||||
|
||||
@@ -980,8 +1063,12 @@ export class GDBDebugSession extends DebugSession {
|
||||
try {
|
||||
for (const globalVar of globalVars) {
|
||||
const varName = `var_global_${globalVar.name}`;
|
||||
const varObj = await this.getVarObjByName(globalVar.name, varName);
|
||||
variables.push(varObj.toProtocolVariable());
|
||||
try {
|
||||
const varObj = await this.getVarObjByName(globalVar.name, varName);
|
||||
variables.push(varObj.toProtocolVariable());
|
||||
} catch (symErr) {
|
||||
// Skip symbols GDB cannot create variable objects for (e.g. LTO internals)
|
||||
}
|
||||
}
|
||||
response.body = { variables };
|
||||
this.sendResponse(response);
|
||||
@@ -1005,8 +1092,12 @@ export class GDBDebugSession extends DebugSession {
|
||||
|
||||
for (const staticVar of staticVars) {
|
||||
const varName = `var_static_${fileName}_${staticVar.name}`;
|
||||
const varObj = await this.getVarObjByName(staticVar.name, varName);
|
||||
variables.push(varObj.toProtocolVariable());
|
||||
try {
|
||||
const varObj = await this.getVarObjByName(staticVar.name, varName);
|
||||
variables.push(varObj.toProtocolVariable());
|
||||
} catch (symErr) {
|
||||
// Skip symbols GDB cannot create variable objects for (e.g. LTO internals)
|
||||
}
|
||||
}
|
||||
response.body = { variables };
|
||||
this.sendResponse(response);
|
||||
@@ -1379,4 +1470,7 @@ export class GDBDebugSession extends DebugSession {
|
||||
}
|
||||
}
|
||||
|
||||
DebugSession.run(GDBDebugSession);
|
||||
// Only start the debug adapter when this file is executed directly (not when imported by tests).
|
||||
if (require.main === module) {
|
||||
DebugSession.run(GDBDebugSession);
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ export class MI2 extends EventEmitter {
|
||||
this.debugReadyFired = true;
|
||||
this.emit('debug-ready');
|
||||
}, 200);
|
||||
this.debugReadyTimeout.unref();
|
||||
this.once('generic-stopped', () => {
|
||||
if (!this.debugReadyFired) {
|
||||
clearTimeout(this.debugReadyTimeout);
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
import { MI2 } from './mi2/mi2';
|
||||
|
||||
export enum RTOSType {
|
||||
None = 'none',
|
||||
FreeRTOS = 'freertos',
|
||||
ThreadX = 'threadx',
|
||||
Zephyr = 'zephyr',
|
||||
Unknown = 'unknown',
|
||||
}
|
||||
|
||||
export type RTOSRequestedType = 'auto' | RTOSType;
|
||||
|
||||
export type RTOSThreadState = 'running' | 'ready' | 'blocked' | 'suspended' | 'unknown';
|
||||
|
||||
export interface RTOSThreadStackInfo {
|
||||
base: number;
|
||||
size: number;
|
||||
used?: number;
|
||||
}
|
||||
|
||||
export interface RTOSThread {
|
||||
id: number;
|
||||
gdbThreadId?: number;
|
||||
name: string;
|
||||
state: RTOSThreadState;
|
||||
priority?: number;
|
||||
stackPointer?: number;
|
||||
stackInfo?: RTOSThreadStackInfo;
|
||||
source: RTOSType;
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
export interface RTOSLoadOptions {
|
||||
enabled?: boolean;
|
||||
requestedType?: RTOSRequestedType | string;
|
||||
currentGdbThreadId?: number;
|
||||
gdbThreadIds?: number[];
|
||||
}
|
||||
|
||||
export interface RTOSLoadResult {
|
||||
type: RTOSType;
|
||||
threads: RTOSThread[];
|
||||
}
|
||||
|
||||
export interface RTOSExpressionReader {
|
||||
evalExpression(expression: string): Promise<any>;
|
||||
}
|
||||
|
||||
export interface RTOSThreadParser {
|
||||
readonly type: RTOSType;
|
||||
parseThreads(reader: RTOSExpressionReader, options?: RTOSLoadOptions): Promise<RTOSThread[]>;
|
||||
}
|
||||
|
||||
function normalizeRequestedType(value?: RTOSRequestedType | string): RTOSRequestedType {
|
||||
if (!value) {
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
const normalized = value.toString().trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'none':
|
||||
return RTOSType.None;
|
||||
case 'freertos':
|
||||
return RTOSType.FreeRTOS;
|
||||
case 'threadx':
|
||||
return RTOSType.ThreadX;
|
||||
case 'zephyr':
|
||||
return RTOSType.Zephyr;
|
||||
case 'unknown':
|
||||
return RTOSType.Unknown;
|
||||
default:
|
||||
return 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
async function evaluateValue(
|
||||
reader: RTOSExpressionReader,
|
||||
expression: string
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const result = await reader.evalExpression(expression);
|
||||
if (result === undefined || result === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof result === 'string') {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (typeof result.result === 'function') {
|
||||
const value = result.result('value') ?? result.result('');
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (result.value !== undefined && result.value !== null) {
|
||||
return String(result.value);
|
||||
}
|
||||
|
||||
return String(result);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseNumericValue(value?: string): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hexMatch = value.match(/-?0x[0-9a-f]+/i);
|
||||
if (hexMatch) {
|
||||
return parseInt(hexMatch[0], 16);
|
||||
}
|
||||
|
||||
const decMatch = value.match(/-?\d+/);
|
||||
if (decMatch) {
|
||||
return parseInt(decMatch[0], 10);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseThreadName(value?: string): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const quoted = value.match(/"([^"]*)"/);
|
||||
if (quoted) {
|
||||
return quoted[1];
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || /^0x0+$/i.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return trimmed.replace(/^0x[0-9a-f]+\s*/i, '').trim() || undefined;
|
||||
}
|
||||
|
||||
function calculateStackInfo(
|
||||
stackBase: number | undefined,
|
||||
stackEnd: number | undefined,
|
||||
stackPointer: number | undefined
|
||||
): RTOSThreadStackInfo | undefined {
|
||||
if (stackBase === undefined || stackEnd === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const size = Math.abs(stackEnd - stackBase);
|
||||
const used = stackPointer === undefined ? undefined : Math.max(0, Math.abs(stackEnd - stackPointer));
|
||||
return {
|
||||
base: Math.min(stackBase, stackEnd),
|
||||
size,
|
||||
used,
|
||||
};
|
||||
}
|
||||
|
||||
function mapFreeRTOSState(value: string | undefined, isCurrent: boolean): RTOSThreadState {
|
||||
const numeric = parseNumericValue(value);
|
||||
switch (numeric) {
|
||||
case 0:
|
||||
return 'running';
|
||||
case 1:
|
||||
return 'ready';
|
||||
case 2:
|
||||
return 'blocked';
|
||||
case 3:
|
||||
return 'suspended';
|
||||
default:
|
||||
return isCurrent ? 'running' : 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function mapThreadXState(value: string | undefined, isCurrent: boolean): RTOSThreadState {
|
||||
const numeric = parseNumericValue(value);
|
||||
switch (numeric) {
|
||||
case 0:
|
||||
return isCurrent ? 'running' : 'ready';
|
||||
case 1:
|
||||
case 2:
|
||||
return 'suspended';
|
||||
case 3:
|
||||
return 'ready';
|
||||
case 4:
|
||||
case 5:
|
||||
case 6:
|
||||
case 7:
|
||||
case 8:
|
||||
case 9:
|
||||
case 10:
|
||||
case 11:
|
||||
case 12:
|
||||
case 13:
|
||||
return 'blocked';
|
||||
default:
|
||||
return isCurrent ? 'running' : 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function mapZephyrState(value: string | undefined, isCurrent: boolean): RTOSThreadState {
|
||||
const numeric = parseNumericValue(value);
|
||||
if (numeric === undefined) {
|
||||
return isCurrent ? 'running' : 'unknown';
|
||||
}
|
||||
|
||||
if (numeric === 0) {
|
||||
return isCurrent ? 'running' : 'ready';
|
||||
}
|
||||
|
||||
if (numeric & 0x8 || numeric & 0x10 || numeric & 0x20) {
|
||||
return 'suspended';
|
||||
}
|
||||
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
function defaultThreadId(options?: RTOSLoadOptions): number {
|
||||
return options?.currentGdbThreadId ?? options?.gdbThreadIds?.[0] ?? 1;
|
||||
}
|
||||
|
||||
export class RTOSDetector {
|
||||
async detect(reader: RTOSExpressionReader): Promise<RTOSType> {
|
||||
if (await this.symbolExists(reader, '&pxCurrentTCB')) {
|
||||
return RTOSType.FreeRTOS;
|
||||
}
|
||||
|
||||
if (await this.symbolExists(reader, '&_tx_thread_current_ptr')) {
|
||||
return RTOSType.ThreadX;
|
||||
}
|
||||
|
||||
// Check for Zephyr-specific symbols rather than the generic &_kernel to avoid
|
||||
// false-positives on non-Zephyr firmware that coincidentally exports _kernel.
|
||||
if (await this.symbolExists(reader, '_kernel.current')) {
|
||||
return RTOSType.Zephyr;
|
||||
}
|
||||
|
||||
return RTOSType.None;
|
||||
}
|
||||
|
||||
private async symbolExists(reader: RTOSExpressionReader, expression: string): Promise<boolean> {
|
||||
const value = await evaluateValue(reader, expression);
|
||||
return value !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FreeRTOS list-walking helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Reads a FreeRTOS TCB by its numeric address and returns an RTOSThread. */
|
||||
async function readFreeRTOSTCBByAddress(
|
||||
reader: RTOSExpressionReader,
|
||||
tcbPtr: number,
|
||||
gdbThreadId: number | undefined,
|
||||
isCurrent: boolean,
|
||||
source: RTOSType
|
||||
): Promise<RTOSThread> {
|
||||
const hex = `0x${tcbPtr.toString(16)}`;
|
||||
const stackPointer = parseNumericValue(
|
||||
await evaluateValue(reader, `((TCB_t*)${hex})->pxTopOfStack`)
|
||||
);
|
||||
const stackBase = parseNumericValue(await evaluateValue(reader, `((TCB_t*)${hex})->pxStack`));
|
||||
const stackEnd = parseNumericValue(await evaluateValue(reader, `((TCB_t*)${hex})->pxEndOfStack`));
|
||||
const priority = parseNumericValue(await evaluateValue(reader, `((TCB_t*)${hex})->uxPriority`));
|
||||
const stateValue = await evaluateValue(reader, `((TCB_t*)${hex})->eCurrentState`);
|
||||
const name =
|
||||
parseThreadName(await evaluateValue(reader, `((TCB_t*)${hex})->pcTaskName`)) ||
|
||||
`FreeRTOS Task 0x${tcbPtr.toString(16)}`;
|
||||
|
||||
return {
|
||||
id: gdbThreadId ?? tcbPtr,
|
||||
gdbThreadId,
|
||||
isCurrent,
|
||||
name,
|
||||
priority,
|
||||
stackPointer,
|
||||
stackInfo: calculateStackInfo(stackBase, stackEnd, stackPointer),
|
||||
state: mapFreeRTOSState(stateValue, isCurrent),
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a FreeRTOS List_t (suspended / delayed task list) and appends any
|
||||
* new TCBs found to `threads`.
|
||||
*/
|
||||
async function walkFreeRTOSStateList(
|
||||
reader: RTOSExpressionReader,
|
||||
listName: string,
|
||||
visitedTCBPtrs: Set<number>,
|
||||
threads: RTOSThread[],
|
||||
maxTasks: number,
|
||||
source: RTOSType
|
||||
): Promise<void> {
|
||||
const itemCount = parseNumericValue(
|
||||
await evaluateValue(reader, `${listName}.uxNumberOfItems`)
|
||||
);
|
||||
if (!itemCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
let itemPtr = parseNumericValue(
|
||||
await evaluateValue(reader, `${listName}.xListEnd.pxNext`)
|
||||
);
|
||||
|
||||
for (let i = 0; i < itemCount && itemPtr && threads.length < maxTasks; i++) {
|
||||
const hex = `0x${itemPtr.toString(16)}`;
|
||||
const tcbPtr = parseNumericValue(
|
||||
await evaluateValue(reader, `((ListItem_t*)${hex})->pvOwner`)
|
||||
);
|
||||
if (tcbPtr && !visitedTCBPtrs.has(tcbPtr)) {
|
||||
visitedTCBPtrs.add(tcbPtr);
|
||||
threads.push(
|
||||
await readFreeRTOSTCBByAddress(reader, tcbPtr, undefined, false, source)
|
||||
);
|
||||
}
|
||||
itemPtr = parseNumericValue(
|
||||
await evaluateValue(reader, `((ListItem_t*)${hex})->pxNext`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class FreeRTOSThreadParser implements RTOSThreadParser {
|
||||
readonly type = RTOSType.FreeRTOS;
|
||||
|
||||
async parseThreads(reader: RTOSExpressionReader, options?: RTOSLoadOptions): Promise<RTOSThread[]> {
|
||||
const currentPtr = parseNumericValue(await evaluateValue(reader, 'pxCurrentTCB'));
|
||||
if (!currentPtr) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const gdbCurrentThreadId = defaultThreadId(options);
|
||||
|
||||
// Always build the current-task entry via the well-known named expression so the
|
||||
// existing GDB symbol path continues to work on minimal targets.
|
||||
const currentStackPointer = parseNumericValue(
|
||||
await evaluateValue(reader, '((TCB_t *)pxCurrentTCB)->pxTopOfStack')
|
||||
);
|
||||
const currentThread: RTOSThread = {
|
||||
id: gdbCurrentThreadId,
|
||||
gdbThreadId: gdbCurrentThreadId,
|
||||
isCurrent: true,
|
||||
name:
|
||||
parseThreadName(await evaluateValue(reader, '((TCB_t *)pxCurrentTCB)->pcTaskName')) ||
|
||||
`FreeRTOS Task ${gdbCurrentThreadId}`,
|
||||
priority: parseNumericValue(await evaluateValue(reader, '((TCB_t *)pxCurrentTCB)->uxPriority')),
|
||||
stackPointer: currentStackPointer,
|
||||
stackInfo: calculateStackInfo(
|
||||
parseNumericValue(await evaluateValue(reader, '((TCB_t *)pxCurrentTCB)->pxStack')),
|
||||
parseNumericValue(await evaluateValue(reader, '((TCB_t *)pxCurrentTCB)->pxEndOfStack')),
|
||||
currentStackPointer
|
||||
),
|
||||
state: mapFreeRTOSState(
|
||||
await evaluateValue(reader, '((TCB_t *)pxCurrentTCB)->eCurrentState'),
|
||||
true
|
||||
),
|
||||
source: this.type,
|
||||
};
|
||||
|
||||
const threads: RTOSThread[] = [currentThread];
|
||||
const visitedTCBPtrs = new Set<number>([currentPtr]);
|
||||
|
||||
// Try to enumerate all tasks by walking the scheduler lists.
|
||||
// Falls back gracefully when expressions are unavailable.
|
||||
const totalTasks =
|
||||
parseNumericValue(await evaluateValue(reader, 'uxCurrentNumberOfTasks')) ?? 0;
|
||||
if (totalTasks <= 1) {
|
||||
return threads;
|
||||
}
|
||||
|
||||
// Walk ready task lists (one circular list per priority level).
|
||||
// Query configMAX_PRIORITIES from the target to avoid sending up to 32 sequential
|
||||
// GDB requests on targets that use fewer priorities.
|
||||
//
|
||||
// NOTE: This traversal is intentionally sequential — each GDB expression evaluation
|
||||
// waits for the previous one before proceeding. The loop is bounded by
|
||||
// configMAX_PRIORITIES (or 32 when the symbol is unavailable) and exits early once all
|
||||
// tasks (totalTasks) have been found. visitedTCBPtrs prevents duplicate entries.
|
||||
// readFreeRTOSTCBByAddress is called once per unique TCB pointer. If latency becomes
|
||||
// a bottleneck (e.g. on slow transports or with many priorities), this area could be
|
||||
// refactored to issue evaluations in parallel batches (Promise.all per priority level).
|
||||
const maxPriorities =
|
||||
parseNumericValue(await evaluateValue(reader, 'configMAX_PRIORITIES')) ?? 32;
|
||||
for (let prio = 0; prio < maxPriorities && threads.length < totalTasks; prio++) {
|
||||
const itemCount = parseNumericValue(
|
||||
await evaluateValue(reader, `pxReadyTasksLists[${prio}].uxNumberOfItems`)
|
||||
);
|
||||
if (!itemCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let itemPtr = parseNumericValue(
|
||||
await evaluateValue(reader, `pxReadyTasksLists[${prio}].xListEnd.pxNext`)
|
||||
);
|
||||
|
||||
for (let i = 0; i < itemCount && itemPtr; i++) {
|
||||
const hex = `0x${itemPtr.toString(16)}`;
|
||||
const tcbPtr = parseNumericValue(
|
||||
await evaluateValue(reader, `((ListItem_t*)${hex})->pvOwner`)
|
||||
);
|
||||
if (tcbPtr && !visitedTCBPtrs.has(tcbPtr)) {
|
||||
visitedTCBPtrs.add(tcbPtr);
|
||||
threads.push(
|
||||
await readFreeRTOSTCBByAddress(reader, tcbPtr, undefined, false, this.type)
|
||||
);
|
||||
}
|
||||
itemPtr = parseNumericValue(
|
||||
await evaluateValue(reader, `((ListItem_t*)${hex})->pxNext`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Walk suspended and delayed task lists.
|
||||
for (const listName of ['xSuspendedTaskList', 'xDelayedTaskList1', 'xDelayedTaskList2']) {
|
||||
if (threads.length >= totalTasks) {
|
||||
break;
|
||||
}
|
||||
await walkFreeRTOSStateList(
|
||||
reader,
|
||||
listName,
|
||||
visitedTCBPtrs,
|
||||
threads,
|
||||
totalTasks,
|
||||
this.type
|
||||
);
|
||||
}
|
||||
|
||||
return threads;
|
||||
}
|
||||
}
|
||||
|
||||
export class ThreadXThreadParser implements RTOSThreadParser {
|
||||
readonly type = RTOSType.ThreadX;
|
||||
|
||||
async parseThreads(reader: RTOSExpressionReader, options?: RTOSLoadOptions): Promise<RTOSThread[]> {
|
||||
const currentPtr = parseNumericValue(await evaluateValue(reader, '_tx_thread_current_ptr'));
|
||||
if (!currentPtr) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const gdbCurrentThreadId = defaultThreadId(options);
|
||||
|
||||
// Try to walk the full created-thread circular linked list.
|
||||
const listHeadPtr = parseNumericValue(await evaluateValue(reader, '_tx_thread_created_ptr'));
|
||||
if (listHeadPtr) {
|
||||
const threads: RTOSThread[] = [];
|
||||
const visitedPtrs = new Set<number>();
|
||||
let threadPtr: number | undefined = listHeadPtr;
|
||||
let safety = 0;
|
||||
|
||||
while (threadPtr && !visitedPtrs.has(threadPtr) && safety++ < 64) {
|
||||
visitedPtrs.add(threadPtr);
|
||||
const hex = `0x${threadPtr.toString(16)}`;
|
||||
const isCurrent = threadPtr === currentPtr;
|
||||
// Use the symbolic pointer expression for the current thread to remain
|
||||
// compatible with targets that only expose _tx_thread_current_ptr.
|
||||
const expr = isCurrent ? '_tx_thread_current_ptr' : `(TX_THREAD*)${hex}`;
|
||||
|
||||
const stackPointer = parseNumericValue(
|
||||
await evaluateValue(reader, `${expr}->tx_thread_stack_ptr`)
|
||||
);
|
||||
const stackBase = parseNumericValue(
|
||||
await evaluateValue(reader, `${expr}->tx_thread_stack_start`)
|
||||
);
|
||||
const stackEnd = parseNumericValue(
|
||||
await evaluateValue(reader, `${expr}->tx_thread_stack_end`)
|
||||
);
|
||||
const priority = parseNumericValue(
|
||||
await evaluateValue(reader, `${expr}->tx_thread_priority`)
|
||||
);
|
||||
const stateValue = await evaluateValue(reader, `${expr}->tx_thread_state`);
|
||||
const name =
|
||||
parseThreadName(await evaluateValue(reader, `${expr}->tx_thread_name`)) ||
|
||||
`ThreadX Thread ${isCurrent ? gdbCurrentThreadId : threadPtr}`;
|
||||
|
||||
threads.push({
|
||||
id: isCurrent ? gdbCurrentThreadId : threadPtr,
|
||||
gdbThreadId: isCurrent ? gdbCurrentThreadId : undefined,
|
||||
isCurrent,
|
||||
name,
|
||||
priority,
|
||||
stackPointer,
|
||||
stackInfo: calculateStackInfo(stackBase, stackEnd, stackPointer),
|
||||
state: mapThreadXState(stateValue, isCurrent),
|
||||
source: this.type,
|
||||
});
|
||||
|
||||
// tx_thread_created_next forms a circular list; the visited-set detects wrap-around.
|
||||
threadPtr = parseNumericValue(
|
||||
await evaluateValue(reader, `((TX_THREAD*)${hex})->tx_thread_created_next`)
|
||||
);
|
||||
}
|
||||
|
||||
if (threads.length > 0) {
|
||||
return threads;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return only the current thread via the existing named expressions.
|
||||
const stackPointer = parseNumericValue(
|
||||
await evaluateValue(reader, '_tx_thread_current_ptr->tx_thread_stack_ptr')
|
||||
);
|
||||
const stackBase = parseNumericValue(
|
||||
await evaluateValue(reader, '_tx_thread_current_ptr->tx_thread_stack_start')
|
||||
);
|
||||
const stackEnd = parseNumericValue(
|
||||
await evaluateValue(reader, '_tx_thread_current_ptr->tx_thread_stack_end')
|
||||
);
|
||||
const priority = parseNumericValue(
|
||||
await evaluateValue(reader, '_tx_thread_current_ptr->tx_thread_priority')
|
||||
);
|
||||
const stateValue = await evaluateValue(reader, '_tx_thread_current_ptr->tx_thread_state');
|
||||
const name =
|
||||
parseThreadName(await evaluateValue(reader, '_tx_thread_current_ptr->tx_thread_name')) ||
|
||||
`ThreadX Thread ${gdbCurrentThreadId}`;
|
||||
|
||||
return [
|
||||
{
|
||||
id: gdbCurrentThreadId,
|
||||
gdbThreadId: gdbCurrentThreadId,
|
||||
isCurrent: true,
|
||||
name,
|
||||
priority,
|
||||
stackPointer,
|
||||
stackInfo: calculateStackInfo(stackBase, stackEnd, stackPointer),
|
||||
state: mapThreadXState(stateValue, true),
|
||||
source: this.type,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export class ZephyrThreadParser implements RTOSThreadParser {
|
||||
readonly type = RTOSType.Zephyr;
|
||||
|
||||
async parseThreads(reader: RTOSExpressionReader, options?: RTOSLoadOptions): Promise<RTOSThread[]> {
|
||||
const currentPtr = parseNumericValue(await evaluateValue(reader, '_kernel.current'));
|
||||
if (!currentPtr) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const gdbCurrentThreadId = defaultThreadId(options);
|
||||
|
||||
// Try to walk the full NULL-terminated thread list via _kernel.threads.
|
||||
const listHeadPtr = parseNumericValue(await evaluateValue(reader, '_kernel.threads'));
|
||||
if (listHeadPtr) {
|
||||
const threads: RTOSThread[] = [];
|
||||
const visitedPtrs = new Set<number>();
|
||||
let threadPtr: number | undefined = listHeadPtr;
|
||||
let safety = 0;
|
||||
|
||||
while (threadPtr && !visitedPtrs.has(threadPtr) && safety++ < 64) {
|
||||
visitedPtrs.add(threadPtr);
|
||||
const hex = `0x${threadPtr.toString(16)}`;
|
||||
const isCurrent = threadPtr === currentPtr;
|
||||
// Use the symbolic pointer expression for the current thread for compat.
|
||||
const expr = isCurrent
|
||||
? '(struct k_thread *)_kernel.current'
|
||||
: `(struct k_thread*)${hex}`;
|
||||
|
||||
const stackPointer = parseNumericValue(
|
||||
await evaluateValue(reader, `${expr}->callee_saved.psp`)
|
||||
);
|
||||
const priority = parseNumericValue(await evaluateValue(reader, `${expr}->base.prio`));
|
||||
const stateValue = await evaluateValue(reader, `${expr}->base.thread_state`);
|
||||
const name =
|
||||
parseThreadName(await evaluateValue(reader, `${expr}->name`)) ||
|
||||
`Zephyr Thread ${isCurrent ? gdbCurrentThreadId : threadPtr}`;
|
||||
|
||||
threads.push({
|
||||
id: isCurrent ? gdbCurrentThreadId : threadPtr,
|
||||
gdbThreadId: isCurrent ? gdbCurrentThreadId : undefined,
|
||||
isCurrent,
|
||||
name,
|
||||
priority,
|
||||
stackPointer,
|
||||
state: mapZephyrState(stateValue, isCurrent),
|
||||
source: this.type,
|
||||
});
|
||||
|
||||
threadPtr = parseNumericValue(
|
||||
await evaluateValue(reader, `((struct k_thread*)${hex})->next_thread`)
|
||||
);
|
||||
}
|
||||
|
||||
if (threads.length > 0) {
|
||||
return threads;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return only the current thread via the existing named expressions.
|
||||
const stackPointer = parseNumericValue(
|
||||
await evaluateValue(reader, '((struct k_thread *)_kernel.current)->callee_saved.psp')
|
||||
);
|
||||
const priority = parseNumericValue(
|
||||
await evaluateValue(reader, '((struct k_thread *)_kernel.current)->base.prio')
|
||||
);
|
||||
const stateValue = await evaluateValue(
|
||||
reader,
|
||||
'((struct k_thread *)_kernel.current)->base.thread_state'
|
||||
);
|
||||
const name =
|
||||
parseThreadName(
|
||||
await evaluateValue(reader, '((struct k_thread *)_kernel.current)->name')
|
||||
) || `Zephyr Thread ${gdbCurrentThreadId}`;
|
||||
|
||||
return [
|
||||
{
|
||||
id: gdbCurrentThreadId,
|
||||
gdbThreadId: gdbCurrentThreadId,
|
||||
isCurrent: true,
|
||||
name,
|
||||
priority,
|
||||
stackPointer,
|
||||
state: mapZephyrState(stateValue, true),
|
||||
source: this.type,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export class RTOSManager {
|
||||
private parsers = new Map<RTOSType, RTOSThreadParser>();
|
||||
|
||||
constructor(private detector: RTOSDetector = new RTOSDetector()) {
|
||||
const supportedParsers: RTOSThreadParser[] = [
|
||||
new FreeRTOSThreadParser(),
|
||||
new ThreadXThreadParser(),
|
||||
new ZephyrThreadParser(),
|
||||
];
|
||||
|
||||
supportedParsers.forEach((parser) => {
|
||||
this.parsers.set(parser.type, parser);
|
||||
});
|
||||
}
|
||||
|
||||
async load(reader: MI2 | RTOSExpressionReader, options?: RTOSLoadOptions): Promise<RTOSLoadResult> {
|
||||
if (options?.enabled === false) {
|
||||
return { type: RTOSType.None, threads: [] };
|
||||
}
|
||||
|
||||
const requestedType = normalizeRequestedType(options?.requestedType);
|
||||
const resolvedType =
|
||||
requestedType === 'auto' ? await this.detector.detect(reader as RTOSExpressionReader) : requestedType;
|
||||
const parser = this.parsers.get(resolvedType);
|
||||
|
||||
if (!parser) {
|
||||
return { type: resolvedType, threads: [] };
|
||||
}
|
||||
|
||||
const threads = await parser.parseThreads(reader as RTOSExpressionReader, options);
|
||||
return { type: resolvedType, threads };
|
||||
}
|
||||
}
|
||||
@@ -130,20 +130,24 @@ export class SymbolTable {
|
||||
return this.symbols.filter((sym) => sym.type === SymbolType.Function);
|
||||
}
|
||||
|
||||
/** Returns all global object symbols. */
|
||||
/** Returns all global object symbols, excluding LTO-internal symbols. */
|
||||
getGlobalVariables(): SymbolInformation[] {
|
||||
return this.symbols.filter(
|
||||
(sym) => sym.type === SymbolType.Object && sym.scope === SymbolScope.Global
|
||||
(sym) =>
|
||||
sym.type === SymbolType.Object &&
|
||||
sym.scope === SymbolScope.Global &&
|
||||
!sym.name.includes('.lto_priv.')
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns file-local object symbols. */
|
||||
/** Returns file-local object symbols, excluding LTO-internal symbols. */
|
||||
getStaticVariables(file: string): SymbolInformation[] {
|
||||
return this.symbols.filter(
|
||||
(sym) =>
|
||||
sym.type === SymbolType.Object &&
|
||||
sym.scope === SymbolScope.Local &&
|
||||
sym.file === file
|
||||
sym.file === file &&
|
||||
!sym.name.includes('.lto_priv.')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+215
-2
@@ -4,10 +4,11 @@ import { encodeDisassembly } from './utils';
|
||||
import { PlatformIODebugConfigurationProvider } from './frontend/configprovider';
|
||||
import { DisassemblyContentProvider } from './frontend/disassembly_content_provider';
|
||||
import { DisassemblyTreeProvider } from './frontend/disassembly_tree_provider';
|
||||
import { MemoryContentProvider } from './frontend/memory_content_provider';
|
||||
import { MemoryContentProvider, MemoryDataType, Endianness } from './frontend/memory_content_provider';
|
||||
import { MemoryTreeProvider } from './frontend/memory_tree_provider';
|
||||
import { PeripheralTreeProvider, RecordType as PeripheralRecordType } from './frontend/peripheral';
|
||||
import { RegisterTreeProvider, RecordType as RegisterRecordType } from './frontend/registers';
|
||||
import { getDiagnosticsManager } from './frontend/diagnostics';
|
||||
|
||||
/**
|
||||
* Main entry point and controller for the PlatformIO Debug VS Code extension.
|
||||
@@ -21,6 +22,7 @@ class PlatformIODebugExtension {
|
||||
private memoryTreeProvider: MemoryTreeProvider;
|
||||
private disassemblyTreeProvider: DisassemblyTreeProvider;
|
||||
private memoryContentProvider: MemoryContentProvider;
|
||||
private diagnostics = getDiagnosticsManager();
|
||||
|
||||
constructor(context: vscode.ExtensionContext) {
|
||||
this.context = context;
|
||||
@@ -30,9 +32,18 @@ class PlatformIODebugExtension {
|
||||
this.disassemblyTreeProvider = new DisassemblyTreeProvider();
|
||||
this.memoryContentProvider = new MemoryContentProvider();
|
||||
|
||||
// Apply workspace configuration defaults for memory and diagnostics settings.
|
||||
const cfg = vscode.workspace.getConfiguration('platformio-debug');
|
||||
const defaultDataType = cfg.get<string>('memory.defaultDataType', 'u8');
|
||||
const defaultEndianness = cfg.get<string>('memory.defaultEndianness', 'little');
|
||||
this.memoryContentProvider.setDataType(defaultDataType as MemoryDataType);
|
||||
this.memoryContentProvider.setEndianness(defaultEndianness as Endianness);
|
||||
this.diagnostics.setShowDevDebugOutput(cfg.get<boolean>('diagnostics.showDevDebugOutput', false));
|
||||
|
||||
const peripheralTreeView = vscode.window.createTreeView('platformio-debug.peripherals', {
|
||||
treeDataProvider: this.peripheralProvider,
|
||||
});
|
||||
this.peripheralProvider.setTreeView(peripheralTreeView);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.debug.registerDebugConfigurationProvider(
|
||||
@@ -56,23 +67,49 @@ class PlatformIODebugExtension {
|
||||
vscode.commands.registerCommand('platformio-debug.peripherals.selectedNode', this.peripheralsSelectedNode.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.peripherals.copyValue', this.peripheralsCopyValue.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.peripherals.setFormat', this.peripheralsSetFormat.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.peripherals.search', this.peripheralsSearch.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.registers.selectedNode', this.registersSelectedNode.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.registers.copyValue', this.registersCopyValue.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.registers.setFormat', this.registersSetFormat.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.memory.deleteHistoryItem', this.memoryDeleteHistoryItem.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.memory.clearHistory', this.memoryClearHistory.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.examineMemory', this.examineMemory.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.memory.setDataType', this.memorySetDataType.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.memory.toggleEndianness', this.memoryToggleEndianness.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.memory.edit', this.memoryWriteByte.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.memory.writeByte', this.memoryWriteByte.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.viewDisassembly', this.showDisassembly.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.setForceDisassembly', this.setForceDisassembly.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.diagnostics.showLog', this.showDiagnosticsLog.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.diagnostics.exportLog', this.exportDiagnosticsLog.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.diagnostics.clearLog', this.clearDiagnosticsLog.bind(this)),
|
||||
vscode.commands.registerCommand('platformio-debug.reloadSVD', this.reloadSVD.bind(this)),
|
||||
|
||||
vscode.debug.onDidReceiveDebugSessionCustomEvent(this.receivedCustomEvent.bind(this)),
|
||||
vscode.debug.onDidStartDebugSession(this.debugSessionStarted.bind(this)),
|
||||
vscode.debug.onDidTerminateDebugSession(this.debugSessionTerminated.bind(this)),
|
||||
vscode.window.onDidChangeActiveTextEditor(this.activeEditorChanged.bind(this)),
|
||||
vscode.workspace.onDidChangeConfiguration(e => {
|
||||
if (e.affectsConfiguration('platformio-debug.diagnostics.showDevDebugOutput')) {
|
||||
this.diagnostics.setShowDevDebugOutput(
|
||||
vscode.workspace.getConfiguration('platformio-debug').get('diagnostics.showDevDebugOutput', false)
|
||||
);
|
||||
}
|
||||
}),
|
||||
vscode.window.onDidChangeTextEditorSelection((e) => {
|
||||
if (e && e.textEditor.document.fileName.endsWith('.dbgmem')) {
|
||||
this.memoryContentProvider.handleSelection(e);
|
||||
}
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument(e => {
|
||||
if (e.document.uri.scheme === 'examinememory') {
|
||||
const editor = vscode.window.visibleTextEditors.find(
|
||||
ed => ed.document.uri.toString() === e.document.uri.toString()
|
||||
);
|
||||
if (editor) {
|
||||
this.memoryContentProvider.applyDiffDecorations(editor);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -267,6 +304,125 @@ class PlatformIODebugExtension {
|
||||
);
|
||||
}
|
||||
|
||||
/** Refreshes all open memory editors. */
|
||||
private refreshOpenMemoryEditors(): void {
|
||||
vscode.workspace.textDocuments.forEach((doc) => {
|
||||
if (doc.fileName.endsWith('.dbgmem')) {
|
||||
this.memoryContentProvider.update(doc);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Sets the data type for memory interpretation. */
|
||||
private memorySetDataType(): void {
|
||||
// Capture the active memory editor URI before opening the quick pick
|
||||
// (which may shift focus away from the editor).
|
||||
const activeMemUri = vscode.window.activeTextEditor?.document?.uri?.scheme === 'examinememory'
|
||||
? vscode.window.activeTextEditor.document.uri.toString()
|
||||
: null;
|
||||
|
||||
const dataTypes = [
|
||||
{ label: 'u8 (unsigned 8-bit)', value: MemoryDataType.U8 },
|
||||
{ label: 'u16 (unsigned 16-bit)', value: MemoryDataType.U16 },
|
||||
{ label: 'u32 (unsigned 32-bit)', value: MemoryDataType.U32 },
|
||||
{ label: 'u64 (unsigned 64-bit)', value: MemoryDataType.U64 },
|
||||
{ label: 'i8 (signed 8-bit)', value: MemoryDataType.I8 },
|
||||
{ label: 'i16 (signed 16-bit)', value: MemoryDataType.I16 },
|
||||
{ label: 'i32 (signed 32-bit)', value: MemoryDataType.I32 },
|
||||
{ label: 'i64 (signed 64-bit)', value: MemoryDataType.I64 },
|
||||
{ label: 'float (32-bit float)', value: MemoryDataType.Float },
|
||||
{ label: 'double (64-bit double)', value: MemoryDataType.Double }
|
||||
];
|
||||
|
||||
vscode.window.showQuickPick(dataTypes.map(dt => dt.label)).then(selected => {
|
||||
if (selected) {
|
||||
const dataType = dataTypes.find(dt => dt.label === selected)?.value;
|
||||
if (dataType) {
|
||||
if (activeMemUri) {
|
||||
this.memoryContentProvider.setDataTypeForUri(activeMemUri, dataType);
|
||||
} else {
|
||||
this.memoryContentProvider.setDataType(dataType);
|
||||
}
|
||||
this.refreshOpenMemoryEditors();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggles endianness for memory interpretation. */
|
||||
private memoryToggleEndianness(): void {
|
||||
const activeMemUri = vscode.window.activeTextEditor?.document?.uri?.scheme === 'examinememory'
|
||||
? vscode.window.activeTextEditor.document.uri.toString()
|
||||
: null;
|
||||
|
||||
let endianness: Endianness;
|
||||
if (activeMemUri) {
|
||||
this.memoryContentProvider.toggleEndiannessForUri(activeMemUri);
|
||||
endianness = this.memoryContentProvider.getEndiannessForUri(activeMemUri);
|
||||
} else {
|
||||
this.memoryContentProvider.toggleEndianness();
|
||||
endianness = this.memoryContentProvider.getEndianness();
|
||||
}
|
||||
vscode.window.showInformationMessage(`Memory view endianness: ${endianness}`);
|
||||
|
||||
this.refreshOpenMemoryEditors();
|
||||
}
|
||||
|
||||
/** Writes a byte to memory at the given address. */
|
||||
private async memoryWriteByte(args: { address: number; value: number }): Promise<void> {
|
||||
if (!this.isPIODebugSession()) {
|
||||
vscode.window.showErrorMessage('No debugging session available');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args || typeof args.address !== 'number' || typeof args.value !== 'number') {
|
||||
// Interactive mode - prompt for address and value
|
||||
const addressInput = await vscode.window.showInputBox({
|
||||
prompt: 'Memory address to write (hex with 0x prefix or decimal)',
|
||||
validateInput: (value) => {
|
||||
if (/^0x[0-9a-f]+$/i.test(value) || /^[0-9]+$/i.test(value)) {
|
||||
return null;
|
||||
}
|
||||
return 'Invalid address format';
|
||||
}
|
||||
});
|
||||
|
||||
if (!addressInput) return;
|
||||
|
||||
const valueInput = await vscode.window.showInputBox({
|
||||
prompt: 'Value to write (0x00 - 0xFF)',
|
||||
validateInput: (value) => {
|
||||
if (/^0x[0-9a-f]{1,2}$/i.test(value)) {
|
||||
return null;
|
||||
}
|
||||
return 'Invalid hex byte format (use 0x00 - 0xFF)';
|
||||
}
|
||||
});
|
||||
|
||||
if (!valueInput) return;
|
||||
|
||||
const address = addressInput.startsWith('0x')
|
||||
? parseInt(addressInput.substring(2), 16)
|
||||
: parseInt(addressInput, 10);
|
||||
const value = parseInt(valueInput.substring(2), 16);
|
||||
|
||||
const success = await this.memoryContentProvider.writeByte(address, value);
|
||||
if (success) {
|
||||
vscode.window.showInformationMessage(`Wrote 0x${value.toString(16).padStart(2, '0').toUpperCase()} to ${addressInput}`);
|
||||
this.refreshOpenMemoryEditors();
|
||||
}
|
||||
} else {
|
||||
// Direct call with args
|
||||
const success = await this.memoryContentProvider.writeByte(args.address, args.value);
|
||||
if (success) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Wrote 0x${args.value.toString(16).padStart(2, '0').toUpperCase()} to 0x${args.address.toString(16).toUpperCase()}`
|
||||
);
|
||||
this.refreshOpenMemoryEditors();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Updates a peripheral node and refreshes. */
|
||||
private peripheralsUpdateNode(node: any): void {
|
||||
node.node.performUpdate().then(
|
||||
@@ -316,6 +472,11 @@ class PlatformIODebugExtension {
|
||||
this.peripheralProvider.refresh();
|
||||
}
|
||||
|
||||
/** Opens the peripheral search/filter QuickPick. */
|
||||
private peripheralsSearch(): Promise<void> {
|
||||
return this.peripheralProvider.search();
|
||||
}
|
||||
|
||||
/** Handles selection of a register node. */
|
||||
private registersSelectedNode(node: any): void {
|
||||
if (node.recordType !== RegisterRecordType.Field) {
|
||||
@@ -352,8 +513,18 @@ class PlatformIODebugExtension {
|
||||
this.registerProvider.debugSessionStarted(
|
||||
this.context.workspaceState.get('debugRegistersTreeState')
|
||||
);
|
||||
let svdPath: string | undefined = args.svdPath;
|
||||
if (!svdPath) {
|
||||
svdPath = this.peripheralProvider.findSVDFile(args.device);
|
||||
if (svdPath) {
|
||||
this.diagnostics.info(
|
||||
'SVD',
|
||||
`Auto-discovered SVD file: ${svdPath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
this.peripheralProvider.debugSessionStarted(
|
||||
args.svdPath,
|
||||
svdPath,
|
||||
this.context.workspaceState.get('debugPeripheralsTreeState')
|
||||
);
|
||||
this.memoryTreeProvider.debugSessionStarted(
|
||||
@@ -446,6 +617,48 @@ class PlatformIODebugExtension {
|
||||
}
|
||||
this.adapterOutputChannel.append(content);
|
||||
}
|
||||
|
||||
/** Shows the diagnostic log output channel. */
|
||||
private showDiagnosticsLog(): void {
|
||||
this.diagnostics.showOutputChannel();
|
||||
}
|
||||
|
||||
/** Exports diagnostic log to clipboard. */
|
||||
private exportDiagnosticsLog(): void {
|
||||
const logContent = this.diagnostics.exportLog();
|
||||
vscode.env.clipboard.writeText(logContent).then(() => {
|
||||
this.diagnostics.showInfo('Diagnostic log copied to clipboard');
|
||||
});
|
||||
}
|
||||
|
||||
/** Clears the diagnostic log. */
|
||||
private clearDiagnosticsLog(): void {
|
||||
this.diagnostics.clearLog();
|
||||
this.diagnostics.showInfo('Diagnostic log cleared');
|
||||
}
|
||||
|
||||
/** Reloads the peripheral tree using the supplied SVD file path. */
|
||||
private async reloadSVD(svdPath: string): Promise<void> {
|
||||
let resolvedPath = svdPath;
|
||||
if (!resolvedPath) {
|
||||
const uris = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: true,
|
||||
canSelectFolders: false,
|
||||
canSelectMany: false,
|
||||
filters: {
|
||||
'SVD Files': ['svd', 'SVD'],
|
||||
'All Files': ['*'],
|
||||
},
|
||||
openLabel: 'Select SVD File',
|
||||
});
|
||||
if (!uris || uris.length === 0) {
|
||||
return;
|
||||
}
|
||||
resolvedPath = uris[0].fsPath;
|
||||
}
|
||||
this.peripheralProvider.reloadSVD(resolvedPath);
|
||||
this.diagnostics.showInfo(`Reloaded SVD: ${resolvedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
import * as vscode from 'vscode'
|
||||
|
||||
/**
|
||||
* Represents an action that can be taken when an error occurs.
|
||||
*/
|
||||
export interface ErrorAction {
|
||||
label: string
|
||||
callback: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Log entry for diagnostic output.
|
||||
*/
|
||||
export interface LogEntry {
|
||||
timestamp: Date
|
||||
level: 'debug' | 'info' | 'warn' | 'error'
|
||||
source: string
|
||||
message: string
|
||||
details?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Error patterns for common GDB/MI errors with suggested actions.
|
||||
*/
|
||||
interface ErrorPattern {
|
||||
pattern: RegExp
|
||||
message: string
|
||||
actions: ErrorAction[]
|
||||
severity: 'error' | 'warning'
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized diagnostics and error handling for the debug extension.
|
||||
* Provides structured error messages, logging, and actionable error recovery.
|
||||
*/
|
||||
export class DiagnosticsManager {
|
||||
private outputChannel: vscode.OutputChannel
|
||||
private logEntries: LogEntry[] = []
|
||||
private maxLogEntries: number = 1000
|
||||
private errorPatterns: ErrorPattern[] = []
|
||||
private showDevDebugOutput: boolean = false
|
||||
|
||||
constructor() {
|
||||
this.outputChannel = vscode.window.createOutputChannel('PlatformIO Debug Diagnostics')
|
||||
this.initializeErrorPatterns()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether to show detailed debug output.
|
||||
*/
|
||||
setShowDevDebugOutput(enabled: boolean): void {
|
||||
this.showDevDebugOutput = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a message to the diagnostic output.
|
||||
*/
|
||||
log(level: LogEntry['level'], source: string, message: string, details?: string): void {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date(),
|
||||
level,
|
||||
source,
|
||||
message,
|
||||
details
|
||||
}
|
||||
|
||||
this.logEntries.push(entry)
|
||||
|
||||
// Maintain log size limit
|
||||
if (this.logEntries.length > this.maxLogEntries) {
|
||||
this.logEntries.shift()
|
||||
}
|
||||
|
||||
// Always log errors and warnings; log info in normal mode; log debug only if dev output enabled
|
||||
if (level === 'error' || level === 'warn' || level === 'info' || this.showDevDebugOutput) {
|
||||
const timestamp = entry.timestamp.toISOString().split('T')[1].split('.')[0]
|
||||
const logLine = `[${timestamp}] [${level.toUpperCase()}] [${source}] ${message}`
|
||||
this.outputChannel.appendLine(logLine)
|
||||
if (details && this.showDevDebugOutput) {
|
||||
this.outputChannel.appendLine(` Details: ${details}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a debug message.
|
||||
*/
|
||||
debug(source: string, message: string, details?: string): void {
|
||||
this.log('debug', source, message, details)
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an informational message.
|
||||
*/
|
||||
info(source: string, message: string, details?: string): void {
|
||||
this.log('info', source, message, details)
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a warning message.
|
||||
*/
|
||||
warn(source: string, message: string, details?: string): void {
|
||||
this.log('warn', source, message, details)
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an error message.
|
||||
*/
|
||||
error(source: string, message: string, details?: string): void {
|
||||
this.log('error', source, message, details)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the diagnostic output channel.
|
||||
*/
|
||||
showOutputChannel(): void {
|
||||
this.outputChannel.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the diagnostic log.
|
||||
*/
|
||||
clearLog(): void {
|
||||
this.logEntries = []
|
||||
this.outputChannel.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all log entries, optionally filtered by level.
|
||||
*/
|
||||
getLogEntries(level?: LogEntry['level']): LogEntry[] {
|
||||
if (level) {
|
||||
return this.logEntries.filter(entry => entry.level === level)
|
||||
}
|
||||
return [...this.logEntries]
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the diagnostic log to a string for bug reports.
|
||||
*/
|
||||
exportLog(): string {
|
||||
const lines: string[] = [
|
||||
'PlatformIO Debug Diagnostic Log',
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
`Total Entries: ${this.logEntries.length}`,
|
||||
'---'
|
||||
]
|
||||
|
||||
for (const entry of this.logEntries) {
|
||||
const timestamp = entry.timestamp.toISOString()
|
||||
lines.push(`[${timestamp}] [${entry.level.toUpperCase()}] [${entry.source}] ${entry.message}`)
|
||||
if (entry.details) {
|
||||
lines.push(` Details: ${entry.details}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an error message with optional actions.
|
||||
*/
|
||||
showError(message: string, actions?: ErrorAction[]): void {
|
||||
this.error('DiagnosticsManager', `Showing error: ${message}`)
|
||||
|
||||
if (actions && actions.length > 0) {
|
||||
const actionLabels = actions.map(a => a.label)
|
||||
Promise.resolve(vscode.window.showErrorMessage(message, ...actionLabels))
|
||||
.then(selected => {
|
||||
if (selected) {
|
||||
const action = actions.find(a => a.label === selected)
|
||||
if (action) {
|
||||
this.info('DiagnosticsManager', `Executing action: ${action.label}`)
|
||||
try { action.callback() } catch (err) {
|
||||
this.error('DiagnosticsManager', `Error dialog action failed: ${err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => this.error('DiagnosticsManager', `Error dialog promise failed: ${err}`))
|
||||
} else {
|
||||
vscode.window.showErrorMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a warning message with optional actions.
|
||||
*/
|
||||
showWarning(message: string, actions?: ErrorAction[]): void {
|
||||
this.warn('DiagnosticsManager', `Showing warning: ${message}`)
|
||||
|
||||
if (actions && actions.length > 0) {
|
||||
const actionLabels = actions.map(a => a.label)
|
||||
Promise.resolve(vscode.window.showWarningMessage(message, ...actionLabels))
|
||||
.then(selected => {
|
||||
if (selected) {
|
||||
const action = actions.find(a => a.label === selected)
|
||||
if (action) {
|
||||
try { action.callback() } catch (err) {
|
||||
this.error('DiagnosticsManager', `Warning dialog action failed: ${err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => this.error('DiagnosticsManager', `Warning dialog promise failed: ${err}`))
|
||||
} else {
|
||||
vscode.window.showWarningMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an informational message with optional actions.
|
||||
*/
|
||||
showInfo(message: string, actions?: ErrorAction[]): void {
|
||||
this.info('DiagnosticsManager', `Showing info: ${message}`)
|
||||
|
||||
if (actions && actions.length > 0) {
|
||||
const actionLabels = actions.map(a => a.label)
|
||||
Promise.resolve(vscode.window.showInformationMessage(message, ...actionLabels))
|
||||
.then(selected => {
|
||||
if (selected) {
|
||||
const action = actions.find(a => a.label === selected)
|
||||
if (action) {
|
||||
try { action.callback() } catch (err) {
|
||||
this.error('DiagnosticsManager', `Info dialog action failed: ${err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => this.error('DiagnosticsManager', `Info dialog promise failed: ${err}`))
|
||||
} else {
|
||||
vscode.window.showInformationMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a GDB/MI error by matching against known patterns.
|
||||
* Returns true if the error was handled by a pattern, false otherwise.
|
||||
*/
|
||||
handleGDBError(errorMessage: string): boolean {
|
||||
this.error('GDB', 'Error received', errorMessage)
|
||||
|
||||
for (const pattern of this.errorPatterns) {
|
||||
if (pattern.pattern.test(errorMessage)) {
|
||||
this.info('DiagnosticsManager', `Matched error pattern: ${pattern.message}`)
|
||||
|
||||
if (pattern.severity === 'error') {
|
||||
this.showError(pattern.message, pattern.actions)
|
||||
} else {
|
||||
this.showWarning(pattern.message, pattern.actions)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// No pattern matched, show generic error
|
||||
this.showError(`Debug error: ${errorMessage}`, [
|
||||
{
|
||||
label: 'Show Diagnostics',
|
||||
callback: () => this.showOutputChannel()
|
||||
},
|
||||
{
|
||||
label: 'Copy Error',
|
||||
callback: () => {
|
||||
vscode.env.clipboard.writeText(errorMessage)
|
||||
this.showInfo('Error message copied to clipboard')
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles connection errors with specific troubleshooting steps.
|
||||
*/
|
||||
handleConnectionError(error: string, host?: string, port?: number): void {
|
||||
this.error('Connection', `Connection error to ${host}:${port}`, error)
|
||||
|
||||
this.showError(
|
||||
`Failed to connect to debug server${host && port ? ` at ${host}:${port}` : ''}.`,
|
||||
[
|
||||
{
|
||||
label: 'Check Connection',
|
||||
callback: () => {
|
||||
this.showInfo(
|
||||
'Troubleshooting:\n' +
|
||||
'1. Verify target device is connected\n' +
|
||||
'2. Check OpenOCD/GDB server is running\n' +
|
||||
'3. Verify port and host settings\n' +
|
||||
'4. Check firewall settings'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Restart Debug',
|
||||
callback: () => {
|
||||
vscode.commands.executeCommand('workbench.action.debug.restart')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Show Diagnostics',
|
||||
callback: () => this.showOutputChannel()
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles SVD file loading errors.
|
||||
*/
|
||||
handleSVDError(error: string, svdPath?: string): void {
|
||||
this.error('SVD', `SVD file error${svdPath ? ` for ${svdPath}` : ''}`, error)
|
||||
|
||||
const message = svdPath
|
||||
? `Failed to load SVD file: ${svdPath}`
|
||||
: 'Failed to load SVD file'
|
||||
|
||||
this.showError(message, [
|
||||
{
|
||||
label: 'Locate SVD File',
|
||||
callback: () => {
|
||||
vscode.window.showOpenDialog({
|
||||
canSelectFiles: true,
|
||||
canSelectFolders: false,
|
||||
filters: {
|
||||
'SVD Files': ['svd'],
|
||||
'All Files': ['*']
|
||||
}
|
||||
}).then(uri => {
|
||||
if (uri && uri[0]) {
|
||||
// Notify extension to reload with new SVD path
|
||||
vscode.commands.executeCommand('platformio-debug.reloadSVD', uri[0].fsPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Skip SVD Load',
|
||||
callback: () => {
|
||||
this.showInfo('SVD loading skipped. Peripheral view will not be available.')
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles memory access errors.
|
||||
*/
|
||||
handleMemoryError(error: string, address?: number): void {
|
||||
this.error('Memory', `Memory access error${address !== undefined ? ` at 0x${address.toString(16)}` : ''}`, error)
|
||||
|
||||
const message = address !== undefined
|
||||
? `Cannot access memory at address 0x${address.toString(16)}`
|
||||
: 'Cannot access memory'
|
||||
|
||||
this.showError(message, [
|
||||
{
|
||||
label: 'Target May Not Be Halted',
|
||||
callback: () => {
|
||||
this.showInfo(
|
||||
'To access memory, the target must be halted.\n' +
|
||||
'Try pausing execution first (F6) or setting a breakpoint.'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Check Address',
|
||||
callback: () => {
|
||||
this.showInfo(
|
||||
'Verify the memory address is valid:\n' +
|
||||
'1. Check device memory map\n' +
|
||||
'2. Verify address is accessible\n' +
|
||||
'3. Check if region requires special permissions'
|
||||
)
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the error pattern matchers.
|
||||
*/
|
||||
private initializeErrorPatterns(): void {
|
||||
this.errorPatterns = [
|
||||
{
|
||||
pattern: /connection\s+(refused|failed|timed\s+out)/i,
|
||||
message: 'Debug server connection failed. The target may not be connected.',
|
||||
severity: 'error',
|
||||
actions: [
|
||||
{
|
||||
label: 'Check Connection',
|
||||
callback: () => this.showConnectionTroubleshooting()
|
||||
},
|
||||
{
|
||||
label: 'Retry',
|
||||
callback: () => vscode.commands.executeCommand('workbench.action.debug.restart')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
pattern: /no\s+such\s+file\s+or\s+directory/i,
|
||||
message: 'Required file not found. Check your project configuration.',
|
||||
severity: 'error',
|
||||
actions: [
|
||||
{
|
||||
label: 'Open Settings',
|
||||
callback: () => vscode.commands.executeCommand('workbench.action.openSettings', 'platformio')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
pattern: /cannot\s+access\s+memory/i,
|
||||
message: 'Cannot access target memory. The target may not be halted.',
|
||||
severity: 'error',
|
||||
actions: [
|
||||
{
|
||||
label: 'Pause Target',
|
||||
callback: () => vscode.commands.executeCommand('workbench.action.debug.pause')
|
||||
},
|
||||
{
|
||||
label: 'Set Breakpoint',
|
||||
callback: () => vscode.commands.executeCommand('editor.debug.action.toggleBreakpoint')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
pattern: /remote\s+replied\s+with\s+error/i,
|
||||
message: 'GDB server protocol error. There may be a version mismatch.',
|
||||
severity: 'warning',
|
||||
actions: [
|
||||
{
|
||||
label: 'Show Diagnostics',
|
||||
callback: () => this.showOutputChannel()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
pattern: /unrecognized\s+command/i,
|
||||
message: 'Unrecognized GDB command. Check GDB server compatibility.',
|
||||
severity: 'warning',
|
||||
actions: [
|
||||
{
|
||||
label: 'Check GDB Version',
|
||||
callback: () => this.showInfo('Ensure GDB version is compatible with your target.')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows connection troubleshooting information.
|
||||
*/
|
||||
private showConnectionTroubleshooting(): void {
|
||||
this.showInfo(
|
||||
'Connection Troubleshooting:\n\n' +
|
||||
'1. Verify target device is connected via USB/JTAG\n' +
|
||||
'2. Check OpenOCD or GDB server is running\n' +
|
||||
'3. Verify port settings in launch.json\n' +
|
||||
'4. Check for driver issues (Zadig for Windows)\n' +
|
||||
'5. Ensure correct permissions (Linux: udev rules)',
|
||||
[
|
||||
{
|
||||
label: 'Open Documentation',
|
||||
callback: () => {
|
||||
vscode.env.openExternal(vscode.Uri.parse('https://docs.platformio.org/en/latest/plus/debugging.html'))
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of the diagnostics manager.
|
||||
*/
|
||||
let diagnosticsManager: DiagnosticsManager | null = null
|
||||
|
||||
/**
|
||||
* Gets the singleton diagnostics manager instance.
|
||||
* Creates the instance on first call.
|
||||
*/
|
||||
export function getDiagnosticsManager(): DiagnosticsManager {
|
||||
if (!diagnosticsManager) {
|
||||
diagnosticsManager = new DiagnosticsManager()
|
||||
}
|
||||
return diagnosticsManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the singleton instance (useful for testing).
|
||||
*/
|
||||
export function resetDiagnosticsManager(): void {
|
||||
diagnosticsManager = null
|
||||
}
|
||||
@@ -1,11 +1,32 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { hexFormat, parseQuery } from '../utils';
|
||||
|
||||
/** Supported data types for memory interpretation. */
|
||||
export enum MemoryDataType {
|
||||
U8 = 'u8',
|
||||
U16 = 'u16',
|
||||
U32 = 'u32',
|
||||
U64 = 'u64',
|
||||
I8 = 'i8',
|
||||
I16 = 'i16',
|
||||
I32 = 'i32',
|
||||
I64 = 'i64',
|
||||
Float = 'float',
|
||||
Double = 'double',
|
||||
}
|
||||
|
||||
/** Endianness options. */
|
||||
export enum Endianness {
|
||||
Little = 'little',
|
||||
Big = 'big',
|
||||
}
|
||||
|
||||
/** TextDocumentContentProvider for examinememory://. */
|
||||
export class MemoryContentProvider implements vscode.TextDocumentContentProvider {
|
||||
private _onDidChange = new vscode.EventEmitter<vscode.Uri>();
|
||||
public onDidChange = this._onDidChange.event;
|
||||
|
||||
private readonly headerLines = 2;
|
||||
private firstBytePos = 10;
|
||||
private lastBytePos = this.firstBytePos + 48 - 1;
|
||||
private firstAsciiPos = this.lastBytePos + 3;
|
||||
@@ -20,6 +41,130 @@ export class MemoryContentProvider implements vscode.TextDocumentContentProvider
|
||||
dark: { borderColor: 'lightblue' },
|
||||
});
|
||||
|
||||
private diffDecorationType = vscode.window.createTextEditorDecorationType({
|
||||
overviewRulerColor: 'orange',
|
||||
overviewRulerLane: vscode.OverviewRulerLane.Right,
|
||||
light: { backgroundColor: 'rgba(255, 140, 0, 0.25)' },
|
||||
dark: { backgroundColor: 'rgba(255, 165, 0, 0.25)' },
|
||||
});
|
||||
|
||||
// New: Data type interpretation settings
|
||||
private dataType: MemoryDataType = MemoryDataType.U8;
|
||||
private endianness: Endianness = Endianness.Little;
|
||||
|
||||
// ASCII column toggle
|
||||
private showAscii: boolean = true;
|
||||
|
||||
// New: Track memory contents for data type display
|
||||
private currentBytes: number[] = [];
|
||||
private currentAddress: number = 0;
|
||||
|
||||
// Memory diff tracking — per-URI to prevent cross-contamination between open windows
|
||||
private previousBytes: number[] = [];
|
||||
private readonly uriBytes = new Map<string, { current: number[]; previous: number[] }>();
|
||||
private readonly uriChangedOffsets = new Map<string, Set<number>>();
|
||||
// Per-URI display settings; fall back to global defaults when absent.
|
||||
private readonly uriSettings = new Map<string, { dataType?: MemoryDataType; endianness?: Endianness; showAscii?: boolean }>();
|
||||
|
||||
/** Sets the data type for interpretation. */
|
||||
setDataType(type: MemoryDataType): void {
|
||||
this.dataType = type;
|
||||
}
|
||||
|
||||
/** Gets the current data type. */
|
||||
getDataType(): MemoryDataType {
|
||||
return this.dataType;
|
||||
}
|
||||
|
||||
/** Sets the endianness for multi-byte data types. */
|
||||
setEndianness(endianness: Endianness): void {
|
||||
this.endianness = endianness;
|
||||
}
|
||||
|
||||
/** Gets the current endianness. */
|
||||
getEndianness(): Endianness {
|
||||
return this.endianness;
|
||||
}
|
||||
|
||||
/** Toggles between little and big endian. */
|
||||
toggleEndianness(): void {
|
||||
this.endianness = this.endianness === Endianness.Little
|
||||
? Endianness.Big
|
||||
: Endianness.Little;
|
||||
}
|
||||
|
||||
/** Sets whether to show the ASCII column. */
|
||||
setShowAscii(show: boolean): void {
|
||||
this.showAscii = show;
|
||||
}
|
||||
|
||||
/** Gets whether the ASCII column is shown. */
|
||||
getShowAscii(): boolean {
|
||||
return this.showAscii;
|
||||
}
|
||||
|
||||
/** Toggles the ASCII column visibility. */
|
||||
toggleAsciiView(): void {
|
||||
this.showAscii = !this.showAscii;
|
||||
}
|
||||
|
||||
/** Sets the data type for a specific document URI. */
|
||||
setDataTypeForUri(uriKey: string, type: MemoryDataType): void {
|
||||
const s = this.uriSettings.get(uriKey) ?? {};
|
||||
s.dataType = type;
|
||||
this.uriSettings.set(uriKey, s);
|
||||
}
|
||||
|
||||
/** Gets the effective data type for a URI, falling back to the global default. */
|
||||
getDataTypeForUri(uriKey: string): MemoryDataType {
|
||||
return this.uriSettings.get(uriKey)?.dataType ?? this.dataType;
|
||||
}
|
||||
|
||||
/** Toggles endianness for a specific document URI. */
|
||||
toggleEndiannessForUri(uriKey: string): void {
|
||||
const s = this.uriSettings.get(uriKey) ?? {};
|
||||
s.endianness = (s.endianness ?? this.endianness) === Endianness.Little
|
||||
? Endianness.Big
|
||||
: Endianness.Little;
|
||||
this.uriSettings.set(uriKey, s);
|
||||
}
|
||||
|
||||
/** Gets the effective endianness for a URI, falling back to the global default. */
|
||||
getEndiannessForUri(uriKey: string): Endianness {
|
||||
return this.uriSettings.get(uriKey)?.endianness ?? this.endianness;
|
||||
}
|
||||
|
||||
/** Toggles the ASCII column for a specific document URI. */
|
||||
toggleAsciiViewForUri(uriKey: string): void {
|
||||
const s = this.uriSettings.get(uriKey) ?? {};
|
||||
s.showAscii = !(s.showAscii ?? this.showAscii);
|
||||
this.uriSettings.set(uriKey, s);
|
||||
}
|
||||
|
||||
/** Returns byte offsets that differ between the last two reads. */
|
||||
getChangedOffsets(): number[] {
|
||||
return this.computeChangedOffsets(this.currentBytes, this.previousBytes);
|
||||
}
|
||||
|
||||
/** Internal helper: returns changed offsets between two byte arrays. */
|
||||
private computeChangedOffsets(current: number[], previous: number[]): number[] {
|
||||
if (previous.length === 0 || previous.length !== current.length) {
|
||||
return [];
|
||||
}
|
||||
const changed: number[] = [];
|
||||
for (let i = 0; i < current.length; i++) {
|
||||
if (current[i] !== previous[i]) {
|
||||
changed.push(i);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Returns the previous bytes snapshot (from the last read). */
|
||||
getPreviousBytes(): number[] {
|
||||
return this.previousBytes.slice();
|
||||
}
|
||||
|
||||
/** Returns hex+ASCII memory dump for the URI. */
|
||||
provideTextDocumentContent(uri: vscode.Uri): Thenable<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -31,16 +176,40 @@ export class MemoryContentProvider implements vscode.TextDocumentContentProvider
|
||||
? parseInt(params.length.substring(2), 16)
|
||||
: parseInt(params.length, 10);
|
||||
|
||||
this.currentAddress = address;
|
||||
|
||||
vscode.debug.activeDebugSession
|
||||
.customRequest('read-memory', { address, length: length || 32 })
|
||||
.then(
|
||||
(result: any) => {
|
||||
const bytes: number[] = result.bytes;
|
||||
let rowAddress = address - (address % 16);
|
||||
const offset = address - rowAddress;
|
||||
this.currentBytes = bytes;
|
||||
|
||||
// Compute diff against the previous read of THIS specific URI to
|
||||
// avoid cross-contamination when multiple memory windows are open.
|
||||
const uriKey = uri.toString();
|
||||
const uriState = this.uriBytes.get(uriKey) ?? { current: [], previous: [] };
|
||||
const changedOffsets = new Set(this.computeChangedOffsets(bytes, uriState.current));
|
||||
uriState.previous = uriState.current;
|
||||
uriState.current = bytes.slice();
|
||||
this.uriBytes.set(uriKey, uriState);
|
||||
this.uriChangedOffsets.set(uriKey, changedOffsets);
|
||||
|
||||
// Resolve per-URI display settings, falling back to global defaults.
|
||||
const uriOverrides = this.uriSettings.get(uriKey) ?? {};
|
||||
const dataType = uriOverrides.dataType ?? this.dataType;
|
||||
const endianness = uriOverrides.endianness ?? this.endianness;
|
||||
const showAscii = uriOverrides.showAscii ?? this.showAscii;
|
||||
|
||||
let output = '';
|
||||
|
||||
output += ' Offset: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F \t\n';
|
||||
// Header with data type info
|
||||
const asciiHeader = showAscii ? ' | ASCII' : '';
|
||||
output += ` Data Type: ${dataType}, Endianness: ${endianness}\n`;
|
||||
output += ` Offset: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F${asciiHeader}\n`;
|
||||
|
||||
let rowAddress = address - (address % 16);
|
||||
const offset = address - rowAddress;
|
||||
output += hexFormat(rowAddress, 8, false) + ': ';
|
||||
|
||||
let asciiStr = '';
|
||||
@@ -58,7 +227,9 @@ export class MemoryContentProvider implements vscode.TextDocumentContentProvider
|
||||
: String.fromCharCode(bytes[i]);
|
||||
|
||||
if ((address + i) % 16 === 15 && i < length - 1) {
|
||||
output += ' ' + asciiStr;
|
||||
if (showAscii) {
|
||||
output += ' |' + asciiStr;
|
||||
}
|
||||
asciiStr = '';
|
||||
output += '\n';
|
||||
rowAddress += 16;
|
||||
@@ -70,9 +241,26 @@ export class MemoryContentProvider implements vscode.TextDocumentContentProvider
|
||||
for (let i = 0; i < remaining; i++) {
|
||||
output += ' ';
|
||||
}
|
||||
output += ' ' + asciiStr;
|
||||
if (showAscii) {
|
||||
output += ' |' + asciiStr;
|
||||
}
|
||||
output += '\n';
|
||||
|
||||
// Add data type interpretation section
|
||||
const typeInfo = this.formatDataTypeInterpretation(bytes, address, dataType, endianness);
|
||||
if (typeInfo) {
|
||||
output += '\n Data Type Interpretation:\n';
|
||||
output += typeInfo;
|
||||
}
|
||||
|
||||
// Add memory diff summary when bytes changed since last read
|
||||
if (changedOffsets.size > 0) {
|
||||
output += `\n Diff: ${changedOffsets.size} byte(s) changed since last read\n`;
|
||||
}
|
||||
|
||||
// Store the prior snapshot so getChangedOffsets() returns the true diff.
|
||||
this.previousBytes = uriState.previous.slice();
|
||||
|
||||
resolve(output);
|
||||
},
|
||||
(error: any) => {
|
||||
@@ -85,6 +273,174 @@ export class MemoryContentProvider implements vscode.TextDocumentContentProvider
|
||||
});
|
||||
}
|
||||
|
||||
/** Formats data type interpretation of the bytes. */
|
||||
private formatDataTypeInterpretation(
|
||||
bytes: number[],
|
||||
baseAddress: number,
|
||||
dataType: MemoryDataType,
|
||||
endianness: Endianness
|
||||
): string {
|
||||
if (bytes.length === 0) return '';
|
||||
|
||||
const typeSize = this.getTypeSize(dataType);
|
||||
let output = '';
|
||||
let lineCount = 0;
|
||||
const maxLines = 16; // Limit output lines
|
||||
|
||||
for (let i = 0; i < bytes.length && lineCount < maxLines; i += typeSize) {
|
||||
if (i + typeSize > bytes.length) break;
|
||||
|
||||
const value = this.readValue(bytes, i, dataType, endianness);
|
||||
const address = baseAddress + i;
|
||||
|
||||
output += ` ${hexFormat(address, 8)}: `;
|
||||
|
||||
switch (dataType) {
|
||||
case MemoryDataType.U8:
|
||||
case MemoryDataType.U16:
|
||||
case MemoryDataType.U32:
|
||||
output += `${value} (0x${value.toString(16).toUpperCase()})\n`;
|
||||
break;
|
||||
case MemoryDataType.U64:
|
||||
output += `${value} (0x${(value as bigint).toString(16).toUpperCase()})\n`;
|
||||
break;
|
||||
case MemoryDataType.I8:
|
||||
case MemoryDataType.I16:
|
||||
case MemoryDataType.I32:
|
||||
output += `${value}\n`;
|
||||
break;
|
||||
case MemoryDataType.I64:
|
||||
output += `${value}\n`;
|
||||
break;
|
||||
case MemoryDataType.Float:
|
||||
output += `${value}\n`;
|
||||
break;
|
||||
case MemoryDataType.Double:
|
||||
output += `${value}\n`;
|
||||
break;
|
||||
}
|
||||
lineCount++;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Gets the size of a data type in bytes. */
|
||||
private getTypeSize(type: MemoryDataType): number {
|
||||
switch (type) {
|
||||
case MemoryDataType.U8:
|
||||
case MemoryDataType.I8:
|
||||
return 1;
|
||||
case MemoryDataType.U16:
|
||||
case MemoryDataType.I16:
|
||||
return 2;
|
||||
case MemoryDataType.U32:
|
||||
case MemoryDataType.I32:
|
||||
case MemoryDataType.Float:
|
||||
return 4;
|
||||
case MemoryDataType.U64:
|
||||
case MemoryDataType.I64:
|
||||
case MemoryDataType.Double:
|
||||
return 8;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads a value from bytes at given offset with specified type and endianness. */
|
||||
private readValue(bytes: number[], offset: number, type: MemoryDataType, endianness: Endianness): number | bigint {
|
||||
const size = this.getTypeSize(type);
|
||||
const buf = Buffer.alloc(size);
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
buf[i] = bytes[offset + i];
|
||||
}
|
||||
|
||||
if (endianness === Endianness.Big) {
|
||||
switch (type) {
|
||||
case MemoryDataType.U8: return buf.readUInt8(0);
|
||||
case MemoryDataType.U16: return buf.readUInt16BE(0);
|
||||
case MemoryDataType.U32: return buf.readUInt32BE(0);
|
||||
case MemoryDataType.U64: return buf.readBigUInt64BE(0);
|
||||
case MemoryDataType.I8: return buf.readInt8(0);
|
||||
case MemoryDataType.I16: return buf.readInt16BE(0);
|
||||
case MemoryDataType.I32: return buf.readInt32BE(0);
|
||||
case MemoryDataType.I64: return buf.readBigInt64BE(0);
|
||||
case MemoryDataType.Float: return buf.readFloatBE(0);
|
||||
case MemoryDataType.Double: return buf.readDoubleBE(0);
|
||||
default: return buf.readUInt8(0);
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case MemoryDataType.U8: return buf.readUInt8(0);
|
||||
case MemoryDataType.U16: return buf.readUInt16LE(0);
|
||||
case MemoryDataType.U32: return buf.readUInt32LE(0);
|
||||
case MemoryDataType.U64: return buf.readBigUInt64LE(0);
|
||||
case MemoryDataType.I8: return buf.readInt8(0);
|
||||
case MemoryDataType.I16: return buf.readInt16LE(0);
|
||||
case MemoryDataType.I32: return buf.readInt32LE(0);
|
||||
case MemoryDataType.I64: return buf.readBigInt64LE(0);
|
||||
case MemoryDataType.Float: return buf.readFloatLE(0);
|
||||
case MemoryDataType.Double: return buf.readDoubleLE(0);
|
||||
default: return buf.readUInt8(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes a single byte to memory at the given address. */
|
||||
async writeByte(address: number, value: number): Promise<boolean> {
|
||||
if (!vscode.debug.activeDebugSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(value) || value < 0 || value > 255) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Invalid byte value ${value}: must be an integer in the range 0–255`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const hexValue = hexFormat(value, 2, false);
|
||||
await vscode.debug.activeDebugSession.customRequest('write-memory', {
|
||||
address,
|
||||
data: hexValue
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(`Failed to write memory at ${hexFormat(address, 8)}: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes multiple bytes to memory at the given address. */
|
||||
async writeBytes(address: number, bytes: number[]): Promise<boolean> {
|
||||
if (!vscode.debug.activeDebugSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const b of bytes) {
|
||||
if (!Number.isInteger(b) || b < 0 || b > 255) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Invalid byte value ${b}: each byte must be an integer in the range 0–255`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const hexData = bytes.map(b => hexFormat(b, 2, false)).join('');
|
||||
await vscode.debug.activeDebugSession.customRequest('write-memory', {
|
||||
address,
|
||||
data: hexData
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(`Failed to write memory at ${hexFormat(address, 8)}: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Triggers a content refresh. */
|
||||
update(document: vscode.TextDocument): void {
|
||||
this._onDidChange.fire(document.uri);
|
||||
@@ -92,26 +448,39 @@ export class MemoryContentProvider implements vscode.TextDocumentContentProvider
|
||||
|
||||
/** Maps editor position to byte offset. */
|
||||
getOffset(position: vscode.Position): number | undefined {
|
||||
if (position.line < 1 || position.character < this.firstBytePos) {
|
||||
if (position.line < this.headerLines) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (position.character < this.firstBytePos) {
|
||||
return;
|
||||
}
|
||||
|
||||
let offset = 16 * (position.line - 1);
|
||||
const charOffset = position.character - this.firstBytePos;
|
||||
|
||||
if (position.character >= this.firstBytePos && position.character <= this.lastBytePos) {
|
||||
offset += Math.floor(charOffset / 3);
|
||||
} else if (position.character >= this.firstAsciiPos) {
|
||||
offset += position.character - this.firstAsciiPos;
|
||||
if (position.character > this.lastBytePos && position.character < this.firstAsciiPos) {
|
||||
return;
|
||||
}
|
||||
|
||||
return offset;
|
||||
let offset = 16 * Math.max(0, position.line - this.headerLines);
|
||||
|
||||
if (position.character >= this.firstBytePos && position.character <= this.lastBytePos) {
|
||||
const charOffset = position.character - this.firstBytePos;
|
||||
offset += Math.floor(charOffset / 3);
|
||||
return offset;
|
||||
}
|
||||
|
||||
if (position.character >= this.firstAsciiPos && position.character < this.lastAsciiPos) {
|
||||
offset += position.character - this.firstAsciiPos;
|
||||
return offset;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** Maps byte offset to editor position. */
|
||||
getPosition(offset: number, isAscii: boolean = false): vscode.Position {
|
||||
const line = 1 + Math.floor(offset / 16);
|
||||
let character = offset % 16;
|
||||
const normalizedOffset = Math.max(0, offset);
|
||||
const line = this.headerLines + Math.floor(normalizedOffset / 16);
|
||||
let character = normalizedOffset % 16;
|
||||
if (isAscii) {
|
||||
character += this.firstAsciiPos;
|
||||
} else {
|
||||
@@ -139,6 +508,24 @@ export class MemoryContentProvider implements vscode.TextDocumentContentProvider
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/** Applies diff decorations (highlighting changed bytes) to the given editor. */
|
||||
applyDiffDecorations(editor: vscode.TextEditor): void {
|
||||
const uriKey = editor.document.uri.toString();
|
||||
const changedOffsets = this.uriChangedOffsets.get(uriKey);
|
||||
if (!changedOffsets || changedOffsets.size === 0) {
|
||||
editor.setDecorations(this.diffDecorationType, []);
|
||||
return;
|
||||
}
|
||||
const ranges: vscode.Range[] = [];
|
||||
for (const offset of changedOffsets) {
|
||||
ranges.push(...this.getRanges(offset, offset, false));
|
||||
if (this.uriSettings.get(uriKey)?.showAscii ?? this.showAscii) {
|
||||
ranges.push(...this.getRanges(offset, offset, true));
|
||||
}
|
||||
}
|
||||
editor.setDecorations(this.diffDecorationType, ranges);
|
||||
}
|
||||
|
||||
/** Applies decorations for the selected range. */
|
||||
handleSelection(event: vscode.TextEditorSelectionChangeEvent): void {
|
||||
const lineCount = event.textEditor.document.lineCount;
|
||||
|
||||
+377
-14
@@ -1,4 +1,6 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { NumberFormat } from '../common';
|
||||
@@ -52,6 +54,7 @@ export class BaseNode {
|
||||
public format: NumberFormat = NumberFormat.Auto;
|
||||
public description: string;
|
||||
public accessType?: AccessType;
|
||||
private cachedTreeNode?: TreeNode;
|
||||
|
||||
constructor(public recordType: RecordType) {}
|
||||
|
||||
@@ -75,6 +78,31 @@ export class BaseNode {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected getOrCreateTreeNode(
|
||||
label: string,
|
||||
collapsibleState: vscode.TreeItemCollapsibleState,
|
||||
contextValue: string
|
||||
): TreeNode {
|
||||
if (!this.cachedTreeNode) {
|
||||
this.cachedTreeNode = new TreeNode(label, collapsibleState, contextValue, this);
|
||||
}
|
||||
|
||||
this.cachedTreeNode.label = label;
|
||||
this.cachedTreeNode.collapsibleState = collapsibleState;
|
||||
this.cachedTreeNode.contextValue = contextValue;
|
||||
this.cachedTreeNode.node = this;
|
||||
this.cachedTreeNode.command = {
|
||||
command: 'platformio-debug.peripherals.selectedNode',
|
||||
arguments: [this],
|
||||
title: 'Selected Node',
|
||||
};
|
||||
this.cachedTreeNode.tooltip = this.description || label;
|
||||
this.cachedTreeNode.iconPath = undefined;
|
||||
this.cachedTreeNode.description = undefined;
|
||||
|
||||
return this.cachedTreeNode;
|
||||
}
|
||||
|
||||
getCopyValue(): string | null {
|
||||
return null;
|
||||
}
|
||||
@@ -175,11 +203,10 @@ export class PeripheralNode extends BaseNode {
|
||||
|
||||
getTreeNode(): TreeNode {
|
||||
const label = this.name + ' [' + hexFormat(this.baseAddress) + ']';
|
||||
return new TreeNode(
|
||||
return this.getOrCreateTreeNode(
|
||||
label,
|
||||
this.expanded ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed,
|
||||
'peripheral',
|
||||
this
|
||||
'peripheral'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,11 +319,10 @@ export class ClusterNode extends BaseNode {
|
||||
|
||||
getTreeNode(): TreeNode {
|
||||
const label = `${this.name} [${hexFormat(this.offset, 0)}]`;
|
||||
return new TreeNode(
|
||||
return this.getOrCreateTreeNode(
|
||||
label,
|
||||
this.expanded ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed,
|
||||
'cluster',
|
||||
this
|
||||
'cluster'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -367,6 +393,8 @@ export class RegisterNode extends BaseNode {
|
||||
public size: number;
|
||||
public resetValue: bigint;
|
||||
public currentValue: bigint;
|
||||
public previousValue: bigint;
|
||||
public valueChanged: boolean = false;
|
||||
public hexLength: number;
|
||||
public maxValue: bigint;
|
||||
public binaryRegex: RegExp;
|
||||
@@ -382,6 +410,7 @@ export class RegisterNode extends BaseNode {
|
||||
this.size = options.size || parent.size;
|
||||
this.resetValue = options.resetValue !== undefined ? BigInt(options.resetValue) : (parent.resetValue ?? 0n);
|
||||
this.currentValue = this.resetValue;
|
||||
this.previousValue = this.resetValue;
|
||||
this.hexLength = Math.ceil(this.size / 4);
|
||||
this.maxValue = 1n << BigInt(this.size);
|
||||
this.binaryRegex = new RegExp(`^0b[01]{1,${this.size}}$`, 'i');
|
||||
@@ -443,7 +472,28 @@ export class RegisterNode extends BaseNode {
|
||||
: vscode.TreeItemCollapsibleState.Collapsed
|
||||
: vscode.TreeItemCollapsibleState.None;
|
||||
|
||||
return new TreeNode(label, collapsible, contextValue, this);
|
||||
const treeNode = this.getOrCreateTreeNode(label, collapsible, contextValue);
|
||||
|
||||
// Highlight registers whose value differs from the documented reset value.
|
||||
// Recently changed registers get an additional indicator.
|
||||
if (this.currentValue !== this.resetValue) {
|
||||
const color = this.valueChanged
|
||||
? new vscode.ThemeColor('charts.orange')
|
||||
: new vscode.ThemeColor('charts.yellow');
|
||||
treeNode.iconPath = new vscode.ThemeIcon('circle-filled', color);
|
||||
const tooltipParts: string[] = [];
|
||||
if (this.description) {
|
||||
tooltipParts.push(this.description);
|
||||
}
|
||||
tooltipParts.push(`Reset: ${hexFormat(this.resetValue, this.hexLength)}`);
|
||||
tooltipParts.push(`Current: ${hexFormat(this.currentValue, this.hexLength)}`);
|
||||
if (this.valueChanged) {
|
||||
tooltipParts.push(`Previous: ${hexFormat(this.previousValue, this.hexLength)}`);
|
||||
}
|
||||
treeNode.tooltip = tooltipParts.join('\n');
|
||||
}
|
||||
|
||||
return treeNode;
|
||||
}
|
||||
|
||||
getChildren(): BaseNode[] {
|
||||
@@ -549,6 +599,7 @@ export class RegisterNode extends BaseNode {
|
||||
}
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
const prior = this.currentValue;
|
||||
switch (byteCount) {
|
||||
case 1:
|
||||
this.currentValue = BigInt(buffer.readUInt8(0));
|
||||
@@ -568,6 +619,10 @@ export class RegisterNode extends BaseNode {
|
||||
);
|
||||
}
|
||||
|
||||
// Track value changes between successive reads to drive change highlighting.
|
||||
this.valueChanged = this.currentValue !== prior;
|
||||
this.previousValue = prior;
|
||||
|
||||
this.children.forEach((child) => child.update());
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
@@ -693,7 +748,35 @@ export class FieldNode extends BaseNode {
|
||||
contextValue = 'field-ro';
|
||||
}
|
||||
|
||||
return new TreeNode(label, vscode.TreeItemCollapsibleState.None, contextValue, this);
|
||||
const treeNode = this.getOrCreateTreeNode(label, vscode.TreeItemCollapsibleState.None, contextValue);
|
||||
const ACCESS_TYPE_NAMES: { [key: number]: string } = {
|
||||
[AccessType.ReadOnly]: 'Read-Only',
|
||||
[AccessType.ReadWrite]: 'Read-Write',
|
||||
[AccessType.WriteOnly]: 'Write-Only',
|
||||
};
|
||||
|
||||
// Build enhanced tooltip with bit-field documentation
|
||||
const tooltipParts: string[] = [];
|
||||
if (this.description) {
|
||||
tooltipParts.push(this.description);
|
||||
}
|
||||
tooltipParts.push(`Bits [${this.offset + this.width - 1}:${this.offset}], width: ${this.width}`);
|
||||
tooltipParts.push(`Access: ${ACCESS_TYPE_NAMES[this.accessType] ?? this.accessType}`)
|
||||
if (this.enumeration) {
|
||||
tooltipParts.push('Values:');
|
||||
const sortedKeys = Object.keys(this.enumeration).sort((a, b) => {
|
||||
const va = BigInt(a);
|
||||
const vb = BigInt(b);
|
||||
return va < vb ? -1 : va > vb ? 1 : 0;
|
||||
});
|
||||
for (const key of sortedKeys) {
|
||||
const e = this.enumeration[key] as EnumerationValue;
|
||||
tooltipParts.push(` ${e.name} = ${key}: ${e.description}`);
|
||||
}
|
||||
}
|
||||
treeNode.tooltip = tooltipParts.join('\n');
|
||||
|
||||
return treeNode;
|
||||
}
|
||||
|
||||
performUpdate(): Promise<boolean> {
|
||||
@@ -772,6 +855,22 @@ export class PeripheralTreeProvider implements vscode.TreeDataProvider<TreeNode>
|
||||
private viewExpanded: boolean = false;
|
||||
private svdPath: string;
|
||||
private initialSettings: any[];
|
||||
private treeView: vscode.TreeView<TreeNode> | undefined;
|
||||
|
||||
/** Stores a reference to the TreeView so search can reveal entries. */
|
||||
setTreeView(treeView: vscode.TreeView<TreeNode>): void {
|
||||
this.treeView = treeView;
|
||||
}
|
||||
|
||||
/** Returns the currently configured SVD path (if any). */
|
||||
getSVDPath(): string | undefined {
|
||||
return this.svdPath;
|
||||
}
|
||||
|
||||
/** Returns the loaded peripheral nodes (used for testing/search). */
|
||||
getPeripherals(): PeripheralNode[] {
|
||||
return this.peripherials;
|
||||
}
|
||||
|
||||
/** Refreshes the tree view. */
|
||||
refresh(): void {
|
||||
@@ -1098,6 +1197,158 @@ export class PeripheralTreeProvider implements vscode.TreeDataProvider<TreeNode>
|
||||
return peripheral;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves transitive `<peripheral derivedFrom="...">` chains.
|
||||
* Each derived peripheral inherits the base's properties (including
|
||||
* registers/clusters) while keeping its own overrides such as name and
|
||||
* baseAddress.
|
||||
*/
|
||||
private _resolvePeripheralDerivedFrom(peripheralMap: { [name: string]: any }): void {
|
||||
const resolved: { [name: string]: boolean } = {};
|
||||
const resolve = (name: string, visiting: Set<string>): void => {
|
||||
if (resolved[name]) {
|
||||
return;
|
||||
}
|
||||
if (visiting.has(name)) {
|
||||
throw new Error(`Circular derivedFrom reference detected at peripheral ${name}`);
|
||||
}
|
||||
const periph = peripheralMap[name];
|
||||
const baseName: string | undefined = periph?.['@_derivedFrom'];
|
||||
if (!baseName || !peripheralMap[baseName]) {
|
||||
resolved[name] = true;
|
||||
return;
|
||||
}
|
||||
visiting.add(name);
|
||||
resolve(baseName, visiting);
|
||||
visiting.delete(name);
|
||||
peripheralMap[name] = this._mergePeripheralDefinitions(peripheralMap[baseName], periph);
|
||||
// Drop the marker so we don't re-process if called again.
|
||||
delete peripheralMap[name]['@_derivedFrom'];
|
||||
resolved[name] = true;
|
||||
};
|
||||
|
||||
for (const name in peripheralMap) {
|
||||
resolve(name, new Set<string>());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-merges a derived peripheral on top of its base.
|
||||
* Scalar properties are overridden by the derived value.
|
||||
* The `registers` container is merged by name so that sibling registers/clusters
|
||||
* from the base are preserved when the derived peripheral only overrides some.
|
||||
*/
|
||||
private _mergePeripheralDefinitions(base: any, derived: any): any {
|
||||
const result: any = { ...base };
|
||||
for (const key of Object.keys(derived)) {
|
||||
if (key === 'registers' && base[key] != null && derived[key] != null) {
|
||||
result[key] = this._mergeRegistersContainer(base[key], derived[key]);
|
||||
} else {
|
||||
result[key] = derived[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two SVD `<registers>` container objects by combining their
|
||||
* `register` and `cluster` arrays, keyed by name. Derived entries override
|
||||
* base entries with the same name; unique base entries are preserved.
|
||||
*/
|
||||
private _mergeRegistersContainer(base: any, derived: any): any {
|
||||
const merged: any = { ...base };
|
||||
for (const key of ['register', 'cluster'] as const) {
|
||||
if (derived[key] === undefined) {
|
||||
continue;
|
||||
}
|
||||
const baseArr: any[] = ([] as any[]).concat(base[key] ?? []);
|
||||
const derivedArr: any[] = ([] as any[]).concat(derived[key]);
|
||||
const byName = new Map<string, any>(baseArr.map((r) => [r.name, r]));
|
||||
for (const item of derivedArr) {
|
||||
const existing = byName.get(item.name);
|
||||
byName.set(item.name, existing ? { ...existing, ...item } : item);
|
||||
}
|
||||
merged[key] = Array.from(byName.values());
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves register, cluster and field `derivedFrom` references within a
|
||||
* single peripheral. The lookup is by simple name and supports clusters
|
||||
* deriving from clusters and registers deriving from registers.
|
||||
*/
|
||||
private _resolveInnerDerivedFrom(periph: any): void {
|
||||
if (!periph?.registers) {
|
||||
return;
|
||||
}
|
||||
|
||||
// In-place merge that mutates the stored object so that array entries
|
||||
// also see the inherited properties.
|
||||
const merge = (map: { [name: string]: any }, name: string, visiting: Set<string>): void => {
|
||||
const node = map[name];
|
||||
const baseName: string | undefined = node?.['@_derivedFrom'];
|
||||
if (!baseName || !map[baseName]) {
|
||||
return;
|
||||
}
|
||||
if (visiting.has(name)) {
|
||||
throw new Error(`Circular derivedFrom reference detected at ${name}`);
|
||||
}
|
||||
visiting.add(name);
|
||||
merge(map, baseName, visiting);
|
||||
visiting.delete(name);
|
||||
const merged = { ...map[baseName], ...node };
|
||||
delete merged['@_derivedFrom'];
|
||||
// Mutate the existing object so array references stay consistent.
|
||||
for (const key of Object.keys(node)) {
|
||||
delete node[key];
|
||||
}
|
||||
Object.assign(node, merged);
|
||||
};
|
||||
|
||||
const resolveArray = (items: any[]): void => {
|
||||
const map: { [name: string]: any } = {};
|
||||
for (const item of items) {
|
||||
if (item?.name) {
|
||||
map[item.name] = item;
|
||||
}
|
||||
}
|
||||
for (const name in map) {
|
||||
merge(map, name, new Set<string>());
|
||||
}
|
||||
};
|
||||
|
||||
const resolveFields = (registersList: any[]): void => {
|
||||
for (const reg of registersList) {
|
||||
const fields: any[] = reg?.fields?.field;
|
||||
if (Array.isArray(fields)) {
|
||||
resolveArray(fields);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const registers: any[] = Array.isArray(periph.registers.register)
|
||||
? periph.registers.register
|
||||
: [];
|
||||
const clusters: any[] = Array.isArray(periph.registers.cluster)
|
||||
? periph.registers.cluster
|
||||
: [];
|
||||
|
||||
resolveArray(registers);
|
||||
resolveArray(clusters);
|
||||
|
||||
// Resolve nested registers and fields within clusters.
|
||||
for (const cluster of clusters) {
|
||||
if (Array.isArray(cluster.register)) {
|
||||
resolveArray(cluster.register);
|
||||
resolveFields(cluster.register);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve fields inside top-level registers.
|
||||
resolveFields(registers);
|
||||
}
|
||||
|
||||
/** Reads/parses SVD XML file. */
|
||||
_loadSVD(svdPath: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -1108,6 +1359,7 @@ export class PeripheralTreeProvider implements vscode.TreeDataProvider<TreeNode>
|
||||
try {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
parseTagValue: false,
|
||||
isArray: (_name: string, jpath: string) => {
|
||||
const arrayPaths = [
|
||||
'device.peripherals.peripheral',
|
||||
@@ -1145,13 +1397,12 @@ export class PeripheralTreeProvider implements vscode.TreeDataProvider<TreeNode>
|
||||
peripheralMap[name] = periph;
|
||||
});
|
||||
|
||||
// Handle derived peripherals
|
||||
// Handle derived peripherals (including transitive chains)
|
||||
this._resolvePeripheralDerivedFrom(peripheralMap);
|
||||
|
||||
// Resolve register/cluster/field derivedFrom within each peripheral
|
||||
for (const name in peripheralMap) {
|
||||
const periph = peripheralMap[name];
|
||||
if (periph['@_derivedFrom']) {
|
||||
const base = peripheralMap[periph['@_derivedFrom']];
|
||||
peripheralMap[name] = { ...base, ...periph };
|
||||
}
|
||||
this._resolveInnerDerivedFrom(peripheralMap[name]);
|
||||
}
|
||||
|
||||
this.peripherials = [];
|
||||
@@ -1280,6 +1531,118 @@ export class PeripheralTreeProvider implements vscode.TreeDataProvider<TreeNode>
|
||||
this.initialSettings = savedState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches well-known locations for a `.svd` file.
|
||||
* Order:
|
||||
* 1. workspaceRoot/.vscode/*.svd
|
||||
* 2. workspaceRoot/*.svd
|
||||
* 3. ~/.platformio/packages/*\/svd/*.svd (e.g. framework-*, tool-openocd)
|
||||
* If a `deviceName` is provided, candidates whose filename contains the
|
||||
* device name (case-insensitive) are preferred.
|
||||
*/
|
||||
public findSVDFile(deviceName?: string): string | undefined {
|
||||
const candidates: string[] = [];
|
||||
|
||||
const collect = (dir: string): void => {
|
||||
try {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return;
|
||||
}
|
||||
const entries = fs.readdirSync(dir).sort((a, b) => a.localeCompare(b));
|
||||
for (const entry of entries) {
|
||||
if (entry.toLowerCase().endsWith('.svd')) {
|
||||
candidates.push(path.join(dir, entry));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore IO errors during discovery.
|
||||
}
|
||||
};
|
||||
|
||||
const folders = vscode.workspace.workspaceFolders || [];
|
||||
for (const folder of folders) {
|
||||
const root = folder.uri.fsPath;
|
||||
collect(path.join(root, '.vscode'));
|
||||
collect(root);
|
||||
}
|
||||
|
||||
const pioPackages = path.join(os.homedir(), '.platformio', 'packages');
|
||||
try {
|
||||
if (fs.existsSync(pioPackages)) {
|
||||
const pkgs = fs.readdirSync(pioPackages).sort((a, b) => a.localeCompare(b));
|
||||
for (const pkg of pkgs) {
|
||||
collect(path.join(pioPackages, pkg, 'svd'));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore IO errors during discovery.
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (deviceName) {
|
||||
const lower = deviceName.toLowerCase();
|
||||
const match = candidates.find((c) =>
|
||||
path.basename(c).toLowerCase().includes(lower)
|
||||
);
|
||||
// When a device name is given but no filename matches, return undefined so
|
||||
// callers can detect the missing device-specific SVD rather than silently
|
||||
// loading the wrong file.
|
||||
return match;
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a QuickPick to filter peripherals by name/address/description and
|
||||
* reveals the chosen peripheral in the tree view.
|
||||
*/
|
||||
public async search(): Promise<void> {
|
||||
if (this.peripherials.length === 0) {
|
||||
vscode.window.showInformationMessage('No peripherals are currently loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
const items: vscode.QuickPickItem[] = this.peripherials.map((p) => ({
|
||||
label: p.name,
|
||||
description: hexFormat(p.baseAddress),
|
||||
detail: p.description,
|
||||
}));
|
||||
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: 'Search peripherals by name, address, or description',
|
||||
matchOnDescription: true,
|
||||
matchOnDetail: true,
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const peripheral = this.peripherials.find((p) => p.name === selected.label);
|
||||
if (!peripheral) {
|
||||
return;
|
||||
}
|
||||
|
||||
const treeNode = peripheral.getTreeNode();
|
||||
peripheral.expanded = true;
|
||||
this.refresh();
|
||||
if (this.treeView) {
|
||||
try {
|
||||
await this.treeView.reveal(treeNode, {
|
||||
select: true,
|
||||
focus: true,
|
||||
expand: true,
|
||||
});
|
||||
} catch {
|
||||
// reveal can fail if the node was just rebuilt; ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Updates the SVD path and reloads the peripheral tree. */
|
||||
reloadSVD(svdPath: string): void {
|
||||
this.peripherials = [];
|
||||
|
||||
+12
-11
@@ -86,21 +86,22 @@ export function extractBitsBigInt(value: bigint, offset: number, width: number):
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a string as a bigint, supporting hex (0x), binary (0b), decimal, and hash-binary (#) prefixes.
|
||||
* Parses a value as a bigint, supporting hex (0x), binary (0b), decimal, and hash-binary (#) prefixes.
|
||||
* Accepts strings, numbers, or bigints; other inputs are coerced via String().
|
||||
*/
|
||||
export function parseBigInt(value: string): bigint | undefined {
|
||||
value = value.trim();
|
||||
if (/^0b([01]+)$/i.test(value)) {
|
||||
return BigInt('0b' + value.substring(2));
|
||||
export function parseBigInt(value: unknown): bigint | undefined {
|
||||
const str = String(value).trim();
|
||||
if (/^0b([01]+)$/i.test(str)) {
|
||||
return BigInt('0b' + str.substring(2));
|
||||
}
|
||||
if (/^0x([0-9a-f]+)$/i.test(value)) {
|
||||
return BigInt('0x' + value.substring(2));
|
||||
if (/^0x([0-9a-f]+)$/i.test(str)) {
|
||||
return BigInt('0x' + str.substring(2));
|
||||
}
|
||||
if (/^[0-9]+$/i.test(value)) {
|
||||
return BigInt(value);
|
||||
if (/^[0-9]+$/i.test(str)) {
|
||||
return BigInt(str);
|
||||
}
|
||||
if (/^#[0-1]+$/i.test(value)) {
|
||||
return BigInt('0b' + value.substring(1));
|
||||
if (/^#[0-1]+$/i.test(str)) {
|
||||
return BigInt('0b' + str.substring(1));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,5 +10,5 @@
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "dist_org", ".vscode-test", "__tests__"]
|
||||
"exclude": ["node_modules", "dist", "dist_org", ".vscode-test", "__tests__", "__mocks__", "coverage"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user