mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
* feat(runner): stop the step loop at the first violation on request
* feat(testrun): report violations as a typed error under exit-on-violation
* feat(cli): add --exit-on-violation and exit 2 when it fires
* docs(cli): document --exit-on-violation, --max-steps, and exit codes
* fix(web): enumerate and query across shadow roots in both producers
* test(chrome): compare both producers on a shadow-dom parity page
* test(browser): drive a canvas-under-shadow-root fixture end to end
* fix(web): select the focused field inside a shadow root before typing
* fix(web): report the pathname as the screen when there is no hash route
* fix(web): settle on dom quiescence instead of returning at body ready
* feat(replay-ui): add data-testid hooks the dogfood spec drives
* feat(replay-ui): add the dogfood spec sanderling runs against the replay ui
* fix(replay-ui): scope the screenshot property to the named state panel
* chore(make): add per-platform sanderling build targets
* ci: add dispatch workflows for folio and the replay ui
* docs: describe the dispatch workflows and how to read a failure
* ci(folio): give the ios leg its jdk, android sdk, just, and a clean app start
* refactor(web): use max for the settle budget
* ci: pin calibrated seeds, skip the flaky ios reinstall, bound every job
* ci: authenticate and pin the buf setup step
the anonymous release download hit the shared runner ip rate limit and
failed the job with 'socket hang up' after three retries.
* docs: record that canvas apps need a dom proxy to be text-fuzzable
* fix(ios): bound lifecycle rpcs and claim the target device
a launch the simulator rejects sent the xctest session into a recovery
chain that answered minutes late or never, and the rpc had no deadline,
so the run hung with no trace and no error. also take a per-udid flock:
a second run's reinstall lands under the first's live automation session
and wedges it.
* docs(ci): correct the ios hang wording and note the device lock
* refactor(verifier): derive the lastAction shape from one field list
both hosts must show a spec the same lastAction. one ordered list now
feeds the goja object and the json the web host installs, so they
cannot drift.
* fix(web): install lastAction in the page before extractors read it
state.lastAction was hardcoded null on web, so every property reading it
was silently vacuous: a correct property passed without ever firing.
* fix(web): carry element identity on actions and fix findAll on paths
an action's target was coordinates only, so a property matching on which
element was acted upon could never fire. ax.findAll([a,b]) also returned
nothing on web.
* fix(chrome): wait out a route transition before sampling facts
the tree stays byte-identical and quiet across a cross-fade, so both the
quiet timer and the unchanged-tree escape called it settled mid-flight
and extractors read two screens at once.
* fix: bound the pre-run app launch
launch happens before the runner starts, so --duration never covered it
and a wedged driver hung with no trace and no error.
* fix(folio): read balances from merged cards and treat unreadable as unknown
compose for web merges the whole accountcard subtree, so the balance
child never exists there and every card parsed as 0. the property then
compared 0 to 0 and fired on any submit, which is a false positive
generator. unknown is now null and null is vacuously true.
* test(folio): cover merged-card parsing and unknown balances
* ci(folio): make web an expect-the-bug leg
the web runtime can observe the double submit now, so the health gate
understates it. seed 1 finds it at step 109, 3 runs out of 3.
* docs(ci): explain why a submit tap landing on home is the bug
* fix(ios): read a StaticText's label as its text
AXValue was the only source for text, but a StaticText carries its
string in AXLabel, so nothing on screen had .text on ios: a spec reading
it saw everything on android and nothing here.
* docs(ci): correct the calibrated step ranges
* fix(folio): stop convicting on arithmetic float64 cannot hold
past 2^53 cents the gap between representable values is 128, so a real
1600-cent move reads back as something else and the equality is false
for a healthy submit as readily as a double one. also match parseCents:
a sign or an oversized amount is rejected, not read as an amount.
* test(folio): pin the safe-integer guard and its boundary
* docs: stop teaching the zero-default that caused a false alarm
* docs: write down the silent-vacuity failure modes
* feat(folio): tag the home total and the card transaction count
the total was the only untagged node on the screen, so the spec had to
sum cards and a clipped card broke the sum.
* fix(folio): read the app's own total and refuse contaminated windows
summing cards went null when one was clipped, and the null poisoned the
carrier for the rest of the run. the balance window also spanned every
transaction since the last home visit, so the property convicted on
deltas it could not attribute: the old web witness was 3.16x the typed
amount, not 2x.
* test(folio): pin the window rules and the count invariant
* fix(folio): never read a frame that shows two screens
android dumps a cross-fade with both screens in the tree. the route said
add-transaction while an unscoped find said home, so the oracle took a
half-rendered total as fresh and convicted on a tap that committed
nothing. one function now decides the route and returns null when the
frame is ambiguous.
* test(folio): cover transition frames, card readings and creation
* fix(folio): only disambiguate counts that came from merged text
the equal-length digit rule exists because web merges the card and an
account named -1 makes '12' ambiguous. a dedicated count node has
nothing to disambiguate, so applying it there threw away real evidence.
* ci(folio): pin the recalibrated seeds and drop android to a health gate
web 3 and ios 7 convict 3 runs out of 3 with an exactly 2x witness.
android convicts 2 in 5 because the same seed does not walk the same
trajectory there, so it proves the app runs instead.
* docs(ci): describe the two properties and why android cannot convict
* fix(android): wait out a route cross-fade before snapshotting
the dump could hold two screens at once, and the runner refuses to act
on such a tree, so a quarter of android steps applied no action and the
count varied per run: the same seed never walked the same trajectory.
the ios companion and the chrome driver already do this.
* ci(folio): let the android leg run far enough to see its conviction
* docs: only the repo owner merges
* ci(folio): a thrown predicate is not a conviction
exit 2 means the run recorded a violation, and a predicate that throws
is recorded as one too. so was newAccountBalanceIsZero, an unrelated
property in the same spec. the gate read the exit code and went green
with detection dead.
* ci: install idb-companion from its tap and stop interpolating inputs
idb-companion is not in homebrew-core, so the ios leg died before it
built anything. replay-ui expanded dispatch inputs into the shell.
* docs: correct the snippets and numbers that drifted from the code
* test(sidecar): pin that a slow read counts toward the stability streak
* fix(web): read the page's extractors only on steps that count
the page advances the spec's carriers when it evaluates, but the runner
applied the result only on non-transitional steps. a discarded step
moved the window forward anyway, so the next accepted pair bracketed two
transactions while counting one submit, and convicted a healthy app.
extractor errors now fail the run instead of leaving goja's values in
current against v8's in previous.
* fix(chrome): anchor the transition deadline when the dom goes quiet
it was anchored at script start, so a page that churned past the window
reached the check already expired and returned mid cross-fade. the
driver now publishes the idle timeout it needs, since the caller's 1s
could never spend the 800ms window.
* fix(web): fail on a partial extractor override
same mixed-producer hazard as the install error: some extractors hold
the page's value and the rest hold goja's, and a property comparing
across that split fires on a healthy app.
* docs: six of seven, the seventh is the stock property
* fix(folio): drop a name two cards answer to
homeTxnCountsOf keyed on the account name and let the last card win, so
two accounts the fuzzer named the same collapsed into one entry. a
reading that saw one Travel card and a later one that saw both then
subtracted two different accounts' counts, and
submitCommitsOneTransactionPerAction convicted a healthy app of
double-submitting. it is a gated property in folio-run.sh, so that reads
as "found the submit bug" over a card scrolling into view.
same rule createdAccountHasNonZeroBalance already applies: a name
nothing can attribute is no evidence. counted over every card, since an
unreadable twin spoils the identity too.
* perf(folio): read each frame once
every extractor asked routeOf, and routeOf does five ax.find calls. on
web each find walks the document and every shadow root beneath it, so
the spec cost 110 tree walks a step; homeCards was parsed four times
over. now 5 and once.
keyed on the identity of the state object because both hosts build a new
one per step and hand that one object to every getter, so it cannot
outlive its frame. holding the reference is what keeps that true rather
than likely.
* fix(web): keep an undefined reading's index through JSON
json has no undefined, so an extractor whose getter returned one had its
whole index dropped by JSON.stringify. that index then kept goja's
dump-derived value while its neighbours held the page's, and a property
comparing previous to current across the split fires on a healthy app.
folio has nine on(route, tag) extractors, so this was most extractors on
most steps.
each reading is wrapped in a {value} envelope: the drop now happens
inside the entry, and an absent value means the getter returned
undefined, which is what the goja host records for the same getter. a
json null would instead claim it returned null and x.current ===
undefined would answer differently on the two hosts.
* feat(verifier): report the registered extractor count
the web path needs it to check the page sent one reading per extractor.
* fix(runner): fail when the page reports fewer readings than extractors
the comment here already claimed a partial override was fatal. it was
not: the skipped check only catches indices outside the extractor list,
so a page reporting values for some extractors and not others left the
rest holding goja's reading of the dump with nothing said.
* test(browser): drive an undefined reading through the whole web path
four layers carry it: the page's envelope, the driver's unwrap, the
runner's count check and the verifier's decode. each has a unit test and
only a run proves they compose. goes red both ways, decoding an absent
value as null and dropping the envelope.
* fix(web): offer the aria roles a user activates
only role=button was in the tappable set, so link, checkbox, radio,
switch, tab, option, the menuitems and treeitem were invisible to the
enumeration however plain the control looked. the replay ui builds its
step rows as <li role="option">, and the spec dogfooding it had to
hand-write an action to reach them because no default verb could see a
single row.
both producers build the set from the same role list, since the parity
test compares them element by element.
* test(browser): tap a role-based control end to end
every control on the page is an <li role="option">, the shape the
replay ui gives its step rows, and the spec carries no action of its
own: the property firing is the evidence the default enumeration offered
a tap on one.
* fix(web): read aria-disabled as disabled
the enabled fact came off the disabled property, which only real form
controls have. it reads undefined on the role-based controls the
tappable set now covers, so every one of them looked enabled however
plainly it was marked otherwise, and the fuzzer would spend actions on
inert ones.
both producers answer the same two ways, and the parity fixture carries
a disabled row so the comparison covers it: reverting one side alone
names the element and the fact.
* docs(replay-ui): the enumeration reaches step rows now
the comment said role="option" is not in the tappable selector set,
which stopped being true a few commits ago. selectAStep stays, for the
reason the tab weight below it stays: one row among the page's clickable
elements is a thin chance, and both step-facing properties go vacuous on
a run that never selects one.
* test(runner): bound the last-action test by steps, not wall clock
100ms of wall clock against an assertion that two steps ran fatals under
load with "the web path never installed it", which reads as a
regression. every sibling test in the package uses a long duration and
MaxSteps.
* ci: run the kotlin tests in make test
RouteTransitionTest and the stability poll cover the android settle and
nothing in ci ran them. :sidecar:test needs no android sdk, checked by
running it with ANDROID_HOME pointed at nothing.
* fix(sidecar): measure the stability streak as observed quiet
parameterising pollUntilStable also moved the clock to the start of the
read that opened a run of identical snapshots, so a read's own duration
counted as quiet. the pre-existing caller polls a real uiautomator dump:
at 400ms a read, 750ms of required quiet became 250ms of observed quiet
and the poll settled in two reads instead of four.
the parameters stay, the semantics go back.
* test(sidecar): pin the transition cap by driving it
it asserted 1500 >= 700 + 300, two constants, which can only fail if
someone edits a constant. it now drives awaitSettledTree against a fade
that lands after 700ms and asserts it hands back the settled tree before
the cap. cut the cap to 1000 and it goes red.
* ci: pin buf-setup-action to a commit
it takes a token now, so a floating tag is a token handed to whatever
that tag moves to. note v1 there is a branch, not a tag, so the ref
lookup that resolves it is matching-refs/heads/v1.
* ci: declare least-privilege permissions
none of the three declared any, so each got the repository default.
release.yml and docs.yml already do this. all three only check out,
build, test and upload artifacts.
* ci: fail fast when a server never comes up
the readiness loops fell through silently after 30 tries, so a server
that never started surfaced as an opaque driver failure minutes later.
each now says what did not answer and on which port.
* ci(folio): a missing trace is not a verdict
with no trace the android gate ran its grep against ./trace.jsonl and
reported "never reached AddTransactionScreen, so it never got past
login", which is not what happened. the web and ios branches had the
same misdiagnosis on exit 0.
same class, one line up: the classifier's own failure was swallowed, so
with the evidence reader dead the gate printed a healthy run and exited
0.
* ci(replay-ui): skip a run directory with no trace
the summarise step is if: always(), and under github's bash -eo pipefail
an unmatched glob stays literal, the redirect fails, pipefail carries it
into the assignment and -e kills the step. so a failed fuzz run went red
twice, once for the real reason.
1208 lines
41 KiB
Go
1208 lines
41 KiB
Go
// Package ioscompanion drives an iOS simulator through the native simulator
|
|
// companion. This file implements the DeviceDriver surface on top of the
|
|
// brand-free transport, supervises the companion child process, and recovers
|
|
// from a dropped connection with one in-place restart.
|
|
package ioscompanion
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
_ "image/png"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/driver"
|
|
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionassets"
|
|
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
|
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
|
)
|
|
|
|
// startupTimeout bounds how long New waits for the spawned companion to accept
|
|
// a connection and answer a health probe.
|
|
const startupTimeout = 30 * time.Second
|
|
|
|
// runnerStartupTimeout bounds the in-simulator runner's startup. The runner is
|
|
// hosted by a test session whose cold start is far slower than the companion's.
|
|
const runnerStartupTimeout = 120 * time.Second
|
|
|
|
// shutdownGrace bounds how long the companion child gets to exit after SIGTERM
|
|
// before it is killed. A variable so the kill-escalation test can shrink it.
|
|
var shutdownGrace = 15 * time.Second
|
|
|
|
// launchTimeout bounds a single app lifecycle RPC. The runner serves lifecycle
|
|
// inside its XCTest session, and a launch the simulator rejects sends that
|
|
// session down a recovery chain (a 120s accessibility wait, a spindump, then an
|
|
// idle wait) that answers minutes late or never. Callers reach Launch with an
|
|
// undeadlined context, since it runs before the run's duration clock starts, so
|
|
// the bound has to come from here or a wedged session hangs the run with no
|
|
// trace, no error, and no end. Kept under runnerStartupTimeout: launching an
|
|
// app inside a live session must cost less than cold-starting that session.
|
|
// A variable so the timeout test can shrink it.
|
|
var launchTimeout = 90 * time.Second
|
|
|
|
// longPressHoldMilliseconds is how long LongPress holds the finger down.
|
|
const longPressHoldMilliseconds = 600
|
|
|
|
// Options configures a Driver.
|
|
type Options struct {
|
|
// UniqueDeviceIdentifier selects the booted simulator the companion drives.
|
|
UniqueDeviceIdentifier string
|
|
// BundleID is the app under test. Launch and Terminate act on it.
|
|
BundleID string
|
|
// AppPath is the .app bundle directory. Required for clear-state reinstall;
|
|
// when empty, clear state falls back to resetting the data container.
|
|
AppPath string
|
|
// Output receives companion stdout and stderr plus driver warnings.
|
|
Output io.Writer
|
|
// DoubleTapGapMilliseconds overrides the synthesized double-tap gap.
|
|
DoubleTapGapMilliseconds float64
|
|
|
|
// spawnChild, dialCompanion, and pickAddress are test seams. Production
|
|
// leaves them nil and New wires the real extraction, spawn, and dial.
|
|
spawnChild func(ctx context.Context, address string) (*exec.Cmd, error)
|
|
dialCompanion func(address string) (transport.Companion, error)
|
|
pickAddress func() (string, error)
|
|
}
|
|
|
|
// Driver implements driver.DeviceDriver against an iOS simulator companion.
|
|
type Driver struct {
|
|
companion transport.Companion
|
|
udid string
|
|
bundleID string
|
|
appPath string
|
|
output io.Writer
|
|
|
|
screenWidth int
|
|
screenHeight int
|
|
|
|
doubleTapGapMilliseconds float64
|
|
|
|
// mu guards Snapshot's hierarchy+screenshot pairing and the lastTap record.
|
|
mu sync.Mutex
|
|
lastTap struct {
|
|
x, y float64
|
|
set bool
|
|
}
|
|
|
|
clearStateWarned bool
|
|
|
|
// restart rebuilds the transport in place after a connection-level failure.
|
|
// It is a seam so tests exercise the supervision logic without spawning a
|
|
// real companion. restarting guards against re-entrant restarts.
|
|
restart func(ctx context.Context) error
|
|
restarting bool
|
|
address string
|
|
|
|
// resetContainer wipes the app data container for the clear-state fallback.
|
|
// A seam so tests skip the xcrun shell-out.
|
|
resetContainer func(ctx context.Context) error
|
|
|
|
// reinstallApp uninstalls and reinstalls the app bundle for clear-state.
|
|
// A seam so tests skip the simctl shell-outs.
|
|
reinstallApp func(ctx context.Context) error
|
|
|
|
// grantPaste pre-authorizes the app's pasteboard access. A seam so tests
|
|
// skip the sqlite shell-out.
|
|
grantPaste func(ctx context.Context) error
|
|
|
|
// idleClock drives WaitForIdle's settle poll. A seam so tests substitute a
|
|
// fake clock and avoid the real settle cap.
|
|
idleClock Clock
|
|
spawnChild func(ctx context.Context, address string) (*exec.Cmd, error)
|
|
dial func(address string) (transport.Companion, error)
|
|
child *exec.Cmd
|
|
|
|
// The hybrid simulator companion pairs the legacy child (HID gestures,
|
|
// lifecycle, screenshot) with an in-simulator runner that serves
|
|
// collapse-free accessibility snapshots and native unicode typing.
|
|
// runnerClient is nil on the legacy-only path.
|
|
runnerClient transport.Companion
|
|
runnerChild *exec.Cmd
|
|
runnerAddress string
|
|
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
|
|
dialRunner func(address string) (transport.Companion, error)
|
|
hybrid bool
|
|
|
|
// Device-mode fields. On the physical-device path d.companion is the runner
|
|
// dialed over a usbmux tunnel, hybrid is false, and runnerClient is nil.
|
|
// coreDeviceID feeds devicectl; tunnel is the in-process usbmux forwarder
|
|
// bridging the host loopback port to the runner's device-side port.
|
|
deviceMode bool
|
|
coreDeviceID string
|
|
tunnel io.Closer
|
|
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
|
|
pickDeviceAddress func() (string, error)
|
|
|
|
// processContext owns the companion child's lifetime: it is derived from
|
|
// New's context (so a canceled run still reaps the child) and canceled by
|
|
// Close. Spawning under a startup-scoped context would SIGTERM the child
|
|
// the moment startup finishes.
|
|
processContext context.Context
|
|
processCancel context.CancelFunc
|
|
|
|
// deviceLock is the exclusive claim on the target, held for the driver's
|
|
// whole life and released by Close.
|
|
deviceLock io.Closer
|
|
}
|
|
|
|
// acquireDeviceLock takes an exclusive advisory lock on the target so only one
|
|
// run drives it at a time. Two runs on one device interleave app lifecycle: the
|
|
// second run's uninstall and reinstall land under the first's live automation
|
|
// session, leaving its app proxies bound to a bundle the simulator no longer
|
|
// knows, and every later snapshot and launch on that session stalls. Failing
|
|
// fast beats recovering silently, since the other run owns the device and would
|
|
// be corrupted either way. The lock lives on the file descriptor, so a crashed
|
|
// run's claim is released by the kernel and never strands the device.
|
|
func acquireDeviceLock(udid string) (io.Closer, error) {
|
|
path := filepath.Join(os.TempDir(), "sanderling-ios-"+udid+".lock")
|
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open device lock %s: %w", path, err)
|
|
}
|
|
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
|
file.Close()
|
|
return nil, fmt.Errorf(
|
|
"ios target %s is already driven by another sanderling run (lock %s); "+
|
|
"wait for that run to finish or point this one at a different device with --ios-device",
|
|
udid, path)
|
|
}
|
|
return file, nil
|
|
}
|
|
|
|
// New extracts the embedded companion, spawns it against the configured
|
|
// simulator, dials the transport, health-probes it, and caches the screen
|
|
// point dimensions. Call Close when done to stop the child.
|
|
func New(ctx context.Context, options Options) (*Driver, error) {
|
|
if options.UniqueDeviceIdentifier == "" {
|
|
return nil, errors.New("ios companion: UniqueDeviceIdentifier is required")
|
|
}
|
|
output := options.Output
|
|
if output == nil {
|
|
output = io.Discard
|
|
}
|
|
gap := options.DoubleTapGapMilliseconds
|
|
if gap <= 0 {
|
|
gap = DefaultDoubleTapGapMilliseconds
|
|
}
|
|
|
|
driverInstance := &Driver{
|
|
udid: options.UniqueDeviceIdentifier,
|
|
bundleID: options.BundleID,
|
|
appPath: options.AppPath,
|
|
output: output,
|
|
doubleTapGapMilliseconds: gap,
|
|
spawnChild: options.spawnChild,
|
|
dial: options.dialCompanion,
|
|
hybrid: hybridCompanionEnabled(),
|
|
}
|
|
if driverInstance.spawnChild == nil {
|
|
driverInstance.spawnChild = driverInstance.realSpawnChild
|
|
}
|
|
if driverInstance.dial == nil {
|
|
driverInstance.dial = transport.Dial
|
|
}
|
|
if driverInstance.spawnRunner == nil {
|
|
driverInstance.spawnRunner = driverInstance.realSpawnRunner
|
|
}
|
|
if driverInstance.dialRunner == nil {
|
|
driverInstance.dialRunner = func(address string) (transport.Companion, error) {
|
|
return transport.DialRunner(address, driverInstance.udid, driverInstance.bundleID)
|
|
}
|
|
}
|
|
pickAddress := options.pickAddress
|
|
if pickAddress == nil {
|
|
pickAddress = pickLoopbackAddress
|
|
}
|
|
|
|
address, err := pickAddress()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
driverInstance.address = address
|
|
driverInstance.restart = driverInstance.respawnAndRedial
|
|
driverInstance.resetContainer = driverInstance.resetDataContainer
|
|
driverInstance.reinstallApp = driverInstance.simctlReinstall
|
|
driverInstance.grantPaste = driverInstance.grantPasteboardAccess
|
|
driverInstance.processContext, driverInstance.processCancel = context.WithCancel(ctx)
|
|
|
|
lock, err := acquireDeviceLock(driverInstance.udid)
|
|
if err != nil {
|
|
driverInstance.processCancel()
|
|
return nil, err
|
|
}
|
|
driverInstance.deviceLock = lock
|
|
|
|
if err := driverInstance.bringUp(ctx); err != nil {
|
|
driverInstance.Close()
|
|
return nil, err
|
|
}
|
|
if driverInstance.hybrid {
|
|
if err := driverInstance.bringUpRunner(ctx); err != nil {
|
|
driverInstance.Close()
|
|
return nil, fmt.Errorf("simulator runner: %w (set SANDERLING_SIMULATOR_COMPANION=legacy to bypass)", err)
|
|
}
|
|
}
|
|
|
|
description, err := driverInstance.companion.Describe(ctx)
|
|
if err != nil {
|
|
driverInstance.Close()
|
|
return nil, fmt.Errorf("describe target: %w", err)
|
|
}
|
|
driverInstance.screenWidth = description.WidthPoints
|
|
driverInstance.screenHeight = description.HeightPoints
|
|
return driverInstance, nil
|
|
}
|
|
|
|
// bringUp spawns the companion child, waits for the listener, dials, and
|
|
// confirms health. It is used by New and by the in-place restart.
|
|
func (d *Driver) bringUp(ctx context.Context) error {
|
|
startupCtx, cancel := context.WithTimeout(ctx, startupTimeout)
|
|
defer cancel()
|
|
|
|
child, err := d.spawnChild(d.processContext, d.address)
|
|
if err != nil {
|
|
return fmt.Errorf("spawn companion: %w", err)
|
|
}
|
|
d.child = child
|
|
|
|
if err := waitForListener(startupCtx, d.address); err != nil {
|
|
d.stopChild()
|
|
return fmt.Errorf("companion listener: %w", err)
|
|
}
|
|
|
|
companion, err := d.dial(d.address)
|
|
if err != nil {
|
|
d.stopChild()
|
|
return fmt.Errorf("dial companion: %w", err)
|
|
}
|
|
d.companion = companion
|
|
|
|
if err := d.waitForHealth(startupCtx); err != nil {
|
|
_ = companion.Close()
|
|
d.stopChild()
|
|
return fmt.Errorf("companion health: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// waitForHealth probes AccessibilityInfo until it succeeds or the context
|
|
// expires. A successful describe-all means the companion is attached to the
|
|
// simulator and ready to serve.
|
|
func (d *Driver) waitForHealth(ctx context.Context) error {
|
|
ticker := time.NewTicker(250 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
if _, err := d.companion.AccessibilityInfo(ctx); err == nil {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// respawnAndRedial tears down the current transports and children, then brings
|
|
// fresh ones up at the same addresses. Used as the supervision restart. On the
|
|
// hybrid path both halves restart together: their failure modes overlap (a
|
|
// rebooted simulator drops both) and one orchestration keeps recovery simple.
|
|
func (d *Driver) respawnAndRedial(ctx context.Context) error {
|
|
// The dead transports are closed but kept in place until their fresh
|
|
// replacements land: if the restart fails, later calls error gracefully on
|
|
// the closed transport instead of dereferencing nil, and a later incident
|
|
// earns another restart attempt.
|
|
if d.companion != nil {
|
|
_ = d.companion.Close()
|
|
}
|
|
d.stopChild()
|
|
if d.runnerClient != nil {
|
|
_ = d.runnerClient.Close()
|
|
}
|
|
d.stopRunnerChild()
|
|
if err := d.bringUp(ctx); err != nil {
|
|
return err
|
|
}
|
|
if d.hybrid {
|
|
return d.bringUpRunner(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// hybridCompanionEnabled reports whether the simulator driver should pair the
|
|
// legacy companion with the in-simulator runner. The hybrid is the default;
|
|
// SANDERLING_SIMULATOR_COMPANION=legacy forces the legacy companion alone.
|
|
func hybridCompanionEnabled() bool {
|
|
return os.Getenv("SANDERLING_SIMULATOR_COMPANION") != "legacy"
|
|
}
|
|
|
|
// bringUpRunner spawns the in-simulator runner, waits for its listener, dials,
|
|
// and confirms it serves snapshots. Used by New and the in-place restart.
|
|
func (d *Driver) bringUpRunner(ctx context.Context) error {
|
|
startupCtx, cancel := context.WithTimeout(ctx, runnerStartupTimeout)
|
|
defer cancel()
|
|
|
|
// A fresh port every bring-up: after a restart the dying session's
|
|
// listener may still answer on the old port and would satisfy the wait
|
|
// below with a dead server.
|
|
address, err := pickLoopbackAddress()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
d.runnerAddress = address
|
|
|
|
child, err := d.spawnRunner(d.processContext, d.runnerAddress)
|
|
if err != nil {
|
|
return fmt.Errorf("spawn runner: %w", err)
|
|
}
|
|
d.runnerChild = child
|
|
|
|
if err := waitForListener(startupCtx, d.runnerAddress); err != nil {
|
|
d.stopRunnerChild()
|
|
return fmt.Errorf("runner listener: %w", err)
|
|
}
|
|
|
|
client, err := d.dialRunner(d.runnerAddress)
|
|
if err != nil {
|
|
d.stopRunnerChild()
|
|
return fmt.Errorf("dial runner: %w", err)
|
|
}
|
|
|
|
ticker := time.NewTicker(250 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
if _, healthErr := client.AccessibilityInfo(startupCtx); healthErr == nil {
|
|
break
|
|
}
|
|
select {
|
|
case <-startupCtx.Done():
|
|
_ = client.Close()
|
|
d.stopRunnerChild()
|
|
return fmt.Errorf("runner health: %w", startupCtx.Err())
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
d.runnerClient = client
|
|
return nil
|
|
}
|
|
|
|
// withRecovery runs call, and on a connection-level failure performs one
|
|
// in-place restart before retrying the call once. A non-connection error, or a
|
|
// second failure of any kind, surfaces to the caller. The restart budget is per
|
|
// failure incident: each healthy call resets restarting to false, so a later
|
|
// drop earns its own single restart.
|
|
func (d *Driver) withRecovery(ctx context.Context, call func() error) error {
|
|
err := call()
|
|
if err == nil || !isConnectionError(err) || d.restarting || d.restart == nil {
|
|
return err
|
|
}
|
|
d.restarting = true
|
|
defer func() { d.restarting = false }()
|
|
fmt.Fprintf(d.output, "companion connection lost (%v); restarting once\n", err)
|
|
// The restart runs under the driver's own lifetime context, not the
|
|
// failed call's: an action whose deadline already expired must not doom
|
|
// the recovery that later actions depend on.
|
|
restartCtx := d.processContext
|
|
if restartCtx == nil {
|
|
restartCtx = ctx
|
|
}
|
|
if restartErr := d.restart(restartCtx); restartErr != nil {
|
|
return fmt.Errorf("companion restart failed: %w (original: %v)", restartErr, err)
|
|
}
|
|
return call()
|
|
}
|
|
|
|
// isConnectionError reports whether err is a dropped-connection signal that a
|
|
// restart can recover from: the transport's unavailable sentinel, a gRPC
|
|
// Unavailable status, or an EOF.
|
|
func isConnectionError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, io.EOF) || errors.Is(err, transport.ErrCompanionUnavailable) {
|
|
return true
|
|
}
|
|
if statusValue, ok := status.FromError(err); ok {
|
|
return statusValue.Code() == codes.Unavailable
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, env map[string]string) error {
|
|
if bundleID != "" {
|
|
d.bundleID = bundleID
|
|
}
|
|
if len(env) > 0 {
|
|
// The launch Start message carries an env map, but this backend does
|
|
// not pass it through: passing it would change the app's process
|
|
// environment in ways the rest of the run does not account for. Reject
|
|
// loudly rather than silently dropping the request.
|
|
return errors.New("ios companion: launch with environment variables is unsupported on this backend")
|
|
}
|
|
|
|
// Terminate first so the launch is a clean cold start regardless of the
|
|
// app's prior state. A not-running app is not an error here.
|
|
_ = d.lifecycleCall(ctx, func(callCtx context.Context, companion transport.Companion) error {
|
|
return companion.Terminate(callCtx, d.bundleID)
|
|
})
|
|
|
|
if clearState {
|
|
if err := d.clearAppState(ctx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Grant the app pasteboard access before it runs so unicode input (which
|
|
// must go through the pasteboard, since HID cannot express it) never trips
|
|
// the iOS paste-permission prompt. clearState reinstall resets the grant,
|
|
// so it is reapplied on every launch. Best effort: if it fails, the paste
|
|
// path still handles the prompt, just slower. The hybrid path types
|
|
// natively and never touches the pasteboard, so it skips the grant.
|
|
if d.bundleID != "" && d.runnerTyper() == nil {
|
|
if err := d.grantPaste(ctx); err != nil {
|
|
fmt.Fprintf(d.output, "grant pasteboard access failed (continuing): %v\n", err)
|
|
}
|
|
}
|
|
|
|
if err := d.lifecycleCall(ctx, func(callCtx context.Context, companion transport.Companion) error {
|
|
return companion.Launch(callCtx, d.bundleID, true)
|
|
}); err != nil {
|
|
return fmt.Errorf("launch %s: %w", d.bundleID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// lifecycleCall runs an app lifecycle RPC against lifecycleCompanion under a
|
|
// launchTimeout-bounded context, with the usual one-restart recovery. The
|
|
// companion is resolved inside the retry so a restart's replacement client
|
|
// serves the second attempt.
|
|
func (d *Driver) lifecycleCall(ctx context.Context, call func(context.Context, transport.Companion) error) error {
|
|
boundedCtx, cancel := context.WithTimeout(ctx, launchTimeout)
|
|
defer cancel()
|
|
return d.withRecovery(boundedCtx, func() error {
|
|
return call(boundedCtx, d.lifecycleCompanion())
|
|
})
|
|
}
|
|
|
|
// lifecycleCompanion is the transport that owns app launch and terminate: the
|
|
// in-simulator runner when the hybrid is active, otherwise the legacy
|
|
// companion. Lifecycle performed outside the runner's automation session
|
|
// leaves the session's app proxies bound to dead processes, after which
|
|
// snapshots hang and typing asserts.
|
|
func (d *Driver) lifecycleCompanion() transport.Companion {
|
|
if d.runnerClient != nil {
|
|
return d.runnerClient
|
|
}
|
|
return d.companion
|
|
}
|
|
|
|
// clearAppState resets the app to a first-launch state. With an app path it
|
|
// uninstalls and reinstalls; without one it falls back to wiping the app's data
|
|
// container and warns once that a full reinstall needs the app path.
|
|
func (d *Driver) clearAppState(ctx context.Context) error {
|
|
if d.appPath != "" {
|
|
if err := d.reinstallApp(ctx); err != nil {
|
|
return fmt.Errorf("reinstall %s: %w", d.appPath, err)
|
|
}
|
|
return nil
|
|
}
|
|
if !d.clearStateWarned {
|
|
fmt.Fprintln(d.output, "clear-state requested without an app path: resetting the data container only; pass the app path for a full reinstall")
|
|
d.clearStateWarned = true
|
|
}
|
|
return d.resetContainer(ctx)
|
|
}
|
|
|
|
// simctlReinstall uninstalls and reinstalls the app bundle via simctl. App
|
|
// lifecycle stays with simctl: the companion's install RPC misreads current
|
|
// simulator targets' architectures and rejects valid bundles.
|
|
func (d *Driver) simctlReinstall(ctx context.Context) error {
|
|
_ = exec.CommandContext(ctx, "xcrun", "simctl", "uninstall", d.udid, d.bundleID).Run()
|
|
output, err := exec.CommandContext(ctx, "xcrun", "simctl", "install", d.udid, d.appPath).CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("simctl install: %w: %s", err, strings.TrimSpace(string(output)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// grantPasteboardAccess authorizes the app to read the pasteboard without the
|
|
// iOS permission prompt, by writing an allow row into the simulator's privacy
|
|
// (TCC) database. This is the simulator counterpart to `simctl privacy grant`,
|
|
// which does not expose the pasteboard service. Without it, every unicode input
|
|
// (which must paste, since HID cannot express unicode) blocks on a modal that
|
|
// costs seconds; with it the paste lands in one frame.
|
|
func (d *Driver) grantPasteboardAccess(ctx context.Context) error {
|
|
databasePath := filepath.Join(
|
|
os.Getenv("HOME"), "Library", "Developer", "CoreSimulator", "Devices",
|
|
d.udid, "data", "Library", "TCC", "TCC.db",
|
|
)
|
|
if _, err := os.Stat(databasePath); err != nil {
|
|
return fmt.Errorf("locate privacy database: %w", err)
|
|
}
|
|
statement := fmt.Sprintf(
|
|
"INSERT OR REPLACE INTO access "+
|
|
"(service,client,client_type,auth_value,auth_reason,auth_version,indirect_object_identifier) "+
|
|
"VALUES ('kTCCServicePasteboard','%s',0,2,4,1,'UNUSED');",
|
|
d.bundleID,
|
|
)
|
|
output, err := exec.CommandContext(ctx, "sqlite3", databasePath, statement).CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("write privacy grant: %w: %s", err, strings.TrimSpace(string(output)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resetDataContainer deletes the contents of the app's data container so the
|
|
// next launch starts with empty storage.
|
|
func (d *Driver) resetDataContainer(ctx context.Context) error {
|
|
output, err := exec.CommandContext(ctx, "xcrun", "simctl", "get_app_container", d.udid, d.bundleID, "data").Output()
|
|
if err != nil {
|
|
return fmt.Errorf("get app container: %w", err)
|
|
}
|
|
container := string(bytes.TrimSpace(output))
|
|
if container == "" {
|
|
return nil
|
|
}
|
|
entries, err := os.ReadDir(container)
|
|
if err != nil {
|
|
return fmt.Errorf("read app container: %w", err)
|
|
}
|
|
for _, entry := range entries {
|
|
if err := os.RemoveAll(filepath.Join(container, entry.Name())); err != nil {
|
|
return fmt.Errorf("clear app container: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *Driver) Terminate(ctx context.Context) error {
|
|
return d.lifecycleCall(ctx, func(callCtx context.Context, companion transport.Companion) error {
|
|
return companion.Terminate(callCtx, d.bundleID)
|
|
})
|
|
}
|
|
|
|
func (d *Driver) Tap(ctx context.Context, x, y int) error {
|
|
d.mu.Lock()
|
|
d.lastTap.x = float64(x)
|
|
d.lastTap.y = float64(y)
|
|
d.lastTap.set = true
|
|
d.mu.Unlock()
|
|
return d.withRecovery(ctx, func() error {
|
|
return d.companion.SendHID(ctx, tapEvents(float64(x), float64(y))...)
|
|
})
|
|
}
|
|
|
|
func (d *Driver) DoubleTap(ctx context.Context, x, y int) error {
|
|
return d.withRecovery(ctx, func() error {
|
|
return d.companion.SendHID(ctx, doubleTapEvents(float64(x), float64(y), d.doubleTapGapMilliseconds)...)
|
|
})
|
|
}
|
|
|
|
func (d *Driver) LongPress(ctx context.Context, x, y int) error {
|
|
return d.withRecovery(ctx, func() error {
|
|
return d.companion.SendHID(ctx, longPressEvents(float64(x), float64(y), longPressHoldMilliseconds)...)
|
|
})
|
|
}
|
|
|
|
func (d *Driver) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
|
|
seconds := duration.Seconds()
|
|
if seconds <= 0 {
|
|
seconds = 0.25
|
|
}
|
|
return d.withRecovery(ctx, func() error {
|
|
return d.companion.SendHID(ctx, transport.SwipeEvent(
|
|
float64(fromX), float64(fromY), float64(toX), float64(toY), seconds))
|
|
})
|
|
}
|
|
|
|
func (d *Driver) PressKey(ctx context.Context, key string) error {
|
|
if d.textEditor() != nil {
|
|
return d.withRecovery(ctx, func() error {
|
|
return d.textEditor().PressKey(ctx, key)
|
|
})
|
|
}
|
|
usage, ok := pressKeyUsage(key)
|
|
if !ok {
|
|
return fmt.Errorf("ios companion: unsupported key %q", key)
|
|
}
|
|
return d.withRecovery(ctx, func() error {
|
|
return d.companion.SendHID(ctx, transport.KeyDown(usage), transport.KeyUp(usage))
|
|
})
|
|
}
|
|
|
|
// textEditor returns the companion's native text-editing capability, or nil
|
|
// when the transport does not implement it. Resolved per call because a
|
|
// restart replaces d.companion.
|
|
func (d *Driver) textEditor() transport.TextEditor {
|
|
if editor, ok := d.companion.(transport.TextEditor); ok {
|
|
return editor
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runnerTyper returns the in-simulator runner's native typing capability, or
|
|
// nil outside the hybrid path. Resolved per call because a restart replaces
|
|
// d.runnerClient.
|
|
func (d *Driver) runnerTyper() transport.TextTyper {
|
|
if typer, ok := d.runnerClient.(transport.TextTyper); ok {
|
|
return typer
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pressKeyUsage maps the logical key names mobile runs emit to a HID usage.
|
|
// Only Return/Enter has a hardware-keyboard equivalent on the simulator; other
|
|
// names (notably "back" and "home") have no HID key and report unsupported.
|
|
func pressKeyUsage(key string) (uint32, bool) {
|
|
switch key {
|
|
case "enter", "return", "Enter", "Return":
|
|
return usageReturn, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func (d *Driver) TapSelector(ctx context.Context, selector string) error {
|
|
x, y, err := d.resolveSelectorCenter(ctx, selector)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return d.Tap(ctx, x, y)
|
|
}
|
|
|
|
func (d *Driver) DoubleTapSelector(ctx context.Context, selector string) error {
|
|
x, y, err := d.resolveSelectorCenter(ctx, selector)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return d.DoubleTap(ctx, x, y)
|
|
}
|
|
|
|
// resolveSelectorCenter fetches a fresh hierarchy and returns the center of the
|
|
// first element matching selector.
|
|
func (d *Driver) resolveSelectorCenter(ctx context.Context, selector string) (int, int, error) {
|
|
hierarchyJSON, err := d.Hierarchy(ctx)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
tree, err := hierarchy.Parse(hierarchyJSON)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("parse hierarchy: %w", err)
|
|
}
|
|
element := tree.Find(selector)
|
|
if element == nil {
|
|
return 0, 0, fmt.Errorf("selector %q matched no element", selector)
|
|
}
|
|
x, y := element.Bounds.Center()
|
|
return x, y, nil
|
|
}
|
|
|
|
func (d *Driver) InputText(ctx context.Context, text string) error {
|
|
// Hybrid path. Mappable text rides one HID stream: select-all chord plus
|
|
// keystrokes, atomic and strictly ordered on a single channel. Unicode
|
|
// (which HID cannot express) is typed natively by the runner after the
|
|
// chord; chord and typing ride different channels with no ordering
|
|
// guarantee between them, so the clear is verified through a snapshot
|
|
// before the first keystroke goes out.
|
|
if typer := d.runnerTyper(); typer != nil {
|
|
if !usesPasteboard(text) {
|
|
events := append(clearFieldEvents(), keyPressEvents(typeStringPresses(text))...)
|
|
return d.withRecovery(ctx, func() error {
|
|
return d.companion.SendHID(ctx, events...)
|
|
})
|
|
}
|
|
return d.withRecovery(ctx, func() error {
|
|
if err := d.companion.SendHID(ctx, clearFieldEvents()...); err != nil {
|
|
return fmt.Errorf("clear field: %w", err)
|
|
}
|
|
d.waitFieldCleared(ctx)
|
|
return d.runnerTyper().TypeText(ctx, text, false)
|
|
})
|
|
}
|
|
// A text-editing companion replaces the field's content natively, which
|
|
// covers unicode without the pasteboard and its permission dialog.
|
|
if d.textEditor() != nil {
|
|
return d.withRecovery(ctx, func() error {
|
|
return d.textEditor().InputText(ctx, text)
|
|
})
|
|
}
|
|
// The field target is only needed for the pasteboard path. Resolving it
|
|
// requires a describe-all, so the fast keyboard path skips that round-trip
|
|
// and lets inputText send the key presses directly.
|
|
var field fieldTarget
|
|
if usesPasteboard(text) {
|
|
field = d.resolveInputField(ctx)
|
|
}
|
|
return inputText(ctx, d.makeRunner(), text, field)
|
|
}
|
|
|
|
// fieldClearedWaitCap and fieldClearedPoll bound the verify-cleared loop
|
|
// between the HID clear chord and the runner's native typing.
|
|
const fieldClearedWaitCap = 1200 * time.Millisecond
|
|
const fieldClearedPoll = 150 * time.Millisecond
|
|
|
|
// waitFieldCleared polls the focused field (the editable element under the
|
|
// last tap) until its value reads empty, so the clear chord has demonstrably
|
|
// landed before typing starts on the other channel. Best effort: when the
|
|
// field cannot be resolved or the cap elapses, typing proceeds anyway.
|
|
func (d *Driver) waitFieldCleared(ctx context.Context) {
|
|
d.mu.Lock()
|
|
tap := d.lastTap
|
|
d.mu.Unlock()
|
|
if !tap.set {
|
|
return
|
|
}
|
|
deadline := time.Now().Add(fieldClearedWaitCap)
|
|
for time.Now().Before(deadline) {
|
|
dump, err := d.describeAllRaw(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
cleared := true
|
|
for _, element := range decodeDump(dump) {
|
|
if !isEditable(element.Type) {
|
|
continue
|
|
}
|
|
frame := element.Frame
|
|
if !finite(frame.X) || !finite(frame.Y) || !finite(frame.Width) || !finite(frame.Height) {
|
|
continue
|
|
}
|
|
if tap.x < frame.X || tap.x > frame.X+frame.Width ||
|
|
tap.y < frame.Y || tap.y > frame.Y+frame.Height {
|
|
continue
|
|
}
|
|
if value := stringValue(element.AXValue); value != "" && value != emptyFieldValueSentinel {
|
|
cleared = false
|
|
}
|
|
break
|
|
}
|
|
if cleared {
|
|
return
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(fieldClearedPoll):
|
|
}
|
|
}
|
|
}
|
|
|
|
// resolveInputField finds the editable element under the last tap so the
|
|
// pasteboard fallback can confirm the paste landed and refocus after dismissing
|
|
// the permission dialog. The runner always taps a field before typing, so
|
|
// lastTap names the focus point. An empty fieldTarget is returned when no
|
|
// editable element contains the tap (the fast keyboard path ignores it).
|
|
func (d *Driver) resolveInputField(ctx context.Context) fieldTarget {
|
|
d.mu.Lock()
|
|
tap := d.lastTap
|
|
d.mu.Unlock()
|
|
if !tap.set {
|
|
return fieldTarget{}
|
|
}
|
|
dump, err := d.describeAll(ctx)
|
|
if err != nil {
|
|
return fieldTarget{}
|
|
}
|
|
for _, element := range decodeDump(dump) {
|
|
if !isEditable(element.Type) {
|
|
continue
|
|
}
|
|
frame := element.Frame
|
|
if !finite(frame.X) || !finite(frame.Y) || !finite(frame.Width) || !finite(frame.Height) {
|
|
continue
|
|
}
|
|
if tap.x < frame.X || tap.x > frame.X+frame.Width ||
|
|
tap.y < frame.Y || tap.y > frame.Y+frame.Height {
|
|
continue
|
|
}
|
|
return fieldTarget{
|
|
identifier: stringValue(element.AXUniqueID),
|
|
centerX: frame.X + frame.Width/2,
|
|
centerY: frame.Y + frame.Height/2,
|
|
}
|
|
}
|
|
return fieldTarget{}
|
|
}
|
|
|
|
func (d *Driver) EraseText(ctx context.Context, characterCount int) error {
|
|
if d.textEditor() != nil {
|
|
return d.withRecovery(ctx, func() error {
|
|
return d.textEditor().EraseText(ctx, characterCount)
|
|
})
|
|
}
|
|
return eraseText(ctx, d.makeRunner(), characterCount)
|
|
}
|
|
|
|
func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
|
|
dump, err := d.describeAll(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
mapped, err := MapHierarchy(dump, d.screenWidth, d.screenHeight)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(mapped), nil
|
|
}
|
|
|
|
func (d *Driver) Screenshot(ctx context.Context) (driver.Image, error) {
|
|
var data []byte
|
|
err := d.withRecovery(ctx, func() error {
|
|
var screenshotErr error
|
|
data, _, screenshotErr = d.companion.Screenshot(ctx)
|
|
return screenshotErr
|
|
})
|
|
if err != nil {
|
|
return driver.Image{}, fmt.Errorf("screenshot: %w", err)
|
|
}
|
|
return decodeScreenshot(data)
|
|
}
|
|
|
|
func (d *Driver) Snapshot(ctx context.Context) (string, driver.Image, error) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
// The hierarchy and the screenshot ride different transports on the
|
|
// hybrid path, so they are captured concurrently. Only the hierarchy leg
|
|
// runs under withRecovery: two concurrent recoveries would race the
|
|
// restart bookkeeping, and a screenshot connection failure surfaces as a
|
|
// plain error that the next serialized call recovers from. The goroutine
|
|
// works through a captured local because a hierarchy-leg recovery
|
|
// reassigns d.companion mid-flight; a screenshot against the torn-down
|
|
// transport then fails as a plain error rather than racing the field.
|
|
var data []byte
|
|
screenshotDone := make(chan error, 1)
|
|
companion := d.companion
|
|
go func() {
|
|
imageData, _, callErr := companion.Screenshot(ctx)
|
|
data = imageData
|
|
screenshotDone <- callErr
|
|
}()
|
|
|
|
dump, err := d.describeAll(ctx)
|
|
screenshotErr := <-screenshotDone
|
|
if err != nil {
|
|
return "", driver.Image{}, err
|
|
}
|
|
if screenshotErr != nil {
|
|
return "", driver.Image{}, fmt.Errorf("screenshot: %w", screenshotErr)
|
|
}
|
|
mapped, err := MapHierarchy(dump, d.screenWidth, d.screenHeight)
|
|
if err != nil {
|
|
return "", driver.Image{}, err
|
|
}
|
|
image, err := decodeScreenshot(data)
|
|
if err != nil {
|
|
return string(mapped), driver.Image{}, err
|
|
}
|
|
return string(mapped), image, nil
|
|
}
|
|
|
|
// WaitForIdle polls the hierarchy until it settles. The duration argument is
|
|
// ignored: the ported settle constants (StabilityPollCap and friends) own the
|
|
// cap, matching the companion's own settle behavior.
|
|
func (d *Driver) WaitForIdle(ctx context.Context, _ time.Duration) error {
|
|
clock := d.idleClock
|
|
if clock == nil {
|
|
clock = SystemClock()
|
|
}
|
|
PollUntilStable(ctx, clock, func() *hierarchy.Tree {
|
|
dump, err := d.describeAllRaw(ctx)
|
|
if err != nil || dumpIsCollapsed(dump) {
|
|
// A collapsed dump is the bridge mid-transition; report it
|
|
// transitional so the streak resets and the poll waits for the
|
|
// real tree rather than settling on the empty shell.
|
|
return nil
|
|
}
|
|
mapped, err := MapHierarchy(dump, d.screenWidth, d.screenHeight)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
tree, err := hierarchy.Parse(string(mapped))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return tree
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// RecentLogs returns no entries: the companion log RPC is a follow-up, so v1
|
|
// reports an empty slice rather than failing.
|
|
func (d *Driver) RecentLogs(_ context.Context, _ time.Time, _ string) ([]driver.LogEntry, error) {
|
|
return []driver.LogEntry{}, nil
|
|
}
|
|
|
|
func (d *Driver) Metrics(_ context.Context, _ string) (driver.Metrics, error) {
|
|
return driver.Metrics{}, nil
|
|
}
|
|
|
|
func (d *Driver) Health(_ context.Context) (driver.Health, error) {
|
|
return driver.Health{Ready: true, Platform: "ios"}, nil
|
|
}
|
|
|
|
// ForegroundApp reports the foreground app. It returns the app under test when
|
|
// it is running; otherwise it names another running user app, or "" when none
|
|
// is. The companion exposes process state but not a foreground flag, so "the
|
|
// app under test is running" stands in for "in the foreground".
|
|
func (d *Driver) ForegroundApp(ctx context.Context) (string, error) {
|
|
var apps []transport.InstalledApp
|
|
if err := d.withRecovery(ctx, func() error {
|
|
var listErr error
|
|
apps, listErr = d.companion.ListApps(ctx)
|
|
return listErr
|
|
}); err != nil {
|
|
return "", err
|
|
}
|
|
other := ""
|
|
for _, app := range apps {
|
|
if app.ProcessState != transport.ProcessStateRunning {
|
|
continue
|
|
}
|
|
if app.BundleID == d.bundleID {
|
|
return d.bundleID, nil
|
|
}
|
|
if app.InstallType == "user" && other == "" {
|
|
other = app.BundleID
|
|
}
|
|
}
|
|
return other, nil
|
|
}
|
|
|
|
// collapsedDumpRetries and collapsedDumpDelay bound how long describeAll waits
|
|
// out a collapsed accessibility dump. The bridge briefly reports only the app
|
|
// shell (no UI content) during cold start and screen transitions; it recovers
|
|
// within a few hundred milliseconds. Re-fetching past the collapse keeps the
|
|
// runner from acting on, and snapshotting, an empty tree.
|
|
const collapsedDumpRetries = 6
|
|
const collapsedDumpDelay = 150 * time.Millisecond
|
|
|
|
// snapshotCompanion is the transport that serves accessibility dumps: the
|
|
// in-simulator runner when the hybrid is active (its snapshots never collapse),
|
|
// otherwise the legacy companion.
|
|
func (d *Driver) snapshotCompanion() transport.Companion {
|
|
if d.runnerClient != nil {
|
|
return d.runnerClient
|
|
}
|
|
return d.companion
|
|
}
|
|
|
|
// describeAllRaw fetches the flat accessibility dump with one-restart recovery
|
|
// and no collapse handling. The settle loop uses it: it treats a collapsed dump
|
|
// as transitional itself, so an inner retry here would double the wait.
|
|
func (d *Driver) describeAllRaw(ctx context.Context) ([]byte, error) {
|
|
var dump []byte
|
|
err := d.withRecovery(ctx, func() error {
|
|
info, infoErr := d.snapshotCompanion().AccessibilityInfo(ctx)
|
|
if infoErr != nil {
|
|
return infoErr
|
|
}
|
|
dump = []byte(info)
|
|
return nil
|
|
})
|
|
return dump, err
|
|
}
|
|
|
|
// describeAll fetches the flat accessibility dump, retrying past a transient
|
|
// collapsed dump so one-shot reads (Snapshot, Hierarchy) see real UI content.
|
|
func (d *Driver) describeAll(ctx context.Context) ([]byte, error) {
|
|
dump, err := d.describeAllRaw(ctx)
|
|
if err != nil {
|
|
return dump, err
|
|
}
|
|
for attempt := 0; attempt < collapsedDumpRetries && dumpIsCollapsed(dump); attempt++ {
|
|
select {
|
|
case <-ctx.Done():
|
|
return dump, nil
|
|
case <-time.After(collapsedDumpDelay):
|
|
}
|
|
next, nextErr := d.describeAllRaw(ctx)
|
|
if nextErr != nil {
|
|
return dump, nil
|
|
}
|
|
dump = next
|
|
}
|
|
return dump, nil
|
|
}
|
|
|
|
// makeRunner builds the input runner backed by the current transport. The text
|
|
// runner does not route through withRecovery: it is invoked synchronously
|
|
// inside a single InputText call and a mid-paste connection drop surfaces as a
|
|
// normal error the runner retries.
|
|
func (d *Driver) makeRunner() runner {
|
|
return simctlRunner{companion: d.companion, udid: d.udid}
|
|
}
|
|
|
|
// Close stops the companion and runner children and releases the transports.
|
|
func (d *Driver) Close() {
|
|
if d.companion != nil {
|
|
_ = d.companion.Close()
|
|
d.companion = nil
|
|
}
|
|
d.stopChild()
|
|
if d.runnerClient != nil {
|
|
_ = d.runnerClient.Close()
|
|
d.runnerClient = nil
|
|
}
|
|
d.stopRunnerChild()
|
|
d.stopTunnel()
|
|
if d.processCancel != nil {
|
|
d.processCancel()
|
|
}
|
|
if d.deviceLock != nil {
|
|
_ = d.deviceLock.Close()
|
|
d.deviceLock = nil
|
|
}
|
|
}
|
|
|
|
// stopTunnel closes the in-process usbmux forwarder on the device path. Closing
|
|
// its listener ends the accept loop and lets the open bridges drain; a nil
|
|
// tunnel (the simulator path) is a no-op.
|
|
func (d *Driver) stopTunnel() {
|
|
tunnel := d.tunnel
|
|
d.tunnel = nil
|
|
if tunnel != nil {
|
|
_ = tunnel.Close()
|
|
}
|
|
}
|
|
|
|
// stopChild terminates the companion child gracefully (SIGTERM, grace window,
|
|
// then SIGKILL) so it leaves no orphan behind.
|
|
func (d *Driver) stopChild() {
|
|
child := d.child
|
|
d.child = nil
|
|
stopProcess(child)
|
|
}
|
|
|
|
// stopRunnerChild terminates the runner's hosting session the same way. The
|
|
// session tears down its in-simulator children on SIGTERM; killing it outright
|
|
// would orphan them.
|
|
func (d *Driver) stopRunnerChild() {
|
|
child := d.runnerChild
|
|
d.runnerChild = nil
|
|
stopProcess(child)
|
|
}
|
|
|
|
func stopProcess(child *exec.Cmd) {
|
|
if child == nil || child.Process == nil {
|
|
return
|
|
}
|
|
if err := child.Process.Signal(syscall.SIGTERM); err != nil {
|
|
_ = child.Process.Kill()
|
|
_ = child.Wait()
|
|
return
|
|
}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
_ = child.Wait()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(shutdownGrace):
|
|
_ = child.Process.Kill()
|
|
<-done
|
|
}
|
|
}
|
|
|
|
// decodeScreenshot sniffs the PNG magic and decodes the pixel dimensions. The
|
|
// companion leaves image_format empty in practice, so the magic bytes are the
|
|
// only reliable format signal. No scaling is applied: the dimensions are pixels.
|
|
func decodeScreenshot(data []byte) (driver.Image, error) {
|
|
if len(data) < 8 || !bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")) {
|
|
return driver.Image{}, errors.New("screenshot: response is not a PNG")
|
|
}
|
|
config, _, err := image.DecodeConfig(bytes.NewReader(data))
|
|
if err != nil {
|
|
return driver.Image{}, fmt.Errorf("decode screenshot: %w", err)
|
|
}
|
|
return driver.Image{PNG: data, Width: config.Width, Height: config.Height}, nil
|
|
}
|
|
|
|
// realSpawnChild extracts the embedded companion and starts it on the given
|
|
// address. Cancel sends SIGTERM so the companion detaches cleanly from the
|
|
// simulator; WaitDelay bounds the grace before the runtime kills it.
|
|
func (d *Driver) realSpawnChild(ctx context.Context, address string) (*exec.Cmd, error) {
|
|
extractDirectory := filepath.Join(os.TempDir(), "sanderling-companion")
|
|
binaryPath, err := companionassets.Extract(extractDirectory)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract companion: %w", err)
|
|
}
|
|
_, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
command := exec.CommandContext(ctx, binaryPath, "--udid", d.udid, "--grpc-port", port)
|
|
command.Stdout = d.output
|
|
command.Stderr = d.output
|
|
// The companion echoes its whole environment into the run log at startup,
|
|
// so it gets a minimal one: secrets in the parent environment must never
|
|
// reach run artifacts.
|
|
command.Env = []string{
|
|
"HOME=" + os.Getenv("HOME"),
|
|
"PATH=/usr/bin:/bin",
|
|
"TMPDIR=" + os.TempDir(),
|
|
}
|
|
command.Cancel = func() error { return command.Process.Signal(syscall.SIGTERM) }
|
|
command.WaitDelay = shutdownGrace
|
|
if err := command.Start(); err != nil {
|
|
return nil, fmt.Errorf("start companion: %w", err)
|
|
}
|
|
fmt.Fprintf(d.output, "companion pid=%d listening on %s\n", command.Process.Pid, address)
|
|
return command, nil
|
|
}
|
|
|
|
// pickLoopbackAddress reserves a free loopback port and returns its address.
|
|
func pickLoopbackAddress() (string, error) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer listener.Close()
|
|
return listener.Addr().String(), nil
|
|
}
|
|
|
|
// waitForListener blocks until address accepts a TCP connection or ctx expires.
|
|
func waitForListener(ctx context.Context, address string) error {
|
|
ticker := time.NewTicker(100 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
dialer := net.Dialer{Timeout: time.Second}
|
|
conn, err := dialer.DialContext(ctx, "tcp", address)
|
|
if err == nil {
|
|
_ = conn.Close()
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// ReplacesTextOnInput reports that InputText replaces the field's content, so
|
|
// the runner skips its pre-erase. The driver clears the field inside InputText,
|
|
// which is robust even when a collapsed accessibility bridge would make the
|
|
// runner read the field length as zero and wrongly skip erasing.
|
|
func (d *Driver) ReplacesTextOnInput() bool { return true }
|
|
|
|
var (
|
|
_ driver.DeviceDriver = (*Driver)(nil)
|
|
_ driver.ForegroundChecker = (*Driver)(nil)
|
|
_ driver.TextReplacer = (*Driver)(nil)
|
|
)
|