mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-06-08 07:05:17 +08:00
Merge pull request #229 from AiMiDi/codex/unrealinsights-agent-harness
feat: add Unreal Insights CLI harness
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -98,6 +98,7 @@
|
||||
!/QGIS/
|
||||
!/n8n/
|
||||
!/obsidian/
|
||||
!/unrealinsights/
|
||||
|
||||
# Step 5: Inside each software dir, ignore everything (including dotfiles)
|
||||
/gimp/*
|
||||
@@ -180,6 +181,8 @@
|
||||
/n8n/.*
|
||||
/obsidian/*
|
||||
/obsidian/.*
|
||||
/unrealinsights/*
|
||||
/unrealinsights/.*
|
||||
|
||||
# Step 6: ...except agent-harness/
|
||||
!/gimp/agent-harness/
|
||||
@@ -228,6 +231,7 @@
|
||||
!/obsidian/agent-harness/
|
||||
!/safari/
|
||||
!/safari/agent-harness/
|
||||
!/unrealinsights/agent-harness/
|
||||
|
||||
# Step 7: Ignore build artifacts within allowed dirs
|
||||
**/__pycache__/
|
||||
|
||||
23
README.md
23
README.md
@@ -15,7 +15,7 @@ CLI-Anything: Bridging the Gap Between AI Agents and the World's Software</stron
|
||||
<a href="#-quick-start"><img src="https://img.shields.io/badge/Quick_Start-5_min-blue?style=for-the-badge" alt="Quick Start"></a>
|
||||
<a href="https://hkuds.github.io/CLI-Anything/"><img src="https://img.shields.io/badge/CLI_Hub-Browse_%26_Install-ff69b4?style=for-the-badge" alt="CLI Hub"></a>
|
||||
<a href="#-demonstrations"><img src="https://img.shields.io/badge/Demos-16_Apps-green?style=for-the-badge" alt="Demos"></a>
|
||||
<a href="#-test-results"><img src="https://img.shields.io/badge/Tests-2%2C130_Passing-brightgreen?style=for-the-badge" alt="Tests"></a>
|
||||
<a href="#-test-results"><img src="https://img.shields.io/badge/Tests-2%2C202_Passing-brightgreen?style=for-the-badge" alt="Tests"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge" alt="License"></a>
|
||||
</p>
|
||||
|
||||
@@ -51,6 +51,8 @@ CLI-Anything: Bridging the Gap Between AI Agents and the World's Software</stron
|
||||
|
||||
- **2026-04-16** 🗺️ **QGIS CLI** merged (#207) — a full GIS / map authoring harness landed. 🧬 **UniMol Tools CLI** merged (#219) for molecular modeling workflows. 🌐 **CLI-Hub** also added more public CLIs, including **py4csr**, refreshed its generated meta-skill, corrected SKILL contribution docs, and fixed `apt-get` package extraction in skill generation (#204).
|
||||
|
||||
- **2026-04-16** 📈 **Unreal Insights CLI** expanded — added background capture session control (`capture start/status/snapshot/stop`), engine-root-matched `UnrealInsights.exe` resolution/build flows, and refreshed docs/tests for the new orchestration workflow.
|
||||
|
||||
- **2026-04-15** 🌐 **CLI-Hub** updated to **v0.2.0** — the PyPI package now supports public CLIs from multiple install sources (`pip`, `npm`, `brew`, bundled/system tools), backed by a new `public_registry.json`. The Hub frontend was redesigned with separate **CLI-Anything CLIs** and **Public CLIs** decks, and live end-to-end checks now cover real install, update, and uninstall flows across both pip and npm packages.
|
||||
|
||||
- **2026-04-14** 🧭 **Safari CLI** merged (#212) and added to the Hub registry — browser automation via `safari-mcp`. 🎬 **Kdenlive** also received compatibility fixes for Gen 5 project output and invalid project generation.
|
||||
@@ -594,7 +596,7 @@ AI agents are great at reasoning but terrible at using real professional softwar
|
||||
| 💸 "UI automation breaks constantly" | No screenshots, no clicking, no RPA fragility. Pure command-line reliability with structured interfaces |
|
||||
| 📊 "Agents need structured data" | Built-in JSON output for seamless agent consumption + human-readable formats for debugging |
|
||||
| 🔧 "Custom integrations are expensive" | One Claude plugin auto-generates CLIs for ANY codebase through proven 7-phase pipeline |
|
||||
| ⚡ "Prototype vs Production gap" | 1,839+ tests with real software validation. Battle-tested across 16 major applications |
|
||||
| ⚡ "Prototype vs Production gap" | 2,202+ tests with real software validation. Battle-tested across 16 major applications |
|
||||
|
||||
---
|
||||
|
||||
@@ -973,6 +975,13 @@ Each application received complete, production-ready CLI interfaces — not demo
|
||||
<td align="center">✅ 40</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>📈 <a href="unrealinsights/agent-harness/">Unreal Insights</a></strong></td>
|
||||
<td>Performance Profiling</td>
|
||||
<td><code>cli-anything-unrealinsights</code></td>
|
||||
<td>Background trace sessions + engine-matched UnrealInsights build + headless export</td>
|
||||
<td align="center">✅ 50</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>☁️ <a href="cloudanalyzer/agent-harness/">CloudAnalyzer</a></strong></td>
|
||||
<td>Point cloud / trajectory QA</td>
|
||||
<td><code>cli-anything-cloudanalyzer</code></td>
|
||||
@@ -988,11 +997,11 @@ Each application received complete, production-ready CLI interfaces — not demo
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="4"><strong>Total</strong></td>
|
||||
<td align="center"><strong>✅ 2,152</strong></td>
|
||||
<td align="center"><strong>✅ 2,202</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> **100% pass rate** across all 2,152 tests — 1,564 unit tests + 569 end-to-end tests + 19 Node.js tests.
|
||||
> **100% pass rate** across all 2,202 tests — 1,613 unit tests + 570 end-to-end tests + 19 Node.js tests.
|
||||
|
||||
---
|
||||
|
||||
@@ -1031,9 +1040,10 @@ sketch 19 passed ✅ (19 jest, Node.js)
|
||||
renderdoc 59 passed ✅ (45 unit + 14 e2e)
|
||||
cloudcompare 88 passed ✅ (49 unit + 39 e2e)
|
||||
openscreen 101 passed ✅ (78 unit + 23 e2e)
|
||||
unrealinsights 50 passed ✅ (49 unit + 1 e2e, 9 backend-gated e2e skipped)
|
||||
cloudanalyzer 14 passed ✅ (7 unit + 7 e2e)
|
||||
──────────────────────────────────────────────────────────────────────────────
|
||||
TOTAL 2,120 passed ✅ 100% pass rate
|
||||
TOTAL 2,202 passed ✅ 100% pass rate
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1107,6 +1117,7 @@ cli-anything/
|
||||
├── 🎮 godot/agent-harness/ # Godot Engine CLI (24 tests)
|
||||
├── 🎨 sketch/agent-harness/ # Sketch CLI (19 tests, Node.js)
|
||||
├── 🔬 renderdoc/agent-harness/ # RenderDoc CLI (59 tests)
|
||||
├── 📈 unrealinsights/agent-harness/ # Unreal Insights CLI (50 tests)
|
||||
├── 🎬 videocaptioner/agent-harness/ # VideoCaptioner CLI (26 tests)
|
||||
├── 🎬 openscreen/agent-harness/ # Openscreen CLI — screen recording editor (101 tests)
|
||||
├── ☁️ cloudcompare/agent-harness/ # CloudCompare CLI (88 tests)
|
||||
@@ -1337,7 +1348,7 @@ MIT License — free to use, modify, and distribute.
|
||||
|
||||
**CLI-Anything** — *Make any software with a codebase Agent-native.*
|
||||
|
||||
<sub>A methodology for the age of AI agents | 16 professional software demos | 1,839 passing tests</sub>
|
||||
<sub>A methodology for the age of AI agents | 16 professional software demos | 2,202 passing tests</sub>
|
||||
|
||||
<br>
|
||||
|
||||
|
||||
15
README_CN.md
15
README_CN.md
@@ -581,6 +581,13 @@ CLI-Anything 适用于任何有代码库的软件 —— 不限领域,不限
|
||||
<td align="center">✅ 50</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>📈 <a href="unrealinsights/agent-harness/">Unreal Insights</a></strong></td>
|
||||
<td>性能分析</td>
|
||||
<td><code>cli-anything-unrealinsights</code></td>
|
||||
<td>后台 trace 会话 + 匹配版 UnrealInsights 构建 + 无头导出</td>
|
||||
<td align="center">✅ 50</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>🎨 Sketch</strong></td>
|
||||
<td>UI 设计</td>
|
||||
<td><code>sketch-cli</code></td>
|
||||
@@ -589,11 +596,11 @@ CLI-Anything 适用于任何有代码库的软件 —— 不限领域,不限
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="4"><strong>合计</strong></td>
|
||||
<td align="center"><strong>✅ 1,527</strong></td>
|
||||
<td align="center"><strong>✅ 1,573</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> 全部 1,628 项测试 **100% 通过** —— 1,151 项单元测试 + 458 项端到端测试 + 19 项 Node.js 测试。
|
||||
> 全部 1,674 项测试 **100% 通过** —— 1,197 项单元测试 + 458 项端到端测试 + 19 项 Node.js 测试。
|
||||
|
||||
---
|
||||
|
||||
@@ -622,9 +629,10 @@ openscreen 101 passed ✅ (78 unit + 23 e2e)
|
||||
zoom 22 passed ✅ (22 unit + 0 e2e)
|
||||
drawio 138 passed ✅ (116 unit + 22 e2e)
|
||||
anygen 50 passed ✅ (40 unit + 10 e2e)
|
||||
unrealinsights 50 passed ✅ (49 unit + 1 e2e, 9 backend-gated e2e skipped)
|
||||
sketch 19 passed ✅ (19 jest, Node.js)
|
||||
──────────────────────────────────────────────────────────────────────────────
|
||||
TOTAL 1,628 passed ✅ 100% pass rate
|
||||
TOTAL 1,674 passed ✅ 100% pass rate
|
||||
```
|
||||
|
||||
---
|
||||
@@ -691,6 +699,7 @@ cli-anything/
|
||||
├── 📞 zoom/agent-harness/ # Zoom CLI(22 项测试)
|
||||
├── 📐 drawio/agent-harness/ # Draw.io CLI(138 项测试)
|
||||
├── ✨ anygen/agent-harness/ # AnyGen CLI(50 项测试)
|
||||
├── 📈 unrealinsights/agent-harness/ # Unreal Insights CLI(50 项测试)
|
||||
└── 🎨 sketch/agent-harness/ # Sketch CLI(19 项测试,Node.js)
|
||||
```
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"meta": {
|
||||
"repo": "https://github.com/HKUDS/CLI-Anything",
|
||||
"description": "CLI-Hub — Agent-native stateful CLI interfaces for softwares, codebases, and Web Services",
|
||||
"updated": "2026-03-31"
|
||||
"updated": "2026-04-16"
|
||||
},
|
||||
"clis": [
|
||||
{
|
||||
@@ -669,6 +669,25 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "unrealinsights",
|
||||
"display_name": "Unreal Insights",
|
||||
"version": "0.1.0",
|
||||
"description": "Windows-first Unreal trace capture, background session control, engine-matched UnrealInsights builds, and headless Timing Insights export workflows",
|
||||
"requires": "Windows + Unreal Engine source or installed build with UnrealEditor and Unreal trace support",
|
||||
"homepage": "https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-insights-in-unreal-engine",
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=unrealinsights/agent-harness",
|
||||
"entry_point": "cli-anything-unrealinsights",
|
||||
"skill_md": "unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md",
|
||||
"category": "debugging",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "AiMiDi",
|
||||
"url": "https://github.com/AiMiDi"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "videocaptioner",
|
||||
"display_name": "VideoCaptioner",
|
||||
|
||||
131
skills/cli-anything-unrealinsights/SKILL.md
Normal file
131
skills/cli-anything-unrealinsights/SKILL.md
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
name: "cli-anything-unrealinsights"
|
||||
description: "Capture Unreal Engine traces to .utrace files and export Unreal Insights timing/counter data in headless mode."
|
||||
---
|
||||
|
||||
# cli-anything-unrealinsights
|
||||
|
||||
Use this CLI when you need agent-friendly access to Unreal Insights trace capture
|
||||
and exporter workflows on Windows.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Unreal Engine 5.5+ installed with `UnrealInsights.exe`
|
||||
- Windows
|
||||
- Optional explicit env vars:
|
||||
- `UNREALINSIGHTS_EXE`
|
||||
- `UNREAL_TRACE_SERVER_EXE`
|
||||
- `UNREALINSIGHTS_TRACE`
|
||||
|
||||
## Core Commands
|
||||
|
||||
### Backend discovery
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json backend info
|
||||
```
|
||||
|
||||
To use a source-built engine's matching `UnrealInsights.exe`:
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json backend ensure-insights `
|
||||
--engine-root 'D:\code\D5\d5render-ue5_3'
|
||||
```
|
||||
|
||||
This first looks for `Engine\Binaries\Win64\UnrealInsights.exe` under the
|
||||
specified engine root, then builds it with that engine's `Build.bat` if needed.
|
||||
|
||||
### Trace session state
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights trace set D:\captures\session.utrace
|
||||
cli-anything-unrealinsights --json trace info
|
||||
```
|
||||
|
||||
### Capture orchestration
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json capture run `
|
||||
--project 'D:\Projects\MyGame\MyGame.uproject' `
|
||||
--engine-root 'D:\Program Files\Epic Games\UE_5.5' `
|
||||
--output-trace D:\captures\boot.utrace `
|
||||
--channels "default,bookmark" `
|
||||
--exec-cmd "Trace.Bookmark BootStart" `
|
||||
--wait --timeout 300
|
||||
```
|
||||
|
||||
You can also keep using the explicit form:
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json capture run `
|
||||
'D:\Program Files\Epic Games\UE_5.5\Engine\Binaries\Win64\UnrealEditor.exe' `
|
||||
--target-arg 'D:\Projects\MyGame\MyGame.uproject'
|
||||
```
|
||||
|
||||
### Continuous capture session control
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json capture start `
|
||||
--project 'D:\Projects\MyGame\MyGame.uproject' `
|
||||
--engine-root 'D:\Program Files\Epic Games\UE_5.5' `
|
||||
--output-trace D:\captures\live_session.utrace
|
||||
|
||||
cli-anything-unrealinsights --json capture status
|
||||
cli-anything-unrealinsights --json capture snapshot D:\captures\live_snapshot.utrace
|
||||
cli-anything-unrealinsights --json capture stop
|
||||
```
|
||||
|
||||
This is the preferred flow when an agent needs to start profiling now and stop
|
||||
or snapshot later in a follow-up turn.
|
||||
|
||||
If a tracked capture session is still running, `capture start` now requires
|
||||
`--replace` so the previous process is stopped before a new one is launched.
|
||||
|
||||
### Offline exporters
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json -t D:\captures\session.utrace export threads D:\out\threads.csv
|
||||
cli-anything-unrealinsights --json -t D:\captures\session.utrace export timer-stats D:\out\stats.csv --region "EXPORT_CAPTURE"
|
||||
cli-anything-unrealinsights --json -t D:\captures\session.utrace export counter-values D:\out\counter_values.csv --counter "*"
|
||||
```
|
||||
|
||||
### Batch response files
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json -t D:\captures\session.utrace batch run-rsp D:\out\exports.rsp
|
||||
```
|
||||
|
||||
## JSON Output Guidance
|
||||
|
||||
- Prefer `--json` for agent workflows.
|
||||
- Export commands return:
|
||||
- `trace_path`
|
||||
- `exec_command`
|
||||
- `output_files`
|
||||
- `log_path`
|
||||
- `exit_code`
|
||||
- `warnings`
|
||||
- `errors`
|
||||
- `succeeded`
|
||||
- Capture returns:
|
||||
- `command`
|
||||
- `trace_path`
|
||||
- `trace_exists`
|
||||
- `trace_size`
|
||||
- `pid` or `exit_code`
|
||||
- Continuous capture status returns:
|
||||
- `pid`
|
||||
- `running`
|
||||
- `target_exe`
|
||||
- `project_path`
|
||||
- `trace_path`
|
||||
- `trace_size`
|
||||
- `started_at`
|
||||
|
||||
## Notes
|
||||
|
||||
- v1 is Windows-first.
|
||||
- v1 supports file-mode capture orchestration only.
|
||||
- v1 does not control already-running UE instances or browse trace stores.
|
||||
- `capture stop` is a best-effort stop of the harness-launched process tree.
|
||||
- `capture snapshot` is a best-effort filesystem snapshot of the active trace.
|
||||
72
unrealinsights/agent-harness/UNREALINSIGHTS.md
Normal file
72
unrealinsights/agent-harness/UNREALINSIGHTS.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# UNREALINSIGHTS.md - Software-Specific SOP
|
||||
|
||||
## About Unreal Insights
|
||||
|
||||
Unreal Insights is Epic's trace analysis tool for Unreal Engine performance,
|
||||
profiling, timing, and counter data stored in `.utrace` files.
|
||||
|
||||
This harness follows the CLI-Anything rule of using the real backend:
|
||||
|
||||
- `UnrealInsights.exe` for headless analysis and CSV/TXT export
|
||||
- a traced Unreal Engine target executable for capture generation
|
||||
|
||||
## Backend Model
|
||||
|
||||
### Analysis backend
|
||||
|
||||
`UnrealInsights.exe` accepts:
|
||||
|
||||
- `-OpenTraceFile=<path>`
|
||||
- `-NoUI`
|
||||
- `-AutoQuit`
|
||||
- `-ABSLOG=<path>`
|
||||
- `-ExecOnAnalysisCompleteCmd=<command>`
|
||||
|
||||
The command may be:
|
||||
|
||||
- a direct exporter command such as `TimingInsights.ExportThreads`
|
||||
- `@=<response-file>` for batch execution
|
||||
|
||||
This harness can also ensure an engine-matched analysis backend for custom
|
||||
source engines by locating or building `Engine/Binaries/Win64/UnrealInsights.exe`.
|
||||
|
||||
### Capture backend
|
||||
|
||||
UE targets can be launched with:
|
||||
|
||||
- `-trace=<channels>`
|
||||
- `-tracefile=<path>`
|
||||
- optional `-ExecCmds=<cmd1,cmd2,...>`
|
||||
|
||||
This harness supports two v1 launch shapes:
|
||||
|
||||
- explicit target executable path
|
||||
- `--project + --engine-root` convenience mode, which resolves `UnrealEditor.exe`
|
||||
|
||||
This harness only supports file-mode capture orchestration in v1.
|
||||
|
||||
## CLI Coverage Map
|
||||
|
||||
| Feature | CLI Command | Status |
|
||||
|--------|-------------|--------|
|
||||
| Resolve Insights binaries | `backend info` | v1 |
|
||||
| Set current trace | `trace set` | v1 |
|
||||
| Inspect current trace | `trace info` | v1 |
|
||||
| Launch traced target | `capture run` | v1 |
|
||||
| Export threads | `export threads` | v1 |
|
||||
| Export timers | `export timers` | v1 |
|
||||
| Export timing events | `export timing-events` | v1 |
|
||||
| Export timer statistics | `export timer-stats` | v1 |
|
||||
| Export timer callees | `export timer-callees` | v1 |
|
||||
| Export counter list | `export counters` | v1 |
|
||||
| Export counter values | `export counter-values` | v1 |
|
||||
| Batch response file | `batch run-rsp` | v1 |
|
||||
| Control live instances | — | future |
|
||||
| Trace store browsing | — | future |
|
||||
|
||||
## Current Limitations
|
||||
|
||||
- Windows-first discovery only
|
||||
- No SessionServices control of already-running UE instances
|
||||
- No trace store session enumeration
|
||||
- Capture orchestration assumes the target executable accepts standard UE trace flags
|
||||
@@ -0,0 +1,256 @@
|
||||
# cli-anything-unrealinsights
|
||||
|
||||
Command-line interface for Unreal Insights trace capture and export workflows.
|
||||
|
||||
This harness wraps the real Unreal Engine tools:
|
||||
|
||||
- `UnrealInsights.exe` for headless `.utrace` analysis and exporters
|
||||
- a traced UE/Game executable for file-mode capture generation
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd unrealinsights/agent-harness
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Windows
|
||||
- Unreal Engine 5.5+ installed with `UnrealInsights.exe`
|
||||
- optional `UnrealTraceServer.exe` for backend reporting
|
||||
|
||||
You can point the harness at explicit binaries:
|
||||
|
||||
```powershell
|
||||
$env:UNREALINSIGHTS_EXE='D:\Program Files\Epic Games\UE_5.5\Engine\Binaries\Win64\UnrealInsights.exe'
|
||||
$env:UNREAL_TRACE_SERVER_EXE='D:\Program Files\Epic Games\UE_5.5\Engine\Binaries\Win64\UnrealTraceServer.exe'
|
||||
```
|
||||
|
||||
If those are not set, the harness auto-discovers common UE installs under
|
||||
`<drive>:\Program Files\Epic Games\UE_*`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```powershell
|
||||
# Inspect resolved backend binaries
|
||||
cli-anything-unrealinsights --json backend info
|
||||
|
||||
# Find or build UnrealInsights.exe for a custom engine root
|
||||
cli-anything-unrealinsights --json backend ensure-insights `
|
||||
--engine-root 'D:\code\D5\d5render-ue5_3'
|
||||
|
||||
# Bind a trace file for the current session
|
||||
cli-anything-unrealinsights trace set D:\captures\session.utrace
|
||||
|
||||
# Start a background capture session and keep it running
|
||||
cli-anything-unrealinsights --json capture start `
|
||||
--project 'D:\Projects\MyGame\MyGame.uproject' `
|
||||
--engine-root 'D:\Program Files\Epic Games\UE_5.5' `
|
||||
--output-trace D:\captures\live_session.utrace
|
||||
|
||||
# If a tracked capture is already running, replace it explicitly
|
||||
cli-anything-unrealinsights --json capture start --replace `
|
||||
--project 'D:\Projects\MyGame\MyGame.uproject' `
|
||||
--engine-root 'D:\Program Files\Epic Games\UE_5.5' `
|
||||
--output-trace D:\captures\replacement_session.utrace
|
||||
|
||||
# Check current capture status
|
||||
cli-anything-unrealinsights --json capture status
|
||||
|
||||
# Create a best-effort snapshot without ending the session
|
||||
cli-anything-unrealinsights --json capture snapshot D:\captures\live_snapshot.utrace
|
||||
|
||||
# Stop the tracked capture session
|
||||
cli-anything-unrealinsights --json capture stop
|
||||
|
||||
# Export threads
|
||||
cli-anything-unrealinsights --json -t D:\captures\session.utrace export threads D:\out\threads.csv
|
||||
|
||||
# Export timer statistics for a region
|
||||
cli-anything-unrealinsights --json -t D:\captures\session.utrace export timer-stats `
|
||||
D:\out\timer_stats.csv --threads "GameThread" --timers "*" --region "EXPORT_CAPTURE"
|
||||
|
||||
# Execute a response file
|
||||
cli-anything-unrealinsights --json -t D:\captures\session.utrace batch run-rsp D:\out\export.rsp
|
||||
|
||||
# Launch a traced UE target and wait for completion
|
||||
cli-anything-unrealinsights --json capture run `
|
||||
--project 'D:\Projects\MyGame\MyGame.uproject' `
|
||||
--engine-root 'D:\Program Files\Epic Games\UE_5.5' `
|
||||
--output-trace D:\captures\editor_boot.utrace `
|
||||
--channels "default,bookmark" `
|
||||
--exec-cmd "Trace.Bookmark BootStart" `
|
||||
--wait --timeout 300
|
||||
|
||||
# Start REPL (default behavior)
|
||||
cli-anything-unrealinsights
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
- `backend`
|
||||
- `info`
|
||||
- `ensure-insights`
|
||||
- `trace`
|
||||
- `set`
|
||||
- `info`
|
||||
- `capture`
|
||||
- `run`
|
||||
- `start`
|
||||
- `status`
|
||||
- `stop`
|
||||
- `snapshot`
|
||||
- `export`
|
||||
- `threads`
|
||||
- `timers`
|
||||
- `timing-events`
|
||||
- `timer-stats`
|
||||
- `timer-callees`
|
||||
- `counters`
|
||||
- `counter-values`
|
||||
- `batch`
|
||||
- `run-rsp`
|
||||
- `repl`
|
||||
|
||||
## Global Options
|
||||
|
||||
- `--json`: machine-readable output
|
||||
- `--debug`: include traceback details in errors
|
||||
- `--trace/-t`: current `.utrace` file
|
||||
- `--insights-exe`: explicit `UnrealInsights.exe` path
|
||||
- `--trace-server-exe`: explicit `UnrealTraceServer.exe` path
|
||||
|
||||
## Engine-Matched Insights
|
||||
|
||||
If you need an `UnrealInsights.exe` matching a custom source engine, use:
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json backend ensure-insights `
|
||||
--engine-root 'D:\code\D5\d5render-ue5_3'
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- looks for `Engine\Binaries\Win64\UnrealInsights.exe` under the given engine root
|
||||
- if missing, runs that engine's `Engine\Build\BatchFiles\Build.bat UnrealInsights Win64 Development -WaitMutex`
|
||||
- returns the resolved path plus the build log path when a build was attempted
|
||||
|
||||
## Capture Convenience Layer
|
||||
|
||||
`capture run` supports two launch styles:
|
||||
|
||||
```powershell
|
||||
# 1. Convenience mode: infer UnrealEditor.exe from engine root
|
||||
cli-anything-unrealinsights capture run `
|
||||
--project 'D:\Projects\MyGame\MyGame.uproject' `
|
||||
--engine-root 'D:\Program Files\Epic Games\UE_5.5'
|
||||
|
||||
# 2. Explicit mode: provide the exact executable yourself
|
||||
cli-anything-unrealinsights capture run `
|
||||
'D:\Program Files\Epic Games\UE_5.5\Engine\Binaries\Win64\UnrealEditor.exe' `
|
||||
--target-arg 'D:\Projects\MyGame\MyGame.uproject'
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `--project` prepends the `.uproject` path to the target command line.
|
||||
- `--engine-root` accepts either the UE install root or its `Engine` subdirectory.
|
||||
- If `target_exe` is omitted, `capture run` resolves `UnrealEditor.exe` from `--engine-root`.
|
||||
- The original explicit `target_exe` path remains supported.
|
||||
|
||||
## Continuous AI-Driven Capture
|
||||
|
||||
For longer-running sessions, prefer this loop:
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json capture start --project ... --engine-root ...
|
||||
cli-anything-unrealinsights --json capture status
|
||||
cli-anything-unrealinsights --json capture snapshot
|
||||
cli-anything-unrealinsights --json capture stop
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- `capture start` launches the target in the background and persists the tracked PID/trace/session metadata
|
||||
- `capture start` refuses to overwrite a still-running tracked session unless you pass `--replace`
|
||||
- `capture status` reads the persisted session state and reports whether the process is still running and how large the trace has grown
|
||||
- `capture snapshot` creates a best-effort copy of the current `.utrace` without requiring you to end the session first
|
||||
- `capture stop` performs a best-effort stop of the tracked process tree launched by the harness
|
||||
|
||||
## Human + AI Workflow
|
||||
|
||||
When a human is directing an AI agent, the best requests usually specify:
|
||||
|
||||
1. engine root
|
||||
2. project path or target executable
|
||||
3. whether the focus is startup or runtime behavior
|
||||
4. the artifact or summary you want back
|
||||
|
||||
Example prompts:
|
||||
|
||||
```text
|
||||
Use D:\code\D5\d5render-ue5_3 to analyze startup performance for
|
||||
D:\code\D5\FusionEffectBuild.
|
||||
First ensure a matching UnrealInsights.exe exists, then capture a startup trace,
|
||||
export timer-stats, and summarize the top 20 hotspots.
|
||||
```
|
||||
|
||||
```text
|
||||
Use D:\code\D5\d5render-ue5_3 and D:\code\D5\FusionEffectBuild
|
||||
to start a background performance capture.
|
||||
Do not block and do not exit the project immediately.
|
||||
Tell me the trace path and current status first.
|
||||
When I say "stop", stop the capture, make a snapshot, export timer-stats and
|
||||
timing-events, then summarize the results.
|
||||
```
|
||||
|
||||
```text
|
||||
Start a background trace for this project, let me interact with the scene
|
||||
manually, and wait.
|
||||
When I say "stop now", export timer-stats and timing-events and focus on
|
||||
GameThread, RenderThread, and task-system waits.
|
||||
```
|
||||
|
||||
Useful phrases in prompts:
|
||||
|
||||
- `ensure matching UnrealInsights`
|
||||
- `background continuous capture`
|
||||
- `wait until I say stop`
|
||||
- `make a snapshot`
|
||||
- `export timer-stats`
|
||||
- `export timing-events`
|
||||
- `summarize top hotspots`
|
||||
- `look at GameThread / RenderThread / WaitForTasks`
|
||||
|
||||
## Export Filters
|
||||
|
||||
`timing-events` and `timer-stats` support:
|
||||
|
||||
- `--columns`
|
||||
- `--threads`
|
||||
- `--timers`
|
||||
- `--start-time`
|
||||
- `--end-time`
|
||||
- `--region`
|
||||
|
||||
`counter-values` supports:
|
||||
|
||||
- `--counter`
|
||||
- `--columns`
|
||||
- `--start-time`
|
||||
- `--end-time`
|
||||
- `--region`
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cd unrealinsights/agent-harness
|
||||
pytest cli_anything/unrealinsights/tests/test_core.py -v
|
||||
pytest cli_anything/unrealinsights/tests/test_full_e2e.py -v -s
|
||||
```
|
||||
|
||||
Optional environment variables for E2E coverage:
|
||||
|
||||
- `UNREALINSIGHTS_TEST_TRACE`
|
||||
- `UNREALINSIGHTS_TEST_TARGET_EXE`
|
||||
@@ -0,0 +1,5 @@
|
||||
"""cli-anything Unreal Insights harness."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Module entry point for cli_anything.unrealinsights."""
|
||||
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""Core helpers for the Unreal Insights harness."""
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
Capture orchestration helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
from cli_anything.unrealinsights.utils import unrealinsights_backend as backend
|
||||
|
||||
DEFAULT_CHANNELS = "default"
|
||||
EDITOR_BINARY_NAME = "UnrealEditor.exe"
|
||||
|
||||
|
||||
def normalize_trace_output_path(
|
||||
target_exe: str,
|
||||
output_trace: str | None = None,
|
||||
current_trace: str | None = None,
|
||||
cwd: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the output trace path for a capture workflow."""
|
||||
if output_trace:
|
||||
path = Path(output_trace).expanduser()
|
||||
elif current_trace:
|
||||
path = Path(current_trace).expanduser()
|
||||
else:
|
||||
base_dir = Path(cwd or os.getcwd()).resolve()
|
||||
timestamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
path = base_dir / f"{Path(target_exe).stem}-{timestamp}.utrace"
|
||||
|
||||
if not path.suffix:
|
||||
path = path.with_suffix(".utrace")
|
||||
return str(path.resolve())
|
||||
|
||||
|
||||
def build_exec_cmds_arg(exec_cmds: Sequence[str] | None) -> str | None:
|
||||
if not exec_cmds:
|
||||
return None
|
||||
commands = [cmd.strip() for cmd in exec_cmds if cmd.strip()]
|
||||
return ",".join(commands) if commands else None
|
||||
|
||||
|
||||
def resolve_engine_root(engine_root: str) -> str:
|
||||
"""Normalize an Unreal Engine installation root."""
|
||||
path = Path(engine_root).expanduser().resolve()
|
||||
root = path.parent if path.name.lower() == "engine" else path
|
||||
|
||||
if not root.exists():
|
||||
raise RuntimeError(f"Engine root not found: {root}")
|
||||
if not (root / "Engine").is_dir():
|
||||
raise RuntimeError(f"Engine root must contain an Engine directory: {root}")
|
||||
|
||||
return str(root)
|
||||
|
||||
|
||||
def resolve_editor_target(engine_root: str) -> str:
|
||||
"""Resolve UnrealEditor.exe from a UE install root or Engine directory."""
|
||||
root = Path(resolve_engine_root(engine_root))
|
||||
editor = root / "Engine" / "Binaries" / "Win64" / EDITOR_BINARY_NAME
|
||||
if not editor.is_file():
|
||||
raise RuntimeError(f"UnrealEditor.exe not found under engine root: {root}")
|
||||
return str(editor.resolve())
|
||||
|
||||
|
||||
def resolve_capture_target(
|
||||
target_exe: str | None,
|
||||
project: str | None = None,
|
||||
engine_root: str | None = None,
|
||||
target_args: Sequence[str] | None = None,
|
||||
) -> tuple[str, list[str], dict[str, str | None]]:
|
||||
"""Resolve the effective target executable and launch args."""
|
||||
resolved_project = None
|
||||
resolved_engine_root = None
|
||||
launch_args = list(target_args or [])
|
||||
|
||||
if project:
|
||||
project_path = Path(project).expanduser().resolve()
|
||||
if not project_path.is_file():
|
||||
raise RuntimeError(f"Project file not found: {project_path}")
|
||||
resolved_project = str(project_path)
|
||||
|
||||
if target_exe:
|
||||
target_path = Path(target_exe).expanduser().resolve()
|
||||
if not target_path.is_file():
|
||||
raise RuntimeError(f"Target executable not found: {target_path}")
|
||||
resolved_target = str(target_path)
|
||||
else:
|
||||
if not resolved_project:
|
||||
raise RuntimeError("Either target_exe or --project must be provided.")
|
||||
if not engine_root:
|
||||
raise RuntimeError("--engine-root is required when inferring UnrealEditor.exe from --project.")
|
||||
resolved_engine_root = resolve_engine_root(engine_root)
|
||||
resolved_target = resolve_editor_target(resolved_engine_root)
|
||||
|
||||
if engine_root and resolved_engine_root is None:
|
||||
resolved_engine_root = resolve_engine_root(engine_root)
|
||||
|
||||
if resolved_project and resolved_project not in launch_args:
|
||||
launch_args = [resolved_project, *launch_args]
|
||||
|
||||
return resolved_target, launch_args, {
|
||||
"project_path": resolved_project,
|
||||
"engine_root": resolved_engine_root,
|
||||
}
|
||||
|
||||
|
||||
def build_capture_command(
|
||||
target_exe: str,
|
||||
output_trace: str,
|
||||
channels: str = DEFAULT_CHANNELS,
|
||||
exec_cmds: Sequence[str] | None = None,
|
||||
target_args: Sequence[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Build the traced target command line."""
|
||||
target_path = Path(target_exe).expanduser().resolve()
|
||||
if not target_path.is_file():
|
||||
raise RuntimeError(f"Target executable not found: {target_path}")
|
||||
|
||||
command = [str(target_path)]
|
||||
command.extend(target_args or [])
|
||||
command.append(f"-trace={channels}")
|
||||
command.append(f"-tracefile={Path(output_trace).expanduser().resolve()}")
|
||||
|
||||
exec_arg = build_exec_cmds_arg(exec_cmds)
|
||||
if exec_arg:
|
||||
command.append(f"-ExecCmds={exec_arg}")
|
||||
|
||||
return command
|
||||
|
||||
|
||||
def run_capture(
|
||||
target_exe: str,
|
||||
output_trace: str,
|
||||
channels: str = DEFAULT_CHANNELS,
|
||||
exec_cmds: Sequence[str] | None = None,
|
||||
target_args: Sequence[str] | None = None,
|
||||
wait: bool = False,
|
||||
timeout: float | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Launch a traced target executable."""
|
||||
backend.ensure_parent_dir(output_trace)
|
||||
command = build_capture_command(
|
||||
target_exe,
|
||||
output_trace=output_trace,
|
||||
channels=channels,
|
||||
exec_cmds=exec_cmds,
|
||||
target_args=target_args,
|
||||
)
|
||||
result = backend.run_process(command, timeout=timeout, wait=wait)
|
||||
|
||||
trace_path = Path(output_trace).expanduser().resolve()
|
||||
trace_exists = trace_path.is_file()
|
||||
trace_size = trace_path.stat().st_size if trace_exists else None
|
||||
waited = bool(result.get("waited", wait))
|
||||
succeeded = True
|
||||
if waited:
|
||||
succeeded = (
|
||||
not bool(result.get("timed_out"))
|
||||
and result.get("exit_code") == 0
|
||||
and trace_exists
|
||||
)
|
||||
|
||||
result.update(
|
||||
{
|
||||
"target_exe": str(Path(target_exe).expanduser().resolve()),
|
||||
"target_args": list(target_args or []),
|
||||
"trace_path": str(trace_path),
|
||||
"channels": channels,
|
||||
"trace_exists": trace_exists,
|
||||
"trace_size": trace_size,
|
||||
"succeeded": succeeded,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def capture_status(session) -> dict[str, object]:
|
||||
"""Return the current tracked capture status."""
|
||||
info = session.capture_info()
|
||||
pid = info.get("pid")
|
||||
info["running"] = backend.is_process_running(pid) if pid else False
|
||||
return info
|
||||
|
||||
|
||||
def stop_capture(session, force: bool = False, timeout: float | None = None) -> dict[str, object]:
|
||||
"""Stop the currently tracked capture process."""
|
||||
info = capture_status(session)
|
||||
pid = info.get("pid")
|
||||
if not pid:
|
||||
raise RuntimeError("No active capture session is being tracked.")
|
||||
|
||||
termination = backend.terminate_process(int(pid), force=force, timeout=timeout)
|
||||
status = capture_status(session)
|
||||
result = {
|
||||
"termination": termination,
|
||||
"capture": status,
|
||||
}
|
||||
if termination.get("stopped"):
|
||||
session.clear_capture()
|
||||
result["capture"] = session.capture_info()
|
||||
if info.get("trace_path"):
|
||||
session.set_trace(info["trace_path"])
|
||||
return result
|
||||
|
||||
|
||||
def snapshot_capture(session, output_trace: str | None = None) -> dict[str, object]:
|
||||
"""Create a best-effort copy of the current trace file."""
|
||||
info = capture_status(session)
|
||||
source = info.get("trace_path")
|
||||
if not source:
|
||||
raise RuntimeError("No active capture trace is available to snapshot.")
|
||||
|
||||
source_path = Path(source).expanduser().resolve()
|
||||
if not source_path.is_file():
|
||||
raise RuntimeError(f"Trace file not found: {source_path}")
|
||||
|
||||
if output_trace:
|
||||
output_path = Path(output_trace).expanduser().resolve()
|
||||
else:
|
||||
timestamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
output_path = source_path.with_name(f"{source_path.stem}-snapshot-{timestamp}{source_path.suffix}")
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source_path, output_path)
|
||||
return {
|
||||
"source_trace": str(source_path),
|
||||
"snapshot_trace": str(output_path),
|
||||
"snapshot_exists": output_path.is_file(),
|
||||
"snapshot_size": output_path.stat().st_size if output_path.is_file() else None,
|
||||
"capture_running": info.get("running", False),
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
Exporter command construction and execution helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.unrealinsights.utils import unrealinsights_backend as backend
|
||||
|
||||
EXPORTER_COMMANDS = {
|
||||
"threads": "TimingInsights.ExportThreads",
|
||||
"timers": "TimingInsights.ExportTimers",
|
||||
"timing-events": "TimingInsights.ExportTimingEvents",
|
||||
"timer-stats": "TimingInsights.ExportTimerStatistics",
|
||||
"timer-callees": "TimingInsights.ExportTimerCallees",
|
||||
"counters": "TimingInsights.ExportCounters",
|
||||
"counter-values": "TimingInsights.ExportCounterValues",
|
||||
}
|
||||
|
||||
|
||||
def _quote(value: str) -> str:
|
||||
return f'"{value}"'
|
||||
|
||||
|
||||
def _is_legacy_unrealinsights(version: str | None) -> bool:
|
||||
return bool(version and version.startswith("5.3"))
|
||||
|
||||
|
||||
def _windows_short_path(path: Path) -> str | None:
|
||||
if os.name != "nt":
|
||||
return None
|
||||
buffer = ctypes.create_unicode_buffer(32768)
|
||||
result = ctypes.windll.kernel32.GetShortPathNameW(str(path), buffer, len(buffer))
|
||||
if result == 0:
|
||||
return None
|
||||
return buffer.value
|
||||
|
||||
|
||||
def _legacy_filename_arg(output_path: str) -> str:
|
||||
path = Path(output_path).expanduser().resolve()
|
||||
path_str = str(path)
|
||||
if " " not in path_str:
|
||||
return path_str
|
||||
|
||||
parent = path.parent
|
||||
short_parent = _windows_short_path(parent)
|
||||
if not short_parent:
|
||||
raise RuntimeError(
|
||||
f"Legacy UnrealInsights export requires a path without spaces or a resolvable short path: {path}"
|
||||
)
|
||||
if " " in path.name:
|
||||
raise RuntimeError(
|
||||
f"Legacy UnrealInsights export does not support spaces in the output filename: {path.name}"
|
||||
)
|
||||
return str(Path(short_parent) / path.name)
|
||||
|
||||
|
||||
def _modern_filename_arg(output_path: str) -> str:
|
||||
"""Build a filename token compatible with modern UnrealInsights builds."""
|
||||
path = Path(output_path).expanduser().resolve()
|
||||
path_str = str(path)
|
||||
if os.name != "nt":
|
||||
return _quote(path_str)
|
||||
if " " not in path_str:
|
||||
return path_str
|
||||
|
||||
short_path = _windows_short_path(path)
|
||||
if short_path:
|
||||
return short_path
|
||||
|
||||
raise RuntimeError(
|
||||
"UnrealInsights export requires a path without spaces or a resolvable short path on Windows: "
|
||||
f"{path}"
|
||||
)
|
||||
|
||||
|
||||
def _filename_arg(output_path: str, insights_version: str | None = None) -> str:
|
||||
output_abs = str(Path(output_path).expanduser().resolve())
|
||||
if _is_legacy_unrealinsights(insights_version):
|
||||
return _legacy_filename_arg(output_abs)
|
||||
return _modern_filename_arg(output_abs)
|
||||
|
||||
|
||||
def build_export_exec_command(
|
||||
exporter: str,
|
||||
output_path: str,
|
||||
*,
|
||||
insights_version: str | None = None,
|
||||
columns: str | None = None,
|
||||
threads: str | None = None,
|
||||
timers: str | None = None,
|
||||
start_time: float | None = None,
|
||||
end_time: float | None = None,
|
||||
region: str | None = None,
|
||||
counter: str | None = None,
|
||||
) -> str:
|
||||
"""Build a TimingInsights exporter command string."""
|
||||
if exporter not in EXPORTER_COMMANDS:
|
||||
raise RuntimeError(f"Unsupported exporter: {exporter}")
|
||||
|
||||
output_abs = str(Path(output_path).expanduser().resolve())
|
||||
filename_token = _filename_arg(output_abs, insights_version=insights_version)
|
||||
|
||||
parts = [EXPORTER_COMMANDS[exporter], filename_token]
|
||||
|
||||
if counter:
|
||||
parts.append(f"-counter={_quote(counter)}")
|
||||
if columns:
|
||||
parts.append(f"-columns={_quote(columns)}")
|
||||
if threads:
|
||||
parts.append(f"-threads={_quote(threads)}")
|
||||
if timers:
|
||||
parts.append(f"-timers={_quote(timers)}")
|
||||
if start_time is not None:
|
||||
parts.append(f"-startTime={start_time}")
|
||||
if end_time is not None:
|
||||
parts.append(f"-endTime={end_time}")
|
||||
if region:
|
||||
parts.append(f"-region={_quote(region)}")
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def build_rsp_exec_command(rsp_path: str) -> str:
|
||||
"""Build the response-file execution token."""
|
||||
return f"@={Path(rsp_path).expanduser().resolve()}"
|
||||
|
||||
|
||||
def _normalize_rsp_line(line: str, insights_version: str | None = None) -> tuple[str, str | None]:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
return line, None
|
||||
|
||||
match = re.match(r'^(?P<indent>\s*)(?P<command>\S+)\s+(?P<output>"[^"]*"|\S+)(?P<rest>.*)$', line)
|
||||
if not match:
|
||||
return line, None
|
||||
|
||||
command = match.group("command")
|
||||
if command not in EXPORTER_COMMANDS.values():
|
||||
return line, None
|
||||
|
||||
output_path = match.group("output").strip('"')
|
||||
normalized_output = _filename_arg(output_path, insights_version=insights_version)
|
||||
normalized_line = f"{match.group('indent')}{command} {normalized_output}{match.group('rest')}"
|
||||
return normalized_line, str(Path(output_path).expanduser().resolve())
|
||||
|
||||
|
||||
def _path_contains_placeholders(path: Path) -> bool:
|
||||
return "{counter}" in path.name or "{region}" in path.name
|
||||
|
||||
|
||||
def collect_materialized_outputs(output_path: str) -> list[str]:
|
||||
"""Collect actual output files for a requested exporter path."""
|
||||
path = Path(output_path).expanduser().resolve()
|
||||
if _path_contains_placeholders(path):
|
||||
pattern = path.name.replace("{counter}", "*").replace("{region}", "*")
|
||||
return sorted(str(match.resolve()) for match in path.parent.glob(pattern) if match.is_file())
|
||||
if path.is_file():
|
||||
return [str(path)]
|
||||
return []
|
||||
|
||||
|
||||
def _token_output_path(command_line: str) -> str | None:
|
||||
try:
|
||||
tokens = shlex.split(command_line, posix=False)
|
||||
except ValueError:
|
||||
return None
|
||||
if len(tokens) < 2:
|
||||
return None
|
||||
return tokens[1].strip('"')
|
||||
|
||||
|
||||
def expected_outputs_from_rsp(rsp_path: str) -> list[str]:
|
||||
"""Read a response file and infer output files from each command line."""
|
||||
path = Path(rsp_path).expanduser().resolve()
|
||||
outputs: list[str] = []
|
||||
if not path.is_file():
|
||||
return outputs
|
||||
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
output = _token_output_path(stripped)
|
||||
if output:
|
||||
outputs.append(str(Path(output).expanduser().resolve()))
|
||||
return outputs
|
||||
|
||||
|
||||
def default_log_path(reference_path: str, suffix: str = ".insights.log") -> str:
|
||||
path = Path(reference_path).expanduser().resolve()
|
||||
return str(path.with_name(f"{path.stem}{suffix}"))
|
||||
|
||||
|
||||
def _execute_insights(
|
||||
insights_exe: str,
|
||||
trace_path: str,
|
||||
exec_command: str,
|
||||
expected_outputs: list[str],
|
||||
log_path: str,
|
||||
) -> dict[str, object]:
|
||||
backend.ensure_parent_dir(log_path)
|
||||
for output in expected_outputs:
|
||||
if "{" not in Path(output).name:
|
||||
backend.ensure_parent_dir(output)
|
||||
|
||||
raw_command = backend.build_insights_command_line(insights_exe, trace_path, exec_command, log_path)
|
||||
run_result = backend.run_process(
|
||||
raw_command,
|
||||
wait=True,
|
||||
)
|
||||
log_info = backend.parse_unreal_log(log_path)
|
||||
|
||||
actual_outputs: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for output in expected_outputs:
|
||||
for match in collect_materialized_outputs(output):
|
||||
if match not in seen:
|
||||
actual_outputs.append(match)
|
||||
seen.add(match)
|
||||
|
||||
run_result.update(
|
||||
{
|
||||
"trace_path": str(Path(trace_path).expanduser().resolve()),
|
||||
"exec_command": exec_command,
|
||||
"expected_outputs": expected_outputs,
|
||||
"output_files": actual_outputs,
|
||||
"log_path": log_info["path"],
|
||||
"warnings": log_info["warnings"],
|
||||
"errors": log_info["errors"],
|
||||
"succeeded": (
|
||||
not run_result["timed_out"]
|
||||
and run_result["exit_code"] == 0
|
||||
and len(actual_outputs) > 0
|
||||
),
|
||||
}
|
||||
)
|
||||
return run_result
|
||||
|
||||
|
||||
def execute_export(
|
||||
insights_exe: str,
|
||||
trace_path: str,
|
||||
exporter: str,
|
||||
output_path: str,
|
||||
*,
|
||||
insights_version: str | None = None,
|
||||
columns: str | None = None,
|
||||
threads: str | None = None,
|
||||
timers: str | None = None,
|
||||
start_time: float | None = None,
|
||||
end_time: float | None = None,
|
||||
region: str | None = None,
|
||||
counter: str | None = None,
|
||||
log_path: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Execute a single TimingInsights exporter."""
|
||||
output_abs = str(Path(output_path).expanduser().resolve())
|
||||
resolved_log_path = log_path or default_log_path(output_abs)
|
||||
exec_command = build_export_exec_command(
|
||||
exporter,
|
||||
output_abs,
|
||||
insights_version=insights_version,
|
||||
columns=columns,
|
||||
threads=threads,
|
||||
timers=timers,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
region=region,
|
||||
counter=counter,
|
||||
)
|
||||
return _execute_insights(
|
||||
insights_exe,
|
||||
trace_path,
|
||||
exec_command=exec_command,
|
||||
expected_outputs=[output_abs],
|
||||
log_path=resolved_log_path,
|
||||
)
|
||||
|
||||
|
||||
def execute_response_file(
|
||||
insights_exe: str,
|
||||
trace_path: str,
|
||||
rsp_path: str,
|
||||
*,
|
||||
insights_version: str | None = None,
|
||||
log_path: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Execute a response file batch export."""
|
||||
rsp_abs = str(Path(rsp_path).expanduser().resolve())
|
||||
resolved_log_path = log_path or default_log_path(rsp_abs)
|
||||
lines = Path(rsp_abs).read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
|
||||
normalized_lines: list[str] = []
|
||||
expected_outputs: list[str] = []
|
||||
for line in lines:
|
||||
normalized_line, normalized_output = _normalize_rsp_line(line, insights_version=insights_version)
|
||||
normalized_lines.append(normalized_line)
|
||||
if normalized_output:
|
||||
expected_outputs.append(normalized_output)
|
||||
|
||||
if not expected_outputs:
|
||||
expected_outputs = expected_outputs_from_rsp(rsp_abs)
|
||||
|
||||
temp_rsp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".rsp", delete=False, encoding="utf-8", newline="\n") as handle:
|
||||
temp_rsp_path = handle.name
|
||||
handle.write("\n".join(normalized_lines))
|
||||
return _execute_insights(
|
||||
insights_exe,
|
||||
trace_path,
|
||||
exec_command=build_rsp_exec_command(temp_rsp_path),
|
||||
expected_outputs=expected_outputs,
|
||||
log_path=resolved_log_path,
|
||||
)
|
||||
finally:
|
||||
if temp_rsp_path:
|
||||
try:
|
||||
Path(temp_rsp_path).unlink()
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Persistent session state for Unreal Insights CLI workflows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _normalize_path(path: str | None) -> str | None:
|
||||
return str(Path(path).expanduser().resolve()) if path else None
|
||||
|
||||
|
||||
def state_dir() -> Path:
|
||||
override = os.environ.get("CLI_ANYTHING_UNREALINSIGHTS_STATE_DIR", "").strip()
|
||||
base = Path(override).expanduser() if override else Path.home() / ".cli-anything-unrealinsights"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base.resolve()
|
||||
|
||||
|
||||
def _state_file() -> Path:
|
||||
return state_dir() / "session.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UnrealInsightsSession:
|
||||
trace_path: str | None = None
|
||||
insights_exe: str | None = None
|
||||
trace_server_exe: str | None = None
|
||||
capture_pid: int | None = None
|
||||
capture_target_exe: str | None = None
|
||||
capture_target_args: list[str] = field(default_factory=list)
|
||||
capture_project_path: str | None = None
|
||||
capture_engine_root: str | None = None
|
||||
capture_trace_path: str | None = None
|
||||
capture_channels: str | None = None
|
||||
capture_started_at: str | None = None
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> "UnrealInsightsSession":
|
||||
path = _state_file()
|
||||
if not path.is_file():
|
||||
return cls()
|
||||
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return cls()
|
||||
|
||||
return cls(
|
||||
trace_path=data.get("trace_path"),
|
||||
insights_exe=data.get("insights_exe"),
|
||||
trace_server_exe=data.get("trace_server_exe"),
|
||||
capture_pid=data.get("capture_pid"),
|
||||
capture_target_exe=data.get("capture_target_exe"),
|
||||
capture_target_args=list(data.get("capture_target_args", [])),
|
||||
capture_project_path=data.get("capture_project_path"),
|
||||
capture_engine_root=data.get("capture_engine_root"),
|
||||
capture_trace_path=data.get("capture_trace_path"),
|
||||
capture_channels=data.get("capture_channels"),
|
||||
capture_started_at=data.get("capture_started_at"),
|
||||
)
|
||||
|
||||
def save(self):
|
||||
path = _state_file()
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
def set_trace(self, trace_path: str | None):
|
||||
self.trace_path = _normalize_path(trace_path)
|
||||
self.save()
|
||||
|
||||
def set_insights_exe(self, path: str | None):
|
||||
self.insights_exe = _normalize_path(path)
|
||||
self.save()
|
||||
|
||||
def set_trace_server_exe(self, path: str | None):
|
||||
self.trace_server_exe = _normalize_path(path)
|
||||
self.save()
|
||||
|
||||
def set_capture(
|
||||
self,
|
||||
*,
|
||||
pid: int | None,
|
||||
target_exe: str,
|
||||
target_args: list[str],
|
||||
trace_path: str,
|
||||
channels: str,
|
||||
project_path: str | None = None,
|
||||
engine_root: str | None = None,
|
||||
):
|
||||
self.capture_pid = pid
|
||||
self.capture_target_exe = _normalize_path(target_exe)
|
||||
self.capture_target_args = list(target_args)
|
||||
self.capture_trace_path = _normalize_path(trace_path)
|
||||
self.capture_channels = channels
|
||||
self.capture_project_path = _normalize_path(project_path)
|
||||
self.capture_engine_root = _normalize_path(engine_root)
|
||||
self.capture_started_at = datetime.now(timezone.utc).isoformat()
|
||||
if self.capture_trace_path:
|
||||
self.trace_path = self.capture_trace_path
|
||||
self.save()
|
||||
|
||||
def clear_capture(self):
|
||||
self.capture_pid = None
|
||||
self.capture_target_exe = None
|
||||
self.capture_target_args = []
|
||||
self.capture_project_path = None
|
||||
self.capture_engine_root = None
|
||||
self.capture_trace_path = None
|
||||
self.capture_channels = None
|
||||
self.capture_started_at = None
|
||||
self.save()
|
||||
|
||||
def trace_info(self) -> dict[str, Any]:
|
||||
if self.trace_path is None:
|
||||
return {
|
||||
"trace_path": None,
|
||||
"exists": False,
|
||||
}
|
||||
|
||||
path = Path(self.trace_path)
|
||||
return {
|
||||
"trace_path": str(path),
|
||||
"exists": path.is_file(),
|
||||
"file_size": path.stat().st_size if path.is_file() else None,
|
||||
}
|
||||
|
||||
def capture_info(self) -> dict[str, Any]:
|
||||
trace_path = self.capture_trace_path or self.trace_path
|
||||
path = Path(trace_path) if trace_path else None
|
||||
return {
|
||||
"active": self.capture_pid is not None or self.capture_trace_path is not None,
|
||||
"pid": self.capture_pid,
|
||||
"target_exe": self.capture_target_exe,
|
||||
"target_args": list(self.capture_target_args),
|
||||
"project_path": self.capture_project_path,
|
||||
"engine_root": self.capture_engine_root,
|
||||
"trace_path": str(path) if path else None,
|
||||
"trace_exists": path.is_file() if path else False,
|
||||
"trace_size": path.stat().st_size if path and path.is_file() else None,
|
||||
"channels": self.capture_channels,
|
||||
"started_at": self.capture_started_at,
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
name: "cli-anything-unrealinsights"
|
||||
description: "Capture Unreal Engine traces to .utrace files and export Unreal Insights timing/counter data in headless mode."
|
||||
---
|
||||
|
||||
# cli-anything-unrealinsights
|
||||
|
||||
Use this CLI when you need agent-friendly access to Unreal Insights trace capture
|
||||
and exporter workflows on Windows.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Unreal Engine 5.5+ installed with `UnrealInsights.exe`
|
||||
- Windows
|
||||
- Optional explicit env vars:
|
||||
- `UNREALINSIGHTS_EXE`
|
||||
- `UNREAL_TRACE_SERVER_EXE`
|
||||
- `UNREALINSIGHTS_TRACE`
|
||||
|
||||
## Core Commands
|
||||
|
||||
### Backend discovery
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json backend info
|
||||
```
|
||||
|
||||
To use a source-built engine's matching `UnrealInsights.exe`:
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json backend ensure-insights `
|
||||
--engine-root 'D:\code\D5\d5render-ue5_3'
|
||||
```
|
||||
|
||||
This first looks for `Engine\Binaries\Win64\UnrealInsights.exe` under the
|
||||
specified engine root, then builds it with that engine's `Build.bat` if needed.
|
||||
|
||||
### Trace session state
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights trace set D:\captures\session.utrace
|
||||
cli-anything-unrealinsights --json trace info
|
||||
```
|
||||
|
||||
### Capture orchestration
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json capture run `
|
||||
--project 'D:\Projects\MyGame\MyGame.uproject' `
|
||||
--engine-root 'D:\Program Files\Epic Games\UE_5.5' `
|
||||
--output-trace D:\captures\boot.utrace `
|
||||
--channels "default,bookmark" `
|
||||
--exec-cmd "Trace.Bookmark BootStart" `
|
||||
--wait --timeout 300
|
||||
```
|
||||
|
||||
You can also keep using the explicit form:
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json capture run `
|
||||
'D:\Program Files\Epic Games\UE_5.5\Engine\Binaries\Win64\UnrealEditor.exe' `
|
||||
--target-arg 'D:\Projects\MyGame\MyGame.uproject'
|
||||
```
|
||||
|
||||
### Continuous capture session control
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json capture start `
|
||||
--project 'D:\Projects\MyGame\MyGame.uproject' `
|
||||
--engine-root 'D:\Program Files\Epic Games\UE_5.5' `
|
||||
--output-trace D:\captures\live_session.utrace
|
||||
|
||||
cli-anything-unrealinsights --json capture status
|
||||
cli-anything-unrealinsights --json capture snapshot D:\captures\live_snapshot.utrace
|
||||
cli-anything-unrealinsights --json capture stop
|
||||
```
|
||||
|
||||
This is the preferred flow when an agent needs to start profiling now and stop
|
||||
or snapshot later in a follow-up turn.
|
||||
|
||||
If a tracked capture session is still running, `capture start` now requires
|
||||
`--replace` so the previous process is stopped before a new one is launched.
|
||||
|
||||
### Offline exporters
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json -t D:\captures\session.utrace export threads D:\out\threads.csv
|
||||
cli-anything-unrealinsights --json -t D:\captures\session.utrace export timer-stats D:\out\stats.csv --region "EXPORT_CAPTURE"
|
||||
cli-anything-unrealinsights --json -t D:\captures\session.utrace export counter-values D:\out\counter_values.csv --counter "*"
|
||||
```
|
||||
|
||||
### Batch response files
|
||||
|
||||
```powershell
|
||||
cli-anything-unrealinsights --json -t D:\captures\session.utrace batch run-rsp D:\out\exports.rsp
|
||||
```
|
||||
|
||||
## JSON Output Guidance
|
||||
|
||||
- Prefer `--json` for agent workflows.
|
||||
- Export commands return:
|
||||
- `trace_path`
|
||||
- `exec_command`
|
||||
- `output_files`
|
||||
- `log_path`
|
||||
- `exit_code`
|
||||
- `warnings`
|
||||
- `errors`
|
||||
- `succeeded`
|
||||
- Capture returns:
|
||||
- `command`
|
||||
- `trace_path`
|
||||
- `trace_exists`
|
||||
- `trace_size`
|
||||
- `pid` or `exit_code`
|
||||
- Continuous capture status returns:
|
||||
- `pid`
|
||||
- `running`
|
||||
- `target_exe`
|
||||
- `project_path`
|
||||
- `trace_path`
|
||||
- `trace_size`
|
||||
- `started_at`
|
||||
|
||||
## Notes
|
||||
|
||||
- v1 is Windows-first.
|
||||
- v1 supports file-mode capture orchestration only.
|
||||
- v1 does not control already-running UE instances or browse trace stores.
|
||||
- `capture stop` is a best-effort stop of the harness-launched process tree.
|
||||
- `capture snapshot` is a best-effort filesystem snapshot of the active trace.
|
||||
@@ -0,0 +1,174 @@
|
||||
# TEST.md - Unreal Insights CLI Test Plan
|
||||
|
||||
## Test Inventory Plan
|
||||
|
||||
- `test_core.py`: 49 unit tests planned
|
||||
- `test_full_e2e.py`: 10 E2E tests planned
|
||||
|
||||
## Unit Test Plan
|
||||
|
||||
### `utils/unrealinsights_backend.py`
|
||||
- Validate binary discovery precedence: explicit path, env var, then Windows auto-discovery
|
||||
- Validate missing explicit and env paths fail loudly
|
||||
- Validate Unreal Insights command construction
|
||||
- Validate engine-root binary resolution and build orchestration
|
||||
- Planned tests: 13
|
||||
|
||||
### `core/capture.py`
|
||||
- Validate output trace path normalization
|
||||
- Validate traced target command construction
|
||||
- Validate `-ExecCmds=` joining semantics
|
||||
- Validate `--project + --engine-root` convenience resolution
|
||||
- Validate tracked capture status, snapshot, and stop flows
|
||||
- Planned tests: 12
|
||||
|
||||
### `core/export.py`
|
||||
- Validate exporter command strings for all supported exporters
|
||||
- Validate response-file parsing and output inference
|
||||
- Validate placeholder-aware output collection
|
||||
- Validate legacy UnrealInsights 5.3 export command compatibility
|
||||
- Planned tests: 9
|
||||
|
||||
### `unrealinsights_cli.py`
|
||||
- Validate root and group help
|
||||
- Validate JSON error payloads when trace/backend requirements are missing
|
||||
- Validate REPL session trace state
|
||||
- Validate capture convenience-layer argument handling
|
||||
- Validate `capture status`, `capture stop`, and `capture snapshot` JSON flows
|
||||
- Planned tests: 12
|
||||
|
||||
## E2E Test Plan
|
||||
|
||||
### Prerequisites
|
||||
- Unreal Engine 5.5+ installed with `UnrealInsights.exe`
|
||||
- Optional trace file via `UNREALINSIGHTS_TEST_TRACE`
|
||||
- Optional UE/Game executable via `UNREALINSIGHTS_TEST_TARGET_EXE`
|
||||
|
||||
### Workflows to validate
|
||||
- `backend info` against the local UE install
|
||||
- Export threads/timers/timing-events/timer-stats/timer-callees/counters/counter-values from a real `.utrace`
|
||||
- Execute a generated response file containing multiple exporter commands
|
||||
- Launch a traced target executable in file mode and verify `.utrace` creation
|
||||
|
||||
### Output validation
|
||||
- All `--json` responses parse correctly
|
||||
- Export commands create non-empty output files and surface log paths
|
||||
- `batch run-rsp` returns the materialized output list
|
||||
- `capture run --wait` returns exit status plus `.utrace` file metadata
|
||||
|
||||
## Realistic Workflow Scenarios
|
||||
|
||||
### Workflow name: `trace_export_bundle`
|
||||
- Simulates: post-capture analysis of a performance trace
|
||||
- Operations chained:
|
||||
1. `trace set`
|
||||
2. `export threads`
|
||||
3. `export timer-stats`
|
||||
4. `export counter-values`
|
||||
5. `batch run-rsp`
|
||||
- Verified:
|
||||
- exporter outputs exist
|
||||
- response-file execution triggers multiple materialized files
|
||||
- JSON payloads include log path and exit code
|
||||
|
||||
### Workflow name: `editor_boot_capture`
|
||||
- Simulates: launching a traced UE executable and recording startup behavior
|
||||
- Operations chained:
|
||||
1. `capture run`
|
||||
2. `trace info`
|
||||
3. optional exporter pass on the produced trace
|
||||
- Verified:
|
||||
- traced command line contains `-trace=` and `-tracefile=`
|
||||
- `.utrace` file is created and has size > 0
|
||||
|
||||
## Test Results
|
||||
|
||||
### Commands run
|
||||
|
||||
```bash
|
||||
python -m pytest cli_anything/unrealinsights/tests/test_core.py -v --tb=no
|
||||
python -m pytest cli_anything/unrealinsights/tests/test_full_e2e.py -v -s --tb=no
|
||||
python -m pip install -e .
|
||||
cli-anything-unrealinsights --json backend info
|
||||
```
|
||||
|
||||
### Result summary
|
||||
|
||||
- `test_core.py`: 49 passed
|
||||
- `test_full_e2e.py`: 1 passed, 9 skipped
|
||||
- Manual smoke: installed entrypoint resolved local UE 5.5 binaries successfully
|
||||
|
||||
### Coverage notes
|
||||
|
||||
- Real export and capture E2E scenarios are env-gated and were skipped because
|
||||
`UNREALINSIGHTS_TEST_TRACE` and `UNREALINSIGHTS_TEST_TARGET_EXE` were not set.
|
||||
- The local Windows auto-discovery path was validated against:
|
||||
- `D:\Program Files\Epic Games\UE_5.5\Engine\Binaries\Win64\UnrealInsights.exe`
|
||||
- `D:\Program Files\Epic Games\UE_5.5\Engine\Binaries\Win64\UnrealTraceServer.exe`
|
||||
|
||||
### Full pytest output
|
||||
|
||||
```text
|
||||
============================= test session starts =============================
|
||||
platform win32 -- Python 3.11.9, pytest-9.0.3, pluggy-1.6.0 -- C:\Users\aimidi\AppData\Local\Programs\Python\Python311\python.exe
|
||||
cachedir: .pytest_cache
|
||||
rootdir: D:\code\D5\CLI-Anything-unrealinsights\unrealinsights\agent-harness
|
||||
collecting ... collected 46 items
|
||||
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestOutputUtils::test_output_json PASSED [ 3%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestOutputUtils::test_output_table_empty PASSED [ 7%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestOutputUtils::test_format_size PASSED [ 11%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestErrorUtils::test_handle_error PASSED [ 14%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestBackendDiscovery::test_explicit_path_precedence PASSED [ 18%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestBackendDiscovery::test_env_path_precedence PASSED [ 22%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestBackendDiscovery::test_auto_discovery PASSED [ 25%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestBackendDiscovery::test_missing_explicit_path_fails PASSED [ 29%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestBackendDiscovery::test_build_insights_command PASSED [ 33%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestCaptureCore::test_normalize_trace_output_path_prefers_explicit PASSED [ 37%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestCaptureCore::test_build_exec_cmds_arg PASSED [ 40%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestCaptureCore::test_resolve_engine_root_from_engine_subdir PASSED [ 43%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestCaptureCore::test_resolve_editor_target PASSED [ 46%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestCaptureCore::test_resolve_capture_target_from_project_and_engine PASSED [ 50%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestCaptureCore::test_build_capture_command PASSED [ 53%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_export_exec_command[threads-TimingInsights.ExportThreads] PASSED [ 56%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_export_exec_command[timers-TimingInsights.ExportTimers] PASSED [ 59%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_export_exec_command[timing-events-TimingInsights.ExportTimingEvents] PASSED [ 62%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_export_exec_command[timer-stats-TimingInsights.ExportTimerStatistics] PASSED [ 65%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_export_exec_command[timer-callees-TimingInsights.ExportTimerCallees] PASSED [ 68%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_export_exec_command[counters-TimingInsights.ExportCounters] PASSED [ 71%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_export_exec_command[counter-values-TimingInsights.ExportCounterValues] PASSED [ 75%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_rsp_exec_command PASSED [ 78%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_expected_outputs_from_rsp PASSED [ 81%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_collect_materialized_outputs_placeholder PASSED [ 84%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestCLIHelp::test_main_help PASSED [ 87%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestCLIHelp::test_group_help PASSED [ 90%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestCLIJsonErrors::test_export_threads_requires_trace PASSED [ 93%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestCLIJsonErrors::test_backend_info_json PASSED [ 96%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestCLIJsonErrors::test_capture_project_requires_engine_root PASSED [ 96%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestREPLSessionState::test_trace_set_then_info_in_repl PASSED [ 99%]
|
||||
cli_anything/unrealinsights/tests/test_core.py::TestCaptureCLIConvenience::test_capture_run_with_project_and_engine_root PASSED [100%]
|
||||
|
||||
============================= 46 passed in 0.28s ==============================
|
||||
|
||||
============================= test session starts =============================
|
||||
platform win32 -- Python 3.11.9, pytest-9.0.3, pluggy-1.6.0 -- C:\Users\aimidi\AppData\Local\Programs\Python\Python311\python.exe
|
||||
cachedir: .pytest_cache
|
||||
rootdir: D:\code\D5\CLI-Anything-unrealinsights\unrealinsights\agent-harness
|
||||
collecting ... [_resolve_cli] Using installed command: C:\Users\aimidi\AppData\Local\Programs\Python\Python311\Scripts\cli-anything-unrealinsights.EXE
|
||||
[_resolve_cli] Using installed command: C:\Users\aimidi\AppData\Local\Programs\Python\Python311\Scripts\cli-anything-unrealinsights.EXE
|
||||
[_resolve_cli] Using installed command: C:\Users\aimidi\AppData\Local\Programs\Python\Python311\Scripts\cli-anything-unrealinsights.EXE
|
||||
collected 10 items
|
||||
|
||||
cli_anything/unrealinsights/tests/test_full_e2e.py::TestCLISmoke::test_backend_info PASSED
|
||||
cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_exporter_creates_output[threads-extra_args0] SKIPPED
|
||||
cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_exporter_creates_output[timers-extra_args1] SKIPPED
|
||||
cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_exporter_creates_output[timing-events-extra_args2] SKIPPED
|
||||
cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_exporter_creates_output[timer-stats-extra_args3] SKIPPED
|
||||
cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_exporter_creates_output[timer-callees-extra_args4] SKIPPED
|
||||
cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_exporter_creates_output[counters-extra_args5] SKIPPED
|
||||
cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_exporter_creates_output[counter-values-extra_args6] SKIPPED
|
||||
cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_batch_run_rsp SKIPPED
|
||||
cli_anything/unrealinsights/tests/test_full_e2e.py::TestCaptureE2E::test_capture_run_wait SKIPPED
|
||||
|
||||
======================== 1 passed, 9 skipped in 0.85s =========================
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for cli-anything-unrealinsights."""
|
||||
@@ -0,0 +1,819 @@
|
||||
"""
|
||||
Unit tests for Unreal Insights harness modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _session_state_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CLI_ANYTHING_UNREALINSIGHTS_STATE_DIR", str(tmp_path / "state"))
|
||||
|
||||
|
||||
class TestOutputUtils:
|
||||
def test_output_json(self):
|
||||
import io
|
||||
|
||||
from cli_anything.unrealinsights.utils.output import output_json
|
||||
|
||||
buf = io.StringIO()
|
||||
output_json({"ok": True, "value": 42}, file=buf)
|
||||
data = json.loads(buf.getvalue())
|
||||
assert data["ok"] is True
|
||||
assert data["value"] == 42
|
||||
|
||||
def test_output_table_empty(self):
|
||||
import io
|
||||
|
||||
from cli_anything.unrealinsights.utils.output import output_table
|
||||
|
||||
buf = io.StringIO()
|
||||
output_table([], ["col"], file=buf)
|
||||
assert "(no data)" in buf.getvalue()
|
||||
|
||||
def test_format_size(self):
|
||||
from cli_anything.unrealinsights.utils.output import format_size
|
||||
|
||||
assert format_size(10) == "10 B"
|
||||
assert "KB" in format_size(4096)
|
||||
|
||||
|
||||
class TestErrorUtils:
|
||||
def test_handle_error(self):
|
||||
from cli_anything.unrealinsights.utils.errors import handle_error
|
||||
|
||||
result = handle_error(ValueError("bad"))
|
||||
assert result["error"] == "bad"
|
||||
assert result["type"] == "ValueError"
|
||||
|
||||
|
||||
def _make_fake_binary(root: Path, binary_name: str) -> Path:
|
||||
target = root / "UE_5.5" / "Engine" / "Binaries" / "Win64" / binary_name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("fake-binary", encoding="utf-8")
|
||||
return target
|
||||
|
||||
|
||||
class TestBackendDiscovery:
|
||||
@patch("cli_anything.unrealinsights.utils.unrealinsights_backend._read_windows_product_version", return_value="5.5.4")
|
||||
def test_explicit_path_precedence(self, _mock_version, tmp_path, monkeypatch):
|
||||
from cli_anything.unrealinsights.utils.unrealinsights_backend import resolve_unrealinsights_exe
|
||||
|
||||
explicit = tmp_path / "explicit" / "UnrealInsights.exe"
|
||||
explicit.parent.mkdir(parents=True)
|
||||
explicit.write_text("x", encoding="utf-8")
|
||||
|
||||
env_binary = tmp_path / "env" / "UnrealInsights.exe"
|
||||
env_binary.parent.mkdir(parents=True)
|
||||
env_binary.write_text("x", encoding="utf-8")
|
||||
monkeypatch.setenv("UNREALINSIGHTS_EXE", str(env_binary))
|
||||
|
||||
auto_root = tmp_path / "Epic Games"
|
||||
_make_fake_binary(auto_root, "UnrealInsights.exe")
|
||||
|
||||
result = resolve_unrealinsights_exe(str(explicit), search_roots=[auto_root])
|
||||
assert result["source"] == "explicit"
|
||||
assert result["path"] == str(explicit.resolve())
|
||||
|
||||
@patch("cli_anything.unrealinsights.utils.unrealinsights_backend._read_windows_product_version", return_value="5.5.4")
|
||||
def test_env_path_precedence(self, _mock_version, tmp_path, monkeypatch):
|
||||
from cli_anything.unrealinsights.utils.unrealinsights_backend import resolve_unrealinsights_exe
|
||||
|
||||
env_binary = tmp_path / "env" / "UnrealInsights.exe"
|
||||
env_binary.parent.mkdir(parents=True)
|
||||
env_binary.write_text("x", encoding="utf-8")
|
||||
monkeypatch.setenv("UNREALINSIGHTS_EXE", str(env_binary))
|
||||
|
||||
auto_root = tmp_path / "Epic Games"
|
||||
_make_fake_binary(auto_root, "UnrealInsights.exe")
|
||||
|
||||
result = resolve_unrealinsights_exe(search_roots=[auto_root])
|
||||
assert result["source"] == "env:UNREALINSIGHTS_EXE"
|
||||
assert result["path"] == str(env_binary.resolve())
|
||||
|
||||
@patch("cli_anything.unrealinsights.utils.unrealinsights_backend._read_windows_product_version", return_value="5.5.4")
|
||||
def test_auto_discovery(self, _mock_version, tmp_path, monkeypatch):
|
||||
from cli_anything.unrealinsights.utils.unrealinsights_backend import resolve_unrealinsights_exe
|
||||
|
||||
monkeypatch.delenv("UNREALINSIGHTS_EXE", raising=False)
|
||||
auto_root = tmp_path / "Epic Games"
|
||||
auto_binary = _make_fake_binary(auto_root, "UnrealInsights.exe")
|
||||
|
||||
result = resolve_unrealinsights_exe(search_roots=[auto_root])
|
||||
assert result["source"].startswith("auto:")
|
||||
assert result["path"] == str(auto_binary.resolve())
|
||||
|
||||
def test_missing_explicit_path_fails(self, tmp_path):
|
||||
from cli_anything.unrealinsights.utils.unrealinsights_backend import resolve_unrealinsights_exe
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
resolve_unrealinsights_exe(str(tmp_path / "missing.exe"))
|
||||
|
||||
def test_build_insights_command(self, tmp_path):
|
||||
from cli_anything.unrealinsights.utils.unrealinsights_backend import build_insights_command
|
||||
|
||||
command = build_insights_command(
|
||||
str(tmp_path / "UnrealInsights.exe"),
|
||||
str(tmp_path / "trace.utrace"),
|
||||
'TimingInsights.ExportThreads "D:\\out\\threads.csv"',
|
||||
str(tmp_path / "threads.log"),
|
||||
)
|
||||
assert any(part.startswith("-OpenTraceFile=") for part in command)
|
||||
assert any(part.startswith("-ExecOnAnalysisCompleteCmd=") for part in command)
|
||||
|
||||
def test_build_insights_command_line(self, tmp_path):
|
||||
from cli_anything.unrealinsights.utils.unrealinsights_backend import build_insights_command_line
|
||||
|
||||
command = build_insights_command_line(
|
||||
str(tmp_path / "UnrealInsights.exe"),
|
||||
str(tmp_path / "trace.utrace"),
|
||||
'TimingInsights.ExportThreads D:\\out\\threads.csv',
|
||||
str(tmp_path / "threads.log"),
|
||||
)
|
||||
assert "-ExecOnAnalysisCompleteCmd=" in command
|
||||
assert command.startswith('"')
|
||||
|
||||
@patch("cli_anything.unrealinsights.utils.unrealinsights_backend._read_windows_product_version", return_value="5.3.0")
|
||||
def test_resolve_binary_from_engine_root(self, _mock_version, tmp_path):
|
||||
from cli_anything.unrealinsights.utils.unrealinsights_backend import resolve_binary_from_engine_root
|
||||
|
||||
binary = _make_fake_binary(tmp_path, "UnrealInsights.exe")
|
||||
result = resolve_binary_from_engine_root("UnrealInsights.exe", str(tmp_path / "UE_5.5"))
|
||||
assert result["path"] == str(binary.resolve())
|
||||
assert result["source"] == "engine:UE_5.5"
|
||||
|
||||
@patch("cli_anything.unrealinsights.utils.unrealinsights_backend.subprocess.run")
|
||||
def test_build_engine_program(self, mock_run, tmp_path):
|
||||
from cli_anything.unrealinsights.utils.unrealinsights_backend import build_engine_program
|
||||
|
||||
build_bat = tmp_path / "UE_5.5" / "Engine" / "Build" / "BatchFiles" / "Build.bat"
|
||||
build_bat.parent.mkdir(parents=True, exist_ok=True)
|
||||
build_bat.write_text("echo build", encoding="utf-8")
|
||||
|
||||
mock_run.return_value = type("Result", (), {"stdout": "ok", "stderr": "", "returncode": 0})()
|
||||
result = build_engine_program(str(tmp_path / "UE_5.5"), "UnrealInsights")
|
||||
assert result["succeeded"] is True
|
||||
assert Path(result["log_path"]).is_file()
|
||||
|
||||
@patch("cli_anything.unrealinsights.utils.unrealinsights_backend._read_windows_product_version", return_value="5.3.0")
|
||||
@patch("cli_anything.unrealinsights.utils.unrealinsights_backend.build_engine_program")
|
||||
def test_ensure_engine_unrealinsights_builds_when_missing(self, mock_build, _mock_version, tmp_path):
|
||||
from cli_anything.unrealinsights.utils.unrealinsights_backend import ensure_engine_unrealinsights
|
||||
|
||||
engine_root = tmp_path / "UE_5.5"
|
||||
(engine_root / "Engine" / "Binaries" / "Win64").mkdir(parents=True, exist_ok=True)
|
||||
(engine_root / "Engine" / "Build" / "BatchFiles").mkdir(parents=True, exist_ok=True)
|
||||
(engine_root / "Engine" / "Build" / "BatchFiles" / "Build.bat").write_text("echo build", encoding="utf-8")
|
||||
built_exe = engine_root / "Engine" / "Binaries" / "Win64" / "UnrealInsights.exe"
|
||||
built_exe.write_text("x", encoding="utf-8")
|
||||
mock_build.return_value = {
|
||||
"command": ["Build.bat"],
|
||||
"cwd": str(engine_root),
|
||||
"log_path": str(engine_root / "build.log"),
|
||||
"exit_code": 0,
|
||||
"timed_out": False,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"succeeded": True,
|
||||
}
|
||||
|
||||
result = ensure_engine_unrealinsights(str(engine_root))
|
||||
assert result["insights"]["path"] == str(built_exe.resolve())
|
||||
|
||||
def test_ensure_engine_unrealinsights_no_build_errors_when_missing(self, tmp_path):
|
||||
from cli_anything.unrealinsights.utils.unrealinsights_backend import ensure_engine_unrealinsights
|
||||
|
||||
engine_root = tmp_path / "UE_5.5"
|
||||
(engine_root / "Engine" / "Binaries" / "Win64").mkdir(parents=True, exist_ok=True)
|
||||
with pytest.raises(RuntimeError):
|
||||
ensure_engine_unrealinsights(str(engine_root), build_if_missing=False)
|
||||
|
||||
|
||||
class TestCaptureCore:
|
||||
def test_normalize_trace_output_path_prefers_explicit(self, tmp_path):
|
||||
from cli_anything.unrealinsights.core.capture import normalize_trace_output_path
|
||||
|
||||
path = normalize_trace_output_path("game.exe", output_trace=str(tmp_path / "capture"))
|
||||
assert path.endswith(".utrace")
|
||||
|
||||
def test_build_exec_cmds_arg(self):
|
||||
from cli_anything.unrealinsights.core.capture import build_exec_cmds_arg
|
||||
|
||||
assert build_exec_cmds_arg(["Trace.Bookmark Boot", "Trace.RegionBegin Boot"]) == (
|
||||
"Trace.Bookmark Boot,Trace.RegionBegin Boot"
|
||||
)
|
||||
|
||||
def test_resolve_engine_root_from_engine_subdir(self, tmp_path):
|
||||
from cli_anything.unrealinsights.core.capture import resolve_engine_root
|
||||
|
||||
engine_dir = tmp_path / "UE_5.5" / "Engine"
|
||||
engine_dir.mkdir(parents=True)
|
||||
assert resolve_engine_root(str(engine_dir)) == str((tmp_path / "UE_5.5").resolve())
|
||||
|
||||
def test_resolve_editor_target(self, tmp_path):
|
||||
from cli_anything.unrealinsights.core.capture import resolve_editor_target
|
||||
|
||||
editor = tmp_path / "UE_5.5" / "Engine" / "Binaries" / "Win64" / "UnrealEditor.exe"
|
||||
editor.parent.mkdir(parents=True)
|
||||
editor.write_text("x", encoding="utf-8")
|
||||
assert resolve_editor_target(str(tmp_path / "UE_5.5")) == str(editor.resolve())
|
||||
|
||||
def test_resolve_capture_target_from_project_and_engine(self, tmp_path):
|
||||
from cli_anything.unrealinsights.core.capture import resolve_capture_target
|
||||
|
||||
editor = tmp_path / "UE_5.5" / "Engine" / "Binaries" / "Win64" / "UnrealEditor.exe"
|
||||
editor.parent.mkdir(parents=True)
|
||||
editor.write_text("x", encoding="utf-8")
|
||||
project = tmp_path / "Project" / "MyGame.uproject"
|
||||
project.parent.mkdir(parents=True)
|
||||
project.write_text("{}", encoding="utf-8")
|
||||
|
||||
target_exe, target_args, launch_info = resolve_capture_target(
|
||||
None,
|
||||
project=str(project),
|
||||
engine_root=str(tmp_path / "UE_5.5"),
|
||||
target_args=["-game"],
|
||||
)
|
||||
assert target_exe == str(editor.resolve())
|
||||
assert target_args[0] == str(project.resolve())
|
||||
assert "-game" in target_args
|
||||
assert launch_info["project_path"] == str(project.resolve())
|
||||
|
||||
def test_build_capture_command(self, tmp_path):
|
||||
from cli_anything.unrealinsights.core.capture import build_capture_command
|
||||
|
||||
exe = tmp_path / "Game.exe"
|
||||
exe.write_text("x", encoding="utf-8")
|
||||
trace = tmp_path / "capture.utrace"
|
||||
command = build_capture_command(
|
||||
str(exe),
|
||||
str(trace),
|
||||
channels="default,bookmark",
|
||||
exec_cmds=["Trace.Bookmark Boot"],
|
||||
target_args=["MyGame.uproject", "-game"],
|
||||
)
|
||||
assert command[0] == str(exe.resolve())
|
||||
assert "MyGame.uproject" in command
|
||||
assert "-trace=default,bookmark" in command
|
||||
assert any(arg.startswith("-tracefile=") for arg in command)
|
||||
assert any(arg.startswith("-ExecCmds=") for arg in command)
|
||||
|
||||
@patch("cli_anything.unrealinsights.core.capture.backend.run_process")
|
||||
def test_run_capture_wait_requires_clean_exit(self, mock_run_process, tmp_path):
|
||||
from cli_anything.unrealinsights.core.capture import run_capture
|
||||
|
||||
exe = tmp_path / "Game.exe"
|
||||
exe.write_text("x", encoding="utf-8")
|
||||
trace = tmp_path / "capture.utrace"
|
||||
trace.write_text("partial-trace", encoding="utf-8")
|
||||
|
||||
mock_run_process.return_value = {
|
||||
"command": [str(exe.resolve())],
|
||||
"waited": True,
|
||||
"timed_out": False,
|
||||
"exit_code": 1,
|
||||
"stdout": "",
|
||||
"stderr": "boom",
|
||||
"pid": None,
|
||||
}
|
||||
|
||||
result = run_capture(str(exe), str(trace), wait=True)
|
||||
assert result["trace_exists"] is True
|
||||
assert result["succeeded"] is False
|
||||
|
||||
def test_capture_status(self):
|
||||
from cli_anything.unrealinsights.core.capture import capture_status
|
||||
from cli_anything.unrealinsights.core.session import UnrealInsightsSession
|
||||
|
||||
session = UnrealInsightsSession()
|
||||
session.set_capture(
|
||||
pid=1234,
|
||||
target_exe="C:/UE/UnrealEditor.exe",
|
||||
target_args=["Project.uproject"],
|
||||
trace_path="C:/trace.utrace",
|
||||
channels="default",
|
||||
)
|
||||
with patch("cli_anything.unrealinsights.core.capture.backend.is_process_running", return_value=True):
|
||||
data = capture_status(session)
|
||||
assert data["active"] is True
|
||||
assert data["running"] is True
|
||||
|
||||
def test_snapshot_capture(self, tmp_path):
|
||||
from cli_anything.unrealinsights.core.capture import snapshot_capture
|
||||
from cli_anything.unrealinsights.core.session import UnrealInsightsSession
|
||||
|
||||
trace = tmp_path / "live.utrace"
|
||||
trace.write_text("trace-data", encoding="utf-8")
|
||||
session = UnrealInsightsSession()
|
||||
session.set_capture(
|
||||
pid=4321,
|
||||
target_exe="C:/UE/UnrealEditor.exe",
|
||||
target_args=[],
|
||||
trace_path=str(trace),
|
||||
channels="default",
|
||||
)
|
||||
with patch("cli_anything.unrealinsights.core.capture.backend.is_process_running", return_value=True):
|
||||
result = snapshot_capture(session)
|
||||
assert Path(result["snapshot_trace"]).is_file()
|
||||
|
||||
def test_stop_capture(self):
|
||||
from cli_anything.unrealinsights.core.capture import stop_capture
|
||||
from cli_anything.unrealinsights.core.session import UnrealInsightsSession
|
||||
|
||||
session = UnrealInsightsSession()
|
||||
session.set_capture(
|
||||
pid=9876,
|
||||
target_exe="C:/UE/UnrealEditor.exe",
|
||||
target_args=[],
|
||||
trace_path="C:/trace.utrace",
|
||||
channels="default",
|
||||
)
|
||||
with patch("cli_anything.unrealinsights.core.capture.backend.terminate_process", return_value={"requested_pid": 9876, "stopped": True, "exit_code": 0}), \
|
||||
patch("cli_anything.unrealinsights.core.capture.backend.is_process_running", return_value=False):
|
||||
result = stop_capture(session)
|
||||
assert result["termination"]["stopped"] is True
|
||||
|
||||
|
||||
class TestExportCore:
|
||||
@pytest.mark.parametrize(
|
||||
("exporter", "expected"),
|
||||
[
|
||||
("threads", "TimingInsights.ExportThreads"),
|
||||
("timers", "TimingInsights.ExportTimers"),
|
||||
("timing-events", "TimingInsights.ExportTimingEvents"),
|
||||
("timer-stats", "TimingInsights.ExportTimerStatistics"),
|
||||
("timer-callees", "TimingInsights.ExportTimerCallees"),
|
||||
("counters", "TimingInsights.ExportCounters"),
|
||||
("counter-values", "TimingInsights.ExportCounterValues"),
|
||||
],
|
||||
)
|
||||
def test_build_export_exec_command(self, exporter, expected, tmp_path):
|
||||
from cli_anything.unrealinsights.core.export import build_export_exec_command
|
||||
|
||||
command = build_export_exec_command(
|
||||
exporter,
|
||||
str(tmp_path / f"{exporter}.csv"),
|
||||
columns="ThreadId,TimerId" if exporter in ("timing-events", "timer-stats", "counter-values") else None,
|
||||
threads="GameThread" if exporter in ("timing-events", "timer-stats", "timer-callees") else None,
|
||||
timers="*" if exporter in ("timing-events", "timer-stats", "timer-callees") else None,
|
||||
counter="*" if exporter == "counter-values" else None,
|
||||
)
|
||||
assert command.startswith(expected)
|
||||
|
||||
def test_build_rsp_exec_command(self, tmp_path):
|
||||
from cli_anything.unrealinsights.core.export import build_rsp_exec_command
|
||||
|
||||
command = build_rsp_exec_command(str(tmp_path / "exports.rsp"))
|
||||
assert command.startswith("@=")
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows quoting behavior")
|
||||
def test_normalize_rsp_line_modern_windows_avoids_nested_quotes(self, tmp_path):
|
||||
from cli_anything.unrealinsights.core.export import _normalize_rsp_line
|
||||
|
||||
line, output = _normalize_rsp_line(
|
||||
f'TimingInsights.ExportThreads "{tmp_path / "threads.csv"}"',
|
||||
insights_version="5.5.4",
|
||||
)
|
||||
resolved = str((tmp_path / "threads.csv").resolve())
|
||||
assert f'"{resolved}"' not in line
|
||||
assert resolved in line
|
||||
assert output == resolved
|
||||
|
||||
def test_build_export_exec_command_legacy_53_unquoted_filename(self, tmp_path):
|
||||
from cli_anything.unrealinsights.core.export import build_export_exec_command
|
||||
|
||||
command = build_export_exec_command(
|
||||
"threads",
|
||||
str(tmp_path / "threads.csv"),
|
||||
insights_version="5.3.0",
|
||||
)
|
||||
assert '"{}"'.format(str((tmp_path / "threads.csv").resolve())) not in command
|
||||
assert str((tmp_path / "threads.csv").resolve()) in command
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows quoting behavior")
|
||||
def test_build_export_exec_command_modern_windows_avoids_nested_quotes(self, tmp_path):
|
||||
from cli_anything.unrealinsights.core.export import build_export_exec_command
|
||||
|
||||
command = build_export_exec_command(
|
||||
"threads",
|
||||
str(tmp_path / "threads.csv"),
|
||||
insights_version="5.5.4",
|
||||
)
|
||||
resolved = str((tmp_path / "threads.csv").resolve())
|
||||
assert f'"{resolved}"' not in command
|
||||
assert resolved in command
|
||||
|
||||
def test_expected_outputs_from_rsp(self, tmp_path):
|
||||
from cli_anything.unrealinsights.core.export import expected_outputs_from_rsp
|
||||
|
||||
rsp = tmp_path / "exports.rsp"
|
||||
rsp.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# comment",
|
||||
f'TimingInsights.ExportThreads "{tmp_path / "threads.csv"}"',
|
||||
f'TimingInsights.ExportTimers "{tmp_path / "timers.csv"}"',
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
outputs = expected_outputs_from_rsp(str(rsp))
|
||||
assert str((tmp_path / "threads.csv").resolve()) in outputs
|
||||
assert str((tmp_path / "timers.csv").resolve()) in outputs
|
||||
|
||||
def test_collect_materialized_outputs_placeholder(self, tmp_path):
|
||||
from cli_anything.unrealinsights.core.export import collect_materialized_outputs
|
||||
|
||||
(tmp_path / "stats_GameThread.csv").write_text("ok", encoding="utf-8")
|
||||
outputs = collect_materialized_outputs(str(tmp_path / "stats_{region}.csv"))
|
||||
assert str((tmp_path / "stats_GameThread.csv").resolve()) in outputs
|
||||
|
||||
|
||||
class TestCLIHelp:
|
||||
def test_main_help(self):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "Unreal Insights harness" in result.output
|
||||
|
||||
def test_group_help(self):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
|
||||
runner = CliRunner()
|
||||
for group in ("backend", "trace", "capture", "export", "batch"):
|
||||
result = runner.invoke(cli, [group, "--help"])
|
||||
assert result.exit_code == 0, f"{group} help failed"
|
||||
|
||||
|
||||
class TestCLIJsonErrors:
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.resolve_unrealinsights_exe")
|
||||
def test_export_threads_requires_trace(self, _mock_resolve):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--json", "export", "threads", "out.csv"])
|
||||
assert result.exit_code == 1
|
||||
data = json.loads(result.output)
|
||||
assert "error" in data
|
||||
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.resolve_unrealinsights_exe")
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.resolve_trace_server_exe")
|
||||
def test_backend_info_json(self, mock_trace_server, mock_insights):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
|
||||
mock_insights.return_value = {
|
||||
"available": True,
|
||||
"path": "C:/UE/UnrealInsights.exe",
|
||||
"source": "explicit",
|
||||
"version": "5.5.4",
|
||||
"engine_version_hint": "5.5",
|
||||
}
|
||||
mock_trace_server.return_value = {
|
||||
"available": False,
|
||||
"path": None,
|
||||
"source": "unresolved",
|
||||
"version": None,
|
||||
"engine_version_hint": None,
|
||||
"error": "missing",
|
||||
}
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--json", "backend", "info"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["insights"]["path"].endswith("UnrealInsights.exe")
|
||||
|
||||
def test_capture_project_requires_engine_root(self, tmp_path):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
|
||||
project = tmp_path / "MyGame.uproject"
|
||||
project.write_text("{}", encoding="utf-8")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--json", "capture", "run", "--project", str(project)])
|
||||
assert result.exit_code == 1
|
||||
data = json.loads(result.output)
|
||||
assert "engine-root" in data["error"]
|
||||
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.ensure_engine_unrealinsights")
|
||||
def test_backend_ensure_insights_json(self, mock_ensure):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
|
||||
mock_ensure.return_value = {
|
||||
"engine_root": "D:/UE_5.3",
|
||||
"insights": {
|
||||
"available": True,
|
||||
"path": "D:/UE_5.3/Engine/Binaries/Win64/UnrealInsights.exe",
|
||||
"source": "engine:UE_5.3",
|
||||
"version": "5.3.0",
|
||||
"engine_version_hint": None,
|
||||
},
|
||||
"trace_server": {
|
||||
"available": False,
|
||||
"path": None,
|
||||
"source": "unresolved",
|
||||
"version": None,
|
||||
"engine_version_hint": None,
|
||||
"error": "missing",
|
||||
},
|
||||
"build_attempted": False,
|
||||
"build": None,
|
||||
}
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--json", "backend", "ensure-insights", "--engine-root", "D:/UE_5.3"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["insights"]["path"].endswith("UnrealInsights.exe")
|
||||
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.capture_status")
|
||||
def test_capture_status_json(self, mock_capture_status):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
|
||||
mock_capture_status.return_value = {
|
||||
"active": True,
|
||||
"pid": 1234,
|
||||
"running": True,
|
||||
"target_exe": "C:/UE/UnrealEditor.exe",
|
||||
"target_args": [],
|
||||
"project_path": "C:/Project.uproject",
|
||||
"engine_root": "C:/UE_5.3",
|
||||
"trace_path": "C:/trace.utrace",
|
||||
"trace_exists": True,
|
||||
"trace_size": 1024,
|
||||
"channels": "default",
|
||||
"started_at": "2026-04-16T00:00:00+00:00",
|
||||
}
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--json", "capture", "status"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["running"] is True
|
||||
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.stop_capture")
|
||||
def test_capture_stop_json(self, mock_stop_capture):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
|
||||
mock_stop_capture.return_value = {
|
||||
"termination": {"requested_pid": 1234, "stopped": True, "exit_code": 0},
|
||||
"capture": {"active": False},
|
||||
}
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--json", "capture", "stop"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["termination"]["stopped"] is True
|
||||
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.snapshot_capture")
|
||||
def test_capture_snapshot_json(self, mock_snapshot_capture):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
|
||||
mock_snapshot_capture.return_value = {
|
||||
"source_trace": "C:/trace.utrace",
|
||||
"snapshot_trace": "C:/trace-snapshot.utrace",
|
||||
"snapshot_exists": True,
|
||||
"snapshot_size": 2048,
|
||||
"capture_running": True,
|
||||
}
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--json", "capture", "snapshot"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["snapshot_exists"] is True
|
||||
|
||||
|
||||
class TestREPLSessionState:
|
||||
def test_trace_set_then_info_in_repl(self):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
|
||||
with patch(
|
||||
"cli_anything.unrealinsights.utils.repl_skin.ReplSkin.create_prompt_session",
|
||||
return_value=None,
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
input="trace set sample.utrace\ntrace info\nquit\n",
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "sample.utrace" in result.output
|
||||
|
||||
|
||||
class TestCaptureCLIConvenience:
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.run_capture")
|
||||
def test_capture_run_with_project_and_engine_root(self, mock_run_capture, tmp_path):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
|
||||
editor = tmp_path / "UE_5.5" / "Engine" / "Binaries" / "Win64" / "UnrealEditor.exe"
|
||||
editor.parent.mkdir(parents=True)
|
||||
editor.write_text("x", encoding="utf-8")
|
||||
project = tmp_path / "Project" / "MyGame.uproject"
|
||||
project.parent.mkdir(parents=True)
|
||||
project.write_text("{}", encoding="utf-8")
|
||||
|
||||
mock_run_capture.return_value = {
|
||||
"command": [str(editor.resolve()), str(project.resolve()), "-trace=default"],
|
||||
"waited": True,
|
||||
"timed_out": False,
|
||||
"exit_code": 0,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"pid": None,
|
||||
"target_exe": str(editor.resolve()),
|
||||
"target_args": [str(project.resolve())],
|
||||
"trace_path": str((tmp_path / "capture.utrace").resolve()),
|
||||
"channels": "default",
|
||||
"trace_exists": True,
|
||||
"trace_size": 10,
|
||||
"succeeded": True,
|
||||
}
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--json",
|
||||
"capture",
|
||||
"run",
|
||||
"--project",
|
||||
str(project),
|
||||
"--engine-root",
|
||||
str(tmp_path / "UE_5.5"),
|
||||
"--output-trace",
|
||||
str(tmp_path / "capture.utrace"),
|
||||
"--wait",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_run_capture.assert_called_once()
|
||||
_, kwargs = mock_run_capture.call_args
|
||||
assert kwargs["target_args"][0] == str(project.resolve())
|
||||
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.run_capture")
|
||||
def test_capture_start_persists_background_session(self, mock_run_capture, tmp_path):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
from cli_anything.unrealinsights.core.session import UnrealInsightsSession
|
||||
|
||||
editor = tmp_path / "UE_5.5" / "Engine" / "Binaries" / "Win64" / "UnrealEditor.exe"
|
||||
editor.parent.mkdir(parents=True)
|
||||
editor.write_text("x", encoding="utf-8")
|
||||
project = tmp_path / "Project" / "MyGame.uproject"
|
||||
project.parent.mkdir(parents=True)
|
||||
project.write_text("{}", encoding="utf-8")
|
||||
|
||||
mock_run_capture.return_value = {
|
||||
"command": [str(editor.resolve()), str(project.resolve()), "-trace=default"],
|
||||
"waited": False,
|
||||
"timed_out": False,
|
||||
"exit_code": None,
|
||||
"stdout": None,
|
||||
"stderr": None,
|
||||
"pid": 2468,
|
||||
"target_exe": str(editor.resolve()),
|
||||
"target_args": [str(project.resolve())],
|
||||
"trace_path": str((tmp_path / "capture.utrace").resolve()),
|
||||
"channels": "default",
|
||||
"trace_exists": False,
|
||||
"trace_size": None,
|
||||
"succeeded": True,
|
||||
}
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--json",
|
||||
"capture",
|
||||
"start",
|
||||
"--project",
|
||||
str(project),
|
||||
"--engine-root",
|
||||
str(tmp_path / "UE_5.5"),
|
||||
"--output-trace",
|
||||
str(tmp_path / "capture.utrace"),
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
session = UnrealInsightsSession.load()
|
||||
assert session.capture_pid == 2468
|
||||
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.capture_status")
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.run_capture")
|
||||
def test_capture_start_refuses_running_session_without_replace(self, mock_run_capture, mock_capture_status):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
|
||||
mock_capture_status.return_value = {
|
||||
"active": True,
|
||||
"pid": 1357,
|
||||
"running": True,
|
||||
"target_exe": "C:/UE/UnrealEditor.exe",
|
||||
"target_args": [],
|
||||
"project_path": "C:/Project.uproject",
|
||||
"engine_root": "C:/UE_5.5",
|
||||
"trace_path": "C:/capture.utrace",
|
||||
"trace_exists": True,
|
||||
"trace_size": 1024,
|
||||
"channels": "default",
|
||||
"started_at": "2026-04-16T00:00:00+00:00",
|
||||
}
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--json",
|
||||
"capture",
|
||||
"start",
|
||||
"--project",
|
||||
"C:/Project.uproject",
|
||||
"--engine-root",
|
||||
"C:/UE_5.5",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
data = json.loads(result.output)
|
||||
assert "--replace" in data["error"]
|
||||
mock_run_capture.assert_not_called()
|
||||
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.stop_capture")
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.capture_status")
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.run_capture")
|
||||
def test_capture_start_replace_stops_existing_session(self, mock_run_capture, mock_capture_status, mock_stop_capture, tmp_path):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
from cli_anything.unrealinsights.core.session import UnrealInsightsSession
|
||||
|
||||
editor = tmp_path / "UE_5.5" / "Engine" / "Binaries" / "Win64" / "UnrealEditor.exe"
|
||||
editor.parent.mkdir(parents=True)
|
||||
editor.write_text("x", encoding="utf-8")
|
||||
project = tmp_path / "Project" / "MyGame.uproject"
|
||||
project.parent.mkdir(parents=True)
|
||||
project.write_text("{}", encoding="utf-8")
|
||||
|
||||
mock_capture_status.return_value = {
|
||||
"active": True,
|
||||
"pid": 1357,
|
||||
"running": True,
|
||||
"target_exe": str(editor.resolve()),
|
||||
"target_args": [str(project.resolve())],
|
||||
"project_path": str(project.resolve()),
|
||||
"engine_root": str((tmp_path / "UE_5.5").resolve()),
|
||||
"trace_path": str((tmp_path / "previous.utrace").resolve()),
|
||||
"trace_exists": True,
|
||||
"trace_size": 1024,
|
||||
"channels": "default",
|
||||
"started_at": "2026-04-16T00:00:00+00:00",
|
||||
}
|
||||
mock_stop_capture.return_value = {
|
||||
"termination": {"requested_pid": 1357, "stopped": True, "exit_code": 0},
|
||||
"capture": {"active": False},
|
||||
}
|
||||
mock_run_capture.return_value = {
|
||||
"command": [str(editor.resolve()), str(project.resolve()), "-trace=default"],
|
||||
"waited": False,
|
||||
"timed_out": False,
|
||||
"exit_code": None,
|
||||
"stdout": None,
|
||||
"stderr": None,
|
||||
"pid": 2468,
|
||||
"target_exe": str(editor.resolve()),
|
||||
"target_args": [str(project.resolve())],
|
||||
"trace_path": str((tmp_path / "capture.utrace").resolve()),
|
||||
"channels": "default",
|
||||
"trace_exists": False,
|
||||
"trace_size": None,
|
||||
"succeeded": True,
|
||||
}
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--json",
|
||||
"capture",
|
||||
"start",
|
||||
"--replace",
|
||||
"--project",
|
||||
str(project),
|
||||
"--engine-root",
|
||||
str(tmp_path / "UE_5.5"),
|
||||
"--output-trace",
|
||||
str(tmp_path / "capture.utrace"),
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_stop_capture.assert_called_once()
|
||||
session = UnrealInsightsSession.load()
|
||||
assert session.capture_pid == 2468
|
||||
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
End-to-end tests for the Unreal Insights harness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cli_anything.unrealinsights.utils.unrealinsights_backend import resolve_unrealinsights_exe
|
||||
|
||||
HARNESS_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
TEST_TRACE = os.environ.get("UNREALINSIGHTS_TEST_TRACE", "")
|
||||
TEST_TARGET_EXE = os.environ.get("UNREALINSIGHTS_TEST_TARGET_EXE", "")
|
||||
|
||||
|
||||
def _has_local_insights() -> bool:
|
||||
try:
|
||||
return bool(resolve_unrealinsights_exe(required=False).get("available"))
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
|
||||
HAS_TRACE = os.path.isfile(TEST_TRACE) if TEST_TRACE else False
|
||||
HAS_TARGET = os.path.isfile(TEST_TARGET_EXE) if TEST_TARGET_EXE else False
|
||||
HAS_LOCAL_INSIGHTS = _has_local_insights()
|
||||
|
||||
skip_no_trace = pytest.mark.skipif(not HAS_TRACE, reason="UNREALINSIGHTS_TEST_TRACE not set or missing")
|
||||
skip_no_target = pytest.mark.skipif(not HAS_TARGET, reason="UNREALINSIGHTS_TEST_TARGET_EXE not set or missing")
|
||||
skip_no_local_ue = pytest.mark.skipif(not HAS_LOCAL_INSIGHTS, reason="No local Unreal Insights install detected")
|
||||
|
||||
|
||||
def _resolve_cli(name: str):
|
||||
"""Resolve installed CLI command; falls back to python -m for dev."""
|
||||
import shutil
|
||||
|
||||
force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
print(f"[_resolve_cli] Using installed command: {path}")
|
||||
return [path]
|
||||
if force:
|
||||
raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
|
||||
print("[_resolve_cli] Falling back to module invocation")
|
||||
return [sys.executable, "-m", "cli_anything.unrealinsights.unrealinsights_cli"]
|
||||
|
||||
|
||||
def _cli_env():
|
||||
env = os.environ.copy()
|
||||
pythonpath = env.get("PYTHONPATH", "")
|
||||
env["PYTHONPATH"] = HARNESS_ROOT if not pythonpath else f"{HARNESS_ROOT}{os.pathsep}{pythonpath}"
|
||||
env["CLI_ANYTHING_UNREALINSIGHTS_STATE_DIR"] = os.path.join(HARNESS_ROOT, ".tmp_state")
|
||||
return env
|
||||
|
||||
|
||||
class TestCLISmoke:
|
||||
CLI_BASE = _resolve_cli("cli-anything-unrealinsights")
|
||||
|
||||
def _run(self, args, check=True, timeout=180):
|
||||
return subprocess.run(
|
||||
self.CLI_BASE + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check,
|
||||
timeout=timeout,
|
||||
env=_cli_env(),
|
||||
)
|
||||
|
||||
@skip_no_local_ue
|
||||
def test_backend_info(self):
|
||||
result = self._run(["--json", "backend", "info"])
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert data["insights"]["available"] is True
|
||||
assert data["insights"]["path"].lower().endswith("unrealinsights.exe")
|
||||
|
||||
|
||||
@skip_no_trace
|
||||
class TestExportE2E:
|
||||
CLI_BASE = _resolve_cli("cli-anything-unrealinsights")
|
||||
|
||||
def _run(self, args, check=True, timeout=180):
|
||||
return subprocess.run(
|
||||
self.CLI_BASE + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check,
|
||||
timeout=timeout,
|
||||
env=_cli_env(),
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("subcommand", "extra_args"),
|
||||
[
|
||||
("threads", []),
|
||||
("timers", []),
|
||||
("timing-events", ["--threads", "GameThread", "--timers", "*"]),
|
||||
("timer-stats", ["--threads", "GameThread", "--timers", "*"]),
|
||||
("timer-callees", ["--threads", "GameThread", "--timers", "*"]),
|
||||
("counters", []),
|
||||
("counter-values", ["--counter", "*"]),
|
||||
],
|
||||
)
|
||||
def test_exporter_creates_output(self, subcommand, extra_args):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = os.path.join(tmpdir, f"{subcommand}.csv")
|
||||
result = self._run(
|
||||
["--json", "-t", TEST_TRACE, "export", subcommand, output, *extra_args],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip(f"{subcommand} exporter failed for supplied trace")
|
||||
data = json.loads(result.stdout)
|
||||
assert data["output_files"]
|
||||
for path in data["output_files"]:
|
||||
assert os.path.isfile(path)
|
||||
assert os.path.getsize(path) > 0
|
||||
|
||||
def test_batch_run_rsp(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
threads = os.path.join(tmpdir, "threads.csv")
|
||||
timers = os.path.join(tmpdir, "timers.csv")
|
||||
rsp = os.path.join(tmpdir, "exports.rsp")
|
||||
Path(rsp).write_text(
|
||||
"\n".join(
|
||||
[
|
||||
f'TimingInsights.ExportThreads "{threads}"',
|
||||
f'TimingInsights.ExportTimers "{timers}"',
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = self._run(["--json", "-t", TEST_TRACE, "batch", "run-rsp", rsp], check=False)
|
||||
if result.returncode != 0:
|
||||
pytest.skip("response-file execution failed for supplied trace")
|
||||
data = json.loads(result.stdout)
|
||||
assert len(data["output_files"]) >= 2
|
||||
for path in data["output_files"]:
|
||||
assert os.path.isfile(path)
|
||||
assert os.path.getsize(path) > 0
|
||||
|
||||
|
||||
@skip_no_target
|
||||
class TestCaptureE2E:
|
||||
CLI_BASE = _resolve_cli("cli-anything-unrealinsights")
|
||||
|
||||
def _run(self, args, check=True, timeout=300):
|
||||
return subprocess.run(
|
||||
self.CLI_BASE + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check,
|
||||
timeout=timeout,
|
||||
env=_cli_env(),
|
||||
)
|
||||
|
||||
def test_capture_run_wait(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output_trace = os.path.join(tmpdir, "capture.utrace")
|
||||
result = self._run(
|
||||
[
|
||||
"--json",
|
||||
"capture",
|
||||
"run",
|
||||
TEST_TARGET_EXE,
|
||||
"--output-trace",
|
||||
output_trace,
|
||||
"--wait",
|
||||
"--timeout",
|
||||
"180",
|
||||
],
|
||||
check=False,
|
||||
timeout=360,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip("capture run failed for supplied target executable")
|
||||
data = json.loads(result.stdout)
|
||||
assert data["trace_path"].lower().endswith(".utrace")
|
||||
assert data["trace_exists"] is True
|
||||
assert data["trace_size"] > 0
|
||||
@@ -0,0 +1,725 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unreal Insights CLI - trace capture and export harness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from cli_anything.unrealinsights import __version__
|
||||
from cli_anything.unrealinsights.core.capture import (
|
||||
DEFAULT_CHANNELS,
|
||||
capture_status,
|
||||
normalize_trace_output_path,
|
||||
resolve_capture_target,
|
||||
run_capture,
|
||||
snapshot_capture,
|
||||
stop_capture,
|
||||
)
|
||||
from cli_anything.unrealinsights.core.export import execute_export, execute_response_file
|
||||
from cli_anything.unrealinsights.core.session import UnrealInsightsSession, state_dir
|
||||
from cli_anything.unrealinsights.utils.errors import handle_error
|
||||
from cli_anything.unrealinsights.utils.output import format_size, output_json
|
||||
from cli_anything.unrealinsights.utils.unrealinsights_backend import (
|
||||
ensure_engine_unrealinsights,
|
||||
resolve_trace_server_exe,
|
||||
resolve_unrealinsights_exe,
|
||||
)
|
||||
|
||||
_repl_mode = False
|
||||
|
||||
|
||||
def _get_session(ctx: click.Context) -> UnrealInsightsSession:
|
||||
ctx.ensure_object(dict)
|
||||
session = ctx.obj.get("session")
|
||||
if session is None:
|
||||
session = UnrealInsightsSession.load()
|
||||
ctx.obj["session"] = session
|
||||
return session
|
||||
|
||||
|
||||
def _output(ctx: click.Context, data, human_fn=None):
|
||||
if ctx.obj.get("json_mode"):
|
||||
output_json(data)
|
||||
elif human_fn:
|
||||
human_fn(data)
|
||||
else:
|
||||
output_json(data)
|
||||
|
||||
|
||||
def _handle_exc(ctx: click.Context, exc: Exception):
|
||||
err = handle_error(exc, debug=ctx.obj.get("debug", False))
|
||||
if ctx.obj.get("json_mode"):
|
||||
output_json(err)
|
||||
ctx.exit(1)
|
||||
raise click.ClickException(err["error"])
|
||||
|
||||
|
||||
def _resolve_insights(ctx: click.Context) -> dict[str, object]:
|
||||
session = _get_session(ctx)
|
||||
info = resolve_unrealinsights_exe(session.insights_exe, required=True)
|
||||
session.set_insights_exe(info["path"])
|
||||
return info
|
||||
|
||||
|
||||
def _resolve_trace_server(ctx: click.Context) -> dict[str, object]:
|
||||
session = _get_session(ctx)
|
||||
info = resolve_trace_server_exe(session.trace_server_exe, required=False)
|
||||
if info["available"]:
|
||||
session.set_trace_server_exe(info["path"])
|
||||
return info
|
||||
|
||||
|
||||
def _require_trace(ctx: click.Context) -> str:
|
||||
session = _get_session(ctx)
|
||||
if not session.trace_path:
|
||||
raise click.ClickException("No trace selected. Use --trace <path> or `trace set <path>` first.")
|
||||
trace_path = Path(session.trace_path).expanduser().resolve()
|
||||
if not trace_path.is_file():
|
||||
raise click.ClickException(f"Trace file not found: {trace_path}")
|
||||
return str(trace_path)
|
||||
|
||||
|
||||
def _human_backend(data: dict[str, object]):
|
||||
insights = data["insights"]
|
||||
trace_server = data["trace_server"]
|
||||
click.echo("Resolved Backends:")
|
||||
click.echo(f" UnrealInsights.exe : {insights['path']} ({insights['source']})")
|
||||
click.echo(f" Version : {insights.get('version') or 'unknown'}")
|
||||
if trace_server["available"]:
|
||||
click.echo(f" UnrealTraceServer : {trace_server['path']} ({trace_server['source']})")
|
||||
click.echo(f" Version : {trace_server.get('version') or 'unknown'}")
|
||||
else:
|
||||
click.echo(f" UnrealTraceServer : unavailable ({trace_server.get('error', 'not found')})")
|
||||
|
||||
|
||||
def _human_ensure_insights(data: dict[str, object]):
|
||||
insights = data["insights"]
|
||||
click.echo(f"Engine root: {data['engine_root']}")
|
||||
click.echo(f"UnrealInsights.exe {insights['path']} ({insights['source']})")
|
||||
click.echo(f"Version: {insights.get('version') or 'unknown'}")
|
||||
trace_server = data.get("trace_server")
|
||||
if trace_server and trace_server.get("available"):
|
||||
click.echo(f"TraceServer: {trace_server['path']}")
|
||||
build = data.get("build")
|
||||
if build:
|
||||
click.echo(f"Built: {'yes' if build['succeeded'] else 'no'}")
|
||||
click.echo(f"Build log: {build['log_path']}")
|
||||
|
||||
|
||||
def _human_trace_info(data: dict[str, object]):
|
||||
trace_path = data.get("trace_path")
|
||||
if not trace_path:
|
||||
click.echo("No active trace selected.")
|
||||
return
|
||||
click.echo(f"Trace: {trace_path}")
|
||||
click.echo(f"Exists: {'yes' if data.get('exists') else 'no'}")
|
||||
if data.get("exists"):
|
||||
click.echo(f"Size: {format_size(data.get('file_size'))}")
|
||||
|
||||
|
||||
def _human_export_result(data: dict[str, object]):
|
||||
click.echo(f"Trace: {data['trace_path']}")
|
||||
click.echo(f"Command: {data['exec_command']}")
|
||||
click.echo(f"Log: {data['log_path']}")
|
||||
click.echo(f"Exit code: {data['exit_code']}")
|
||||
click.echo(f"Success: {'yes' if data['succeeded'] else 'no'}")
|
||||
if data["output_files"]:
|
||||
click.echo("Outputs:")
|
||||
for output_path in data["output_files"]:
|
||||
click.echo(f" {output_path}")
|
||||
if data["errors"]:
|
||||
click.echo("Errors:")
|
||||
for line in data["errors"]:
|
||||
click.echo(f" {line}")
|
||||
|
||||
|
||||
def _human_capture_result(data: dict[str, object]):
|
||||
click.echo(f"Target exe: {data['target_exe']}")
|
||||
if data.get("project_path"):
|
||||
click.echo(f"Project: {data['project_path']}")
|
||||
if data.get("engine_root"):
|
||||
click.echo(f"Engine root: {data['engine_root']}")
|
||||
click.echo(f"Trace output: {data['trace_path']}")
|
||||
click.echo(f"Channels: {data['channels']}")
|
||||
click.echo(f"Command: {' '.join(map(str, data['command']))}")
|
||||
if data["waited"]:
|
||||
click.echo(f"Exit code: {data['exit_code']}")
|
||||
click.echo(f"Trace exists: {'yes' if data['trace_exists'] else 'no'}")
|
||||
if data["trace_exists"]:
|
||||
click.echo(f"Trace size: {format_size(data['trace_size'])}")
|
||||
else:
|
||||
click.echo(f"PID: {data['pid']}")
|
||||
|
||||
|
||||
def _human_capture_status(data: dict[str, object]):
|
||||
if not data.get("active"):
|
||||
click.echo("No tracked capture session.")
|
||||
return
|
||||
click.echo(f"PID: {data.get('pid')}")
|
||||
click.echo(f"Running: {'yes' if data.get('running') else 'no'}")
|
||||
click.echo(f"Target exe: {data.get('target_exe')}")
|
||||
if data.get("project_path"):
|
||||
click.echo(f"Project: {data['project_path']}")
|
||||
if data.get("engine_root"):
|
||||
click.echo(f"Engine root: {data['engine_root']}")
|
||||
click.echo(f"Trace: {data.get('trace_path')}")
|
||||
click.echo(f"Trace exists: {'yes' if data.get('trace_exists') else 'no'}")
|
||||
if data.get("trace_exists"):
|
||||
click.echo(f"Trace size: {format_size(data.get('trace_size'))}")
|
||||
if data.get("started_at"):
|
||||
click.echo(f"Started at: {data['started_at']}")
|
||||
|
||||
|
||||
def _human_snapshot_result(data: dict[str, object]):
|
||||
click.echo(f"Source trace: {data['source_trace']}")
|
||||
click.echo(f"Snapshot trace: {data['snapshot_trace']}")
|
||||
click.echo(f"Exists: {'yes' if data['snapshot_exists'] else 'no'}")
|
||||
if data.get("snapshot_exists"):
|
||||
click.echo(f"Size: {format_size(data.get('snapshot_size'))}")
|
||||
click.echo(f"Capture running:{' yes' if data.get('capture_running') else ' no'}")
|
||||
|
||||
|
||||
def _human_stop_result(data: dict[str, object]):
|
||||
termination = data["termination"]
|
||||
click.echo(f"Requested PID: {termination['requested_pid']}")
|
||||
click.echo(f"Stopped: {'yes' if termination['stopped'] else 'no'}")
|
||||
click.echo(f"Exit code: {termination.get('exit_code')}")
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.option("--json", "json_mode", is_flag=True, help="Output in JSON format.")
|
||||
@click.option("--debug", is_flag=True, help="Show debug tracebacks on errors.")
|
||||
@click.option(
|
||||
"--trace",
|
||||
"-t",
|
||||
type=click.Path(exists=False),
|
||||
envvar="UNREALINSIGHTS_TRACE",
|
||||
help="Path to the active .utrace file.",
|
||||
)
|
||||
@click.option(
|
||||
"--insights-exe",
|
||||
type=click.Path(exists=False),
|
||||
envvar="UNREALINSIGHTS_EXE",
|
||||
help="Explicit path to UnrealInsights.exe.",
|
||||
)
|
||||
@click.option(
|
||||
"--trace-server-exe",
|
||||
type=click.Path(exists=False),
|
||||
envvar="UNREAL_TRACE_SERVER_EXE",
|
||||
help="Explicit path to UnrealTraceServer.exe.",
|
||||
)
|
||||
@click.version_option(version=__version__)
|
||||
@click.pass_context
|
||||
def cli(ctx, json_mode, debug, trace, insights_exe, trace_server_exe):
|
||||
"""Windows-first Unreal Insights harness with REPL and exporter wrappers."""
|
||||
ctx.ensure_object(dict)
|
||||
session = _get_session(ctx)
|
||||
ctx.obj["json_mode"] = json_mode
|
||||
ctx.obj["debug"] = debug
|
||||
|
||||
if trace is not None:
|
||||
session.set_trace(trace)
|
||||
if insights_exe is not None:
|
||||
session.set_insights_exe(insights_exe)
|
||||
if trace_server_exe is not None:
|
||||
session.set_trace_server_exe(trace_server_exe)
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
ctx.invoke(repl)
|
||||
|
||||
|
||||
@cli.group("backend")
|
||||
def backend_group():
|
||||
"""Backend executable discovery and inspection."""
|
||||
|
||||
|
||||
@backend_group.command("info")
|
||||
@click.pass_context
|
||||
def backend_info(ctx):
|
||||
"""Resolve Unreal Insights backend executables."""
|
||||
try:
|
||||
data = {
|
||||
"insights": _resolve_insights(ctx),
|
||||
"trace_server": _resolve_trace_server(ctx),
|
||||
}
|
||||
_output(ctx, data, _human_backend)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
@backend_group.command("ensure-insights")
|
||||
@click.option("--engine-root", required=True, type=click.Path(exists=False), help="UE install root or its Engine subdir.")
|
||||
@click.option(
|
||||
"--build-if-missing/--no-build-if-missing",
|
||||
default=True,
|
||||
show_default=True,
|
||||
help="Build UnrealInsights when it is missing under the given engine root.",
|
||||
)
|
||||
@click.option("--configuration", default="Development", show_default=True, help="Build configuration.")
|
||||
@click.option("--timeout", type=float, default=None, help="Optional build timeout in seconds.")
|
||||
@click.pass_context
|
||||
def backend_ensure_insights(ctx, engine_root, build_if_missing, configuration, timeout):
|
||||
"""Find or build UnrealInsights.exe for a specific engine root."""
|
||||
try:
|
||||
data = ensure_engine_unrealinsights(
|
||||
engine_root,
|
||||
build_if_missing=build_if_missing,
|
||||
configuration=configuration,
|
||||
timeout=timeout,
|
||||
)
|
||||
session = _get_session(ctx)
|
||||
session.set_insights_exe(data["insights"]["path"])
|
||||
trace_server = data.get("trace_server")
|
||||
if trace_server and trace_server.get("available"):
|
||||
session.set_trace_server_exe(trace_server["path"])
|
||||
_output(ctx, data, _human_ensure_insights)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
@cli.group("trace")
|
||||
def trace_group():
|
||||
"""Session trace path management."""
|
||||
|
||||
|
||||
@trace_group.command("set")
|
||||
@click.argument("trace_path", type=click.Path(exists=False))
|
||||
@click.pass_context
|
||||
def trace_set(ctx, trace_path):
|
||||
"""Set the active trace path for this session or REPL."""
|
||||
session = _get_session(ctx)
|
||||
session.set_trace(trace_path)
|
||||
_output(ctx, session.trace_info(), _human_trace_info)
|
||||
|
||||
|
||||
@trace_group.command("info")
|
||||
@click.pass_context
|
||||
def trace_info(ctx):
|
||||
"""Show the active trace path."""
|
||||
session = _get_session(ctx)
|
||||
_output(ctx, session.trace_info(), _human_trace_info)
|
||||
|
||||
|
||||
@cli.group("capture")
|
||||
def capture_group():
|
||||
"""Trace capture orchestration."""
|
||||
|
||||
|
||||
@capture_group.command("run")
|
||||
@click.argument("target_exe", required=False, type=click.Path(exists=False))
|
||||
@click.option("--project", type=click.Path(exists=False), default=None, help="Path to a .uproject file.")
|
||||
@click.option(
|
||||
"--engine-root",
|
||||
type=click.Path(exists=False),
|
||||
default=None,
|
||||
help="UE install root such as D:\\Program Files\\Epic Games\\UE_5.5 or its Engine subdir.",
|
||||
)
|
||||
@click.option("--target-arg", "target_args", multiple=True, help="Argument to pass to the target executable.")
|
||||
@click.option("--output-trace", type=click.Path(exists=False), default=None, help="Output .utrace path.")
|
||||
@click.option("--channels", default=DEFAULT_CHANNELS, show_default=True, help="Comma-separated UE trace channels.")
|
||||
@click.option("--exec-cmd", "exec_cmds", multiple=True, help="Startup UE console command for -ExecCmds.")
|
||||
@click.option("--wait", is_flag=True, help="Wait for the target to exit.")
|
||||
@click.option("--timeout", type=float, default=None, help="Optional timeout in seconds when waiting.")
|
||||
@click.pass_context
|
||||
def capture_run(ctx, target_exe, project, engine_root, target_args, output_trace, channels, exec_cmds, wait, timeout):
|
||||
"""Launch a target executable with UE trace flags in file mode."""
|
||||
try:
|
||||
session = _get_session(ctx)
|
||||
resolved_target_exe, resolved_target_args, launch_info = resolve_capture_target(
|
||||
target_exe,
|
||||
project=project,
|
||||
engine_root=engine_root,
|
||||
target_args=target_args,
|
||||
)
|
||||
resolved_output = normalize_trace_output_path(
|
||||
resolved_target_exe,
|
||||
output_trace=output_trace,
|
||||
current_trace=session.trace_path,
|
||||
)
|
||||
data = run_capture(
|
||||
resolved_target_exe,
|
||||
output_trace=resolved_output,
|
||||
channels=channels,
|
||||
exec_cmds=exec_cmds,
|
||||
target_args=resolved_target_args,
|
||||
wait=wait,
|
||||
timeout=timeout,
|
||||
)
|
||||
data.update(launch_info)
|
||||
session.set_trace(resolved_output)
|
||||
if wait:
|
||||
session.clear_capture()
|
||||
else:
|
||||
session.set_capture(
|
||||
pid=data.get("pid"),
|
||||
target_exe=resolved_target_exe,
|
||||
target_args=resolved_target_args,
|
||||
trace_path=resolved_output,
|
||||
channels=channels,
|
||||
project_path=launch_info.get("project_path"),
|
||||
engine_root=launch_info.get("engine_root"),
|
||||
)
|
||||
_output(ctx, data, _human_capture_result)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
def _prepare_capture_start(ctx: click.Context, replace: bool):
|
||||
session = _get_session(ctx)
|
||||
status = capture_status(session)
|
||||
if status.get("active") and status.get("running"):
|
||||
if not replace:
|
||||
raise RuntimeError(
|
||||
"A capture session is already running. Use `capture status` to inspect it, "
|
||||
"`capture stop` to end it, or rerun `capture start` with `--replace`."
|
||||
)
|
||||
|
||||
stop_result = stop_capture(session)
|
||||
if not stop_result.get("termination", {}).get("stopped"):
|
||||
raise RuntimeError("Failed to stop the existing capture session before starting a replacement.")
|
||||
elif status.get("active"):
|
||||
session.clear_capture()
|
||||
|
||||
|
||||
@capture_group.command("start")
|
||||
@click.argument("target_exe", required=False, type=click.Path(exists=False))
|
||||
@click.option("--project", type=click.Path(exists=False), default=None, help="Path to a .uproject file.")
|
||||
@click.option(
|
||||
"--engine-root",
|
||||
type=click.Path(exists=False),
|
||||
default=None,
|
||||
help="UE install root such as D:\\Program Files\\Epic Games\\UE_5.5 or its Engine subdir.",
|
||||
)
|
||||
@click.option("--target-arg", "target_args", multiple=True, help="Argument to pass to the target executable.")
|
||||
@click.option("--output-trace", type=click.Path(exists=False), default=None, help="Output .utrace path.")
|
||||
@click.option("--channels", default=DEFAULT_CHANNELS, show_default=True, help="Comma-separated UE trace channels.")
|
||||
@click.option("--exec-cmd", "exec_cmds", multiple=True, help="Startup UE console command for -ExecCmds.")
|
||||
@click.option("--replace", is_flag=True, help="Stop the currently tracked capture session before starting a new one.")
|
||||
@click.pass_context
|
||||
def capture_start(ctx, target_exe, project, engine_root, target_args, output_trace, channels, exec_cmds, replace):
|
||||
"""Launch a traced target in the background and track the session."""
|
||||
try:
|
||||
_prepare_capture_start(ctx, replace=replace)
|
||||
ctx.invoke(
|
||||
capture_run,
|
||||
target_exe=target_exe,
|
||||
project=project,
|
||||
engine_root=engine_root,
|
||||
target_args=target_args,
|
||||
output_trace=output_trace,
|
||||
channels=channels,
|
||||
exec_cmds=exec_cmds,
|
||||
wait=False,
|
||||
timeout=None,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
@capture_group.command("status")
|
||||
@click.pass_context
|
||||
def capture_status_cmd(ctx):
|
||||
"""Show the tracked background capture status."""
|
||||
try:
|
||||
data = capture_status(_get_session(ctx))
|
||||
_output(ctx, data, _human_capture_status)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
@capture_group.command("stop")
|
||||
@click.option("--force", is_flag=True, help="Force terminate the process tree.")
|
||||
@click.option("--timeout", type=float, default=None, help="Optional stop timeout in seconds.")
|
||||
@click.pass_context
|
||||
def capture_stop_cmd(ctx, force, timeout):
|
||||
"""Stop the tracked capture process."""
|
||||
try:
|
||||
data = stop_capture(_get_session(ctx), force=force, timeout=timeout)
|
||||
_output(ctx, data, _human_stop_result)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
@capture_group.command("snapshot")
|
||||
@click.argument("output_trace", required=False, type=click.Path(exists=False))
|
||||
@click.pass_context
|
||||
def capture_snapshot_cmd(ctx, output_trace):
|
||||
"""Create a best-effort snapshot copy of the current trace."""
|
||||
try:
|
||||
data = snapshot_capture(_get_session(ctx), output_trace=output_trace)
|
||||
_output(ctx, data, _human_snapshot_result)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
@cli.group("export")
|
||||
def export_group():
|
||||
"""Offline Unreal Insights exporters."""
|
||||
|
||||
|
||||
def _run_export(
|
||||
ctx: click.Context,
|
||||
exporter: str,
|
||||
output_path: str,
|
||||
*,
|
||||
columns: str | None = None,
|
||||
threads: str | None = None,
|
||||
timers: str | None = None,
|
||||
start_time: float | None = None,
|
||||
end_time: float | None = None,
|
||||
region: str | None = None,
|
||||
counter: str | None = None,
|
||||
):
|
||||
trace_path = _require_trace(ctx)
|
||||
insights = _resolve_insights(ctx)
|
||||
data = execute_export(
|
||||
insights["path"],
|
||||
trace_path,
|
||||
exporter,
|
||||
output_path,
|
||||
insights_version=insights.get("version"),
|
||||
columns=columns,
|
||||
threads=threads,
|
||||
timers=timers,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
region=region,
|
||||
counter=counter,
|
||||
)
|
||||
_output(ctx, data, _human_export_result)
|
||||
|
||||
|
||||
@export_group.command("threads")
|
||||
@click.argument("output_path", type=click.Path(exists=False))
|
||||
@click.pass_context
|
||||
def export_threads(ctx, output_path):
|
||||
"""Export thread metadata."""
|
||||
try:
|
||||
_run_export(ctx, "threads", output_path)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
@export_group.command("timers")
|
||||
@click.argument("output_path", type=click.Path(exists=False))
|
||||
@click.pass_context
|
||||
def export_timers(ctx, output_path):
|
||||
"""Export timer metadata."""
|
||||
try:
|
||||
_run_export(ctx, "timers", output_path)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
@export_group.command("timing-events")
|
||||
@click.argument("output_path", type=click.Path(exists=False))
|
||||
@click.option("--columns", default=None)
|
||||
@click.option("--threads", default=None)
|
||||
@click.option("--timers", default=None)
|
||||
@click.option("--start-time", type=float, default=None)
|
||||
@click.option("--end-time", type=float, default=None)
|
||||
@click.option("--region", default=None)
|
||||
@click.pass_context
|
||||
def export_timing_events(ctx, output_path, columns, threads, timers, start_time, end_time, region):
|
||||
"""Export timing events."""
|
||||
try:
|
||||
_run_export(
|
||||
ctx,
|
||||
"timing-events",
|
||||
output_path,
|
||||
columns=columns,
|
||||
threads=threads,
|
||||
timers=timers,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
region=region,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
@export_group.command("timer-stats")
|
||||
@click.argument("output_path", type=click.Path(exists=False))
|
||||
@click.option("--columns", default=None)
|
||||
@click.option("--threads", default=None)
|
||||
@click.option("--timers", default=None)
|
||||
@click.option("--start-time", type=float, default=None)
|
||||
@click.option("--end-time", type=float, default=None)
|
||||
@click.option("--region", default=None)
|
||||
@click.pass_context
|
||||
def export_timer_stats(ctx, output_path, columns, threads, timers, start_time, end_time, region):
|
||||
"""Export aggregated timer statistics."""
|
||||
try:
|
||||
_run_export(
|
||||
ctx,
|
||||
"timer-stats",
|
||||
output_path,
|
||||
columns=columns,
|
||||
threads=threads,
|
||||
timers=timers,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
region=region,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
@export_group.command("timer-callees")
|
||||
@click.argument("output_path", type=click.Path(exists=False))
|
||||
@click.option("--threads", default=None)
|
||||
@click.option("--timers", default=None)
|
||||
@click.option("--start-time", type=float, default=None)
|
||||
@click.option("--end-time", type=float, default=None)
|
||||
@click.option("--region", default=None)
|
||||
@click.pass_context
|
||||
def export_timer_callees(ctx, output_path, threads, timers, start_time, end_time, region):
|
||||
"""Export timer callee trees."""
|
||||
try:
|
||||
_run_export(
|
||||
ctx,
|
||||
"timer-callees",
|
||||
output_path,
|
||||
threads=threads,
|
||||
timers=timers,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
region=region,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
@export_group.command("counters")
|
||||
@click.argument("output_path", type=click.Path(exists=False))
|
||||
@click.pass_context
|
||||
def export_counters(ctx, output_path):
|
||||
"""Export the counter list."""
|
||||
try:
|
||||
_run_export(ctx, "counters", output_path)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
@export_group.command("counter-values")
|
||||
@click.argument("output_path", type=click.Path(exists=False))
|
||||
@click.option("--counter", default=None)
|
||||
@click.option("--columns", default=None)
|
||||
@click.option("--start-time", type=float, default=None)
|
||||
@click.option("--end-time", type=float, default=None)
|
||||
@click.option("--region", default=None)
|
||||
@click.pass_context
|
||||
def export_counter_values(ctx, output_path, counter, columns, start_time, end_time, region):
|
||||
"""Export counter values."""
|
||||
try:
|
||||
_run_export(
|
||||
ctx,
|
||||
"counter-values",
|
||||
output_path,
|
||||
counter=counter,
|
||||
columns=columns,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
region=region,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
@cli.group("batch")
|
||||
def batch_group():
|
||||
"""Batch export workflows."""
|
||||
|
||||
|
||||
@batch_group.command("run-rsp")
|
||||
@click.argument("rsp_path", type=click.Path(exists=False))
|
||||
@click.pass_context
|
||||
def batch_run_rsp(ctx, rsp_path):
|
||||
"""Execute a response file using UnrealInsights.exe."""
|
||||
try:
|
||||
trace_path = _require_trace(ctx)
|
||||
insights = _resolve_insights(ctx)
|
||||
data = execute_response_file(
|
||||
insights["path"],
|
||||
trace_path,
|
||||
rsp_path,
|
||||
insights_version=insights.get("version"),
|
||||
)
|
||||
_output(ctx, data, _human_export_result)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.pass_context
|
||||
def repl(ctx):
|
||||
"""Start the interactive REPL."""
|
||||
from cli_anything.unrealinsights.utils.repl_skin import ReplSkin
|
||||
|
||||
global _repl_mode
|
||||
_repl_mode = True
|
||||
|
||||
session = _get_session(ctx)
|
||||
skin = ReplSkin("unrealinsights", version=__version__, history_file=str(state_dir() / "history"))
|
||||
skin.print_banner()
|
||||
pt_session = skin.create_prompt_session()
|
||||
|
||||
repl_commands = {
|
||||
"backend": "info|ensure-insights",
|
||||
"trace": "set|info",
|
||||
"capture": "run|start|status|stop|snapshot",
|
||||
"export": "threads|timers|timing-events|timer-stats|timer-callees|counters|counter-values",
|
||||
"batch": "run-rsp",
|
||||
"help": "Show this help",
|
||||
"quit": "Exit REPL",
|
||||
}
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
trace_name = Path(session.trace_path).name if session.trace_path else ""
|
||||
line = skin.get_input(pt_session, project_name=trace_name, modified=False)
|
||||
if not line:
|
||||
continue
|
||||
if line.lower() in ("quit", "exit", "q"):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
if line.lower() == "help":
|
||||
skin.help(repl_commands)
|
||||
continue
|
||||
|
||||
args = shlex.split(line, posix=os.name != "nt")
|
||||
if ctx.obj.get("json_mode"):
|
||||
args = ["--json", *args]
|
||||
if ctx.obj.get("debug"):
|
||||
args = ["--debug", *args]
|
||||
try:
|
||||
cli.main(args, standalone_mode=False, obj=ctx.obj)
|
||||
except SystemExit:
|
||||
pass
|
||||
except click.exceptions.UsageError as exc:
|
||||
skin.warning(f"Usage error: {exc}")
|
||||
except Exception as exc:
|
||||
if ctx.obj.get("json_mode"):
|
||||
output_json({"error": str(exc)})
|
||||
else:
|
||||
skin.error(str(exc))
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
finally:
|
||||
_repl_mode = False
|
||||
|
||||
|
||||
def main():
|
||||
cli(obj={})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility helpers for the Unreal Insights harness."""
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Error handling utilities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
|
||||
def handle_error(exc: Exception, debug: bool = False) -> dict[str, Any]:
|
||||
"""Convert an exception into a structured error payload."""
|
||||
result = {
|
||||
"error": str(exc),
|
||||
"type": type(exc).__name__,
|
||||
}
|
||||
if debug:
|
||||
result["traceback"] = traceback.format_exc()
|
||||
return result
|
||||
|
||||
|
||||
def die(message: str, code: int = 1):
|
||||
"""Print an error message and exit."""
|
||||
sys.stderr.write(f"Error: {message}\n")
|
||||
sys.exit(code)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Output formatting helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def output_json(data: Any, indent: int = 2, file=None):
|
||||
"""Write JSON data to stdout or a file-like object."""
|
||||
if file is None:
|
||||
file = sys.stdout
|
||||
json.dump(data, file, indent=indent, default=str)
|
||||
file.write("\n")
|
||||
|
||||
|
||||
def output_table(rows: list[list[Any]], headers: list[str], file=None):
|
||||
"""Print a simple ASCII table."""
|
||||
if file is None:
|
||||
file = sys.stdout
|
||||
|
||||
if not rows:
|
||||
file.write("(no data)\n")
|
||||
return
|
||||
|
||||
col_widths = [len(header) for header in headers]
|
||||
for row in rows:
|
||||
for idx, value in enumerate(row[: len(headers)]):
|
||||
col_widths[idx] = max(col_widths[idx], len(str(value)))
|
||||
|
||||
header_line = " ".join(str(headers[idx]).ljust(col_widths[idx]) for idx in range(len(headers)))
|
||||
file.write(header_line + "\n")
|
||||
file.write(" ".join("-" * width for width in col_widths) + "\n")
|
||||
|
||||
for row in rows:
|
||||
truncated = row[: len(headers)]
|
||||
line = " ".join(str(value).ljust(col_widths[idx]) for idx, value in enumerate(truncated))
|
||||
file.write(line + "\n")
|
||||
|
||||
|
||||
def format_size(size_bytes: int | None) -> str:
|
||||
"""Format a byte count as a human-readable string."""
|
||||
if size_bytes is None:
|
||||
return "unknown"
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
if size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
if size_bytes < 1024 * 1024 * 1024:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
|
||||
@@ -0,0 +1,521 @@
|
||||
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
|
||||
|
||||
Copy this file into your CLI package at:
|
||||
cli_anything/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("shotcut", version="1.0.0")
|
||||
skin.print_banner() # auto-detects skills/SKILL.md inside the package
|
||||
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
|
||||
skin.success("Project saved")
|
||||
skin.error("File not found")
|
||||
skin.warning("Unsaved changes")
|
||||
skin.info("Processing 24 clips...")
|
||||
skin.status("Track 1", "3 clips, 00:02:30")
|
||||
skin.table(headers, rows)
|
||||
skin.print_goodbye()
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ── ANSI color codes (no external deps for core styling) ──────────────
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_BOLD = "\033[1m"
|
||||
_DIM = "\033[2m"
|
||||
_ITALIC = "\033[3m"
|
||||
_UNDERLINE = "\033[4m"
|
||||
|
||||
# Brand colors
|
||||
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||
_CYAN_BG = "\033[48;5;80m"
|
||||
_WHITE = "\033[97m"
|
||||
_GRAY = "\033[38;5;245m"
|
||||
_DARK_GRAY = "\033[38;5;240m"
|
||||
_LIGHT_GRAY = "\033[38;5;250m"
|
||||
|
||||
# Software accent colors — each software gets a unique accent
|
||||
_ACCENT_COLORS = {
|
||||
"gimp": "\033[38;5;214m", # warm orange
|
||||
"blender": "\033[38;5;208m", # deep orange
|
||||
"inkscape": "\033[38;5;39m", # bright blue
|
||||
"audacity": "\033[38;5;33m", # navy blue
|
||||
"libreoffice": "\033[38;5;40m", # green
|
||||
"obs_studio": "\033[38;5;55m", # purple
|
||||
"kdenlive": "\033[38;5;69m", # slate blue
|
||||
"shotcut": "\033[38;5;35m", # teal green
|
||||
}
|
||||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||
|
||||
# Status colors
|
||||
_GREEN = "\033[38;5;78m"
|
||||
_YELLOW = "\033[38;5;220m"
|
||||
_RED = "\033[38;5;196m"
|
||||
_BLUE = "\033[38;5;75m"
|
||||
_MAGENTA = "\033[38;5;176m"
|
||||
|
||||
# ── Brand icon ────────────────────────────────────────────────────────
|
||||
|
||||
# The cli-anything icon: a small colored diamond/chevron mark
|
||||
_ICON = f"{_CYAN}{_BOLD}◆{_RESET}"
|
||||
_ICON_SMALL = f"{_CYAN}▸{_RESET}"
|
||||
|
||||
# ── Box drawing characters ────────────────────────────────────────────
|
||||
|
||||
_H_LINE = "─"
|
||||
_V_LINE = "│"
|
||||
_TL = "╭"
|
||||
_TR = "╮"
|
||||
_BL = "╰"
|
||||
_BR = "╯"
|
||||
_T_DOWN = "┬"
|
||||
_T_UP = "┴"
|
||||
_T_RIGHT = "├"
|
||||
_T_LEFT = "┤"
|
||||
_CROSS = "┼"
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes for length calculation."""
|
||||
import re
|
||||
return re.sub(r"\033\[[^m]*m", "", text)
|
||||
|
||||
|
||||
def _visible_len(text: str) -> int:
|
||||
"""Get visible length of text (excluding ANSI codes)."""
|
||||
return len(_strip_ansi(text))
|
||||
|
||||
|
||||
class ReplSkin:
|
||||
"""Unified REPL skin for cli-anything CLIs.
|
||||
|
||||
Provides consistent branding, prompts, and message formatting
|
||||
across all CLI harnesses built with the cli-anything methodology.
|
||||
"""
|
||||
|
||||
def __init__(self, software: str, version: str = "1.0.0",
|
||||
history_file: str | None = None, skill_path: str | None = None):
|
||||
"""Initialize the REPL skin.
|
||||
|
||||
Args:
|
||||
software: Software name (e.g., "gimp", "shotcut", "blender").
|
||||
version: CLI version string.
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/history
|
||||
skill_path: Path to the SKILL.md file for agent discovery.
|
||||
Auto-detected from the package's skills/ directory if not provided.
|
||||
Displayed in banner for AI agents to know where to read skill info.
|
||||
"""
|
||||
self.software = software.lower().replace("-", "_")
|
||||
self.display_name = software.replace("_", " ").title()
|
||||
self.version = version
|
||||
|
||||
# Auto-detect skill path from package layout:
|
||||
# cli_anything/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
if skill_path is None:
|
||||
from pathlib import Path
|
||||
_auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
|
||||
if _auto.is_file():
|
||||
skill_path = str(_auto)
|
||||
self.skill_path = skill_path
|
||||
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||
|
||||
# History file
|
||||
if history_file is None:
|
||||
from pathlib import Path
|
||||
hist_dir = Path.home() / f".cli-anything-{self.software}"
|
||||
hist_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.history_file = str(hist_dir / "history")
|
||||
else:
|
||||
self.history_file = history_file
|
||||
|
||||
# Detect terminal capabilities
|
||||
self._color = self._detect_color_support()
|
||||
|
||||
def _detect_color_support(self) -> bool:
|
||||
"""Check if terminal supports color."""
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
|
||||
return False
|
||||
if not hasattr(sys.stdout, "isatty"):
|
||||
return False
|
||||
return sys.stdout.isatty()
|
||||
|
||||
def _c(self, code: str, text: str) -> str:
|
||||
"""Apply color code if colors are supported."""
|
||||
if not self._color:
|
||||
return text
|
||||
return f"{code}{text}{_RESET}"
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────
|
||||
|
||||
def print_banner(self):
|
||||
"""Print the startup banner with branding."""
|
||||
inner = 54
|
||||
|
||||
def _box_line(content: str) -> str:
|
||||
"""Wrap content in box drawing, padding to inner width."""
|
||||
pad = inner - _visible_len(content)
|
||||
vl = self._c(_DARK_GRAY, _V_LINE)
|
||||
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
|
||||
|
||||
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
|
||||
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
|
||||
|
||||
# Title: ◆ cli-anything · Shotcut
|
||||
icon = self._c(_CYAN + _BOLD, "◆")
|
||||
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||||
dot = self._c(_DARK_GRAY, "·")
|
||||
name = self._c(self.accent + _BOLD, self.display_name)
|
||||
title = f" {icon} {brand} {dot} {name}"
|
||||
|
||||
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
|
||||
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
|
||||
empty = ""
|
||||
|
||||
# Skill path for agent discovery
|
||||
skill_line = None
|
||||
if self.skill_path:
|
||||
skill_icon = self._c(_MAGENTA, "◇")
|
||||
skill_label = self._c(_DARK_GRAY, " Skill:")
|
||||
skill_path_display = self._c(_LIGHT_GRAY, self.skill_path)
|
||||
skill_line = f" {skill_icon} {skill_label} {skill_path_display}"
|
||||
|
||||
print(top)
|
||||
print(_box_line(title))
|
||||
print(_box_line(ver))
|
||||
if skill_line:
|
||||
print(_box_line(skill_line))
|
||||
print(_box_line(empty))
|
||||
print(_box_line(tip))
|
||||
print(bot)
|
||||
print()
|
||||
|
||||
# ── Prompt ────────────────────────────────────────────────────────
|
||||
|
||||
def prompt(self, project_name: str = "", modified: bool = False,
|
||||
context: str = "") -> str:
|
||||
"""Build a styled prompt string for prompt_toolkit or input().
|
||||
|
||||
Args:
|
||||
project_name: Current project name (empty if none open).
|
||||
modified: Whether the project has unsaved changes.
|
||||
context: Optional extra context to show in prompt.
|
||||
|
||||
Returns:
|
||||
Formatted prompt string.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Icon
|
||||
if self._color:
|
||||
parts.append(f"{_CYAN}◆{_RESET} ")
|
||||
else:
|
||||
parts.append("> ")
|
||||
|
||||
# Software name
|
||||
parts.append(self._c(self.accent + _BOLD, self.software))
|
||||
|
||||
# Project context
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||||
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||||
parts.append(self._c(_DARK_GRAY, ']'))
|
||||
|
||||
parts.append(self._c(_GRAY, " ❯ "))
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||||
context: str = ""):
|
||||
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||||
|
||||
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||||
|
||||
Returns:
|
||||
list of (style, text) tuples for prompt_toolkit.
|
||||
"""
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
tokens = []
|
||||
|
||||
tokens.append(("class:icon", "◆ "))
|
||||
tokens.append(("class:software", self.software))
|
||||
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
tokens.append(("class:bracket", " ["))
|
||||
tokens.append(("class:context", f"{ctx}{mod}"))
|
||||
tokens.append(("class:bracket", "]"))
|
||||
|
||||
tokens.append(("class:arrow", " ❯ "))
|
||||
|
||||
return tokens
|
||||
|
||||
def get_prompt_style(self):
|
||||
"""Get a prompt_toolkit Style object matching the skin.
|
||||
|
||||
Returns:
|
||||
prompt_toolkit.styles.Style
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.styles import Style
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
|
||||
return Style.from_dict({
|
||||
"icon": "#5fdfdf bold", # cyan brand color
|
||||
"software": f"{accent_hex} bold",
|
||||
"bracket": "#585858",
|
||||
"context": "#bcbcbc",
|
||||
"arrow": "#808080",
|
||||
# Completion menu
|
||||
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||
# Auto-suggest
|
||||
"auto-suggest": "#585858",
|
||||
# Bottom toolbar
|
||||
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||
"bottom-toolbar.text": "#808080",
|
||||
})
|
||||
|
||||
# ── Messages ──────────────────────────────────────────────────────
|
||||
|
||||
def success(self, message: str):
|
||||
"""Print a success message with green checkmark."""
|
||||
icon = self._c(_GREEN + _BOLD, "✓")
|
||||
print(f" {icon} {self._c(_GREEN, message)}")
|
||||
|
||||
def error(self, message: str):
|
||||
"""Print an error message with red cross."""
|
||||
icon = self._c(_RED + _BOLD, "✗")
|
||||
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
|
||||
|
||||
def warning(self, message: str):
|
||||
"""Print a warning message with yellow triangle."""
|
||||
icon = self._c(_YELLOW + _BOLD, "⚠")
|
||||
print(f" {icon} {self._c(_YELLOW, message)}")
|
||||
|
||||
def info(self, message: str):
|
||||
"""Print an info message with blue dot."""
|
||||
icon = self._c(_BLUE, "●")
|
||||
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
|
||||
|
||||
def hint(self, message: str):
|
||||
"""Print a subtle hint message."""
|
||||
print(f" {self._c(_DARK_GRAY, message)}")
|
||||
|
||||
def section(self, title: str):
|
||||
"""Print a section header."""
|
||||
print()
|
||||
print(f" {self._c(self.accent + _BOLD, title)}")
|
||||
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
|
||||
|
||||
# ── Status display ────────────────────────────────────────────────
|
||||
|
||||
def status(self, label: str, value: str):
|
||||
"""Print a key-value status line."""
|
||||
lbl = self._c(_GRAY, f" {label}:")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def status_block(self, items: dict[str, str], title: str = ""):
|
||||
"""Print a block of status key-value pairs.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs.
|
||||
title: Optional title for the block.
|
||||
"""
|
||||
if title:
|
||||
self.section(title)
|
||||
|
||||
max_key = max(len(k) for k in items) if items else 0
|
||||
for label, value in items.items():
|
||||
lbl = self._c(_GRAY, f" {label:<{max_key}}")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def progress(self, current: int, total: int, label: str = ""):
|
||||
"""Print a simple progress indicator.
|
||||
|
||||
Args:
|
||||
current: Current step number.
|
||||
total: Total number of steps.
|
||||
label: Optional label for the progress.
|
||||
"""
|
||||
pct = int(current / total * 100) if total > 0 else 0
|
||||
bar_width = 20
|
||||
filled = int(bar_width * current / total) if total > 0 else 0
|
||||
bar = "█" * filled + "░" * (bar_width - filled)
|
||||
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
|
||||
if label:
|
||||
text += f" {self._c(_LIGHT_GRAY, label)}"
|
||||
print(text)
|
||||
|
||||
# ── Table display ─────────────────────────────────────────────────
|
||||
|
||||
def table(self, headers: list[str], rows: list[list[str]],
|
||||
max_col_width: int = 40):
|
||||
"""Print a formatted table with box-drawing characters.
|
||||
|
||||
Args:
|
||||
headers: Column header strings.
|
||||
rows: List of rows, each a list of cell strings.
|
||||
max_col_width: Maximum column width before truncation.
|
||||
"""
|
||||
if not headers:
|
||||
return
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [min(len(h), max_col_width) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
col_widths[i] = min(
|
||||
max(col_widths[i], len(str(cell))), max_col_width
|
||||
)
|
||||
|
||||
def pad(text: str, width: int) -> str:
|
||||
t = str(text)[:width]
|
||||
return t + " " * (width - len(t))
|
||||
|
||||
# Header
|
||||
header_cells = [
|
||||
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||||
for i, h in enumerate(headers)
|
||||
]
|
||||
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
header_line = f" {sep.join(header_cells)}"
|
||||
print(header_line)
|
||||
|
||||
# Separator
|
||||
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||||
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||||
print(sep_line)
|
||||
|
||||
# Rows
|
||||
for row in rows:
|
||||
cells = []
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
|
||||
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
print(f" {row_sep.join(cells)}")
|
||||
|
||||
# ── Help display ──────────────────────────────────────────────────
|
||||
|
||||
def help(self, commands: dict[str, str]):
|
||||
"""Print a formatted help listing.
|
||||
|
||||
Args:
|
||||
commands: Dict of command -> description pairs.
|
||||
"""
|
||||
self.section("Commands")
|
||||
max_cmd = max(len(c) for c in commands) if commands else 0
|
||||
for cmd, desc in commands.items():
|
||||
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
|
||||
desc_styled = self._c(_GRAY, f" {desc}")
|
||||
print(f"{cmd_styled}{desc_styled}")
|
||||
print()
|
||||
|
||||
# ── Goodbye ───────────────────────────────────────────────────────
|
||||
|
||||
def print_goodbye(self):
|
||||
"""Print a styled goodbye message."""
|
||||
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
|
||||
|
||||
# ── Prompt toolkit session factory ────────────────────────────────
|
||||
|
||||
def create_prompt_session(self):
|
||||
"""Create a prompt_toolkit PromptSession with skin styling.
|
||||
|
||||
Returns:
|
||||
A configured PromptSession, or None if prompt_toolkit unavailable.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
style = self.get_prompt_style()
|
||||
|
||||
session = PromptSession(
|
||||
history=FileHistory(self.history_file),
|
||||
auto_suggest=AutoSuggestFromHistory(),
|
||||
style=style,
|
||||
enable_history_search=True,
|
||||
)
|
||||
return session
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def get_input(self, pt_session, project_name: str = "",
|
||||
modified: bool = False, context: str = "") -> str:
|
||||
"""Get input from user using prompt_toolkit or fallback.
|
||||
|
||||
Args:
|
||||
pt_session: A prompt_toolkit PromptSession (or None).
|
||||
project_name: Current project name.
|
||||
modified: Whether project has unsaved changes.
|
||||
context: Optional context string.
|
||||
|
||||
Returns:
|
||||
User input string (stripped).
|
||||
"""
|
||||
if pt_session is not None:
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
tokens = self.prompt_tokens(project_name, modified, context)
|
||||
return pt_session.prompt(FormattedText(tokens)).strip()
|
||||
else:
|
||||
raw_prompt = self.prompt(project_name, modified, context)
|
||||
return input(raw_prompt).strip()
|
||||
|
||||
# ── Toolbar builder ───────────────────────────────────────────────
|
||||
|
||||
def bottom_toolbar(self, items: dict[str, str]):
|
||||
"""Create a bottom toolbar callback for prompt_toolkit.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs to show in toolbar.
|
||||
|
||||
Returns:
|
||||
A callable that returns FormattedText for the toolbar.
|
||||
"""
|
||||
def toolbar():
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
parts = []
|
||||
for i, (k, v) in enumerate(items.items()):
|
||||
if i > 0:
|
||||
parts.append(("class:bottom-toolbar.text", " │ "))
|
||||
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||||
parts.append(("class:bottom-toolbar", v))
|
||||
return FormattedText(parts)
|
||||
return toolbar
|
||||
|
||||
|
||||
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||||
|
||||
_ANSI_256_TO_HEX = {
|
||||
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||||
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||||
"\033[38;5;40m": "#00d700", # libreoffice green
|
||||
"\033[38;5;55m": "#5f00af", # obs purple
|
||||
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
|
||||
"\033[38;5;75m": "#5fafff", # default sky blue
|
||||
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||||
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
"""
|
||||
Backend helpers for resolving and invoking Unreal Insights binaries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import time
|
||||
from typing import Iterable
|
||||
|
||||
INSIGHTS_BINARY_NAME = "UnrealInsights.exe"
|
||||
TRACE_SERVER_BINARY_NAME = "UnrealTraceServer.exe"
|
||||
|
||||
|
||||
def _normalize_path(path: str | Path) -> str:
|
||||
return str(Path(path).expanduser().resolve())
|
||||
|
||||
|
||||
def _extract_engine_version_hint(path: Path) -> str | None:
|
||||
for parent in path.parents:
|
||||
if parent.name.startswith("UE_"):
|
||||
return parent.name.removeprefix("UE_")
|
||||
return None
|
||||
|
||||
|
||||
def _engine_sort_key(path: Path) -> tuple[int, ...]:
|
||||
match = re.findall(r"\d+", path.name)
|
||||
if not match:
|
||||
return (0,)
|
||||
return tuple(int(part) for part in match)
|
||||
|
||||
|
||||
def _default_search_roots() -> list[Path]:
|
||||
roots: dict[str, Path] = {}
|
||||
|
||||
for env_key in ("ProgramW6432", "ProgramFiles"):
|
||||
value = os.environ.get(env_key)
|
||||
if value:
|
||||
root = Path(value) / "Epic Games"
|
||||
roots[str(root).lower()] = root
|
||||
|
||||
for drive in "CDEFGHIJKLMNOPQRSTUVWXYZ":
|
||||
root = Path(f"{drive}:/Program Files/Epic Games")
|
||||
roots[str(root).lower()] = root
|
||||
|
||||
return [root for root in roots.values() if root.exists()]
|
||||
|
||||
|
||||
def _existing_engine_installations(search_roots: Iterable[Path] | None = None) -> list[Path]:
|
||||
installs: dict[str, Path] = {}
|
||||
roots = list(search_roots) if search_roots is not None else _default_search_roots()
|
||||
for root in roots:
|
||||
if not root.exists():
|
||||
continue
|
||||
for candidate in root.glob("UE_*"):
|
||||
if candidate.is_dir():
|
||||
installs[str(candidate.resolve()).lower()] = candidate.resolve()
|
||||
return sorted(installs.values(), key=_engine_sort_key, reverse=True)
|
||||
|
||||
|
||||
def _candidate_binary_paths(binary_name: str, search_roots: Iterable[Path] | None = None) -> list[Path]:
|
||||
candidates: list[Path] = []
|
||||
for install in _existing_engine_installations(search_roots):
|
||||
candidates.append(install / "Engine" / "Binaries" / "Win64" / binary_name)
|
||||
return candidates
|
||||
|
||||
|
||||
def _read_windows_product_version(path: Path) -> str | None:
|
||||
if os.name != "nt":
|
||||
return None
|
||||
|
||||
literal = str(path).replace("'", "''")
|
||||
cmd = [
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
f"(Get-Item -LiteralPath '{literal}').VersionInfo.ProductVersion",
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
version = result.stdout.strip()
|
||||
return version or None
|
||||
|
||||
|
||||
def _build_resolution(path: Path, source: str) -> dict[str, object]:
|
||||
version = _read_windows_product_version(path)
|
||||
return {
|
||||
"available": True,
|
||||
"path": _normalize_path(path),
|
||||
"source": source,
|
||||
"version": version or _extract_engine_version_hint(path),
|
||||
"engine_version_hint": _extract_engine_version_hint(path),
|
||||
}
|
||||
|
||||
|
||||
def _missing_resolution(binary_name: str, reason: str) -> dict[str, object]:
|
||||
return {
|
||||
"available": False,
|
||||
"path": None,
|
||||
"source": "unresolved",
|
||||
"version": None,
|
||||
"engine_version_hint": None,
|
||||
"error": f"{binary_name} not found: {reason}",
|
||||
}
|
||||
|
||||
|
||||
def resolve_engine_root(engine_root: str | Path) -> Path:
|
||||
"""Normalize a UE install root from either the install root or Engine subdir."""
|
||||
path = Path(engine_root).expanduser().resolve()
|
||||
root = path.parent if path.name.lower() == "engine" else path
|
||||
if not root.exists():
|
||||
raise RuntimeError(f"Engine root not found: {root}")
|
||||
if not (root / "Engine").is_dir():
|
||||
raise RuntimeError(f"Engine root must contain an Engine directory: {root}")
|
||||
return root
|
||||
|
||||
|
||||
def resolve_binary_from_engine_root(
|
||||
binary_name: str,
|
||||
engine_root: str | Path,
|
||||
required: bool = True,
|
||||
) -> dict[str, object]:
|
||||
"""Resolve a UE program binary from a specific engine root."""
|
||||
root = resolve_engine_root(engine_root)
|
||||
candidate = root / "Engine" / "Binaries" / "Win64" / binary_name
|
||||
if candidate.is_file():
|
||||
return _build_resolution(candidate, f"engine:{root.name}")
|
||||
if required:
|
||||
raise RuntimeError(f"{binary_name} not found under engine root: {root}")
|
||||
return _missing_resolution(binary_name, f"missing under engine root {root}")
|
||||
|
||||
|
||||
def resolve_windows_binary(
|
||||
binary_name: str,
|
||||
explicit_path: str | None = None,
|
||||
env_var_name: str | None = None,
|
||||
search_roots: Iterable[Path] | None = None,
|
||||
required: bool = True,
|
||||
) -> dict[str, object]:
|
||||
"""Resolve a UE program binary using explicit path, env var, then auto-discovery."""
|
||||
if explicit_path:
|
||||
explicit = Path(explicit_path).expanduser()
|
||||
if not explicit.is_file():
|
||||
raise RuntimeError(f"Explicit path does not exist: {explicit}")
|
||||
return _build_resolution(explicit.resolve(), "explicit")
|
||||
|
||||
if env_var_name:
|
||||
env_value = os.environ.get(env_var_name, "").strip()
|
||||
if env_value:
|
||||
env_path = Path(env_value).expanduser()
|
||||
if not env_path.is_file():
|
||||
raise RuntimeError(f"{env_var_name} points to a missing file: {env_path}")
|
||||
return _build_resolution(env_path.resolve(), f"env:{env_var_name}")
|
||||
|
||||
for candidate in _candidate_binary_paths(binary_name, search_roots):
|
||||
if candidate.is_file():
|
||||
return _build_resolution(candidate.resolve(), f"auto:{candidate.parents[3].name}")
|
||||
|
||||
if required:
|
||||
raise RuntimeError(
|
||||
f"{binary_name} not found. Set an explicit path or install UE 5.5+ in an Epic Games directory."
|
||||
)
|
||||
return _missing_resolution(binary_name, "auto-discovery did not find a matching UE install")
|
||||
|
||||
|
||||
def resolve_unrealinsights_exe(
|
||||
explicit_path: str | None = None,
|
||||
engine_root: str | None = None,
|
||||
search_roots: Iterable[Path] | None = None,
|
||||
required: bool = True,
|
||||
) -> dict[str, object]:
|
||||
if engine_root:
|
||||
return resolve_binary_from_engine_root(INSIGHTS_BINARY_NAME, engine_root, required=required)
|
||||
return resolve_windows_binary(
|
||||
INSIGHTS_BINARY_NAME,
|
||||
explicit_path=explicit_path,
|
||||
env_var_name="UNREALINSIGHTS_EXE",
|
||||
search_roots=search_roots,
|
||||
required=required,
|
||||
)
|
||||
|
||||
|
||||
def resolve_trace_server_exe(
|
||||
explicit_path: str | None = None,
|
||||
engine_root: str | None = None,
|
||||
search_roots: Iterable[Path] | None = None,
|
||||
required: bool = False,
|
||||
) -> dict[str, object]:
|
||||
if engine_root:
|
||||
return resolve_binary_from_engine_root(TRACE_SERVER_BINARY_NAME, engine_root, required=required)
|
||||
return resolve_windows_binary(
|
||||
TRACE_SERVER_BINARY_NAME,
|
||||
explicit_path=explicit_path,
|
||||
env_var_name="UNREAL_TRACE_SERVER_EXE",
|
||||
search_roots=search_roots,
|
||||
required=required,
|
||||
)
|
||||
|
||||
|
||||
def ensure_parent_dir(path: str | Path):
|
||||
Path(path).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def build_engine_program(
|
||||
engine_root: str | Path,
|
||||
target_name: str,
|
||||
*,
|
||||
platform: str = "Win64",
|
||||
configuration: str = "Development",
|
||||
timeout: float | None = None,
|
||||
log_path: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Build a UE program target using the engine's Build.bat."""
|
||||
root = resolve_engine_root(engine_root)
|
||||
build_bat = root / "Engine" / "Build" / "BatchFiles" / "Build.bat"
|
||||
if not build_bat.is_file():
|
||||
raise RuntimeError(f"Build.bat not found under engine root: {root}")
|
||||
|
||||
if log_path is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
log_path = str(root / "Engine" / "Programs" / target_name / "Saved" / "Logs" / f"build-{target_name}-{timestamp}.log")
|
||||
ensure_parent_dir(log_path)
|
||||
|
||||
command = [str(build_bat), target_name, platform, configuration, "-WaitMutex"]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=str(root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
stdout = result.stdout
|
||||
stderr = result.stderr
|
||||
exit_code = result.returncode
|
||||
timed_out = False
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
stdout = exc.stdout or ""
|
||||
stderr = exc.stderr or ""
|
||||
exit_code = None
|
||||
timed_out = True
|
||||
|
||||
Path(log_path).write_text(
|
||||
"\n".join(
|
||||
[
|
||||
f"# Command: {' '.join(command)}",
|
||||
"",
|
||||
stdout or "",
|
||||
stderr or "",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
return {
|
||||
"command": command,
|
||||
"cwd": str(root),
|
||||
"log_path": str(Path(log_path).resolve()),
|
||||
"exit_code": exit_code,
|
||||
"timed_out": timed_out,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"succeeded": (not timed_out and exit_code == 0),
|
||||
}
|
||||
|
||||
|
||||
def ensure_engine_unrealinsights(
|
||||
engine_root: str | Path,
|
||||
*,
|
||||
build_if_missing: bool = True,
|
||||
configuration: str = "Development",
|
||||
platform: str = "Win64",
|
||||
timeout: float | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Resolve UnrealInsights.exe for a given engine root, building it if requested."""
|
||||
root = resolve_engine_root(engine_root)
|
||||
trace_server = resolve_binary_from_engine_root(
|
||||
TRACE_SERVER_BINARY_NAME,
|
||||
root,
|
||||
required=False,
|
||||
)
|
||||
existing = resolve_binary_from_engine_root(
|
||||
INSIGHTS_BINARY_NAME,
|
||||
root,
|
||||
required=False,
|
||||
)
|
||||
result = {
|
||||
"engine_root": str(root),
|
||||
"trace_server": trace_server,
|
||||
"build_attempted": False,
|
||||
"build": None,
|
||||
}
|
||||
if existing["available"]:
|
||||
result["insights"] = existing
|
||||
return result
|
||||
|
||||
if not build_if_missing:
|
||||
raise RuntimeError(f"{INSIGHTS_BINARY_NAME} not found under engine root: {root}")
|
||||
|
||||
build = build_engine_program(
|
||||
root,
|
||||
"UnrealInsights",
|
||||
platform=platform,
|
||||
configuration=configuration,
|
||||
timeout=timeout,
|
||||
)
|
||||
result["build_attempted"] = True
|
||||
result["build"] = build
|
||||
if not build["succeeded"]:
|
||||
raise RuntimeError(f"Failed to build UnrealInsights for engine root: {root}")
|
||||
|
||||
result["insights"] = resolve_binary_from_engine_root(INSIGHTS_BINARY_NAME, root, required=True)
|
||||
return result
|
||||
|
||||
|
||||
def build_insights_command(
|
||||
insights_exe: str,
|
||||
trace_path: str,
|
||||
exec_on_complete: str,
|
||||
log_path: str,
|
||||
) -> list[str]:
|
||||
"""Build the UnrealInsights.exe command line."""
|
||||
return [
|
||||
_normalize_path(insights_exe),
|
||||
f"-OpenTraceFile={_normalize_path(trace_path)}",
|
||||
f"-ABSLOG={_normalize_path(log_path)}",
|
||||
"-AutoQuit",
|
||||
"-NoUI",
|
||||
f"-ExecOnAnalysisCompleteCmd={exec_on_complete}",
|
||||
"-log",
|
||||
]
|
||||
|
||||
|
||||
def _quote_cmd_value(value: str) -> str:
|
||||
escaped = value.replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def build_insights_command_line(
|
||||
insights_exe: str,
|
||||
trace_path: str,
|
||||
exec_on_complete: str,
|
||||
log_path: str,
|
||||
) -> str:
|
||||
"""Build a raw Windows command line for UnrealInsights.exe.
|
||||
|
||||
This avoids CreateProcess argv wrapping the whole -ExecOnAnalysisCompleteCmd
|
||||
argument in outer quotes, which older UnrealInsights builds fail to parse.
|
||||
"""
|
||||
exe = _quote_cmd_value(_normalize_path(insights_exe))
|
||||
trace = _quote_cmd_value(_normalize_path(trace_path))
|
||||
log = _quote_cmd_value(_normalize_path(log_path))
|
||||
exec_value = _quote_cmd_value(exec_on_complete)
|
||||
return (
|
||||
f"{exe} "
|
||||
f"-OpenTraceFile={trace} "
|
||||
f"-ABSLOG={log} "
|
||||
f"-AutoQuit -NoUI "
|
||||
f"-ExecOnAnalysisCompleteCmd={exec_value} "
|
||||
f"-log"
|
||||
)
|
||||
|
||||
|
||||
def run_process(command: list[str] | str, timeout: float | None = None, wait: bool = True) -> dict[str, object]:
|
||||
"""Run or launch a subprocess and return structured execution metadata."""
|
||||
if wait:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
return {
|
||||
"command": command,
|
||||
"waited": True,
|
||||
"timed_out": False,
|
||||
"exit_code": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"pid": None,
|
||||
}
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return {
|
||||
"command": command,
|
||||
"waited": True,
|
||||
"timed_out": True,
|
||||
"exit_code": None,
|
||||
"stdout": exc.stdout or "",
|
||||
"stderr": exc.stderr or "",
|
||||
"pid": None,
|
||||
}
|
||||
|
||||
process = subprocess.Popen(command)
|
||||
return {
|
||||
"command": command,
|
||||
"waited": False,
|
||||
"timed_out": False,
|
||||
"exit_code": None,
|
||||
"stdout": None,
|
||||
"stderr": None,
|
||||
"pid": process.pid,
|
||||
}
|
||||
|
||||
|
||||
def is_process_running(pid: int | None) -> bool:
|
||||
"""Check whether a process is still running."""
|
||||
if not pid or pid <= 0:
|
||||
return False
|
||||
|
||||
if os.name == "nt":
|
||||
result = subprocess.run(
|
||||
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
stdout = result.stdout.strip()
|
||||
return bool(stdout) and "No tasks are running" not in stdout and "INFO:" not in stdout
|
||||
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def terminate_process(pid: int, force: bool = False, timeout: float | None = None) -> dict[str, object]:
|
||||
"""Terminate a process tree and report whether it stopped."""
|
||||
if pid <= 0:
|
||||
raise RuntimeError(f"Invalid PID: {pid}")
|
||||
|
||||
if os.name == "nt":
|
||||
command = ["taskkill", "/PID", str(pid), "/T"]
|
||||
if force:
|
||||
command.append("/F")
|
||||
result = run_process(command, timeout=timeout, wait=True)
|
||||
else:
|
||||
sig = signal.SIGKILL if force else signal.SIGTERM
|
||||
signal_arg = f"-{sig.name}"
|
||||
try:
|
||||
os.kill(pid, sig)
|
||||
result = {
|
||||
"command": ["kill", signal_arg, str(pid)],
|
||||
"waited": True,
|
||||
"timed_out": False,
|
||||
"exit_code": 0,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"pid": None,
|
||||
}
|
||||
except OSError as exc:
|
||||
result = {
|
||||
"command": ["kill", signal_arg, str(pid)],
|
||||
"waited": True,
|
||||
"timed_out": False,
|
||||
"exit_code": 1,
|
||||
"stdout": "",
|
||||
"stderr": str(exc),
|
||||
"pid": None,
|
||||
}
|
||||
|
||||
deadline = time.time() + (timeout or 10)
|
||||
while time.time() < deadline and is_process_running(pid):
|
||||
time.sleep(0.25)
|
||||
|
||||
result["stopped"] = not is_process_running(pid)
|
||||
result["requested_pid"] = pid
|
||||
result["force"] = force
|
||||
return result
|
||||
|
||||
|
||||
def parse_unreal_log(log_path: str | Path) -> dict[str, object]:
|
||||
"""Extract warning and error lines from an Unreal log file."""
|
||||
path = Path(log_path).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
return {
|
||||
"path": str(path),
|
||||
"exists": False,
|
||||
"warnings": [],
|
||||
"errors": [],
|
||||
"tail": [],
|
||||
}
|
||||
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
warnings = [line for line in lines if "Warning:" in line]
|
||||
errors = [line for line in lines if "Error:" in line]
|
||||
return {
|
||||
"path": str(path),
|
||||
"exists": True,
|
||||
"warnings": warnings,
|
||||
"errors": errors,
|
||||
"tail": lines[-20:],
|
||||
}
|
||||
41
unrealinsights/agent-harness/setup.py
Normal file
41
unrealinsights/agent-harness/setup.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Setup for cli-anything-unrealinsights package."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from setuptools import find_namespace_packages, setup
|
||||
|
||||
_README = Path(__file__).parent / "cli_anything" / "unrealinsights" / "README.md"
|
||||
_long_desc = _README.read_text(encoding="utf-8") if _README.is_file() else ""
|
||||
|
||||
setup(
|
||||
name="cli-anything-unrealinsights",
|
||||
version="0.1.0",
|
||||
description="CLI harness for Unreal Insights trace capture and export workflows",
|
||||
long_description=_long_desc,
|
||||
long_description_content_type="text/markdown",
|
||||
author="cli-anything",
|
||||
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||
python_requires=">=3.10",
|
||||
install_requires=[
|
||||
"click>=8.0",
|
||||
"prompt-toolkit>=3.0",
|
||||
],
|
||||
extras_require={
|
||||
"test": ["pytest>=7.0"],
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cli-anything-unrealinsights=cli_anything.unrealinsights.unrealinsights_cli:main",
|
||||
],
|
||||
},
|
||||
package_data={
|
||||
"cli_anything.unrealinsights": ["skills/*.md", "README.md"],
|
||||
},
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Topic :: Software Development :: Debuggers",
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user