feat(inspect-ui): hierarchy + html panels in run detail

HierarchyPanel renders the captured DOM/AX tree with a filter input.
HtmlPanel renders the per-step HTML in an iframe (sandboxed) with a
toggle to view source. HTML tab only shows when the step actually has
HTML captured.
This commit is contained in:
pj committed 2026-04-27 18:54:02 +07:00
1 parent 26b27c09ed
commit 8e9ac938f3
8 files changed
+375

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect } from "bun:test";
import { htmlUrl, screenshotUrl } from "../api";
describe("htmlUrl", () => {
it("encodes runId and name", () => {
expect(htmlUrl("run-1", "step-00001.html")).toBe(
"/api/runs/run-1/html/step-00001.html",
);
expect(htmlUrl("run with spaces", "after step.html")).toBe(
"/api/runs/run%20with%20spaces/html/after%20step.html",
);
});
});
describe("screenshotUrl", () => {
it("encodes runId and name", () => {
expect(screenshotUrl("run-1", "step-00001.png")).toBe(
"/api/runs/run-1/screenshots/step-00001.png",
);
});
});
+4
View File
@@ -23,3 +23,7 @@ export function getStep(runId: string, index: number): Promise<Step> {
export function screenshotUrl(runId: string, name: string): string { export function screenshotUrl(runId: string, name: string): string {
return `/api/runs/${encodeURIComponent(runId)}/screenshots/${encodeURIComponent(name)}`; return `/api/runs/${encodeURIComponent(runId)}/screenshots/${encodeURIComponent(name)}`;
} }
export function htmlUrl(runId: string, name: string): string {
return `/api/runs/${encodeURIComponent(runId)}/html/${encodeURIComponent(name)}`;
}
+83
View File
@@ -0,0 +1,83 @@
.hierarchy-panel {
display: flex;
flex-direction: column;
gap: 8px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
}
.hierarchy-filter {
font-family: var(--font-mono);
font-size: 12px;
padding: 4px 8px;
border: 1px solid var(--border);
background: var(--surface-1);
color: var(--text-primary);
}
.hierarchy-table-wrap {
overflow: auto;
max-height: 480px;
border: 1px solid var(--border);
}
.hierarchy-table {
width: 100%;
border-collapse: collapse;
}
.hierarchy-table th {
position: sticky;
top: 0;
background: var(--surface-1);
text-align: left;
padding: 4px 8px;
font-weight: normal;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
}
.hierarchy-table td {
padding: 2px 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid var(--border-subtle, var(--border));
}
.hierarchy-table tr:hover td {
background: var(--surface-2, transparent);
}
.hierarchy-tag {
color: var(--text-secondary);
max-width: 140px;
}
.hierarchy-id {
color: var(--text-primary);
max-width: 160px;
}
.hierarchy-text {
max-width: 320px;
color: var(--text-primary);
}
.hierarchy-bounds {
color: var(--text-secondary);
text-align: right;
}
.hierarchy-empty {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
padding: 16px;
}
.hierarchy-count {
color: var(--text-secondary);
font-size: 11px;
}
+89
View File
@@ -0,0 +1,89 @@
import { useMemo, useState } from "react";
import type { HierarchyElement, Hierarchy } from "../types";
import "./HierarchyPanel.css";
interface HierarchyPanelProps {
hierarchy?: Hierarchy;
}
export default function HierarchyPanel({ hierarchy }: HierarchyPanelProps): JSX.Element {
const [filter, setFilter] = useState("");
const elements = hierarchy?.elements ?? [];
const rows = useMemo(() => {
const lower = filter.trim().toLowerCase();
if (!lower) return elements;
return elements.filter((element) => matches(element, lower));
}, [elements, filter]);
if (elements.length === 0) {
return <div className="hierarchy-empty">no hierarchy captured</div>;
}
return (
<div className="hierarchy-panel">
<input
type="text"
className="hierarchy-filter"
placeholder="filter by id, text, desc, class, tag..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<div className="hierarchy-table-wrap">
<table className="hierarchy-table">
<thead>
<tr>
<th>tag/class</th>
<th>id</th>
<th>text/desc</th>
<th>bounds</th>
</tr>
</thead>
<tbody>
{rows.map((element, index) => (
<HierarchyRow key={index} element={element} />
))}
</tbody>
</table>
</div>
<div className="hierarchy-count">
{rows.length} of {elements.length}
</div>
</div>
);
}
function HierarchyRow({ element }: { element: HierarchyElement }): JSX.Element {
const tag = element.tag ?? element.class ?? "";
const text = (element.text ?? element.description ?? "").slice(0, 80);
const bounds = element.bounds;
return (
<tr>
<td className="hierarchy-tag">{tag}</td>
<td className="hierarchy-id">{element.resourceId ?? ""}</td>
<td className="hierarchy-text">{text}</td>
<td className="hierarchy-bounds">
[{bounds.left},{bounds.top},{bounds.right},{bounds.bottom}]
</td>
</tr>
);
}
function matches(element: HierarchyElement, needle: string): boolean {
const fields = [
element.resourceId,
element.text,
element.description,
element.class,
element.tag,
];
for (const value of fields) {
if (value && value.toLowerCase().includes(needle)) return true;
}
if (element.attributes) {
for (const value of Object.values(element.attributes)) {
if (value.toLowerCase().includes(needle)) return true;
}
}
return false;
}
+60
View File
@@ -0,0 +1,60 @@
.html-panel {
display: flex;
flex-direction: column;
gap: 8px;
}
.html-toolbar {
display: flex;
gap: 4px;
}
.html-tab {
font-family: var(--font-mono);
font-size: 12px;
padding: 2px 10px;
background: var(--surface-1);
border: 1px solid var(--border);
color: var(--text-secondary);
cursor: pointer;
}
.html-tab-active {
color: var(--text-primary);
border-color: var(--border-strong, var(--border));
}
.html-frame {
width: 100%;
height: 480px;
border: 1px solid var(--border);
background: white;
}
.html-source {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-primary);
background: var(--surface-1);
border: 1px solid var(--border);
padding: 8px;
margin: 0;
max-height: 480px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.html-empty {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
padding: 16px;
}
.html-error {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-violation, #c33);
padding: 16px;
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
import { htmlUrl } from "../api";
import "./HtmlPanel.css";
interface HtmlPanelProps {
runId: string;
fileName: string;
available: boolean;
}
type ViewMode = "render" | "source";
export default function HtmlPanel({ runId, fileName, available }: HtmlPanelProps): JSX.Element {
const [mode, setMode] = useState<ViewMode>("render");
const [source, setSource] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const url = htmlUrl(runId, fileName);
useEffect(() => {
if (!available || mode !== "source") return;
let cancelled = false;
setSource(null);
setError(null);
fetch(url)
.then((response) => {
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return response.text();
})
.then((text) => {
if (!cancelled) setSource(text);
})
.catch((failure: unknown) => {
if (!cancelled) setError(failure instanceof Error ? failure.message : String(failure));
});
return () => {
cancelled = true;
};
}, [available, mode, url]);
if (!available) {
return <div className="html-empty">no html captured for this step</div>;
}
return (
<div className="html-panel">
<div className="html-toolbar">
<button
type="button"
className={mode === "render" ? "html-tab html-tab-active" : "html-tab"}
onClick={() => setMode("render")}
>
render
</button>
<button
type="button"
className={mode === "source" ? "html-tab html-tab-active" : "html-tab"}
onClick={() => setMode("source")}
>
source
</button>
</div>
{mode === "render" ? (
<iframe className="html-frame" src={url} sandbox="" title={fileName} />
) : error ? (
<div className="html-error">failed to load: {error}</div>
) : source === null ? (
<div className="html-empty">loading...</div>
) : (
<pre className="html-source">{source}</pre>
)}
</div>
);
}
+42
View File
@@ -3,6 +3,8 @@ import { Link } from "react-router-dom";
import { getRun, getStep, screenshotUrl } from "../api"; import { getRun, getStep, screenshotUrl } from "../api";
import type { Run, Step } from "../types"; import type { Run, Step } from "../types";
import ActionList from "../panels/ActionList"; import ActionList from "../panels/ActionList";
import HierarchyPanel from "../panels/HierarchyPanel";
import HtmlPanel from "../panels/HtmlPanel";
import Screenshot from "../panels/Screenshot"; import Screenshot from "../panels/Screenshot";
import SnapshotTable from "../panels/SnapshotTable"; import SnapshotTable from "../panels/SnapshotTable";
import ViolationsPanel from "../panels/ViolationsPanel"; import ViolationsPanel from "../panels/ViolationsPanel";
@@ -135,6 +137,26 @@ export default function RunDetail() {
/> />
), ),
}, },
{
id: "hierarchy",
label: "Hierarchy",
content: <HierarchyPanel hierarchy={currentStep?.hierarchy} />,
},
...(runId && currentStep?.html_available
? [
{
id: "html",
label: "HTML",
content: (
<HtmlPanel
runId={runId}
fileName={`step-${String(currentStep.step).padStart(5, "0")}.html`}
available
/>
),
} satisfies TabDefinition,
]
: []),
{ {
id: "properties", id: "properties",
label: "Properties", label: "Properties",
@@ -186,6 +208,26 @@ export default function RunDetail() {
/> />
), ),
}, },
{
id: "hierarchy",
label: "Hierarchy",
content: <HierarchyPanel hierarchy={nextStep?.hierarchy ?? currentStep?.hierarchy} />,
},
...(runId && currentStep?.html_available
? [
{
id: "html",
label: "HTML",
content: (
<HtmlPanel
runId={runId}
fileName={`step-${String(currentStep.step).padStart(5, "0")}-after.html`}
available
/>
),
} satisfies TabDefinition,
]
: []),
{ {
id: "properties", id: "properties",
label: "Properties", label: "Properties",
+3
View File
@@ -50,6 +50,7 @@ export interface HierarchyElement {
text?: string; text?: string;
description?: string; description?: string;
class?: string; class?: string;
tag?: string;
package?: string; package?: string;
clickable?: boolean; clickable?: boolean;
enabled?: boolean; enabled?: boolean;
@@ -57,6 +58,7 @@ export interface HierarchyElement {
focused?: boolean; focused?: boolean;
selected?: boolean; selected?: boolean;
bounds: BoundsRect; bounds: BoundsRect;
attributes?: Record<string, string>;
} }
export interface Hierarchy { export interface Hierarchy {
@@ -124,4 +126,5 @@ export interface Step {
hierarchy?: Hierarchy; hierarchy?: Hierarchy;
residuals?: Record<string, ResidualNode>; residuals?: Record<string, ResidualNode>;
metrics?: Metrics; metrics?: Metrics;
html_available?: boolean;
} }