mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
feat: uatu inspect UI (web trace explorer) (#23)
* feat(trace): extend Step/Action/Meta schema for inspect UI
Add Step.Hierarchy, Step.Residuals, Action.Selector/ResolvedBounds/TapPoint,
Meta.EndedAt and JSON tags on hierarchy.Element/Bounds/Tree so trace.jsonl
can drive the upcoming uatu inspect web UI.
* test(trace): cover EndedAt + new step fields round-trip
* feat(ltl): MarshalJSON for Formula AST + Evaluator.Residual()
Each Formula concrete type now serializes to a closed-set residual node
(true/false/not/and/or/implies/always/now/next/eventually/predicate/error)
that mirrors the TS spec API surface. Evaluator.Residual() folds pending
obligations into a single Formula so the runner can stamp one residual
per property per step into trace.jsonl.
* feat(runner): stamp residuals, hierarchy, selector targets, ended_at
Each Step now carries the captured hierarchy, per-property residual ASTs,
and (for Tap/InputText) the selector + resolved bounds + tap point. The
test_run command writes meta.ended_at on graceful shutdown so the inspect
UI can distinguish completed runs from in-progress ones.
* feat(inspect): scaffold embed dist for SPA assets
Stage 2 stub for the inspect server. Real web bundle gets wired in
Stage 4 (Makefile copies web/dist into internal/inspect/dist).
* chore(web): ignore web/ build output in root .gitignore
* chore(web): add bun + vite + vitest scaffold config
* feat(web): monochrome design tokens, typography, app shell CSS
* chore(web): placeholder for self-hosted JetBrains Mono fonts
* feat(web): index.html entry with style links and root mount
* feat(web): typescript types mirroring run/step trace schema
* feat(web): typed fetchers for runs/steps/screenshots
* feat(web): App shell with router and run/step routes
* feat(inspect): runs scan, lazy step parse, mtime-aware cache
* feat(web): RunList route with table, loading, and error states
* feat(web): RunDetail route shell with three placeholder panels
* feat(inspect): fsnotify-backed runs watcher with debounce
* fix(web): use jest-dom/vitest entry so matchers register
* test(web): cover listRuns happy path and error response
* test(web): render RunList with mocked fetch and assert row
* chore(web): commit bun lockfile
* feat(inspect): http handlers for runs/steps/screenshots/SSE
* test(inspect): cover handlers, screenshot whitelist, SSE, dev proxy
* feat(cmd): add 'uatu inspect' subcommand
* fix(web): align TS types with snake_case wire format
Go inspect server serializes RunSummary, StepSummary, Step, Meta with
snake_case JSON tags (matching the on-disk trace.jsonl/meta.json). Update
the TS types and consumers to match so API responses parse without
runtime undefined fields. Action keeps resolvedBounds/tapPoint as camelCase
because those keys were defined that way in the trace schema.
* feat(web): add ActionList panel for run-detail step navigation
* feat(web): add SnapshotTable panel with diff highlighting
Renders snapshots dictionary as a flat sorted dotted-path tree.
Changed leaves get data-changed plus a hover title with the previous value.
* test(web): cover SnapshotTable rendering and diff behavior
Eight cases: empty state, sort order, dotted-path expansion,
changed/unchanged/missing-previous flagging, and inline-vs-expanded arrays.
* feat(web): add Screenshot panel with bounds and tap overlays
Center column of run-detail page. Renders the device screenshot
scaled to fit, with an SVG overlay drawing resolvedBounds as a
violation-colored rect, tapPoint as a contrast ring, and swipes
as an arrow. Falls back to a placeholder when src is missing or
the image fails to load.
* fix(web): guard scrollIntoView call for jsdom compatibility
* test(web): cover Screenshot panel rendering and overlays
* test(web): cover ActionList rendering, selection, keyboard, and markers
* feat(web): add ExceptionsPanel component
* test(web): add ExceptionsPanel tests
* feat(web): add Timeline panel with property swimlanes
Renders SVG swimlanes per property with violated/pending/holds cells,
action-marker dots, click-to-seek, and a selected-step highlight bar.
* test(web): cover Timeline empty state, cells, status, click, highlight
* feat(web): add ResidualNode recursive AST renderer
* test(web): cover ResidualNode operators, predicate, and error chip
* feat(web): add ViolationsPanel with status badges and jump button
* test(web): cover ViolationsPanel rows, status grouping, and jump button
* test(web): register testing-library cleanup globally
All six panel test files added local afterEach(cleanup); centralize it in
the shared setup so future tests inherit DOM isolation by default.
* feat(web): hooks for url/keyboard/theme/sse
* feat(web): wire all panels into run-detail with phone-dominant grid
ActionList left, Screenshot center, Snapshots/Properties/Exceptions
stacked right, Timeline bottom. URL-synced step index (useStep), keyboard
shortcuts (j/k/arrows/g/G/.), light+dark theme toggle stored in
localStorage, SSE auto-refresh on the run index.
* test(web): add three reference run fixtures (clean, violation, exception)
* build: web targets in Makefile + bun in CI; docs(inspect)
- Makefile: web-build/web-dev/inspect-dev/test-web targets; uatu and
install now depend on web-build so the binary embeds the latest SPA.
- ci.yml: setup-bun + cache; existing make test now runs web typecheck +
vitest as part of the full suite.
- docs/manual/inspect.md: panel reference, keyboard shortcuts, URLs.
- docs/manual/cli.md: document uatu inspect.
- README: link to inspect docs.
* feat(runner): capture a screenshot per step
The driver already exposes Screenshot(ctx), but the runner never called
it. Each step now writes <run>/screenshots/step-NNNNN.png right after
the trace line, using the same failure-is-a-warning posture as other
best-effort observability hooks. Makes the inspect UI's center panel
actually useful.
* feat(inspect): include action_label in StepSummary
Tap/InputText/Swipe/PressKey/Wait each get a short human-readable
label (selector, quoted text, swipe direction, key name, duration) so
the action list panel can render readable rows instead of just 'Tap'
with no target.
* test(inspect): accept either #app or #root in SPA shell fallback
* feat(web): render action_label and screen in ActionList rows
Step rows now show 'Tap id:save', 'InputText "alice"', 'Swipe up',
'PressKey back', etc. Steps with no action fall back to
'observe @ <screen>' so the list reads as a flow instead of a wall
of '--' placeholders.
* feat(sidecar): implement screencap for android driver backend
Was stubbed to return an empty byte array, which made the runner's
per-step screenshot capture a no-op. Shell out to 'adb exec-out
screencap -p' and stream the PNG bytes back. Width/height stay zero
because the PNG header carries them; the Go side can parse if needed.
* feat(proto): add Metrics RPC for per-step CPU and memory capture
* feat(driver): Metrics(bundleID) returns cpu_percent + heap/total bytes
* feat(sidecar): implement Metrics RPC via adb top + /proc/<pid>/status
* feat(runner): capture metrics + before/after screenshots per step
Each step now writes step-NNNNN.png (before applyAction) and
step-NNNNN-after.png (after the action + wait-for-idle). The runner
samples Driver.Metrics(bundleID) before writing the trace line and
stamps Step.Metrics with cpu_percent, heap_bytes, total_memory_bytes
so the inspect UI can chart CPU and heap over the run.
* fix(runner,sidecar): measure CPU across step via /proc stat delta
'top -d 0.3 -n 2' measures CPU in a 300ms window that coincides with
the SDK-paused app, always reporting 0%. Switch to reading
/proc/<pid>/stat utime+stime and computing the delta between successive
calls; the natural step cadence gives a 2-5s measurement window that
captures the action response and render cycle. Also moved the sample
to before snapshotStep so the delta starts before the SDK pause.
* feat(web): add Metrics type for per-step cpu and memory
* refactor(web): replace --accent-change with --accent-positive token
* refactor(web): recolor chip-progress as neutral outlined chip
* refactor(web): use neutral border for changed snapshot rows
* feat(web): add MetricsChart panel with HEAP and CPU lanes
SVG-based time-series chart rendering heap bytes and CPU percent per
step across two stacked lanes, with a shared step axis below. Lines are
monochrome; a vertical highlight marks the selected step; per-step hit
rects make any click seek to that step.
* feat(web): revamp ActionList with tag targets, elapsed time, and expandable rows
Render selector-based Tap actions as <tag/> markup, show zero-padded MM:SS.mmm
elapsed time per row, and expand the active row with Position/Content sub-rows
when a full Step is available. Adds formatActionRow/formatElapsed helpers and
covers both with unit tests.
* fix(runner): stop copying Tap selector into action.text
The 'Content' inspect row should show the user-supplied text for
InputText actions and stay empty for Taps. Previously the runner copied
action.On into traceAction.Text for both, so the inspect UI showed the
selector as the tap's 'Content'.
* fix(web): use text-muted for swipe arrow after accent-change removal
* fix(web): snapshot values truncate with ellipsis + title tooltip
Long JSON values were breaking one character per line due to
overflow-wrap:anywhere in a narrow column. Switch to single-line ellipsis
with the full value exposed via the title attribute on hover.
* feat(web): state-before/after columns + metrics chart at bottom
RunDetail now renders a four-column grid:
actions | state-before | state-after | side (exceptions + timeline)
with MetricsChart spanning the bottom row. Each state column shows its
own screenshot (step-NNNNN.png vs step-NNNNN-after.png), snapshot table,
and violations panel. ActionList now receives runStartMillis and the
selected Step so the active row can expand Position/Content sub-rows.
* fix(web): skip zero-value ticks + add exception markers to metrics
HEAP '0B' and CPU '100%' labels overlapped at the lane boundary. Drop
the bottom-of-range tick on both lanes (baseline is implied) and widen
LANE_GAP so the remaining labels have breathing room. Accept an
exceptionStepIndices prop and draw a dashed red vertical line at each
to surface exception spikes directly on the CPU/heap chart.
* fix(web): let action body column shrink below its content
Required minmax(0, 1fr) so the row grid honours the column's min-size of
0 instead of the implicit 'auto', preventing the action-list from
overflowing its parent when the target string is long.
* feat(web): bigger state screenshots + single properties row
Collapse snapshots into a summary chip ('SNAPSHOTS · N violations') so
the screenshot fills its state card. Deduplicate ViolationsPanel —
show it once in a new full-width 'properties' row between the state
cards and the timeline. Drop the right sidebar; exceptions now surface
as dashed markers on the metrics chart with the ExceptionsPanel only
rendering when there are actual exceptions to report.
* feat(web): add minimal Tabs component
Monochrome tab strip with underline-on-active. Used by state-before
and state-after cards to swap between Screenshot, Snapshots, Properties.
Pane scrolls internally so the outer grid stays fixed-height.
* feat(web): fold timeline into MetricsChart as STEPS lane
Adds a thin per-step status row above HEAP showing violated (red),
pending (dim gray) or holds (green-tinted). Extends highlight +
exception markers to span the status lane. Frees a whole row in the
detail grid so the page can fit in 100vh.
* refactor(web): tabbed state cards, drop standalone Timeline panel
State-before/after now use Tabs (Screenshot / Snapshots / Properties,
default Screenshot). Removes the dedicated timeline row; status lane
lives on the metrics chart. Banner is gone from the shell.
* feat(web): lock app shell to 100vh with no page scroll
html/body/#root fill the viewport, body gets overflow:hidden, and the
detail grid uses minmax(0, 1fr) rows so inner panels own their scroll.
Tightens toolbar + panel padding for a denser feel.
* feat(web): arrow-key nav + badges on Tabs (WAI-ARIA tablist)
Roving tabindex, ArrowLeft/Right/Up/Down/Home/End navigation, explicit
aria-selected/aria-controls/id wiring, and support for an optional
badge inside each tab (used for violation counts).
* feat(web): ViolationsPanel supports violationsOnly filter
* feat(web): ActionList arrow-key nav + listbox semantics + smaller font
Promote the list to role=listbox with role=option rows; roving tabindex
lets ArrowUp/Down (and Home/End) seek between steps with focus. Font
size dropped to 11px and padding tightened so long selector-tag labels
fit in the 340px actions column.
* fix(web): useKeyboardNav yields arrow keys to tablist/listbox targets
Previously pressing ArrowRight on a focused tab switched tabs AND
advanced the step. Skip arrow handling when the event target is inside
an element with an arrow-owning ARIA role.
* feat(web): fourth 'Violations' tab + wider actions + shorter metrics
Adds a Violations tab to each state card showing only violated properties
(with count badge on the tab label when > 0). Actions column widened
from 280px to 340px, bottom metrics strip trimmed from 220px to 140px
with tighter lane heights, so the whole page still fits in 100vh with
no scrollbar.
* feat(web): compact RunDetail layout using 1px borders instead of panel padding
* refactor(inspect): simplify MetricsChart to HEAP+CPU with time axis
Drop the STEPS status lane and per-sample circle markers, switch the
x-axis from step indices to mm:ss clock time, trim y-axis ticks to
min/max with compact units, rotate lane labels into the left gutter,
and replace the thin playhead line with a wider dotted red band.
Traces stay grayscale; red appears only on the playhead pattern.
* fix(web): RunList rows no longer stretch to fill viewport height
Tables inherited flex: 1 1 auto from .app-main > * and distributed extra
vertical space across rows. Override with flex: 0 0 auto + align-self.
* misc changes
* fix(web): hoist useState above early return in MetricsChart
Calling useState after an unconditional early return violates React's
Rules of Hooks: the empty-samples branch renders 0 hooks while the
populated branch calls 1. On the initial null->loaded transition of
history the hook count changes and React throws.
* fix(web): subscribe to named SSE event instead of 'message'
Server emits 'event: runs.changed' frames; the WHATWG EventSource spec
dispatches those as events of type 'runs.changed', not 'message'. The
listener registered on 'message' was never fired, so RunList never
auto-refreshed on run create/finish/delete.
* fix(inspect): unsubscribe SSE clients on disconnect
Watcher.Subscribe appended to a slice with no matching removal path,
so every closed EventSource connection leaked its channel. Over a
long-running server the slice grew unbounded and every fs event paid
O(N) iterating dead channels. Add Unsubscribe + defer it in
handleEvents.
Unsubscribe does not close the channel: broadcast snapshots the
slice without holding the mutex, so a concurrent close would race
with its non-blocking send.
* fix(trace): rename resolvedBounds/tapPoint to snake_case
Every other json tag in the trace schema (from_x, duration_millis,
bundle_sha256, etc.) uses snake_case. The two new Action fields
introduced with the inspect UI broke that pattern. Rename them
before the format ships to external consumers.
* chore(web): drop vitest and remove UI tests from CI
No UI tests wanted in web. Removes vitest, jsdom, testing-library
devDeps and the vitest.setup.ts + vite.config.ts test block.
Makefile test-web becomes web-typecheck (typecheck only).
Fixes CI failure where `vitest run` exits 1 with no test files.
* chore(make): dedupe sidecar embed and drop recursive make
Make $(SIDECAR_JAR) the real recipe and $(SIDECAR_EMBED) a file
target, so uatu/install/inspect-dev share one copy step and
sidecar/release-cli just depend on the jar instead of re-invoking make.
This commit is contained in:
77 files changed
+6504
-72
No files matched your search
@@ -40,6 +40,19 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: pkg/spec-api/package-lock.json
|
||||
|
||||
- name: Set up bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Cache bun store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('web/bun.lock') }}
|
||||
restore-keys: |
|
||||
bun-${{ runner.os }}-
|
||||
|
||||
- name: Install buf
|
||||
uses: bufbuild/buf-setup-action@v1
|
||||
|
||||
|
||||
+15
-1
@@ -35,8 +35,22 @@ internal/sidecar/assets/sidecar-all.jar
|
||||
pkg/spec-api/dist/
|
||||
|
||||
# goreleaser local output
|
||||
dist/
|
||||
/dist/
|
||||
|
||||
# inspect web bundle output (real bundle wired in Stage 4 via Makefile/CI).
|
||||
# Track only the stub index.html + .gitkeep so //go:embed succeeds.
|
||||
/internal/inspect/dist/*
|
||||
!/internal/inspect/dist/.gitkeep
|
||||
!/internal/inspect/dist/index.html
|
||||
|
||||
# inspect web frontend
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web/.vite/
|
||||
|
||||
# coding agent files
|
||||
.claude/
|
||||
.claude/*
|
||||
|
||||
# Personal research notes
|
||||
/research/
|
||||
@@ -15,7 +15,10 @@ DOCS_SRC := $(shell find docs -type f -name '*.md' -not -path 'docs/_*')
|
||||
DOCS_OUT := $(patsubst docs/%.md,build/site/%.html,$(DOCS_SRC))
|
||||
DOCS_TEMPLATE := docs/_template/page.html
|
||||
|
||||
.PHONY: bootstrap proto sidecar sdk-android sdk-android-publish uatu install test test-go test-kotlin test-spec-api docs clean release-cli release-android-local release-npm-dry
|
||||
INSPECT_DIST := internal/inspect/dist
|
||||
WEB_DIST := web/dist
|
||||
|
||||
.PHONY: bootstrap proto sidecar sdk-android sdk-android-publish uatu install test test-go test-kotlin test-spec-api web-typecheck web-build web-dev inspect-dev docs clean release-cli release-android-local release-npm-dry
|
||||
|
||||
bootstrap:
|
||||
$(GO) mod download
|
||||
@@ -26,8 +29,7 @@ proto:
|
||||
$(BUF) lint
|
||||
$(BUF) generate
|
||||
|
||||
sidecar:
|
||||
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:shadowJar
|
||||
sidecar: $(SIDECAR_JAR)
|
||||
|
||||
sdk-android:
|
||||
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sdk-android:assembleRelease
|
||||
@@ -37,23 +39,39 @@ sdk-android-publish:
|
||||
|
||||
uatu: $(UATU_BIN)
|
||||
|
||||
$(UATU_BIN): $(SIDECAR_JAR)
|
||||
mkdir -p bin $(dir $(SIDECAR_EMBED))
|
||||
cp $(SIDECAR_JAR) $(SIDECAR_EMBED)
|
||||
$(UATU_BIN): $(SIDECAR_EMBED) web-build
|
||||
mkdir -p bin
|
||||
$(GO) build -tags withsidecar -o $(UATU_BIN) ./cmd/uatu
|
||||
|
||||
# Installs `uatu` into $GOBIN (or $GOPATH/bin) so it's directly on PATH for
|
||||
# anyone with a standard Go toolchain setup.
|
||||
install: $(SIDECAR_JAR)
|
||||
mkdir -p $(dir $(SIDECAR_EMBED))
|
||||
cp $(SIDECAR_JAR) $(SIDECAR_EMBED)
|
||||
install: $(SIDECAR_EMBED) web-build
|
||||
$(GO) install -tags withsidecar ./cmd/uatu
|
||||
@dest="$$($(GO) env GOBIN)"; [ -n "$$dest" ] || dest="$$($(GO) env GOPATH)/bin"; echo "installed uatu to $$dest"
|
||||
|
||||
$(SIDECAR_JAR):
|
||||
$(MAKE) sidecar
|
||||
web-build:
|
||||
cd web && bun install --frozen-lockfile && bun run build
|
||||
mkdir -p $(INSPECT_DIST)
|
||||
rm -rf $(INSPECT_DIST)/assets $(INSPECT_DIST)/fonts
|
||||
cp -R $(WEB_DIST)/. $(INSPECT_DIST)/
|
||||
|
||||
test: test-go test-kotlin test-spec-api
|
||||
web-dev:
|
||||
cd web && bun run dev
|
||||
|
||||
inspect-dev: $(SIDECAR_EMBED)
|
||||
$(GO) run -tags withsidecar ./cmd/uatu inspect --dev
|
||||
|
||||
web-typecheck:
|
||||
cd web && bun install --frozen-lockfile && bun run typecheck
|
||||
|
||||
$(SIDECAR_JAR):
|
||||
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:shadowJar
|
||||
|
||||
$(SIDECAR_EMBED): $(SIDECAR_JAR)
|
||||
mkdir -p $(dir $@)
|
||||
cp $< $@
|
||||
|
||||
test: test-go test-kotlin test-spec-api web-typecheck
|
||||
|
||||
test-go:
|
||||
$(GO) test $(GO_PACKAGES)
|
||||
@@ -86,8 +104,7 @@ clean:
|
||||
|
||||
# Local release dry-runs. None of these touch remote registries.
|
||||
|
||||
release-cli:
|
||||
$(MAKE) sidecar
|
||||
release-cli: $(SIDECAR_JAR)
|
||||
goreleaser release --snapshot --clean
|
||||
|
||||
release-android-local:
|
||||
|
||||
@@ -8,5 +8,8 @@ Autonomous property-based testing for mobile apps. Specs in TypeScript. Core in
|
||||
|
||||
- [Getting started](https://priyanshujain.github.io/uatu/manual/getting-started.html)
|
||||
- [Writing specs](https://priyanshujain.github.io/uatu/manual/writing-specs.html)
|
||||
- [`uatu inspect` UI](https://priyanshujain.github.io/uatu/manual/inspect.html)
|
||||
- [Example](https://github.com/priyanshujain/uatu/tree/master/examples/folio)
|
||||
- [Architecture](https://priyanshujain.github.io/uatu/development/architecture.html)
|
||||
|
||||
After a `uatu test` run, browse traces locally with `uatu inspect`. It opens a web UI for stepping through actions, screenshots, snapshots, residual formulas, and exceptions.
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/uatu/internal/inspect"
|
||||
)
|
||||
|
||||
type inspectOptions struct {
|
||||
port int
|
||||
noOpen bool
|
||||
dev bool
|
||||
directory string
|
||||
}
|
||||
|
||||
func parseInspectArgs(args []string, stderr io.Writer) (inspectOptions, error) {
|
||||
flagSet := flag.NewFlagSet("inspect", flag.ContinueOnError)
|
||||
flagSet.SetOutput(stderr)
|
||||
var options inspectOptions
|
||||
flagSet.IntVar(&options.port, "port", 0, "TCP port to listen on (0 = ephemeral)")
|
||||
flagSet.BoolVar(&options.noOpen, "no-open", false, "do not open the default browser on startup")
|
||||
flagSet.BoolVar(&options.dev, "dev", false, "reverse-proxy non-API requests to "+inspect.DevTarget)
|
||||
if err := flagSet.Parse(args); err != nil {
|
||||
return inspectOptions{}, err
|
||||
}
|
||||
rest := flagSet.Args()
|
||||
if len(rest) > 1 {
|
||||
return inspectOptions{}, errors.New("inspect takes at most one positional argument (run or runs directory)")
|
||||
}
|
||||
if len(rest) == 1 {
|
||||
options.directory = rest[0]
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func runInspect(options inspectOptions, stdout io.Writer) error {
|
||||
runsDirectory, deepLinkID, err := inspect.ResolveRunsDirectory(options.directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
devTarget := ""
|
||||
if options.dev {
|
||||
devTarget = inspect.DevTarget
|
||||
}
|
||||
server, err := inspect.NewServer(inspect.ServerOptions{
|
||||
RunsDirectory: runsDirectory,
|
||||
DevTarget: devTarget,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(options.port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
address := listener.Addr().(*net.TCPAddr)
|
||||
browseURL := buildBrowseURL(address, deepLinkID)
|
||||
fmt.Fprintf(stdout, "uatu inspect listening on %s (runs=%s)\n", browseURL, runsDirectory)
|
||||
|
||||
context, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
watcherDone := make(chan struct{})
|
||||
go func() {
|
||||
_ = server.Watcher().Run(context)
|
||||
close(watcherDone)
|
||||
}()
|
||||
|
||||
httpServer := &http.Server{Handler: server.Handler(), ReadHeaderTimeout: 10 * time.Second}
|
||||
serverDone := make(chan error, 1)
|
||||
go func() { serverDone <- httpServer.Serve(listener) }()
|
||||
|
||||
if !options.noOpen {
|
||||
if err := openBrowser(browseURL); err != nil {
|
||||
fmt.Fprintf(stdout, "warning: could not open browser: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = <-serverDone
|
||||
cancel()
|
||||
<-watcherDone
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func buildBrowseURL(address *net.TCPAddr, deepLinkID string) string {
|
||||
target := url.URL{Scheme: "http", Host: address.String(), Path: "/"}
|
||||
if deepLinkID != "" {
|
||||
target.Path = "/runs/" + deepLinkID
|
||||
}
|
||||
return target.String()
|
||||
}
|
||||
|
||||
func openBrowser(target string) error {
|
||||
var command *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
command = exec.Command("open", target)
|
||||
case "windows":
|
||||
command = exec.Command("cmd", "/c", "start", target)
|
||||
default:
|
||||
command = exec.Command("xdg-open", target)
|
||||
}
|
||||
return command.Start()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseInspectArgs_Defaults(t *testing.T) {
|
||||
options, err := parseInspectArgs(nil, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if options.port != 0 || options.noOpen || options.dev || options.directory != "" {
|
||||
t.Errorf("unexpected defaults: %+v", options)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInspectArgs_AllFlags(t *testing.T) {
|
||||
options, err := parseInspectArgs([]string{"--port", "9090", "--no-open", "--dev", "/tmp/runs"}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if options.port != 9090 || !options.noOpen || !options.dev || options.directory != "/tmp/runs" {
|
||||
t.Errorf("unexpected options: %+v", options)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInspectArgs_RejectsTooManyPositional(t *testing.T) {
|
||||
_, err := parseInspectArgs([]string{"a", "b"}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "at most one") {
|
||||
t.Fatalf("expected too-many-args error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_HelpListsInspectCommand(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
if err := run([]string{"uatu"}, &stdout, io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "inspect") {
|
||||
t.Errorf("usage missing inspect command: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ Usage:
|
||||
|
||||
Commands:
|
||||
test Run a spec against an app for a fixed duration.
|
||||
inspect Serve a local web UI for browsing runs/.
|
||||
doctor Check that the host environment is ready to run uatu.
|
||||
version Print the uatu version.
|
||||
|
||||
@@ -87,6 +88,12 @@ func run(args []string, stdout, stderr io.Writer) error {
|
||||
return err
|
||||
}
|
||||
return runTest(options, stdout)
|
||||
case "inspect":
|
||||
options, err := parseInspectArgs(args[2:], stderr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runInspect(options, stdout)
|
||||
case "doctor":
|
||||
return runDoctor(stdout)
|
||||
case "version", "-v", "--version":
|
||||
|
||||
@@ -159,7 +159,7 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
|
||||
return fmt.Errorf("trace writer: %w", err)
|
||||
}
|
||||
defer traceWriter.Close()
|
||||
if err := traceWriter.WriteMeta(trace.Meta{
|
||||
meta := trace.Meta{
|
||||
Seed: seed,
|
||||
SpecPath: options.spec,
|
||||
BundleSHA256: bundle.SHA256,
|
||||
@@ -167,9 +167,15 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
|
||||
BundleID: options.bundleID,
|
||||
StartedAt: time.Now().UTC(),
|
||||
UatuVersion: "0.0.1",
|
||||
}); err != nil {
|
||||
}
|
||||
if err := traceWriter.WriteMeta(meta); err != nil {
|
||||
return fmt.Errorf("trace meta: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
endedAt := time.Now().UTC()
|
||||
meta.EndedAt = &endedAt
|
||||
_ = traceWriter.WriteMeta(meta)
|
||||
}()
|
||||
fmt.Fprintf(stdout, "trace dir: %s\n", runDirectory)
|
||||
|
||||
fmt.Fprintf(stdout, "running for %s (seed=%d)\n", options.duration, seed)
|
||||
@@ -177,6 +183,7 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
|
||||
Duration: options.duration,
|
||||
SnapshotTimeout: 5 * time.Second,
|
||||
IdleTimeout: 1 * time.Second,
|
||||
BundleID: options.bundleID,
|
||||
Connection: connection,
|
||||
Driver: driverClient,
|
||||
Verifier: verifierInstance,
|
||||
|
||||
+12
-1
@@ -23,6 +23,18 @@ Run a spec against an app for a fixed duration.
|
||||
| `--seed` | `0` | PRNG seed. `0` uses a random seed and records it in `meta.json`. |
|
||||
| `--output` | `./runs` | Output directory for traces. |
|
||||
|
||||
## `uatu inspect [run-or-runs-dir]`
|
||||
|
||||
Serve a local web UI for browsing traces. The positional argument is optional and may point at either a runs directory (the parent of many runs) or a single run directory (auto-detected by the presence of `meta.json`). Defaults to `./runs`.
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `--port` | `0` (ephemeral) | TCP port to listen on. |
|
||||
| `--no-open` | `false` | Skip opening the default browser on startup. |
|
||||
| `--dev` | `false` | Reverse-proxy non-API requests to the Vite dev server on `127.0.0.1:5173`. |
|
||||
|
||||
See [the inspect UI page](inspect.md) for the panel reference and keyboard shortcuts.
|
||||
|
||||
## `uatu doctor`
|
||||
|
||||
Check the host environment for a working uatu setup: Go toolchain, JDK, Maestro availability, emulator reachability, SDK linkage hints.
|
||||
@@ -36,6 +48,5 @@ Print the CLI version.
|
||||
- `--permissions` to pre-set OS-level permissions (for example `--permissions location=allow,notifications=deny`).
|
||||
- `--max-steps` hard cap on step count.
|
||||
- `--exit-on-violation` stop the run on the first property violation.
|
||||
- `uatu inspect` command for browsing traces in the built-in UI.
|
||||
|
||||
Tracked in [issue #4](https://github.com/priyanshujain/uatu/issues/4).
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: uatu inspect
|
||||
---
|
||||
|
||||
# uatu inspect
|
||||
|
||||
`uatu inspect` is a local web UI for exploring runs produced by `uatu test`. It reads `runs/<id>/meta.json` and `runs/<id>/trace.jsonl` and renders each step with its action, screenshot, snapshots, residual formulas, and exceptions.
|
||||
|
||||
```
|
||||
uatu inspect [run-or-runs-dir] [--port N] [--no-open] [--dev]
|
||||
```
|
||||
|
||||
The positional argument can be either a runs directory or a single run directory (auto-detected by the presence of `meta.json`). When omitted, it defaults to `./runs`.
|
||||
|
||||
## Layout
|
||||
|
||||
The detail page uses a phone-dominant grid:
|
||||
|
||||
- **Actions** (left): vertical step list. Steps with violations are marked with a red dot; steps with exceptions have a dashed-outline marker.
|
||||
- **Screenshot** (center): the device screenshot for the current step. The runner's resolved tap target is overlaid as a red rectangle, the tap point as an outlined circle. Swipes show an arrow from start to end.
|
||||
- **Snapshots** (top right): the current step's snapshots flattened into dotted-path rows. Values that changed since the previous step are highlighted; hover to see the previous value.
|
||||
- **Properties** (middle right): one row per property with status (violated / pending / holds) and an expandable residual formula.
|
||||
- **Exceptions** (bottom right): SDK-captured uncaught throwables. Stack traces expand inline.
|
||||
- **Timeline** (bottom): per-property swimlane across all steps; click a cell to seek.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `j`, `Right` | Next step |
|
||||
| `k`, `Left` | Previous step |
|
||||
| `Shift+j`, `Shift+Right` | Jump 10 forward |
|
||||
| `Shift+k`, `Shift+Left` | Jump 10 back |
|
||||
| `g` | First step |
|
||||
| `G` | Last step |
|
||||
| `.` | Next step with a violation |
|
||||
|
||||
## URLs
|
||||
|
||||
- `/` — run index (auto-refreshes via SSE when new runs land)
|
||||
- `/runs/:id` — redirects to step 1
|
||||
- `/runs/:id/steps/:n` — direct deep link
|
||||
|
||||
## Theme
|
||||
|
||||
Defaults to the system color scheme via `prefers-color-scheme`. The `light`/`dark` button in the toolbar toggles a manual override stored in `localStorage`.
|
||||
|
||||
## Development
|
||||
|
||||
Two-process loop:
|
||||
|
||||
```
|
||||
make web-dev # bun + vite, http://127.0.0.1:5173
|
||||
make inspect-dev # uatu inspect --dev, proxies non-API to 5173
|
||||
```
|
||||
|
||||
For a single binary with embedded assets:
|
||||
|
||||
```
|
||||
make uatu # builds web/dist, copies to internal/inspect/dist, then go build
|
||||
```
|
||||
@@ -5,6 +5,7 @@ go 1.26.1
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||
github.com/evanw/esbuild v0.28.0
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwW
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
|
||||
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
|
||||
@@ -30,6 +30,11 @@ type Driver interface {
|
||||
|
||||
WaitForIdle(ctx context.Context, duration time.Duration) error
|
||||
Health(ctx context.Context) (Health, error)
|
||||
// Metrics samples the app's CPU and memory at the time of the call.
|
||||
// CPUPercent is percent of a single core (multi-core apps can exceed
|
||||
// 100). HeapBytes is resident set size; TotalMemoryBytes includes
|
||||
// native allocations.
|
||||
Metrics(ctx context.Context, bundleID string) (Metrics, error)
|
||||
}
|
||||
|
||||
type LogEntry struct {
|
||||
@@ -50,3 +55,9 @@ type Health struct {
|
||||
Version string
|
||||
Platform string
|
||||
}
|
||||
|
||||
type Metrics struct {
|
||||
CPUPercent float64
|
||||
HeapBytes int64
|
||||
TotalMemoryBytes int64
|
||||
}
|
||||
@@ -153,4 +153,16 @@ func (c *Client) Health(ctx context.Context) (driver.Health, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Metrics(ctx context.Context, bundleID string) (driver.Metrics, error) {
|
||||
response, err := c.stub.Metrics(ctx, &driverpb.MetricsRequest{BundleId: bundleID})
|
||||
if err != nil {
|
||||
return driver.Metrics{}, err
|
||||
}
|
||||
return driver.Metrics{
|
||||
CPUPercent: response.GetCpuPercent(),
|
||||
HeapBytes: response.GetHeapBytes(),
|
||||
TotalMemoryBytes: response.GetTotalMemoryBytes(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*Client)(nil)
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
ActionRecentLogs ActionKind = "recent_logs"
|
||||
ActionWaitForIdle ActionKind = "wait_for_idle"
|
||||
ActionHealth ActionKind = "health"
|
||||
ActionMetrics ActionKind = "metrics"
|
||||
)
|
||||
|
||||
type Action struct {
|
||||
@@ -53,6 +54,7 @@ type Driver struct {
|
||||
ImageData driver.Image
|
||||
HealthInfo driver.Health
|
||||
LogEntries []driver.LogEntry
|
||||
MetricsData driver.Metrics
|
||||
Failures map[ActionKind]error
|
||||
}
|
||||
|
||||
@@ -208,4 +210,14 @@ func (d *Driver) Health(ctx context.Context) (driver.Health, error) {
|
||||
return d.HealthInfo, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Metrics(ctx context.Context, bundleID string) (driver.Metrics, error) {
|
||||
if err := d.failure(ActionMetrics); err != nil {
|
||||
return driver.Metrics{}, err
|
||||
}
|
||||
d.record(Action{Kind: ActionMetrics, BundleID: bundleID})
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
return d.MetricsData, nil
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*Driver)(nil)
|
||||
@@ -19,7 +19,10 @@ import (
|
||||
|
||||
// Bounds is an inclusive rectangle in device pixels.
|
||||
type Bounds struct {
|
||||
Left, Top, Right, Bottom int
|
||||
Left int `json:"left"`
|
||||
Top int `json:"top"`
|
||||
Right int `json:"right"`
|
||||
Bottom int `json:"bottom"`
|
||||
}
|
||||
|
||||
// Center returns the center point of the bounds.
|
||||
@@ -35,22 +38,22 @@ func (b Bounds) Height() int { return b.Bottom - b.Top }
|
||||
|
||||
// Element is a flattened view of one uiautomator node.
|
||||
type Element struct {
|
||||
ResourceID string
|
||||
Text string
|
||||
Description string
|
||||
Class string
|
||||
Package string
|
||||
Clickable bool
|
||||
Enabled bool
|
||||
Checked bool
|
||||
Focused bool
|
||||
Selected bool
|
||||
Bounds Bounds
|
||||
ResourceID string `json:"resourceId,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Class string `json:"class,omitempty"`
|
||||
Package string `json:"package,omitempty"`
|
||||
Clickable bool `json:"clickable,omitempty"`
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
Focused bool `json:"focused,omitempty"`
|
||||
Selected bool `json:"selected,omitempty"`
|
||||
Bounds Bounds `json:"bounds"`
|
||||
}
|
||||
|
||||
// Tree is a flat collection of every node in a hierarchy dump, in pre-order.
|
||||
type Tree struct {
|
||||
Elements []*Element
|
||||
Elements []*Element `json:"elements"`
|
||||
}
|
||||
|
||||
// Parse parses a uiautomator-style XML dump.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// Assets returns the embedded SPA bundle rooted at the dist directory.
|
||||
// In Stage 2 this contains a stub index.html. Stage 4 wires the real
|
||||
// bundle in via Makefile (copy web/dist -> internal/inspect/dist).
|
||||
func Assets() fs.FS {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
panic("inspect: dist embed missing: " + err.Error())
|
||||
}
|
||||
return sub
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// DevTarget is the hardcoded Vite dev server address. The --dev flag
|
||||
// enables a reverse proxy to this address for non-API requests.
|
||||
const DevTarget = "http://127.0.0.1:5173"
|
||||
|
||||
func newDevProxy(target string) (http.Handler, error) {
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(parsed)
|
||||
return proxy, nil
|
||||
}
|
||||
Vendored
Whitespace-only changes.
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>uatu inspect</title>
|
||||
<script type="module" crossorigin src="/assets/index-DFpYQDqN.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BSJUf7yI.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,429 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/uatu/internal/trace"
|
||||
)
|
||||
|
||||
// Maximum size of a single trace.jsonl line. Hierarchies and snapshots
|
||||
// can be large; 16 MiB is enough headroom for realistic traces.
|
||||
const maxScanTokenSize = 16 * 1024 * 1024
|
||||
|
||||
// RunSummary is the lightweight per-run record returned by Scan and the
|
||||
// /api/runs handler. Keep this in lockstep with the JSON shape consumed
|
||||
// by the SPA's run list view.
|
||||
type RunSummary struct {
|
||||
ID string `json:"id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
SpecPath string `json:"spec_path"`
|
||||
Seed int64 `json:"seed"`
|
||||
Platform string `json:"platform"`
|
||||
BundleID string `json:"bundle_id"`
|
||||
DurationMillis int64 `json:"duration_millis"`
|
||||
StepCount int `json:"step_count"`
|
||||
ViolationCount int `json:"violation_count"`
|
||||
InProgress bool `json:"in_progress"`
|
||||
}
|
||||
|
||||
// StepSummary is the slim per-step record used to render the step list
|
||||
// and timeline. Heavy fields (hierarchy, snapshots, residuals) are sent
|
||||
// only via the per-step endpoint.
|
||||
type StepSummary struct {
|
||||
Index int `json:"index"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Screen string `json:"screen,omitempty"`
|
||||
ActionKind string `json:"action_kind,omitempty"`
|
||||
ActionLabel string `json:"action_label,omitempty"`
|
||||
HasViolations bool `json:"has_violations"`
|
||||
HasExceptions bool `json:"has_exceptions"`
|
||||
}
|
||||
|
||||
// RunDetail is the full /api/runs/{id} payload: meta + slim step list.
|
||||
type RunDetail struct {
|
||||
RunSummary
|
||||
Meta trace.Meta `json:"meta"`
|
||||
Steps []StepSummary `json:"steps"`
|
||||
}
|
||||
|
||||
// Run is a cached parse of one run directory. Step lookups re-read the
|
||||
// JSONL file from disk; only the line offsets are kept in memory.
|
||||
type Run struct {
|
||||
ID string
|
||||
Directory string
|
||||
Meta trace.Meta
|
||||
Summary RunSummary
|
||||
Steps []StepSummary
|
||||
tracePath string
|
||||
traceMtime time.Time
|
||||
offsets []int64
|
||||
}
|
||||
|
||||
// IsRunDirectory reports whether dir looks like a single run (has meta.json).
|
||||
func IsRunDirectory(directory string) bool {
|
||||
_, err := os.Stat(filepath.Join(directory, "meta.json"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Scan walks runsDirectory and returns one RunSummary per child directory
|
||||
// that contains meta.json. Results are sorted by StartedAt descending.
|
||||
func Scan(runsDirectory string) ([]RunSummary, error) {
|
||||
entries, err := os.ReadDir(runsDirectory)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return []RunSummary{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read runs dir: %w", err)
|
||||
}
|
||||
summaries := make([]RunSummary, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
runDirectory := filepath.Join(runsDirectory, entry.Name())
|
||||
summary, err := summarize(runDirectory, entry.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
sort.Slice(summaries, func(i, j int) bool {
|
||||
return summaries[i].StartedAt.After(summaries[j].StartedAt)
|
||||
})
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func summarize(runDirectory, id string) (RunSummary, error) {
|
||||
meta, err := readMeta(runDirectory)
|
||||
if err != nil {
|
||||
return RunSummary{}, err
|
||||
}
|
||||
stepCount, violationCount, err := tallyTrace(filepath.Join(runDirectory, "trace.jsonl"))
|
||||
if err != nil {
|
||||
return RunSummary{}, err
|
||||
}
|
||||
return buildSummary(id, meta, stepCount, violationCount), nil
|
||||
}
|
||||
|
||||
func buildSummary(id string, meta trace.Meta, stepCount, violationCount int) RunSummary {
|
||||
summary := RunSummary{
|
||||
ID: id,
|
||||
StartedAt: meta.StartedAt,
|
||||
EndedAt: meta.EndedAt,
|
||||
SpecPath: meta.SpecPath,
|
||||
Seed: meta.Seed,
|
||||
Platform: meta.Platform,
|
||||
BundleID: meta.BundleID,
|
||||
StepCount: stepCount,
|
||||
ViolationCount: violationCount,
|
||||
InProgress: meta.EndedAt == nil,
|
||||
}
|
||||
if meta.EndedAt != nil {
|
||||
summary.DurationMillis = meta.EndedAt.Sub(meta.StartedAt).Milliseconds()
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func readMeta(runDirectory string) (trace.Meta, error) {
|
||||
body, err := os.ReadFile(filepath.Join(runDirectory, "meta.json"))
|
||||
if err != nil {
|
||||
return trace.Meta{}, fmt.Errorf("read meta: %w", err)
|
||||
}
|
||||
var meta trace.Meta
|
||||
if err := json.Unmarshal(body, &meta); err != nil {
|
||||
return trace.Meta{}, fmt.Errorf("decode meta: %w", err)
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func tallyTrace(tracePath string) (steps, violations int, err error) {
|
||||
file, err := os.Open(tracePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, 0, nil
|
||||
}
|
||||
return 0, 0, fmt.Errorf("open trace: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 64*1024), maxScanTokenSize)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var partial struct {
|
||||
Violations []string `json:"violations,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &partial); err != nil {
|
||||
return 0, 0, fmt.Errorf("decode step: %w", err)
|
||||
}
|
||||
steps++
|
||||
violations += len(partial.Violations)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return 0, 0, fmt.Errorf("scan trace: %w", err)
|
||||
}
|
||||
return steps, violations, nil
|
||||
}
|
||||
|
||||
// Cache holds parsed Run records keyed by id. Open returns a fresh parse
|
||||
// when the underlying trace.jsonl mtime changes.
|
||||
type Cache struct {
|
||||
root string
|
||||
mutex sync.Mutex
|
||||
runs map[string]*Run
|
||||
}
|
||||
|
||||
func NewCache(runsDirectory string) *Cache {
|
||||
return &Cache{root: runsDirectory, runs: map[string]*Run{}}
|
||||
}
|
||||
|
||||
func (c *Cache) Root() string { return c.root }
|
||||
|
||||
// Open parses (or returns a cached parse of) the run named id.
|
||||
func (c *Cache) Open(id string) (*Run, error) {
|
||||
if !validRunID(id) {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
runDirectory := filepath.Join(c.root, id)
|
||||
tracePath := filepath.Join(runDirectory, "trace.jsonl")
|
||||
traceInfo, traceErr := os.Stat(tracePath)
|
||||
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
if cached, ok := c.runs[id]; ok {
|
||||
if traceErr == nil && cached.traceMtime.Equal(traceInfo.ModTime()) {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
run, err := parseRun(runDirectory, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.runs[id] = run
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func parseRun(runDirectory, id string) (*Run, error) {
|
||||
meta, err := readMeta(runDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracePath := filepath.Join(runDirectory, "trace.jsonl")
|
||||
steps, offsets, violationCount, traceMtime, err := scanSteps(tracePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary := buildSummary(id, meta, len(steps), violationCount)
|
||||
return &Run{
|
||||
ID: id,
|
||||
Directory: runDirectory,
|
||||
Meta: meta,
|
||||
Summary: summary,
|
||||
Steps: steps,
|
||||
tracePath: tracePath,
|
||||
traceMtime: traceMtime,
|
||||
offsets: offsets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func scanSteps(tracePath string) ([]StepSummary, []int64, int, time.Time, error) {
|
||||
file, err := os.Open(tracePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return []StepSummary{}, nil, 0, time.Time{}, nil
|
||||
}
|
||||
return nil, nil, 0, time.Time{}, fmt.Errorf("open trace: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, nil, 0, time.Time{}, fmt.Errorf("stat trace: %w", err)
|
||||
}
|
||||
reader := bufio.NewReaderSize(file, 64*1024)
|
||||
steps := []StepSummary{}
|
||||
offsets := []int64{}
|
||||
violationCount := 0
|
||||
var offset int64
|
||||
for {
|
||||
lineStart := offset
|
||||
line, err := reader.ReadBytes('\n')
|
||||
offset += int64(len(line))
|
||||
trimmed := line
|
||||
if len(trimmed) > 0 && trimmed[len(trimmed)-1] == '\n' {
|
||||
trimmed = trimmed[:len(trimmed)-1]
|
||||
}
|
||||
if len(trimmed) > 0 {
|
||||
summary, partial, decodeErr := decodeStepSummary(trimmed)
|
||||
if decodeErr != nil {
|
||||
return nil, nil, 0, time.Time{}, decodeErr
|
||||
}
|
||||
steps = append(steps, summary)
|
||||
offsets = append(offsets, lineStart)
|
||||
violationCount += partial
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return steps, offsets, violationCount, info.ModTime(), nil
|
||||
}
|
||||
|
||||
func decodeStepSummary(line []byte) (StepSummary, int, error) {
|
||||
var partial struct {
|
||||
Index int `json:"step"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Screen string `json:"screen,omitempty"`
|
||||
Action *struct {
|
||||
Kind string `json:"kind"`
|
||||
X int `json:"x,omitempty"`
|
||||
Y int `json:"y,omitempty"`
|
||||
FromX int `json:"from_x,omitempty"`
|
||||
FromY int `json:"from_y,omitempty"`
|
||||
ToX int `json:"to_x,omitempty"`
|
||||
ToY int `json:"to_y,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Selector string `json:"selector,omitempty"`
|
||||
DurationMillis int `json:"duration_millis,omitempty"`
|
||||
} `json:"action,omitempty"`
|
||||
Exceptions []json.RawMessage `json:"exceptions,omitempty"`
|
||||
Violations []string `json:"violations,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &partial); err != nil {
|
||||
return StepSummary{}, 0, fmt.Errorf("decode step: %w", err)
|
||||
}
|
||||
summary := StepSummary{
|
||||
Index: partial.Index,
|
||||
Timestamp: partial.Timestamp,
|
||||
Screen: partial.Screen,
|
||||
HasViolations: len(partial.Violations) > 0,
|
||||
HasExceptions: len(partial.Exceptions) > 0,
|
||||
}
|
||||
if partial.Action != nil {
|
||||
summary.ActionKind = partial.Action.Kind
|
||||
switch partial.Action.Kind {
|
||||
case "Tap":
|
||||
if partial.Action.Selector != "" {
|
||||
summary.ActionLabel = partial.Action.Selector
|
||||
} else if partial.Action.Text != "" {
|
||||
summary.ActionLabel = partial.Action.Text
|
||||
} else if partial.Action.X != 0 || partial.Action.Y != 0 {
|
||||
summary.ActionLabel = fmt.Sprintf("(%d,%d)", partial.Action.X, partial.Action.Y)
|
||||
}
|
||||
case "InputText":
|
||||
summary.ActionLabel = fmt.Sprintf("%q", partial.Action.Text)
|
||||
case "Swipe":
|
||||
summary.ActionLabel = swipeDirectionLabel(
|
||||
partial.Action.FromX, partial.Action.FromY,
|
||||
partial.Action.ToX, partial.Action.ToY,
|
||||
)
|
||||
case "PressKey":
|
||||
summary.ActionLabel = partial.Action.Key
|
||||
case "Wait":
|
||||
if partial.Action.DurationMillis > 0 {
|
||||
summary.ActionLabel = fmt.Sprintf("%dms", partial.Action.DurationMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
return summary, len(partial.Violations), nil
|
||||
}
|
||||
|
||||
func swipeDirectionLabel(fromX, fromY, toX, toY int) string {
|
||||
dx := toX - fromX
|
||||
dy := toY - fromY
|
||||
absX := dx
|
||||
if absX < 0 {
|
||||
absX = -absX
|
||||
}
|
||||
absY := dy
|
||||
if absY < 0 {
|
||||
absY = -absY
|
||||
}
|
||||
if absY >= absX {
|
||||
if dy < 0 {
|
||||
return "up"
|
||||
}
|
||||
return "down"
|
||||
}
|
||||
if dx < 0 {
|
||||
return "left"
|
||||
}
|
||||
return "right"
|
||||
}
|
||||
|
||||
// Step decodes the full Step record at index n (1-based, matching trace.Step.Index).
|
||||
func (c *Cache) Step(run *Run, index int) (trace.Step, error) {
|
||||
position := -1
|
||||
for i, summary := range run.Steps {
|
||||
if summary.Index == index {
|
||||
position = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if position == -1 {
|
||||
return trace.Step{}, fs.ErrNotExist
|
||||
}
|
||||
file, err := os.Open(run.tracePath)
|
||||
if err != nil {
|
||||
return trace.Step{}, fmt.Errorf("open trace: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := file.Seek(run.offsets[position], 0); err != nil {
|
||||
return trace.Step{}, fmt.Errorf("seek trace: %w", err)
|
||||
}
|
||||
reader := bufio.NewReaderSize(file, 64*1024)
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if err != nil && len(line) == 0 {
|
||||
return trace.Step{}, fmt.Errorf("read step line: %w", err)
|
||||
}
|
||||
if len(line) > 0 && line[len(line)-1] == '\n' {
|
||||
line = line[:len(line)-1]
|
||||
}
|
||||
var step trace.Step
|
||||
if err := json.Unmarshal(line, &step); err != nil {
|
||||
return trace.Step{}, fmt.Errorf("decode step: %w", err)
|
||||
}
|
||||
return step, nil
|
||||
}
|
||||
|
||||
// Detail returns the /api/runs/{id} payload.
|
||||
func (c *Cache) Detail(id string) (RunDetail, error) {
|
||||
run, err := c.Open(id)
|
||||
if err != nil {
|
||||
return RunDetail{}, err
|
||||
}
|
||||
return RunDetail{
|
||||
RunSummary: run.Summary,
|
||||
Meta: run.Meta,
|
||||
Steps: run.Steps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validRunID(id string) bool {
|
||||
if id == "" || id == "." || id == ".." {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '-' || r == '_' || r == '.':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/uatu/internal/trace"
|
||||
)
|
||||
|
||||
func TestScan_OrdersByStartedAtDescendingAndCountsViolations(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
older := time.Date(2026, 4, 17, 10, 0, 0, 0, time.UTC)
|
||||
newer := time.Date(2026, 4, 17, 12, 0, 0, 0, time.UTC)
|
||||
writeRun(t, root, "older", trace.Meta{StartedAt: older, EndedAt: timePointer(older.Add(2 * time.Second))}, []trace.Step{
|
||||
{Index: 1, Timestamp: older, Violations: []string{"propA"}},
|
||||
{Index: 2, Timestamp: older.Add(time.Second)},
|
||||
})
|
||||
writeRun(t, root, "newer", trace.Meta{StartedAt: newer, EndedAt: timePointer(newer.Add(time.Second))}, []trace.Step{
|
||||
{Index: 1, Timestamp: newer, Violations: []string{"propA", "propB"}},
|
||||
})
|
||||
|
||||
summaries, err := Scan(root)
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if len(summaries) != 2 {
|
||||
t.Fatalf("len(summaries) = %d, want 2", len(summaries))
|
||||
}
|
||||
if summaries[0].ID != "newer" {
|
||||
t.Errorf("first id = %q, want newer", summaries[0].ID)
|
||||
}
|
||||
if summaries[0].ViolationCount != 2 {
|
||||
t.Errorf("newer violations = %d, want 2", summaries[0].ViolationCount)
|
||||
}
|
||||
if summaries[1].ViolationCount != 1 {
|
||||
t.Errorf("older violations = %d, want 1", summaries[1].ViolationCount)
|
||||
}
|
||||
if summaries[1].StepCount != 2 {
|
||||
t.Errorf("older steps = %d, want 2", summaries[1].StepCount)
|
||||
}
|
||||
if summaries[0].DurationMillis != 1000 {
|
||||
t.Errorf("newer duration = %d, want 1000", summaries[0].DurationMillis)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScan_MissingDirectoryReturnsEmpty(t *testing.T) {
|
||||
summaries, err := Scan(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if len(summaries) != 0 {
|
||||
t.Errorf("len(summaries) = %d, want 0", len(summaries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScan_MissingEndedAtSurfacesInProgress(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeRun(t, root, "live", trace.Meta{StartedAt: time.Now().UTC()}, []trace.Step{
|
||||
{Index: 1, Timestamp: time.Now().UTC()},
|
||||
})
|
||||
summaries, err := Scan(root)
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if len(summaries) != 1 || !summaries[0].InProgress {
|
||||
t.Errorf("expected in_progress=true, got %+v", summaries)
|
||||
}
|
||||
if summaries[0].EndedAt != nil {
|
||||
t.Errorf("ended_at should be nil, got %v", summaries[0].EndedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScan_EmptyTraceTreatedAsZeroSteps(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
directory := filepath.Join(root, "empty")
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := trace.Meta{StartedAt: time.Now().UTC()}
|
||||
body, _ := json.Marshal(meta)
|
||||
if err := os.WriteFile(filepath.Join(directory, "meta.json"), body, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summaries, err := Scan(root)
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("len = %d", len(summaries))
|
||||
}
|
||||
if summaries[0].StepCount != 0 {
|
||||
t.Errorf("step_count = %d, want 0", summaries[0].StepCount)
|
||||
}
|
||||
if !summaries[0].InProgress {
|
||||
t.Error("expected in_progress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStep_LazyDecodeReturnsFullStep(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
startedAt := time.Now().UTC()
|
||||
steps := []trace.Step{
|
||||
{Index: 1, Timestamp: startedAt, Screen: "A"},
|
||||
{Index: 2, Timestamp: startedAt.Add(time.Second), Screen: "B", Action: &trace.Action{Kind: "tap"}},
|
||||
{Index: 3, Timestamp: startedAt.Add(2 * time.Second), Screen: "C", Violations: []string{"prop1"}},
|
||||
}
|
||||
writeRun(t, root, "r1", trace.Meta{StartedAt: startedAt, EndedAt: timePointer(startedAt.Add(3 * time.Second))}, steps)
|
||||
|
||||
cache := NewCache(root)
|
||||
run, err := cache.Open("r1")
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if len(run.Steps) != 3 {
|
||||
t.Fatalf("steps = %d, want 3", len(run.Steps))
|
||||
}
|
||||
if !run.Steps[2].HasViolations {
|
||||
t.Error("step 3 should HasViolations")
|
||||
}
|
||||
if run.Steps[1].ActionKind != "tap" {
|
||||
t.Errorf("step 2 action = %q, want tap", run.Steps[1].ActionKind)
|
||||
}
|
||||
|
||||
for _, target := range []int{1, 2, 3} {
|
||||
step, err := cache.Step(run, target)
|
||||
if err != nil {
|
||||
t.Fatalf("Step(%d): %v", target, err)
|
||||
}
|
||||
if step.Index != target {
|
||||
t.Errorf("Step(%d).Index = %d", target, step.Index)
|
||||
}
|
||||
}
|
||||
if _, err := cache.Step(run, 99); err == nil {
|
||||
t.Error("expected error for out-of-range step")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeStepSummary_ActionLabelPerKind(t *testing.T) {
|
||||
cases := []struct {
|
||||
line string
|
||||
wantKind string
|
||||
wantLabel string
|
||||
}{
|
||||
{`{"step":1,"timestamp":"2026-04-20T10:00:00Z","action":{"kind":"Tap","selector":"id:save"}}`, "Tap", "id:save"},
|
||||
{`{"step":2,"timestamp":"2026-04-20T10:00:01Z","action":{"kind":"Tap","x":140,"y":220}}`, "Tap", "(140,220)"},
|
||||
{`{"step":3,"timestamp":"2026-04-20T10:00:02Z","action":{"kind":"InputText","text":"alice"}}`, "InputText", `"alice"`},
|
||||
{`{"step":4,"timestamp":"2026-04-20T10:00:03Z","action":{"kind":"Swipe","from_x":10,"from_y":500,"to_x":10,"to_y":50}}`, "Swipe", "up"},
|
||||
{`{"step":5,"timestamp":"2026-04-20T10:00:04Z","action":{"kind":"Swipe","from_x":100,"from_y":50,"to_x":600,"to_y":50}}`, "Swipe", "right"},
|
||||
{`{"step":6,"timestamp":"2026-04-20T10:00:05Z","action":{"kind":"PressKey","key":"back"}}`, "PressKey", "back"},
|
||||
{`{"step":7,"timestamp":"2026-04-20T10:00:06Z","action":{"kind":"Wait","duration_millis":500}}`, "Wait", "500ms"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
summary, _, err := decodeStepSummary([]byte(tc.line))
|
||||
if err != nil {
|
||||
t.Fatalf("decode %s: %v", tc.line, err)
|
||||
}
|
||||
if summary.ActionKind != tc.wantKind {
|
||||
t.Errorf("kind = %q, want %q (line=%s)", summary.ActionKind, tc.wantKind, tc.line)
|
||||
}
|
||||
if summary.ActionLabel != tc.wantLabel {
|
||||
t.Errorf("label = %q, want %q (line=%s)", summary.ActionLabel, tc.wantLabel, tc.line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheOpen_RejectsTraversalIDs(t *testing.T) {
|
||||
cache := NewCache(t.TempDir())
|
||||
for _, id := range []string{"", ".", "..", "../etc", "a/b", "a\\b"} {
|
||||
if _, err := cache.Open(id); err == nil {
|
||||
t.Errorf("Open(%q) should fail", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRunDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeRun(t, root, "x", trace.Meta{StartedAt: time.Now().UTC()}, nil)
|
||||
if !IsRunDirectory(filepath.Join(root, "x")) {
|
||||
t.Error("expected true for run dir")
|
||||
}
|
||||
if IsRunDirectory(root) {
|
||||
t.Error("expected false for parent dir")
|
||||
}
|
||||
}
|
||||
|
||||
func writeRun(t *testing.T, root, id string, meta trace.Meta, steps []trace.Step) {
|
||||
t.Helper()
|
||||
directory := filepath.Join(root, id)
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metaBody, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "meta.json"), metaBody, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if steps == nil {
|
||||
return
|
||||
}
|
||||
file, err := os.Create(filepath.Join(directory, "trace.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
encoder := json.NewEncoder(file)
|
||||
for _, step := range steps {
|
||||
if err := encoder.Encode(step); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func timePointer(t time.Time) *time.Time { return &t }
|
||||
@@ -0,0 +1,281 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ServerOptions configures a new Server.
|
||||
type ServerOptions struct {
|
||||
RunsDirectory string
|
||||
DevTarget string
|
||||
}
|
||||
|
||||
// Server holds the HTTP handlers for `uatu inspect`.
|
||||
type Server struct {
|
||||
options ServerOptions
|
||||
cache *Cache
|
||||
watcher *Watcher
|
||||
assets http.Handler
|
||||
dev http.Handler
|
||||
}
|
||||
|
||||
// NewServer constructs a Server. When options.DevTarget is non-empty the
|
||||
// server reverse-proxies non-API GETs to it; otherwise it serves embedded
|
||||
// assets from the dist FS.
|
||||
func NewServer(options ServerOptions) (*Server, error) {
|
||||
server := &Server{
|
||||
options: options,
|
||||
cache: NewCache(options.RunsDirectory),
|
||||
watcher: NewWatcher(options.RunsDirectory),
|
||||
assets: spaHandler(Assets()),
|
||||
}
|
||||
if options.DevTarget != "" {
|
||||
proxy, err := newDevProxy(options.DevTarget)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dev proxy: %w", err)
|
||||
}
|
||||
server.dev = proxy
|
||||
}
|
||||
return server, nil
|
||||
}
|
||||
|
||||
// Watcher exposes the runs-directory watcher so callers can run it under their
|
||||
// own context.
|
||||
func (s *Server) Watcher() *Watcher { return s.watcher }
|
||||
|
||||
// Handler returns the root HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/runs", s.handleRunsList)
|
||||
mux.HandleFunc("/api/runs/", s.handleRunsTree)
|
||||
mux.HandleFunc("/api/events", s.handleEvents)
|
||||
mux.HandleFunc("/", s.handleAssets)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) handleRunsList(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet {
|
||||
http.Error(responseWriter, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
summaries, err := Scan(s.options.RunsDirectory)
|
||||
if err != nil {
|
||||
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(responseWriter, http.StatusOK, summaries)
|
||||
}
|
||||
|
||||
var stepPathPattern = regexp.MustCompile(`^([a-zA-Z0-9._-]+)/steps/([^/]+)$`)
|
||||
var screenshotPathPattern = regexp.MustCompile(`^([a-zA-Z0-9._-]+)/screenshots/([a-zA-Z0-9._-]+\.png)$`)
|
||||
var runDetailPathPattern = regexp.MustCompile(`^([a-zA-Z0-9._-]+)/?$`)
|
||||
|
||||
func (s *Server) handleRunsTree(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet {
|
||||
http.Error(responseWriter, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(request.URL.Path, "/api/runs/")
|
||||
if rest == "" {
|
||||
s.handleRunsList(responseWriter, request)
|
||||
return
|
||||
}
|
||||
if match := stepPathPattern.FindStringSubmatch(rest); match != nil {
|
||||
s.serveStep(responseWriter, match[1], match[2])
|
||||
return
|
||||
}
|
||||
if match := screenshotPathPattern.FindStringSubmatch(rest); match != nil {
|
||||
s.serveScreenshot(responseWriter, request, match[1], match[2])
|
||||
return
|
||||
}
|
||||
if match := runDetailPathPattern.FindStringSubmatch(rest); match != nil {
|
||||
s.serveDetail(responseWriter, match[1])
|
||||
return
|
||||
}
|
||||
http.NotFound(responseWriter, request)
|
||||
}
|
||||
|
||||
func (s *Server) serveDetail(responseWriter http.ResponseWriter, id string) {
|
||||
detail, err := s.cache.Detail(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
http.Error(responseWriter, "run not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(responseWriter, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (s *Server) serveStep(responseWriter http.ResponseWriter, id, indexText string) {
|
||||
index, err := strconv.Atoi(indexText)
|
||||
if err != nil {
|
||||
http.Error(responseWriter, "step index must be numeric", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
run, err := s.cache.Open(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
http.Error(responseWriter, "run not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
step, err := s.cache.Step(run, index)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
http.Error(responseWriter, "step not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(responseWriter, http.StatusOK, step)
|
||||
}
|
||||
|
||||
func (s *Server) serveScreenshot(responseWriter http.ResponseWriter, request *http.Request, id, name string) {
|
||||
if !validRunID(id) {
|
||||
http.Error(responseWriter, "run not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
full := filepath.Join(s.options.RunsDirectory, id, "screenshots", name)
|
||||
http.ServeFile(responseWriter, request, full)
|
||||
}
|
||||
|
||||
func (s *Server) handleEvents(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
flusher, ok := responseWriter.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(responseWriter, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
responseWriter.Header().Set("Content-Type", "text/event-stream")
|
||||
responseWriter.Header().Set("Cache-Control", "no-cache")
|
||||
responseWriter.Header().Set("Connection", "keep-alive")
|
||||
responseWriter.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
subscription := s.watcher.Subscribe()
|
||||
defer s.watcher.Unsubscribe(subscription)
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-request.Context().Done():
|
||||
return
|
||||
case _, ok := <-subscription:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Fprint(responseWriter, "event: runs.changed\ndata: {\"type\":\"runs.changed\"}\n\n")
|
||||
flusher.Flush()
|
||||
case <-heartbeat.C:
|
||||
fmt.Fprint(responseWriter, ": ping\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAssets(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
if strings.HasPrefix(request.URL.Path, "/api/") {
|
||||
http.NotFound(responseWriter, request)
|
||||
return
|
||||
}
|
||||
if s.dev != nil {
|
||||
s.dev.ServeHTTP(responseWriter, request)
|
||||
return
|
||||
}
|
||||
s.assets.ServeHTTP(responseWriter, request)
|
||||
}
|
||||
|
||||
// spaHandler serves files from assets, falling back to index.html for
|
||||
// unknown paths so the SPA router can take over.
|
||||
func spaHandler(assets fs.FS) http.Handler {
|
||||
fileServer := http.FileServer(http.FS(assets))
|
||||
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
clean := strings.TrimPrefix(path.Clean(request.URL.Path), "/")
|
||||
if clean == "" {
|
||||
serveIndex(responseWriter, request, assets)
|
||||
return
|
||||
}
|
||||
file, err := assets.Open(clean)
|
||||
if err != nil {
|
||||
serveIndex(responseWriter, request, assets)
|
||||
return
|
||||
}
|
||||
file.Close()
|
||||
fileServer.ServeHTTP(responseWriter, request)
|
||||
})
|
||||
}
|
||||
|
||||
func serveIndex(responseWriter http.ResponseWriter, request *http.Request, assets fs.FS) {
|
||||
file, err := assets.Open("index.html")
|
||||
if err != nil {
|
||||
http.Error(responseWriter, "index.html missing from embedded assets", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
body, err := readAll(file)
|
||||
if err != nil {
|
||||
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
responseWriter.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = responseWriter.Write(body)
|
||||
}
|
||||
|
||||
func readAll(file fs.File) ([]byte, error) {
|
||||
const initialCapacity = 4 * 1024
|
||||
buffer := make([]byte, 0, initialCapacity)
|
||||
chunk := make([]byte, 4*1024)
|
||||
for {
|
||||
read, err := file.Read(chunk)
|
||||
if read > 0 {
|
||||
buffer = append(buffer, chunk[:read]...)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrInvalid) {
|
||||
return nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return buffer, nil
|
||||
}
|
||||
|
||||
func writeJSON(responseWriter http.ResponseWriter, status int, payload any) {
|
||||
responseWriter.Header().Set("Content-Type", "application/json")
|
||||
responseWriter.WriteHeader(status)
|
||||
encoder := json.NewEncoder(responseWriter)
|
||||
_ = encoder.Encode(payload)
|
||||
}
|
||||
|
||||
// ResolveRunsDirectory takes the optional positional argument and returns
|
||||
// (runsDirectory, deepLinkID, error). When argument is "" it falls back
|
||||
// to ./runs. When argument is a single run directory (has meta.json), the
|
||||
// parent becomes runsDirectory and the basename becomes the deep-link id.
|
||||
func ResolveRunsDirectory(argument string) (string, string, error) {
|
||||
if argument == "" {
|
||||
return "./runs", "", nil
|
||||
}
|
||||
if IsRunDirectory(argument) {
|
||||
cleaned := filepath.Clean(argument)
|
||||
parent := filepath.Dir(cleaned)
|
||||
base := filepath.Base(cleaned)
|
||||
return parent, base, nil
|
||||
}
|
||||
return argument, "", nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/uatu/internal/trace"
|
||||
)
|
||||
|
||||
func newFixtureServer(t *testing.T) (*Server, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
startedAt := time.Date(2026, 4, 17, 18, 0, 0, 0, time.UTC)
|
||||
endedAt := startedAt.Add(5 * time.Second)
|
||||
writeRun(t, root, "run-a", trace.Meta{
|
||||
StartedAt: startedAt,
|
||||
EndedAt: &endedAt,
|
||||
SpecPath: "spec.ts",
|
||||
Seed: 42,
|
||||
Platform: "android",
|
||||
BundleID: "com.example",
|
||||
}, []trace.Step{
|
||||
{Index: 1, Timestamp: startedAt, Screen: "Home"},
|
||||
{Index: 2, Timestamp: startedAt.Add(time.Second), Screen: "Home", Action: &trace.Action{Kind: "tap"}},
|
||||
{Index: 3, Timestamp: startedAt.Add(2 * time.Second), Screen: "Cart", Violations: []string{"propA"}},
|
||||
})
|
||||
writeRun(t, root, "run-b", trace.Meta{
|
||||
StartedAt: startedAt.Add(time.Hour),
|
||||
}, []trace.Step{
|
||||
{Index: 1, Timestamp: startedAt.Add(time.Hour)},
|
||||
})
|
||||
|
||||
screenshotsDirectory := filepath.Join(root, "run-a", "screenshots")
|
||||
if err := os.MkdirAll(screenshotsDirectory, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pngBody := []byte{0x89, 'P', 'N', 'G', 0, 1, 2, 3}
|
||||
if err := os.WriteFile(filepath.Join(screenshotsDirectory, "step-00001.png"), pngBody, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server, err := NewServer(ServerOptions{RunsDirectory: root})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return server, root
|
||||
}
|
||||
|
||||
func TestHandleRunsList_OrdersAndCountsViolations(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/runs", nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", recorder.Code)
|
||||
}
|
||||
var summaries []RunSummary
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &summaries); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(summaries) != 2 {
|
||||
t.Fatalf("len = %d", len(summaries))
|
||||
}
|
||||
if summaries[0].ID != "run-b" {
|
||||
t.Errorf("first id = %q, want run-b (most recent)", summaries[0].ID)
|
||||
}
|
||||
if summaries[1].ViolationCount != 1 {
|
||||
t.Errorf("run-a violation count = %d, want 1", summaries[1].ViolationCount)
|
||||
}
|
||||
if !summaries[0].InProgress {
|
||||
t.Error("run-b should be in_progress (no ended_at)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRunDetail_DecodesMetaAndStepSummaries(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/runs/run-a", nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var detail RunDetail
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &detail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.Meta.BundleID != "com.example" {
|
||||
t.Errorf("bundle = %q", detail.Meta.BundleID)
|
||||
}
|
||||
if len(detail.Steps) != 3 {
|
||||
t.Fatalf("steps len = %d", len(detail.Steps))
|
||||
}
|
||||
if detail.Steps[1].ActionKind != "tap" {
|
||||
t.Errorf("step 2 action = %q", detail.Steps[1].ActionKind)
|
||||
}
|
||||
if !detail.Steps[2].HasViolations {
|
||||
t.Error("step 3 should HasViolations")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStep_ReturnsFullStep(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/runs/run-a/steps/3", nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", recorder.Code)
|
||||
}
|
||||
var step trace.Step
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &step); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if step.Index != 3 || step.Screen != "Cart" {
|
||||
t.Errorf("step = %+v", step)
|
||||
}
|
||||
if len(step.Violations) != 1 {
|
||||
t.Errorf("violations = %v", step.Violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStep_ErrorCases(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
}{
|
||||
{"unknown run", "/api/runs/nope", http.StatusNotFound},
|
||||
{"non-numeric step", "/api/runs/run-a/steps/abc", http.StatusBadRequest},
|
||||
{"out-of-range step", "/api/runs/run-a/steps/999", http.StatusNotFound},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, testCase.path, nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != testCase.wantStatus {
|
||||
t.Errorf("status = %d, want %d, body=%s", recorder.Code, testCase.wantStatus, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenshot_ServesWhitelistedPNG(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/runs/run-a/screenshots/step-00001.png", nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", recorder.Code)
|
||||
}
|
||||
if recorder.Body.Len() == 0 {
|
||||
t.Error("empty body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenshot_RejectsTraversalAndBadNames(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
cases := []string{
|
||||
"/api/runs/run-a/screenshots/../meta.json",
|
||||
"/api/runs/run-a/screenshots/..%2Fmeta.json",
|
||||
"/api/runs/run-a/screenshots/step-00001.txt",
|
||||
"/api/runs/run-a/screenshots/.png",
|
||||
}
|
||||
for _, path := range cases {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code == http.StatusOK {
|
||||
t.Errorf("status = %d (should not be 200) body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSE_ReturnsWhenContextCanceled(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
context, cancel := context.WithCancel(context.Background())
|
||||
request, err := http.NewRequestWithContext(context, http.MethodGet, httpServer.URL+"/api/events", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.StatusCode)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
close(done)
|
||||
}()
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("SSE handler did not exit after context cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevProxy_ForwardsRequestBodyUnchanged(t *testing.T) {
|
||||
received := make(chan string, 1)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Errorf("upstream read: %v", err)
|
||||
}
|
||||
received <- string(body)
|
||||
responseWriter.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
root := t.TempDir()
|
||||
server, err := NewServer(ServerOptions{RunsDirectory: root, DevTarget: upstream.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
body := strings.NewReader("hello world")
|
||||
request, err := http.NewRequest(http.MethodPost, httpServer.URL+"/anything", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d", response.StatusCode)
|
||||
}
|
||||
select {
|
||||
case got := <-received:
|
||||
if got != "hello world" {
|
||||
t.Errorf("upstream body = %q, want %q", got, "hello world")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("upstream never received the request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssets_FallbackToIndexHTML(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/runs/some-id-that-only-the-spa-knows", nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", recorder.Code)
|
||||
}
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(body, "<div id=\"app\"></div>") && !strings.Contains(body, "<div id=\"root\"></div>") {
|
||||
t.Errorf("expected SPA shell with #app or #root, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssets_API404DoesNotFallThrough(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/wat", nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRunsDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeRun(t, root, "r1", trace.Meta{StartedAt: time.Now().UTC()}, nil)
|
||||
|
||||
runsDirectory, deepLink, err := ResolveRunsDirectory("")
|
||||
if err != nil || runsDirectory != "./runs" || deepLink != "" {
|
||||
t.Errorf("default = (%q, %q, %v)", runsDirectory, deepLink, err)
|
||||
}
|
||||
runsDirectory, deepLink, err = ResolveRunsDirectory(root)
|
||||
if err != nil || runsDirectory != root || deepLink != "" {
|
||||
t.Errorf("multi-run dir = (%q, %q, %v)", runsDirectory, deepLink, err)
|
||||
}
|
||||
singleRun := filepath.Join(root, "r1")
|
||||
runsDirectory, deepLink, err = ResolveRunsDirectory(singleRun)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Clean(runsDirectory) != filepath.Clean(root) || deepLink != "r1" {
|
||||
t.Errorf("single-run dir = (%q, %q)", runsDirectory, deepLink)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevProxy_ParsesTarget(t *testing.T) {
|
||||
if _, err := newDevProxy(":://bad-url"); err == nil {
|
||||
t.Error("expected parse error for invalid URL")
|
||||
}
|
||||
parsed, err := url.Parse(DevTarget)
|
||||
if err != nil || parsed.Host != "127.0.0.1:5173" {
|
||||
t.Errorf("DevTarget parsed wrong: %v %q", err, parsed.Host)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
const watcherDebounce = 200 * time.Millisecond
|
||||
|
||||
// Watcher reports coalesced runs.changed events from the runs directory.
|
||||
// Subscribe returns a channel that receives one event per debounce window.
|
||||
// The watcher tolerates a missing runs directory by polling for it to appear.
|
||||
type Watcher struct {
|
||||
directory string
|
||||
debounce time.Duration
|
||||
mutex sync.Mutex
|
||||
subscribers []chan struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewWatcher(directory string) *Watcher {
|
||||
return &Watcher{directory: directory, debounce: watcherDebounce}
|
||||
}
|
||||
|
||||
func (w *Watcher) Subscribe() <-chan struct{} {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
channel := make(chan struct{}, 4)
|
||||
if w.closed {
|
||||
close(channel)
|
||||
return channel
|
||||
}
|
||||
w.subscribers = append(w.subscribers, channel)
|
||||
return channel
|
||||
}
|
||||
|
||||
// Unsubscribe removes a channel previously returned by Subscribe. The channel
|
||||
// is not closed because broadcast snapshots subscribers without holding the
|
||||
// mutex and a concurrent close would race with its non-blocking send.
|
||||
// Safe to call multiple times; unknown channels are ignored.
|
||||
func (w *Watcher) Unsubscribe(subscription <-chan struct{}) {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
for index, channel := range w.subscribers {
|
||||
if (<-chan struct{})(channel) != subscription {
|
||||
continue
|
||||
}
|
||||
last := len(w.subscribers) - 1
|
||||
w.subscribers[index] = w.subscribers[last]
|
||||
w.subscribers[last] = nil
|
||||
w.subscribers = w.subscribers[:last]
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Run blocks until ctx is canceled, watching directory for create/remove/rename
|
||||
// events and emitting one notification per debounce window to all subscribers.
|
||||
func (w *Watcher) Run(ctx context.Context) error {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
if err := watchOrWaitForDirectory(ctx, watcher, w.directory); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var pending bool
|
||||
timer := time.NewTimer(w.debounce)
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
w.shutdown()
|
||||
return nil
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
w.shutdown()
|
||||
return nil
|
||||
}
|
||||
if event.Op&(fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
|
||||
continue
|
||||
}
|
||||
if !pending {
|
||||
pending = true
|
||||
timer.Reset(w.debounce)
|
||||
}
|
||||
case <-watcher.Errors:
|
||||
// Drop transient errors; SSE is best-effort.
|
||||
case <-timer.C:
|
||||
if pending {
|
||||
pending = false
|
||||
w.broadcast()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) broadcast() {
|
||||
w.mutex.Lock()
|
||||
subscribers := append([]chan struct{}(nil), w.subscribers...)
|
||||
w.mutex.Unlock()
|
||||
for _, channel := range subscribers {
|
||||
select {
|
||||
case channel <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) shutdown() {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
w.closed = true
|
||||
for _, channel := range w.subscribers {
|
||||
close(channel)
|
||||
}
|
||||
w.subscribers = nil
|
||||
}
|
||||
|
||||
func watchOrWaitForDirectory(ctx context.Context, watcher *fsnotify.Watcher, directory string) error {
|
||||
for {
|
||||
err := watcher.Add(directory)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWatcher_UnsubscribeRemovesChannel(t *testing.T) {
|
||||
w := NewWatcher(t.TempDir())
|
||||
first := w.Subscribe()
|
||||
second := w.Subscribe()
|
||||
third := w.Subscribe()
|
||||
|
||||
if count := len(w.subscribers); count != 3 {
|
||||
t.Fatalf("expected 3 subscribers, got %d", count)
|
||||
}
|
||||
|
||||
w.Unsubscribe(second)
|
||||
|
||||
if count := len(w.subscribers); count != 2 {
|
||||
t.Fatalf("expected 2 subscribers after Unsubscribe, got %d", count)
|
||||
}
|
||||
|
||||
// broadcast should still notify remaining subscribers
|
||||
w.broadcast()
|
||||
select {
|
||||
case <-first:
|
||||
default:
|
||||
t.Error("first subscriber did not receive broadcast")
|
||||
}
|
||||
select {
|
||||
case <-third:
|
||||
default:
|
||||
t.Error("third subscriber did not receive broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_UnsubscribeUnknownChannelIsNoop(t *testing.T) {
|
||||
w := NewWatcher(t.TempDir())
|
||||
existing := w.Subscribe()
|
||||
|
||||
stranger := make(chan struct{})
|
||||
w.Unsubscribe(stranger)
|
||||
|
||||
if count := len(w.subscribers); count != 1 {
|
||||
t.Fatalf("expected 1 subscriber after no-op Unsubscribe, got %d", count)
|
||||
}
|
||||
|
||||
w.broadcast()
|
||||
select {
|
||||
case <-existing:
|
||||
default:
|
||||
t.Error("existing subscriber did not receive broadcast")
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,26 @@ func (e *Evaluator) ObserveAt(now time.Time) Verdict {
|
||||
return VerdictHolds
|
||||
}
|
||||
|
||||
// Residual returns a single Formula describing what the evaluator still has
|
||||
// to prove after the most recent ObserveAt. PureFormula{true} means the
|
||||
// property holds for the run so far; PureFormula{false} means it has latched
|
||||
// to violated. When obligations are still pending, they are folded together
|
||||
// with AndFormula in the order they were registered so the JSON AST reflects
|
||||
// the same order the evaluator processes them in.
|
||||
func (e *Evaluator) Residual() Formula {
|
||||
if e.violated {
|
||||
return PureFormula{Value: false}
|
||||
}
|
||||
if len(e.pending) == 0 {
|
||||
return PureFormula{Value: true}
|
||||
}
|
||||
combined := e.pending[0]
|
||||
for _, formula := range e.pending[1:] {
|
||||
combined = AndFormula{Left: combined, Right: formula}
|
||||
}
|
||||
return combined
|
||||
}
|
||||
|
||||
// rootObligation returns the formula to instantiate at each step. An outer
|
||||
// Always is stripped so its inner is re-evaluated every step; any other root
|
||||
// formula is itself re-instantiated each step (matching the v0.1 semantics
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ltl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -12,6 +13,26 @@ type Formula interface {
|
||||
describe() string
|
||||
}
|
||||
|
||||
// PredicateLabel lets a ThunkFormula carry a human-readable name for the
|
||||
// closure it wraps. Verifier wires this in when the spec gives the predicate
|
||||
// a property name; otherwise it stays empty and serializes without a name.
|
||||
type PredicateLabel interface {
|
||||
PredicateName() string
|
||||
}
|
||||
|
||||
// ErrorFormula represents a thunk that threw during evaluation. The verifier
|
||||
// substitutes one of these into the residual when MarshalJSON would otherwise
|
||||
// have to encode an opaque thunk that already errored. It exists so that the
|
||||
// inspect UI can render "predicate threw" inline.
|
||||
type ErrorFormula struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (ErrorFormula) isFormula() {}
|
||||
func (e ErrorFormula) describe() string {
|
||||
return fmt.Sprintf("Error(%q)", e.Message)
|
||||
}
|
||||
|
||||
type AlwaysFormula struct {
|
||||
Inner Formula
|
||||
}
|
||||
@@ -146,3 +167,104 @@ func (n NotFormula) describe() string { return "Not(" + n.Inner.describe() + ")"
|
||||
|
||||
// Describe returns a debug-friendly representation of the formula.
|
||||
func Describe(formula Formula) string { return formula.describe() }
|
||||
|
||||
// withinNode mirrors the optional `within` clause attached to bounded
|
||||
// Eventually nodes in the JSON AST.
|
||||
type withinNode struct {
|
||||
Amount int64 `json:"amount"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
|
||||
func (a AlwaysFormula) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Op string `json:"op"`
|
||||
Arg Formula `json:"arg"`
|
||||
}{"always", a.Inner})
|
||||
}
|
||||
|
||||
func (n NowFormula) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Op string `json:"op"`
|
||||
Arg Formula `json:"arg"`
|
||||
}{"now", n.Inner})
|
||||
}
|
||||
|
||||
func (n NextFormula) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Op string `json:"op"`
|
||||
Arg Formula `json:"arg"`
|
||||
}{"next", n.Inner})
|
||||
}
|
||||
|
||||
func (n NotFormula) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Op string `json:"op"`
|
||||
Arg Formula `json:"arg"`
|
||||
}{"not", n.Inner})
|
||||
}
|
||||
|
||||
func (e EventuallyFormula) MarshalJSON() ([]byte, error) {
|
||||
payload := struct {
|
||||
Op string `json:"op"`
|
||||
Arg Formula `json:"arg"`
|
||||
Within *withinNode `json:"within,omitempty"`
|
||||
}{Op: "eventually", Arg: e.Inner}
|
||||
switch {
|
||||
case e.HasStepBound:
|
||||
payload.Within = &withinNode{Amount: int64(e.StepBound), Unit: "steps"}
|
||||
case e.Duration > 0:
|
||||
payload.Within = &withinNode{Amount: e.Duration.Milliseconds(), Unit: "milliseconds"}
|
||||
case e.HasDeadline:
|
||||
payload.Within = &withinNode{Amount: e.Deadline.UnixMilli(), Unit: "deadline"}
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
func (a AndFormula) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Op string `json:"op"`
|
||||
Left Formula `json:"left"`
|
||||
Right Formula `json:"right"`
|
||||
}{"and", a.Left, a.Right})
|
||||
}
|
||||
|
||||
func (o OrFormula) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Op string `json:"op"`
|
||||
Left Formula `json:"left"`
|
||||
Right Formula `json:"right"`
|
||||
}{"or", o.Left, o.Right})
|
||||
}
|
||||
|
||||
func (i ImpliesFormula) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Op string `json:"op"`
|
||||
Left Formula `json:"left"`
|
||||
Right Formula `json:"right"`
|
||||
}{"implies", i.Antecedent, i.Consequent})
|
||||
}
|
||||
|
||||
func (p PureFormula) MarshalJSON() ([]byte, error) {
|
||||
if p.Value {
|
||||
return []byte(`{"op":"true"}`), nil
|
||||
}
|
||||
return []byte(`{"op":"false"}`), nil
|
||||
}
|
||||
|
||||
func (t ThunkFormula) MarshalJSON() ([]byte, error) {
|
||||
payload := struct {
|
||||
Op string `json:"op"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}{Op: "predicate"}
|
||||
if labeled, ok := any(t).(PredicateLabel); ok {
|
||||
payload.Name = labeled.PredicateName()
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
func (e ErrorFormula) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Op string `json:"op"`
|
||||
Message string `json:"message"`
|
||||
}{"error", e.Message})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package ltl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -191,3 +192,75 @@ func TestVerdict_StringPending(t *testing.T) {
|
||||
t.Errorf("VerdictPending.String() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalJSON_AlwaysImpliesEventually(t *testing.T) {
|
||||
formula := Always(Implies(Now(Pure(true)), EventuallyWithinSteps(Pure(false), 3)))
|
||||
body, err := json.Marshal(formula)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"op":"always","arg":{"op":"implies","left":{"op":"now","arg":{"op":"true"}},"right":{"op":"eventually","arg":{"op":"false"},"within":{"amount":3,"unit":"steps"}}}}`
|
||||
if string(body) != want {
|
||||
t.Errorf("marshal mismatch:\n got: %s\nwant: %s", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalJSON_AndOrNot(t *testing.T) {
|
||||
formula := And(Or(Pure(true), Pure(false)), Not(Pure(true)))
|
||||
body, _ := json.Marshal(formula)
|
||||
want := `{"op":"and","left":{"op":"or","left":{"op":"true"},"right":{"op":"false"}},"right":{"op":"not","arg":{"op":"true"}}}`
|
||||
if string(body) != want {
|
||||
t.Errorf("and/or/not marshal mismatch:\n got: %s\nwant: %s", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalJSON_EventuallyMillisecondsAndDeadline(t *testing.T) {
|
||||
body, _ := json.Marshal(EventuallyWithin(Pure(true), 250*time.Millisecond))
|
||||
if !strings.Contains(string(body), `"unit":"milliseconds"`) || !strings.Contains(string(body), `"amount":250`) {
|
||||
t.Errorf("milliseconds within wrong: %s", body)
|
||||
}
|
||||
deadline := time.UnixMilli(1700000000000)
|
||||
body, _ = json.Marshal(EventuallyBefore(Pure(true), deadline))
|
||||
if !strings.Contains(string(body), `"unit":"deadline"`) || !strings.Contains(string(body), `"amount":1700000000000`) {
|
||||
t.Errorf("deadline within wrong: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalJSON_NextAndThunkAndError(t *testing.T) {
|
||||
body, _ := json.Marshal(Next(Pure(true)))
|
||||
if string(body) != `{"op":"next","arg":{"op":"true"}}` {
|
||||
t.Errorf("next marshal wrong: %s", body)
|
||||
}
|
||||
body, _ = json.Marshal(Thunk(func() bool { return true }))
|
||||
if string(body) != `{"op":"predicate"}` {
|
||||
t.Errorf("thunk marshal wrong: %s", body)
|
||||
}
|
||||
body, _ = json.Marshal(ErrorFormula{Message: "bad"})
|
||||
if string(body) != `{"op":"error","message":"bad"}` {
|
||||
t.Errorf("error marshal wrong: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResidual_HoldsViolatedPending(t *testing.T) {
|
||||
holdsEval := NewEvaluator(Always(Pure(true)))
|
||||
holdsEval.Observe()
|
||||
if got := holdsEval.Residual(); got != (PureFormula{Value: true}) {
|
||||
t.Errorf("holds residual = %v, want true", got)
|
||||
}
|
||||
|
||||
violEval := NewEvaluator(Always(Now(Pure(false))))
|
||||
violEval.Observe()
|
||||
if got := violEval.Residual(); got != (PureFormula{Value: false}) {
|
||||
t.Errorf("violated residual = %v, want false", got)
|
||||
}
|
||||
|
||||
pendingEval := NewEvaluator(Always(Next(Pure(true))))
|
||||
pendingEval.Observe()
|
||||
body, err := json.Marshal(pendingEval.Residual())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"op":"and"`) && !strings.Contains(string(body), `"op":"true"`) {
|
||||
t.Errorf("pending residual unexpected: %s", body)
|
||||
}
|
||||
}
|
||||
+111
-3
@@ -21,6 +21,7 @@ type Options struct {
|
||||
SnapshotTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
|
||||
BundleID string
|
||||
Connection *agent.Conn
|
||||
Driver driver.Driver
|
||||
Verifier *verifier.Verifier
|
||||
@@ -79,6 +80,11 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
treeSize = len(tree.Elements)
|
||||
}
|
||||
|
||||
// Sample metrics now, while the app is still running freely.
|
||||
// Measuring after snapshotStep would see the SDK-paused app and
|
||||
// report CPU=0 for every step.
|
||||
metrics := captureMetrics(ctx, options, logger, stepIndex)
|
||||
|
||||
snapshot, err := snapshotStep(ctx, options)
|
||||
if err != nil {
|
||||
return summary, fmt.Errorf("step %d snapshot: %w", stepIndex, err)
|
||||
@@ -116,11 +122,16 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
nextAction, nextErr := options.Verifier.NextAction()
|
||||
var traceAction *trace.Action
|
||||
if nextErr == nil {
|
||||
traceAction = traceActionFor(nextAction)
|
||||
traceAction = traceActionFor(nextAction, tree)
|
||||
} else if !errors.Is(nextErr, verifier.ErrNoAction) {
|
||||
return summary, fmt.Errorf("step %d next action: %w", stepIndex, nextErr)
|
||||
}
|
||||
|
||||
residuals, residualErr := encodeResiduals(options.Verifier.Residuals())
|
||||
if residualErr != nil {
|
||||
logger.Warn("residual encode failed", "step", stepIndex, "err", residualErr)
|
||||
}
|
||||
|
||||
step := trace.Step{
|
||||
Index: stepIndex,
|
||||
Timestamp: stepStart,
|
||||
@@ -129,10 +140,14 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
Action: traceAction,
|
||||
Exceptions: traceExceptions(exceptions),
|
||||
Violations: violations,
|
||||
Hierarchy: tree,
|
||||
Residuals: residuals,
|
||||
Metrics: metrics,
|
||||
}
|
||||
if err := options.TraceWriter.WriteStep(step); err != nil {
|
||||
return summary, fmt.Errorf("step %d trace: %w", stepIndex, err)
|
||||
}
|
||||
captureScreenshot(ctx, options, logger, stepIndex, false)
|
||||
summary.Steps = stepIndex
|
||||
if len(violations) > 0 {
|
||||
summary.Violations = append(summary.Violations, ViolationRecord{
|
||||
@@ -157,6 +172,9 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
|
||||
idleCtx, idleCancel := context.WithTimeout(ctx, options.IdleTimeout)
|
||||
idleErr := options.Driver.WaitForIdle(idleCtx, options.IdleTimeout)
|
||||
if nextErr == nil {
|
||||
captureScreenshot(ctx, options, logger, stepIndex, true)
|
||||
}
|
||||
if idleErr != nil && idleCtx.Err() == nil {
|
||||
logger.Warn("wait_for_idle failed", "step", stepIndex, "err", idleErr)
|
||||
}
|
||||
@@ -334,13 +352,16 @@ func fetchHierarchy(ctx context.Context, drv driver.Driver) (*hierarchy.Tree, er
|
||||
return hierarchy.Parse(xmlText)
|
||||
}
|
||||
|
||||
func traceActionFor(action verifier.Action) *trace.Action {
|
||||
func traceActionFor(action verifier.Action, tree *hierarchy.Tree) *trace.Action {
|
||||
traceAction := &trace.Action{Kind: string(action.Kind), X: action.X, Y: action.Y}
|
||||
switch action.Kind {
|
||||
case verifier.ActionKindTap:
|
||||
traceAction.Text = action.On
|
||||
traceAction.Selector = action.On
|
||||
stampSelectorTarget(traceAction, action, tree)
|
||||
case verifier.ActionKindInputText:
|
||||
traceAction.Text = action.Text
|
||||
traceAction.Selector = action.On
|
||||
stampSelectorTarget(traceAction, action, tree)
|
||||
case verifier.ActionKindSwipe:
|
||||
traceAction.FromX = action.FromX
|
||||
traceAction.FromY = action.FromY
|
||||
@@ -357,6 +378,93 @@ func traceActionFor(action verifier.Action) *trace.Action {
|
||||
return traceAction
|
||||
}
|
||||
|
||||
// stampSelectorTarget mirrors applyAction's coordinate-resolution rule so the
|
||||
// trace records the same point the runner taps. When the spec passed an ax
|
||||
// element directly, action.X/Y are already populated and we use them; when the
|
||||
// spec passed a string selector, we resolve it against the captured hierarchy.
|
||||
func stampSelectorTarget(traceAction *trace.Action, action verifier.Action, tree *hierarchy.Tree) {
|
||||
if action.X > 0 && action.Y > 0 {
|
||||
traceAction.TapPoint = &trace.PointRecord{X: action.X, Y: action.Y}
|
||||
return
|
||||
}
|
||||
if tree == nil || action.On == "" {
|
||||
return
|
||||
}
|
||||
element := tree.Find(action.On)
|
||||
if element == nil {
|
||||
return
|
||||
}
|
||||
bounds := element.Bounds
|
||||
traceAction.ResolvedBounds = &trace.BoundsRecord{
|
||||
X: bounds.Left,
|
||||
Y: bounds.Top,
|
||||
Width: bounds.Width(),
|
||||
Height: bounds.Height(),
|
||||
}
|
||||
x, y := bounds.Center()
|
||||
if x > 0 && y > 0 {
|
||||
traceAction.TapPoint = &trace.PointRecord{X: x, Y: y}
|
||||
}
|
||||
}
|
||||
|
||||
func captureMetrics(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) *trace.Metrics {
|
||||
if options.BundleID == "" {
|
||||
return nil
|
||||
}
|
||||
sample, err := options.Driver.Metrics(ctx, options.BundleID)
|
||||
if err != nil {
|
||||
logger.Warn("metrics capture failed", "step", stepIndex, "err", err)
|
||||
return nil
|
||||
}
|
||||
if sample.CPUPercent == 0 && sample.HeapBytes == 0 && sample.TotalMemoryBytes == 0 {
|
||||
return nil
|
||||
}
|
||||
return &trace.Metrics{
|
||||
CPUPercent: sample.CPUPercent,
|
||||
HeapBytes: sample.HeapBytes,
|
||||
TotalMemoryBytes: sample.TotalMemoryBytes,
|
||||
}
|
||||
}
|
||||
|
||||
func captureScreenshot(ctx context.Context, options Options, logger *slog.Logger, stepIndex int, after bool) {
|
||||
image, err := options.Driver.Screenshot(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("screenshot capture failed", "step", stepIndex, "after", after, "err", err)
|
||||
return
|
||||
}
|
||||
if len(image.PNG) == 0 {
|
||||
return
|
||||
}
|
||||
var writeErr error
|
||||
if after {
|
||||
writeErr = options.TraceWriter.WriteScreenshotAfter(stepIndex, image.PNG)
|
||||
} else {
|
||||
writeErr = options.TraceWriter.WriteScreenshot(stepIndex, image.PNG)
|
||||
}
|
||||
if writeErr != nil {
|
||||
logger.Warn("screenshot write failed", "step", stepIndex, "after", after, "err", writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func encodeResiduals(residuals map[string]ltl.Formula) (map[string]json.RawMessage, error) {
|
||||
if len(residuals) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
encoded := make(map[string]json.RawMessage, len(residuals))
|
||||
var firstErr error
|
||||
for name, formula := range residuals {
|
||||
body, err := json.Marshal(formula)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
encoded[name] = body
|
||||
}
|
||||
return encoded, firstErr
|
||||
}
|
||||
|
||||
func traceExceptions(exceptions []verifier.Exception) []trace.Exception {
|
||||
if len(exceptions) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -310,6 +310,55 @@ func TestScreenFromSnapshot(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunner_StampsHierarchyResolvedBoundsAndResiduals(t *testing.T) {
|
||||
snapshots := []map[string]json.RawMessage{
|
||||
{"balance": json.RawMessage(`100`)},
|
||||
{"balance": json.RawMessage(`200`)},
|
||||
}
|
||||
state := newHarness(t, snapshots)
|
||||
state.startSDK(t)
|
||||
state.acceptConnection(t)
|
||||
|
||||
state.mock.HierarchyJSON = `<?xml version="1.0"?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.fixture:id/next" clickable="true" enabled="true" bounds="[40,80][240,160]"/>
|
||||
</hierarchy>`
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := Run(ctx, Options{
|
||||
Duration: 100 * time.Millisecond,
|
||||
SnapshotTimeout: 2 * time.Second,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
Connection: state.conn,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(body)
|
||||
if !strings.Contains(text, `"selector":"id:next"`) {
|
||||
t.Errorf("expected selector in trace: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, `"resolved_bounds":{"x":40,"y":80,"width":200,"height":80}`) {
|
||||
t.Errorf("expected resolved_bounds in trace: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, `"tap_point":{"x":140,"y":120}`) {
|
||||
t.Errorf("expected tap_point in trace: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, `"hierarchy":{"elements":`) {
|
||||
t.Errorf("expected hierarchy in trace: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, `"residuals":{`) {
|
||||
t.Errorf("expected residuals in trace: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) {
|
||||
snapshots := []map[string]json.RawMessage{
|
||||
{"balance": json.RawMessage(`100`)},
|
||||
|
||||
+56
-19
@@ -8,6 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/uatu/internal/hierarchy"
|
||||
)
|
||||
|
||||
type Step struct {
|
||||
@@ -18,19 +20,43 @@ type Step struct {
|
||||
Action *Action `json:"action,omitempty"`
|
||||
Exceptions []Exception `json:"exceptions,omitempty"`
|
||||
Violations []string `json:"violations,omitempty"`
|
||||
Hierarchy *hierarchy.Tree `json:"hierarchy,omitempty"`
|
||||
Residuals map[string]json.RawMessage `json:"residuals,omitempty"`
|
||||
Metrics *Metrics `json:"metrics,omitempty"`
|
||||
}
|
||||
|
||||
type Metrics struct {
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
HeapBytes int64 `json:"heap_bytes,omitempty"`
|
||||
TotalMemoryBytes int64 `json:"total_memory_bytes,omitempty"`
|
||||
}
|
||||
|
||||
type Action struct {
|
||||
Kind string `json:"kind"`
|
||||
X int `json:"x,omitempty"`
|
||||
Y int `json:"y,omitempty"`
|
||||
FromX int `json:"from_x,omitempty"`
|
||||
FromY int `json:"from_y,omitempty"`
|
||||
ToX int `json:"to_x,omitempty"`
|
||||
ToY int `json:"to_y,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
DurationMillis int `json:"duration_millis,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
X int `json:"x,omitempty"`
|
||||
Y int `json:"y,omitempty"`
|
||||
FromX int `json:"from_x,omitempty"`
|
||||
FromY int `json:"from_y,omitempty"`
|
||||
ToX int `json:"to_x,omitempty"`
|
||||
ToY int `json:"to_y,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
DurationMillis int `json:"duration_millis,omitempty"`
|
||||
Selector string `json:"selector,omitempty"`
|
||||
ResolvedBounds *BoundsRecord `json:"resolved_bounds,omitempty"`
|
||||
TapPoint *PointRecord `json:"tap_point,omitempty"`
|
||||
}
|
||||
|
||||
type BoundsRecord struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
type PointRecord struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
|
||||
type Exception struct {
|
||||
@@ -41,13 +67,14 @@ type Exception struct {
|
||||
}
|
||||
|
||||
type Meta struct {
|
||||
Seed int64 `json:"seed"`
|
||||
SpecPath string `json:"spec_path"`
|
||||
BundleSHA256 string `json:"bundle_sha256"`
|
||||
Platform string `json:"platform"`
|
||||
BundleID string `json:"bundle_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
UatuVersion string `json:"uatu_version"`
|
||||
Seed int64 `json:"seed"`
|
||||
SpecPath string `json:"spec_path"`
|
||||
BundleSHA256 string `json:"bundle_sha256"`
|
||||
Platform string `json:"platform"`
|
||||
BundleID string `json:"bundle_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
UatuVersion string `json:"uatu_version"`
|
||||
}
|
||||
|
||||
type Writer struct {
|
||||
@@ -95,6 +122,17 @@ func (w *Writer) WriteStep(step Step) error {
|
||||
}
|
||||
|
||||
func (w *Writer) WriteScreenshot(stepIndex int, png []byte) error {
|
||||
return w.writePNG(fmt.Sprintf("step-%05d.png", stepIndex), png)
|
||||
}
|
||||
|
||||
// WriteScreenshotAfter writes the post-action screenshot for a step.
|
||||
// Callers use this after applyAction + waitForIdle so the UI can show a
|
||||
// before/after pair.
|
||||
func (w *Writer) WriteScreenshotAfter(stepIndex int, png []byte) error {
|
||||
return w.writePNG(fmt.Sprintf("step-%05d-after.png", stepIndex), png)
|
||||
}
|
||||
|
||||
func (w *Writer) writePNG(name string, png []byte) error {
|
||||
if len(png) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -102,8 +140,7 @@ func (w *Writer) WriteScreenshot(stepIndex int, png []byte) error {
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir screenshots: %w", err)
|
||||
}
|
||||
path := filepath.Join(directory, fmt.Sprintf("step-%05d.png", stepIndex))
|
||||
return os.WriteFile(path, png, 0o644)
|
||||
return os.WriteFile(filepath.Join(directory, name), png, 0o644)
|
||||
}
|
||||
|
||||
func (w *Writer) Close() error {
|
||||
|
||||
@@ -44,6 +44,109 @@ func TestWriteMeta_RoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMeta_EndedAtRoundTrip(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writer, err := NewWriter(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer writer.Close()
|
||||
|
||||
endedAt := time.Date(2026, 4, 17, 22, 31, 0, 0, time.UTC)
|
||||
meta := Meta{
|
||||
Seed: 7,
|
||||
SpecPath: "spec.ts",
|
||||
Platform: "android",
|
||||
BundleID: "in.test",
|
||||
StartedAt: time.Date(2026, 4, 17, 22, 30, 0, 0, time.UTC),
|
||||
EndedAt: &endedAt,
|
||||
UatuVersion: "0.0.1",
|
||||
}
|
||||
if err := writer.WriteMeta(meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(directory, "meta.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"ended_at": "2026-04-17T22:31:00Z"`) {
|
||||
t.Errorf("ended_at not in meta.json: %s", body)
|
||||
}
|
||||
var got Meta
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.EndedAt == nil || !got.EndedAt.Equal(endedAt) {
|
||||
t.Errorf("EndedAt round-trip wrong: %v", got.EndedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMeta_OmitsEndedAtWhenNil(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writer, _ := NewWriter(directory)
|
||||
defer writer.Close()
|
||||
if err := writer.WriteMeta(Meta{StartedAt: time.Now().UTC()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := os.ReadFile(filepath.Join(directory, "meta.json"))
|
||||
if strings.Contains(string(body), "ended_at") {
|
||||
t.Errorf("ended_at should be omitted when nil: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStep_HierarchyAndResidualsRoundTrip(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writer, _ := NewWriter(directory)
|
||||
defer writer.Close()
|
||||
|
||||
step := Step{
|
||||
Index: 1,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Action: &Action{
|
||||
Kind: "tap",
|
||||
Selector: "id:next",
|
||||
ResolvedBounds: &BoundsRecord{X: 10, Y: 20, Width: 100, Height: 50},
|
||||
TapPoint: &PointRecord{X: 60, Y: 45},
|
||||
},
|
||||
Residuals: map[string]json.RawMessage{
|
||||
"prop1": json.RawMessage(`{"op":"true"}`),
|
||||
},
|
||||
}
|
||||
if err := writer.WriteStep(step); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := os.ReadFile(filepath.Join(directory, "trace.jsonl"))
|
||||
var got Step
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("bad jsonl: %v\n%s", err, body)
|
||||
}
|
||||
if got.Action.Selector != "id:next" {
|
||||
t.Errorf("selector = %q", got.Action.Selector)
|
||||
}
|
||||
if got.Action.ResolvedBounds == nil || got.Action.ResolvedBounds.Width != 100 {
|
||||
t.Errorf("resolved_bounds round-trip wrong: %+v", got.Action.ResolvedBounds)
|
||||
}
|
||||
if got.Action.TapPoint == nil || got.Action.TapPoint.X != 60 {
|
||||
t.Errorf("tap_point round-trip wrong: %+v", got.Action.TapPoint)
|
||||
}
|
||||
if string(got.Residuals["prop1"]) != `{"op":"true"}` {
|
||||
t.Errorf("residuals round-trip wrong: %s", got.Residuals["prop1"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStep_OmitsEmptyHierarchyAndResiduals(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writer, _ := NewWriter(directory)
|
||||
defer writer.Close()
|
||||
if err := writer.WriteStep(Step{Index: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := os.ReadFile(filepath.Join(directory, "trace.jsonl"))
|
||||
if strings.Contains(string(body), "hierarchy") || strings.Contains(string(body), "residuals") {
|
||||
t.Errorf("empty hierarchy/residuals must omit: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStep_AppendsOneJsonLine(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writer, err := NewWriter(directory)
|
||||
|
||||
@@ -273,6 +273,22 @@ func (v *Verifier) EvaluateProperties() map[string]ltl.Verdict {
|
||||
return verdicts
|
||||
}
|
||||
|
||||
// Residuals returns the residual formula for each registered property after
|
||||
// the most recent EvaluateProperties call. Properties that errored during
|
||||
// predicate evaluation surface as ErrorFormula so the inspect UI can render
|
||||
// "predicate threw" inline.
|
||||
func (v *Verifier) Residuals() map[string]ltl.Formula {
|
||||
residuals := map[string]ltl.Formula{}
|
||||
for name, evaluator := range v.evaluators {
|
||||
if predicateErr := v.PredicateError(name); predicateErr != nil {
|
||||
residuals[name] = ltl.ErrorFormula{Message: predicateErr.Error()}
|
||||
continue
|
||||
}
|
||||
residuals[name] = evaluator.Residual()
|
||||
}
|
||||
return residuals
|
||||
}
|
||||
|
||||
// NextAction resolves the root action generator into a single Action.
|
||||
// Returns ErrNoAction when no branch of the generator produces one after a
|
||||
// small number of retries. Retrying avoids wedging when most branches of a
|
||||
|
||||
+135
-18
@@ -741,6 +741,110 @@ func (x *LogEntry) GetMessage() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type MetricsRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
BundleId string `protobuf:"bytes,1,opt,name=bundle_id,json=bundleId,proto3" json:"bundle_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *MetricsRequest) Reset() {
|
||||
*x = MetricsRequest{}
|
||||
mi := &file_driverpb_driver_proto_msgTypes[14]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *MetricsRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*MetricsRequest) ProtoMessage() {}
|
||||
|
||||
func (x *MetricsRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_driverpb_driver_proto_msgTypes[14]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use MetricsRequest.ProtoReflect.Descriptor instead.
|
||||
func (*MetricsRequest) Descriptor() ([]byte, []int) {
|
||||
return file_driverpb_driver_proto_rawDescGZIP(), []int{14}
|
||||
}
|
||||
|
||||
func (x *MetricsRequest) GetBundleId() string {
|
||||
if x != nil {
|
||||
return x.BundleId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type MetricsResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
CpuPercent float64 `protobuf:"fixed64,1,opt,name=cpu_percent,json=cpuPercent,proto3" json:"cpu_percent,omitempty"`
|
||||
HeapBytes int64 `protobuf:"varint,2,opt,name=heap_bytes,json=heapBytes,proto3" json:"heap_bytes,omitempty"`
|
||||
TotalMemoryBytes int64 `protobuf:"varint,3,opt,name=total_memory_bytes,json=totalMemoryBytes,proto3" json:"total_memory_bytes,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *MetricsResponse) Reset() {
|
||||
*x = MetricsResponse{}
|
||||
mi := &file_driverpb_driver_proto_msgTypes[15]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *MetricsResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*MetricsResponse) ProtoMessage() {}
|
||||
|
||||
func (x *MetricsResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_driverpb_driver_proto_msgTypes[15]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use MetricsResponse.ProtoReflect.Descriptor instead.
|
||||
func (*MetricsResponse) Descriptor() ([]byte, []int) {
|
||||
return file_driverpb_driver_proto_rawDescGZIP(), []int{15}
|
||||
}
|
||||
|
||||
func (x *MetricsResponse) GetCpuPercent() float64 {
|
||||
if x != nil {
|
||||
return x.CpuPercent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *MetricsResponse) GetHeapBytes() int64 {
|
||||
if x != nil {
|
||||
return x.HeapBytes
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *MetricsResponse) GetTotalMemoryBytes() int64 {
|
||||
if x != nil {
|
||||
return x.TotalMemoryBytes
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var File_driverpb_driver_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_driverpb_driver_proto_rawDesc = "" +
|
||||
@@ -788,7 +892,15 @@ const file_driverpb_driver_proto_rawDesc = "" +
|
||||
"unixMillis\x12\x14\n" +
|
||||
"\x05level\x18\x02 \x01(\tR\x05level\x12\x10\n" +
|
||||
"\x03tag\x18\x03 \x01(\tR\x03tag\x12\x18\n" +
|
||||
"\amessage\x18\x04 \x01(\tR\amessage2\xff\x05\n" +
|
||||
"\amessage\x18\x04 \x01(\tR\amessage\"-\n" +
|
||||
"\x0eMetricsRequest\x12\x1b\n" +
|
||||
"\tbundle_id\x18\x01 \x01(\tR\bbundleId\"\x7f\n" +
|
||||
"\x0fMetricsResponse\x12\x1f\n" +
|
||||
"\vcpu_percent\x18\x01 \x01(\x01R\n" +
|
||||
"cpuPercent\x12\x1d\n" +
|
||||
"\n" +
|
||||
"heap_bytes\x18\x02 \x01(\x03R\theapBytes\x12,\n" +
|
||||
"\x12total_memory_bytes\x18\x03 \x01(\x03R\x10totalMemoryBytes2\xcb\x06\n" +
|
||||
"\x06Driver\x12>\n" +
|
||||
"\x06Launch\x12\x1d.uatu.driver.v1.LaunchRequest\x1a\x15.uatu.driver.v1.Empty\x129\n" +
|
||||
"\tTerminate\x12\x15.uatu.driver.v1.Empty\x1a\x15.uatu.driver.v1.Empty\x123\n" +
|
||||
@@ -803,7 +915,8 @@ const file_driverpb_driver_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"RecentLogs\x12!.uatu.driver.v1.RecentLogsRequest\x1a\x1a.uatu.driver.v1.LogEntries\x12>\n" +
|
||||
"\vWaitForIdle\x12\x18.uatu.driver.v1.Duration\x1a\x15.uatu.driver.v1.Empty\x12=\n" +
|
||||
"\x06Health\x12\x15.uatu.driver.v1.Empty\x1a\x1c.uatu.driver.v1.HealthStatusBM\n" +
|
||||
"\x06Health\x12\x15.uatu.driver.v1.Empty\x1a\x1c.uatu.driver.v1.HealthStatus\x12J\n" +
|
||||
"\aMetrics\x12\x1e.uatu.driver.v1.MetricsRequest\x1a\x1f.uatu.driver.v1.MetricsResponseBM\n" +
|
||||
"\x12dev.uatu.driver.v1P\x01Z5github.com/priyanshujain/uatu/proto/driverpb;driverpbb\x06proto3"
|
||||
|
||||
var (
|
||||
@@ -818,7 +931,7 @@ func file_driverpb_driver_proto_rawDescGZIP() []byte {
|
||||
return file_driverpb_driver_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_driverpb_driver_proto_msgTypes = make([]protoimpl.MessageInfo, 14)
|
||||
var file_driverpb_driver_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
|
||||
var file_driverpb_driver_proto_goTypes = []any{
|
||||
(*Empty)(nil), // 0: uatu.driver.v1.Empty
|
||||
(*LaunchRequest)(nil), // 1: uatu.driver.v1.LaunchRequest
|
||||
@@ -834,6 +947,8 @@ var file_driverpb_driver_proto_goTypes = []any{
|
||||
(*RecentLogsRequest)(nil), // 11: uatu.driver.v1.RecentLogsRequest
|
||||
(*LogEntries)(nil), // 12: uatu.driver.v1.LogEntries
|
||||
(*LogEntry)(nil), // 13: uatu.driver.v1.LogEntry
|
||||
(*MetricsRequest)(nil), // 14: uatu.driver.v1.MetricsRequest
|
||||
(*MetricsResponse)(nil), // 15: uatu.driver.v1.MetricsResponse
|
||||
}
|
||||
var file_driverpb_driver_proto_depIdxs = []int32{
|
||||
2, // 0: uatu.driver.v1.SwipeRequest.from:type_name -> uatu.driver.v1.Point
|
||||
@@ -851,20 +966,22 @@ var file_driverpb_driver_proto_depIdxs = []int32{
|
||||
11, // 12: uatu.driver.v1.Driver.RecentLogs:input_type -> uatu.driver.v1.RecentLogsRequest
|
||||
7, // 13: uatu.driver.v1.Driver.WaitForIdle:input_type -> uatu.driver.v1.Duration
|
||||
0, // 14: uatu.driver.v1.Driver.Health:input_type -> uatu.driver.v1.Empty
|
||||
0, // 15: uatu.driver.v1.Driver.Launch:output_type -> uatu.driver.v1.Empty
|
||||
0, // 16: uatu.driver.v1.Driver.Terminate:output_type -> uatu.driver.v1.Empty
|
||||
0, // 17: uatu.driver.v1.Driver.Tap:output_type -> uatu.driver.v1.Empty
|
||||
0, // 18: uatu.driver.v1.Driver.TapSelector:output_type -> uatu.driver.v1.Empty
|
||||
0, // 19: uatu.driver.v1.Driver.InputText:output_type -> uatu.driver.v1.Empty
|
||||
0, // 20: uatu.driver.v1.Driver.Swipe:output_type -> uatu.driver.v1.Empty
|
||||
0, // 21: uatu.driver.v1.Driver.PressKey:output_type -> uatu.driver.v1.Empty
|
||||
5, // 22: uatu.driver.v1.Driver.Screenshot:output_type -> uatu.driver.v1.Image
|
||||
6, // 23: uatu.driver.v1.Driver.Hierarchy:output_type -> uatu.driver.v1.HierarchyJSON
|
||||
12, // 24: uatu.driver.v1.Driver.RecentLogs:output_type -> uatu.driver.v1.LogEntries
|
||||
0, // 25: uatu.driver.v1.Driver.WaitForIdle:output_type -> uatu.driver.v1.Empty
|
||||
8, // 26: uatu.driver.v1.Driver.Health:output_type -> uatu.driver.v1.HealthStatus
|
||||
15, // [15:27] is the sub-list for method output_type
|
||||
3, // [3:15] is the sub-list for method input_type
|
||||
14, // 15: uatu.driver.v1.Driver.Metrics:input_type -> uatu.driver.v1.MetricsRequest
|
||||
0, // 16: uatu.driver.v1.Driver.Launch:output_type -> uatu.driver.v1.Empty
|
||||
0, // 17: uatu.driver.v1.Driver.Terminate:output_type -> uatu.driver.v1.Empty
|
||||
0, // 18: uatu.driver.v1.Driver.Tap:output_type -> uatu.driver.v1.Empty
|
||||
0, // 19: uatu.driver.v1.Driver.TapSelector:output_type -> uatu.driver.v1.Empty
|
||||
0, // 20: uatu.driver.v1.Driver.InputText:output_type -> uatu.driver.v1.Empty
|
||||
0, // 21: uatu.driver.v1.Driver.Swipe:output_type -> uatu.driver.v1.Empty
|
||||
0, // 22: uatu.driver.v1.Driver.PressKey:output_type -> uatu.driver.v1.Empty
|
||||
5, // 23: uatu.driver.v1.Driver.Screenshot:output_type -> uatu.driver.v1.Image
|
||||
6, // 24: uatu.driver.v1.Driver.Hierarchy:output_type -> uatu.driver.v1.HierarchyJSON
|
||||
12, // 25: uatu.driver.v1.Driver.RecentLogs:output_type -> uatu.driver.v1.LogEntries
|
||||
0, // 26: uatu.driver.v1.Driver.WaitForIdle:output_type -> uatu.driver.v1.Empty
|
||||
8, // 27: uatu.driver.v1.Driver.Health:output_type -> uatu.driver.v1.HealthStatus
|
||||
15, // 28: uatu.driver.v1.Driver.Metrics:output_type -> uatu.driver.v1.MetricsResponse
|
||||
16, // [16:29] is the sub-list for method output_type
|
||||
3, // [3:16] is the sub-list for method input_type
|
||||
3, // [3:3] is the sub-list for extension type_name
|
||||
3, // [3:3] is the sub-list for extension extendee
|
||||
0, // [0:3] is the sub-list for field type_name
|
||||
@@ -881,7 +998,7 @@ func file_driverpb_driver_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_driverpb_driver_proto_rawDesc), len(file_driverpb_driver_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 14,
|
||||
NumMessages: 16,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ service Driver {
|
||||
|
||||
rpc WaitForIdle(Duration) returns (Empty);
|
||||
rpc Health(Empty) returns (HealthStatus);
|
||||
rpc Metrics(MetricsRequest) returns (MetricsResponse);
|
||||
}
|
||||
|
||||
message Empty {}
|
||||
@@ -98,3 +99,13 @@ message LogEntry {
|
||||
string tag = 3;
|
||||
string message = 4;
|
||||
}
|
||||
|
||||
message MetricsRequest {
|
||||
string bundle_id = 1;
|
||||
}
|
||||
|
||||
message MetricsResponse {
|
||||
double cpu_percent = 1;
|
||||
int64 heap_bytes = 2;
|
||||
int64 total_memory_bytes = 3;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ const (
|
||||
Driver_RecentLogs_FullMethodName = "/uatu.driver.v1.Driver/RecentLogs"
|
||||
Driver_WaitForIdle_FullMethodName = "/uatu.driver.v1.Driver/WaitForIdle"
|
||||
Driver_Health_FullMethodName = "/uatu.driver.v1.Driver/Health"
|
||||
Driver_Metrics_FullMethodName = "/uatu.driver.v1.Driver/Metrics"
|
||||
)
|
||||
|
||||
// DriverClient is the client API for Driver service.
|
||||
@@ -49,6 +50,7 @@ type DriverClient interface {
|
||||
RecentLogs(ctx context.Context, in *RecentLogsRequest, opts ...grpc.CallOption) (*LogEntries, error)
|
||||
WaitForIdle(ctx context.Context, in *Duration, opts ...grpc.CallOption) (*Empty, error)
|
||||
Health(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*HealthStatus, error)
|
||||
Metrics(ctx context.Context, in *MetricsRequest, opts ...grpc.CallOption) (*MetricsResponse, error)
|
||||
}
|
||||
|
||||
type driverClient struct {
|
||||
@@ -179,6 +181,16 @@ func (c *driverClient) Health(ctx context.Context, in *Empty, opts ...grpc.CallO
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *driverClient) Metrics(ctx context.Context, in *MetricsRequest, opts ...grpc.CallOption) (*MetricsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(MetricsResponse)
|
||||
err := c.cc.Invoke(ctx, Driver_Metrics_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DriverServer is the server API for Driver service.
|
||||
// All implementations must embed UnimplementedDriverServer
|
||||
// for forward compatibility.
|
||||
@@ -195,6 +207,7 @@ type DriverServer interface {
|
||||
RecentLogs(context.Context, *RecentLogsRequest) (*LogEntries, error)
|
||||
WaitForIdle(context.Context, *Duration) (*Empty, error)
|
||||
Health(context.Context, *Empty) (*HealthStatus, error)
|
||||
Metrics(context.Context, *MetricsRequest) (*MetricsResponse, error)
|
||||
mustEmbedUnimplementedDriverServer()
|
||||
}
|
||||
|
||||
@@ -241,6 +254,9 @@ func (UnimplementedDriverServer) WaitForIdle(context.Context, *Duration) (*Empty
|
||||
func (UnimplementedDriverServer) Health(context.Context, *Empty) (*HealthStatus, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
|
||||
}
|
||||
func (UnimplementedDriverServer) Metrics(context.Context, *MetricsRequest) (*MetricsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Metrics not implemented")
|
||||
}
|
||||
func (UnimplementedDriverServer) mustEmbedUnimplementedDriverServer() {}
|
||||
func (UnimplementedDriverServer) testEmbeddedByValue() {}
|
||||
|
||||
@@ -478,6 +494,24 @@ func _Driver_Health_Handler(srv interface{}, ctx context.Context, dec func(inter
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Driver_Metrics_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(MetricsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DriverServer).Metrics(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Driver_Metrics_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DriverServer).Metrics(ctx, req.(*MetricsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// Driver_ServiceDesc is the grpc.ServiceDesc for Driver service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -533,6 +567,10 @@ var Driver_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "Health",
|
||||
Handler: _Driver_Health_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Metrics",
|
||||
Handler: _Driver_Metrics_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "driverpb/driver.proto",
|
||||
|
||||
@@ -25,6 +25,7 @@ func TestDriverServiceDescriptor(t *testing.T) {
|
||||
"RecentLogs": true,
|
||||
"WaitForIdle": true,
|
||||
"Health": true,
|
||||
"Metrics": true,
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, m := range sd.Methods {
|
||||
|
||||
@@ -13,8 +13,15 @@ interface DriverBackend {
|
||||
fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine>
|
||||
fun waitForIdle(durationMillis: Long)
|
||||
fun healthy(): Boolean
|
||||
fun metrics(bundleId: String): MetricsSample
|
||||
}
|
||||
|
||||
data class MetricsSample(
|
||||
val cpuPercent: Double,
|
||||
val heapBytes: Long,
|
||||
val totalMemoryBytes: Long,
|
||||
)
|
||||
|
||||
data class LogLine(
|
||||
val unixMillis: Long,
|
||||
val level: String,
|
||||
@@ -291,7 +298,19 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
|
||||
}
|
||||
}
|
||||
|
||||
override fun screenshot(): Triple<ByteArray, Int, Int> = Triple(ByteArray(0), 0, 0)
|
||||
override fun screenshot(): Triple<ByteArray, Int, Int> {
|
||||
return try {
|
||||
val process = ProcessBuilder(listOf("adb", "exec-out", "screencap", "-p"))
|
||||
.redirectErrorStream(false)
|
||||
.start()
|
||||
val png = process.inputStream.readAllBytes()
|
||||
process.waitFor()
|
||||
if (png.isEmpty()) Triple(ByteArray(0), 0, 0) else Triple(png, 0, 0)
|
||||
} catch (cause: Exception) {
|
||||
println("adb screencap failed: $cause")
|
||||
Triple(ByteArray(0), 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun hierarchy(): String {
|
||||
return try {
|
||||
@@ -315,4 +334,118 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
|
||||
}
|
||||
|
||||
override fun healthy(): Boolean = true
|
||||
|
||||
// Stateful CPU delta tracker. Reading /proc/<pid>/stat gives cumulative
|
||||
// utime+stime in jiffies; CPU % over a step is (delta_ticks / delta_wall)
|
||||
// * 100. First call blocks briefly for a real reading instead of 0.
|
||||
private data class CpuSample(val pid: Int, val ticks: Long, val wallNanos: Long)
|
||||
@Volatile private var lastCpuSample: CpuSample? = null
|
||||
private val clockTicksPerSecond: Long by lazy { resolveClockTicksPerSecond() }
|
||||
private val cpuFirstSampleSleepMillis: Long = 50L
|
||||
|
||||
override fun metrics(bundleId: String): MetricsSample {
|
||||
if (bundleId.isEmpty()) return MetricsSample(0.0, 0L, 0L)
|
||||
return try {
|
||||
val pid = runAdbOutput(listOf("shell", "pidof", bundleId)).trim().split(Regex("\\s+")).firstOrNull()?.toIntOrNull()
|
||||
?: run {
|
||||
lastCpuSample = null
|
||||
return MetricsSample(0.0, 0L, 0L)
|
||||
}
|
||||
val cpu = sampleCpuPercent(pid)
|
||||
val (rssBytes, vmSizeBytes) = sampleProcessMemory(pid)
|
||||
MetricsSample(cpu, rssBytes, vmSizeBytes)
|
||||
} catch (cause: Exception) {
|
||||
println("metrics capture failed: $cause")
|
||||
MetricsSample(0.0, 0L, 0L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sampleCpuPercent(pid: Int): Double {
|
||||
val previous = lastCpuSample
|
||||
if (previous != null && previous.pid == pid) {
|
||||
val ticks = readCpuTicks(pid) ?: return 0.0
|
||||
val now = System.nanoTime()
|
||||
lastCpuSample = CpuSample(pid, ticks, now)
|
||||
return cpuPercentFromDelta(ticks - previous.ticks, now - previous.wallNanos)
|
||||
}
|
||||
// No baseline for this PID: one adb round-trip with a device-side sleep
|
||||
// so the first step gets a real reading instead of 0.
|
||||
val pair = readCpuTicksPair(pid, cpuFirstSampleSleepMillis) ?: return 0.0
|
||||
lastCpuSample = CpuSample(pid, pair.second, System.nanoTime())
|
||||
return cpuPercentFromDelta(pair.second - pair.first, cpuFirstSampleSleepMillis * 1_000_000L)
|
||||
}
|
||||
|
||||
private fun readCpuTicks(pid: Int): Long? {
|
||||
val stat = runAdbOutput(listOf("shell", "cat", "/proc/$pid/stat")).trim()
|
||||
if (stat.isEmpty()) return null
|
||||
return parseCpuTicks(stat)
|
||||
}
|
||||
|
||||
private fun readCpuTicksPair(pid: Int, sleepMillis: Long): Pair<Long, Long>? {
|
||||
// "sleep 0.050" — toybox sleep accepts fractional seconds on modern Android.
|
||||
val sleepArg = "0.${"%03d".format(sleepMillis)}"
|
||||
val command = "cat /proc/$pid/stat; sleep $sleepArg; cat /proc/$pid/stat"
|
||||
val output = runAdbOutput(listOf("shell", command))
|
||||
val lines = output.lines().filter { it.isNotBlank() }
|
||||
if (lines.size < 2) return null
|
||||
val first = parseCpuTicks(lines[0]) ?: return null
|
||||
val second = parseCpuTicks(lines[1]) ?: return null
|
||||
return Pair(first, second)
|
||||
}
|
||||
|
||||
private fun parseCpuTicks(statLine: String): Long? {
|
||||
// /proc/<pid>/stat format: pid (comm) state ppid ... utime stime ...
|
||||
// comm is parenthesized and may contain spaces; rsplit on ')' to skip it.
|
||||
val afterComm = statLine.substringAfterLast(')').trim()
|
||||
val fields = afterComm.split(Regex("\\s+"))
|
||||
// After the ')' we are at the "state" field (index 0 in afterComm).
|
||||
// utime is proc(14) = afterComm[11], stime is proc(15) = afterComm[12].
|
||||
if (fields.size < 13) return null
|
||||
val utime = fields[11].toLongOrNull() ?: return null
|
||||
val stime = fields[12].toLongOrNull() ?: return null
|
||||
return utime + stime
|
||||
}
|
||||
|
||||
private fun cpuPercentFromDelta(deltaTicks: Long, deltaWallNanos: Long): Double {
|
||||
if (deltaTicks < 0 || deltaWallNanos <= 0) return 0.0
|
||||
val tickHz = clockTicksPerSecond.coerceAtLeast(1L)
|
||||
val deltaCpuNanos = deltaTicks * 1_000_000_000.0 / tickHz
|
||||
return (deltaCpuNanos / deltaWallNanos) * 100.0
|
||||
}
|
||||
|
||||
private fun resolveClockTicksPerSecond(): Long {
|
||||
val output = runAdbOutput(listOf("shell", "getconf", "CLK_TCK")).trim()
|
||||
return output.toLongOrNull() ?: 100L
|
||||
}
|
||||
|
||||
private fun sampleProcessMemory(pid: Int): Pair<Long, Long> {
|
||||
val status = runAdbOutput(listOf("shell", "cat", "/proc/$pid/status"))
|
||||
var rssKb = 0L
|
||||
var vmSizeKb = 0L
|
||||
for (raw in status.lineSequence()) {
|
||||
val line = raw.trim()
|
||||
when {
|
||||
line.startsWith("VmRSS:") -> rssKb = parseKb(line) ?: rssKb
|
||||
line.startsWith("VmSize:") -> vmSizeKb = parseKb(line) ?: vmSizeKb
|
||||
}
|
||||
}
|
||||
return Pair(rssKb * 1024L, vmSizeKb * 1024L)
|
||||
}
|
||||
|
||||
private fun parseKb(line: String): Long? {
|
||||
val parts = line.split(Regex("\\s+"))
|
||||
if (parts.size < 2) return null
|
||||
return parts[1].toLongOrNull()
|
||||
}
|
||||
|
||||
private fun runAdbOutput(arguments: List<String>): String {
|
||||
return try {
|
||||
val process = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(false).start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
process.waitFor()
|
||||
output
|
||||
} catch (cause: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import dev.uatu.driver.v1.Image
|
||||
import dev.uatu.driver.v1.LaunchRequest
|
||||
import dev.uatu.driver.v1.LogEntries
|
||||
import dev.uatu.driver.v1.LogEntry
|
||||
import dev.uatu.driver.v1.MetricsRequest
|
||||
import dev.uatu.driver.v1.MetricsResponse
|
||||
import dev.uatu.driver.v1.Point
|
||||
import dev.uatu.driver.v1.PressKeyRequest
|
||||
import dev.uatu.driver.v1.RecentLogsRequest
|
||||
@@ -132,6 +134,18 @@ class DriverService(
|
||||
}
|
||||
}
|
||||
|
||||
override fun metrics(request: MetricsRequest, responseObserver: StreamObserver<MetricsResponse>) {
|
||||
runRpc(responseObserver) {
|
||||
val bundleId = if (request.bundleId.isNotEmpty()) request.bundleId else launchedBundleId.get().orEmpty()
|
||||
val sample = backend.metrics(bundleId)
|
||||
MetricsResponse.newBuilder()
|
||||
.setCpuPercent(sample.cpuPercent)
|
||||
.setHeapBytes(sample.heapBytes)
|
||||
.setTotalMemoryBytes(sample.totalMemoryBytes)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> runRpc(observer: StreamObserver<T>, block: () -> T) {
|
||||
try {
|
||||
observer.onNext(block())
|
||||
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@uatu/inspect-web",
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.6",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
|
||||
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
|
||||
|
||||
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@remix-run/router": ["@remix-run/[email protected]", "", {}, "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="],
|
||||
|
||||
"@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/prop-types": ["@types/[email protected]", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||
|
||||
"@types/react": ["@types/[email protected]", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
|
||||
|
||||
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ=="],
|
||||
|
||||
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"caniuse-lite": ["[email protected]", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
|
||||
|
||||
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"electron-to-chromium": ["[email protected]", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="],
|
||||
|
||||
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"loose-envify": ["[email protected]", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"node-releases": ["[email protected]", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
|
||||
|
||||
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
||||
|
||||
"react": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-dom": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
||||
|
||||
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"react-router": ["[email protected]", "", { "dependencies": { "@remix-run/router": "1.23.2" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw=="],
|
||||
|
||||
"react-router-dom": ["[email protected]", "", { "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag=="],
|
||||
|
||||
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="],
|
||||
|
||||
"scheduler": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>uatu inspect</title>
|
||||
<link rel="stylesheet" href="/src/styles/typography.css" />
|
||||
<link rel="stylesheet" href="/src/styles/tokens.css" />
|
||||
<link rel="stylesheet" href="/src/styles/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@uatu/inspect-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.6"
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
@@ -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 = "uatu-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 `uatu pbt` 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;
|
||||
uatu_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;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"useDefineForClassFields": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:8765",
|
||||
changeOrigin: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user