From b23fb0c723e65d8b1504bc3f67a264a63fb5215c Mon Sep 17 00:00:00 2001 From: pjay Date: Sun, 3 May 2026 11:21:21 +0700 Subject: [PATCH] feat: web-native specs + per-platform doctor (#49) * feat(doctor): per-platform check sets + --platform flag Replaces unconditional defaultDoctorChecks with doctorChecksFor(platform); web-only users no longer see scary FAILs for adb/emulator/java/sidecar. * feat(testrun): add Preflight() before sidecar/driver setup Mobile platforms get a friendly install hint pointing at `sanderling doctor --platform=

` instead of `fork/exec java: not found`. Web is a no-op. * refactor(chrome): split tag (HTML name) from class (CSS classList) Hierarchy attributes now expose HTML tag under 'tag' and CSS classes under 'class', stopping the conflation of the two. * feat(chrome): translate legacy string selectors to CSS/XPath TapSelector now maps id:/desc:/descPrefix:/testTag:/etc. through TranslateStringSelector. Unknown prefixes pass through to a CSS attribute selector so a future Maestro key works without a release. * feat(trace): add WriteHTML + Step.HTMLAvailable Per-step HTML lives in /html/step-NNNNN.html so trace.jsonl stays line-greppable on apps with hundreds-of-KB DOMs. * feat(driver): add WebDriver capability + chrome implementation WebDriver exposes InstallBundle/EvaluateExtractors/NextActionFromV8/Document for the V8-native web tick path. Mobile drivers stay binary-compatible. * feat(verifier): OverrideExtractorValues for V8-driven extractors Web tick path runs extractor bodies in V8 against the real DOM, then overrides goja-side .current slots so LTL predicates evaluate against those values. Mobile callers can pass nil for a no-op. * feat(spec): add WebState + camelCase attribute aliases WebState extends State with live `document`/`window` for V8-side web extractors. KnownAttrSelectors gains camelCase aliases (contentDescription, ariaLabel, testID, etc.) so cross-framework specs autocomplete. * feat(runner): per-tick HTML capture for WebDriver-capable drivers Type-asserts driver.WebDriver and writes /html/step-NNNNN.html in parallel with screenshot/hierarchy/metrics. Step.HTMLAvailable flips so the inspect UI can hide the html tab on mobile runs. * feat(inspect): serveHTML route under /api/runs//html/ Mirrors serveScreenshot path validation; rejects traversal segments and unknown extensions. text/html content-type so the iframe renders cleanly. * feat(bundler): BundleWeb + V8-side runtime shim web-runtime.ts installs globalThis.__sanderling__ with extractor / action registries, plus __sanderlingExtractors__ + __sanderlingNextAction__ globals. BundleWeb composes user spec + runtime under esbuild's PlatformBrowser into one IIFE. * feat(runner): V8 extractor overrides + V8 action source for WebDriver When the driver implements WebDriver, the runner sources extractor values from V8 (real DOM) and the next action from the V8-side action generator. LTL property predicates still run host-side in goja. * feat(testrun): bundle + install web runtime when platform=web BundleWeb composes the user spec with web-runtime.ts; the chrome driver installs the resulting IIFE via Page.AddScriptToEvaluateOnNewDocument post-Launch so the per-tick V8 extractor + action evaluation can begin on step 1. * feat(inspect-ui): hierarchy + html panels in run detail HierarchyPanel renders the captured DOM/AX tree with a filter input. HtmlPanel renders the per-step HTML in an iframe (sandboxed) with a toggle to view source. HTML tab only shows when the step actually has HTML captured. * fix(folio-web): drop aria-label data-carrier abuse Account cards now expose data-account-id + data-balance attrs and use a human-readable aria-label. total-balance / ledger / ledger-balance carry data-cents and data-txn-count instead of stuffing values into title. Spec rewritten to read structured attrs via object-form selectors. * chore: rebuild inspect-ui dist + folio-web .gitignore Embeds the new HierarchyPanel + HtmlPanel into the inspect-ui dist that ships with sanderling. Adds folio-web/.gitignore so generated runs/ don't leak into commits. * revert(trace): drop WriteHTML + Step.HTMLAvailable Screenshots already cover inspection; HTML capture bloats disk by 50-200MB per run with no payoff. * revert(runner): drop per-tick HTML capture Removes captureHTML helper and its three call sites; HTMLAvailable flag no longer set on Step. * revert(driver): drop WebDriver.Document Document was only consumed by the runner's HTML capture which is gone. * revert(inspect): drop /html route Removes htmlPathPattern, serveHTML, and the dispatch block that called it; HTML capture no longer exists on disk. * revert(inspect-ui): drop htmlUrl + html_available type API surface no longer needs the HTML route; Step.html_available has no producer. * revert(inspect-ui): drop HtmlPanel + html tab Removes the iframe-based HTML viewer and its before/after tab wiring from RunDetail. * test(inspect-ui): drop htmlUrl test, add @types/bun Pulls bun-types into tsconfig so api.test.ts (which uses bun:test) typechecks; this was broken from the original feature commit. * chore: rebuild inspect-ui dist without HtmlPanel Embedded SPA bundle no longer ships the iframe HTML viewer. * fix(web-runtime): retry action resolution + implement taps/swipes V8-side runtime previously returned null when weighted picked a generator that returned [] (page-gated), causing 80%+ of ticks on narrow routes to emit no action and no post-screenshot. Now retries up to 16x like goja, and the taps/swipes builtins query the live DOM for clickable elements / dispatch random swipes instead of returning null. * fix(web-runtime): drop swipe, restrict pressKey to browser-meaningful keys Web has no swipe gesture, so swipes dispatched pointer events into empty divs. Make swipe() and the swipes builtin no-op. For PressKey, replace the always-"back" choice with a random pick from {enter, tab, escape, up, down, left, right} - keys that have real semantics in a browser. * chore(folio-web): drop swipes from action root Web runtime no-ops Swipe; remove the import and weighted entry so the spec doesn't request actions that won't fire. * fix(inspect-ui): correct HierarchyPanel CSS variable names Tokens --surface-1/--surface-2/--text-secondary/--border-subtle don't exist in tokens.css, so sticky thead had no background and tag/bounds text fell back to inherited color. Map to the canonical --surface, --surface-elevated, --text-muted, --border that other panels use. * fix(chrome): correct PressKey mappings to chromedp/kb constants Old keyMap had "home":"\x00" (NUL byte) and arrow keys mapped to random punctuation runes (\x25-\x28 = % & ' () instead of arrow keys. "escape" was missing entirely while the V8 runtime emits it. Drop back/home (no browser navigation semantics) and route the remaining keys through chromedp/kb constants so they actually dispatch as the named keys. * fix(cli): -h/--help exits 0 instead of error code parseDoctorArgs hand-rolled its own flag loop and surfaced help text as an error; parseTestArgs used flag.ContinueOnError but propagated flag.ErrHelp to main() which printed "error: flag: help requested" and exited 1. Switch parseDoctorArgs to flag.NewFlagSet matching parseTestArgs, then recognise flag.ErrHelp in main() so all subcommands exit 0 on -h. * fix(chrome): harden cssEscape for control chars + use [class~=] Previous cssEscape only handled " and \, leaving NUL/newlines/control chars to break out of the CSS string literal. Port the CSSOM string serialization rules: NUL becomes U+FFFD, control chars become \HEX, quotes/backslashes get escaped. Class selector switched from `.x` (which would need separate identifier escaping) to `[class~="x"]`, which is also semantically correct for multi-class elements. * fix(web-runtime): use CSS.escape and validate tag-name selectors The previous cssEscape only handled " and \, leaving newlines/control chars to break out of attribute string literals. Delegate to the platform CSS.escape per CSSOM spec. The `tag` selector branch returned the bare value through cssEscape, which doesn't prevent pseudo-classes (`*:hover`) from injecting into the surrounding selector. Add a positive whitelist; values that don't match a tag-name pattern collapse to a never-matching `:not(*)`. Also switch class selectors to `[class~="..."]` to remove the only identifier-context use of cssEscape. * fix(chrome): validate attribute name in unknown-prefix branch A selector like `foo]:has(*),body[x:value` previously produced [foo]:has(*),body[x="..."], a syntactically valid CSS selector that escaped the attribute match and selected `body`. Reject anything that isn't a plain HTML attribute name. * fix(selectors): emit valid XPath 1.0 string literals via concat() Both the Go translator and the V8 runtime escaped " by prepending \, which XPath 1.0 doesn't accept (its string literals have no escape syntax). A `text:` value containing a quote produced malformed XPath that chromedp/document.evaluate rejected. Use the standard concat() composition: when the value contains both ' and ", split on " and join with `, '"', ` so each fragment is wrapped in single or double quotes individually. * fix(runtime): surface unresolved action targets instead of dropping silently serializeAction emitted {x:0,y:0} via `?? 0` whenever a Tap/InputText/Swipe target failed to resolve to coordinates. The runner then collapsed those to ErrNoAction, so every selector typo became a silent no-op tick. Have the runtime return null on unresolved targets and log a console warning (visible via chromedp's runtime listener). Drop the now-redundant {0,0} -> ErrNoAction guard so a deliberate Tap at the origin actually fires. * fix(runner): use errgroup-bound ctx so siblings cancel on failure The errgroup's bound ctx was discarded; goroutines closed over the outer ctx, so neither a sibling failure nor the future ability to propagate per-step cancellation reached the V8 extractor's CDP round-trip. Switch closures to gctx and document why Wait()'s error is intentionally discarded. * fix(chrome): propagate caller ctx cancellation to CDP calls InstallBundle, EvaluateExtractors, NextActionFromV8 ignored the caller ctx and ran chromedp.Run on d.tabCtx alone, so step deadlines and Ctrl-C couldn't interrupt an in-flight CDP round-trip on a hung tab. Add a runCtx helper that derives a chromedp-bound context which also cancels when the caller's ctx cancels, and route the three V8 entry points through it. * fix(verifier): tolerate out-of-range override indices A single stale index from V8 aborted the entire override map, so any valid entries alongside it were dropped and verification ran on stale extractor values. V8 and goja register from the same bundle so a mismatch is unusual but recoverable. Skip out-of-range entries instead of erroring, and return the skipped count so the runner logs the mismatch without losing valid overrides. * test(verifier): cover object-shaped extractor overrides Existing tests only override scalars (777, 200), so a future jsonToJSValue regression around nested object propagation would slip through. Lock down the contract: a JSON object override should make {attrs.testTag, balance} readable from goja predicates. * fix(web-runtime): lock global runtime hooks against page shadowing AddScriptToEvaluateOnNewDocument runs first, but a page script can still delete or replace window.__sanderling{,Extractors__,NextAction__} between install and host invocation. Define them as non-writable, non-configurable properties so any attempt to shadow them throws in strict mode rather than silently breaking the run. * perf(web-runtime): cache randomTap candidate DOM scan per tick The 16-attempt retry loop in __sanderlingNextAction__ called randomTap repeatedly; each call ran querySelectorAll over a-button- input-... and re-flushed layout per match via getBoundingClientRect. On heavy SPA routes that's the per-tick budget gone. Cache the scan in a module-level slot, reset at the top of each __sanderlingNextAction__ invocation so the cache doesn't outlive a tick. * fix(web-runtime): cap sanitize recursion to prevent stack overflow State exposes document and window (per WebState in types.ts). A user extractor returning either crashes the runtime via stack overflow on the circular DOM/Window references. Track seen objects in a WeakSet and bail at depth 32 so the worst case becomes a truncated value, not a process kill. * fix(web-runtime): enforce pressKey allowlist in factory The factory accepted any string while randomPressKey only emitted enter/tab/escape/arrows. A spec emitting pressKey({key:"home"}) would flow through to the chrome driver, which rejects unsupported keys with a runtime error mid-step. Reject at the factory so the spec author sees the failure where it originates. * chore(chrome): drop dead bundleSource/bundleMu bundleSource was written under bundleMu but never read. Either remove it or wire a re-install path; remove until the second is actually needed. * fix(chrome): use strconv.Atoi for extractor key parsing fmt.Sscanf("%d", ...) silently accepts trailing garbage like "3abc" as 3. strconv.Atoi rejects the same input outright, so a malformed key surfaces as an error instead of a wrong-bucket override. * fix(doctor): raise per-check timeout to 15s for chromium launch 5s could time out the headless chromium check on cold CI. Most checks finish in milliseconds, so a longer ceiling doesn't slow real failures. * fix(runner): trust V8 coordinates for InputText, even at origin resolveCoordinates required strict positive X/Y, so a V8-emitted InputText for an element at viewport (0, *) or (*, 0) skipped the focus tap and typed into whatever was focused. Distinguish the selector-driven path (mobile) from the coords-only path (web V8) so edge coordinates are honored without breaking the existing tree-lookup fallback. Add applyAction tests covering both the typical web case and the (0,0) edge case. * test(bundler): lock down deterministic output across builds The review flagged map-iteration nondeterminism as a possible cause of unstable bundle SHAs. Empirically esbuild's Define handling is order- independent (parallel substitution rules), so output is already stable. Add a regression test that builds 10x with multiple Defines and asserts SHA equality so any future change that introduces ordering surfaces. --- cmd/sanderling/doctor.go | 100 ++- cmd/sanderling/doctor_test.go | 101 +++ cmd/sanderling/main.go | 16 +- examples/folio-web/.gitignore | 3 + examples/folio-web/sanderling/spec.ts | 132 ++-- examples/folio-web/src/pages/Home.tsx | 5 +- examples/folio-web/src/pages/Ledger.tsx | 4 +- inspect-ui/bun.lock | 9 + inspect-ui/dist/assets/index-BSJUf7yI.css | 1 - inspect-ui/dist/assets/index-BV6z-_9-.css | 1 + .../{index-DAhEzdrG.js => index-CEStFXHZ.js} | 22 +- inspect-ui/dist/index.html | 4 +- inspect-ui/package.json | 1 + inspect-ui/src/__tests__/api.test.ts | 10 + inspect-ui/src/panels/HierarchyPanel.css | 83 +++ inspect-ui/src/panels/HierarchyPanel.tsx | 89 +++ inspect-ui/src/routes/RunDetail.tsx | 11 + inspect-ui/src/types.ts | 2 + inspect-ui/tsconfig.json | 2 +- internal/bundler/web_bundle.go | 113 ++++ internal/bundler/web_bundle_test.go | 130 ++++ internal/driver/chrome/driver.go | 131 +++- internal/driver/chrome/driver_test.go | 44 ++ internal/driver/chrome/translate.go | 109 ++++ internal/driver/chrome/translate_test.go | 108 ++++ internal/driver/driver.go | 25 + internal/runner/runner.go | 130 +++- internal/runner/runner_test.go | 31 + internal/testrun/driver.go | 3 + internal/testrun/preflight.go | 61 ++ internal/testrun/preflight_test.go | 82 +++ internal/testrun/testrun.go | 67 +- internal/trace/writer.go | 14 +- internal/verifier/verifier_test.go | 103 ++++ internal/verifier/worker.go | 32 + pkg/spec/src/index.ts | 1 + pkg/spec/src/types.ts | 21 + pkg/spec/src/web-runtime.ts | 582 ++++++++++++++++++ 38 files changed, 2244 insertions(+), 139 deletions(-) create mode 100644 examples/folio-web/.gitignore delete mode 100644 inspect-ui/dist/assets/index-BSJUf7yI.css create mode 100644 inspect-ui/dist/assets/index-BV6z-_9-.css rename inspect-ui/dist/assets/{index-DAhEzdrG.js => index-CEStFXHZ.js} (59%) create mode 100644 inspect-ui/src/__tests__/api.test.ts create mode 100644 inspect-ui/src/panels/HierarchyPanel.css create mode 100644 inspect-ui/src/panels/HierarchyPanel.tsx create mode 100644 internal/bundler/web_bundle.go create mode 100644 internal/bundler/web_bundle_test.go create mode 100644 internal/driver/chrome/driver_test.go create mode 100644 internal/driver/chrome/translate.go create mode 100644 internal/driver/chrome/translate_test.go create mode 100644 internal/testrun/preflight.go create mode 100644 internal/testrun/preflight_test.go create mode 100644 pkg/spec/src/web-runtime.ts diff --git a/cmd/sanderling/doctor.go b/cmd/sanderling/doctor.go index 2eb93a7..cfd579d 100644 --- a/cmd/sanderling/doctor.go +++ b/cmd/sanderling/doctor.go @@ -2,6 +2,7 @@ package main import ( "context" + "flag" "fmt" "io" "os" @@ -11,6 +12,8 @@ import ( "strconv" "time" + "github.com/chromedp/chromedp" + "github.com/priyanshujain/sanderling/internal/sidecar" ) @@ -19,7 +22,31 @@ type doctorCheck struct { Run func(ctx context.Context) error } -func defaultDoctorChecks() []doctorCheck { +// doctorChecksFor returns the host-readiness checks for a target platform. +// "all" returns the union (deduped by name) so the legacy zero-arg `doctor` +// behaviour keeps surfacing every platform's prerequisites. +func doctorChecksFor(platform string) []doctorCheck { + switch platform { + case "web": + return webChecks() + case "android": + return androidChecks() + case "ios": + return iosChecks() + case "all": + return allChecks() + default: + return nil + } +} + +func webChecks() []doctorCheck { + return []doctorCheck{ + {Name: "headless chromium can launch", Run: checkChromiumLaunch}, + } +} + +func androidChecks() []doctorCheck { return []doctorCheck{ {Name: "adb on PATH", Run: checkExecutableOnPath("adb")}, {Name: "emulator on PATH or under ANDROID_HOME", Run: checkEmulator}, @@ -28,6 +55,49 @@ func defaultDoctorChecks() []doctorCheck { } } +func iosChecks() []doctorCheck { + return []doctorCheck{ + {Name: "xcrun on PATH", Run: checkExecutableOnPath("xcrun")}, + {Name: "simctl on PATH", Run: checkExecutableOnPath("simctl")}, + {Name: "java 17+ on PATH", Run: checkJavaVersion}, + {Name: "sidecar JAR is real (not placeholder)", Run: checkSidecarJAR}, + } +} + +func allChecks() []doctorCheck { + seen := map[string]bool{} + var combined []doctorCheck + for _, group := range [][]doctorCheck{webChecks(), androidChecks(), iosChecks()} { + for _, c := range group { + if seen[c.Name] { + continue + } + seen[c.Name] = true + combined = append(combined, c) + } + } + return combined +} + +// checkChromiumLaunch boots a headless chromium under chromedp's default +// allocator, opens a blank tab, and tears down. Confirms the bundled CDP +// surface plus a working Chromium binary path. +func checkChromiumLaunch(ctx context.Context) error { + allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, + append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", true), + chromedp.Flag("disable-gpu", true), + )..., + ) + defer allocCancel() + tabCtx, tabCancel := chromedp.NewContext(allocCtx) + defer tabCancel() + if err := chromedp.Run(tabCtx, chromedp.Navigate("about:blank")); err != nil { + return fmt.Errorf("chromium launch: %w", err) + } + return nil +} + func checkSidecarJAR(_ context.Context) error { if sidecar.IsPlaceholder() { return fmt.Errorf("placeholder JAR embedded; run `make sidecar && make sanderling` to embed the real fat JAR") @@ -38,10 +108,36 @@ func checkSidecarJAR(_ context.Context) error { return nil } +type doctorOptions struct { + platform string +} + +func parseDoctorArgs(args []string, stderr io.Writer) (doctorOptions, error) { + flagSet := flag.NewFlagSet("doctor", flag.ContinueOnError) + flagSet.SetOutput(stderr) + var options doctorOptions + flagSet.StringVar(&options.platform, "platform", "all", "target platform: web, android, ios, all") + if err := flagSet.Parse(args); err != nil { + return doctorOptions{}, err + } + switch options.platform { + case "web", "android", "ios", "all": + return options, nil + default: + return doctorOptions{}, fmt.Errorf("unsupported platform: %q (web, android, ios, all)", options.platform) + } +} + +// doctorCheckTimeout bounds a single host-readiness check. Most checks (exec +// lookups, file stats, java -version) finish in milliseconds, but +// checkChromiumLaunch boots a real browser and can exceed 5s on a cold CI +// host - 15s leaves headroom without making real failures feel hung. +const doctorCheckTimeout = 15 * time.Second + func runDoctorChecks(ctx context.Context, checks []doctorCheck, stdout io.Writer) error { failures := 0 for _, check := range checks { - callCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + callCtx, cancel := context.WithTimeout(ctx, doctorCheckTimeout) err := check.Run(callCtx) cancel() if err != nil { diff --git a/cmd/sanderling/doctor_test.go b/cmd/sanderling/doctor_test.go index 43c157a..d33a34c 100644 --- a/cmd/sanderling/doctor_test.go +++ b/cmd/sanderling/doctor_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "errors" + "flag" + "io" "strings" "testing" ) @@ -81,3 +83,102 @@ func TestCheckExecutableOnPath_MissingCommand(t *testing.T) { t.Errorf("expected error for missing command") } } + +func TestDoctorChecksFor_Web_OmitsJava(t *testing.T) { + for _, c := range doctorChecksFor("web") { + if strings.Contains(c.Name, "java") || strings.Contains(c.Name, "sidecar") || strings.Contains(c.Name, "adb") { + t.Errorf("web checks should not include %q", c.Name) + } + } + if len(doctorChecksFor("web")) == 0 { + t.Error("web checks empty") + } +} + +func TestDoctorChecksFor_Android_IncludesADB(t *testing.T) { + checks := doctorChecksFor("android") + found := false + for _, c := range checks { + if strings.Contains(c.Name, "adb") { + found = true + } + } + if !found { + t.Errorf("android checks missing adb: %+v", checks) + } +} + +func TestDoctorChecksFor_iOS_IncludesXcrun(t *testing.T) { + checks := doctorChecksFor("ios") + found := false + for _, c := range checks { + if strings.Contains(c.Name, "xcrun") { + found = true + } + } + if !found { + t.Errorf("ios checks missing xcrun: %+v", checks) + } +} + +func TestDoctorChecksFor_All_IsUnion(t *testing.T) { + all := doctorChecksFor("all") + names := map[string]int{} + for _, c := range all { + names[c.Name]++ + } + for _, name := range []string{"adb on PATH", "xcrun on PATH", "headless chromium can launch"} { + if names[name] != 1 { + t.Errorf("expected %q in 'all' exactly once, got %d", name, names[name]) + } + } +} + +func TestDoctorChecksFor_UnknownPlatform(t *testing.T) { + if got := doctorChecksFor("fuchsia"); got != nil { + t.Errorf("expected nil for unknown platform, got %+v", got) + } +} + +func TestParseDoctorArgs_DefaultAll(t *testing.T) { + options, err := parseDoctorArgs(nil, io.Discard) + if err != nil { + t.Fatal(err) + } + if options.platform != "all" { + t.Errorf("default platform: got %q, want all", options.platform) + } +} + +func TestParseDoctorArgs_ExplicitPlatform(t *testing.T) { + for _, form := range [][]string{ + {"--platform", "web"}, + {"--platform=web"}, + } { + options, err := parseDoctorArgs(form, io.Discard) + if err != nil { + t.Fatalf("%v: %v", form, err) + } + if options.platform != "web" { + t.Errorf("%v: got platform=%q, want web", form, options.platform) + } + } +} + +func TestParseDoctorArgs_RejectsUnknown(t *testing.T) { + if _, err := parseDoctorArgs([]string{"--platform=fuchsia"}, io.Discard); err == nil { + t.Error("expected error for unsupported platform") + } + if _, err := parseDoctorArgs([]string{"--bogus"}, io.Discard); err == nil { + t.Error("expected error for unknown argument") + } +} + +func TestParseDoctorArgs_HelpReturnsErrHelp(t *testing.T) { + if _, err := parseDoctorArgs([]string{"-h"}, io.Discard); !errors.Is(err, flag.ErrHelp) { + t.Errorf("expected flag.ErrHelp for -h, got %v", err) + } + if _, err := parseDoctorArgs([]string{"--help"}, io.Discard); !errors.Is(err, flag.ErrHelp) { + t.Errorf("expected flag.ErrHelp for --help, got %v", err) + } +} diff --git a/cmd/sanderling/main.go b/cmd/sanderling/main.go index 53b49fa..e14fe37 100644 --- a/cmd/sanderling/main.go +++ b/cmd/sanderling/main.go @@ -76,8 +76,13 @@ func runTest(options testOptions, stdout io.Writer) error { return runTestPipeline(ctx, options, stdout) } -func runDoctor(stdout io.Writer) error { - return runDoctorChecks(context.Background(), defaultDoctorChecks(), stdout) +func runDoctor(args []string, stdout, stderr io.Writer) error { + options, err := parseDoctorArgs(args, stderr) + if err != nil { + return err + } + checks := doctorChecksFor(options.platform) + return runDoctorChecks(context.Background(), checks, stdout) } func run(args []string, stdout, stderr io.Writer) error { @@ -99,7 +104,7 @@ func run(args []string, stdout, stderr io.Writer) error { } return runInspect(options, stdout) case "doctor": - return runDoctor(stdout) + return runDoctor(args[2:], stdout, stderr) case "version", "-v", "--version": fmt.Fprintln(stdout, Version) return nil @@ -110,6 +115,11 @@ func run(args []string, stdout, stderr io.Writer) error { func main() { if err := run(os.Args, os.Stdout, os.Stderr); err != nil { + // flag.ErrHelp means -h/--help was requested; flag already printed + // usage to stderr, so exit 0 rather than treating it as a failure. + if errors.Is(err, flag.ErrHelp) { + return + } fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } diff --git a/examples/folio-web/.gitignore b/examples/folio-web/.gitignore new file mode 100644 index 0000000..590bc01 --- /dev/null +++ b/examples/folio-web/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +sanderling/runs/ diff --git a/examples/folio-web/sanderling/spec.ts b/examples/folio-web/sanderling/spec.ts index cf3ba9e..b201a49 100644 --- a/examples/folio-web/sanderling/spec.ts +++ b/examples/folio-web/sanderling/spec.ts @@ -8,81 +8,79 @@ import { from, next, now, - swipes, taps, waitOnce, weighted, } from "@sanderling/spec"; import { noUncaughtExceptions } from "@sanderling/spec/defaults/properties"; -// Route detection via stable DOM ids -const onLoginPage = extract((s) => !!s.ax.find("id:email")); -const onHomePage = extract((s) => !!s.ax.find("id:add-account")); -const onAddAccountPage = extract((s) => !!s.ax.find("id:account-name")); -const onLedgerPage = extract((s) => !!s.ax.find("id:ledger")); -const onAddTxnPage = extract((s) => !!s.ax.find("id:txn-amount")); +// Page-presence checks via stable element ids. +const onLoginPage = extract((s) => !!s.ax.find({ id: "email" })); +const onHomePage = extract((s) => !!s.ax.find({ id: "add-account" })); +const onAddAccountPage = extract((s) => !!s.ax.find({ id: "account-name" })); +const onLedgerPage = extract((s) => !!s.ax.find({ id: "ledger" })); +const onAddTxnPage = extract((s) => !!s.ax.find({ id: "txn-amount" })); -// Auth state: true on any authenticated page, false only on login page +// Auth state: true on any authenticated page, false only on login page. const loggedIn = extract((s) => { - if (s.ax.find("id:email")) return false; + if (s.ax.find({ id: "email" })) return false; return !!( - s.ax.find("id:logout") || - s.ax.find("id:add-account") || - s.ax.find("id:ledger") || - s.ax.find("id:account-name") || - s.ax.find("id:txn-amount") || - s.ax.find("id:add-txn") + s.ax.find({ id: "logout" }) || + s.ax.find({ id: "add-account" }) || + s.ax.find({ id: "ledger" }) || + s.ax.find({ id: "account-name" }) || + s.ax.find({ id: "txn-amount" }) || + s.ax.find({ id: "add-txn" }) ); }); -// Total balance from title attribute on id:total-balance (set to raw cents integer) +// Read raw cents off explicit data-cents attributes; no aria-label parsing. +function readCents(value: string | undefined): number { + if (!value) return 0; + const parsed = parseInt(value, 10); + return isNaN(parsed) ? 0 : parsed; +} + const totalBalance = extract((s) => { - const el = s.ax.find("id:total-balance"); - if (!el) return 0; - const v = parseInt((el as { desc?: string }).desc ?? "", 10); - return isNaN(v) ? 0 : v; + const el = s.ax.find({ id: "total-balance" }); + return readCents(el?.attrs?.["data-cents"]); }); -// Account cards expose "account:{id}:{balance}" via aria-label -> content-desc +// Account cards expose `data-account-id` + `data-balance` so the spec reads +// structured data without parsing aria-label. const accountCards = extract((s) => { - return s.ax.findAll("descPrefix:account:").map((el) => { - const parts = ((el as { desc?: string }).desc ?? "").split(":"); - const balance = parseInt(parts[2] ?? "0", 10); - return { element: el, id: parts[1] ?? "", balance: isNaN(balance) ? 0 : balance }; - }); + return s.ax.findAll({ "data-testid": "account-card" }).map((el) => ({ + element: el, + id: el.attrs?.["data-account-id"] ?? "", + balance: readCents(el.attrs?.["data-balance"]), + })); }); -// Ledger txn count from title on id:ledger const ledgerTxnCount = extract((s) => { - const el = s.ax.find("id:ledger"); - if (!el) return 0; - const v = parseInt((el as { desc?: string }).desc ?? "", 10); - return isNaN(v) ? 0 : v; + const el = s.ax.find({ id: "ledger" }); + return readCents(el?.attrs?.["data-txn-count"]); }); -// Ledger balance from title on id:ledger-balance const ledgerBalance = extract((s) => { - const el = s.ax.find("id:ledger-balance"); - if (!el) return 0; - const v = parseInt((el as { desc?: string }).desc ?? "", 10); - return isNaN(v) ? 0 : v; + const el = s.ax.find({ id: "ledger-balance" }); + return readCents(el?.attrs?.["data-cents"]); }); -// UI element handles -const emailField = extract((s) => s.ax.find("id:email")); -const passwordField = extract((s) => s.ax.find("id:password")); -const loginSubmit = extract((s) => s.ax.find("id:login-submit")); -const logoutButton = extract((s) => s.ax.find("id:logout")); -const addAccountButton = extract((s) => s.ax.find("id:add-account")); -const accountNameField = extract((s) => s.ax.find("id:account-name")); -const addAccountSubmit = extract((s) => s.ax.find("id:add-account-submit")); -const addTxnButton = extract((s) => s.ax.find("id:add-txn")); -const txnAmountField = extract((s) => s.ax.find("id:txn-amount")); -const txnNoteField = extract((s) => s.ax.find("id:txn-note")); -const txnCreditButton = extract((s) => s.ax.find("id:txn-credit")); -const txnDebitButton = extract((s) => s.ax.find("id:txn-debit")); -const txnSubmit = extract((s) => s.ax.find("id:txn-submit")); -const backButton = extract((s) => s.ax.find("id:back")); +// UI element handles. +const emailField = extract((s) => s.ax.find({ id: "email" })); +const passwordField = extract((s) => s.ax.find({ id: "password" })); +const loginSubmit = extract((s) => s.ax.find({ id: "login-submit" })); +const logoutButton = extract((s) => s.ax.find({ id: "logout" })); +const addAccountButton = extract((s) => s.ax.find({ id: "add-account" })); +const accountNameField = extract((s) => s.ax.find({ id: "account-name" })); +const addAccountSubmit = extract((s) => s.ax.find({ id: "add-account-submit" })); +const addTxnButton = extract((s) => s.ax.find({ id: "add-txn" })); +const txnAmountField = extract((s) => s.ax.find({ id: "txn-amount" })); +const txnNoteField = extract((s) => s.ax.find({ id: "txn-note" })); +const txnCreditButton = extract((s) => s.ax.find({ id: "txn-credit" })); +const txnDebitButton = extract((s) => s.ax.find({ id: "txn-debit" })); +const txnSubmit = extract((s) => s.ax.find({ id: "txn-submit" })); +const backButton = extract((s) => s.ax.find({ id: "back" })); // -- Properties -- @@ -98,8 +96,6 @@ const loggedOutReachesLogin = always( ), ); -// Total balance must equal the sum of all account balances visible on home page. -// Only checked when home page is active and account cards are loaded. const totalBalanceMatchesAccounts = always(() => { if (!onHomePage.current) return true; const cards = accountCards.current; @@ -108,8 +104,6 @@ const totalBalanceMatchesAccounts = always(() => { return sum === totalBalance.current; }); -// When a transaction is added (ledger txn count increases by 1 on the same -// account), the ledger balance delta matches what was actually stored. const balanceMatchesTransactionDelta = always( now(() => onLedgerPage.current && ledgerTxnCount.current > 0).implies( next(() => { @@ -119,7 +113,6 @@ const balanceMatchesTransactionDelta = always( if (curCount !== prevCount + 1) return true; const prevBal = ledgerBalance.previous ?? 0; const curBal = ledgerBalance.current; - // Delta must be non-zero (a valid transaction was added) return curBal !== prevBal; }), ), @@ -149,19 +142,7 @@ export const properties = { const DEMO_EMAIL = "demo@ledger.app"; const DEMO_PASSWORD = "ledger123"; -const loginHelper = actions(() => { - if (loggedIn.current) return []; - const email = emailField.current; - const password = passwordField.current; - const submit = loginSubmit.current; - if (!email || !password || !submit) return []; - const focused = s_focused(); - if (focused === "password") return [Tap({ on: submit })]; - if (focused === "email") return [InputText({ into: password, text: DEMO_PASSWORD })]; - return [InputText({ into: email, text: DEMO_EMAIL })]; -}); - -function s_focused(): string | null { +function focusedField(): string | null { const email = emailField.current; const password = passwordField.current; if (email && (email as { focused?: boolean }).focused) return "email"; @@ -169,6 +150,18 @@ function s_focused(): string | null { return null; } +const loginHelper = actions(() => { + if (loggedIn.current) return []; + const email = emailField.current; + const password = passwordField.current; + const submit = loginSubmit.current; + if (!email || !password || !submit) return []; + const focused = focusedField(); + if (focused === "password") return [Tap({ on: submit })]; + if (focused === "email") return [InputText({ into: password, text: DEMO_PASSWORD })]; + return [InputText({ into: email, text: DEMO_EMAIL })]; +}); + const adversarialLogin = actions(() => { if (loggedIn.current) return []; const submit = loginSubmit.current; @@ -297,7 +290,6 @@ export const actionsRoot = weighted( [6, goBack], [1, logoutAction], [4, taps], - [2, swipes], [2, waitOnce], ); diff --git a/examples/folio-web/src/pages/Home.tsx b/examples/folio-web/src/pages/Home.tsx index 0ce6a06..2625b57 100644 --- a/examples/folio-web/src/pages/Home.tsx +++ b/examples/folio-web/src/pages/Home.tsx @@ -86,8 +86,7 @@ export function HomePage(props: {

Total balance navigate(`/accounts/${a.id}`)} >