webgpu-inspector-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- webgpu_inspector_cli-0.1.0/PKG-INFO +14 -0
- webgpu_inspector_cli-0.1.0/setup.cfg +4 -0
- webgpu_inspector_cli-0.1.0/setup.py +24 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/__init__.py +3 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/__main__.py +5 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/commands/__init__.py +0 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/commands/browser.py +106 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/commands/capture.py +197 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/commands/errors.py +82 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/commands/objects.py +129 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/commands/shaders.py +108 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/commands/status.py +79 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/core/__init__.py +0 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/core/bridge.py +189 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/core/session.py +40 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/js/collector.js +439 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/skills/SKILL.md +87 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/tests/__init__.py +0 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/tests/test_core.py +151 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/tests/test_full_e2e.py +200 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/utils/__init__.py +0 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/utils/repl_skin.py +523 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/webgpu_inspector_cli.py +91 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli.egg-info/PKG-INFO +14 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli.egg-info/SOURCES.txt +27 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli.egg-info/dependency_links.txt +1 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli.egg-info/entry_points.txt +2 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli.egg-info/requires.txt +4 -0
- webgpu_inspector_cli-0.1.0/webgpu_inspector_cli.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: webgpu-inspector-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool for WebGPU Inspector - debug WebGPU applications from the command line
|
|
5
|
+
Author: Arvind Sekar
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: click>=8.0
|
|
8
|
+
Requires-Dist: playwright>=1.40
|
|
9
|
+
Requires-Dist: Pillow>=10.0
|
|
10
|
+
Requires-Dist: prompt_toolkit>=3.0
|
|
11
|
+
Dynamic: author
|
|
12
|
+
Dynamic: requires-dist
|
|
13
|
+
Dynamic: requires-python
|
|
14
|
+
Dynamic: summary
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="webgpu-inspector-cli",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
description="CLI tool for WebGPU Inspector - debug WebGPU applications from the command line",
|
|
7
|
+
author="Arvind Sekar",
|
|
8
|
+
python_requires=">=3.10",
|
|
9
|
+
packages=find_packages(),
|
|
10
|
+
package_data={
|
|
11
|
+
"webgpu_inspector_cli": ["js/*.js", "skills/*.md"],
|
|
12
|
+
},
|
|
13
|
+
install_requires=[
|
|
14
|
+
"click>=8.0",
|
|
15
|
+
"playwright>=1.40",
|
|
16
|
+
"Pillow>=10.0",
|
|
17
|
+
"prompt_toolkit>=3.0",
|
|
18
|
+
],
|
|
19
|
+
entry_points={
|
|
20
|
+
"console_scripts": [
|
|
21
|
+
"webgpu-inspector-cli=webgpu_inspector_cli.webgpu_inspector_cli:cli",
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Browser session management commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from webgpu_inspector_cli.core.bridge import get_bridge, require_bridge
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group()
|
|
10
|
+
def browser():
|
|
11
|
+
"""Browser session management."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@browser.command()
|
|
16
|
+
@click.option("--url", required=True, help="URL to navigate to.")
|
|
17
|
+
@click.option("--headless", is_flag=True, default=False, help="Run in headless mode.")
|
|
18
|
+
@click.option("--gpu-backend", type=str, default=None, help="GPU backend (e.g., swiftshader).")
|
|
19
|
+
@click.pass_context
|
|
20
|
+
def launch(ctx, url, headless, gpu_backend):
|
|
21
|
+
"""Launch browser, navigate to URL, and inject the WebGPU Inspector."""
|
|
22
|
+
bridge = get_bridge()
|
|
23
|
+
if bridge.is_connected:
|
|
24
|
+
click.echo("Browser session already active. Close it first with 'browser close'.")
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
bridge.launch(url, headless=headless, gpu_backend=gpu_backend)
|
|
28
|
+
info = bridge.get_browser_info()
|
|
29
|
+
|
|
30
|
+
if ctx.obj.get("json"):
|
|
31
|
+
click.echo(json.dumps({
|
|
32
|
+
"status": "launched",
|
|
33
|
+
"url": info["url"],
|
|
34
|
+
"title": info["title"],
|
|
35
|
+
"gpu": info["gpu"],
|
|
36
|
+
}, indent=2))
|
|
37
|
+
else:
|
|
38
|
+
click.echo(f"Browser launched: {info['url']}")
|
|
39
|
+
click.echo(f" Title: {info['title']}")
|
|
40
|
+
click.echo(f" GPU: {info['gpu']}")
|
|
41
|
+
click.echo("Inspector injected and active.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@browser.command()
|
|
45
|
+
@click.pass_context
|
|
46
|
+
def close(ctx):
|
|
47
|
+
"""Close the browser session."""
|
|
48
|
+
bridge = get_bridge()
|
|
49
|
+
if not bridge.is_connected:
|
|
50
|
+
click.echo("No active browser session.")
|
|
51
|
+
return
|
|
52
|
+
bridge.close()
|
|
53
|
+
if ctx.obj.get("json"):
|
|
54
|
+
click.echo(json.dumps({"status": "closed"}))
|
|
55
|
+
else:
|
|
56
|
+
click.echo("Browser session closed.")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@browser.command()
|
|
60
|
+
@click.option("--url", required=True, help="URL to navigate to.")
|
|
61
|
+
@click.pass_context
|
|
62
|
+
def navigate(ctx, url):
|
|
63
|
+
"""Navigate to a new URL and re-inject the inspector."""
|
|
64
|
+
bridge = require_bridge()
|
|
65
|
+
bridge.navigate(url)
|
|
66
|
+
info = bridge.get_browser_info()
|
|
67
|
+
|
|
68
|
+
if ctx.obj.get("json"):
|
|
69
|
+
click.echo(json.dumps({
|
|
70
|
+
"status": "navigated",
|
|
71
|
+
"url": info["url"],
|
|
72
|
+
"title": info["title"],
|
|
73
|
+
}, indent=2))
|
|
74
|
+
else:
|
|
75
|
+
click.echo(f"Navigated to: {info['url']}")
|
|
76
|
+
click.echo(f" Title: {info['title']}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@browser.command()
|
|
80
|
+
@click.option("--output", "-o", required=True, help="Output file path.")
|
|
81
|
+
@click.option("--full-page", is_flag=True, default=False, help="Capture full scrollable page.")
|
|
82
|
+
@click.pass_context
|
|
83
|
+
def screenshot(ctx, output, full_page):
|
|
84
|
+
"""Take a screenshot of the current page."""
|
|
85
|
+
bridge = require_bridge()
|
|
86
|
+
path = bridge.screenshot(output, full_page=full_page)
|
|
87
|
+
|
|
88
|
+
if ctx.obj.get("json"):
|
|
89
|
+
click.echo(json.dumps({"status": "saved", "path": path}))
|
|
90
|
+
else:
|
|
91
|
+
click.echo(f"Screenshot saved: {path}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@browser.command("status")
|
|
95
|
+
@click.pass_context
|
|
96
|
+
def browser_status(ctx):
|
|
97
|
+
"""Show browser and GPU status information."""
|
|
98
|
+
bridge = require_bridge()
|
|
99
|
+
info = bridge.get_browser_info()
|
|
100
|
+
|
|
101
|
+
if ctx.obj.get("json"):
|
|
102
|
+
click.echo(json.dumps(info, indent=2))
|
|
103
|
+
else:
|
|
104
|
+
click.echo(f"URL: {info['url']}")
|
|
105
|
+
click.echo(f"Title: {info['title']}")
|
|
106
|
+
click.echo(f"GPU: {info['gpu']}")
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Frame capture and data inspection commands."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from webgpu_inspector_cli.core.bridge import require_bridge
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
def capture():
|
|
13
|
+
"""Frame capture and data inspection."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@capture.command()
|
|
18
|
+
@click.option("--timeout", type=float, default=30.0, help="Seconds to wait for capture.")
|
|
19
|
+
@click.option("--poll-interval", type=float, default=0.5, help="Seconds between status polls.")
|
|
20
|
+
@click.pass_context
|
|
21
|
+
def frame(ctx, timeout, poll_interval):
|
|
22
|
+
"""Capture the next frame's GPU commands."""
|
|
23
|
+
bridge = require_bridge()
|
|
24
|
+
|
|
25
|
+
# Trigger capture
|
|
26
|
+
bridge.query("requestCapture", {})
|
|
27
|
+
|
|
28
|
+
if not ctx.obj.get("json"):
|
|
29
|
+
click.echo("Capture requested, waiting for frame...")
|
|
30
|
+
|
|
31
|
+
# Poll for completion
|
|
32
|
+
start = time.time()
|
|
33
|
+
while time.time() - start < timeout:
|
|
34
|
+
status = bridge.query("getCaptureStatus")
|
|
35
|
+
if status == "complete":
|
|
36
|
+
break
|
|
37
|
+
time.sleep(poll_interval)
|
|
38
|
+
else:
|
|
39
|
+
msg = f"Capture timed out after {timeout}s"
|
|
40
|
+
if ctx.obj.get("json"):
|
|
41
|
+
click.echo(json.dumps({"status": "timeout", "error": msg}))
|
|
42
|
+
else:
|
|
43
|
+
click.echo(msg, err=True)
|
|
44
|
+
raise SystemExit(1)
|
|
45
|
+
|
|
46
|
+
results = bridge.query("getCapturedFrameResults")
|
|
47
|
+
if ctx.obj.get("json"):
|
|
48
|
+
click.echo(json.dumps({"status": "complete", "results": results}, indent=2))
|
|
49
|
+
else:
|
|
50
|
+
click.echo("Frame captured successfully.")
|
|
51
|
+
if results:
|
|
52
|
+
click.echo(f" Frame: {results.get('frame', '?')}")
|
|
53
|
+
click.echo(f" Commands: {results.get('count', '?')}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@capture.command()
|
|
57
|
+
@click.option("--pass-index", type=int, default=None, help="Filter by render pass index.")
|
|
58
|
+
@click.pass_context
|
|
59
|
+
def commands(ctx, pass_index):
|
|
60
|
+
"""List GPU commands from a captured frame."""
|
|
61
|
+
bridge = require_bridge()
|
|
62
|
+
|
|
63
|
+
status = bridge.query("getCaptureStatus")
|
|
64
|
+
if status != "complete":
|
|
65
|
+
click.echo("No captured frame. Run 'capture frame' first.", err=True)
|
|
66
|
+
raise SystemExit(1)
|
|
67
|
+
|
|
68
|
+
results = bridge.query("getCapturedFrameResults")
|
|
69
|
+
if not results:
|
|
70
|
+
click.echo("No capture data available.", err=True)
|
|
71
|
+
raise SystemExit(1)
|
|
72
|
+
|
|
73
|
+
if ctx.obj.get("json"):
|
|
74
|
+
click.echo(json.dumps(results, indent=2))
|
|
75
|
+
else:
|
|
76
|
+
batches = results.get("batches", [])
|
|
77
|
+
if not batches:
|
|
78
|
+
click.echo("No command batches in capture.")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
for i, batch in enumerate(batches):
|
|
82
|
+
if pass_index is not None and i != pass_index:
|
|
83
|
+
continue
|
|
84
|
+
cmds = batch.get("commands", batch) if isinstance(batch, dict) else batch
|
|
85
|
+
click.echo(f"Batch {i}:")
|
|
86
|
+
if isinstance(cmds, list):
|
|
87
|
+
for cmd in cmds:
|
|
88
|
+
if isinstance(cmd, dict):
|
|
89
|
+
click.echo(f" {cmd.get('method', cmd.get('name', str(cmd)))}")
|
|
90
|
+
else:
|
|
91
|
+
click.echo(f" {cmd}")
|
|
92
|
+
else:
|
|
93
|
+
click.echo(f" {cmds}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@capture.command()
|
|
97
|
+
@click.option("--id", "tex_id", type=int, required=True, help="Texture object ID.")
|
|
98
|
+
@click.option("--mip-level", type=int, default=0, help="Mip level to capture.")
|
|
99
|
+
@click.option("--output", "-o", type=str, default=None, help="Save as PNG to path.")
|
|
100
|
+
@click.option("--timeout", type=float, default=30.0, help="Seconds to wait for texture data.")
|
|
101
|
+
@click.pass_context
|
|
102
|
+
def texture(ctx, tex_id, mip_level, output, timeout):
|
|
103
|
+
"""Read texture data from a live or captured texture."""
|
|
104
|
+
bridge = require_bridge()
|
|
105
|
+
|
|
106
|
+
# Request the texture data
|
|
107
|
+
bridge.query("requestTexture", tex_id, mip_level)
|
|
108
|
+
|
|
109
|
+
if not ctx.obj.get("json"):
|
|
110
|
+
click.echo(f"Requesting texture #{tex_id} mip {mip_level}...")
|
|
111
|
+
|
|
112
|
+
# Poll for data
|
|
113
|
+
start = time.time()
|
|
114
|
+
while time.time() - start < timeout:
|
|
115
|
+
data = bridge.query("getTextureData", tex_id)
|
|
116
|
+
if data and data.get("complete"):
|
|
117
|
+
break
|
|
118
|
+
time.sleep(0.5)
|
|
119
|
+
else:
|
|
120
|
+
msg = f"Texture data timed out after {timeout}s"
|
|
121
|
+
if ctx.obj.get("json"):
|
|
122
|
+
click.echo(json.dumps({"status": "timeout", "error": msg}))
|
|
123
|
+
else:
|
|
124
|
+
click.echo(msg, err=True)
|
|
125
|
+
raise SystemExit(1)
|
|
126
|
+
|
|
127
|
+
if output and data.get("data"):
|
|
128
|
+
# The data is base64 encoded - decode and save as raw or convert to PNG
|
|
129
|
+
try:
|
|
130
|
+
raw = base64.b64decode(data["data"].split(",")[-1] if "," in data["data"] else data["data"])
|
|
131
|
+
# Get texture info for dimensions
|
|
132
|
+
obj = bridge.query("getObject", tex_id)
|
|
133
|
+
desc = obj.get("descriptor", {}) if obj else {}
|
|
134
|
+
width = desc.get("size", {}).get("width", desc.get("size", [0])[0] if isinstance(desc.get("size"), list) else 0)
|
|
135
|
+
height = desc.get("size", {}).get("height", desc.get("size", [0, 0])[1] if isinstance(desc.get("size"), list) and len(desc.get("size", [])) > 1 else 0)
|
|
136
|
+
|
|
137
|
+
if output.endswith(".png") and width and height:
|
|
138
|
+
try:
|
|
139
|
+
from PIL import Image
|
|
140
|
+
img = Image.frombytes("RGBA", (width, height), raw)
|
|
141
|
+
img.save(output)
|
|
142
|
+
except Exception:
|
|
143
|
+
# Fallback: save raw bytes
|
|
144
|
+
with open(output, "wb") as f:
|
|
145
|
+
f.write(raw)
|
|
146
|
+
else:
|
|
147
|
+
with open(output, "wb") as f:
|
|
148
|
+
f.write(raw)
|
|
149
|
+
|
|
150
|
+
if ctx.obj.get("json"):
|
|
151
|
+
click.echo(json.dumps({"status": "saved", "path": output, "size": len(raw)}))
|
|
152
|
+
else:
|
|
153
|
+
click.echo(f"Texture saved: {output} ({len(raw)} bytes)")
|
|
154
|
+
except Exception as e:
|
|
155
|
+
click.echo(f"Error saving texture: {e}", err=True)
|
|
156
|
+
raise SystemExit(1)
|
|
157
|
+
else:
|
|
158
|
+
if ctx.obj.get("json"):
|
|
159
|
+
click.echo(json.dumps({
|
|
160
|
+
"status": "complete",
|
|
161
|
+
"textureId": tex_id,
|
|
162
|
+
"mipLevel": mip_level,
|
|
163
|
+
"dataSize": len(data.get("data", "")) if data else 0,
|
|
164
|
+
}, indent=2))
|
|
165
|
+
else:
|
|
166
|
+
click.echo(f"Texture #{tex_id} data received ({data.get('totalChunks', 0)} chunks)")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@capture.command()
|
|
170
|
+
@click.option("--id", "buf_id", type=int, required=True, help="Buffer object ID.")
|
|
171
|
+
@click.option("--offset", type=int, default=0, help="Byte offset into buffer.")
|
|
172
|
+
@click.option("--size", "read_size", type=int, default=None, help="Number of bytes to read.")
|
|
173
|
+
@click.option("--format", "fmt", type=click.Choice(["hex", "float32", "uint32", "raw"]),
|
|
174
|
+
default="hex", help="Display format.")
|
|
175
|
+
@click.pass_context
|
|
176
|
+
def buffer(ctx, buf_id, offset, read_size, fmt):
|
|
177
|
+
"""Read buffer contents."""
|
|
178
|
+
bridge = require_bridge()
|
|
179
|
+
data = bridge.query("getBufferData", buf_id)
|
|
180
|
+
|
|
181
|
+
if not data:
|
|
182
|
+
click.echo(f"No buffer data for #{buf_id}. Capture a frame first.", err=True)
|
|
183
|
+
raise SystemExit(1)
|
|
184
|
+
|
|
185
|
+
if ctx.obj.get("json"):
|
|
186
|
+
click.echo(json.dumps({
|
|
187
|
+
"bufferId": buf_id,
|
|
188
|
+
"offset": data.get("offset", 0),
|
|
189
|
+
"size": data.get("size", 0),
|
|
190
|
+
"data": data.get("data"),
|
|
191
|
+
}, indent=2))
|
|
192
|
+
else:
|
|
193
|
+
click.echo(f"Buffer #{buf_id}:")
|
|
194
|
+
click.echo(f" Offset: {data.get('offset', 0)}")
|
|
195
|
+
click.echo(f" Size: {data.get('size', 0)} bytes")
|
|
196
|
+
if data.get("data"):
|
|
197
|
+
click.echo(f" Data (first 256 chars): {str(data['data'])[:256]}")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Validation error tracking commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from webgpu_inspector_cli.core.bridge import require_bridge
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
def errors():
|
|
12
|
+
"""Validation error tracking."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@errors.command("list")
|
|
17
|
+
@click.pass_context
|
|
18
|
+
def list_errors(ctx):
|
|
19
|
+
"""List all validation errors."""
|
|
20
|
+
bridge = require_bridge()
|
|
21
|
+
result = bridge.query("getErrors")
|
|
22
|
+
|
|
23
|
+
if ctx.obj.get("json"):
|
|
24
|
+
click.echo(json.dumps({"errors": result, "count": len(result)}, indent=2))
|
|
25
|
+
else:
|
|
26
|
+
if not result:
|
|
27
|
+
click.echo("No validation errors.")
|
|
28
|
+
return
|
|
29
|
+
for err in result:
|
|
30
|
+
click.echo(f"Error #{err['id']}:")
|
|
31
|
+
click.echo(f" Message: {err['message']}")
|
|
32
|
+
if err.get("objectId"):
|
|
33
|
+
click.echo(f" Object: #{err['objectId']}")
|
|
34
|
+
if err.get("stacktrace"):
|
|
35
|
+
click.echo(" Stacktrace:")
|
|
36
|
+
for line in err["stacktrace"].split("\n"):
|
|
37
|
+
if line.strip():
|
|
38
|
+
click.echo(f" {line.strip()}")
|
|
39
|
+
click.echo()
|
|
40
|
+
click.echo(f"Total: {len(result)} errors")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@errors.command()
|
|
44
|
+
@click.option("--timeout", type=float, default=30.0, help="Seconds to watch for errors.")
|
|
45
|
+
@click.option("--poll-interval", type=float, default=1.0, help="Seconds between polls.")
|
|
46
|
+
@click.pass_context
|
|
47
|
+
def watch(ctx, timeout, poll_interval):
|
|
48
|
+
"""Watch for new validation errors in real-time."""
|
|
49
|
+
bridge = require_bridge()
|
|
50
|
+
seen_count = bridge.query("getErrorCount")
|
|
51
|
+
use_json = ctx.obj.get("json")
|
|
52
|
+
|
|
53
|
+
click.echo(f"Watching for errors (timeout: {timeout}s)..." if not use_json else "", nl=not use_json)
|
|
54
|
+
|
|
55
|
+
start = time.time()
|
|
56
|
+
while time.time() - start < timeout:
|
|
57
|
+
current_count = bridge.query("getErrorCount")
|
|
58
|
+
if current_count > seen_count:
|
|
59
|
+
all_errors = bridge.query("getErrors")
|
|
60
|
+
new_errors = all_errors[seen_count:]
|
|
61
|
+
for err in new_errors:
|
|
62
|
+
if use_json:
|
|
63
|
+
click.echo(json.dumps(err))
|
|
64
|
+
else:
|
|
65
|
+
click.echo(f"[{err['id']}] {err['message']}")
|
|
66
|
+
seen_count = current_count
|
|
67
|
+
time.sleep(poll_interval)
|
|
68
|
+
|
|
69
|
+
if not use_json:
|
|
70
|
+
click.echo(f"Watch ended. Total errors: {seen_count}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@errors.command()
|
|
74
|
+
@click.pass_context
|
|
75
|
+
def clear(ctx):
|
|
76
|
+
"""Clear error history."""
|
|
77
|
+
bridge = require_bridge()
|
|
78
|
+
bridge.query("clearErrors")
|
|
79
|
+
if ctx.obj.get("json"):
|
|
80
|
+
click.echo(json.dumps({"status": "cleared"}))
|
|
81
|
+
else:
|
|
82
|
+
click.echo("Errors cleared.")
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""GPU object inspection commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from webgpu_inspector_cli.core.bridge import require_bridge
|
|
7
|
+
|
|
8
|
+
GPU_TYPES = [
|
|
9
|
+
"Adapter", "Device", "Buffer", "Texture", "TextureView", "Sampler",
|
|
10
|
+
"ShaderModule", "BindGroup", "BindGroupLayout", "PipelineLayout",
|
|
11
|
+
"RenderPipeline", "ComputePipeline", "RenderBundle",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _format_bytes(n):
|
|
16
|
+
if n is None or n == 0:
|
|
17
|
+
return "0 B"
|
|
18
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
19
|
+
if abs(n) < 1024:
|
|
20
|
+
return f"{n:.1f} {unit}" if unit != "B" else f"{n} {unit}"
|
|
21
|
+
n /= 1024
|
|
22
|
+
return f"{n:.1f} TB"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group()
|
|
26
|
+
def objects():
|
|
27
|
+
"""GPU object inspection."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@objects.command("list")
|
|
32
|
+
@click.option("--type", "obj_type", type=click.Choice(GPU_TYPES, case_sensitive=False),
|
|
33
|
+
default=None, help="Filter by object type.")
|
|
34
|
+
@click.pass_context
|
|
35
|
+
def list_objects(ctx, obj_type):
|
|
36
|
+
"""List all live GPU objects."""
|
|
37
|
+
bridge = require_bridge()
|
|
38
|
+
result = bridge.query("getObjects", obj_type)
|
|
39
|
+
|
|
40
|
+
if ctx.obj.get("json"):
|
|
41
|
+
click.echo(json.dumps({"objects": result, "count": len(result)}, indent=2))
|
|
42
|
+
else:
|
|
43
|
+
if not result:
|
|
44
|
+
click.echo("No GPU objects found.")
|
|
45
|
+
return
|
|
46
|
+
click.echo(f"{'ID':>6} {'Type':<20} {'Label':<30} {'Size':>12}")
|
|
47
|
+
click.echo("-" * 72)
|
|
48
|
+
for obj in result:
|
|
49
|
+
size_str = ""
|
|
50
|
+
if obj.get("size"):
|
|
51
|
+
size_str = _format_bytes(obj["size"])
|
|
52
|
+
elif obj.get("gpuSize"):
|
|
53
|
+
size_str = _format_bytes(obj["gpuSize"])
|
|
54
|
+
label = obj.get("label") or ""
|
|
55
|
+
click.echo(f"{obj['id']:>6} {obj['type']:<20} {label:<30} {size_str:>12}")
|
|
56
|
+
click.echo(f"\nTotal: {len(result)} objects")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@objects.command()
|
|
60
|
+
@click.option("--id", "obj_id", type=int, required=True, help="Object ID.")
|
|
61
|
+
@click.pass_context
|
|
62
|
+
def inspect(ctx, obj_id):
|
|
63
|
+
"""Detailed view of a single GPU object."""
|
|
64
|
+
bridge = require_bridge()
|
|
65
|
+
obj = bridge.query("getObject", obj_id)
|
|
66
|
+
|
|
67
|
+
if obj is None:
|
|
68
|
+
click.echo(f"Object {obj_id} not found.", err=True)
|
|
69
|
+
raise SystemExit(1)
|
|
70
|
+
|
|
71
|
+
if ctx.obj.get("json"):
|
|
72
|
+
click.echo(json.dumps(obj, indent=2))
|
|
73
|
+
else:
|
|
74
|
+
click.echo(f"Object #{obj['id']} ({obj['type']})")
|
|
75
|
+
if obj.get("label"):
|
|
76
|
+
click.echo(f" Label: {obj['label']}")
|
|
77
|
+
if obj.get("parent") is not None:
|
|
78
|
+
click.echo(f" Parent: #{obj['parent']}")
|
|
79
|
+
if obj.get("pending"):
|
|
80
|
+
click.echo(" Status: pending (async)")
|
|
81
|
+
if obj.get("size"):
|
|
82
|
+
click.echo(f" Size: {_format_bytes(obj['size'])}")
|
|
83
|
+
if obj.get("gpuSize"):
|
|
84
|
+
click.echo(f" GPU Memory: {_format_bytes(obj['gpuSize'])}")
|
|
85
|
+
if obj.get("descriptor"):
|
|
86
|
+
click.echo(" Descriptor:")
|
|
87
|
+
desc_str = json.dumps(obj["descriptor"], indent=4)
|
|
88
|
+
for line in desc_str.split("\n"):
|
|
89
|
+
click.echo(f" {line}")
|
|
90
|
+
if obj.get("stacktrace"):
|
|
91
|
+
click.echo(" Creation stacktrace:")
|
|
92
|
+
for line in obj["stacktrace"].split("\n"):
|
|
93
|
+
if line.strip():
|
|
94
|
+
click.echo(f" {line.strip()}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@objects.command()
|
|
98
|
+
@click.option("--label", required=True, help="Label substring to search for.")
|
|
99
|
+
@click.pass_context
|
|
100
|
+
def search(ctx, label):
|
|
101
|
+
"""Find objects by label."""
|
|
102
|
+
bridge = require_bridge()
|
|
103
|
+
all_objs = bridge.query("getObjects", None)
|
|
104
|
+
matched = [o for o in all_objs if o.get("label") and label.lower() in o["label"].lower()]
|
|
105
|
+
|
|
106
|
+
if ctx.obj.get("json"):
|
|
107
|
+
click.echo(json.dumps({"objects": matched, "count": len(matched)}, indent=2))
|
|
108
|
+
else:
|
|
109
|
+
if not matched:
|
|
110
|
+
click.echo(f"No objects matching '{label}'.")
|
|
111
|
+
return
|
|
112
|
+
for obj in matched:
|
|
113
|
+
click.echo(f" #{obj['id']} {obj['type']}: {obj.get('label', '')}")
|
|
114
|
+
click.echo(f"\nFound: {len(matched)} objects")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@objects.command()
|
|
118
|
+
@click.pass_context
|
|
119
|
+
def memory(ctx):
|
|
120
|
+
"""Show GPU memory usage breakdown."""
|
|
121
|
+
bridge = require_bridge()
|
|
122
|
+
mem = bridge.query("getMemoryUsage")
|
|
123
|
+
|
|
124
|
+
if ctx.obj.get("json"):
|
|
125
|
+
click.echo(json.dumps(mem, indent=2))
|
|
126
|
+
else:
|
|
127
|
+
click.echo(f"Texture memory: {_format_bytes(mem['totalTextureMemory'])}")
|
|
128
|
+
click.echo(f"Buffer memory: {_format_bytes(mem['totalBufferMemory'])}")
|
|
129
|
+
click.echo(f"Total: {_format_bytes(mem['totalMemory'])}")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Shader module inspection commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from webgpu_inspector_cli.core.bridge import require_bridge
|
|
7
|
+
from webgpu_inspector_cli.core.session import get_session
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
def shaders():
|
|
12
|
+
"""Shader module inspection."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@shaders.command("list")
|
|
17
|
+
@click.pass_context
|
|
18
|
+
def list_shaders(ctx):
|
|
19
|
+
"""List all shader modules."""
|
|
20
|
+
bridge = require_bridge()
|
|
21
|
+
result = bridge.query("getObjects", "ShaderModule")
|
|
22
|
+
|
|
23
|
+
if ctx.obj.get("json"):
|
|
24
|
+
click.echo(json.dumps({"shaders": result, "count": len(result)}, indent=2))
|
|
25
|
+
else:
|
|
26
|
+
if not result:
|
|
27
|
+
click.echo("No shader modules found.")
|
|
28
|
+
return
|
|
29
|
+
click.echo(f"{'ID':>6} {'Label':<30} {'Size':>10}")
|
|
30
|
+
click.echo("-" * 50)
|
|
31
|
+
for obj in result:
|
|
32
|
+
label = obj.get("label") or ""
|
|
33
|
+
size = obj.get("size", 0)
|
|
34
|
+
click.echo(f"{obj['id']:>6} {label:<30} {size:>10} chars")
|
|
35
|
+
click.echo(f"\nTotal: {len(result)} shader modules")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@shaders.command()
|
|
39
|
+
@click.option("--id", "shader_id", type=int, required=True, help="Shader module ID.")
|
|
40
|
+
@click.pass_context
|
|
41
|
+
def view(ctx, shader_id):
|
|
42
|
+
"""View WGSL source code of a shader module."""
|
|
43
|
+
bridge = require_bridge()
|
|
44
|
+
code = bridge.query("getShaderCode", shader_id)
|
|
45
|
+
|
|
46
|
+
if code is None:
|
|
47
|
+
click.echo(f"Shader #{shader_id} not found or has no code.", err=True)
|
|
48
|
+
raise SystemExit(1)
|
|
49
|
+
|
|
50
|
+
if ctx.obj.get("json"):
|
|
51
|
+
click.echo(json.dumps({"shaderId": shader_id, "code": code}, indent=2))
|
|
52
|
+
else:
|
|
53
|
+
click.echo(f"--- Shader #{shader_id} ---")
|
|
54
|
+
click.echo(code)
|
|
55
|
+
click.echo(f"--- End ({len(code)} chars) ---")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@shaders.command("compile")
|
|
59
|
+
@click.option("--id", "shader_id", type=int, required=True, help="Shader module ID.")
|
|
60
|
+
@click.option("--file", "code_file", type=click.Path(exists=True), default=None,
|
|
61
|
+
help="Path to WGSL file.")
|
|
62
|
+
@click.option("--code", "code_str", type=str, default=None,
|
|
63
|
+
help="WGSL code string (alternative to --file).")
|
|
64
|
+
@click.pass_context
|
|
65
|
+
def compile_shader(ctx, shader_id, code_file, code_str):
|
|
66
|
+
"""Hot-replace shader code with new WGSL source."""
|
|
67
|
+
if not code_file and not code_str:
|
|
68
|
+
click.echo("Provide either --file or --code.", err=True)
|
|
69
|
+
raise SystemExit(1)
|
|
70
|
+
|
|
71
|
+
bridge = require_bridge()
|
|
72
|
+
|
|
73
|
+
# Save original for undo
|
|
74
|
+
session = get_session()
|
|
75
|
+
original = bridge.query("getShaderCode", shader_id)
|
|
76
|
+
if original:
|
|
77
|
+
session.push_shader_edit(shader_id, original)
|
|
78
|
+
|
|
79
|
+
if code_file:
|
|
80
|
+
with open(code_file) as f:
|
|
81
|
+
code = f.read()
|
|
82
|
+
else:
|
|
83
|
+
code = code_str
|
|
84
|
+
|
|
85
|
+
bridge.query("compileShader", shader_id, code)
|
|
86
|
+
|
|
87
|
+
if ctx.obj.get("json"):
|
|
88
|
+
click.echo(json.dumps({"status": "compiled", "shaderId": shader_id, "codeLength": len(code)}))
|
|
89
|
+
else:
|
|
90
|
+
click.echo(f"Shader #{shader_id} recompiled ({len(code)} chars)")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@shaders.command("revert")
|
|
94
|
+
@click.option("--id", "shader_id", type=int, required=True, help="Shader module ID.")
|
|
95
|
+
@click.pass_context
|
|
96
|
+
def revert_shader(ctx, shader_id):
|
|
97
|
+
"""Revert shader to its original code."""
|
|
98
|
+
bridge = require_bridge()
|
|
99
|
+
bridge.query("revertShader", shader_id)
|
|
100
|
+
|
|
101
|
+
# Also clear session history for this shader
|
|
102
|
+
session = get_session()
|
|
103
|
+
session.clear_shader_edits(shader_id)
|
|
104
|
+
|
|
105
|
+
if ctx.obj.get("json"):
|
|
106
|
+
click.echo(json.dumps({"status": "reverted", "shaderId": shader_id}))
|
|
107
|
+
else:
|
|
108
|
+
click.echo(f"Shader #{shader_id} reverted to original.")
|