mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
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=<p>` 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 <run>/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 <run>/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/<id>/html/<name>
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.
This commit is contained in:
38 files changed
+2244
-139
No files matched your search
@@ -0,0 +1,113 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
// WebOptions configures BundleWeb. WebRuntimeFile points at
|
||||
// pkg/spec/src/web-runtime.ts (resolved via the same upward search the regular
|
||||
// Bundle uses for @sanderling/spec). When empty, BundleWeb expects callers to
|
||||
// have set Aliases such that "@sanderling/spec/web-runtime" resolves.
|
||||
type WebOptions struct {
|
||||
EntryFile string
|
||||
WebRuntimeFile string
|
||||
Defines map[string]string
|
||||
Aliases map[string]string
|
||||
}
|
||||
|
||||
// BundleWeb compiles the user spec together with the V8-side runtime into a
|
||||
// single IIFE that, on evaluation, installs `globalThis.__sanderling__` and
|
||||
// the `__sanderlingExtractors__` / `__sanderlingNextAction__` globals. The
|
||||
// host injects the result via Page.AddScriptToEvaluateOnNewDocument.
|
||||
func BundleWeb(options WebOptions) (Result, error) {
|
||||
if options.EntryFile == "" {
|
||||
return Result{}, errors.New("EntryFile is required")
|
||||
}
|
||||
if options.WebRuntimeFile == "" {
|
||||
return Result{}, errors.New("WebRuntimeFile is required")
|
||||
}
|
||||
if _, err := filepath.Abs(options.EntryFile); err != nil {
|
||||
return Result{}, fmt.Errorf("entry path: %w", err)
|
||||
}
|
||||
runtimeAbs, err := filepath.Abs(options.WebRuntimeFile)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("runtime path: %w", err)
|
||||
}
|
||||
specAbs, err := filepath.Abs(options.EntryFile)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("entry path: %w", err)
|
||||
}
|
||||
|
||||
defines := map[string]string{}
|
||||
for key, value := range options.Defines {
|
||||
defines["process.env."+key] = quoteJSString(value)
|
||||
}
|
||||
|
||||
stdinContents := fmt.Sprintf(`import %q;
|
||||
import %q;
|
||||
`, runtimeAbs, specAbs)
|
||||
|
||||
output := esbuild.Build(esbuild.BuildOptions{
|
||||
Stdin: &esbuild.StdinOptions{
|
||||
Contents: stdinContents,
|
||||
ResolveDir: filepath.Dir(specAbs),
|
||||
Loader: esbuild.LoaderTS,
|
||||
},
|
||||
Bundle: true,
|
||||
Format: esbuild.FormatIIFE,
|
||||
Target: esbuild.ES2020,
|
||||
Platform: esbuild.PlatformBrowser,
|
||||
Define: defines,
|
||||
Alias: options.Aliases,
|
||||
Write: false,
|
||||
LogLevel: esbuild.LogLevelSilent,
|
||||
})
|
||||
|
||||
if len(output.Errors) > 0 {
|
||||
var messages []string
|
||||
for _, message := range output.Errors {
|
||||
messages = append(messages, message.Text)
|
||||
}
|
||||
return Result{}, fmt.Errorf("web bundle failed: %s", strings.Join(messages, "; "))
|
||||
}
|
||||
if len(output.OutputFiles) == 0 {
|
||||
return Result{}, errors.New("web bundle produced no output files")
|
||||
}
|
||||
|
||||
javascript := output.OutputFiles[0].Contents
|
||||
sum := sha256.Sum256(javascript)
|
||||
return Result{
|
||||
JavaScript: javascript,
|
||||
SHA256: hex.EncodeToString(sum[:]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func quoteJSString(value string) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteByte('"')
|
||||
for index := 0; index < len(value); index++ {
|
||||
c := value[index]
|
||||
switch c {
|
||||
case '"', '\\':
|
||||
builder.WriteByte('\\')
|
||||
builder.WriteByte(c)
|
||||
case '\n':
|
||||
builder.WriteString("\\n")
|
||||
case '\r':
|
||||
builder.WriteString("\\r")
|
||||
case '\t':
|
||||
builder.WriteString("\\t")
|
||||
default:
|
||||
builder.WriteByte(c)
|
||||
}
|
||||
}
|
||||
builder.WriteByte('"')
|
||||
return builder.String()
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const fakeRuntime = `
|
||||
(globalThis as Record<string, unknown>).__sanderling__ = {
|
||||
extract: (g: () => unknown) => ({ current: g(), previous: undefined }),
|
||||
always: () => ({ __sanderlingFormula: true }),
|
||||
now: () => ({ __sanderlingFormula: true }),
|
||||
next: () => ({ __sanderlingFormula: true }),
|
||||
eventually: () => ({ __sanderlingFormula: true }),
|
||||
actions: (g: () => unknown) => ({ __sanderlingActionGenerator: true, generate: g }),
|
||||
weighted: () => ({ __sanderlingActionGenerator: true }),
|
||||
from: () => ({ generate: () => null }),
|
||||
tap: () => null,
|
||||
inputText: () => null,
|
||||
swipe: () => null,
|
||||
pressKey: () => null,
|
||||
wait: () => null,
|
||||
taps: {},
|
||||
swipes: {},
|
||||
waitOnce: {},
|
||||
pressKeys: {},
|
||||
};
|
||||
(globalThis as Record<string, unknown>).__sanderlingExtractors__ = () => ({});
|
||||
(globalThis as Record<string, unknown>).__sanderlingNextAction__ = () => null;
|
||||
export {};
|
||||
`
|
||||
|
||||
const fakeSpec = `
|
||||
const handle = (globalThis as { __sanderling__: { extract: (g: () => unknown) => unknown } }).__sanderling__.extract(() => 42);
|
||||
(globalThis as { actions?: unknown }).actions = handle;
|
||||
export {};
|
||||
`
|
||||
|
||||
func TestBundleWeb_RegistersExpectedGlobals(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
runtimePath := filepath.Join(directory, "web-runtime.ts")
|
||||
specPath := filepath.Join(directory, "spec.ts")
|
||||
if err := os.WriteFile(runtimePath, []byte(fakeRuntime), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(specPath, []byte(fakeSpec), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := BundleWeb(WebOptions{
|
||||
EntryFile: specPath,
|
||||
WebRuntimeFile: runtimePath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BundleWeb: %v", err)
|
||||
}
|
||||
source := string(result.JavaScript)
|
||||
for _, expected := range []string{
|
||||
"__sanderlingExtractors__",
|
||||
"__sanderlingNextAction__",
|
||||
"__sanderling__",
|
||||
} {
|
||||
if !strings.Contains(source, expected) {
|
||||
t.Errorf("bundle missing %q\nsource head:\n%s", expected, head(source, 500))
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimSpace(source), "(()") {
|
||||
t.Errorf("expected IIFE format, head:\n%s", head(source, 200))
|
||||
}
|
||||
if result.SHA256 == "" {
|
||||
t.Errorf("expected non-empty sha256")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleWeb_RejectsMissingEntry(t *testing.T) {
|
||||
if _, err := BundleWeb(WebOptions{}); err == nil {
|
||||
t.Error("expected error for empty options")
|
||||
}
|
||||
if _, err := BundleWeb(WebOptions{EntryFile: "x.ts"}); err == nil {
|
||||
t.Error("expected error for missing runtime")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBundleWeb_IsDeterministic builds the same source repeatedly with multiple
|
||||
// Defines entries and asserts the SHA matches. This guards against any future
|
||||
// change that introduces map-iteration ordering into the bundle output.
|
||||
func TestBundleWeb_IsDeterministic(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
runtimePath := filepath.Join(directory, "web-runtime.ts")
|
||||
specPath := filepath.Join(directory, "spec.ts")
|
||||
if err := os.WriteFile(runtimePath, []byte(fakeRuntime), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(specPath, []byte(fakeSpec), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
options := WebOptions{
|
||||
EntryFile: specPath,
|
||||
WebRuntimeFile: runtimePath,
|
||||
Defines: map[string]string{
|
||||
"API_BASE": "https://example.com",
|
||||
"FEATURE": "true",
|
||||
"VERSION": "1.2.3",
|
||||
"TENANT": "alpha",
|
||||
},
|
||||
}
|
||||
first, err := BundleWeb(options)
|
||||
if err != nil {
|
||||
t.Fatalf("first build: %v", err)
|
||||
}
|
||||
for attempt := range 10 {
|
||||
repeat, err := BundleWeb(options)
|
||||
if err != nil {
|
||||
t.Fatalf("attempt %d: %v", attempt, err)
|
||||
}
|
||||
if repeat.SHA256 != first.SHA256 {
|
||||
t.Fatalf("attempt %d: SHA changed (%s vs %s)", attempt, repeat.SHA256, first.SHA256)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func head(text string, max int) string {
|
||||
if len(text) <= max {
|
||||
return text
|
||||
}
|
||||
return text[:max] + "..."
|
||||
}
|
||||
@@ -4,14 +4,17 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/input"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/chromedp/chromedp/kb"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver"
|
||||
)
|
||||
@@ -126,9 +129,16 @@ func (d *Driver) Tap(_ context.Context, x, y int) error {
|
||||
}
|
||||
|
||||
func (d *Driver) TapSelector(_ context.Context, selector string) error {
|
||||
return chromedp.Run(d.tabCtx,
|
||||
chromedp.Click(selector, chromedp.NodeVisible),
|
||||
)
|
||||
target, isXPath, err := TranslateStringSelector(selector)
|
||||
if err != nil {
|
||||
// Fall back to passing the string straight through; chromedp will
|
||||
// reject it loudly if it isn't a valid CSS selector.
|
||||
target = selector
|
||||
}
|
||||
if isXPath {
|
||||
return chromedp.Run(d.tabCtx, chromedp.Click(target, chromedp.NodeVisible, chromedp.BySearch))
|
||||
}
|
||||
return chromedp.Run(d.tabCtx, chromedp.Click(target, chromedp.NodeVisible))
|
||||
}
|
||||
|
||||
func (d *Driver) InputText(_ context.Context, text string) error {
|
||||
@@ -180,15 +190,17 @@ func (d *Driver) PressKey(_ context.Context, key string) error {
|
||||
return chromedp.Run(d.tabCtx, chromedp.KeyEvent(k))
|
||||
}
|
||||
|
||||
// keyMap covers the keys web specs may emit (enter/tab/escape/arrows).
|
||||
// "back"/"home" are intentionally absent: backspace/NUL have no navigation
|
||||
// semantics in a browser, and the V8 action mix already excludes them.
|
||||
var keyMap = map[string]string{
|
||||
"back": "\b",
|
||||
"home": "\x00",
|
||||
"enter": "\r",
|
||||
"tab": "\t",
|
||||
"up": "\x26",
|
||||
"down": "\x28",
|
||||
"left": "\x25",
|
||||
"right": "\x27",
|
||||
"enter": kb.Enter,
|
||||
"tab": kb.Tab,
|
||||
"escape": kb.Escape,
|
||||
"up": kb.ArrowUp,
|
||||
"down": kb.ArrowDown,
|
||||
"left": kb.ArrowLeft,
|
||||
"right": kb.ArrowRight,
|
||||
}
|
||||
|
||||
func (d *Driver) Hierarchy(_ context.Context) (string, error) {
|
||||
@@ -206,7 +218,10 @@ func (d *Driver) Hierarchy(_ context.Context) (string, error) {
|
||||
if (el.id) attrs['resource-id'] = el.id;
|
||||
const label = el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title') || '';
|
||||
if (label) attrs['content-desc'] = label;
|
||||
if (el.tagName) attrs['class'] = el.tagName.toLowerCase();
|
||||
if (el.tagName) attrs['tag'] = el.tagName.toLowerCase();
|
||||
if (el.className && typeof el.className === 'string' && el.className.trim()) {
|
||||
attrs['class'] = el.className.trim();
|
||||
}
|
||||
if (isRoot) attrs['sanderling-screen'] = route;
|
||||
const isClickable = !!(el.onclick || el.tagName === 'A' || el.tagName === 'BUTTON' ||
|
||||
el.tagName === 'INPUT' || el.tagName === 'SELECT' ||
|
||||
@@ -310,4 +325,94 @@ func pngDimensions(png []byte) (int, int) {
|
||||
return w, h
|
||||
}
|
||||
|
||||
var _ driver.DeviceDriver = (*Driver)(nil)
|
||||
var (
|
||||
_ driver.DeviceDriver = (*Driver)(nil)
|
||||
_ driver.WebDriver = (*Driver)(nil)
|
||||
)
|
||||
|
||||
// runCtx returns a chromedp-bound context that is also cancelled when the
|
||||
// caller's ctx is cancelled. This is how step deadlines and Ctrl-C propagate
|
||||
// into a CDP round-trip - chromedp.Run only honors the ctx it is given, and
|
||||
// d.tabCtx alone has no link to the caller.
|
||||
func (d *Driver) runCtx(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
derived, cancel := context.WithCancel(d.tabCtx)
|
||||
if ctx == nil || ctx.Done() == nil {
|
||||
return derived, cancel
|
||||
}
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
case <-derived.Done():
|
||||
}
|
||||
}()
|
||||
return derived, cancel
|
||||
}
|
||||
|
||||
// InstallBundle registers the source so it runs at every freshly-navigated
|
||||
// document context, then immediately evaluates it against the current page so
|
||||
// the very first tick has access to the registered globals.
|
||||
func (d *Driver) InstallBundle(ctx context.Context, source []byte) error {
|
||||
runCtx, cancel := d.runCtx(ctx)
|
||||
defer cancel()
|
||||
return chromedp.Run(runCtx,
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if _, err := page.AddScriptToEvaluateOnNewDocument(string(source)).Do(ctx); err != nil {
|
||||
return fmt.Errorf("addScriptToEvaluateOnNewDocument: %w", err)
|
||||
}
|
||||
_, exception, err := runtime.Evaluate(string(source)).Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evaluate bundle: %w", err)
|
||||
}
|
||||
if exception != nil {
|
||||
return fmt.Errorf("bundle threw: %s", exception.Text)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// EvaluateExtractors invokes the bundle-installed extractor table and returns
|
||||
// each extractor's JSON-encoded current value keyed by its registration index.
|
||||
func (d *Driver) EvaluateExtractors(ctx context.Context) (map[int]json.RawMessage, error) {
|
||||
const script = `JSON.stringify(window.__sanderlingExtractors__ ? window.__sanderlingExtractors__() : {})`
|
||||
var encoded string
|
||||
runCtx, cancel := d.runCtx(ctx)
|
||||
defer cancel()
|
||||
if err := chromedp.Run(runCtx, chromedp.Evaluate(script, &encoded)); err != nil {
|
||||
return nil, fmt.Errorf("evaluate extractors: %w", err)
|
||||
}
|
||||
if encoded == "" || encoded == "{}" {
|
||||
return map[int]json.RawMessage{}, nil
|
||||
}
|
||||
stringMap := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal([]byte(encoded), &stringMap); err != nil {
|
||||
return nil, fmt.Errorf("decode extractor map: %w", err)
|
||||
}
|
||||
result := make(map[int]json.RawMessage, len(stringMap))
|
||||
for key, value := range stringMap {
|
||||
index, err := strconv.Atoi(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("non-integer extractor key %q", key)
|
||||
}
|
||||
result[index] = value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// NextActionFromV8 invokes the bundle-installed action generator and returns
|
||||
// the resulting Action JSON. Returns an empty json.RawMessage when the
|
||||
// generator declines to act this tick.
|
||||
func (d *Driver) NextActionFromV8(ctx context.Context) (json.RawMessage, error) {
|
||||
const script = `JSON.stringify(window.__sanderlingNextAction__ ? window.__sanderlingNextAction__() : null)`
|
||||
var encoded string
|
||||
runCtx, cancel := d.runCtx(ctx)
|
||||
defer cancel()
|
||||
if err := chromedp.Run(runCtx, chromedp.Evaluate(script, &encoded)); err != nil {
|
||||
return nil, fmt.Errorf("evaluate next action: %w", err)
|
||||
}
|
||||
if encoded == "" || encoded == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
return json.RawMessage(encoded), nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package chrome
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestRunCtx_CallerCancelPropagates confirms that cancelling the caller's
|
||||
// context cancels the chromedp-bound context returned by runCtx. This is the
|
||||
// channel by which step deadlines and Ctrl-C reach in-flight CDP calls.
|
||||
func TestRunCtx_CallerCancelPropagates(t *testing.T) {
|
||||
tabCtx, tabCancel := context.WithCancel(context.Background())
|
||||
defer tabCancel()
|
||||
d := &Driver{tabCtx: tabCtx}
|
||||
|
||||
callerCtx, callerCancel := context.WithCancel(context.Background())
|
||||
derived, cancel := d.runCtx(callerCtx)
|
||||
defer cancel()
|
||||
|
||||
callerCancel()
|
||||
select {
|
||||
case <-derived.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("derived ctx did not cancel after caller cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunCtx_TabCancelPropagates confirms the inverse: tearing down the tab
|
||||
// also cancels any in-flight derived context.
|
||||
func TestRunCtx_TabCancelPropagates(t *testing.T) {
|
||||
tabCtx, tabCancel := context.WithCancel(context.Background())
|
||||
d := &Driver{tabCtx: tabCtx}
|
||||
|
||||
derived, cancel := d.runCtx(context.Background())
|
||||
defer cancel()
|
||||
|
||||
tabCancel()
|
||||
select {
|
||||
case <-derived.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("derived ctx did not cancel after tab cancellation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package chrome
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// attrNamePattern matches HTML attribute names that are safe to drop into a
|
||||
// CSS attribute selector without escaping. This avoids selectors like
|
||||
// `foo]:has(*),body[x="..."]` that would escape the intended match.
|
||||
var attrNamePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
|
||||
|
||||
// TranslateStringSelector converts a legacy string selector ("id:foo",
|
||||
// "descPrefix:bar") into a CSS selector or XPath expression usable from the
|
||||
// chrome driver's TapSelector fallback path. The boolean return is true when
|
||||
// the result is XPath rather than CSS. Unknown prefixes pass through to a CSS
|
||||
// attribute match by the same name so a Maestro-side addition (e.g. a future
|
||||
// "role:") works without a Sanderling release.
|
||||
func TranslateStringSelector(selector string) (string, bool, error) {
|
||||
if selector == "" {
|
||||
return "", false, errors.New("empty selector")
|
||||
}
|
||||
colon := strings.IndexByte(selector, ':')
|
||||
if colon <= 0 {
|
||||
return "", false, errors.New("selector missing prefix (expected `kind:value`)")
|
||||
}
|
||||
kind := selector[:colon]
|
||||
value := selector[colon+1:]
|
||||
switch kind {
|
||||
case "id", "resource-id":
|
||||
return `[id="` + cssEscape(value) + `"]`, false, nil
|
||||
case "class":
|
||||
return `[class~="` + cssEscape(value) + `"]`, false, nil
|
||||
case "tag":
|
||||
return cssEscape(value), false, nil
|
||||
case "text":
|
||||
return `//*[normalize-space(text())=` + xpathStringLiteral(value) + `]`, true, nil
|
||||
case "desc", "label", "content-desc", "accessibilityLabel", "accessibilityText", "ariaLabel", "aria-label":
|
||||
return `[aria-label="` + cssEscape(value) + `"]`, false, nil
|
||||
case "descPrefix":
|
||||
return `[aria-label^="` + cssEscape(value) + `"]`, false, nil
|
||||
case "testTag", "testID", "testid", "data-testid":
|
||||
return `[data-testid="` + cssEscape(value) + `"]`, false, nil
|
||||
case "placeholder", "placeholderValue", "hintText":
|
||||
return `[placeholder="` + cssEscape(value) + `"]`, false, nil
|
||||
default:
|
||||
if !attrNamePattern.MatchString(kind) {
|
||||
return "", false, fmt.Errorf("unsafe selector prefix %q", kind)
|
||||
}
|
||||
return `[` + kind + `="` + cssEscape(value) + `"]`, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// cssEscape escapes a value for use inside a CSS double-quoted string
|
||||
// (`[attr="VALUE"]`). Per the CSSOM spec for serializing strings:
|
||||
// - U+0000 becomes U+FFFD (REPLACEMENT CHARACTER)
|
||||
// - control characters (U+0001-U+001F, U+007F) become \HEX escapes
|
||||
// - " and \ are escaped with a leading backslash
|
||||
// - everything else passes through, including non-ASCII
|
||||
//
|
||||
// Callers should not pass this output into identifier contexts (class names,
|
||||
// tag names) — use an attribute selector form (`[class~="..."]`) instead.
|
||||
func cssEscape(value string) string {
|
||||
var builder strings.Builder
|
||||
builder.Grow(len(value))
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r == 0:
|
||||
builder.WriteRune(utf8.RuneError)
|
||||
case (r >= 0x01 && r <= 0x1F) || r == 0x7F:
|
||||
fmt.Fprintf(&builder, "\\%X ", r)
|
||||
case r == '\\' || r == '"':
|
||||
builder.WriteByte('\\')
|
||||
builder.WriteRune(r)
|
||||
default:
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// xpathStringLiteral wraps the value in a valid XPath 1.0 string literal.
|
||||
// XPath 1.0 has no escape syntax, so a value containing both ' and " must be
|
||||
// composed via concat(). The output already includes the surrounding quotes
|
||||
// (or concat() call), so callers don't quote it again.
|
||||
func xpathStringLiteral(value string) string {
|
||||
if !strings.ContainsRune(value, '"') {
|
||||
return `"` + value + `"`
|
||||
}
|
||||
if !strings.ContainsRune(value, '\'') {
|
||||
return `'` + value + `'`
|
||||
}
|
||||
parts := strings.Split(value, `"`)
|
||||
var builder strings.Builder
|
||||
builder.WriteString(`concat(`)
|
||||
for index, part := range parts {
|
||||
if index > 0 {
|
||||
builder.WriteString(`, '"', `)
|
||||
}
|
||||
builder.WriteByte('"')
|
||||
builder.WriteString(part)
|
||||
builder.WriteByte('"')
|
||||
}
|
||||
builder.WriteByte(')')
|
||||
return builder.String()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package chrome
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTranslateStringSelector_KnownKeys(t *testing.T) {
|
||||
cases := []struct {
|
||||
selector string
|
||||
want string
|
||||
isXPath bool
|
||||
}{
|
||||
{"id:email", `[id="email"]`, false},
|
||||
{"resource-id:account-name", `[id="account-name"]`, false},
|
||||
{"class:btn-primary", `[class~="btn-primary"]`, false},
|
||||
{"tag:button", `button`, false},
|
||||
{"text:Sign in", `//*[normalize-space(text())="Sign in"]`, true},
|
||||
{`text:Say "hi"`, `//*[normalize-space(text())='Say "hi"']`, true},
|
||||
{`text:it's`, `//*[normalize-space(text())="it's"]`, true},
|
||||
{`text:it's "fine"`, `//*[normalize-space(text())=concat("it's ", '"', "fine", '"', "")]`, true},
|
||||
{"desc:logout", `[aria-label="logout"]`, false},
|
||||
{"label:logout", `[aria-label="logout"]`, false},
|
||||
{"accessibilityLabel:logout", `[aria-label="logout"]`, false},
|
||||
{"aria-label:Sign in", `[aria-label="Sign in"]`, false},
|
||||
{"descPrefix:account:", `[aria-label^="account:"]`, false},
|
||||
{"testTag:submit", `[data-testid="submit"]`, false},
|
||||
{"testID:submit", `[data-testid="submit"]`, false},
|
||||
{"placeholder:Email", `[placeholder="Email"]`, false},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
got, isXPath, err := TranslateStringSelector(testCase.selector)
|
||||
if err != nil {
|
||||
t.Errorf("%q: unexpected error %v", testCase.selector, err)
|
||||
continue
|
||||
}
|
||||
if got != testCase.want || isXPath != testCase.isXPath {
|
||||
t.Errorf("%q: got (%q, xpath=%v), want (%q, xpath=%v)",
|
||||
testCase.selector, got, isXPath, testCase.want, testCase.isXPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateStringSelector_UnknownPrefixPassesThrough(t *testing.T) {
|
||||
got, _, err := TranslateStringSelector("role:button")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != `[role="button"]` {
|
||||
t.Errorf("unknown prefix should map to attribute selector, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateStringSelector_EscapesQuotes(t *testing.T) {
|
||||
got, _, err := TranslateStringSelector(`label:say "hi"`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != `[aria-label="say \"hi\""]` {
|
||||
t.Errorf("expected escaped quotes, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateStringSelector_RejectsMissingPrefix(t *testing.T) {
|
||||
if _, _, err := TranslateStringSelector("foo"); err == nil {
|
||||
t.Error("expected error for missing prefix")
|
||||
}
|
||||
if _, _, err := TranslateStringSelector(""); err == nil {
|
||||
t.Error("expected error for empty selector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateStringSelector_RejectsUnsafePrefix(t *testing.T) {
|
||||
cases := []string{
|
||||
`foo]:has(*),body[x:value`,
|
||||
`x y:value`,
|
||||
`x"y:value`,
|
||||
`*:value`,
|
||||
`(:value`,
|
||||
}
|
||||
for _, selector := range cases {
|
||||
if _, _, err := TranslateStringSelector(selector); err == nil {
|
||||
t.Errorf("%q: expected error for unsafe prefix", selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSSEscape_ControlCharactersAndNUL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"plain ascii", "hello", "hello"},
|
||||
{"double quote", `say "hi"`, `say \"hi\"`},
|
||||
{"backslash", `a\b`, `a\\b`},
|
||||
{"newline", "a\nb", `a\A b`},
|
||||
{"carriage return", "a\rb", `a\D b`},
|
||||
{"form feed", "a\fb", `a\C b`},
|
||||
{"NUL replaced", "a\x00b", "a�b"},
|
||||
{"DEL", "a\x7Fb", `a\7F b`},
|
||||
{"non-ascii passes through", "café", "café"},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
got := cssEscape(testCase.input)
|
||||
if got != testCase.want {
|
||||
t.Errorf("%s: cssEscape(%q) = %q, want %q",
|
||||
testCase.name, testCase.input, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package driver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -58,3 +59,27 @@ type Metrics struct {
|
||||
HeapBytes int64
|
||||
TotalMemoryBytes int64
|
||||
}
|
||||
|
||||
// WebDriver is the optional capability surface exposed by the chrome driver
|
||||
// for the V8-native tick path. The runner type-asserts on this interface;
|
||||
// mobile drivers stay binary-compatible by simply not implementing it.
|
||||
//
|
||||
// Element references never cross V8/host. V8 serializes targets as {x, y}
|
||||
// (or bounds) into the returned WebAction JSON; the host dispatches via the
|
||||
// normal DeviceDriver methods (Tap, InputText, etc.).
|
||||
type WebDriver interface {
|
||||
// InstallBundle injects the given JS source so it runs once per
|
||||
// freshly-navigated document, plus immediately in the current page.
|
||||
// The bundle is expected to register globals
|
||||
// `__sanderlingExtractors__` and `__sanderlingNextAction__` on
|
||||
// `window`.
|
||||
InstallBundle(ctx context.Context, source []byte) error
|
||||
// EvaluateExtractors invokes the extractor table installed by the
|
||||
// bundle and returns each extractor's JSON-encoded current value
|
||||
// keyed by its registration index.
|
||||
EvaluateExtractors(ctx context.Context) (map[int]json.RawMessage, error)
|
||||
// NextActionFromV8 invokes the action generator installed by the
|
||||
// bundle and returns the resulting Action JSON for the host to
|
||||
// dispatch. The shape mirrors verifier.Action's JSON form.
|
||||
NextActionFromV8(ctx context.Context) (json.RawMessage, error)
|
||||
}
|
||||
+113
-17
@@ -74,30 +74,48 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
var metrics *trace.Metrics
|
||||
var logs []verifier.LogEntry
|
||||
|
||||
g, _ := errgroup.WithContext(ctx)
|
||||
// gctx is bound to the errgroup so a returned error (or outer
|
||||
// cancellation) propagates to siblings - notably the V8 extractor
|
||||
// goroutine, whose CDP round-trip can otherwise outrun the step
|
||||
// budget on a hung tab.
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
g.Go(func() error {
|
||||
tree, hierarchyErr = fetchHierarchy(ctx, options.Driver)
|
||||
tree, hierarchyErr = fetchHierarchy(gctx, options.Driver)
|
||||
return nil
|
||||
})
|
||||
si := stepIndex
|
||||
g.Go(func() error {
|
||||
metrics = captureMetrics(ctx, options, logger, si)
|
||||
metrics = captureMetrics(gctx, options, logger, si)
|
||||
return nil
|
||||
})
|
||||
logSince := lastLogTime
|
||||
g.Go(func() error {
|
||||
logs = collectLogs(ctx, options.Driver, logSince)
|
||||
logs = collectLogs(gctx, options.Driver, logSince)
|
||||
return nil
|
||||
})
|
||||
var v8Overrides map[int]json.RawMessage
|
||||
if web, ok := options.Driver.(driver.WebDriver); ok {
|
||||
g.Go(func() error {
|
||||
overrides, err := web.EvaluateExtractors(gctx)
|
||||
if err != nil {
|
||||
logger.Warn("v8 extractor evaluation failed", "step", si, "err", err)
|
||||
return nil
|
||||
}
|
||||
v8Overrides = overrides
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if pendingPostScreenshot {
|
||||
postStep := pendingPostScreenshotStep
|
||||
g.Go(func() error {
|
||||
captureScreenshot(ctx, options, logger, postStep, true)
|
||||
captureScreenshot(gctx, options, logger, postStep, true)
|
||||
return nil
|
||||
})
|
||||
pendingPostScreenshot = false
|
||||
}
|
||||
g.Wait()
|
||||
// All goroutines write to local variables and return nil, so the Wait
|
||||
// error is always nil; ignored intentionally.
|
||||
_ = g.Wait()
|
||||
|
||||
if hierarchyErr != nil {
|
||||
if isWDADrop(hierarchyErr) {
|
||||
@@ -120,6 +138,14 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
}); err != nil {
|
||||
return summary, fmt.Errorf("step %d push: %w", stepIndex, err)
|
||||
}
|
||||
skipped, overrideErr := options.Verifier.OverrideExtractorValues(v8Overrides)
|
||||
if overrideErr != nil {
|
||||
logger.Warn("v8 override apply failed", "step", stepIndex, "err", overrideErr)
|
||||
}
|
||||
if skipped > 0 {
|
||||
logger.Warn("v8 override skipped out-of-range entries",
|
||||
"step", stepIndex, "skipped", skipped, "have", len(v8Overrides))
|
||||
}
|
||||
|
||||
screen := ""
|
||||
if tree != nil && len(tree.Elements) > 0 {
|
||||
@@ -134,7 +160,13 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
}
|
||||
}
|
||||
|
||||
nextAction, nextErr := options.Verifier.NextAction()
|
||||
var nextAction verifier.Action
|
||||
var nextErr error
|
||||
if web, ok := options.Driver.(driver.WebDriver); ok {
|
||||
nextAction, nextErr = nextActionFromV8(ctx, web)
|
||||
} else {
|
||||
nextAction, nextErr = options.Verifier.NextAction()
|
||||
}
|
||||
var traceAction *trace.Action
|
||||
if nextErr == nil {
|
||||
traceAction = traceActionFor(nextAction, tree)
|
||||
@@ -148,14 +180,14 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
}
|
||||
|
||||
step := trace.Step{
|
||||
Index: stepIndex,
|
||||
Timestamp: stepStart,
|
||||
Screen: screen,
|
||||
Action: traceAction,
|
||||
Index: stepIndex,
|
||||
Timestamp: stepStart,
|
||||
Screen: screen,
|
||||
Action: traceAction,
|
||||
Violations: violations,
|
||||
Hierarchy: tree,
|
||||
Residuals: residuals,
|
||||
Metrics: metrics,
|
||||
Hierarchy: tree,
|
||||
Residuals: residuals,
|
||||
Metrics: metrics,
|
||||
}
|
||||
if err := options.TraceWriter.WriteStep(step); err != nil {
|
||||
return summary, fmt.Errorf("step %d trace: %w", stepIndex, err)
|
||||
@@ -303,10 +335,18 @@ func collectLogs(ctx context.Context, drv driver.DeviceDriver, since time.Time)
|
||||
}
|
||||
|
||||
func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, bool) {
|
||||
if action.X > 0 && action.Y > 0 {
|
||||
return action.X, action.Y, true
|
||||
// When On is empty, X/Y are authoritative (web V8 path emits coordinates
|
||||
// directly from getBoundingClientRect; the runtime nullifies unresolved
|
||||
// actions upstream so a non-null InputText here always has real coords,
|
||||
// even at (0,0)). When On is set, prefer the tree lookup so stale coords
|
||||
// don't leak from earlier ticks.
|
||||
if action.On == "" {
|
||||
if action.X >= 0 && action.Y >= 0 {
|
||||
return action.X, action.Y, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
if tree != nil && action.On != "" {
|
||||
if tree != nil {
|
||||
if element := tree.Find(action.On); element != nil {
|
||||
x, y := element.Bounds.Center()
|
||||
if x > 0 && y > 0 {
|
||||
@@ -314,6 +354,9 @@ func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int,
|
||||
}
|
||||
}
|
||||
}
|
||||
if action.X > 0 && action.Y > 0 {
|
||||
return action.X, action.Y, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
@@ -397,6 +440,59 @@ func captureMetrics(ctx context.Context, options Options, logger *slog.Logger, s
|
||||
}
|
||||
}
|
||||
|
||||
// nextActionFromV8 invokes the V8-side action generator and decodes the
|
||||
// resulting JSON into a verifier.Action. ErrNoAction is returned when the
|
||||
// generator declined to act this tick.
|
||||
func nextActionFromV8(ctx context.Context, web driver.WebDriver) (verifier.Action, error) {
|
||||
raw, err := web.NextActionFromV8(ctx)
|
||||
if err != nil {
|
||||
return verifier.Action{}, fmt.Errorf("v8 next action: %w", err)
|
||||
}
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return verifier.Action{}, verifier.ErrNoAction
|
||||
}
|
||||
var decoded struct {
|
||||
Kind string `json:"kind"`
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
FromX int `json:"from_x"`
|
||||
FromY int `json:"from_y"`
|
||||
ToX int `json:"to_x"`
|
||||
ToY int `json:"to_y"`
|
||||
Key string `json:"key"`
|
||||
Text string `json:"text"`
|
||||
DurationMillis int `json:"duration_millis"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
return verifier.Action{}, fmt.Errorf("decode v8 action: %w", err)
|
||||
}
|
||||
switch decoded.Kind {
|
||||
case "Tap":
|
||||
return verifier.Action{Kind: verifier.ActionKindTap, X: decoded.X, Y: decoded.Y}, nil
|
||||
case "InputText":
|
||||
return verifier.Action{
|
||||
Kind: verifier.ActionKindInputText,
|
||||
X: decoded.X, Y: decoded.Y,
|
||||
Text: decoded.Text,
|
||||
}, nil
|
||||
case "Swipe":
|
||||
return verifier.Action{
|
||||
Kind: verifier.ActionKindSwipe,
|
||||
FromX: decoded.FromX,
|
||||
FromY: decoded.FromY,
|
||||
ToX: decoded.ToX,
|
||||
ToY: decoded.ToY,
|
||||
DurationMillis: decoded.DurationMillis,
|
||||
}, nil
|
||||
case "PressKey":
|
||||
return verifier.Action{Kind: verifier.ActionKindPressKey, Key: decoded.Key}, nil
|
||||
case "Wait":
|
||||
return verifier.Action{Kind: verifier.ActionKindWait, DurationMillis: decoded.DurationMillis}, nil
|
||||
default:
|
||||
return verifier.Action{}, verifier.ErrNoAction
|
||||
}
|
||||
}
|
||||
|
||||
func captureScreenshot(ctx context.Context, options Options, logger *slog.Logger, stepIndex int, after bool) {
|
||||
image, err := options.Driver.Screenshot(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -263,6 +263,37 @@ func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyAction_V8InputTextTapsAtCoordinates(t *testing.T) {
|
||||
driverMock := mockdriver.New()
|
||||
action := verifier.Action{Kind: verifier.ActionKindInputText, X: 50, Y: 100, Text: "alice"}
|
||||
|
||||
if err := applyAction(context.Background(), driverMock, action, nil); err != nil {
|
||||
t.Fatalf("apply action: %v", err)
|
||||
}
|
||||
actions := driverMock.Actions()
|
||||
if !containsAction(actions, mockdriver.ActionTap, "") {
|
||||
t.Errorf("expected focus Tap before InputText, got %v", actions)
|
||||
}
|
||||
if !containsAction(actions, mockdriver.ActionInputText, "") {
|
||||
t.Errorf("expected InputText after focus Tap, got %v", actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAction_V8InputTextAtOriginStillTaps(t *testing.T) {
|
||||
driverMock := mockdriver.New()
|
||||
// V8 emits real (0,0) coordinates for an element at viewport top-left
|
||||
// (post-#15 the runtime nullifies unresolved actions, so a non-null
|
||||
// InputText with (0,0) is a deliberate edge tap, not a sentinel).
|
||||
action := verifier.Action{Kind: verifier.ActionKindInputText, X: 0, Y: 0, Text: "alice"}
|
||||
|
||||
if err := applyAction(context.Background(), driverMock, action, nil); err != nil {
|
||||
t.Fatalf("apply action: %v", err)
|
||||
}
|
||||
if !containsAction(driverMock.Actions(), mockdriver.ActionTap, "") {
|
||||
t.Errorf("expected focus Tap at (0,0), got %v", driverMock.Actions())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) {
|
||||
state := newHarness(t)
|
||||
state.mock.MetricsData = driver.Metrics{CPUPercent: 5.0, HeapBytes: 1024, TotalMemoryBytes: 4096}
|
||||
|
||||
@@ -21,6 +21,9 @@ import (
|
||||
// a cleanup function. For web, ChromeDriver is used directly; for android/ios
|
||||
// the JVM sidecar is extracted, spawned, and dialed.
|
||||
func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver.DeviceDriver, func(), error) {
|
||||
if err := Preflight(ctx, options.Platform); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if options.Platform == "web" {
|
||||
d := chrome.New()
|
||||
return d, func() { _ = d.Terminate(context.Background()) }, nil
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package testrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// Preflight runs platform-specific host checks before sidecar/driver setup.
|
||||
// On failure it returns a wrapped error pointing the user at the matching
|
||||
// `sanderling doctor --platform=<p>` 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,
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user