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}`)} >