* feat(hierarchy): add Node tree + path query support
Preserve parent-child relationships in a Node tree at parse time.
Extend Find/FindAll with " > " path operator for scoped queries,
e.g. id:LoginScreen > desc:EmailInput.
Flat Elements slice and all public signatures unchanged.
* test(hierarchy): add path query tests
Cover single-level, multi-level, mixed-type, not-found, wrong-subtree,
and FindAll across multiple roots.
* feat(folio): add modifier param to Screen composable
* feat(folio): testTag LoginScreen
* feat(folio): testTag HomeScreen
* feat(folio): testTag AddAccountScreen
* feat(folio): testTag LedgerScreen
* feat(folio): testTag AddTransactionScreen
* feat(folio): scope spec selectors to screen testTags via path queries
* fix(sidecar): align grpc-netty/grpc-okhttp with grpc-core version
Maestro 1.40.0 brings grpc-netty:1.50.2 which compiled against
AbstractManagedChannelImplBuilder, removed in grpc-core 1.64+.
Gradle was upgrading grpc-core to 1.68.0 while leaving grpc-netty at
1.50.2, causing NoClassDefFoundError at startup.
Force grpc-netty and grpc-okhttp to match grpcVersion so all gRPC
artifacts are binary-compatible.
* fix(sidecar): use ephemeral host port for AndroidDriver
Hard-coded port 7001 caused TcpForwarder.start() to fail with
TimeoutException when a previous sidecar process held the port open.
Pick a free port via ServerSocket(0) instead.
* chore(sidecar): bump maestro to 2.4.0, exclude graalvm from fat JAR
Maestro 2.4.0 still ships grpc-netty:1.50.2 so the grpc transport
resolution strategy is kept. GraalVM JS is excluded: unused by our
sidecar and causes shadow JAR expansion errors (pom treated as zip).
* fix(sidecar): update AndroidDriver calls for Maestro 2.4.0 API
launchApp no longer accepts a UUID argument. Third constructor param
changed from hostname to emulatorName so drop the "localhost" value.
* feat(ios): add simulator management package
* feat(testrun): add iOS platform path (simctl launch + direct TCP)
* feat(cli): add --ios-device flag and IosDevice option
* feat(sdk-ios): add Kotlin Native iOS SDK (TCP agent + POSIX socket + dispatch pauser)
* feat(folio-ios): wire SanderlingIos.start() in MainViewController
* feat(folio-ios): add test-ios justfile recipe
* fix(sdk-ios): remove unavailable C macros; manual byte swap + no-cast warnings
* fix(testrun): simctl-first launch order for iOS; Maestro init after SDK connects
* feat(proto): add env map to LaunchRequest
* feat(driver): add env param to Launch interface + all implementations
* feat(testrun): launch iOS app via XCTest with env vars instead of simctl
* feat(sidecar): add IosDriverBackend using Maestro IOSDriver + env pass-through
* feat(sidecar): wire env map in DriverService + IosDriverBackend in Main
* fix(sidecar): use LocalIOSDevice (WDA+simctl) + stop before relaunch
* fix(sidecar): include exception type in gRPC error description
* fix(sidecar): pick free WDA port instead of hardcoded 9100
Use SocketUtils.nextFreePort to pick a free port in the 22000-23000 range
rather than hardcoding 9100, which only worked if a previous WDA session
left a listener there.
* fix(sdk-ios): check semaphore wait result and throw on snapshot timeout
dispatch_semaphore_wait returns nonzero on timeout; ignoring the return
value caused pauseAndSnapshot to silently return an empty map, sending a
garbage empty STATE frame to the host. Now throws so the agent loop
reconnects instead.
* fix(folio-ios): register snapshot extractors before starting agent
SanderlingIos.start() was called before the snapshot objects were
initialized, so a PAUSE message arriving early produced an empty snapshot.
Move start() to after all extractors are registered.
* chore(ios): remove dead LaunchApp function
LaunchApp had no callers since bff3a49 switched iOS launch to go through
the sidecar driver. Remove the dead code and unused os import.
* test(ios): add unit tests for pickSimulator and iOS flag parsing
Tests for all pickSimulator branches (by name, by UDID, unknown, empty
list, iPhone preference, fallback to first). Also tests BootedUDID on a
canceled context and verifies --platform ios and --ios-device flags are
accepted by parseTestArgs.
* fix(ios): propagate error from BootedUDID instead of silently swallowing
* fix(sidecar): IosDriverBackend.healthy() returns true; WDA liveness checked in open()
* fix(sidecar): warm up WDA after health check to absorb startup race
* fix(runner): surface clear message on WDA connection drop
* docs(testrun): note WDA warmup location above WaitForHealth
* fix(sidecar): extract warmup + add one-shot WDA reconnect on IOException
* fix(runner): fatal on permanent WDA drop during hierarchy fetch
* fix(sidecar): walk cause chain in withReconnect to catch Maestro-wrapped IOException
* fix(sidecar): explicit Unit return in pressKey and waitForIdle withReconnect lambdas
* fix(sidecar): serialize WDA reconnect with ReentrantLock to prevent concurrent xcodebuild races
* fix web examples package config
* Revert "fix web examples package config"
This reverts commit 70c10ade27.
* chore: gitignore built sanderling binary
* fix(hierarchy): parse iOS [x1,y1][x2,y2] bounds + match iOS merged desc labels
* refactor(folio-spec): replace bloated spec with two focused properties
Login is opportunistic. Two concrete properties:
1. every new account starts with balance 0
2. every new txn changes ledger balance by exactly its signed amount
Actions: directed login -> addAccount -> addTxn -> back weighted flow.
* feat(proto): add env map to LaunchRequest
* feat(driver): add env param to Launch interface and all implementations
* feat(sidecar): add IosDriverBackend using Maestro IOSDriver + env pass-through
* feat(testrun): launch iOS app via XCTest with env vars instead of simctl
* fix(ios): replace LaunchApp with BootedUDID; simctl launch moved to XCTest path
* test(cli): add tests for ios platform flag and ios-device flag parsing
* fix(sdk-ios): check semaphore wait result; resolve port from args and env; register extractors before start
* chore(docs): replace d2 diagram pipeline with mermaid
Remove docs/_diagrams/ and d2 build step from Makefile. The HTML
template already initialises mermaid.js; diagrams are now inline
code fences rendered client-side.
* docs(architecture): add mermaid diagram + web/CDP platform docs
Replace SVG img tag with inline mermaid flowchart showing both native
(Maestro sidecar + in-app SDK) and web (Chrome CDP) paths. Update
Processes, Transports table, and per-step cycle sections.
* docs(design-principles): update principles 1-4 for web platform
Principles 1, 2, 3, and 4 referenced Maestro and native-only concepts.
Add web/CDP context and update driver-is-an-interface to name both
sidecar and chrome implementations.
* docs(manual): add web prerequisites and folio-web example
Update --platform flag to list android, ios, web. Add web prerequisites
section (Chrome, no SDK needed) and folio-web quick-start to
getting-started.
* chore(gitignore): untrack inspect dist/index.html build artifact
index.html is regenerated by vite on every build with a new content hash,
making it permanently dirty. Only .gitkeep is needed for //go:embed to
compile on a fresh checkout. Also remove duplicate dist/* line and stale
d2 diagram ignore entries.
* feat(docs): click-to-zoom for mermaid diagrams
* docs(architecture): change diagram layout from LR to TB
* docs(getting-started): link npm and Maven Central package headers
* update docs root
* docs(spec): rewrite npm package README
Update usage example to current API, drop stale version-compatibility
and license sections.
* build(docs): output pages as pagename/index.html for clean URLs
Split DOCS_OUT into INDEX_OUT (index.md files stay as index.html) and
PAGE_OUT (all other pages become pagename/index.html). The __ROOT__
depth computation already handles the extra directory level correctly.
* chore(docs): update sidebar links to directory-style URLs
* docs: update cross-links from .html to directory-style paths
* ci(docs): remove d2 install step
* feat(docs): dark/light mode toggle
Add theme toggle button (top-right, fixed). Persists preference in
localStorage; falls back to prefers-color-scheme. Flash-free via inline
script in <head> that sets data-theme before first paint.
* fix(docs): fix inspect image path broken by directory URL restructure
* feat(docs): click-to-fullscreen for all article images
* fix(inspect): allow AssetsFS override in ServerOptions; drop unused request param from serveIndex
* fix(inspect): use in-memory FS in tests so TestAssets_FallbackToIndexHTML passes without web build
* fix(runner): allow nil connection for web platform
* fix(testrun): skip SDK handshake for web platform
* feat(folio-web): add React/Vite web sample app
* feat(folio-web): add sanderling spec
* fix(chrome): use InsertText for multi-char text input
* feat(hierarchy): add Screen field populated from sanderling-screen attr
* fix(chrome): auto-detect viewport from CSS vars, fix InputText accumulation, expose route as screen
* fix(runner): fall back to hierarchy root screen when snapshot screen is empty
* fix(folio-web): broaden loggedIn extractor to all authenticated pages
* proto(driver): drop launcher_activity from LaunchRequest
* refactor(driver): rename Driver to DeviceDriver, drop launcherActivity from Launch
* refactor(driver): rename package maestro to sidecar
* feat(driver): add ChromeDriver backed by chromedp
* feat(hierarchy): replace XML parser with TreeNode JSON parser
* refactor(driver): update mock and runner to DeviceDriver, drop launcherActivity
* feat(runner): add platform routing for web vs sidecar
* test(verifier): update hierarchy fixtures from XML to TreeNode JSON
* feat(sidecar): extract readLogcat/readProcMetrics; add MaestroDriverBackend
- Extract readLogcat() and readProcMetrics() as internal package-level
helpers parameterised by serial
- Add MaestroDriverBackend wiring maestro-client AndroidDriver
- Drop launcherActivity from DriverBackend interface and StubDriverBackend
- Add maestro-utils and micrometer-core as explicit compile deps
* refactor(sidecar): inject DriverBackend into DriverService; drop launcherActivity
- Remove serial and default backend from DriverService constructor
- Make backend a required parameter
- Drop launcherActivity from launch RPC handler
- Create MaestroDriverBackend in Main.kt when platform is android
* test(sidecar): update tests for dropped launcherActivity and required backend
* chore(sidecar): remove unnecessary micrometer-core direct dependency
* perf(sidecar): use exec-out + tmpfs for hierarchy dump
Avoids FUSE overhead on /sdcard and shell startup cost by using
exec-out with /data/local/tmp. Saves ~100ms per hierarchy fetch.
* perf(sidecar): replace Thread.sleep waitForIdle with real idle detection
Poll `dumpsys window -a` for mAnimating=true every 50ms instead of
blindly sleeping. Breaks early when device is idle, saving 500-800ms
per step since most settle in <200ms after an action.
* test(sidecar): add idle detection parsing tests
* perf(runner): parallelize hierarchy, metrics, and logs fetch
Run fetchHierarchy, captureMetrics, and collectLogs concurrently via
errgroup so metrics+logs (~150ms) hide behind the hierarchy fetch
(~2s) instead of running serially.
* perf(runner): pipeline post-action screenshot with next step
Defer the post-action screenshot from step N and run it concurrently
with step N+1's hierarchy/metrics/logs fetch. Saves ~335ms per step
by hiding screenshot latency behind the hierarchy fetch.
* test(runner): add tests for parallel fetch and pipelined screenshots
Verify that hierarchy, metrics, and logs are all called per step.
Verify post-action screenshots are written with correct step indices
when pipelined, including the final flush after the loop.
* perf(sidecar): grep mAnimating on-device instead of pulling full dump
The full `dumpsys window -a` output is ~88KB per poll. Running
grep on-device transfers only a count byte, cutting per-poll
overhead from ~63ms to ~56ms and eliminating 88KB of ADB transfer.
* test(folio): replace tautological properties with 4 domain invariants
Drop properties that can't fail (e.g. List.size >= 0, balances are Long
integers) or that just check the extractor itself (account_count equals
accounts.length). Keep auth routing liveness, error-clear liveness, and
reachability goals.
Add four properties that target the real code paths:
- balanceMatchesTransactionDelta: a new ledger row shifts the balance
by exactly its signed amount (credit +, debit -).
- totalEqualsSumOfAccounts: home total equals sum of per-account
balances at every state, not only on home.
- balanceChangeRequiresActiveAccount: an account's balance can only
change while that account is the active one in the navigator.
- duplicateAccountNamesRejected: account names are unique under the
app's actual dedup rule (case-insensitive after trim).
* chore(folio): upgrade sdk-android to io.github.priyanshujain.sanderling:0.0.1-rc4
Group id moved from io.github.priyanshujain to
io.github.priyanshujain.sanderling in the rc4 publish.
* chore(folio): pin @sanderling/spec to 0.0.1-rc4
* refactor(sdk-android): publish under io.github.priyanshujain.sanderling
Move the published Maven coordinates to a sanderling sub-namespace so the
brand is visible in the dependency line (was io.github.priyanshujain:sdk-android).
Sub-namespace is auto-allowed by Sonatype under the verified parent groupId.
* chore: update sdk-android coordinates in folio + docs
Follow the groupId change to io.github.priyanshujain.sanderling:sdk-android.
* ci(docs): use --version flag for d2 install script
The d2 installer accepts --version vX.Y.Z, not --tag. The --tag form
was rejected as "unrecognized flag" on the docs workflow run after
PR #26 merged.
* docs: add sanderling bird artwork to README and docs index
* chore: add one-line install script for macOS and Linux
Detects os/arch, resolves latest (or pre-)release, verifies sha256,
and installs the binary into $HOME/.sanderling/bin.
* docs: use the one-line installer in getting-started
Replaces the broken `<version>` placeholder snippets with the
install.sh one-liner.
* docs: drop filler line under the install one-liner
* docs: rename index heading to Sanderling Manual
* docs(style): adopt JetBrains Mono and uppercase brand mark
* docs(getting-started): use justfile flow for folio sample
* docs(writing-specs): drop 'coming soon' notes for eventually and implies
* docs(inspect): rewrite layout for tabbed state panels and metrics chart
* docs(inspect): add UI screenshot
* docs: add Inspect to sidebar and index
* docs: restore mermaid bootstrap script in page template
* docs(style): constrain article images to content width
* docs(inspect): drop layout prose, keep what the screenshot doesn't show
* docs(architecture): add d2 source for architecture diagram
Replaces the in-page mermaid block with a d2-rendered SVG. Generated
outputs (svg/png) stay out of git; only the .d2 source is checked in.
* build(docs): render d2 diagrams into build/site/_assets/diagrams
* docs(architecture): swap mermaid block for rendered d2 svg
* ci(docs): install d2 before building the site
* docs(architecture): tighten layout and reroute label-crossing edges
Flip device/sidecar order so trace writer drops cleanly to runs/
without cutting through the JVM cell, right-align the inspect row
via a pad column, and tune grid gaps to keep gRPC and Unix socket
labels off the SANDERLING boundary.
* feat(trace): extend Step/Action/Meta schema for inspect UI
Add Step.Hierarchy, Step.Residuals, Action.Selector/ResolvedBounds/TapPoint,
Meta.EndedAt and JSON tags on hierarchy.Element/Bounds/Tree so trace.jsonl
can drive the upcoming uatu inspect web UI.
* test(trace): cover EndedAt + new step fields round-trip
* feat(ltl): MarshalJSON for Formula AST + Evaluator.Residual()
Each Formula concrete type now serializes to a closed-set residual node
(true/false/not/and/or/implies/always/now/next/eventually/predicate/error)
that mirrors the TS spec API surface. Evaluator.Residual() folds pending
obligations into a single Formula so the runner can stamp one residual
per property per step into trace.jsonl.
* feat(runner): stamp residuals, hierarchy, selector targets, ended_at
Each Step now carries the captured hierarchy, per-property residual ASTs,
and (for Tap/InputText) the selector + resolved bounds + tap point. The
test_run command writes meta.ended_at on graceful shutdown so the inspect
UI can distinguish completed runs from in-progress ones.
* feat(inspect): scaffold embed dist for SPA assets
Stage 2 stub for the inspect server. Real web bundle gets wired in
Stage 4 (Makefile copies web/dist into internal/inspect/dist).
* chore(web): ignore web/ build output in root .gitignore
* chore(web): add bun + vite + vitest scaffold config
* feat(web): monochrome design tokens, typography, app shell CSS
* chore(web): placeholder for self-hosted JetBrains Mono fonts
* feat(web): index.html entry with style links and root mount
* feat(web): typescript types mirroring run/step trace schema
* feat(web): typed fetchers for runs/steps/screenshots
* feat(web): App shell with router and run/step routes
* feat(inspect): runs scan, lazy step parse, mtime-aware cache
* feat(web): RunList route with table, loading, and error states
* feat(web): RunDetail route shell with three placeholder panels
* feat(inspect): fsnotify-backed runs watcher with debounce
* fix(web): use jest-dom/vitest entry so matchers register
* test(web): cover listRuns happy path and error response
* test(web): render RunList with mocked fetch and assert row
* chore(web): commit bun lockfile
* feat(inspect): http handlers for runs/steps/screenshots/SSE
* test(inspect): cover handlers, screenshot whitelist, SSE, dev proxy
* feat(cmd): add 'uatu inspect' subcommand
* fix(web): align TS types with snake_case wire format
Go inspect server serializes RunSummary, StepSummary, Step, Meta with
snake_case JSON tags (matching the on-disk trace.jsonl/meta.json). Update
the TS types and consumers to match so API responses parse without
runtime undefined fields. Action keeps resolvedBounds/tapPoint as camelCase
because those keys were defined that way in the trace schema.
* feat(web): add ActionList panel for run-detail step navigation
* feat(web): add SnapshotTable panel with diff highlighting
Renders snapshots dictionary as a flat sorted dotted-path tree.
Changed leaves get data-changed plus a hover title with the previous value.
* test(web): cover SnapshotTable rendering and diff behavior
Eight cases: empty state, sort order, dotted-path expansion,
changed/unchanged/missing-previous flagging, and inline-vs-expanded arrays.
* feat(web): add Screenshot panel with bounds and tap overlays
Center column of run-detail page. Renders the device screenshot
scaled to fit, with an SVG overlay drawing resolvedBounds as a
violation-colored rect, tapPoint as a contrast ring, and swipes
as an arrow. Falls back to a placeholder when src is missing or
the image fails to load.
* fix(web): guard scrollIntoView call for jsdom compatibility
* test(web): cover Screenshot panel rendering and overlays
* test(web): cover ActionList rendering, selection, keyboard, and markers
* feat(web): add ExceptionsPanel component
* test(web): add ExceptionsPanel tests
* feat(web): add Timeline panel with property swimlanes
Renders SVG swimlanes per property with violated/pending/holds cells,
action-marker dots, click-to-seek, and a selected-step highlight bar.
* test(web): cover Timeline empty state, cells, status, click, highlight
* feat(web): add ResidualNode recursive AST renderer
* test(web): cover ResidualNode operators, predicate, and error chip
* feat(web): add ViolationsPanel with status badges and jump button
* test(web): cover ViolationsPanel rows, status grouping, and jump button
* test(web): register testing-library cleanup globally
All six panel test files added local afterEach(cleanup); centralize it in
the shared setup so future tests inherit DOM isolation by default.
* feat(web): hooks for url/keyboard/theme/sse
* feat(web): wire all panels into run-detail with phone-dominant grid
ActionList left, Screenshot center, Snapshots/Properties/Exceptions
stacked right, Timeline bottom. URL-synced step index (useStep), keyboard
shortcuts (j/k/arrows/g/G/.), light+dark theme toggle stored in
localStorage, SSE auto-refresh on the run index.
* test(web): add three reference run fixtures (clean, violation, exception)
* build: web targets in Makefile + bun in CI; docs(inspect)
- Makefile: web-build/web-dev/inspect-dev/test-web targets; uatu and
install now depend on web-build so the binary embeds the latest SPA.
- ci.yml: setup-bun + cache; existing make test now runs web typecheck +
vitest as part of the full suite.
- docs/manual/inspect.md: panel reference, keyboard shortcuts, URLs.
- docs/manual/cli.md: document uatu inspect.
- README: link to inspect docs.
* feat(runner): capture a screenshot per step
The driver already exposes Screenshot(ctx), but the runner never called
it. Each step now writes <run>/screenshots/step-NNNNN.png right after
the trace line, using the same failure-is-a-warning posture as other
best-effort observability hooks. Makes the inspect UI's center panel
actually useful.
* feat(inspect): include action_label in StepSummary
Tap/InputText/Swipe/PressKey/Wait each get a short human-readable
label (selector, quoted text, swipe direction, key name, duration) so
the action list panel can render readable rows instead of just 'Tap'
with no target.
* test(inspect): accept either #app or #root in SPA shell fallback
* feat(web): render action_label and screen in ActionList rows
Step rows now show 'Tap id:save', 'InputText "alice"', 'Swipe up',
'PressKey back', etc. Steps with no action fall back to
'observe @ <screen>' so the list reads as a flow instead of a wall
of '--' placeholders.
* feat(sidecar): implement screencap for android driver backend
Was stubbed to return an empty byte array, which made the runner's
per-step screenshot capture a no-op. Shell out to 'adb exec-out
screencap -p' and stream the PNG bytes back. Width/height stay zero
because the PNG header carries them; the Go side can parse if needed.
* feat(proto): add Metrics RPC for per-step CPU and memory capture
* feat(driver): Metrics(bundleID) returns cpu_percent + heap/total bytes
* feat(sidecar): implement Metrics RPC via adb top + /proc/<pid>/status
* feat(runner): capture metrics + before/after screenshots per step
Each step now writes step-NNNNN.png (before applyAction) and
step-NNNNN-after.png (after the action + wait-for-idle). The runner
samples Driver.Metrics(bundleID) before writing the trace line and
stamps Step.Metrics with cpu_percent, heap_bytes, total_memory_bytes
so the inspect UI can chart CPU and heap over the run.
* fix(runner,sidecar): measure CPU across step via /proc stat delta
'top -d 0.3 -n 2' measures CPU in a 300ms window that coincides with
the SDK-paused app, always reporting 0%. Switch to reading
/proc/<pid>/stat utime+stime and computing the delta between successive
calls; the natural step cadence gives a 2-5s measurement window that
captures the action response and render cycle. Also moved the sample
to before snapshotStep so the delta starts before the SDK pause.
* feat(web): add Metrics type for per-step cpu and memory
* refactor(web): replace --accent-change with --accent-positive token
* refactor(web): recolor chip-progress as neutral outlined chip
* refactor(web): use neutral border for changed snapshot rows
* feat(web): add MetricsChart panel with HEAP and CPU lanes
SVG-based time-series chart rendering heap bytes and CPU percent per
step across two stacked lanes, with a shared step axis below. Lines are
monochrome; a vertical highlight marks the selected step; per-step hit
rects make any click seek to that step.
* feat(web): revamp ActionList with tag targets, elapsed time, and expandable rows
Render selector-based Tap actions as <tag/> markup, show zero-padded MM:SS.mmm
elapsed time per row, and expand the active row with Position/Content sub-rows
when a full Step is available. Adds formatActionRow/formatElapsed helpers and
covers both with unit tests.
* fix(runner): stop copying Tap selector into action.text
The 'Content' inspect row should show the user-supplied text for
InputText actions and stay empty for Taps. Previously the runner copied
action.On into traceAction.Text for both, so the inspect UI showed the
selector as the tap's 'Content'.
* fix(web): use text-muted for swipe arrow after accent-change removal
* fix(web): snapshot values truncate with ellipsis + title tooltip
Long JSON values were breaking one character per line due to
overflow-wrap:anywhere in a narrow column. Switch to single-line ellipsis
with the full value exposed via the title attribute on hover.
* feat(web): state-before/after columns + metrics chart at bottom
RunDetail now renders a four-column grid:
actions | state-before | state-after | side (exceptions + timeline)
with MetricsChart spanning the bottom row. Each state column shows its
own screenshot (step-NNNNN.png vs step-NNNNN-after.png), snapshot table,
and violations panel. ActionList now receives runStartMillis and the
selected Step so the active row can expand Position/Content sub-rows.
* fix(web): skip zero-value ticks + add exception markers to metrics
HEAP '0B' and CPU '100%' labels overlapped at the lane boundary. Drop
the bottom-of-range tick on both lanes (baseline is implied) and widen
LANE_GAP so the remaining labels have breathing room. Accept an
exceptionStepIndices prop and draw a dashed red vertical line at each
to surface exception spikes directly on the CPU/heap chart.
* fix(web): let action body column shrink below its content
Required minmax(0, 1fr) so the row grid honours the column's min-size of
0 instead of the implicit 'auto', preventing the action-list from
overflowing its parent when the target string is long.
* feat(web): bigger state screenshots + single properties row
Collapse snapshots into a summary chip ('SNAPSHOTS · N violations') so
the screenshot fills its state card. Deduplicate ViolationsPanel —
show it once in a new full-width 'properties' row between the state
cards and the timeline. Drop the right sidebar; exceptions now surface
as dashed markers on the metrics chart with the ExceptionsPanel only
rendering when there are actual exceptions to report.
* feat(web): add minimal Tabs component
Monochrome tab strip with underline-on-active. Used by state-before
and state-after cards to swap between Screenshot, Snapshots, Properties.
Pane scrolls internally so the outer grid stays fixed-height.
* feat(web): fold timeline into MetricsChart as STEPS lane
Adds a thin per-step status row above HEAP showing violated (red),
pending (dim gray) or holds (green-tinted). Extends highlight +
exception markers to span the status lane. Frees a whole row in the
detail grid so the page can fit in 100vh.
* refactor(web): tabbed state cards, drop standalone Timeline panel
State-before/after now use Tabs (Screenshot / Snapshots / Properties,
default Screenshot). Removes the dedicated timeline row; status lane
lives on the metrics chart. Banner is gone from the shell.
* feat(web): lock app shell to 100vh with no page scroll
html/body/#root fill the viewport, body gets overflow:hidden, and the
detail grid uses minmax(0, 1fr) rows so inner panels own their scroll.
Tightens toolbar + panel padding for a denser feel.
* feat(web): arrow-key nav + badges on Tabs (WAI-ARIA tablist)
Roving tabindex, ArrowLeft/Right/Up/Down/Home/End navigation, explicit
aria-selected/aria-controls/id wiring, and support for an optional
badge inside each tab (used for violation counts).
* feat(web): ViolationsPanel supports violationsOnly filter
* feat(web): ActionList arrow-key nav + listbox semantics + smaller font
Promote the list to role=listbox with role=option rows; roving tabindex
lets ArrowUp/Down (and Home/End) seek between steps with focus. Font
size dropped to 11px and padding tightened so long selector-tag labels
fit in the 340px actions column.
* fix(web): useKeyboardNav yields arrow keys to tablist/listbox targets
Previously pressing ArrowRight on a focused tab switched tabs AND
advanced the step. Skip arrow handling when the event target is inside
an element with an arrow-owning ARIA role.
* feat(web): fourth 'Violations' tab + wider actions + shorter metrics
Adds a Violations tab to each state card showing only violated properties
(with count badge on the tab label when > 0). Actions column widened
from 280px to 340px, bottom metrics strip trimmed from 220px to 140px
with tighter lane heights, so the whole page still fits in 100vh with
no scrollbar.
* feat(web): compact RunDetail layout using 1px borders instead of panel padding
* refactor(inspect): simplify MetricsChart to HEAP+CPU with time axis
Drop the STEPS status lane and per-sample circle markers, switch the
x-axis from step indices to mm:ss clock time, trim y-axis ticks to
min/max with compact units, rotate lane labels into the left gutter,
and replace the thin playhead line with a wider dotted red band.
Traces stay grayscale; red appears only on the playhead pattern.
* fix(web): RunList rows no longer stretch to fill viewport height
Tables inherited flex: 1 1 auto from .app-main > * and distributed extra
vertical space across rows. Override with flex: 0 0 auto + align-self.
* misc changes
* fix(web): hoist useState above early return in MetricsChart
Calling useState after an unconditional early return violates React's
Rules of Hooks: the empty-samples branch renders 0 hooks while the
populated branch calls 1. On the initial null->loaded transition of
history the hook count changes and React throws.
* fix(web): subscribe to named SSE event instead of 'message'
Server emits 'event: runs.changed' frames; the WHATWG EventSource spec
dispatches those as events of type 'runs.changed', not 'message'. The
listener registered on 'message' was never fired, so RunList never
auto-refreshed on run create/finish/delete.
* fix(inspect): unsubscribe SSE clients on disconnect
Watcher.Subscribe appended to a slice with no matching removal path,
so every closed EventSource connection leaked its channel. Over a
long-running server the slice grew unbounded and every fs event paid
O(N) iterating dead channels. Add Unsubscribe + defer it in
handleEvents.
Unsubscribe does not close the channel: broadcast snapshots the
slice without holding the mutex, so a concurrent close would race
with its non-blocking send.
* fix(trace): rename resolvedBounds/tapPoint to snake_case
Every other json tag in the trace schema (from_x, duration_millis,
bundle_sha256, etc.) uses snake_case. The two new Action fields
introduced with the inspect UI broke that pattern. Rename them
before the format ships to external consumers.
* chore(web): drop vitest and remove UI tests from CI
No UI tests wanted in web. Removes vitest, jsdom, testing-library
devDeps and the vitest.setup.ts + vite.config.ts test block.
Makefile test-web becomes web-typecheck (typecheck only).
Fixes CI failure where `vitest run` exits 1 with no test files.
* chore(make): dedupe sidecar embed and drop recursive make
Make $(SIDECAR_JAR) the real recipe and $(SIDECAR_EMBED) a file
target, so uatu/install/inspect-dev share one copy step and
sidecar/release-cli just depend on the jar instead of re-invoking make.
* test(sidecar): replace assertTrue(true) placeholder with real server test
MainTest.mainExists() always passed and inflated the green-check count.
DriverServiceTest covers RPCs, but SidecarServer start/stop had no
coverage. Drop the placeholder and add SidecarServerTest that binds to
port 0, asserts a real ephemeral port, and stops cleanly.
* test(agent): drop 50ms sleep before cancel in TestServer_AcceptCancelsOnContext
Accept's closeListenerOnCancel watcher closes the listener as soon as
ctx fires, regardless of whether the outer Accept has reached
listener.Accept() yet. The sleep was a CI-flake surface (50ms is not
enough on a slow runner), and dropping it still exercises the same
outcome — Accept returns with ctx.Err() after cancellation.
Stable across 50x -count runs.
* test(agent): replace 2s sleep with done-chan in TestConn_SnapshotTimesOutIfSDKSilent
The silent-SDK fake held the connection open via time.Sleep(2s), which
coupled the test's wall clock to the server's 200ms snapshot-timeout
assertion. Swap for a done channel closed by t.Cleanup — the goroutine
exits when the test ends, independent of timing.
* test(maestro): make WaitForHealth_PollsUntilReady deterministic
Replace the 50ms wall-clock sleep that flipped healthReady with a
healthReadyAfterCall counter in the fake server. The handler returns
ready=true once healthCalls reaches the threshold, so the test's
"at least 2 polls before ready" assertion is satisfied by call
count rather than a race between the flip goroutine and the 25ms
poll loop.
* refactor(runner): route per-step progress through slog instead of fmt.Printf
The runner already carries a *slog.Logger for warnings (logger.Warn on
decode failures, predicate errors). The per-step status line was the
outlier — a bare fmt.Printf that wrote to os.Stdout unconditionally,
bypassing both the injected logger and any caller-configured writer.
Switch it to logger.Info("step", "index", ..., "screen", ..., "nodes", ...).
The caller (cmd/uatu) is responsible for wiring a logger whose handler
renders to the right stream; the next commit adds that wiring.
* feat(cli): render runner progress via a thin slog handler on stdout
progressHandler writes Info records as "msg key=value ..." and prefixes
warnings/errors with their level, matching the prose style of the
surrounding CLI status prints. Wired into the runner via
runner.Options.Logger so the per-step status line still lands on stdout
without slog's default time= / level= framing.
* fix(verifier): don't crash on throwing JS predicates
formulaThunk used to panic whenever goja returned an error from a
predicate callable, and nothing on the LTL -> runner path recovered, so
a malformed spec (e.g. a property whose body throws or touches an
undefined field) would kill the verifier process.
Latch the first error on formulaState, return false so LTL marks the
property violated, and expose PredicateError(name) that walks the
property's formula-spec tree and surfaces the latched cause.
* fix(runner): log predicate errors alongside violations
For each violated property, surface the verifier's latched predicate
error via logger.Warn so operators can distinguish a genuine false
verdict from a malformed spec. Add a runner-level test asserting that a
throwing predicate no longer crashes the run and that the error message
appears in the log.
* refactor: rename examples/sample-app to examples/folio
Directory-level rename and path references in Go tests, bundle-check,
top-level README, and getting-started docs. Package declarations,
Gradle config, iOS bundle IDs, and class names follow in later commits.
* refactor(folio): rename Kotlin package dev.uatu.sample to app.folio
Moves source dirs and sqldelight schema from dev/uatu/sample to
app/folio, updates package declarations and imports, and switches
Android namespace/applicationId, iOS binaryOption bundleId, and
sqldelight database packageName to the new identifier.
* refactor(folio): rename SampleApplication to FolioApplication
Android manifest now points at .FolioApplication with label 'Folio'
instead of 'Uatu Sample'.
* refactor(folio): set iOS bundle id and display name to Folio
bundleIdPrefix + PRODUCT_BUNDLE_IDENTIFIER -> app.folio.
CFBundleName + CFBundleDisplayName -> 'Folio'.
* refactor(folio): update demo email to [email protected]
* refactor(folio): point justfile at app.folio bundle id
Updates xcrun simctl launch target, uatu test --bundle-id, and the
build/uninstall comments to reference folio instead of sample.
* test: update fixture package ids to app.folio
Sidecar activity-resolver test and verifier spec-integration XML
fixtures referenced the old dev.uatu.sample Android package. Updates
them to match the folio app's real package id so the tests stay
representative of what the CLI sees on-device.
* test(verifier): rename SampleApp identifiers to Folio
Renames TestSampleAppSpec* functions, bundleSampleAppSpec helper, and
sampleAppHierarchyXML const (now loginHierarchyXML for consistency with
the other per-screen fixtures). Updates trailing sample-app mentions in
comments and assertion messages.
* refactor(folio): rename Gradle/npm/wasm project identifiers to folio
settings.gradle.kts rootProject.name, package.json + package-lock.json
name, and the WasmJS index.html <title> all still read 'uatu-sample' /
'Uatu Sample'. Realigns them with the Folio brand.
* docs(folio): rewrite README title + getting-started bundle id
examples/folio/README.md is now titled 'Folio' with the Kotlin source
paths corrected to app/folio. Getting-started example uses --bundle-id
app.folio. Harness launch message is now generic ('app under test')
since uatu-sample-harness is not specific to folio.
* chore(folio): drop trailing 'sample' reference in gradle.properties
* refactor(folio): rename LoginPage composable to LoginScreen
Align with KMP/Android industry convention (NowInAndroid, Cash App,
JetBrains samples use Screen, not Page).
* refactor(folio): rename HomePage composable to HomeScreen
* refactor(folio): rename AddAccountPage composable to AddAccountScreen
* refactor(folio): rename LedgerPage composable to LedgerScreen
* refactor(folio): rename AddTransactionPage composable to AddTransactionScreen
* refactor(folio): split Models.kt into app.folio.data package
Account, Transaction (with TxnType), and Session move into their own
files under app.folio.data, matching NowInAndroid-style per-type
organization.
* refactor(folio): move data layer into app.folio.data package
Repository, LedgerStore (expect + interface), SqlLedgerStore,
WebLedgerStore, DriverFactory (expect + actuals), AndroidLedgerContext,
and Snapshot move into app.folio.data. Update all consumer imports.
* refactor(folio): move Navigation into app.folio.navigation package
Split the former Navigation.kt into Route.kt (sealed interface) and
Navigator.kt (singleton). Update consumer imports across screens,
App.kt, and FolioApplication.
* refactor(folio): move Platform and Format into app.folio.platform
Both files carry expect declarations (Platform object, formatDate);
grouping them into a dedicated platform package makes the KMP seam
obvious and mirrors the structure used by JetBrains samples.
* refactor(folio): move login into feature/auth package
Create app.folio.feature.auth with LoginScreen + LoginUiState. Inline
the former Auth.kt (DEMO_EMAIL, DEMO_PASSWORD, checkCredentials) into
LoginScreen since it is the sole caller.
* refactor(folio): move HomeScreen into feature/home package
* refactor(folio): move account creation into feature/account package
AddAccountScreen gets its own AddAccountUiState colocated with the
screen, replacing the shared UiState.addAccountError.
* refactor(folio): move ledger screens into feature/ledger package
LedgerScreen and AddTransactionScreen move into app.folio.feature.ledger
with AddTransactionUiState (txnError, txnFormType) colocated. The
former catch-all UiState.kt is removed now that each screen owns its
state alongside its UI.
* refactor(folio): split Theme.kt; move theme and icons to subpackages
Theme split into Theme.kt (tokens, layout dims, LedgerTheme) and
Type.kt (typography) under app.folio.ui.theme. Icons moves to
app.folio.ui.icon. Update every consumer's imports to match.
* refactor(folio): split ui components into per-file under ui/component
Former Widgets.kt and Components.kt become 10 focused files: AppButton,
Card, EmptyState, ErrorText, FieldLabel, Header, IconButton (w/
BackButton), Screen, Segmented, TextInput. Matches NowInAndroid style
of one composable per file in a designsystem/component package.
* chore(folio): consolidate uatu testing files under uatu/ folder
Move spec.ts, package.json, package-lock.json into examples/folio/uatu
so all uatu-specific testing artifacts live in one place. runs/ and
node_modules/ follow the same convention (both remain gitignored).
Update justfile, README, and the two Go consumers (bundle-check tool +
verifier/trace tests) that referenced the old path.
* refactor(trace): drop folio path in writer test
Round-trip only needs a non-empty string; neutralize to keep the
library free of folio references.
* refactor(sidecar): neutralize ResolveActivity test fixtures
Swap app.folio for com.example.app in the fixture strings so the
sidecar tests don't reference the example app by name.
* refactor(bundle-check): take spec path as argument
Previously the tool hardcoded examples/folio/uatu/spec.ts. Accept a
positional spec path instead so the tool works for any example and
leaves no folio reference in the library surface.
* test(verifier): add neutral integration spec and hierarchy fixtures
Adds testdata/integration_spec.ts with two routes ("list", "form"),
an InputText on text_field, a Tap on primary/secondary_action, a
safety property (itemCountNonNegative), and a liveness property
(submitEventually). Adds hierarchies_test.go with matching XML
fixtures. Constants intentionally go in a _test.go at package root
rather than testdata/hierarchies.go because go skips .go files
under testdata/.
* refactor(verifier): replace folio integration tests with neutral ones
Renames bundleFolioSpec -> bundleIntegrationSpec and the three Test*
entry points to TestIntegrationSpec*. Uses the synthetic spec and
hierarchies added in the previous commit so the library's test suite
no longer references examples/folio at all.
Folio-specific coverage remains covered by examples/folio/justfile's
'just test' which exercises the real spec on device/emulator.
* chore: remove cmd/uatu-sample-harness
Not referenced by Makefile, docs, CI, or any script. Duplicates the
adb reverse helpers already in cmd/uatu/test_run.go, and its name
implies ownership by the sample app which violates the library/
example decoupling. If a bare-protocol debugging tool is later
needed it belongs inside cmd/uatu/.
* docs(folio): drop Layout section and KMP layout paragraph; fix AVD override syntax
The directory-tree Layout section rots faster than the code and
duplicates what ls shows for free. The expect/actual paragraph in
Stack was the same kind of filler. The README also claimed 'just
AVD=Pixel_7 test' but the justfile reads AVD as an env var via
env_var_or_default, so the correct invocation is 'AVD=Pixel_7
just test'.
* feat(ltl): add Now/Next/Eventually/Implies/Or/And/Not formulas
Replace the fold-with-latch evaluator with a residual-formula reducer.
Each Observe() instantiates a fresh obligation from the root (stripping
an outer Always), reduces each pending obligation against current state,
latches Violated on first failure, and surfaces Pending verdicts for
deferred obligations. Existing Always/Pure/Thunk tests continue to pass.
* feat(ltl): support relative duration for eventually().within()
* feat(proto): add Swipe, PressKey, RecentLogs RPCs
* feat(verifier,runner): formula handles, new action kinds, rich state
- verifier: add formula-spec registry; bindNow/bindNext/bindEventually with
chainable .implies/.or/.and/.not and .within(n,unit) on eventually; bindFrom
for uniform sampling. bindAlways keeps accepting plain predicates.
- verifier: store lastTree, lastAction, step time, logs, exceptions on the
Verifier; SnapshotInput replaces the (snapshots, tree) pair. stateObject now
produces state.lastAction/time/logs/exceptions matching the TS State type.
- verifier: make taps/swipes/waitOnce/pressKey built-in generators actually
fire; taps picks a clickable, enabled element from the last hierarchy.
- agent: add exceptions field to Message wire format.
- driver: add Swipe/PressKey/RecentLogs to Driver interface; wire maestro
client and mock driver. LogEntry exposed for runner consumption.
- runner: apply Swipe/PressKey/Wait actions; collect logcat and exceptions;
pass lastAction and step time into PushSnapshot.
* feat(spec-api): LTL operators, new actions, richer State
- ltl.ts exports now/next/eventually; always overload accepts a Formula
- types.ts: Formula gains implies/or/and/not; EventuallyFormula adds .within;
State gains lastAction/time/logs/exceptions; Swipe/PressKey/Wait action types
- actions.ts: Swipe/PressKey/Wait/from constructors; waitOnce + pressKey
default generators
- tests exercise the chaining, sampling, and new actions through a recorded
fake runtime
* feat(sidecar): add swipe, pressKey, recentLogs RPC handlers
* feat(sdk-android): capture uncaught exceptions
Install a default uncaught handler on Uatu.start, chained with any
existing handler so Android's crash reporter still runs. Expose
Uatu.reportError for callers to forward caught throwables. A bounded
circular buffer (default 50) drains into each STATE message's new
exceptions field. Protocol.kt serializes/deserializes the field,
matching the Go wire format added to internal/agent/protocol.go.
* feat(spec-api): add @uatu/spec/defaults/properties bundle
* feat(sample-app): exercise new LTL operators + defaults
spec.ts now imports eventually/next/now/from from @uatu/spec and
noUncaughtExceptions from @uatu/spec/defaults/properties. It declares
three properties that exercise the new surface:
- accountCountNonNegative: plain always() safety
- addAccountAdvances: always(now(x).implies(next(y)))
- eventuallyLoggedIn: eventually(p).within(30, "seconds")
- noUncaughtExceptions: imported default
The weighted actions root uses from() for random phone/name sampling
and entries for taps/swipes/waitOnce/pressKey built-ins.
SampleApplication gains a debug hook gated on the system property
uatu.inject_error so the e2e run can synthesize an Uatu.reportError and
verify noUncaughtExceptions violates.
cmd/uatu/test_run.go adds a subpath alias so specs importing
"@uatu/spec/defaults/properties" resolve against the in-tree source
when running from the uatu checkout. The spec-integration tests swap
the old click-counter fixtures for the new login hierarchy.
* feat(trace): record swipe/key/wait details + exceptions
trace.Step gains an Exceptions array so the trace captures the
class/message/stackTrace for each SDK-reported throwable in a step.
trace.Action gains FromX/FromY/ToX/ToY/Key/DurationMillis so the full
payload of Swipe/PressKey/Wait actions is visible in trace.jsonl.
sample-app's debug error hook now gates on ApplicationInfo.DEBUGGABLE
instead of a system property (adb setprop fails on non-rooted
emulators).
* chore(sample-app): hoist gradle wrapper to sample-app root
* chore(sample-app): add KMP root gradle config
* chore(sample-app): add composeApp KMP module build config
* chore(sample-app): add Android manifest for composeApp
* feat(sample-app): add shared domain models and auth constants
* feat(sample-app): add shared number and date formatting
* feat(sample-app): add cross-platform storage, clock, and id
* feat(sample-app): add shared repository with file-backed state
* feat(sample-app): add shared in-memory navigator
* feat(sample-app): add Compose theme and design tokens
* feat(sample-app): add shared UI components (icons, screen, widgets)
* feat(sample-app): add Login and Home pages
* feat(sample-app): add AddAccount, Ledger, AddTransaction pages
* feat(sample-app): add App root composable with routing
* feat(sample-app): add Android Application and Activity hosting Compose UI
* chore(sample-app): remove legacy android module (replaced by composeApp)
* chore(sample-app): bump to latest stable Kotlin/AGP/Compose deps
* fix(sample-app): make iOS compile (drop @Volatile, set bundleId)
* feat(sample-app): add xcodegen spec, SwiftUI host, and iOS Info.plist
* chore(sample-app): ignore build artifacts and generated xcodeproj
* chore(sample-app): update justfile for composeApp layout, add ios target
* docs(sample-app): rewrite README for KMP + iOS flow
* refactor(sample-app): drop in-app status bar and formatClock
* feat(sample-app): add SQLDelight schema and per-platform drivers
* refactor(sample-app): back Repository with SQLite, drop file serializer
* feat(sample-app): wire native back on Android and iOS edge swipe
* feat(sample-app): semantic roles, labels, and a11y descriptions
* fix(sample-app): link libsqlite3 for iOS target
SQLDelight's native driver needs libsqlite3.tbd on iOS; without it the
linker fails with undefined _sqlite3_bind_blob and friends.
* refactor(sample-app): abstract storage behind LedgerStore interface
Platform-specific createLedgerStore() returns a SqlLedgerStore backed
by SQLDelight on Android + iOS. Opens the door for a pure in-memory
web implementation that does not require a SQLite driver.
* feat(sample-app): add wasmJs target with in-memory LedgerStore
Wires a Compose Multiplatform browser canvas entry point. The web
implementation of LedgerStore is an in-memory model with localStorage
persistence, so it does not need a SQLite driver. Back navigation maps
the browser back button to the same BackHandler contract Android and
iOS use.
* chore(sample-app): settings + gitignore for wasmJs dev run
Registers the Node.js distributions repository and switches
repositoriesMode to PREFER_PROJECT so the Kotlin wasmJs plugin can
download its toolchain. Adds kotlin-js-store (lockfile) and ignores
runs/, web screenshots, playwright-mcp scratch output.
* fix(sample-app): singularize transaction count on Home
Shows "1 transaction" not "1 transactions" for accounts with a single
transaction; falls back to "$count transactions" otherwise.
* fix(runner): surface non-deadline WaitForIdle errors
Previously the WaitForIdle return value was discarded entirely, hiding
real driver failures (gRPC transport errors, sidecar crashes) behind
the expected deadline-exceeded case. Log non-deadline errors so they
are visible without changing control flow.
* chore(sample-app): drop unused uptime_millis extractor
Registered in SampleApplication but never consumed by spec.ts.
* fix(sample-app): drop trivial appIsRunning property
app_state was hardcoded to 'running' so the property was a tautology
that could never fail. Removing both the extractor and the property
is the simplest fix; demo-grade properties that can fail land next.
* feat(sample-app): add Reset button that zeroes clickCount
Pairs with the next commit's tap-reset action so the fuzzer can
violate clickCountNeverDecreases and demonstrate uatu actually
finding a property violation.
* feat(sample-app): add tap-reset action to exercise Reset button
Weighted at 10/122, fuzzer reaches it within a short run. Pairs with
the Reset button to demonstrate uatu detecting the
clickCountNeverDecreases violation.
* fix(runner): filter WaitForIdle errors via context state, not errors.Is
errors.Is(err, context.DeadlineExceeded) misses gRPC's wrapped
status.DeadlineExceeded, so every step under the maestro driver
logged a spurious warning. Check idleCtx.Err() instead — captures
both deadline-fired and parent-canceled cases regardless of how the
driver wraps them.
* chore(sample-app): tune action weights so demo violates in ~30s
Prior weights left tap-reset rare enough that short demo runs missed
the violation by chance. Bumped to 30/107, with typeUsername reduced
since username noise doesn't help exercise clickCount.
* refactor(runner): route warnings through slog
Adds Options.Logger (defaults to slog.Default()) and converts the
three warning sites that were using fmt.Printf. Progress line stays
on Printf since it's user-facing UI, not a log. Makes the warnings
testable via a capturing handler.
* test(runner): assert WaitForIdle driver errors are logged
Captures slog output via TextHandler into a buffer and asserts the
warning message + injected error text appear when the mock driver
returns a non-context error from WaitForIdle. Guards against a
regression of the silent-error swallow.
* fix(sdk-android): add @JvmOverloads to Uatu.start
Java callers can now invoke start(application) without supplying a
Configuration, matching the Kotlin default-arg ergonomics.
* feat(agent): add protocol_version to HELLO handshake
ProtocolVersion=1 lives on Message and is set by Hello(). Server.Accept
rejects mismatches with a clear error. SDK upgrades that don't change
the wire format keep the same protocol_version; bump on breaking changes.
* test(agent): assert protocol_version in Hello round-trip
* feat(sdk-android): send protocol_version=1 in HELLO
Mirrors agent.ProtocolVersion on the Go side. Bump in lockstep with
the Go constant when the wire format breaks.
* chore(sample-app): pull @uatu/spec from npm next tag
Replaces the file: dep. Copy-paste users can now npm install against
the registry. The release workflow publishes pre-release tags to
npm dist-tag 'next', so the sample tracks the latest rc without
manual version bumps. Lockfile currently resolves to 0.0.1-rc3.
* fix(runner): warn on malformed screen snapshot
screenFromSnapshot swallowed json.Unmarshal errors, so a non-string
screen value silently became "" in the step log and trace while the
verifier still saw the raw JSON. Return the error and warn at the
call site, matching the hierarchy warning pattern.
* docs: clarify --avd is optional for uatu test
The CLI accepts --avd as an empty-string default (cmd/uatu/main.go:49)
and only requires it when no device is connected and multiple AVDs
exist (cmd/uatu/android_env.go:63). Docs and examples that showed it
as required or always-passed were misleading.
* fix(runner): surface focus-tap errors in InputText action
A failed Tap/TapSelector before InputText was swallowed, so text typed
into the wrong field (or no field) still reported success. Return the
error so the step fails explicitly.
* feat(sample-app): add username EditText and snapshot
Gives the spec a real EditText target (content-desc: username_field)
so the InputText action path can be exercised end-to-end. The typed
value is mirrored into MainActivity.username and surfaced as the
"username" snapshot for spec assertions.
* feat(sample-app): exercise InputText action against username field
Adds typeUsername action and usernameNeverShrinks property to the
sample spec, and extends the integration test to assert the bundled
spec emits an InputText(desc:username_field, "alice") action and that
the property correctly violates when a snapshot reports a shorter
string.
interestingTags hardcoded selectors from a specific app (etMobileNumber,
customer_row_, supplier_row_, etc.) inside the generic runner. None of
these selectors exist in the checked-in sample spec. Debug log now just
reports screen + hierarchy size; specs that want richer visibility can
log from state.ax.find themselves.
* build(sdk-android): bump AGP 8.11.0 → 8.13.0
Required by com.vanniktech.maven.publish 0.36.0, bumped in the
following commit to pull a Dokka 2.x that handles JDK 21+ version
strings.
* build(sdk-android): migrate to vanniktech 0.36 + Dokka v2 javadoc
Fixes the `javaDocReleaseGeneration` crash on JDK 21+ hosts
(IllegalArgumentException: 25.0.2 in the bundled IntelliJ
JavaVersion.parse). Root cause: AGP's JavaDocGenerationTask pulled
an old Dokka whose vendored util-lang predated JDK 9+ version
strings. Fixed upstream in Dokka 2.1.0 (Kotlin/dokka#4202).
Changes in this commit:
- Bump vanniktech-maven-publish 0.30.0 → 0.36.0, which drops Dokka
v1 support and the SonatypeHost parameter (Central Portal is
now the default).
- Apply org.jetbrains.dokka and org.jetbrains.dokka-javadoc 2.2.0
so the javadoc jar is generated by Dokka 2.x directly, not AGP's
bundled-Dokka path.
- Switch AndroidSingleVariantLibrary to the explicit
JavadocJar.Dokka("dokkaGeneratePublicationJavadoc") form; the
old `publishJavadocJar = true` boolean now maps to plain
javadoc, which still goes through AGP's broken path on Android
source sets.
- Enable V2Enabled in gradle.properties (required by 0.36+) and
silence the transitional opt-in warning.
Verified: `make release-android-local` on openjdk 25.0.2 produces
aar, sources jar, javadoc jar, pom, and module; `./gradlew
:sdk-android:testDebugUnitTest :sidecar:test` still pass.
* fix(cli): fall back to node_modules for @uatu/spec resolution
Drop the hard failure when the uatu source tree is not reachable from
the spec file. Users integrating uatu in their own app have @uatu/spec
installed via npm; esbuild now resolves it from node_modules.
* build(gradle): drop :sample-app include from root settings
The sample now has its own Gradle project in examples/sample-app/android.
* build(sample-app): vendor gradle wrapper
Users running the sample build the APK via ./gradlew from inside the
sample-app's own android/ directory, no repo-root wrapper required.
* build(sample-app): make gradle project standalone
Drop the project(':sdk-android') dependency in favor of the Maven
Central coordinate io.github.priyanshujain:sdk-android. The sample now
owns its settings.gradle.kts and gradle.properties, so it builds
without any pieces of the uatu source tree.
* chore(sample-app): declare @uatu/spec npm dependency
Mirrors what a downstream user would put in their own package.json.
Uses file: for pre-release development; becomes a normal semver pin
once @uatu/spec ships to npm.
* docs(sample-app): rewrite justfile and add README
Justfile drops repo_root; all recipes run against the local gradle
wrapper and uatu from PATH. README is scoped to what a user needs to
run the sample against their own device.
* docs(manual): update sample install steps to standalone layout
./gradlew :sample-app:installDebug no longer exists; the sample owns
its own wrapper under android/.
* feat(sdk): log when Uatu.start succeeds
Silent SDK start makes the "SDK didn't connect" failure mode
impossible to debug. One INFO line at start time is enough.
* fix(sidecar): launch via am start -W instead of monkey
monkey -p <pkg> -c LAUNCHER 1 is unreliable on API 36+: it reports no
error but silently fails to start the activity, so the SDK never runs
and the CLI times out on the SDK-accept handshake.
Resolve the launcher activity via `cmd package resolve-activity
--brief` and launch it with `am start -W -n`. -W makes the call block
until the activity is up, which also makes the subsequent SDK
accept timing deterministic.
* feat(cli): auto-resolve Android device; boot AVD if none connected
--avd becomes optional. Resolution order:
- use any already-connected adb device;
- else if --avd names an existing AVD, boot it and wait for boot;
- else if --avd is missing or names no AVD, error with a clear message.
Falls back to $ANDROID_HOME/emulator/emulator when the binary is not on
PATH, so a standard Android SDK install works without extra shell setup.
* docs(sample-app): AVD is optional; document both paths
just test runs against any connected device. If none, pass AVD=<name>
to have uatu boot the emulator for you.
* feat(cli): auto-discover Android SDK; auto-pick the lone AVD
adb and emulator are looked up via PATH, then $ANDROID_HOME,
$ANDROID_SDK_ROOT, ~/Library/Android/sdk, ~/Android/Sdk, and the
Homebrew cask path. The discovered platform-tools directory is
prepended to the sidecar's PATH so its adb subprocess calls work too.
When --avd isn't passed and no device is connected, the CLI picks the
sole local AVD and boots it. Multiple AVDs → error listing them.
* build(make): add `make install` that go-installs uatu onto PATH
Puts `uatu` into $GOBIN (or $GOPATH/bin) so the sample and any local
dev flow can call it without PATH= prefixes.
* docs(sample-app): zero-config just test; dotenv-load for persistence
Drop the expectation that users prefix commands with PATH=, ANDROID_HOME=,
or AVD=. `just test` now works as-is; optional knobs can be pinned in a
.env file alongside the justfile.
* fix(sample-app): auto-detect ANDROID_HOME for Gradle tasks
The Go CLI finds the SDK itself, but AGP still needs ANDROID_HOME to
resolve `sdk.dir`. The justfile now resolves it from env or canonical
install paths before invoking ./gradlew, so `just install` works out
of the box on a standard Android SDK setup.
* gitignore runs directory for sample app
* refactor(docs): inline pandoc build into Makefile, drop scripts dir
* fix(agent): wait for deadline watcher before returning
readWithDeadline's watcher goroutine could clobber the conn's read
deadline with time.Unix(1, 0) after the main function reset it to zero.
When the Accept ctx was canceled shortly after Accept returned, the
watcher raced with close(done) in select and sometimes picked ctx.Done()
even though we were already done reading, leaving the conn unusable for
the next read (instant i/o timeout on step 1 snapshot).
Synchronize on the watcher's exit before resetting the deadline so it
can never override the reset.
* test(agent): cover readWithDeadline race on Accept ctx cancel
Drives Accept with a short-timeout ctx, cancels it right after Accept
returns, then does a Snapshot. Reliably fails without the readWithDeadline
synchronization fix (watcher goroutine overwrites the deadline to past).
Targets: install/uninstall the APK, build the uatu CLI, run 'uatu test'
against a named AVD, run the Go verify tests, and clean. Keeps the
example self-contained so users can 'just test AVD=pixel_7' after cloning.
The old tests loaded merchant-android uiautomator dumps from /tmp and
skipped when absent. Replace with two hermetic tests that bundle
examples/sample-app/spec.ts against a synthetic hierarchy: one checks
tapClickMe fires, the other drives the three properties through a
holds/holds/violated snapshot sequence.
Introduce examples/sample-app/spec.ts — a minimal property-based spec that
taps the sample app's "Click me" button and asserts click_count is
monotonic. Bundle-check and the trace writer test now reference the new
path.
* feat(cli): add Version var and version subcommand
* build(gradle): introduce uatu.version property for lockstep releases
* build(sdk-android): swap GitHub Packages for vanniktech Maven Central plugin
* build(spec-api): make package publish-ready for npm
* ci(release): add goreleaser config for cross-platform uatu CLI builds
* ci: add ci and release GitHub Actions workflows
* ci: restrict ci.yml to PR + workflow_dispatch (no direct push to master)
* docs(release): add local release targets, env example, and install docs
* build(sdk-android): make signAllPublications conditional on signing key
* ci(release): stage sidecar JAR at embed path before go build
* chore(spec-api): regenerate package-lock for updated package.json
* ci: install protoc-gen-go plugins before buf generate
* ci: bump Node to 22 (required for --experimental-strip-types)
make uatu copies the real fat JAR into assets/ before
go build -tags withsidecar. Keeping that path tracked was
the root cause of the 130 MB push rejection.
Splits embed.go so the go:embed directive only fires under
-tags withsidecar. Default builds get a stub with a nil JAR
and IsPlaceholder()=true. This removes the landmine where
make uatu overwrote a tracked placeholder file, making any
git add silently stage 130 MB.
runTestPipeline assembles the v0.1 stack end to end:
1. Bundle the spec (with @uatu/spec alias resolution)
2. Extract the embedded sidecar JAR
3. Spawn java -jar sidecar --port <free>
4. Wait for sidecar Health
5. Listen on a host TCP port + adb reverse to the device's
localabstract:uatu-agent socket
6. Launch the app via the maestro driver
7. Accept the SDK HELLO
8. Load the bundle into the verifier
9. Open the trace writer + write meta.json
10. runner.Run for the requested duration
11. Terminate the app + clean up adb reverse
Test subcommand now prints a bundle error for a missing spec,
verifying the flag surface reaches the pipeline.
Removes Launch + Terminate from runner.Run so the CLI can launch
the app first, wait for the SDK to connect, then start the loop.
The previous shape forced runner to launch internally which fought
with the SDK-must-be-connected-first ordering.
BundleID/ClearState fields go away too since runner no longer
launches; the CLI keeps them on its testOptions struct.
Coordinates: dev.uatu:sdk-android:0.0.1. Credentials read from
GH_TOKEN/GH_USERNAME (or GITHUB_TOKEN/GITHUB_ACTOR for CI).
.env is gitignored so local tokens stay out of git.
Consumers add the maven repo + debugImplementation in their
build.gradle and we're done.
Property ledgerBalanceMatchesTxns asserts displayed balance equals
sum(Given) - sum(Received) on either ledger screen. Catches the
class of bug where the server-fed CustomerModel.balance diverges
from the local-DB-fed CoreDatabaseDao sums (stale cache, partial
sync, deleted-txn handling glitch, etc.).
Generators are gated by screen state and weighted to push the run
through login -> home -> ledger quickly:
enterPhone (100), enterOtp (100), openCustomerOrSupplier (80),
taps (10), swipes (2).
Phone/OTP read from process.env via esbuild defines so credentials
never land in source. cmd/internal-tools/bundle-check is a quick
sanity tool to confirm the spec bundles before running uatu test.
Doctor flags a shipped binary that's still carrying the build-time
placeholder so `uatu test` won't silently fail trying to launch a
nonexistent sidecar. Makefile uatu target now copies the freshly
built fat JAR into the embed directory before `go build`.
Ships with a 24-byte placeholder so fresh clones build without
requiring a sidecar build first. `make uatu` copies the real
fat JAR into internal/sidecar/assets before `go build`, so
shipping binaries carry the full sidecar (~130 MB).
Extract writes the JAR to a temp dir alongside a SHA-256 file
and skips rewrite when the checksum already matches.
Wraps each v0.1 RPC, plus a WaitForHealth helper that polls until
the sidecar reports Ready=true (used at startup before any other
calls happen). Tests stub the gRPC server in-process so they don't
need a real sidecar JAR.
Driver interface in Go already exposes TapSelector for selector-based
taps; mirror that in the proto so maestro can resolve selectors
sidecar-side rather than forcing Go to walk the hierarchy first.
DriverService delegates each RPC to a pluggable DriverBackend so
real Maestro integration can land in a follow-up without changing
the gRPC layer. StubDriverBackend satisfies the contract and lets
the Go side talk to a running sidecar today.
Generates Java stubs from proto/driverpb/driver.proto via the
protobuf plugin (sourceSets points at the shared root proto dir).
Pulls in dev.mobile:maestro-client:1.40.0, grpc-netty-shaded,
slf4j-simple, and the JUnit vintage engine so the gRPC test rules
work alongside JUnit Jupiter.
Inspector parses uses-permission entries from `aapt dump permissions`
and the granter shells out to `adb shell pm grant`. Both are pluggable
so tests can drive logic without aapt or a device. Granter failures
become warnings rather than errors — Android refuses non-runtime
permissions and we'd rather keep going than abort the run.
Each check is a value so tests can swap in fakes. Java check parses
both legacy (1.8) and modern (17+) version strings. Emulator check
falls back to ANDROID_HOME/emulator/emulator since the brew cask
ships it without putting it on PATH.
Wires agent.Conn + driver.Driver + verifier.Verifier + trace.Writer
into the v0.1 step cycle: snapshot the SDK, push to verifier,
evaluate properties, write the trace step (with violations), release
the SDK pause, apply the next action via the driver, wait for idle.
Driver.Launch happens once before the loop and Terminate runs in
defer so even an early error tears down the app cleanly. Summary
returns step count and per-step violation records for the caller
to print or persist.
Real maestro driver will resolve selectors itself rather than
forcing the runner to look up coordinates from the hierarchy. Mock
now waits the requested duration so tests don't busy-spin and
starve the SDK fixture goroutine.
Installs globalThis.__uatu__ with extract, always, actions,
weighted, tap, inputText, and stub taps/swipes. Load runs the
bundled spec, then pulls properties + actions out of globalThis.
PushSnapshot rebuilds state.snapshots and refreshes every
extractor handle's current/previous in registration order so
chained extractors observe up-to-date values.
Properties are wired through internal/ltl as Always(Thunk(...)),
so verdicts latch to violated as soon as a predicate returns
false. NextAction resolves actions/weighted recursively with a
seedable rand source for reproducible runs.
Thin wrappers over a globalThis.__uatu__ runtime that the verifier
provides as host bindings. Spec authors get IntelliSense for
extract/always/Tap/InputText/actions/weighted/taps/swipes, but the
bundle stays small because runtime logic lives in goja.
TypeScript 5.6 with strict mode, bundler module resolution. Tests
run via node --test --experimental-strip-types so we don't need
vitest or a build step. Adds node_modules to .gitignore.
Each WriteStep appends one JSON object per line so the trace can
be filtered with jq directly. Screenshots land under screenshots/
with zero-padded indexes. Writer is concurrency-safe; Close is
idempotent and a write after Close errors loudly rather than
silently dropping.
Supports Always over Pure/Thunk leaves; eventually/next/bounds
deferred. Once a thunk returns false under an Always, the verdict
latches to violated so the runner can surface the offending step
without later observations masking it.
Bundles a TypeScript entry into an IIFE ES2020 blob with optional
inline sourcemaps. process.env defines are JSON-quoted before being
fed to esbuild so values with quotes/backslashes survive correctly.
Returns the bundle's SHA-256 for trace meta.json.
Records every call as an Action and lets tests program hierarchy,
screenshot, health, and per-method failures. Actions() returns a
copy so test code can't mutate the driver's history.