diff --git a/inspect-ui/src/__tests__/api.test.ts b/inspect-ui/src/__tests__/api.test.ts new file mode 100644 index 0000000..13af2a3 --- /dev/null +++ b/inspect-ui/src/__tests__/api.test.ts @@ -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", + ); + }); +}); diff --git a/inspect-ui/src/api.ts b/inspect-ui/src/api.ts index ba1e97a..5db5695 100644 --- a/inspect-ui/src/api.ts +++ b/inspect-ui/src/api.ts @@ -23,3 +23,7 @@ export function getStep(runId: string, index: number): Promise { export function screenshotUrl(runId: string, name: string): string { return `/api/runs/${encodeURIComponent(runId)}/screenshots/${encodeURIComponent(name)}`; } + +export function htmlUrl(runId: string, name: string): string { + return `/api/runs/${encodeURIComponent(runId)}/html/${encodeURIComponent(name)}`; +} diff --git a/inspect-ui/src/panels/HierarchyPanel.css b/inspect-ui/src/panels/HierarchyPanel.css new file mode 100644 index 0000000..2acdbd1 --- /dev/null +++ b/inspect-ui/src/panels/HierarchyPanel.css @@ -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; +} diff --git a/inspect-ui/src/panels/HierarchyPanel.tsx b/inspect-ui/src/panels/HierarchyPanel.tsx new file mode 100644 index 0000000..aceed5d --- /dev/null +++ b/inspect-ui/src/panels/HierarchyPanel.tsx @@ -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
no hierarchy captured
; + } + + return ( +
+ setFilter(e.target.value)} + /> +
+ + + + + + + + + + + {rows.map((element, index) => ( + + ))} + +
tag/classidtext/descbounds
+
+
+ {rows.length} of {elements.length} +
+
+ ); +} + +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 ( + + {tag} + {element.resourceId ?? ""} + {text} + + [{bounds.left},{bounds.top},{bounds.right},{bounds.bottom}] + + + ); +} + +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; +} diff --git a/inspect-ui/src/panels/HtmlPanel.css b/inspect-ui/src/panels/HtmlPanel.css new file mode 100644 index 0000000..7e94687 --- /dev/null +++ b/inspect-ui/src/panels/HtmlPanel.css @@ -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; +} diff --git a/inspect-ui/src/panels/HtmlPanel.tsx b/inspect-ui/src/panels/HtmlPanel.tsx new file mode 100644 index 0000000..5155a6c --- /dev/null +++ b/inspect-ui/src/panels/HtmlPanel.tsx @@ -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("render"); + const [source, setSource] = useState(null); + const [error, setError] = useState(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
no html captured for this step
; + } + + return ( +
+
+ + +
+ {mode === "render" ? ( +