mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat(sdk-android): snapshot property delegate + feature-scoped files + screen fix (#34)
* rename web to inspect ui * feat(sdk-android): add camelToSnakeCase conversion * feat(sdk-android): add snapshot() property delegate with tests * feat(folio): add feature-scoped sanderling snapshot objects * refactor(folio): slim FolioApplication to snapshot object references * fix(folio): rename route snapshot to screen, update spec.ts
This commit is contained in:
51 files changed
+297
-84
No files matched your search
@@ -0,0 +1,32 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes, useParams } from "react-router-dom";
|
||||
import RunList from "./routes/RunList";
|
||||
import RunDetail from "./routes/RunDetail";
|
||||
|
||||
function RunIndexRedirect() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
if (!id) {
|
||||
return <NotFound />;
|
||||
}
|
||||
return <Navigate to={`/runs/${id}/steps/1`} replace />;
|
||||
}
|
||||
|
||||
function NotFound() {
|
||||
return <div className="status-block">not found</div>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="app-shell">
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route path="/" element={<RunList />} />
|
||||
<Route path="/runs/:id" element={<RunIndexRedirect />} />
|
||||
<Route path="/runs/:id/steps/:step" element={<RunDetail />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Run, RunSummary, Step } from "./types";
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(path, { headers: { Accept: "application/json" } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`request failed: ${response.status} ${response.statusText} (${path})`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function listRuns(): Promise<RunSummary[]> {
|
||||
return getJson<RunSummary[]>("/api/runs");
|
||||
}
|
||||
|
||||
export function getRun(runId: string): Promise<Run> {
|
||||
return getJson<Run>(`/api/runs/${encodeURIComponent(runId)}`);
|
||||
}
|
||||
|
||||
export function getStep(runId: string, index: number): Promise<Step> {
|
||||
return getJson<Step>(`/api/runs/${encodeURIComponent(runId)}/steps/${index}`);
|
||||
}
|
||||
|
||||
export function screenshotUrl(runId: string, name: string): string {
|
||||
return `/api/runs/${encodeURIComponent(runId)}/screenshots/${encodeURIComponent(name)}`;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
.residual {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.residual-leaf,
|
||||
.residual-unary,
|
||||
.residual-binary,
|
||||
.residual-group {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.residual-unary,
|
||||
.residual-binary {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.op-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
padding: 1px 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.residual-bound {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.residual-error {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--accent-violation);
|
||||
border-radius: 3px;
|
||||
color: var(--accent-violation);
|
||||
}
|
||||
|
||||
.residual-error .op-label {
|
||||
color: var(--accent-violation);
|
||||
border-color: var(--accent-violation);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.residual-error-message {
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ResidualNode } from "../types";
|
||||
import "./ResidualNode.css";
|
||||
|
||||
export interface ResidualNodeProps {
|
||||
node: ResidualNode;
|
||||
}
|
||||
|
||||
function OperatorLabel({ children }: { children: string }) {
|
||||
return <span className="op-label">{children}</span>;
|
||||
}
|
||||
|
||||
function Wrapped({ node }: { node: ResidualNode }) {
|
||||
return (
|
||||
<span className="residual-group">
|
||||
(<ResidualNodeView node={node} />)
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ResidualNodeView({ node }: ResidualNodeProps) {
|
||||
switch (node.op) {
|
||||
case "true":
|
||||
return <span className="residual-leaf">true</span>;
|
||||
case "false":
|
||||
return <span className="residual-leaf">false</span>;
|
||||
case "predicate":
|
||||
return (
|
||||
<span className="residual-leaf">
|
||||
<OperatorLabel>pred</OperatorLabel>
|
||||
{node.name ? <>({node.name})</> : null}
|
||||
</span>
|
||||
);
|
||||
case "error":
|
||||
return (
|
||||
<span className="residual-error" role="status">
|
||||
<OperatorLabel>error</OperatorLabel>
|
||||
<span className="residual-error-message">{node.message}</span>
|
||||
</span>
|
||||
);
|
||||
case "always":
|
||||
case "now":
|
||||
case "next":
|
||||
case "not":
|
||||
return (
|
||||
<span className="residual-unary">
|
||||
<OperatorLabel>{node.op}</OperatorLabel>
|
||||
<Wrapped node={node.arg} />
|
||||
</span>
|
||||
);
|
||||
case "eventually":
|
||||
return (
|
||||
<span className="residual-unary">
|
||||
<OperatorLabel>eventually</OperatorLabel>
|
||||
{node.within ? (
|
||||
<span className="residual-bound">
|
||||
within {node.within.amount} {node.within.unit}
|
||||
</span>
|
||||
) : null}
|
||||
<Wrapped node={node.arg} />
|
||||
</span>
|
||||
);
|
||||
case "and":
|
||||
case "or":
|
||||
return (
|
||||
<span className="residual-binary">
|
||||
<Wrapped node={node.left} />
|
||||
<OperatorLabel>{node.op}</OperatorLabel>
|
||||
<Wrapped node={node.right} />
|
||||
</span>
|
||||
);
|
||||
case "implies":
|
||||
return (
|
||||
<span className="residual-binary">
|
||||
<Wrapped node={node.left} />
|
||||
<OperatorLabel>{"=>"}</OperatorLabel>
|
||||
<Wrapped node={node.right} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ResidualNodeRoot({ node }: ResidualNodeProps) {
|
||||
return (
|
||||
<span className="residual">
|
||||
<ResidualNodeView node={node} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.tabs-header {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: 0 0 auto;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.tabs-tab {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 4px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
margin-bottom: -1px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tabs-tab:focus-visible {
|
||||
outline: 1px solid var(--text-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.tabs-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.tabs-tab[data-active="true"] .tabs-badge {
|
||||
border-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tabs-badge[data-kind="violation"] {
|
||||
border-color: var(--accent-violation);
|
||||
color: var(--accent-violation);
|
||||
}
|
||||
|
||||
.tabs-tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tabs-tab[data-active="true"] {
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tabs-panel {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import "./Tabs.css";
|
||||
|
||||
export interface TabDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
content: ReactNode;
|
||||
badge?: ReactNode;
|
||||
}
|
||||
|
||||
export interface TabsProps {
|
||||
tabs: TabDefinition[];
|
||||
defaultTabId?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export default function Tabs({ tabs, defaultTabId, ariaLabel }: TabsProps) {
|
||||
const initial = defaultTabId && tabs.some((t) => t.id === defaultTabId) ? defaultTabId : tabs[0]?.id;
|
||||
const [activeId, setActiveId] = useState<string | undefined>(initial);
|
||||
const buttonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
const active = tabs.find((t) => t.id === activeId) ?? tabs[0];
|
||||
|
||||
const focusTab = (id: string) => {
|
||||
setActiveId(id);
|
||||
const node = buttonRefs.current.get(id);
|
||||
if (node) node.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
const currentIndex = tabs.findIndex((t) => t.id === active.id);
|
||||
if (currentIndex < 0) return;
|
||||
switch (event.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowDown": {
|
||||
event.preventDefault();
|
||||
const next = tabs[(currentIndex + 1) % tabs.length];
|
||||
focusTab(next.id);
|
||||
return;
|
||||
}
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp": {
|
||||
event.preventDefault();
|
||||
const prev = tabs[(currentIndex - 1 + tabs.length) % tabs.length];
|
||||
focusTab(prev.id);
|
||||
return;
|
||||
}
|
||||
case "Home": {
|
||||
event.preventDefault();
|
||||
focusTab(tabs[0].id);
|
||||
return;
|
||||
}
|
||||
case "End": {
|
||||
event.preventDefault();
|
||||
focusTab(tabs[tabs.length - 1].id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const panelId = `${ariaLabel ?? "tabs"}-panel`.replace(/\s+/g, "-");
|
||||
|
||||
return (
|
||||
<div className="tabs">
|
||||
<div
|
||||
className="tabs-header"
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === active.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
buttonRefs.current.set(tab.id, node);
|
||||
} else {
|
||||
buttonRefs.current.delete(tab.id);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${panelId}-tab-${tab.id}`}
|
||||
className="tabs-tab"
|
||||
data-active={isActive ? "true" : "false"}
|
||||
aria-selected={isActive}
|
||||
aria-controls={panelId}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{tab.badge !== undefined ? <span className="tabs-badge">{tab.badge}</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className="tabs-panel"
|
||||
role="tabpanel"
|
||||
id={panelId}
|
||||
aria-labelledby={`${panelId}-tab-${active.id}`}
|
||||
>
|
||||
{active.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface UseKeyboardNavOptions {
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onJumpStart: () => void;
|
||||
onJumpEnd: () => void;
|
||||
onJumpPrev10: () => void;
|
||||
onJumpNext10: () => void;
|
||||
onJumpNextViolation: () => void;
|
||||
}
|
||||
|
||||
const NAVIGATION_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
|
||||
const ARROW_OWNING_ROLES = new Set(["tab", "tablist", "option", "listbox", "menuitem", "menu"]);
|
||||
|
||||
function targetOwnsArrowKeys(target: HTMLElement | null): boolean {
|
||||
let node: HTMLElement | null = target;
|
||||
while (node) {
|
||||
const role = node.getAttribute?.("role");
|
||||
if (role && ARROW_OWNING_ROLES.has(role)) return true;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useKeyboardNav(options: UseKeyboardNavOptions) {
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (NAVIGATION_TAGS.has(target.tagName) || target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
const isArrow = event.key.startsWith("Arrow");
|
||||
if (isArrow && targetOwnsArrowKeys(target)) {
|
||||
return;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
case "k":
|
||||
if (event.shiftKey) {
|
||||
options.onJumpPrev10();
|
||||
} else {
|
||||
options.onPrev();
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
case "ArrowRight":
|
||||
case "j":
|
||||
if (event.shiftKey) {
|
||||
options.onJumpNext10();
|
||||
} else {
|
||||
options.onNext();
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
case "g":
|
||||
options.onJumpStart();
|
||||
event.preventDefault();
|
||||
return;
|
||||
case "G":
|
||||
options.onJumpEnd();
|
||||
event.preventDefault();
|
||||
return;
|
||||
case ".":
|
||||
options.onJumpNextViolation();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [options]);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useSse(eventName: string, onEvent: () => void) {
|
||||
useEffect(() => {
|
||||
if (typeof EventSource === "undefined") {
|
||||
return;
|
||||
}
|
||||
const source = new EventSource("/api/events");
|
||||
const messageHandler = () => {
|
||||
onEvent();
|
||||
};
|
||||
source.addEventListener(eventName, messageHandler);
|
||||
return () => {
|
||||
source.removeEventListener(eventName, messageHandler);
|
||||
source.close();
|
||||
};
|
||||
}, [eventName, onEvent]);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
export interface UseStepResult {
|
||||
runId: string;
|
||||
stepIndex: number;
|
||||
goTo: (index: number) => void;
|
||||
}
|
||||
|
||||
export function useStep(maxIndex: number | undefined): UseStepResult {
|
||||
const navigate = useNavigate();
|
||||
const { id, step } = useParams<{ id: string; step: string }>();
|
||||
const runId = id ?? "";
|
||||
const parsed = Number(step ?? "1");
|
||||
const stepIndex = Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
||||
|
||||
const clamped = clampIndex(stepIndex, maxIndex);
|
||||
|
||||
useEffect(() => {
|
||||
if (clamped !== stepIndex && runId !== "") {
|
||||
navigate(`/runs/${runId}/steps/${clamped}`, { replace: true });
|
||||
}
|
||||
}, [clamped, stepIndex, runId, navigate]);
|
||||
|
||||
const goTo = (index: number) => {
|
||||
const target = clampIndex(index, maxIndex);
|
||||
if (target !== stepIndex && runId !== "") {
|
||||
navigate(`/runs/${runId}/steps/${target}`);
|
||||
}
|
||||
};
|
||||
|
||||
return { runId, stepIndex: clamped, goTo };
|
||||
}
|
||||
|
||||
function clampIndex(index: number, maxIndex: number | undefined): number {
|
||||
if (index < 1) {
|
||||
return 1;
|
||||
}
|
||||
if (maxIndex !== undefined && index > maxIndex) {
|
||||
return maxIndex;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type ThemeMode = "light" | "dark";
|
||||
const STORAGE_KEY = "sanderling-inspect-theme";
|
||||
|
||||
function detectInitial(): ThemeMode {
|
||||
if (typeof window === "undefined") {
|
||||
return "light";
|
||||
}
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark") {
|
||||
return stored;
|
||||
}
|
||||
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
return "dark";
|
||||
}
|
||||
return "light";
|
||||
}
|
||||
|
||||
export function useTheme(): { theme: ThemeMode; toggle: () => void } {
|
||||
const [theme, setTheme] = useState<ThemeMode>(() => detectInitial());
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
document.documentElement.dataset.theme = theme;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, theme);
|
||||
} catch {
|
||||
// private mode or storage disabled — theme still applies for the session.
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return {
|
||||
theme,
|
||||
toggle: () => setTheme((current) => (current === "dark" ? "light" : "dark")),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) {
|
||||
throw new Error("missing #root element");
|
||||
}
|
||||
createRoot(container).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,132 @@
|
||||
.action-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.action-list-item {
|
||||
display: block;
|
||||
padding: 3px 6px;
|
||||
border-left: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.action-list-item:focus-visible {
|
||||
outline: 1px solid var(--text-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.action-list-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr) auto auto;
|
||||
align-items: baseline;
|
||||
column-gap: 4px;
|
||||
}
|
||||
|
||||
.action-list-index {
|
||||
text-align: left;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.action-list-body {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-list-verb {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-list-target {
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-list-target-tag {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.action-list-markers {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.action-list-elapsed {
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.action-list-item:hover {
|
||||
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||
}
|
||||
|
||||
.action-list-item:focus-visible {
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
}
|
||||
|
||||
.action-list-item[data-active="true"] {
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
border-left-color: currentColor;
|
||||
}
|
||||
|
||||
.action-list-item[data-active="true"][data-violations="true"] {
|
||||
border-left-color: var(--accent-violation);
|
||||
}
|
||||
|
||||
.action-list-marker-violation {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-violation);
|
||||
}
|
||||
|
||||
.action-list-marker-exception {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--text-muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.action-list-details {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
column-gap: 4px;
|
||||
padding: 2px 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.action-list-detail-row {
|
||||
grid-column: 2;
|
||||
display: grid;
|
||||
grid-template-columns: 70px 1fr;
|
||||
gap: 6px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.action-list-detail-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.action-list-detail-value {
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import type { Step, StepSummary } from "../types";
|
||||
import "./ActionList.css";
|
||||
|
||||
export interface ActionListProps {
|
||||
steps: StepSummary[];
|
||||
selectedIndex: number;
|
||||
onSelect: (index: number) => void;
|
||||
runStartMillis?: number;
|
||||
selectedStep?: Step;
|
||||
}
|
||||
|
||||
interface FormattedRow {
|
||||
verb: string;
|
||||
target: string;
|
||||
targetIsTag: boolean;
|
||||
}
|
||||
|
||||
const SELECTOR_PREFIXES = [
|
||||
"id",
|
||||
"text",
|
||||
"textPrefix",
|
||||
"desc",
|
||||
"descPrefix",
|
||||
"class",
|
||||
"classPrefix",
|
||||
"package",
|
||||
];
|
||||
|
||||
function parseSelector(selector: string): { kind: string; value: string } | null {
|
||||
const colonIndex = selector.indexOf(":");
|
||||
if (colonIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
const kind = selector.slice(0, colonIndex);
|
||||
const value = selector.slice(colonIndex + 1);
|
||||
if (!SELECTOR_PREFIXES.includes(kind)) {
|
||||
return null;
|
||||
}
|
||||
return { kind, value };
|
||||
}
|
||||
|
||||
function tagFromSelector(selector: string): string {
|
||||
const parsed = parseSelector(selector);
|
||||
if (!parsed) {
|
||||
return selector;
|
||||
}
|
||||
if (parsed.kind.endsWith("Prefix")) {
|
||||
return `${parsed.value}...`;
|
||||
}
|
||||
return parsed.value;
|
||||
}
|
||||
|
||||
export function formatActionRow(step: StepSummary): FormattedRow {
|
||||
const kind = step.action_kind;
|
||||
const label = step.action_label ?? "";
|
||||
|
||||
if (!kind) {
|
||||
if (step.screen) {
|
||||
return { verb: "Observe", target: `@ ${step.screen}`, targetIsTag: false };
|
||||
}
|
||||
return { verb: "Observe", target: "", targetIsTag: false };
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case "Tap": {
|
||||
if (!label) {
|
||||
return { verb: "Click", target: "", targetIsTag: false };
|
||||
}
|
||||
if (label.startsWith("(") && label.endsWith(")")) {
|
||||
return { verb: "Click", target: label, targetIsTag: false };
|
||||
}
|
||||
if (parseSelector(label)) {
|
||||
return { verb: "Click", target: tagFromSelector(label), targetIsTag: true };
|
||||
}
|
||||
return { verb: "Click", target: label, targetIsTag: false };
|
||||
}
|
||||
case "InputText":
|
||||
return { verb: "Type", target: label, targetIsTag: false };
|
||||
case "Swipe":
|
||||
return { verb: "Swipe", target: label, targetIsTag: true };
|
||||
case "PressKey":
|
||||
return { verb: "Press", target: label, targetIsTag: true };
|
||||
case "Wait":
|
||||
return { verb: "Wait", target: label, targetIsTag: true };
|
||||
default:
|
||||
return { verb: kind, target: label, targetIsTag: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function formatElapsed(millis: number): string {
|
||||
const safe = Math.max(0, Math.floor(millis));
|
||||
const totalSeconds = Math.floor(safe / 1000);
|
||||
const mm = Math.floor(totalSeconds / 60);
|
||||
const ss = totalSeconds % 60;
|
||||
const ms = safe % 1000;
|
||||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||||
const pad3 = (n: number) => String(n).padStart(3, "0");
|
||||
return `${pad2(mm)}:${pad2(ss)}.${pad3(ms)}`;
|
||||
}
|
||||
|
||||
function renderTarget(target: string, isTag: boolean) {
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
if (isTag) {
|
||||
return <span className="action-list-target action-list-target-tag">{`<${target}/>`}</span>;
|
||||
}
|
||||
return <span className="action-list-target">{target}</span>;
|
||||
}
|
||||
|
||||
function contentTextForStep(step: Step): string {
|
||||
const action = step.action;
|
||||
if (!action) {
|
||||
return "";
|
||||
}
|
||||
if (typeof action.text === "string") {
|
||||
return action.text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function positionTextForStep(step: Step): string | null {
|
||||
const action = step.action;
|
||||
if (!action) {
|
||||
return null;
|
||||
}
|
||||
if (action.tap_point) {
|
||||
return `${action.tap_point.x.toFixed(1)}, ${action.tap_point.y.toFixed(1)}`;
|
||||
}
|
||||
if (typeof action.x === "number" && typeof action.y === "number") {
|
||||
return `${action.x.toFixed(1)}, ${action.y.toFixed(1)}`;
|
||||
}
|
||||
if (
|
||||
typeof action.from_x === "number" &&
|
||||
typeof action.from_y === "number"
|
||||
) {
|
||||
return `${action.from_x.toFixed(1)}, ${action.from_y.toFixed(1)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function ActionList({
|
||||
steps,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
runStartMillis,
|
||||
selectedStep,
|
||||
}: ActionListProps) {
|
||||
const itemRefs = useRef<Map<number, HTMLLIElement>>(new Map());
|
||||
const baseMillis =
|
||||
runStartMillis ?? (steps[0] ? new Date(steps[0].timestamp).getTime() : 0);
|
||||
|
||||
useEffect(() => {
|
||||
const node = itemRefs.current.get(selectedIndex);
|
||||
if (node && typeof node.scrollIntoView === "function") {
|
||||
node.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
const focusIndex = (index: number) => {
|
||||
const node = itemRefs.current.get(index);
|
||||
if (node && typeof node.focus === "function") {
|
||||
node.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLLIElement>, index: number) => {
|
||||
const position = steps.findIndex((entry) => entry.index === index);
|
||||
switch (event.key) {
|
||||
case "Enter":
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
onSelect(index);
|
||||
return;
|
||||
case "ArrowDown": {
|
||||
if (position < 0 || position >= steps.length - 1) return;
|
||||
event.preventDefault();
|
||||
const next = steps[position + 1].index;
|
||||
onSelect(next);
|
||||
focusIndex(next);
|
||||
return;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
if (position <= 0) return;
|
||||
event.preventDefault();
|
||||
const prev = steps[position - 1].index;
|
||||
onSelect(prev);
|
||||
focusIndex(prev);
|
||||
return;
|
||||
}
|
||||
case "Home": {
|
||||
event.preventDefault();
|
||||
const first = steps[0]?.index;
|
||||
if (first !== undefined) {
|
||||
onSelect(first);
|
||||
focusIndex(first);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "End": {
|
||||
event.preventDefault();
|
||||
const last = steps[steps.length - 1]?.index;
|
||||
if (last !== undefined) {
|
||||
onSelect(last);
|
||||
focusIndex(last);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ol className="action-list" role="listbox" aria-label="Steps">
|
||||
{steps.map((step) => {
|
||||
const isActive = step.index === selectedIndex;
|
||||
const { verb, target, targetIsTag } = formatActionRow(step);
|
||||
const elapsedMillis = new Date(step.timestamp).getTime() - baseMillis;
|
||||
const elapsed = formatElapsed(elapsedMillis);
|
||||
const spokenTarget = target
|
||||
? targetIsTag
|
||||
? `<${target}/>`
|
||||
: target
|
||||
: "";
|
||||
const ariaLabel = spokenTarget
|
||||
? `Step ${step.index} ${verb} ${spokenTarget}`
|
||||
: `Step ${step.index} ${verb}`;
|
||||
const showDetails = isActive && selectedStep && selectedStep.step === step.index;
|
||||
const positionText = showDetails ? positionTextForStep(selectedStep) : null;
|
||||
const contentText = showDetails ? contentTextForStep(selectedStep) : "";
|
||||
return (
|
||||
<li
|
||||
key={step.index}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
itemRefs.current.set(step.index, node);
|
||||
} else {
|
||||
itemRefs.current.delete(step.index);
|
||||
}
|
||||
}}
|
||||
className="action-list-item"
|
||||
role="option"
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
aria-selected={isActive}
|
||||
aria-label={ariaLabel}
|
||||
data-active={isActive ? "true" : "false"}
|
||||
data-violations={step.has_violations ? "true" : "false"}
|
||||
data-exceptions={step.has_exceptions ? "true" : "false"}
|
||||
onClick={() => onSelect(step.index)}
|
||||
onKeyDown={(event) => handleKeyDown(event, step.index)}
|
||||
title={target || undefined}
|
||||
>
|
||||
<div className="action-list-row">
|
||||
<span className="action-list-index">{step.index}.</span>
|
||||
<span className="action-list-body">
|
||||
<span className="action-list-verb">{verb}</span>
|
||||
{renderTarget(target, targetIsTag)}
|
||||
</span>
|
||||
<span className="action-list-markers">
|
||||
{step.has_exceptions ? (
|
||||
<span
|
||||
className="action-list-marker-exception"
|
||||
aria-label="exceptions"
|
||||
role="img"
|
||||
/>
|
||||
) : null}
|
||||
{step.has_violations ? (
|
||||
<span
|
||||
className="action-list-marker-violation"
|
||||
aria-label="violations"
|
||||
role="img"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="action-list-elapsed">{elapsed}</span>
|
||||
</div>
|
||||
{showDetails ? (
|
||||
<div className="action-list-details">
|
||||
<div className="action-list-detail-row">
|
||||
<span className="action-list-detail-label">Position</span>
|
||||
<span className="action-list-detail-value">
|
||||
{positionText ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="action-list-detail-row">
|
||||
<span className="action-list-detail-label">Content</span>
|
||||
<span className="action-list-detail-value">
|
||||
{contentText ? contentText : `""`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
.exceptions-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.exceptions-panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.exceptions-panel-jump {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.exceptions-panel-jump:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.exceptions-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.exceptions-row details {
|
||||
border: 1px dashed var(--border-strong);
|
||||
background: var(--surface);
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.exceptions-row details[open] {
|
||||
border-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.exceptions-summary {
|
||||
display: grid;
|
||||
grid-template-columns: 8px auto auto minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.exceptions-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.exceptions-marker {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 1px dashed var(--text-primary);
|
||||
background: transparent;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.exceptions-class {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.exceptions-colon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.exceptions-message {
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.exceptions-time {
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.exceptions-stack {
|
||||
margin: 8px 0 0;
|
||||
padding: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Exception } from "../types";
|
||||
import "./ExceptionsPanel.css";
|
||||
|
||||
export interface ExceptionsPanelProps {
|
||||
exceptions?: Exception[];
|
||||
onJumpToFirstException: () => void;
|
||||
hasFirstException: boolean;
|
||||
}
|
||||
|
||||
function formatTimestamp(unixMillis: number): string {
|
||||
const date = new Date(unixMillis);
|
||||
const hh = String(date.getHours()).padStart(2, "0");
|
||||
const mm = String(date.getMinutes()).padStart(2, "0");
|
||||
const ss = String(date.getSeconds()).padStart(2, "0");
|
||||
const ms = String(date.getMilliseconds()).padStart(3, "0");
|
||||
return `${hh}:${mm}:${ss}.${ms}`;
|
||||
}
|
||||
|
||||
export default function ExceptionsPanel({
|
||||
exceptions,
|
||||
onJumpToFirstException,
|
||||
hasFirstException,
|
||||
}: ExceptionsPanelProps) {
|
||||
return (
|
||||
<div className="exceptions-panel">
|
||||
<div className="exceptions-panel-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="exceptions-panel-jump"
|
||||
onClick={onJumpToFirstException}
|
||||
disabled={!hasFirstException}
|
||||
>
|
||||
jump to first exception
|
||||
</button>
|
||||
</div>
|
||||
{!exceptions || exceptions.length === 0 ? (
|
||||
<div className="status-block">no exceptions</div>
|
||||
) : (
|
||||
<ol className="exceptions-list">
|
||||
{exceptions.map((exception, index) => (
|
||||
<li key={index} className="exceptions-row">
|
||||
<details open={index === 0}>
|
||||
<summary className="exceptions-summary">
|
||||
<span className="exceptions-marker" aria-hidden="true" />
|
||||
<span className="exceptions-class">{exception.class}</span>
|
||||
{exception.message ? (
|
||||
<>
|
||||
<span className="exceptions-colon">:</span>
|
||||
<span className="exceptions-message">{exception.message}</span>
|
||||
</>
|
||||
) : null}
|
||||
{exception.unix_millis !== undefined ? (
|
||||
<span className="exceptions-time">
|
||||
{formatTimestamp(exception.unix_millis)}
|
||||
</span>
|
||||
) : null}
|
||||
</summary>
|
||||
{exception.stack_trace ? (
|
||||
<pre className="exceptions-stack">{exception.stack_trace}</pre>
|
||||
) : null}
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
.metrics-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.metrics-lanes {
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.metrics-lane {
|
||||
flex: 1 1 0;
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) 32px;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.metrics-lane:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.metrics-lane-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.metrics-lane-plot {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.metrics-lane-canvas {
|
||||
position: absolute;
|
||||
inset: 4px 0;
|
||||
}
|
||||
|
||||
.metrics-lane-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.metrics-lane-path {
|
||||
fill: none;
|
||||
stroke: var(--text-primary);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.metrics-lane-dot {
|
||||
position: absolute;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.metrics-lane-dot[data-active="true"] {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.metrics-lane-tooltip {
|
||||
position: absolute;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.metrics-lane-tooltip[data-placement="above"] {
|
||||
transform: translate(-50%, calc(-100% - 6px));
|
||||
}
|
||||
|
||||
.metrics-lane-tooltip[data-placement="below"] {
|
||||
transform: translate(-50%, 6px);
|
||||
}
|
||||
|
||||
.metrics-lane-highlight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--text-primary);
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.metrics-lane-ticks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 2px 6px;
|
||||
border-left: 1px solid var(--border);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.metrics-hits {
|
||||
position: absolute;
|
||||
inset: 0 32px 0 18px;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.metrics-hit {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.metrics-hit:hover {
|
||||
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
|
||||
}
|
||||
|
||||
.metrics-axis {
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
height: 20px;
|
||||
margin: 0 32px 0 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.metrics-axis-label {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
transform: translateX(-50%);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useState } from "react";
|
||||
import type { Metrics } from "../types";
|
||||
import "./MetricsChart.css";
|
||||
|
||||
export interface MetricsSample {
|
||||
stepIndex: number;
|
||||
timestamp: string;
|
||||
metrics?: Metrics;
|
||||
}
|
||||
|
||||
export interface MetricsChartProps {
|
||||
samples: MetricsSample[];
|
||||
selectedIndex: number;
|
||||
onSelect: (stepIndex: number) => void;
|
||||
runStartMillis?: number;
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
function formatHeap(bytes: number): string {
|
||||
if (bytes <= 0) return "0B";
|
||||
if (bytes < MB) return `${Math.round(bytes / 1024)}K`;
|
||||
if (bytes < 1024 * MB) return `${Math.round(bytes / MB)}M`;
|
||||
return `${(bytes / (1024 * MB)).toFixed(1)}G`;
|
||||
}
|
||||
|
||||
function formatTime(millis: number): string {
|
||||
const safe = Math.max(0, Math.floor(millis));
|
||||
const seconds = Math.floor(safe / 1000);
|
||||
const mm = String(Math.floor(seconds / 60)).padStart(2, "0");
|
||||
const ss = String(seconds % 60).padStart(2, "0");
|
||||
return `${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function fractionFor(index: number, count: number): number {
|
||||
if (count <= 1) return 0.5;
|
||||
return index / (count - 1);
|
||||
}
|
||||
|
||||
function buildPath(
|
||||
samples: MetricsSample[],
|
||||
getValue: (sample: MetricsSample) => number | undefined,
|
||||
ceiling: number,
|
||||
): string {
|
||||
const segments: string[] = [];
|
||||
let pendingCommand = "M";
|
||||
samples.forEach((sample, index) => {
|
||||
const value = getValue(sample);
|
||||
if (value === undefined) {
|
||||
pendingCommand = "M";
|
||||
return;
|
||||
}
|
||||
const x = fractionFor(index, samples.length);
|
||||
const ratio = ceiling === 0 ? 0 : Math.min(value / ceiling, 1);
|
||||
const y = 1 - ratio;
|
||||
segments.push(`${pendingCommand}${x.toFixed(4)},${y.toFixed(4)}`);
|
||||
pendingCommand = "L";
|
||||
});
|
||||
return segments.join(" ");
|
||||
}
|
||||
|
||||
interface LaneDot {
|
||||
key: number | string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface LaneHover {
|
||||
fraction: number;
|
||||
y: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface LaneProps {
|
||||
label: string;
|
||||
path: string;
|
||||
dots: LaneDot[];
|
||||
topTickLabel: string;
|
||||
bottomTickLabel: string;
|
||||
selectedFraction: number | null;
|
||||
hover: LaneHover | null;
|
||||
}
|
||||
|
||||
function Lane({
|
||||
label,
|
||||
path,
|
||||
dots,
|
||||
topTickLabel,
|
||||
bottomTickLabel,
|
||||
selectedFraction,
|
||||
hover,
|
||||
}: LaneProps) {
|
||||
return (
|
||||
<div className="metrics-lane">
|
||||
<div className="metrics-lane-label">{label}</div>
|
||||
<div className="metrics-lane-plot">
|
||||
{selectedFraction !== null ? (
|
||||
<div
|
||||
className="metrics-lane-highlight"
|
||||
style={{ left: `${selectedFraction * 100}%` }}
|
||||
/>
|
||||
) : null}
|
||||
<div className="metrics-lane-canvas">
|
||||
<svg
|
||||
className="metrics-lane-svg"
|
||||
viewBox="0 0 1 1"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{path ? <path d={path} className="metrics-lane-path" /> : null}
|
||||
</svg>
|
||||
{dots.map((dot) => (
|
||||
<div
|
||||
key={dot.key}
|
||||
className="metrics-lane-dot"
|
||||
style={{ left: `${dot.x * 100}%`, top: `${dot.y * 100}%` }}
|
||||
data-active={hover !== null && Math.abs(hover.fraction - dot.x) < 1e-6 ? "true" : undefined}
|
||||
/>
|
||||
))}
|
||||
{hover ? (
|
||||
<div
|
||||
className="metrics-lane-tooltip"
|
||||
data-placement={hover.y < 0.35 ? "below" : "above"}
|
||||
style={{ left: `${hover.fraction * 100}%`, top: `${hover.y * 100}%` }}
|
||||
>
|
||||
{hover.label}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="metrics-lane-ticks">
|
||||
<span>{topTickLabel}</span>
|
||||
<span>{bottomTickLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildDots(
|
||||
samples: MetricsSample[],
|
||||
getValue: (sample: MetricsSample) => number | undefined,
|
||||
ceiling: number,
|
||||
): LaneDot[] {
|
||||
const dots: LaneDot[] = [];
|
||||
samples.forEach((sample, index) => {
|
||||
const value = getValue(sample);
|
||||
if (value === undefined) return;
|
||||
const ratio = ceiling === 0 ? 0 : Math.min(value / ceiling, 1);
|
||||
dots.push({
|
||||
key: sample.stepIndex,
|
||||
x: fractionFor(index, samples.length),
|
||||
y: 1 - ratio,
|
||||
});
|
||||
});
|
||||
return dots;
|
||||
}
|
||||
|
||||
export default function MetricsChart({
|
||||
samples,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
runStartMillis,
|
||||
}: MetricsChartProps) {
|
||||
const [hoveredColumn, setHoveredColumn] = useState<number | null>(null);
|
||||
|
||||
if (samples.length === 0 || !samples.some((sample) => sample.metrics !== undefined)) {
|
||||
return <div className="status-block">no metrics</div>;
|
||||
}
|
||||
|
||||
const heapTop = samples.reduce(
|
||||
(max, sample) => Math.max(max, sample.metrics?.heap_bytes ?? 0),
|
||||
0,
|
||||
);
|
||||
const cpuObservedMax = samples.reduce(
|
||||
(max, sample) => Math.max(max, sample.metrics?.cpu_percent ?? 0),
|
||||
0,
|
||||
);
|
||||
const cpuTop = Math.max(100, cpuObservedMax);
|
||||
|
||||
const heapPath = buildPath(samples, (sample) => sample.metrics?.heap_bytes, heapTop);
|
||||
const cpuPath = buildPath(samples, (sample) => sample.metrics?.cpu_percent, cpuTop);
|
||||
const heapDots = buildDots(samples, (sample) => sample.metrics?.heap_bytes, heapTop);
|
||||
const cpuDots = buildDots(samples, (sample) => sample.metrics?.cpu_percent, cpuTop);
|
||||
|
||||
const baseMillis = runStartMillis ?? new Date(samples[0].timestamp).getTime();
|
||||
|
||||
const axisCount = Math.min(5, samples.length);
|
||||
const axisTicks = Array.from({ length: axisCount }, (_, i) => {
|
||||
const idx =
|
||||
axisCount === 1
|
||||
? 0
|
||||
: Math.round((i / (axisCount - 1)) * (samples.length - 1));
|
||||
const sample = samples[idx];
|
||||
const sampleMillis = new Date(sample.timestamp).getTime();
|
||||
const elapsed =
|
||||
Number.isFinite(sampleMillis) && Number.isFinite(baseMillis)
|
||||
? sampleMillis - baseMillis
|
||||
: NaN;
|
||||
return {
|
||||
key: sample.stepIndex,
|
||||
fraction: fractionFor(idx, samples.length),
|
||||
label: Number.isFinite(elapsed) ? formatTime(elapsed) : String(sample.stepIndex),
|
||||
};
|
||||
});
|
||||
|
||||
const selectedColumn = samples.findIndex((sample) => sample.stepIndex === selectedIndex);
|
||||
const selectedFraction =
|
||||
selectedColumn >= 0 ? fractionFor(selectedColumn, samples.length) : null;
|
||||
|
||||
const hoveredSample =
|
||||
hoveredColumn !== null && hoveredColumn >= 0 && hoveredColumn < samples.length
|
||||
? samples[hoveredColumn]
|
||||
: null;
|
||||
const hoverFraction =
|
||||
hoveredColumn !== null ? fractionFor(hoveredColumn, samples.length) : null;
|
||||
|
||||
const heapHover: LaneHover | null =
|
||||
hoveredSample && hoverFraction !== null && hoveredSample.metrics?.heap_bytes !== undefined
|
||||
? {
|
||||
fraction: hoverFraction,
|
||||
y: 1 - (heapTop === 0 ? 0 : Math.min(hoveredSample.metrics.heap_bytes / heapTop, 1)),
|
||||
label: formatHeap(hoveredSample.metrics.heap_bytes),
|
||||
}
|
||||
: null;
|
||||
const cpuHover: LaneHover | null =
|
||||
hoveredSample && hoverFraction !== null && hoveredSample.metrics?.cpu_percent !== undefined
|
||||
? {
|
||||
fraction: hoverFraction,
|
||||
y: 1 - (cpuTop === 0 ? 0 : Math.min(hoveredSample.metrics.cpu_percent / cpuTop, 1)),
|
||||
label: `${hoveredSample.metrics.cpu_percent.toFixed(1)}%`,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="metrics-chart">
|
||||
<div className="metrics-lanes">
|
||||
<Lane
|
||||
label="HEAP"
|
||||
path={heapPath}
|
||||
dots={heapDots}
|
||||
topTickLabel={formatHeap(heapTop)}
|
||||
bottomTickLabel="0B"
|
||||
selectedFraction={selectedFraction}
|
||||
hover={heapHover}
|
||||
/>
|
||||
<Lane
|
||||
label="CPU"
|
||||
path={cpuPath}
|
||||
dots={cpuDots}
|
||||
topTickLabel={`${Math.round(cpuTop)}%`}
|
||||
bottomTickLabel="0%"
|
||||
selectedFraction={selectedFraction}
|
||||
hover={cpuHover}
|
||||
/>
|
||||
<div className="metrics-hits" role="presentation">
|
||||
{samples.map((sample, index) => {
|
||||
const left = (() => {
|
||||
if (samples.length === 1) return 0;
|
||||
const half = 0.5 / (samples.length - 1);
|
||||
return Math.max(0, fractionFor(index, samples.length) - half);
|
||||
})();
|
||||
const width = samples.length === 1 ? 1 : 1 / (samples.length - 1);
|
||||
return (
|
||||
<button
|
||||
key={sample.stepIndex}
|
||||
type="button"
|
||||
className="metrics-hit"
|
||||
style={{ left: `${left * 100}%`, width: `${width * 100}%` }}
|
||||
onClick={() => onSelect(sample.stepIndex)}
|
||||
onPointerEnter={() => setHoveredColumn(index)}
|
||||
onPointerLeave={() =>
|
||||
setHoveredColumn((current) => (current === index ? null : current))
|
||||
}
|
||||
aria-label={`select step ${sample.stepIndex}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="metrics-axis">
|
||||
{axisTicks.map((tick) => (
|
||||
<span
|
||||
key={tick.key}
|
||||
className="metrics-axis-label"
|
||||
style={{ left: `${tick.fraction * 100}%` }}
|
||||
>
|
||||
{tick.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.screenshot-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
margin: 0 auto;
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screenshot-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.screenshot-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.screenshot-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 240px;
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border-strong);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import type { Action } from "../types";
|
||||
import "./Screenshot.css";
|
||||
|
||||
export interface ScreenshotProps {
|
||||
src?: string;
|
||||
action?: Action;
|
||||
deviceWidth?: number;
|
||||
deviceHeight?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_WIDTH = 1080;
|
||||
const DEFAULT_HEIGHT = 1920;
|
||||
|
||||
export default function Screenshot({ src, action, deviceWidth, deviceHeight }: ScreenshotProps) {
|
||||
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const previousSrc = useRef<string | undefined>(src);
|
||||
const reactId = useId();
|
||||
const maskId = `screenshot-spotlight-${reactId.replace(/:/g, "")}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (previousSrc.current !== src) {
|
||||
previousSrc.current = src;
|
||||
setLoadFailed(false);
|
||||
setNaturalSize(null);
|
||||
}
|
||||
}, [src]);
|
||||
|
||||
if (!src || loadFailed) {
|
||||
return (
|
||||
<div className="screenshot-placeholder" data-testid="screenshot-placeholder">
|
||||
no screenshot
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const width = deviceWidth ?? naturalSize?.width ?? DEFAULT_WIDTH;
|
||||
const height = deviceHeight ?? naturalSize?.height ?? DEFAULT_HEIGHT;
|
||||
const stroke = Math.max(2, width / 200);
|
||||
const tapRadius = Math.max(12, width / 60);
|
||||
const arrowHeadSize = Math.max(18, width / 40);
|
||||
const spotlightRadius = tapRadius * 3;
|
||||
|
||||
const bounds = action?.resolved_bounds;
|
||||
const tap = action?.tap_point;
|
||||
const isSwipe =
|
||||
action?.kind === "Swipe" &&
|
||||
action.from_x !== undefined &&
|
||||
action.from_y !== undefined &&
|
||||
action.to_x !== undefined &&
|
||||
action.to_y !== undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="screenshot-frame"
|
||||
style={{ aspectRatio: `${width} / ${height}` }}
|
||||
>
|
||||
<img
|
||||
className="screenshot-image"
|
||||
src={src}
|
||||
alt="device screenshot"
|
||||
onLoad={(event) => {
|
||||
const img = event.currentTarget;
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
setNaturalSize({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
}
|
||||
}}
|
||||
onError={() => setLoadFailed(true)}
|
||||
/>
|
||||
<svg
|
||||
className="screenshot-overlay"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{tap && (
|
||||
<>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id={`${maskId}-gradient`}
|
||||
cx={tap.x}
|
||||
cy={tap.y}
|
||||
r={spotlightRadius}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="black" />
|
||||
<stop offset="55%" stopColor="black" />
|
||||
<stop offset="100%" stopColor="white" />
|
||||
</radialGradient>
|
||||
<mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="0" width={width} height={height}>
|
||||
<rect x="0" y="0" width={width} height={height} fill={`url(#${maskId}-gradient)`} />
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
data-testid="screenshot-spotlight"
|
||||
x="0"
|
||||
y="0"
|
||||
width={width}
|
||||
height={height}
|
||||
fill="black"
|
||||
opacity="0.5"
|
||||
mask={`url(#${maskId})`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{bounds && (
|
||||
<rect
|
||||
x={bounds.x}
|
||||
y={bounds.y}
|
||||
width={bounds.width}
|
||||
height={bounds.height}
|
||||
fill="none"
|
||||
stroke="var(--accent-violation)"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
)}
|
||||
{isSwipe && (
|
||||
<SwipeArrow
|
||||
fromX={action!.from_x!}
|
||||
fromY={action!.from_y!}
|
||||
toX={action!.to_x!}
|
||||
toY={action!.to_y!}
|
||||
stroke={stroke}
|
||||
headSize={arrowHeadSize}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SwipeArrowProps {
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
stroke: number;
|
||||
headSize: number;
|
||||
}
|
||||
|
||||
function SwipeArrow({ fromX, fromY, toX, toY, stroke, headSize }: SwipeArrowProps) {
|
||||
const angle = (Math.atan2(toY - fromY, toX - fromX) * 180) / Math.PI;
|
||||
const half = headSize / 2;
|
||||
const points = `${toX},${toY} ${toX - headSize},${toY - half} ${toX - headSize},${toY + half}`;
|
||||
return (
|
||||
<g>
|
||||
<line
|
||||
x1={fromX}
|
||||
y1={fromY}
|
||||
x2={toX}
|
||||
y2={toY}
|
||||
stroke="var(--text-muted)"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<polygon
|
||||
points={points}
|
||||
fill="var(--text-muted)"
|
||||
transform={`rotate(${angle} ${toX} ${toY})`}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
.snapshot-table {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.snapshot-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 160px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
padding: 4px 8px;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.snapshot-row[data-changed="true"] {
|
||||
border-left-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.snapshot-path {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.snapshot-value {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.snapshot-row[data-long-value="true"] .snapshot-value {
|
||||
white-space: pre-wrap;
|
||||
max-height: 6em;
|
||||
overflow-y: auto;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import "./SnapshotTable.css";
|
||||
|
||||
export interface SnapshotTableProps {
|
||||
snapshots?: Record<string, unknown>;
|
||||
previousSnapshots?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
path: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
const INLINE_ARRAY_LIMIT = 2;
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.getPrototypeOf(value) === Object.prototype
|
||||
);
|
||||
}
|
||||
|
||||
function flatten(input: Record<string, unknown>): Row[] {
|
||||
const rows: Row[] = [];
|
||||
const walk = (value: unknown, path: string) => {
|
||||
if (isPlainObject(value)) {
|
||||
const keys = Object.keys(value).sort();
|
||||
if (keys.length === 0) {
|
||||
rows.push({ path, value: {} });
|
||||
return;
|
||||
}
|
||||
for (const key of keys) {
|
||||
const nextPath = path === "" ? key : `${path}.${key}`;
|
||||
walk(value[key], nextPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length <= INLINE_ARRAY_LIMIT) {
|
||||
rows.push({ path, value });
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
walk(value[i], `${path}[${i}]`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
rows.push({ path, value });
|
||||
};
|
||||
for (const key of Object.keys(input).sort()) {
|
||||
walk(input[key], key);
|
||||
}
|
||||
return rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
||||
}
|
||||
|
||||
function getAtPath(source: Record<string, unknown> | undefined, path: string): unknown {
|
||||
if (!source) {
|
||||
return undefined;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(source, path)) {
|
||||
return source[path];
|
||||
}
|
||||
const segments = path.split(/\.|\[(\d+)\]/).filter((segment) => segment !== undefined && segment !== "");
|
||||
let current: unknown = source;
|
||||
for (const segment of segments) {
|
||||
if (current === null || current === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
const index = Number(segment);
|
||||
if (Number.isNaN(index)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[index];
|
||||
continue;
|
||||
}
|
||||
if (typeof current === "object") {
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
continue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function canonicalize(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalize);
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
out[key] = canonicalize(value[key]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
return JSON.stringify(canonicalize(value));
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
if (value === undefined) {
|
||||
return "undefined";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return stableStringify(value);
|
||||
}
|
||||
|
||||
export default function SnapshotTable({ snapshots, previousSnapshots }: SnapshotTableProps) {
|
||||
if (!snapshots || Object.keys(snapshots).length === 0) {
|
||||
return <div className="status-block">no snapshots</div>;
|
||||
}
|
||||
|
||||
const rows = flatten(snapshots);
|
||||
const hasPrevious = previousSnapshots !== undefined;
|
||||
const previousRows = hasPrevious ? flatten(previousSnapshots) : [];
|
||||
const previousByPath = new Map(previousRows.map((row) => [row.path, row.value]));
|
||||
|
||||
return (
|
||||
<dl className="snapshot-table">
|
||||
{rows.map((row) => {
|
||||
const formatted = formatValue(row.value);
|
||||
let changed = false;
|
||||
let previousFormatted: string | undefined;
|
||||
if (hasPrevious) {
|
||||
const prevValue = previousByPath.has(row.path)
|
||||
? previousByPath.get(row.path)
|
||||
: getAtPath(previousSnapshots, row.path);
|
||||
if (stableStringify(prevValue) !== stableStringify(row.value)) {
|
||||
changed = true;
|
||||
previousFormatted = formatValue(prevValue);
|
||||
}
|
||||
}
|
||||
const rowProps: Record<string, string> = {};
|
||||
if (changed) {
|
||||
rowProps["data-changed"] = "true";
|
||||
rowProps.title = `was: ${previousFormatted}`;
|
||||
}
|
||||
return (
|
||||
<div key={row.path} className="snapshot-row" {...rowProps}>
|
||||
<dt className="snapshot-path" title={row.path}>
|
||||
{row.path}
|
||||
</dt>
|
||||
<dd className="snapshot-value" title={formatted}>
|
||||
{formatted}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.timeline {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.timeline-label {
|
||||
font-size: 11px;
|
||||
fill: var(--text-muted);
|
||||
}
|
||||
|
||||
.timeline-cell {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.timeline-cell:hover {
|
||||
stroke: var(--text-primary);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.timeline-cell:focus-visible {
|
||||
stroke: var(--text-primary);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.timeline-action {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.timeline-action:hover,
|
||||
.timeline-action:focus-visible {
|
||||
fill: var(--text-primary);
|
||||
}
|
||||
|
||||
.timeline-highlight {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { KeyboardEvent } from "react";
|
||||
import type { StepSummary } from "../types";
|
||||
import "./Timeline.css";
|
||||
|
||||
export type LaneStatus = "violated" | "pending" | "holds";
|
||||
|
||||
export interface PropertyLane {
|
||||
name: string;
|
||||
statuses: LaneStatus[];
|
||||
}
|
||||
|
||||
export interface TimelineProps {
|
||||
steps: StepSummary[];
|
||||
lanes: PropertyLane[];
|
||||
selectedIndex: number;
|
||||
onSelect: (index: number) => void;
|
||||
}
|
||||
|
||||
const LANE_HEIGHT = 22;
|
||||
const LABEL_WIDTH = 140;
|
||||
const CELL_GAP = 2;
|
||||
const CHART_WIDTH = 1000;
|
||||
|
||||
const STATUS_FILL: Record<LaneStatus, string> = {
|
||||
violated: "var(--accent-violation)",
|
||||
pending: "color-mix(in srgb, var(--text-primary) 30%, transparent)",
|
||||
holds: "color-mix(in srgb, var(--text-primary) 10%, transparent)",
|
||||
};
|
||||
|
||||
export default function Timeline({ steps, lanes, selectedIndex, onSelect }: TimelineProps) {
|
||||
if (steps.length === 0 || lanes.length === 0) {
|
||||
return <div className="status-block">no timeline data</div>;
|
||||
}
|
||||
|
||||
const cellAreaWidth = CHART_WIDTH - LABEL_WIDTH;
|
||||
const cellWidth = cellAreaWidth / steps.length;
|
||||
const totalRows = lanes.length + 1;
|
||||
const height = totalRows * LANE_HEIGHT;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<SVGRectElement>, stepIndex: number) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onSelect(stepIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedColumn = steps.findIndex((step) => step.index === selectedIndex);
|
||||
const highlightX =
|
||||
selectedColumn >= 0 ? LABEL_WIDTH + selectedColumn * cellWidth + cellWidth / 2 : null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="timeline"
|
||||
viewBox={`0 0 ${CHART_WIDTH} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="property timeline"
|
||||
>
|
||||
{lanes.map((lane, laneIndex) => {
|
||||
const rowY = laneIndex * LANE_HEIGHT;
|
||||
return (
|
||||
<g key={lane.name} className="timeline-lane" data-lane={lane.name}>
|
||||
<text
|
||||
className="timeline-label"
|
||||
x={LABEL_WIDTH - 8}
|
||||
y={rowY + LANE_HEIGHT / 2}
|
||||
dominantBaseline="middle"
|
||||
textAnchor="end"
|
||||
>
|
||||
{lane.name}
|
||||
</text>
|
||||
{steps.map((step, stepCol) => {
|
||||
const status = lane.statuses[stepCol] ?? "pending";
|
||||
const x = LABEL_WIDTH + stepCol * cellWidth + CELL_GAP / 2;
|
||||
const w = Math.max(cellWidth - CELL_GAP, 1);
|
||||
const y = rowY + 3;
|
||||
const h = LANE_HEIGHT - 6;
|
||||
return (
|
||||
<rect
|
||||
key={step.index}
|
||||
className="timeline-cell"
|
||||
data-status={status}
|
||||
data-step={step.index}
|
||||
x={x}
|
||||
y={y}
|
||||
width={w}
|
||||
height={h}
|
||||
fill={STATUS_FILL[status]}
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(step.index)}
|
||||
onKeyDown={(event) => handleKeyDown(event, step.index)}
|
||||
>
|
||||
<title>{`step ${step.index}: ${status}`}</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<g className="timeline-actions" data-row="actions">
|
||||
{steps.map((step, stepCol) => {
|
||||
const cx = LABEL_WIDTH + stepCol * cellWidth + cellWidth / 2;
|
||||
const cy = lanes.length * LANE_HEIGHT + LANE_HEIGHT / 2;
|
||||
return (
|
||||
<circle
|
||||
key={step.index}
|
||||
className="timeline-action"
|
||||
data-step={step.index}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={3}
|
||||
fill="var(--text-muted)"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(step.index)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onSelect(step.index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<title>{`step ${step.index}`}</title>
|
||||
</circle>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
{highlightX !== null ? (
|
||||
<line
|
||||
className="timeline-highlight"
|
||||
data-selected="true"
|
||||
x1={highlightX}
|
||||
x2={highlightX}
|
||||
y1={0}
|
||||
y2={height}
|
||||
stroke="var(--text-primary)"
|
||||
strokeWidth={2}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
.violations-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.violations-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.violations-panel-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.violations-panel-jump {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-transform: lowercase;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.violations-panel-jump:hover:not(:disabled) {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.violations-panel-jump:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.violations-panel-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.violations-panel-row {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 8px;
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.violations-panel-row[data-status="violated"] {
|
||||
border-color: var(--accent-violation);
|
||||
}
|
||||
|
||||
.violations-panel-row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.violations-panel-badge {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.violations-panel-badge[data-status="violated"] {
|
||||
color: var(--accent-violation);
|
||||
border-color: var(--accent-violation);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.violations-panel-badge[data-status="holds"] {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.violations-panel-name {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.violations-panel-residual {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.violations-panel-residual summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.violations-panel-residual[open] summary {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ResidualNode } from "../types";
|
||||
import ResidualNodeView from "../components/ResidualNode";
|
||||
import "./ViolationsPanel.css";
|
||||
|
||||
export interface ViolationsPanelProps {
|
||||
propertyNames: string[];
|
||||
violations: string[];
|
||||
residuals?: Record<string, ResidualNode>;
|
||||
onJumpToFirstViolation: () => void;
|
||||
hasFirstViolation: boolean;
|
||||
/** When true, only render violated rows and hide the header button row. */
|
||||
violationsOnly?: boolean;
|
||||
}
|
||||
|
||||
type Status = "violated" | "pending" | "holds";
|
||||
|
||||
const STATUS_ORDER: Record<Status, number> = {
|
||||
violated: 0,
|
||||
pending: 1,
|
||||
holds: 2,
|
||||
};
|
||||
|
||||
function statusFor(
|
||||
name: string,
|
||||
violations: Set<string>,
|
||||
residuals?: Record<string, ResidualNode>,
|
||||
): Status {
|
||||
if (violations.has(name)) {
|
||||
return "violated";
|
||||
}
|
||||
const residual = residuals?.[name];
|
||||
if (residual && residual.op === "true") {
|
||||
return "holds";
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export default function ViolationsPanel({
|
||||
propertyNames,
|
||||
violations,
|
||||
residuals,
|
||||
onJumpToFirstViolation,
|
||||
hasFirstViolation,
|
||||
violationsOnly = false,
|
||||
}: ViolationsPanelProps) {
|
||||
const violationSet = useMemo(() => new Set(violations), [violations]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const sorted = [...propertyNames].sort((a, b) => a.localeCompare(b));
|
||||
const all = sorted.map((name) => ({
|
||||
name,
|
||||
status: statusFor(name, violationSet, residuals),
|
||||
}));
|
||||
const filtered = violationsOnly ? all.filter((row) => row.status === "violated") : all;
|
||||
return filtered.sort((a, b) => {
|
||||
const groupDelta = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
|
||||
if (groupDelta !== 0) {
|
||||
return groupDelta;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [propertyNames, violationSet, residuals, violationsOnly]);
|
||||
|
||||
if (violationsOnly && rows.length === 0) {
|
||||
return <div className="status-block">no violations</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="violations-panel">
|
||||
{violationsOnly ? null : (
|
||||
<header className="violations-panel-header">
|
||||
<h2 className="violations-panel-title">properties</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="violations-panel-jump"
|
||||
onClick={onJumpToFirstViolation}
|
||||
disabled={!hasFirstViolation}
|
||||
>
|
||||
jump to first violation
|
||||
</button>
|
||||
</header>
|
||||
)}
|
||||
<ul className="violations-panel-list">
|
||||
{rows.map(({ name, status }) => {
|
||||
const residual = residuals?.[name];
|
||||
return (
|
||||
<li key={name} className="violations-panel-row" data-status={status}>
|
||||
<div className="violations-panel-row-head">
|
||||
<span
|
||||
className="violations-panel-badge"
|
||||
data-status={status}
|
||||
aria-label={`status ${status}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
<span className="violations-panel-name">{name}</span>
|
||||
</div>
|
||||
{residual ? (
|
||||
<details className="violations-panel-residual">
|
||||
<summary>residual</summary>
|
||||
<ResidualNodeView node={residual} />
|
||||
</details>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { getRun, getStep, screenshotUrl } from "../api";
|
||||
import type { Run, Step } from "../types";
|
||||
import ActionList from "../panels/ActionList";
|
||||
import Screenshot from "../panels/Screenshot";
|
||||
import SnapshotTable from "../panels/SnapshotTable";
|
||||
import ViolationsPanel from "../panels/ViolationsPanel";
|
||||
import ExceptionsPanel from "../panels/ExceptionsPanel";
|
||||
import type { LaneStatus, PropertyLane } from "../panels/Timeline";
|
||||
import MetricsChart, { type MetricsSample } from "../panels/MetricsChart";
|
||||
import Tabs, { type TabDefinition } from "../components/Tabs";
|
||||
import { useStep } from "../hooks/useStep";
|
||||
import { useKeyboardNav } from "../hooks/useKeyboardNav";
|
||||
import { useTheme } from "../hooks/useTheme";
|
||||
|
||||
interface RunHistory {
|
||||
names: string[];
|
||||
lanes: PropertyLane[];
|
||||
firstViolationStep?: number;
|
||||
firstExceptionStep?: number;
|
||||
exceptionStepIndices: number[];
|
||||
violationStepIndices: number[];
|
||||
metricsSamples: MetricsSample[];
|
||||
steps: (Step | null)[];
|
||||
}
|
||||
|
||||
export default function RunDetail() {
|
||||
const [run, setRun] = useState<Run | null>(null);
|
||||
const [history, setHistory] = useState<RunHistory | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { theme, toggle } = useTheme();
|
||||
|
||||
const stepCount = run?.steps.length;
|
||||
const { runId, stepIndex, goTo } = useStep(stepCount);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runId) return;
|
||||
let cancelled = false;
|
||||
setRun(null);
|
||||
setHistory(null);
|
||||
setError(null);
|
||||
getRun(runId)
|
||||
.then(async (loaded) => {
|
||||
if (cancelled) return;
|
||||
setRun(loaded);
|
||||
const computed = await loadHistory(loaded);
|
||||
if (!cancelled) {
|
||||
setHistory(computed);
|
||||
}
|
||||
})
|
||||
.catch((failure: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(failure instanceof Error ? failure.message : String(failure));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [runId]);
|
||||
|
||||
const currentStep = history?.steps[stepIndex - 1] ?? null;
|
||||
const previousStep = stepIndex > 1 ? history?.steps[stepIndex - 2] ?? null : null;
|
||||
const nextStep = history && stepIndex < history.steps.length ? history.steps[stepIndex] ?? null : null;
|
||||
|
||||
const jumpToFirstViolation = useCallback(() => {
|
||||
if (history?.firstViolationStep) {
|
||||
goTo(history.firstViolationStep);
|
||||
}
|
||||
}, [history, goTo]);
|
||||
|
||||
const jumpToFirstException = useCallback(() => {
|
||||
if (history?.firstExceptionStep) {
|
||||
goTo(history.firstExceptionStep);
|
||||
}
|
||||
}, [history, goTo]);
|
||||
|
||||
const jumpToNextViolation = useCallback(() => {
|
||||
if (!run) return;
|
||||
const next = run.steps.find((entry) => entry.index > stepIndex && entry.has_violations);
|
||||
if (next) goTo(next.index);
|
||||
}, [run, stepIndex, goTo]);
|
||||
|
||||
useKeyboardNav({
|
||||
onPrev: () => goTo(stepIndex - 1),
|
||||
onNext: () => goTo(stepIndex + 1),
|
||||
onJumpStart: () => goTo(1),
|
||||
onJumpEnd: () => stepCount && goTo(stepCount),
|
||||
onJumpPrev10: () => goTo(stepIndex - 10),
|
||||
onJumpNext10: () => goTo(stepIndex + 10),
|
||||
onJumpNextViolation: jumpToNextViolation,
|
||||
});
|
||||
|
||||
const beforeScreenshot = useMemo(() => {
|
||||
if (!runId || !currentStep) return undefined;
|
||||
return screenshotUrl(runId, `step-${String(currentStep.step).padStart(5, "0")}.png`);
|
||||
}, [runId, currentStep]);
|
||||
|
||||
const afterScreenshot = useMemo(() => {
|
||||
if (!runId || !currentStep) return undefined;
|
||||
return screenshotUrl(runId, `step-${String(currentStep.step).padStart(5, "0")}-after.png`);
|
||||
}, [runId, currentStep]);
|
||||
|
||||
const runStartMillis = useMemo(() => {
|
||||
if (!run?.steps.length) return 0;
|
||||
return new Date(run.steps[0].timestamp).getTime();
|
||||
}, [run]);
|
||||
|
||||
if (error) {
|
||||
return <div className="status-block status-error">failed: {error}</div>;
|
||||
}
|
||||
if (!run) {
|
||||
return <div className="status-block">loading run...</div>;
|
||||
}
|
||||
|
||||
const violationsBefore = currentStep?.violations ?? [];
|
||||
const violationsAfter = nextStep?.violations ?? violationsBefore;
|
||||
const residualsBefore = currentStep?.residuals;
|
||||
const residualsAfter = nextStep?.residuals ?? residualsBefore;
|
||||
const exceptionsForStep = currentStep?.exceptions;
|
||||
|
||||
const beforeTabs: TabDefinition[] = [
|
||||
{
|
||||
id: "screenshot",
|
||||
label: "Screenshot",
|
||||
content: <Screenshot src={beforeScreenshot} action={currentStep?.action} />,
|
||||
},
|
||||
{
|
||||
id: "snapshots",
|
||||
label: "Snapshots",
|
||||
content: (
|
||||
<SnapshotTable
|
||||
snapshots={currentStep?.snapshots}
|
||||
previousSnapshots={previousStep?.snapshots ?? undefined}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "properties",
|
||||
label: "Properties",
|
||||
content: (
|
||||
<ViolationsPanel
|
||||
propertyNames={history?.names ?? []}
|
||||
violations={violationsBefore}
|
||||
residuals={residualsBefore}
|
||||
onJumpToFirstViolation={jumpToFirstViolation}
|
||||
hasFirstViolation={history?.firstViolationStep !== undefined}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "violations",
|
||||
label: "Violations",
|
||||
badge:
|
||||
violationsBefore.length > 0 ? (
|
||||
<span className="tabs-badge" data-kind="violation">
|
||||
{violationsBefore.length}
|
||||
</span>
|
||||
) : undefined,
|
||||
content: (
|
||||
<ViolationsPanel
|
||||
propertyNames={history?.names ?? []}
|
||||
violations={violationsBefore}
|
||||
residuals={residualsBefore}
|
||||
onJumpToFirstViolation={jumpToFirstViolation}
|
||||
hasFirstViolation={history?.firstViolationStep !== undefined}
|
||||
violationsOnly
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const afterTabs: TabDefinition[] = [
|
||||
{
|
||||
id: "screenshot",
|
||||
label: "Screenshot",
|
||||
content: <Screenshot src={afterScreenshot} action={undefined} />,
|
||||
},
|
||||
{
|
||||
id: "snapshots",
|
||||
label: "Snapshots",
|
||||
content: (
|
||||
<SnapshotTable
|
||||
snapshots={nextStep?.snapshots ?? currentStep?.snapshots}
|
||||
previousSnapshots={currentStep?.snapshots ?? undefined}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "properties",
|
||||
label: "Properties",
|
||||
content: (
|
||||
<ViolationsPanel
|
||||
propertyNames={history?.names ?? []}
|
||||
violations={violationsAfter}
|
||||
residuals={residualsAfter}
|
||||
onJumpToFirstViolation={jumpToFirstViolation}
|
||||
hasFirstViolation={history?.firstViolationStep !== undefined}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "violations",
|
||||
label: "Violations",
|
||||
badge:
|
||||
violationsAfter.length > 0 ? (
|
||||
<span className="tabs-badge" data-kind="violation">
|
||||
{violationsAfter.length}
|
||||
</span>
|
||||
) : undefined,
|
||||
content: (
|
||||
<ViolationsPanel
|
||||
propertyNames={history?.names ?? []}
|
||||
violations={violationsAfter}
|
||||
residuals={residualsAfter}
|
||||
onJumpToFirstViolation={jumpToFirstViolation}
|
||||
hasFirstViolation={history?.firstViolationStep !== undefined}
|
||||
violationsOnly
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="detail-root">
|
||||
<div className="detail-toolbar">
|
||||
<div className="detail-toolbar-meta">
|
||||
<Link to="/">runs</Link>
|
||||
<span>{run.id}</span>
|
||||
<span>
|
||||
<strong>{run.spec_path}</strong> seed={run.seed}
|
||||
</span>
|
||||
<span>
|
||||
step {stepIndex} / {stepCount ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="theme-toggle"
|
||||
onClick={toggle}
|
||||
aria-label={theme === "dark" ? "switch to light theme" : "switch to dark theme"}
|
||||
title={theme === "dark" ? "switch to light theme" : "switch to dark theme"}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
|
||||
<circle cx="8" cy="8" r="3" fill="none" stroke="currentColor" strokeWidth="1.25" />
|
||||
<g stroke="currentColor" strokeWidth="1.25" strokeLinecap="round">
|
||||
<line x1="8" y1="1.5" x2="8" y2="3.5" />
|
||||
<line x1="8" y1="12.5" x2="8" y2="14.5" />
|
||||
<line x1="1.5" y1="8" x2="3.5" y2="8" />
|
||||
<line x1="12.5" y1="8" x2="14.5" y2="8" />
|
||||
<line x1="3.4" y1="3.4" x2="4.8" y2="4.8" />
|
||||
<line x1="11.2" y1="11.2" x2="12.6" y2="12.6" />
|
||||
<line x1="3.4" y1="12.6" x2="4.8" y2="11.2" />
|
||||
<line x1="11.2" y1="4.8" x2="12.6" y2="3.4" />
|
||||
</g>
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
d="M13.2 9.8A5.2 5.2 0 0 1 6.2 2.8a5.2 5.2 0 1 0 7 7Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.25"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<aside className="detail-actions detail-panel">
|
||||
<h2>actions</h2>
|
||||
<div className="detail-panel-body">
|
||||
<ActionList
|
||||
steps={run.steps}
|
||||
selectedIndex={stepIndex}
|
||||
onSelect={goTo}
|
||||
runStartMillis={runStartMillis}
|
||||
selectedStep={currentStep ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="detail-state-before detail-panel">
|
||||
<h2>state before</h2>
|
||||
<div className="detail-panel-body">
|
||||
<Tabs tabs={beforeTabs} defaultTabId="screenshot" ariaLabel="state before" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="detail-state-after detail-panel">
|
||||
<h2>state after</h2>
|
||||
<div className="detail-panel-body">
|
||||
<Tabs tabs={afterTabs} defaultTabId="screenshot" ariaLabel="state after" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="detail-metrics detail-panel">
|
||||
<div className="detail-panel-body">
|
||||
<MetricsChart
|
||||
samples={history?.metricsSamples ?? []}
|
||||
selectedIndex={stepIndex}
|
||||
onSelect={goTo}
|
||||
runStartMillis={runStartMillis}
|
||||
/>
|
||||
{(exceptionsForStep && exceptionsForStep.length > 0) ||
|
||||
history?.firstExceptionStep !== undefined ? (
|
||||
<div className="detail-metrics-exceptions">
|
||||
<ExceptionsPanel
|
||||
exceptions={exceptionsForStep}
|
||||
onJumpToFirstException={jumpToFirstException}
|
||||
hasFirstException={history?.firstExceptionStep !== undefined}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadHistory(run: Run): Promise<RunHistory> {
|
||||
const responses = await Promise.all(
|
||||
run.steps.map((entry) => getStep(run.id, entry.index).catch(() => null)),
|
||||
);
|
||||
const propertyNames = collectPropertyNames(responses);
|
||||
const lanes: PropertyLane[] = propertyNames.map((name) => ({
|
||||
name,
|
||||
statuses: responses.map((step) => statusForProperty(name, step)),
|
||||
}));
|
||||
const firstViolationStep = run.steps.find((entry) => entry.has_violations)?.index;
|
||||
const firstExceptionStep = run.steps.find((entry) => entry.has_exceptions)?.index;
|
||||
const exceptionStepIndices = run.steps
|
||||
.filter((entry) => entry.has_exceptions)
|
||||
.map((entry) => entry.index);
|
||||
const violationStepIndices = run.steps
|
||||
.filter((entry) => entry.has_violations)
|
||||
.map((entry) => entry.index);
|
||||
const metricsSamples: MetricsSample[] = run.steps.map((entry, position) => ({
|
||||
stepIndex: entry.index,
|
||||
timestamp: entry.timestamp,
|
||||
metrics: responses[position]?.metrics,
|
||||
}));
|
||||
return {
|
||||
names: propertyNames,
|
||||
lanes: sortLanes(lanes),
|
||||
firstViolationStep,
|
||||
firstExceptionStep,
|
||||
exceptionStepIndices,
|
||||
violationStepIndices,
|
||||
metricsSamples,
|
||||
steps: responses,
|
||||
};
|
||||
}
|
||||
|
||||
function collectPropertyNames(steps: (Step | null)[]): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const step of steps) {
|
||||
if (!step?.residuals) continue;
|
||||
for (const name of Object.keys(step.residuals)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
return [...names].sort();
|
||||
}
|
||||
|
||||
function statusForProperty(name: string, step: Step | null): LaneStatus {
|
||||
if (!step) return "pending";
|
||||
if (step.violations?.includes(name)) return "violated";
|
||||
const residual = step.residuals?.[name];
|
||||
if (residual && residual.op === "true") return "holds";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function sortLanes(lanes: PropertyLane[]): PropertyLane[] {
|
||||
const rank = (lane: PropertyLane): number => {
|
||||
const last = lane.statuses[lane.statuses.length - 1];
|
||||
if (lane.statuses.includes("violated")) return 0;
|
||||
if (last === "pending") return 1;
|
||||
return 2;
|
||||
};
|
||||
return [...lanes].sort((a, b) => {
|
||||
const delta = rank(a) - rank(b);
|
||||
if (delta !== 0) return delta;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { listRuns } from "../api";
|
||||
import type { RunSummary } from "../types";
|
||||
import { useSse } from "../hooks/useSse";
|
||||
|
||||
function formatDuration(milliseconds?: number): string {
|
||||
if (!milliseconds) {
|
||||
return "-";
|
||||
}
|
||||
if (milliseconds < 1000) {
|
||||
return `${milliseconds}ms`;
|
||||
}
|
||||
const seconds = milliseconds / 1000;
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.round(seconds - minutes * 60);
|
||||
return `${minutes}m${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
function formatStartedAt(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export default function RunList() {
|
||||
const [runs, setRuns] = useState<RunSummary[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
listRuns()
|
||||
.then(setRuns)
|
||||
.catch((failure: unknown) => {
|
||||
setError(failure instanceof Error ? failure.message : String(failure));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useSse("runs.changed", refresh);
|
||||
|
||||
if (error) {
|
||||
return <div className="status-block status-error">failed to load runs: {error}</div>;
|
||||
}
|
||||
if (!runs) {
|
||||
return <div className="status-block">loading runs...</div>;
|
||||
}
|
||||
if (runs.length === 0) {
|
||||
return <div className="status-block">no runs yet. run `sanderling test` to produce some.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="run-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>started</th>
|
||||
<th>spec</th>
|
||||
<th>seed</th>
|
||||
<th>platform</th>
|
||||
<th>duration</th>
|
||||
<th>steps</th>
|
||||
<th>violations</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((run) => (
|
||||
<tr key={run.id}>
|
||||
<td>
|
||||
<Link to={`/runs/${run.id}`}>{formatStartedAt(run.started_at)}</Link>
|
||||
</td>
|
||||
<td>{run.spec_path}</td>
|
||||
<td>{run.seed}</td>
|
||||
<td>{run.platform}</td>
|
||||
<td>{formatDuration(run.duration_millis)}</td>
|
||||
<td>{run.step_count}</td>
|
||||
<td>
|
||||
{run.violation_count > 0 ? (
|
||||
<span className="chip chip-violation">{run.violation_count}</span>
|
||||
) : (
|
||||
"0"
|
||||
)}
|
||||
</td>
|
||||
<td>{run.in_progress ? <span className="chip chip-progress">in progress</span> : null}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-main > * {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.run-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
align-self: flex-start;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.run-table th,
|
||||
.run-table td {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.run-table th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.run-table tr:hover td {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chip-violation {
|
||||
border-color: var(--accent-violation);
|
||||
color: var(--accent-violation);
|
||||
}
|
||||
|
||||
.chip-progress {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.detail-toolbar-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-toolbar-meta strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.theme-toggle svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 340px minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 130px);
|
||||
grid-template-areas:
|
||||
"actions before after"
|
||||
"metrics metrics metrics";
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-grid > .detail-actions {
|
||||
grid-area: actions;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-grid > .detail-state-before,
|
||||
.detail-grid > .detail-state-after {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-grid > .detail-state-before {
|
||||
grid-area: before;
|
||||
}
|
||||
|
||||
.detail-grid > .detail-state-after {
|
||||
grid-area: after;
|
||||
}
|
||||
|
||||
.detail-grid > .detail-actions,
|
||||
.detail-grid > .detail-state-before {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detail-grid > .detail-state-before .screenshot-frame,
|
||||
.detail-grid > .detail-state-after .screenshot-frame,
|
||||
.detail-grid > .detail-state-before .screenshot-placeholder,
|
||||
.detail-grid > .detail-state-after .screenshot-placeholder {
|
||||
flex: 1 1 auto;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.detail-grid > .detail-metrics {
|
||||
grid-area: metrics;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detail-grid > .detail-metrics .metrics-chart {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.detail-metrics .detail-panel-body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-metrics-exceptions {
|
||||
padding: 4px 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
overflow: auto;
|
||||
max-height: 80px;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-panel h2 {
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.detail-panel-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-actions .detail-panel-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.status-block {
|
||||
padding: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: var(--accent-violation);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--surface: #f7f7f7;
|
||||
--surface-elevated: #ffffff;
|
||||
--border: #e4e4e4;
|
||||
--border-strong: #c8c8c8;
|
||||
--text-primary: #111111;
|
||||
--text-muted: #6b6b6b;
|
||||
--accent-violation: #d32f2f;
|
||||
--accent-positive: #2e7d32;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"],
|
||||
:root[data-theme="dark"] body {
|
||||
--background: #0e0e0e;
|
||||
--surface: #161616;
|
||||
--surface-elevated: #1d1d1d;
|
||||
--border: #2a2a2a;
|
||||
--border-strong: #3a3a3a;
|
||||
--text-primary: #f0f0f0;
|
||||
--text-muted: #9a9a9a;
|
||||
--accent-violation: #f06b6b;
|
||||
--accent-positive: #7cc47c;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--background: #0e0e0e;
|
||||
--surface: #161616;
|
||||
--surface-elevated: #1d1d1d;
|
||||
--border: #2a2a2a;
|
||||
--border-strong: #3a3a3a;
|
||||
--text-primary: #f0f0f0;
|
||||
--text-muted: #9a9a9a;
|
||||
--accent-violation: #f06b6b;
|
||||
--accent-positive: #7cc47c;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/fonts/JetBrainsMono-Regular.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/fonts/JetBrainsMono-Medium.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/fonts/JetBrainsMono-Bold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background: var(--background);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
export interface Meta {
|
||||
seed: number;
|
||||
spec_path: string;
|
||||
bundle_sha256: string;
|
||||
platform: string;
|
||||
bundle_id: string;
|
||||
started_at: string;
|
||||
ended_at?: string;
|
||||
sanderling_version: string;
|
||||
}
|
||||
|
||||
export interface RunSummary {
|
||||
id: string;
|
||||
started_at: string;
|
||||
ended_at?: string;
|
||||
spec_path: string;
|
||||
seed: number;
|
||||
platform: string;
|
||||
bundle_id: string;
|
||||
duration_millis: number;
|
||||
step_count: number;
|
||||
violation_count: number;
|
||||
in_progress: boolean;
|
||||
}
|
||||
|
||||
export interface StepSummary {
|
||||
index: number;
|
||||
timestamp: string;
|
||||
screen?: string;
|
||||
action_kind?: string;
|
||||
action_label?: string;
|
||||
has_violations: boolean;
|
||||
has_exceptions: boolean;
|
||||
}
|
||||
|
||||
export interface Run extends RunSummary {
|
||||
meta: Meta;
|
||||
steps: StepSummary[];
|
||||
}
|
||||
|
||||
export interface BoundsRect {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface HierarchyElement {
|
||||
resourceId?: string;
|
||||
text?: string;
|
||||
description?: string;
|
||||
class?: string;
|
||||
package?: string;
|
||||
clickable?: boolean;
|
||||
enabled?: boolean;
|
||||
checked?: boolean;
|
||||
focused?: boolean;
|
||||
selected?: boolean;
|
||||
bounds: BoundsRect;
|
||||
}
|
||||
|
||||
export interface Hierarchy {
|
||||
elements: HierarchyElement[];
|
||||
}
|
||||
|
||||
export interface BoundsRecord {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PointRecord {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Action {
|
||||
kind: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
from_x?: number;
|
||||
from_y?: number;
|
||||
to_x?: number;
|
||||
to_y?: number;
|
||||
key?: string;
|
||||
text?: string;
|
||||
duration_millis?: number;
|
||||
selector?: string;
|
||||
resolved_bounds?: BoundsRecord;
|
||||
tap_point?: PointRecord;
|
||||
}
|
||||
|
||||
export interface Exception {
|
||||
class: string;
|
||||
message?: string;
|
||||
stack_trace?: string;
|
||||
unix_millis?: number;
|
||||
}
|
||||
|
||||
export type ResidualNode =
|
||||
| { op: "true" }
|
||||
| { op: "false" }
|
||||
| { op: "always" | "now" | "next" | "not"; arg: ResidualNode }
|
||||
| { op: "eventually"; arg: ResidualNode; within?: { amount: number; unit: string } }
|
||||
| { op: "and" | "or" | "implies"; left: ResidualNode; right: ResidualNode }
|
||||
| { op: "predicate"; name?: string }
|
||||
| { op: "error"; message: string };
|
||||
|
||||
export interface Metrics {
|
||||
cpu_percent?: number;
|
||||
heap_bytes?: number;
|
||||
total_memory_bytes?: number;
|
||||
}
|
||||
|
||||
export interface Step {
|
||||
step: number;
|
||||
timestamp: string;
|
||||
screen?: string;
|
||||
snapshots?: Record<string, unknown>;
|
||||
action?: Action;
|
||||
exceptions?: Exception[];
|
||||
violations?: string[];
|
||||
hierarchy?: Hierarchy;
|
||||
residuals?: Record<string, ResidualNode>;
|
||||
metrics?: Metrics;
|
||||
}
|
||||
Reference in new issue
Block a user