mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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:
1 parent
26b27c09ed
commit
8e9ac938f3
8 files changed
+375
No files matched your search
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user