mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 03:57:09 +00:00
* chore(test): start test-suite refactor sweep * test(ltl): pin exact multi-obligation residual AST * test(ltl): table-test finalize Kleene connective combinations * test(ltl): pin reduce over pending inner for bound, Or, Not * test(ltl): marshal bounded Always steps/duration/deadline * test(verifier): cover LTL combinator verdict transitions and within unit panic * test(verifier): table-test DecodeAction kinds and lastAction field exposure * test(verifier): assert WithPlatform(ios) reaches the picker host and key pool * test(verifier): widen weighted-selection assertion to a 5x skew margin * test(verifier): un-skip ax-find round trip with a committed tree fixture * test(runner): pin isWDADrop to sidecar reconnect-failed message origin * test(runner): assert PressKey/Wait trace encoding records kind-specific fields * test(runner): cover RenderSummary unsupported-verbs surfacing branch * test(trace): set Hierarchy in round-trip and lock lossy Tree contract Also add a -race concurrent WriteStep test that asserts N well-formed JSONL lines, catching torn lines if the writer mutex is dropped. * test(trace): round-trip witnesses/changes/metrics/exceptions, pin step-0 witness * test(trace): document ViolationsAreGreppable grep contract and lock-free WriteScreenshot * test(hierarchy): cover invalid-JSON and malformed-bounds parser paths * test(trace): guard writer mutex via WriteStep/Close race on w.file * test(replay): drop unfailable assets and devproxy assertions * test(replay): cache reuses on equal mtime, reparses after append * test(replay): violation marker falls back to detection step when attributed missing * test(replay): corrupt meta/trace dirs return 500 with error body * test(replay): SSE client receives runs.changed after a broadcast * test(replay): Run coalesces creates, ignores write/chmod, closes subs on cancel * fix(sidecar): synchronize health fixture writes and exercise healthError * test(sidecar): cover swipe/longpress/doubletap/erase/presskey/metrics/logs translations * test(sidecar): cover DoubleTapSelector composition and mid-gesture cancel * test(sidecar): assert gRPC error status surfaces from action RPC * fix(chrome): route action methods through runCtx so caller cancellation aborts CDP * fix(chrome): route hierarchy/screenshot/waitidle/metrics through runCtx * refactor(ios): extract pure simctl JSON parsers * refactor(ios): add command-runner seams for EnsureSimulator * test(ios): table-test simctl parsers and EnsureSimulator seams * test(sidecarassets): cover placeholder build path * test(sidecarassets): assert reuse via sentinel bytes not mtime * test(bundler): cover properties-only spec registration * refactor(testrun): extract prepareBundleInputs from Execute * test(testrun): cover prepareBundleInputs aliases and missing-runtime error * test(testrun): table-test resolveRuntimeSibling search edges * test(testrun): exact-output tests for progressHandler line format * fix(cmd): point bundle-check aliases at pkg/spec/src * test(cmd): smoke-test bundle-check resolves spec aliases * test(cmd): table-test hier-check parse and FindAll on fixture * test(cmd): unit-test buildBrowseURL deep-link vs root * test(cmd): drop flaky TestRun_Doctor that launched real Chromium * test(cmd): pin pipeline error to bundle resolution on web platform * test(replay-ui): add bun test script * ci(replay-ui): run bun test via make web-test target * ci(replay-ui): point bun cache key at replay-ui/bun.lock * test(replay-ui): exercise real URL encoding and non-ok throw in getJson * refactor(replay-ui): extract snapshot flatten/getAtPath into lib module * test(replay-ui): pin snapshot flatten/getAtPath path round-trip * refactor(replay-ui): extract action selector/format into lib module * test(replay-ui): pin action selector parse and row formatting * refactor(replay-ui): share one statusFor between panels * refactor(replay-ui): extract run-history derivation into lib module * test(replay-ui): pin shared statusFor precedence and ordering * test(replay-ui): pin run-history derivation alignment * refactor(replay-ui): export clampIndex for testing * refactor(replay-ui): extract keyboard-nav dispatch into pure module * refactor(replay-ui): extract metrics formatters into lib module * test(replay-ui): pin clampIndex step boundaries * test(replay-ui): pin keyboard-nav ownership and key routing * test(replay-ui): pin metrics formatters and path gap handling * refactor(sidecar): expose device-output parsers as internal for testing * test(sidecar): table-test device-output parsers against malformed input * test(sidecar): cover logcat parsing year inference and line skipping * test(sidecar): pin pressKey keycode mapping and unknown-key rejection * test(sidecar): metrics bundleId falls back to launched app and honors override * test(sidecar): loosen deadline upper bound to tolerate slow CI scheduling * test(web-runtime): export selector builders for unit tests * test(web-runtime): guard sanitize cycle, function, and depth limits * test(web-runtime): table-test selector builder quoting and escaping * test(sidecar): collapse scalar-forwarding RPC tests into a table * test(replay-ui): dedup step/summary fixtures into shared module * test(ios): collapse pickSimulator point-tests into a table
256 lines
7.6 KiB
TypeScript
256 lines
7.6 KiB
TypeScript
import { useState } from "react";
|
|
import type { Metrics } from "../types";
|
|
import {
|
|
buildPath,
|
|
formatHeap,
|
|
formatTime,
|
|
fractionFor,
|
|
} from "../lib/metrics-format";
|
|
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;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|