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: {
| tag/class | +id | +text/desc | +bounds | +
|---|
` command. Web returns nil (no host
+// prerequisites beyond a working chromium, which the driver will surface
+// itself if missing).
+func Preflight(ctx context.Context, platform string) error {
+ check := preflightCheck
+ return runPreflight(ctx, platform, check)
+}
+
+type preflightFunc func(name string) error
+
+func preflightCheck(name string) error {
+ if _, err := exec.LookPath(name); err != nil {
+ return fmt.Errorf("%s not found on PATH: %w", name, err)
+ }
+ return nil
+}
+
+func runPreflight(ctx context.Context, platform string, check preflightFunc) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ switch platform {
+ case "web":
+ return nil
+ case "android":
+ if err := check("adb"); err != nil {
+ return preflightFailure("android", err)
+ }
+ if err := check("java"); err != nil {
+ return preflightFailure("android", err)
+ }
+ return nil
+ case "ios":
+ if err := check("xcrun"); err != nil {
+ return preflightFailure("ios", err)
+ }
+ if err := check("java"); err != nil {
+ return preflightFailure("ios", err)
+ }
+ return nil
+ default:
+ return fmt.Errorf("preflight: unknown platform %q", platform)
+ }
+}
+
+func preflightFailure(platform string, cause error) error {
+ return fmt.Errorf(
+ "preflight: %w\nrun `sanderling doctor --platform=%s` for full host-readiness checks",
+ cause, platform,
+ )
+}
diff --git a/internal/testrun/preflight_test.go b/internal/testrun/preflight_test.go
new file mode 100644
index 0000000..d84d033
--- /dev/null
+++ b/internal/testrun/preflight_test.go
@@ -0,0 +1,82 @@
+package testrun
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestPreflight_WebSkips(t *testing.T) {
+ called := 0
+ check := func(name string) error {
+ called++
+ return nil
+ }
+ if err := runPreflight(context.Background(), "web", check); err != nil {
+ t.Fatalf("web preflight should be no-op, got %v", err)
+ }
+ if called != 0 {
+ t.Errorf("web preflight ran %d binary checks; expected 0", called)
+ }
+}
+
+func TestPreflight_AndroidNeedsAdbAndJava(t *testing.T) {
+ cases := []struct {
+ name string
+ missing string
+ wantInErr string
+ }{
+ {name: "missing adb", missing: "adb", wantInErr: "adb"},
+ {name: "missing java", missing: "java", wantInErr: "java"},
+ }
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ check := func(name string) error {
+ if name == testCase.missing {
+ return errors.New(name + " not found")
+ }
+ return nil
+ }
+ err := runPreflight(context.Background(), "android", check)
+ if err == nil || !strings.Contains(err.Error(), testCase.wantInErr) {
+ t.Fatalf("expected error mentioning %q, got %v", testCase.wantInErr, err)
+ }
+ if !strings.Contains(err.Error(), "sanderling doctor --platform=android") {
+ t.Errorf("error missing doctor hint: %v", err)
+ }
+ })
+ }
+}
+
+func TestPreflight_iOSNeedsXcrunAndJava(t *testing.T) {
+ check := func(name string) error {
+ if name == "xcrun" {
+ return errors.New("xcrun not found")
+ }
+ return nil
+ }
+ err := runPreflight(context.Background(), "ios", check)
+ if err == nil || !strings.Contains(err.Error(), "xcrun") {
+ t.Fatalf("expected xcrun error, got %v", err)
+ }
+ if !strings.Contains(err.Error(), "sanderling doctor --platform=ios") {
+ t.Errorf("error missing doctor hint: %v", err)
+ }
+}
+
+func TestPreflight_AllOK(t *testing.T) {
+ check := func(name string) error { return nil }
+ for _, platform := range []string{"web", "android", "ios"} {
+ if err := runPreflight(context.Background(), platform, check); err != nil {
+ t.Errorf("%s: unexpected error %v", platform, err)
+ }
+ }
+}
+
+func TestPreflight_UnknownPlatform(t *testing.T) {
+ check := func(string) error { return nil }
+ if err := runPreflight(context.Background(), "fuchsia", check); err == nil {
+ t.Error("expected error for unknown platform")
+ }
+}
diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go
index fb25818..7fb2ac4 100644
--- a/internal/testrun/testrun.go
+++ b/internal/testrun/testrun.go
@@ -11,6 +11,7 @@ import (
"github.com/priyanshujain/sanderling/internal/android"
"github.com/priyanshujain/sanderling/internal/bundler"
+ "github.com/priyanshujain/sanderling/internal/driver"
"github.com/priyanshujain/sanderling/internal/ios"
"github.com/priyanshujain/sanderling/internal/runner"
"github.com/priyanshujain/sanderling/internal/trace"
@@ -45,24 +46,44 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
}
}
aliases := map[string]string{}
- if specAPIPath := resolveSpecAPIPath(options.Spec); specAPIPath != "" {
+ specAPIPath := resolveSpecAPIPath(options.Spec)
+ if specAPIPath != "" {
aliases["@sanderling/spec"] = specAPIPath
base := filepath.Dir(specAPIPath)
aliases["@sanderling/spec/defaults/properties"] = filepath.Join(base, "defaults/properties.ts")
}
+ defines := map[string]string{
+ "SANDERLING_TEST_PHONE": os.Getenv("SANDERLING_TEST_PHONE"),
+ "SANDERLING_TEST_OTP": os.Getenv("SANDERLING_TEST_OTP"),
+ }
bundle, err := bundler.Bundle(bundler.Options{
EntryFile: options.Spec,
- Defines: map[string]string{
- "SANDERLING_TEST_PHONE": os.Getenv("SANDERLING_TEST_PHONE"),
- "SANDERLING_TEST_OTP": os.Getenv("SANDERLING_TEST_OTP"),
- },
- Aliases: aliases,
+ Defines: defines,
+ Aliases: aliases,
})
if err != nil {
return fmt.Errorf("bundle spec: %w", err)
}
fmt.Fprintf(stdout, "bundled spec: %d bytes (sha256=%s)\n", len(bundle.JavaScript), bundle.SHA256[:12])
+ var webBundle bundler.Result
+ if options.Platform == "web" {
+ runtimePath := resolveWebRuntimePath(specAPIPath, options.Spec)
+ if runtimePath == "" {
+ return fmt.Errorf("web-runtime.ts not found near %s; checkout pkg/spec or set @sanderling/spec alias", options.Spec)
+ }
+ webBundle, err = bundler.BundleWeb(bundler.WebOptions{
+ EntryFile: options.Spec,
+ WebRuntimeFile: runtimePath,
+ Defines: defines,
+ Aliases: aliases,
+ })
+ if err != nil {
+ return fmt.Errorf("bundle web spec: %w", err)
+ }
+ fmt.Fprintf(stdout, "bundled web spec: %d bytes (sha256=%s)\n", len(webBundle.JavaScript), webBundle.SHA256[:12])
+ }
+
activeDriver, cleanup, err := buildDriver(ctx, options, stdout)
if err != nil {
return err
@@ -73,6 +94,12 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
return fmt.Errorf("launch app: %w", err)
}
+ if web, ok := activeDriver.(driver.WebDriver); ok && len(webBundle.JavaScript) > 0 {
+ if err := web.InstallBundle(ctx, webBundle.JavaScript); err != nil {
+ return fmt.Errorf("install web bundle: %w", err)
+ }
+ }
+
seed := options.Seed
if seed == 0 {
seed = time.Now().UnixNano()
@@ -142,6 +169,34 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
return nil
}
+// resolveWebRuntimePath returns the path to pkg/spec/src/web-runtime.ts.
+// Tries the spec-API checkout first (so monorepo development works without
+// publishing the package), then falls back to a sibling of the resolved
+// @sanderling/spec entry, and finally to a node_modules path.
+func resolveWebRuntimePath(specAPIPath, userSpecPath string) string {
+ if specAPIPath != "" {
+ candidate := filepath.Join(filepath.Dir(specAPIPath), "web-runtime.ts")
+ if _, err := os.Stat(candidate); err == nil {
+ return candidate
+ }
+ }
+ if absoluteSpec, err := filepath.Abs(userSpecPath); err == nil {
+ directory := filepath.Dir(absoluteSpec)
+ for {
+ candidate := filepath.Join(directory, "node_modules", "@sanderling", "spec", "src", "web-runtime.ts")
+ if _, err := os.Stat(candidate); err == nil {
+ return candidate
+ }
+ parent := filepath.Dir(directory)
+ if parent == directory {
+ break
+ }
+ directory = parent
+ }
+ }
+ return ""
+}
+
// resolveSpecAPIPath returns the path to pkg/spec/src/index.ts inside
// a sanderling source checkout, searched upward from the spec file and the cwd.
// Returns "" when not found, in which case esbuild resolves @sanderling/spec via
diff --git a/internal/trace/writer.go b/internal/trace/writer.go
index 4dfad4a..bb17c5f 100644
--- a/internal/trace/writer.go
+++ b/internal/trace/writer.go
@@ -13,13 +13,13 @@ import (
)
type Step struct {
- Index int `json:"step"`
- Timestamp time.Time `json:"timestamp"`
- Screen string `json:"screen,omitempty"`
- Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"`
- Action *Action `json:"action,omitempty"`
- Exceptions []Exception `json:"exceptions,omitempty"`
- Violations []string `json:"violations,omitempty"`
+ Index int `json:"step"`
+ Timestamp time.Time `json:"timestamp"`
+ Screen string `json:"screen,omitempty"`
+ Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"`
+ Action *Action `json:"action,omitempty"`
+ Exceptions []Exception `json:"exceptions,omitempty"`
+ Violations []string `json:"violations,omitempty"`
Hierarchy *hierarchy.Tree `json:"hierarchy,omitempty"`
Residuals map[string]json.RawMessage `json:"residuals,omitempty"`
Metrics *Metrics `json:"metrics,omitempty"`
diff --git a/internal/verifier/verifier_test.go b/internal/verifier/verifier_test.go
index 47dbb68..449d59a 100644
--- a/internal/verifier/verifier_test.go
+++ b/internal/verifier/verifier_test.go
@@ -532,3 +532,106 @@ globalThis.properties = {
}
}
}
+
+func TestOverrideExtractorValues_PreservesPrevious(t *testing.T) {
+ verifier := newVerifier(t)
+ mustLoad(t, verifier, helloSpec)
+
+ if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": json.RawMessage(`100`)}}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := verifier.OverrideExtractorValues(map[int]json.RawMessage{1: json.RawMessage(`777`)}); err != nil {
+ t.Fatal(err)
+ }
+ balance := verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime)
+ if balance.Get("current").ToInteger() != 777 {
+ t.Errorf("override didn't take: current=%v", balance.Get("current"))
+ }
+
+ // Next push: previous mirrors the *override*, not the snapshot value.
+ if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": json.RawMessage(`200`)}}); err != nil {
+ t.Fatal(err)
+ }
+ balance = verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime)
+ if balance.Get("previous").ToInteger() != 777 {
+ t.Errorf("previous should reflect override, got %v", balance.Get("previous"))
+ }
+}
+
+func TestOverrideExtractorValues_NilIsNoop(t *testing.T) {
+ verifier := newVerifier(t)
+ mustLoad(t, verifier, helloSpec)
+ if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": json.RawMessage(`42`)}}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := verifier.OverrideExtractorValues(nil); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := verifier.OverrideExtractorValues(map[int]json.RawMessage{}); err != nil {
+ t.Fatal(err)
+ }
+ balance := verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime)
+ if balance.Get("current").ToInteger() != 42 {
+ t.Errorf("expected snapshot-driven current to remain 42, got %v", balance.Get("current"))
+ }
+}
+
+func TestOverrideExtractorValues_UnknownIndexSkipped(t *testing.T) {
+ verifier := newVerifier(t)
+ mustLoad(t, verifier, helloSpec)
+ if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": json.RawMessage(`42`)}}); err != nil {
+ t.Fatal(err)
+ }
+ skipped, err := verifier.OverrideExtractorValues(map[int]json.RawMessage{
+ 1: json.RawMessage(`777`),
+ 99: json.RawMessage(`1`),
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if skipped != 1 {
+ t.Errorf("expected 1 skipped entry, got %d", skipped)
+ }
+ balance := verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime)
+ if balance.Get("current").ToInteger() != 777 {
+ t.Errorf("valid override should still apply alongside skipped one, got current=%v", balance.Get("current"))
+ }
+}
+
+const objectExtractorSpec = `
+const card = __sanderling__.extract(state => ({attrs: {testTag: "default"}, balance: 0}));
+globalThis.card = card;
+
+globalThis.properties = {
+ hasTestTag: __sanderling__.always(() => typeof card.current.attrs.testTag === "string"),
+};
+
+globalThis.actions = __sanderling__.actions(() => []);
+`
+
+func TestOverrideExtractorValues_PropagatesNestedObjectFields(t *testing.T) {
+ verifier := newVerifier(t)
+ mustLoad(t, verifier, objectExtractorSpec)
+
+ if err := verifier.PushSnapshot(SnapshotInput{}); err != nil {
+ t.Fatal(err)
+ }
+ override := json.RawMessage(`{"attrs": {"testTag": "account-card"}, "balance": 12345}`)
+ skipped, err := verifier.OverrideExtractorValues(map[int]json.RawMessage{0: override})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if skipped != 0 {
+ t.Errorf("unexpected skipped count: %d", skipped)
+ }
+
+ card := verifier.runtime.GlobalObject().Get("card").ToObject(verifier.runtime)
+ current := card.Get("current").ToObject(verifier.runtime)
+ attrs := current.Get("attrs").ToObject(verifier.runtime)
+ if got := attrs.Get("testTag").String(); got != "account-card" {
+ t.Errorf("nested override missing: card.current.attrs.testTag = %q, want %q", got, "account-card")
+ }
+ if got := current.Get("balance").ToInteger(); got != 12345 {
+ t.Errorf("scalar field missing: card.current.balance = %d, want 12345", got)
+ }
+}
diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go
index 0ac7729..d588339 100644
--- a/internal/verifier/worker.go
+++ b/internal/verifier/worker.go
@@ -1,6 +1,7 @@
package verifier
import (
+ "encoding/json"
"errors"
"fmt"
"math/rand/v2"
@@ -238,6 +239,37 @@ func (v *Verifier) PushSnapshot(input SnapshotInput) error {
return nil
}
+// OverrideExtractorValues replaces each extractor's `current` slot with a
+// caller-supplied value, keyed by registration index. Used by the web tick
+// path so extractor bodies that ran in V8 (against the real DOM) drive the
+// goja-side LTL predicates without re-running the getter against an empty
+// state.ax shim. Passing a nil/empty map is a no-op so the mobile path can
+// call this unconditionally. The override must run *after* PushSnapshot
+// (which advanced `previous`) and *before* EvaluateProperties.
+//
+// Out-of-range indices are tolerated (skipped) rather than fatal: V8 and goja
+// register extractors from the same spec bundle so counts should always
+// match, but a stale or partial override map should not block valid overrides
+// from applying. The number of skipped entries is reported so the caller can
+// surface a mismatch.
+func (v *Verifier) OverrideExtractorValues(overrides map[int]json.RawMessage) (skipped int, err error) {
+ if len(overrides) == 0 {
+ return 0, nil
+ }
+ for index, raw := range overrides {
+ if index < 0 || index >= len(v.extractors) {
+ skipped++
+ continue
+ }
+ value, conversionErr := jsonToJSValue(v.runtime, raw)
+ if conversionErr != nil {
+ return skipped, fmt.Errorf("extractor override %d: %w", index, conversionErr)
+ }
+ _ = v.extractors[index].handle.Set("current", value)
+ }
+ return skipped, nil
+}
+
// SnapshotInput bundles everything a step feeds into the verifier. Fields
// other than Snapshots are optional; callers that only have snapshots can
// populate Snapshots alone and leave the rest zero.
diff --git a/pkg/spec/src/index.ts b/pkg/spec/src/index.ts
index 39f9f46..4bbb54e 100644
--- a/pkg/spec/src/index.ts
+++ b/pkg/spec/src/index.ts
@@ -23,6 +23,7 @@ export type {
TapAction,
SanderlingRuntime,
WaitAction,
+ WebState,
WeightedEntry,
} from "./types.ts";
diff --git a/pkg/spec/src/types.ts b/pkg/spec/src/types.ts
index a72eb3d..cd61b62 100644
--- a/pkg/spec/src/types.ts
+++ b/pkg/spec/src/types.ts
@@ -1,3 +1,5 @@
+///