Files
sanderling/internal/testrun/testrun.go
pj 7343085614 llm action-selection backend (#68)
* feat(spec): add llm() action-backend marker

* feat(spec): make llm marker inert on the JS picker

* feat(spec): expose __sanderlingSampleInput__ corpus draw

* feat(openrouter): minimal chat-completions client

* test(openrouter): cover request shape, parse, and errors

* feat(verifier): thread screenshot + capture corpus sampler

* feat(verifier): LLM accessors — candidates, config, sampler

* test(verifier): cover AllCandidates, LLMConfig, SampleInput

* feat(trace): record action Source and LLMReasoning

* feat(runner): thread step screenshot into PushSnapshot

* feat(runner): llmSource selects actions via OpenRouter

* feat(runner): wire llmSource selection and trace stamping

* test(runner): cover llmSource selection, mapping, downscale

* docs(folio): add llm action-backend example spec

* docs(folio): document the LLM action backend run

* feat(llmclient): support OPENAI_API_KEY, openrouter wins

* refactor(runner): rename openrouter package to llmclient

* docs: both api keys, example model gpt-5.4-nano

* docs: add pr style rules to claude.md

* fix(runner): explain action kinds in llm prompt to stop swipe loops

* feat(trace): record llm ranked list and chosen rank

* feat(runner): stamp llm ranked list and chosen rank on trace

* fix(runner): tap by selector to survive layout shift after observe

* revert(runner): drop selector-first tap; broke path/testTag selectors

* feat(spec): llm() accepts optional instructions

* feat(verifier): read llm instructions off config

* feat(runner): append spec instructions to llm system prompt

* docs(folio): describe app in llm spec instructions

* feat(bundler): map generator export to globalThis.generator

* feat(verifier): read llm config off globalThis.generator

* feat(runner): gate llm source on --generator flag

* feat(cmd): add --generator llm|seeded flag

* test: cover --generator flag parsing and pickSources gating

* feat(verifier): enumerate llm candidates by walking actionsRoot

collect-walk the weighted action tree: recurse weighted branches
accumulating selection probability, call authored leaves once for
concrete actions, enumerate builtins per element. label controls by
visible text (borrowing descendant text), fold gestures into directional
scrolls over scrollable containers, drop disabled, dedup descriptions.

* test(verifier): cover candidate enumeration walk

* feat(verifier): add SetupAction to walk setup without the seeded root

* test(verifier): cover SetupAction setup-only precedence

* refactor(llmclient): make JSONSchema.Schema raw json for pinned field order

* feat(trace): record llm choice number and chosen_action echo

* feat(runner): llm picks one number from weighted candidates

drop the seeded-root call for a setup-only precedence path, render a
numbered weighted candidate list, pin a reasoning-first choice schema,
strict-skip when chosen_action does not echo the numbered entry, and let
the model supply typed values (corpus fallback when empty).

* test(runner): cover choice schema, strict-skip, and setup precedence

* refactor(verifier): drop the superseded AllCandidates enumeration

* feat(folio): drive spec.ts under --generator llm; drop spec-llm.ts

* fix(verifier): label editable fields by hint, not the typed value

an editable field's own text is its transient content; prefer the hint
so the field is named by purpose and the label stays stable.

* test(runner): cover weight-suffixed echo and stripWeightSuffix

* fix(runner): accept chosen_action echo that carries the weight suffix

real runs showed the model copies the whole numbered line including the
trailing (w34) weight annotation, so strict-skip rejected ~91% of picks
and the llm was paralyzed. strip the weight suffix before comparing. also
nudge the prompt to stress-test repeated submissions (idempotency).

* fix(verifier): skip llm enumeration on cross-fade frames

a navhost mid-transition carries >1 route *Screen in a collapsed
coordinate space; acting on it taps garbage (soft keyboard). real runs
showed the llm acting on 44% of steps being such frames. skip them so the
llm re-observes a settled frame next step.

* feat(folio): show current balance on the add-transaction screen

renders the account's balance (testTag TxnCurrentBalance) below the
account name, above the credit/debit toggle, so before/after screenshots
carry comparison data.

* fix(replay): derive device space from screen extent, not first node

the first positive-bounds element is often a short status-bar node
(320x24 on android); using it gave a 320/24 aspect ratio that squashed
the screenshot overlay into a grey horizontal band. use the max extent
across elements (like the runner's screenBounds) instead.

* fix(folio): show balance as a compact one-line label

per review: one line, account-name-sized, e.g. "Balance: $0.00"
instead of a large balance card.

* fix(folio): move balance into the header, one compact line under the account name

* fix(replay): attribute deferred violations to the causing step, not detection

* fix(replay): show a step's own violations in both panels, no next-step bleed

* refactor(hierarchy): one Tree.Transitional, drop the duplicated cross-fade check

* chore: ignore .playwright-mcp scratch output

* docs: document the llm generator and --generator flag

* docs(spec): correct the llm() comment; config reads off globalThis.generator

* docs: add pr description rules
2026-07-31 21:12:00 +05:30

303 lines
9.9 KiB
Go

// Package testrun wires together the device, bundler, runner, and verifier into a single test pipeline.
package testrun
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"time"
"github.com/priyanshujain/sanderling/internal/android"
"github.com/priyanshujain/sanderling/internal/bundler"
"github.com/priyanshujain/sanderling/internal/driver"
"github.com/priyanshujain/sanderling/internal/runner"
"github.com/priyanshujain/sanderling/internal/trace"
"github.com/priyanshujain/sanderling/internal/verifier"
)
const sidecarStartupTimeout = 30 * time.Second
// Options are the parameters for a single test pipeline run.
type Options struct {
Spec string
BundleID string
Platform string
AVD string
Device string
IosDevice string
IosAppPath string
AndroidAppPath string
Duration time.Duration
Seed int64
Output string
ClearData bool
// Generator selects the action picker: "llm" or the default seeded picker.
Generator string
// iosUDID, iosIsSimulator, and iosCoreDeviceID are filled by Execute after
// resolving the iOS target, then read by buildDriver to choose the simulator
// companion or the physical-device driver. On the device path iosUDID is the
// hardware UDID and iosCoreDeviceID is the CoreDevice id.
iosUDID string
iosIsSimulator bool
iosCoreDeviceID string
}
// Execute runs the full test pipeline: bundle, launch app, verify properties.
func Execute(ctx context.Context, options Options, stdout io.Writer) error {
switch options.Platform {
case "android":
if err := android.EnsureDevice(ctx, options.Device, options.AVD, stdout); err != nil {
return err
}
if err := android.PrepareDevice(ctx, options.Device, stdout); err != nil {
return err
}
// Switch to 3-button navigation for the run so fuzzer swipes cannot
// trigger the gesture-nav home/back and fling the app off screen;
// restore the original mode when the run ends.
restoreNav := android.ForceThreeButtonNav(ctx, options.Device, stdout)
defer restoreNav()
case "ios":
resolved, err := resolveIOSTarget(ctx, options, stdout)
if err != nil {
return err
}
options = resolved
}
prep, err := prepareBundleInputs(options)
if err != nil {
return err
}
aliases := prep.aliases
seed := prep.seed
defines := prep.defines
specAPIPath := prep.specAPIPath
bundle, err := bundler.Bundle(bundler.Options{
EntryFile: options.Spec,
RuntimeFile: prep.gojaRuntimePath,
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
}
defer cleanup()
if err := activeDriver.Launch(ctx, options.BundleID, options.ClearData, nil); err != nil {
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)
}
}
verifierInstance, err := verifier.New(
verifier.WithSeed(uint64(seed)),
verifier.WithPlatform(options.Platform),
verifier.WithAppPackage(options.BundleID),
)
if err != nil {
return fmt.Errorf("verifier: %w", err)
}
if err := verifierInstance.Load(string(bundle.JavaScript)); err != nil {
return fmt.Errorf("load spec: %w", err)
}
fmt.Fprintln(stdout, "spec loaded into verifier")
runDirectory := filepath.Join(options.Output, time.Now().UTC().Format("20060102-150405"))
traceWriter, err := trace.NewWriter(runDirectory)
if err != nil {
return fmt.Errorf("trace writer: %w", err)
}
defer traceWriter.Close()
meta := trace.Meta{
Seed: seed,
SpecPath: options.Spec,
BundleSHA256: bundle.SHA256,
Platform: options.Platform,
BundleID: options.BundleID,
StartedAt: time.Now().UTC(),
SanderlingVersion: "0.0.1",
}
if err := traceWriter.WriteMeta(meta); err != nil {
return fmt.Errorf("trace meta: %w", err)
}
defer func() {
endedAt := time.Now().UTC()
meta.EndedAt = &endedAt
_ = traceWriter.WriteMeta(meta)
}()
fmt.Fprintf(stdout, "trace dir: %s\n", runDirectory)
fmt.Fprintf(stdout, "running for %s (seed=%d)\n", options.Duration, seed)
summary, err := runner.Run(ctx, runner.Options{
Duration: options.Duration,
IdleTimeout: 1 * time.Second,
BundleID: options.BundleID,
Driver: activeDriver,
Verifier: verifierInstance,
TraceWriter: traceWriter,
Logger: newProgressLogger(stdout),
Generator: options.Generator,
})
terminateCtx, terminateCancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = activeDriver.Terminate(terminateCtx)
terminateCancel()
if err != nil {
return fmt.Errorf("runner: %w", err)
}
fmt.Fprintf(stdout, "\nelapsed: %s\n", summary.EndTime.Sub(summary.StartTime).Round(time.Millisecond))
runner.RenderSummary(stdout, summary, options.Platform)
return nil
}
// bundleInputs holds the pre-driver assembly: alias map, seed, esbuild defines,
// and the resolved spec-API/goja-runtime paths the bundler consumes.
type bundleInputs struct {
aliases map[string]string
seed int64
defines map[string]string
specAPIPath string
gojaRuntimePath string
}
// prepareBundleInputs builds the alias map, defines, seed, and resolves the
// goja runtime path. It is the pure (no driver/JVM) front half of Execute,
// returning the documented error when the runtime entry cannot be located.
func prepareBundleInputs(options Options) (bundleInputs, error) {
aliases := map[string]string{}
specAPIPath := resolveSpecAPIPath(options.Spec)
if specAPIPath != "" {
aliases["@sanderling/spec"] = specAPIPath
base := filepath.Dir(specAPIPath)
aliases["@sanderling/spec/defaults"] = filepath.Join(base, "defaults/index.ts")
aliases["@sanderling/spec/defaults/properties"] = filepath.Join(base, "defaults/properties.ts")
}
seed := resolveSeed(options.Seed)
defines := map[string]string{
"SANDERLING_TEST_PHONE": os.Getenv("SANDERLING_TEST_PHONE"),
"SANDERLING_TEST_OTP": os.Getenv("SANDERLING_TEST_OTP"),
"SANDERLING_SEED": strconv.FormatInt(seed, 10),
}
gojaRuntimePath := resolveGojaRuntimePath(specAPIPath, options.Spec)
if gojaRuntimePath == "" {
return bundleInputs{}, fmt.Errorf("goja-runtime.ts not found near %s; checkout pkg/spec or set @sanderling/spec alias", options.Spec)
}
return bundleInputs{
aliases: aliases,
seed: seed,
defines: defines,
specAPIPath: specAPIPath,
gojaRuntimePath: gojaRuntimePath,
}, nil
}
// resolveSeed returns the configured seed, or a time-derived one when unset.
// The same value seeds both the goja PRNG and the web bundle's SANDERLING_SEED
// define, so a single run is reproducible across both runtimes.
func resolveSeed(configured int64) int64 {
if configured != 0 {
return configured
}
return time.Now().UnixNano()
}
// resolveWebRuntimePath returns the path to pkg/spec/src/web-runtime.ts.
func resolveWebRuntimePath(specAPIPath, userSpecPath string) string {
return resolveRuntimeSibling(specAPIPath, userSpecPath, "web-runtime.ts")
}
// resolveGojaRuntimePath returns the path to pkg/spec/src/goja-runtime.ts, the
// native verifier's runtime entry that installs __sanderlingNextAction__.
func resolveGojaRuntimePath(specAPIPath, userSpecPath string) string {
return resolveRuntimeSibling(specAPIPath, userSpecPath, "goja-runtime.ts")
}
// resolveRuntimeSibling finds a runtime-entry file that sits beside the spec-API
// index.ts. Tries the spec-API checkout first (so monorepo development works
// without publishing the package), then falls back to a node_modules path.
func resolveRuntimeSibling(specAPIPath, userSpecPath, filename string) string {
if specAPIPath != "" {
candidate := filepath.Join(filepath.Dir(specAPIPath), filename)
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", filename)
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
// node_modules the way a downstream user's project would.
func resolveSpecAPIPath(specPath string) string {
var candidates []string
if absoluteSpec, err := filepath.Abs(specPath); err == nil {
directory := filepath.Dir(absoluteSpec)
for {
candidates = append(candidates, filepath.Join(directory, "pkg/spec/src/index.ts"))
parent := filepath.Dir(directory)
if parent == directory {
break
}
directory = parent
}
}
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(cwd, "pkg/spec/src/index.ts"))
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return ""
}