From 13bb2feb82946524cc4f3ce194974f82359d1bd7 Mon Sep 17 00:00:00 2001 From: pjay Date: Tue, 21 Apr 2026 11:32:17 +0700 Subject: [PATCH] feat: uatu inspect UI (web trace explorer) (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 /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 @ ' 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//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//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 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. --- .github/workflows/ci.yml | 13 + .gitignore | 18 +- Makefile | 45 +- README.md | 3 + cmd/uatu/inspect.go | 119 +++++ cmd/uatu/inspect_test.go | 45 ++ cmd/uatu/main.go | 7 + cmd/uatu/test_run.go | 11 +- docs/manual/cli.md | 13 +- docs/manual/inspect.md | 61 +++ go.mod | 1 + go.sum | 2 + internal/driver/driver.go | 11 + internal/driver/maestro/client.go | 12 + internal/driver/mock/mock.go | 12 + internal/hierarchy/hierarchy.go | 29 +- internal/inspect/assets.go | 20 + internal/inspect/dev.go | 20 + internal/inspect/dist/.gitkeep | 0 internal/inspect/dist/index.html | 13 + internal/inspect/runs.go | 429 ++++++++++++++++++ internal/inspect/runs_test.go | 218 +++++++++ internal/inspect/server.go | 281 ++++++++++++ internal/inspect/server_test.go | 315 +++++++++++++ internal/inspect/watcher.go | 147 ++++++ internal/inspect/watcher_test.go | 54 +++ internal/ltl/evaluator.go | 20 + internal/ltl/formula.go | 122 +++++ internal/ltl/formula_test.go | 73 +++ internal/runner/runner.go | 114 ++++- internal/runner/runner_test.go | 49 ++ internal/trace/writer.go | 75 ++- internal/trace/writer_test.go | 103 +++++ internal/verifier/worker.go | 16 + proto/driverpb/driver.pb.go | 153 ++++++- proto/driverpb/driver.proto | 11 + proto/driverpb/driver_grpc.pb.go | 38 ++ proto/driverpb/driver_test.go | 1 + .../kotlin/dev/uatu/sidecar/DriverBackend.kt | 135 +++++- .../kotlin/dev/uatu/sidecar/DriverService.kt | 14 + web/bun.lock | 256 +++++++++++ web/index.html | 15 + web/package.json | 23 + web/public/fonts/.gitkeep | 0 web/src/App.tsx | 32 ++ web/src/api.ts | 25 + web/src/components/ResidualNode.css | 59 +++ web/src/components/ResidualNode.tsx | 88 ++++ web/src/components/Tabs.css | 77 ++++ web/src/components/Tabs.tsx | 110 +++++ web/src/hooks/useKeyboardNav.ts | 76 ++++ web/src/hooks/useSse.ts | 18 + web/src/hooks/useStep.ts | 43 ++ web/src/hooks/useTheme.ts | 39 ++ web/src/main.tsx | 13 + web/src/panels/ActionList.css | 132 ++++++ web/src/panels/ActionList.tsx | 299 ++++++++++++ web/src/panels/ExceptionsPanel.css | 98 ++++ web/src/panels/ExceptionsPanel.tsx | 68 +++ web/src/panels/MetricsChart.css | 166 +++++++ web/src/panels/MetricsChart.tsx | 293 ++++++++++++ web/src/panels/Screenshot.css | 40 ++ web/src/panels/Screenshot.tsx | 164 +++++++ web/src/panels/SnapshotTable.css | 44 ++ web/src/panels/SnapshotTable.tsx | 164 +++++++ web/src/panels/Timeline.css | 40 ++ web/src/panels/Timeline.tsx | 142 ++++++ web/src/panels/ViolationsPanel.css | 117 +++++ web/src/panels/ViolationsPanel.tsx | 111 +++++ web/src/routes/RunDetail.tsx | 389 ++++++++++++++++ web/src/routes/RunList.tsx | 97 ++++ web/src/styles/app.css | 272 +++++++++++ web/src/styles/tokens.css | 46 ++ web/src/styles/typography.css | 32 ++ web/src/types.ts | 127 ++++++ web/tsconfig.json | 22 + web/vite.config.ts | 18 + 77 files changed, 6505 insertions(+), 73 deletions(-) create mode 100644 cmd/uatu/inspect.go create mode 100644 cmd/uatu/inspect_test.go create mode 100644 docs/manual/inspect.md create mode 100644 internal/inspect/assets.go create mode 100644 internal/inspect/dev.go create mode 100644 internal/inspect/dist/.gitkeep create mode 100644 internal/inspect/dist/index.html create mode 100644 internal/inspect/runs.go create mode 100644 internal/inspect/runs_test.go create mode 100644 internal/inspect/server.go create mode 100644 internal/inspect/server_test.go create mode 100644 internal/inspect/watcher.go create mode 100644 internal/inspect/watcher_test.go create mode 100644 web/bun.lock create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/public/fonts/.gitkeep create mode 100644 web/src/App.tsx create mode 100644 web/src/api.ts create mode 100644 web/src/components/ResidualNode.css create mode 100644 web/src/components/ResidualNode.tsx create mode 100644 web/src/components/Tabs.css create mode 100644 web/src/components/Tabs.tsx create mode 100644 web/src/hooks/useKeyboardNav.ts create mode 100644 web/src/hooks/useSse.ts create mode 100644 web/src/hooks/useStep.ts create mode 100644 web/src/hooks/useTheme.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/panels/ActionList.css create mode 100644 web/src/panels/ActionList.tsx create mode 100644 web/src/panels/ExceptionsPanel.css create mode 100644 web/src/panels/ExceptionsPanel.tsx create mode 100644 web/src/panels/MetricsChart.css create mode 100644 web/src/panels/MetricsChart.tsx create mode 100644 web/src/panels/Screenshot.css create mode 100644 web/src/panels/Screenshot.tsx create mode 100644 web/src/panels/SnapshotTable.css create mode 100644 web/src/panels/SnapshotTable.tsx create mode 100644 web/src/panels/Timeline.css create mode 100644 web/src/panels/Timeline.tsx create mode 100644 web/src/panels/ViolationsPanel.css create mode 100644 web/src/panels/ViolationsPanel.tsx create mode 100644 web/src/routes/RunDetail.tsx create mode 100644 web/src/routes/RunList.tsx create mode 100644 web/src/styles/app.css create mode 100644 web/src/styles/tokens.css create mode 100644 web/src/styles/typography.css create mode 100644 web/src/types.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12de457..ae3edbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 4ca7534..776cc05 100644 --- a/.gitignore +++ b/.gitignore @@ -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/* \ No newline at end of file +.claude/* + +# Personal research notes +/research/ \ No newline at end of file diff --git a/Makefile b/Makefile index 417cc97..0329f0f 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/README.md b/README.md index c55e14e..9d858f0 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/uatu/inspect.go b/cmd/uatu/inspect.go new file mode 100644 index 0000000..965a346 --- /dev/null +++ b/cmd/uatu/inspect.go @@ -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() +} diff --git a/cmd/uatu/inspect_test.go b/cmd/uatu/inspect_test.go new file mode 100644 index 0000000..2e4fbdd --- /dev/null +++ b/cmd/uatu/inspect_test.go @@ -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()) + } +} diff --git a/cmd/uatu/main.go b/cmd/uatu/main.go index 5c57697..90de4ec 100644 --- a/cmd/uatu/main.go +++ b/cmd/uatu/main.go @@ -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": diff --git a/cmd/uatu/test_run.go b/cmd/uatu/test_run.go index fc11dd9..5847af2 100644 --- a/cmd/uatu/test_run.go +++ b/cmd/uatu/test_run.go @@ -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, diff --git a/docs/manual/cli.md b/docs/manual/cli.md index 1e607ff..298d611 100644 --- a/docs/manual/cli.md +++ b/docs/manual/cli.md @@ -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). diff --git a/docs/manual/inspect.md b/docs/manual/inspect.md new file mode 100644 index 0000000..d1b701c --- /dev/null +++ b/docs/manual/inspect.md @@ -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//meta.json` and `runs//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 +``` diff --git a/go.mod b/go.mod index 49693ed..cba1918 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 89ba45d..4aa4194 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/driver/driver.go b/internal/driver/driver.go index 9b908b1..4e9e9b3 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -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 +} diff --git a/internal/driver/maestro/client.go b/internal/driver/maestro/client.go index 44cedb5..bfc7fd3 100644 --- a/internal/driver/maestro/client.go +++ b/internal/driver/maestro/client.go @@ -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) diff --git a/internal/driver/mock/mock.go b/internal/driver/mock/mock.go index 88bfa4a..afeab14 100644 --- a/internal/driver/mock/mock.go +++ b/internal/driver/mock/mock.go @@ -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) diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index 4881520..9bef7ed 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -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. diff --git a/internal/inspect/assets.go b/internal/inspect/assets.go new file mode 100644 index 0000000..43c35e0 --- /dev/null +++ b/internal/inspect/assets.go @@ -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 +} diff --git a/internal/inspect/dev.go b/internal/inspect/dev.go new file mode 100644 index 0000000..9d8b5ff --- /dev/null +++ b/internal/inspect/dev.go @@ -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 +} diff --git a/internal/inspect/dist/.gitkeep b/internal/inspect/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/inspect/dist/index.html b/internal/inspect/dist/index.html new file mode 100644 index 0000000..17663b0 --- /dev/null +++ b/internal/inspect/dist/index.html @@ -0,0 +1,13 @@ + + + + + + uatu inspect + + + + +
+ + diff --git a/internal/inspect/runs.go b/internal/inspect/runs.go new file mode 100644 index 0000000..ee8c809 --- /dev/null +++ b/internal/inspect/runs.go @@ -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 +} diff --git a/internal/inspect/runs_test.go b/internal/inspect/runs_test.go new file mode 100644 index 0000000..1fe1a10 --- /dev/null +++ b/internal/inspect/runs_test.go @@ -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 } diff --git a/internal/inspect/server.go b/internal/inspect/server.go new file mode 100644 index 0000000..85412d0 --- /dev/null +++ b/internal/inspect/server.go @@ -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 +} + diff --git a/internal/inspect/server_test.go b/internal/inspect/server_test.go new file mode 100644 index 0000000..cc2c9f5 --- /dev/null +++ b/internal/inspect/server_test.go @@ -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, "
") && !strings.Contains(body, "
") { + 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) + } +} diff --git a/internal/inspect/watcher.go b/internal/inspect/watcher.go new file mode 100644 index 0000000..1e3c302 --- /dev/null +++ b/internal/inspect/watcher.go @@ -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): + } + } +} diff --git a/internal/inspect/watcher_test.go b/internal/inspect/watcher_test.go new file mode 100644 index 0000000..570977f --- /dev/null +++ b/internal/inspect/watcher_test.go @@ -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") + } +} diff --git a/internal/ltl/evaluator.go b/internal/ltl/evaluator.go index 7b12a05..783df8c 100644 --- a/internal/ltl/evaluator.go +++ b/internal/ltl/evaluator.go @@ -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 diff --git a/internal/ltl/formula.go b/internal/ltl/formula.go index 337c377..11f99ec 100644 --- a/internal/ltl/formula.go +++ b/internal/ltl/formula.go @@ -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}) +} diff --git a/internal/ltl/formula_test.go b/internal/ltl/formula_test.go index dacb7aa..44cabc0 100644 --- a/internal/ltl/formula_test.go +++ b/internal/ltl/formula_test.go @@ -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) + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index e431e38..d276db4 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -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 diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 6c4d643..a7efb06 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -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 = ` + + +` + + 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`)}, diff --git a/internal/trace/writer.go b/internal/trace/writer.go index 1d33e83..26b7b46 100644 --- a/internal/trace/writer.go +++ b/internal/trace/writer.go @@ -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 { diff --git a/internal/trace/writer_test.go b/internal/trace/writer_test.go index f374b76..d18a25f 100644 --- a/internal/trace/writer_test.go +++ b/internal/trace/writer_test.go @@ -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) diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index 9d54bc6..6fdd389 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -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 diff --git a/proto/driverpb/driver.pb.go b/proto/driverpb/driver.pb.go index e2da014..445cf7e 100644 --- a/proto/driverpb/driver.pb.go +++ b/proto/driverpb/driver.pb.go @@ -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, }, diff --git a/proto/driverpb/driver.proto b/proto/driverpb/driver.proto index 5b47713..a6f014b 100644 --- a/proto/driverpb/driver.proto +++ b/proto/driverpb/driver.proto @@ -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; +} diff --git a/proto/driverpb/driver_grpc.pb.go b/proto/driverpb/driver_grpc.pb.go index d91c6d2..a7f9612 100644 --- a/proto/driverpb/driver_grpc.pb.go +++ b/proto/driverpb/driver_grpc.pb.go @@ -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", diff --git a/proto/driverpb/driver_test.go b/proto/driverpb/driver_test.go index cf37685..46f1aaf 100644 --- a/proto/driverpb/driver_test.go +++ b/proto/driverpb/driver_test.go @@ -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 { diff --git a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt index 7aad06e..7975482 100644 --- a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt @@ -13,8 +13,15 @@ interface DriverBackend { fun recentLogs(sinceUnixMillis: Long, minLevel: String): List 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 = Triple(ByteArray(0), 0, 0) + override fun screenshot(): Triple { + 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//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? { + // "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//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 { + 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 { + return try { + val process = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(false).start() + val output = process.inputStream.bufferedReader().readText() + process.waitFor() + output + } catch (cause: Exception) { + "" + } + } } diff --git a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverService.kt b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverService.kt index 3706baf..1c5b844 100644 --- a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverService.kt +++ b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverService.kt @@ -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) { + 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 runRpc(observer: StreamObserver, block: () -> T) { try { observer.onNext(block()) diff --git a/web/bun.lock b/web/bun.lock new file mode 100644 index 0000000..4c873b3 --- /dev/null +++ b/web/bun.lock @@ -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/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "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/generator@7.29.1", "", { "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/helper-compilation-targets@7.28.6", "", { "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/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "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/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "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/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "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/traverse@7.29.0", "", { "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/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@remix-run/router": ["@remix-run/router@1.23.2", "", {}, "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.2", "", { "os": "none", "cpu": "arm64" }, "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "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/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="], + + "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "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": ["baseline-browser-mapping@2.10.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ=="], + + "browserslist": ["browserslist@4.28.2", "", { "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": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="], + + "esbuild": ["esbuild@0.21.5", "", { "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": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "react-router": ["react-router@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw=="], + + "react-router-dom": ["react-router-dom@6.30.3", "", { "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": ["rollup@4.60.2", "", { "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": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "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": ["vite@5.4.21", "", { "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": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..1cd2f03 --- /dev/null +++ b/web/index.html @@ -0,0 +1,15 @@ + + + + + + uatu inspect + + + + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..c4158d8 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/public/fonts/.gitkeep b/web/public/fonts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..64ecaeb --- /dev/null +++ b/web/src/App.tsx @@ -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 ; + } + return ; +} + +function NotFound() { + return
not found
; +} + +export default function App() { + return ( + +
+
+ + } /> + } /> + } /> + } /> + +
+
+
+ ); +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..ba1e97a --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,25 @@ +import type { Run, RunSummary, Step } from "./types"; + +async function getJson(path: string): Promise { + 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 { + return getJson("/api/runs"); +} + +export function getRun(runId: string): Promise { + return getJson(`/api/runs/${encodeURIComponent(runId)}`); +} + +export function getStep(runId: string, index: number): Promise { + return getJson(`/api/runs/${encodeURIComponent(runId)}/steps/${index}`); +} + +export function screenshotUrl(runId: string, name: string): string { + return `/api/runs/${encodeURIComponent(runId)}/screenshots/${encodeURIComponent(name)}`; +} diff --git a/web/src/components/ResidualNode.css b/web/src/components/ResidualNode.css new file mode 100644 index 0000000..cbb3919 --- /dev/null +++ b/web/src/components/ResidualNode.css @@ -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; +} diff --git a/web/src/components/ResidualNode.tsx b/web/src/components/ResidualNode.tsx new file mode 100644 index 0000000..5f70577 --- /dev/null +++ b/web/src/components/ResidualNode.tsx @@ -0,0 +1,88 @@ +import type { ResidualNode } from "../types"; +import "./ResidualNode.css"; + +export interface ResidualNodeProps { + node: ResidualNode; +} + +function OperatorLabel({ children }: { children: string }) { + return {children}; +} + +function Wrapped({ node }: { node: ResidualNode }) { + return ( + + () + + ); +} + +function ResidualNodeView({ node }: ResidualNodeProps) { + switch (node.op) { + case "true": + return true; + case "false": + return false; + case "predicate": + return ( + + pred + {node.name ? <>({node.name}) : null} + + ); + case "error": + return ( + + error + {node.message} + + ); + case "always": + case "now": + case "next": + case "not": + return ( + + {node.op} + + + ); + case "eventually": + return ( + + eventually + {node.within ? ( + + within {node.within.amount} {node.within.unit} + + ) : null} + + + ); + case "and": + case "or": + return ( + + + {node.op} + + + ); + case "implies": + return ( + + + {"=>"} + + + ); + } +} + +export default function ResidualNodeRoot({ node }: ResidualNodeProps) { + return ( + + + + ); +} diff --git a/web/src/components/Tabs.css b/web/src/components/Tabs.css new file mode 100644 index 0000000..fea0f76 --- /dev/null +++ b/web/src/components/Tabs.css @@ -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; +} diff --git a/web/src/components/Tabs.tsx b/web/src/components/Tabs.tsx new file mode 100644 index 0000000..ec34bbf --- /dev/null +++ b/web/src/components/Tabs.tsx @@ -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(initial); + const buttonRefs = useRef>(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) => { + 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 ( +
+
+ {tabs.map((tab) => { + const isActive = tab.id === active.id; + return ( + + ); + })} +
+
+ {active.content} +
+
+ ); +} diff --git a/web/src/hooks/useKeyboardNav.ts b/web/src/hooks/useKeyboardNav.ts new file mode 100644 index 0000000..d7a2017 --- /dev/null +++ b/web/src/hooks/useKeyboardNav.ts @@ -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]); +} diff --git a/web/src/hooks/useSse.ts b/web/src/hooks/useSse.ts new file mode 100644 index 0000000..b65985c --- /dev/null +++ b/web/src/hooks/useSse.ts @@ -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]); +} diff --git a/web/src/hooks/useStep.ts b/web/src/hooks/useStep.ts new file mode 100644 index 0000000..a9e3ecf --- /dev/null +++ b/web/src/hooks/useStep.ts @@ -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; +} diff --git a/web/src/hooks/useTheme.ts b/web/src/hooks/useTheme.ts new file mode 100644 index 0000000..85cfc92 --- /dev/null +++ b/web/src/hooks/useTheme.ts @@ -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(() => 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")), + }; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..ccf070a --- /dev/null +++ b/web/src/main.tsx @@ -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( + + + , +); diff --git a/web/src/panels/ActionList.css b/web/src/panels/ActionList.css new file mode 100644 index 0000000..c322f97 --- /dev/null +++ b/web/src/panels/ActionList.css @@ -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; +} diff --git a/web/src/panels/ActionList.tsx b/web/src/panels/ActionList.tsx new file mode 100644 index 0000000..75b03bd --- /dev/null +++ b/web/src/panels/ActionList.tsx @@ -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 {`<${target}/>`}; + } + return {target}; +} + +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>(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, 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 ( +
    + {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 ( +
  1. { + 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} + > +
    + {step.index}. + + {verb} + {renderTarget(target, targetIsTag)} + + + {step.has_exceptions ? ( + + ) : null} + {step.has_violations ? ( + + ) : null} + + {elapsed} +
    + {showDetails ? ( +
    +
    + Position + + {positionText ?? ""} + +
    +
    + Content + + {contentText ? contentText : `""`} + +
    +
    + ) : null} +
  2. + ); + })} +
+ ); +} diff --git a/web/src/panels/ExceptionsPanel.css b/web/src/panels/ExceptionsPanel.css new file mode 100644 index 0000000..9c14419 --- /dev/null +++ b/web/src/panels/ExceptionsPanel.css @@ -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; +} diff --git a/web/src/panels/ExceptionsPanel.tsx b/web/src/panels/ExceptionsPanel.tsx new file mode 100644 index 0000000..4bb6153 --- /dev/null +++ b/web/src/panels/ExceptionsPanel.tsx @@ -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 ( +
+
+ +
+ {!exceptions || exceptions.length === 0 ? ( +
no exceptions
+ ) : ( +
    + {exceptions.map((exception, index) => ( +
  1. +
    + + + {exception.stack_trace ? ( +
    {exception.stack_trace}
    + ) : null} +
    +
  2. + ))} +
+ )} +
+ ); +} diff --git a/web/src/panels/MetricsChart.css b/web/src/panels/MetricsChart.css new file mode 100644 index 0000000..e626945 --- /dev/null +++ b/web/src/panels/MetricsChart.css @@ -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; +} diff --git a/web/src/panels/MetricsChart.tsx b/web/src/panels/MetricsChart.tsx new file mode 100644 index 0000000..730ebe8 --- /dev/null +++ b/web/src/panels/MetricsChart.tsx @@ -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 ( +
+
{label}
+
+ {selectedFraction !== null ? ( +
+ ) : null} +
+ + {dots.map((dot) => ( +
+ ))} + {hover ? ( +
+ {hover.label} +
+ ) : null} +
+
+
+ {topTickLabel} + {bottomTickLabel} +
+
+ ); +} + +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(null); + + if (samples.length === 0 || !samples.some((sample) => sample.metrics !== undefined)) { + return
no metrics
; + } + + 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 ( +
+
+ + +
+ {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 ( +
+
+
+ {axisTicks.map((tick) => ( + + {tick.label} + + ))} +
+
+ ); +} diff --git a/web/src/panels/Screenshot.css b/web/src/panels/Screenshot.css new file mode 100644 index 0000000..8e18cb9 --- /dev/null +++ b/web/src/panels/Screenshot.css @@ -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; +} diff --git a/web/src/panels/Screenshot.tsx b/web/src/panels/Screenshot.tsx new file mode 100644 index 0000000..9222633 --- /dev/null +++ b/web/src/panels/Screenshot.tsx @@ -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(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 ( +
+ no screenshot +
+ ); + } + + 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 ( +
+ device screenshot { + const img = event.currentTarget; + if (img.naturalWidth > 0 && img.naturalHeight > 0) { + setNaturalSize({ width: img.naturalWidth, height: img.naturalHeight }); + } + }} + onError={() => setLoadFailed(true)} + /> + +
+ ); +} + +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 ( + + + + + ); +} diff --git a/web/src/panels/SnapshotTable.css b/web/src/panels/SnapshotTable.css new file mode 100644 index 0000000..cb9823e --- /dev/null +++ b/web/src/panels/SnapshotTable.css @@ -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; +} diff --git a/web/src/panels/SnapshotTable.tsx b/web/src/panels/SnapshotTable.tsx new file mode 100644 index 0000000..7848316 --- /dev/null +++ b/web/src/panels/SnapshotTable.tsx @@ -0,0 +1,164 @@ +import "./SnapshotTable.css"; + +export interface SnapshotTableProps { + snapshots?: Record; + previousSnapshots?: Record; +} + +interface Row { + path: string; + value: unknown; +} + +const INLINE_ARRAY_LIMIT = 2; + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +function flatten(input: Record): 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 | 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)[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 = {}; + 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
no snapshots
; + } + + 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 ( +
+ {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 = {}; + if (changed) { + rowProps["data-changed"] = "true"; + rowProps.title = `was: ${previousFormatted}`; + } + return ( +
+
+ {row.path} +
+
+ {formatted} +
+
+ ); + })} +
+ ); +} diff --git a/web/src/panels/Timeline.css b/web/src/panels/Timeline.css new file mode 100644 index 0000000..dac64a0 --- /dev/null +++ b/web/src/panels/Timeline.css @@ -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; +} diff --git a/web/src/panels/Timeline.tsx b/web/src/panels/Timeline.tsx new file mode 100644 index 0000000..f95d729 --- /dev/null +++ b/web/src/panels/Timeline.tsx @@ -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 = { + 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
no timeline data
; + } + + 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, 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 ( + + {lanes.map((lane, laneIndex) => { + const rowY = laneIndex * LANE_HEIGHT; + return ( + + + {lane.name} + + {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 ( + onSelect(step.index)} + onKeyDown={(event) => handleKeyDown(event, step.index)} + > + {`step ${step.index}: ${status}`} + + ); + })} + + ); + })} + + {steps.map((step, stepCol) => { + const cx = LABEL_WIDTH + stepCol * cellWidth + cellWidth / 2; + const cy = lanes.length * LANE_HEIGHT + LANE_HEIGHT / 2; + return ( + onSelect(step.index)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(step.index); + } + }} + > + {`step ${step.index}`} + + ); + })} + + {highlightX !== null ? ( + + ) : null} + + ); +} diff --git a/web/src/panels/ViolationsPanel.css b/web/src/panels/ViolationsPanel.css new file mode 100644 index 0000000..0b3c731 --- /dev/null +++ b/web/src/panels/ViolationsPanel.css @@ -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; +} diff --git a/web/src/panels/ViolationsPanel.tsx b/web/src/panels/ViolationsPanel.tsx new file mode 100644 index 0000000..b2d457c --- /dev/null +++ b/web/src/panels/ViolationsPanel.tsx @@ -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; + 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 = { + violated: 0, + pending: 1, + holds: 2, +}; + +function statusFor( + name: string, + violations: Set, + residuals?: Record, +): 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
no violations
; + } + + return ( +
+ {violationsOnly ? null : ( +
+

properties

+ +
+ )} +
    + {rows.map(({ name, status }) => { + const residual = residuals?.[name]; + return ( +
  • +
    + + {status} + + {name} +
    + {residual ? ( +
    + residual + +
    + ) : null} +
  • + ); + })} +
+
+ ); +} diff --git a/web/src/routes/RunDetail.tsx b/web/src/routes/RunDetail.tsx new file mode 100644 index 0000000..39a08fb --- /dev/null +++ b/web/src/routes/RunDetail.tsx @@ -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(null); + const [history, setHistory] = useState(null); + const [error, setError] = useState(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
failed: {error}
; + } + if (!run) { + return
loading run...
; + } + + 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: , + }, + { + id: "snapshots", + label: "Snapshots", + content: ( + + ), + }, + { + id: "properties", + label: "Properties", + content: ( + + ), + }, + { + id: "violations", + label: "Violations", + badge: + violationsBefore.length > 0 ? ( + + {violationsBefore.length} + + ) : undefined, + content: ( + + ), + }, + ]; + + const afterTabs: TabDefinition[] = [ + { + id: "screenshot", + label: "Screenshot", + content: , + }, + { + id: "snapshots", + label: "Snapshots", + content: ( + + ), + }, + { + id: "properties", + label: "Properties", + content: ( + + ), + }, + { + id: "violations", + label: "Violations", + badge: + violationsAfter.length > 0 ? ( + + {violationsAfter.length} + + ) : undefined, + content: ( + + ), + }, + ]; + + return ( +
+
+
+ runs + {run.id} + + {run.spec_path} seed={run.seed} + + + step {stepIndex} / {stepCount ?? 0} + +
+ +
+
+ + +
+

state before

+
+ +
+
+ +
+

state after

+
+ +
+
+ +
+
+ + {(exceptionsForStep && exceptionsForStep.length > 0) || + history?.firstExceptionStep !== undefined ? ( +
+ +
+ ) : null} +
+
+
+
+ ); +} + +async function loadHistory(run: Run): Promise { + 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(); + 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); + }); +} diff --git a/web/src/routes/RunList.tsx b/web/src/routes/RunList.tsx new file mode 100644 index 0000000..d74b75d --- /dev/null +++ b/web/src/routes/RunList.tsx @@ -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(null); + const [error, setError] = useState(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
failed to load runs: {error}
; + } + if (!runs) { + return
loading runs...
; + } + if (runs.length === 0) { + return
no runs yet. run `uatu pbt` to produce some.
; + } + + return ( + + + + + + + + + + + + + + + {runs.map((run) => ( + + + + + + + + + + + ))} + +
startedspecseedplatformdurationstepsviolations
+ {formatStartedAt(run.started_at)} + {run.spec_path}{run.seed}{run.platform}{formatDuration(run.duration_millis)}{run.step_count} + {run.violation_count > 0 ? ( + {run.violation_count} + ) : ( + "0" + )} + {run.in_progress ? in progress : null}
+ ); +} diff --git a/web/src/styles/app.css b/web/src/styles/app.css new file mode 100644 index 0000000..180e76e --- /dev/null +++ b/web/src/styles/app.css @@ -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); +} diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css new file mode 100644 index 0000000..036c58f --- /dev/null +++ b/web/src/styles/tokens.css @@ -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); +} diff --git a/web/src/styles/typography.css b/web/src/styles/typography.css new file mode 100644 index 0000000..2060768 --- /dev/null +++ b/web/src/styles/typography.css @@ -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; +} diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..b5990df --- /dev/null +++ b/web/src/types.ts @@ -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; + action?: Action; + exceptions?: Exception[]; + violations?: string[]; + hierarchy?: Hierarchy; + residuals?: Record; + metrics?: Metrics; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..23d1251 --- /dev/null +++ b/web/tsconfig.json @@ -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"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..cbef7fd --- /dev/null +++ b/web/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, + }, +});