mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
redact passwords only, and say what each step did in the log (#93)
* feat(hierarchy): name the route a native tree shows The screen name was web-only: the Chrome driver stamps sanderling-screen on the root and nothing else does, so every Android and iOS step recorded and logged an empty screen. The route marker the tree already carries (the resource id ending in Screen, the same one Transitional counts) names it. * feat(runner): say what each step did in the step log One line per step carried only an index and a node count. It now names the screen, the action, its target and the typed value, the last through the same redaction the trace and the prompt use. Emitted after the apply so the line reports what actually happened, skip reason included. * fix(sidecar): state on android whether a field is a secure entry maestro's tree mapper copies a fixed attribute list off the device's XML and password is not on it, so no android element ever reported the fact and the conservative rule downstream redacted every typed value in the trace, the prompt and the log. The XML still carries it: re-read it once per settled snapshot and state the fact on the text fields it matches. A field it cannot match stays unstated, which still reads as a credential. * docs: correct the record that android never reports a secure field Four places said android reports the fact for nothing and that every typed value there is redacted. The sidecar now states it, so they described the old behaviour. * test(sidecar): fail the build if maestro renames the call the fact comes from * fix(sidecar): state the fact on a field named by its hint alone collectTextFields matched on class only, so a node the go side calls editable off its hintText was left unstated and its typed value redacted. * docs: record that ios and web state secure:false for compose password fields Both derive the fact from a widget type a compose app never has, so the value reaches the trace in the clear. Verified on folio on both targets. * feat(android): read the application id out of an apk parses the compiled AndroidManifest.xml rather than shelling out to aapt2, which lives in the versioned build-tools directory that hosts with only platform-tools never install. Claude-Session: https://claude.ai/code/session_012PVErdr3ZzyUASeVQDWsUc * feat(cli): let --android-app-path supply the bundle id --bundle-id stays required everywhere else, and an explicit one still wins, so the apk can never quietly override what was asked for. Claude-Session: https://claude.ai/code/session_012PVErdr3ZzyUASeVQDWsUc * docs: record that the apk can name the package itself Claude-Session: https://claude.ai/code/session_012PVErdr3ZzyUASeVQDWsUc * fix(testrun): pass the jvm the flag that silences the jdk 24 unsafe warning * feat(folio): ask which android device to run on when none is named * feat(folio): pin ios recipes to one simulator udid and ask when several match * docs(ci): say how just ios lands on the simulator the boot step chose * chore(folio): ignore run output anywhere under examples/folio * refactor(hierarchy): name no screen for a tree Transitional calls a cross-fade ScreenName kept its own reading of the route markers and disagreed with Transitional on a marker repeated by a nested node: it named the screen on a step the runner was skipping as unsettled. One reading now. * refactor(sidecar): inline the one attempt passed to callViewHierarchy A named constant and its own comment for a literal used once. * test(sidecar): compare the whole tree when checking the annotation changes nothing else The old assertions checked one id string and one bounds value, and passed with every other attribute stripped off every node. Now the annotated tree minus the two facts it stated must equal the input. * fix(sidecar): match a field to its xml node by class as well as id and bounds A wrapper drawn to the same bounds as the untagged field inside it shared the field's key, both were dropped as ambiguous, and every value typed into an untagged field was redacted. The class tells them apart. * docs(runs): record what the android hierarchy re-read costs per step Two 1m runs per binary on folio, same seed, before and after the re-read.
This commit is contained in:
25 files changed
+1287
-98
No files matched your search
@@ -275,12 +275,13 @@ jobs:
|
|||||||
# pair fails here naming what it does carry, rather than as a
|
# pair fails here naming what it does carry, rather than as a
|
||||||
# `bootstatus` error to read backwards from.
|
# `bootstatus` error to read backwards from.
|
||||||
#
|
#
|
||||||
# The UDID stays in this step. IOS_DEVICE has to keep holding the name,
|
# The UDID stays in this step, and IOS_DEVICE keeps holding the name,
|
||||||
# because `just ios` spends it as xcodebuild's `-destination name=`, which
|
# because the lookup below is what pairs that name with IOS_RUNTIME.
|
||||||
# matches on the display name and rejects a UDID. Nothing downstream needs
|
# Nothing downstream needs the UDID: `just ios` resolves IOS_DEVICE to one
|
||||||
# it anyway: the install, the launch and the terminate all address
|
# itself, matching booted simulators before merely available ones, so it
|
||||||
# `booted`, and sanderling resolves --ios-device against booted simulators
|
# lands on the simulator this step booted and spends its UDID as the build
|
||||||
# before available ones, so the one booted here is the one they all get.
|
# destination, the install and the launch. sanderling matches
|
||||||
|
# --ios-device the same way.
|
||||||
- name: Boot a simulator
|
- name: Boot a simulator
|
||||||
run: |
|
run: |
|
||||||
udid="$(python3 <<'PY'
|
udid="$(python3 <<'PY'
|
||||||
|
|||||||
+10
-2
@@ -12,6 +12,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/android"
|
||||||
"github.com/priyanshujain/sanderling/internal/testrun"
|
"github.com/priyanshujain/sanderling/internal/testrun"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
|
|||||||
flagSet.SetOutput(stderr)
|
flagSet.SetOutput(stderr)
|
||||||
var options testOptions
|
var options testOptions
|
||||||
flagSet.StringVar(&options.spec, "spec", "", "path to the TypeScript spec (required)")
|
flagSet.StringVar(&options.spec, "spec", "", "path to the TypeScript spec (required)")
|
||||||
flagSet.StringVar(&options.bundleID, "bundle-id", "", "target app bundle ID (required)")
|
flagSet.StringVar(&options.bundleID, "bundle-id", "", "target app bundle ID (required, except on android with --android-app-path: the APK names the package itself)")
|
||||||
flagSet.StringVar(&options.platform, "platform", "android", "target platform: android, ios, web")
|
flagSet.StringVar(&options.platform, "platform", "android", "target platform: android, ios, web")
|
||||||
flagSet.StringVar(&options.avd, "avd", "", "Android AVD name to boot if no device is connected")
|
flagSet.StringVar(&options.avd, "avd", "", "Android AVD name to boot if no device is connected")
|
||||||
flagSet.StringVar(&options.device, "device", "", "Android device serial (from `adb devices`) to target when several are connected")
|
flagSet.StringVar(&options.device, "device", "", "Android device serial (from `adb devices`) to target when several are connected")
|
||||||
@@ -85,7 +86,14 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
|
|||||||
return testOptions{}, errors.New("--spec is required")
|
return testOptions{}, errors.New("--spec is required")
|
||||||
}
|
}
|
||||||
if options.bundleID == "" {
|
if options.bundleID == "" {
|
||||||
return testOptions{}, errors.New("--bundle-id is required")
|
if options.platform != "android" || options.androidAppPath == "" {
|
||||||
|
return testOptions{}, errors.New("--bundle-id is required")
|
||||||
|
}
|
||||||
|
packageName, err := android.PackageName(options.androidAppPath)
|
||||||
|
if err != nil {
|
||||||
|
return testOptions{}, err
|
||||||
|
}
|
||||||
|
options.bundleID = packageName
|
||||||
}
|
}
|
||||||
switch options.platform {
|
switch options.platform {
|
||||||
case "android", "ios", "web":
|
case "android", "ios", "web":
|
||||||
|
|||||||
@@ -88,6 +88,52 @@ func TestParseTestArgs_RequiresBundleID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// folioAPK is the android package's fixture, borrowed rather than copied so
|
||||||
|
// there is one compiled manifest in the tree to keep current.
|
||||||
|
const folioAPK = "../../internal/android/testdata/folio.apk"
|
||||||
|
|
||||||
|
func TestParseTestArgs_ReadsBundleIDFromTheAPK(t *testing.T) {
|
||||||
|
options, err := parseTestArgs([]string{"--spec", "s.ts", "--android-app-path", folioAPK}, io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if options.bundleID != "app.folio" {
|
||||||
|
t.Fatalf("bundle id: got %q, want app.folio", options.bundleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTestArgs_ExplicitBundleIDOutranksTheAPK(t *testing.T) {
|
||||||
|
options, err := parseTestArgs([]string{
|
||||||
|
"--spec", "s.ts",
|
||||||
|
"--bundle-id", "app.folio.debug",
|
||||||
|
"--android-app-path", folioAPK,
|
||||||
|
}, io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if options.bundleID != "app.folio.debug" {
|
||||||
|
t.Fatalf("bundle id: got %q, want app.folio.debug", options.bundleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTestArgs_APKDoesNotNameTheBundleOffAndroid(t *testing.T) {
|
||||||
|
_, err := parseTestArgs([]string{
|
||||||
|
"--spec", "s.ts",
|
||||||
|
"--platform", "ios",
|
||||||
|
"--android-app-path", folioAPK,
|
||||||
|
}, io.Discard)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "--bundle-id") {
|
||||||
|
t.Fatalf("expected missing --bundle-id error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTestArgs_UnreadableAPKIsReported(t *testing.T) {
|
||||||
|
_, err := parseTestArgs([]string{"--spec", "s.ts", "--android-app-path", "testdata/absent.apk"}, io.Discard)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "testdata/absent.apk") {
|
||||||
|
t.Fatalf("expected an error naming the apk, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseTestArgs_AVDIsOptional(t *testing.T) {
|
func TestParseTestArgs_AVDIsOptional(t *testing.T) {
|
||||||
options, err := parseTestArgs([]string{"--spec", "s.ts", "--bundle-id", "com.example"}, io.Discard)
|
options, err := parseTestArgs([]string{"--spec", "s.ts", "--bundle-id", "com.example"}, io.Discard)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Vendored
+2
-2
@@ -12,8 +12,8 @@ BASE = datetime(2026, 6, 6, 12, 0, 0, tzinfo=timezone(timedelta(hours=5, minutes
|
|||||||
|
|
||||||
|
|
||||||
# What a typed value is written to the trace as when the target reports no
|
# What a typed value is written to the trace as when the target reports no
|
||||||
# secure fact for the field. Android reports none for any field, so every
|
# secure fact for the field, which is what the android fixtures below model
|
||||||
# InputText it records reads this (internal/verifier/redaction.go).
|
# (internal/verifier/redaction.go).
|
||||||
REDACTED = "[redacted]"
|
REDACTED = "[redacted]"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -15,9 +15,9 @@ Run a spec against an app for a fixed duration.
|
|||||||
| Flag | Default | Description |
|
| Flag | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `--spec` | required | Path to the TypeScript spec. |
|
| `--spec` | required | Path to the TypeScript spec. |
|
||||||
| `--bundle-id` | required | Target app bundle ID (Android: applicationId). |
|
| `--bundle-id` | required | Target app bundle ID (Android: applicationId). Optional on Android when `--android-app-path` is given: the applicationId is read from the APK's compiled manifest. |
|
||||||
| `--device` | optional (android) | Android device serial, as `adb devices` reports it. Required when more than one device is attached. |
|
| `--device` | optional (android) | Android device serial, as `adb devices` reports it. Required when more than one device is attached. |
|
||||||
| `--android-app-path` | optional (android) | Path to the APK. Clear-state reinstalls from it instead of running `pm clear`. |
|
| `--android-app-path` | optional (android) | Path to the APK. Clear-state reinstalls from it instead of running `pm clear`, and it also supplies `--bundle-id` when that flag is absent. |
|
||||||
| `--platform` | `android` | Target platform: `android`, `ios`, or `web`. |
|
| `--platform` | `android` | Target platform: `android`, `ios`, or `web`. |
|
||||||
| `--avd` | optional (android) | Android AVD name to boot if no device is connected. Required only when no device is connected and multiple AVDs exist. |
|
| `--avd` | optional (android) | Android AVD name to boot if no device is connected. Required only when no device is connected and multiple AVDs exist. |
|
||||||
| `--ios-device` | optional (ios) | iOS target: a simulator name/UDID to boot, or a connected device's name, UDID, or CoreDevice id. |
|
| `--ios-device` | optional (ios) | iOS target: a simulator name/UDID to boot, or a connected device's name, UDID, or CoreDevice id. |
|
||||||
|
|||||||
+5
-1
@@ -33,7 +33,11 @@ The trace is written incrementally. An interrupted run is complete up to the ste
|
|||||||
|
|
||||||
A run types into whatever the app puts on screen, login forms included, and the trace and the model call record are both shared. So a typed value is written down as `[redacted]` whenever the target may be a credential entry: the trace action, the recent-action memory the prompt carries, the numbered candidate list, and the `state.lastAction` a spec reads (and can extract into the trace) all render it that way. The app still receives the real keystrokes; only the record is redacted, and the record still names the field that was typed into.
|
A run types into whatever the app puts on screen, login forms included, and the trace and the model call record are both shared. So a typed value is written down as `[redacted]` whenever the target may be a credential entry: the trace action, the recent-action memory the prompt carries, the numbered candidate list, and the `state.lastAction` a spec reads (and can extract into the trace) all render it that way. The app still receives the real keystrokes; only the record is redacted, and the record still names the field that was typed into.
|
||||||
|
|
||||||
Which values that covers differs by platform, because the platforms differ in what they report. iOS and web state on every editable field whether it masks its input, so only the fields that do are redacted and the rest of the memory keeps its values. Android reports nothing: uiautomator's password attribute is dropped by the native tree mapper before the driver sees it, a password field is indistinguishable from a search box there, and so every typed value on Android is redacted.
|
Which values that covers is decided per field, from what the platform says about it. iOS and web state on every editable field whether it masks its input. Android states it too, though the tree the sidecar gets from maestro does not: maestro's mapper copies a fixed attribute list off the device's view hierarchy and `password` is not on it, so the sidecar re-reads that hierarchy once per settled snapshot and puts the fact back on the text fields it can match. A field it cannot match is left unstated, and an unstated field is redacted.
|
||||||
|
|
||||||
|
The re-read is one more device round trip on every screen that has a text field. Measured on 2026-09-05 against `examples/folio` on an emulator behind a remote adb server, seed 1, 1m runs, two per binary: 20 and 20 steps at the merge base (9b4ff5f), 20 and 19 with the re-read (8dc4d08), and a mean gap between steps of 3.08 s and 3.02 s against 3.13 s and 3.31 s. Nearly every screen that seed visits has a text field, so the runs paid for the re-read on almost every step, and it cost at most one step a minute. Two runs each cannot separate the 2% and 10% differences from run-to-run noise.
|
||||||
|
|
||||||
|
On iOS and web the fact comes from the platform's own widget type, and a Compose Multiplatform app has none: iOS exposes a password field as a `TextArea` rather than a `SecureTextField` (`internal/driver/ioscompanion/hierarchymap.go`), and web renders it as a `contenteditable` div rather than an `<input type="password">` (`internal/driver/chrome/driver.go`). Both checks then state `secure: false` from a test that cannot say yes for such an app, and the value is written to the record in the clear. Measured on 2026-08-19 against `examples/folio` on both targets: the login password reaches `trace.jsonl` as `ledger123`, in the step's `next_action.text` and again in the `state.lastAction` the following step reports. Android is not affected; the fact it reads is the device's own `password` attribute. Until this is closed, treat an iOS or web trace of a Compose app as holding whatever the run typed.
|
||||||
|
|
||||||
## App state across runs
|
## App state across runs
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ Every key-value pair must match. A key means the same thing here as in the strin
|
|||||||
|
|
||||||
Known attribute names are typed; you get autocomplete on `testTag`, `text`, `content-desc`, the boolean states (`clickable`, `enabled`, `focused`, `checked`, `selected`, `editable`, `secure`), and the cross-platform aliases (`identifier`, `accessibilityIdentifier`, `accessibilityText`, `accessibilityLabel`, `ariaLabel`, `contentDescription`, `label`, `testID`, `resource-id`, `class`, `className`, `elementType`, `package`, `placeholderValue`, `hintText`). Boolean state attributes accept a native `true` / `false`. Other attribute keys still type-check as a string-valued fallback so raw driver attributes remain reachable.
|
Known attribute names are typed; you get autocomplete on `testTag`, `text`, `content-desc`, the boolean states (`clickable`, `enabled`, `focused`, `checked`, `selected`, `editable`, `secure`), and the cross-platform aliases (`identifier`, `accessibilityIdentifier`, `accessibilityText`, `accessibilityLabel`, `ariaLabel`, `contentDescription`, `label`, `testID`, `resource-id`, `class`, `className`, `elementType`, `package`, `placeholderValue`, `hintText`). Boolean state attributes accept a native `true` / `false`. Other attribute keys still type-check as a string-valued fallback so raw driver attributes remain reachable.
|
||||||
|
|
||||||
A boolean state matches only where the platform reports it. `{secure: true}` names the password entry and `{secure: false}` names an editable field that is not one, so neither value names an element that is no field at all, and neither matches anything on Android, which reports the fact for nothing.
|
A boolean state matches only where the platform reports it. `{secure: true}` names the password entry and `{secure: false}` names an editable field that is not one, so neither value names an element that is no field at all. All three platforms report it; on Android a text field the sidecar cannot match against the device's own view hierarchy is left unstated and answers to neither value.
|
||||||
|
|
||||||
`clickable`, `enabled`, `focused`, `checked`, `selected` and `editable` are reported for every element, so both values of each match: `{clickable: false}` names every element that is not a tap target. They are read off the element as it stands, never off a markup attribute of the same name, so a box the user ticked answers to `{checked: true}` on a page whose markup never wrote `checked` anywhere.
|
`clickable`, `enabled`, `focused`, `checked`, `selected` and `editable` are reported for every element, so both values of each match: `{clickable: false}` names every element that is not a tap target. They are read off the element as it stands, never off a markup attribute of the same name, so a box the user ticked answers to `{checked: true}` on a page whose markup never wrote `checked` anywhere.
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ Fields available on every element returned by `find` / `findAll`:
|
|||||||
| `checked` | `boolean` | Checkbox or toggle state |
|
| `checked` | `boolean` | Checkbox or toggle state |
|
||||||
| `focused` | `boolean` | Element has input focus |
|
| `focused` | `boolean` | Element has input focus |
|
||||||
| `selected` | `boolean` | Selection state |
|
| `selected` | `boolean` | Selection state |
|
||||||
| `secure` | `boolean \| null` | Field masks what is typed into it; `null` where the platform does not report it (Android never does) |
|
| `secure` | `boolean \| null` | Field masks what is typed into it; `null` where the platform does not report it |
|
||||||
| `bounds` | `{ left, top, right, bottom }` | Bounding box in device pixels |
|
| `bounds` | `{ left, top, right, bottom }` | Bounding box in device pixels |
|
||||||
| `x` | `number` | Center X (derived from bounds) |
|
| `x` | `number` | Center X (derived from bounds) |
|
||||||
| `y` | `number` | Center Y (derived from bounds) |
|
| `y` | `number` | Center Y (derived from bounds) |
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ app/iosApp/iosApp.xcodeproj/
|
|||||||
app/iosApp/iosApp.xcodeproj/**
|
app/iosApp/iosApp.xcodeproj/**
|
||||||
.DS_Store
|
.DS_Store
|
||||||
sanderling/node_modules/
|
sanderling/node_modules/
|
||||||
sanderling/runs/
|
runs/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
web-*.png
|
web-*.png
|
||||||
+35
-15
@@ -23,15 +23,17 @@ example sanderling runs its property-based specs against.
|
|||||||
## Android
|
## Android
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
just install # asks which device, then builds + installs
|
||||||
ANDROID_DEVICE=emulator-5554 just install # build + install on that device
|
ANDROID_DEVICE=emulator-5554 just install # build + install on that device
|
||||||
ANDROID_DEVICE=emulator-5554 just uninstall
|
ANDROID_DEVICE=emulator-5554 just uninstall
|
||||||
just clean
|
just clean
|
||||||
```
|
```
|
||||||
|
|
||||||
`ANDROID_DEVICE` is the serial `adb devices` reports. Every recipe that
|
`ANDROID_DEVICE` is the serial `adb devices` reports. Every recipe that
|
||||||
installs, uninstalls or fuzzes refuses to run without it, unless the only
|
installs, uninstalls or fuzzes acts on that serial. Without it they print the
|
||||||
device adb can see is a single emulator on the local adb server. The refusal
|
online devices and ask which one to use, and pick on their own only when the
|
||||||
prints what adb currently sees.
|
one device adb can see is an emulator on the local adb server. With no terminal
|
||||||
|
to ask on, a CI job say, the ask becomes a refusal that prints what adb sees.
|
||||||
|
|
||||||
A run installs the app, clears its state and drives it, which is not something
|
A run installs the app, clears its state and drives it, which is not something
|
||||||
to do to a handset that happens to be the one thing plugged in. An emulator is
|
to do to a handset that happens to be the one thing plugged in. An emulator is
|
||||||
@@ -40,10 +42,18 @@ cheap to rebuild, so a lone local one is the single case worth guessing at.
|
|||||||
## iOS
|
## iOS
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
just ios # default device: iPhone 17 Pro
|
just ios # asks which simulator, unless one is booted
|
||||||
IOS_DEVICE="iPhone 15" just ios # pick a different simulator
|
IOS_DEVICE="iPhone 15" just ios # name it, by name or UDID
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`IOS_DEVICE` follows the same rule as `ANDROID_DEVICE`: a lone booted simulator
|
||||||
|
is taken without asking, anything else is asked about, and with no terminal to
|
||||||
|
ask on it refuses and lists what is installed. A name is matched against booted
|
||||||
|
simulators first and available ones second, and it can name several, since the
|
||||||
|
same iPhone exists under every installed runtime. When it does, you pick which,
|
||||||
|
and everything after that addresses the chosen UDID: the build destination, the
|
||||||
|
install, the launch and `--ios-device` all get the one simulator.
|
||||||
|
|
||||||
`just ios` regenerates `app/iosApp/iosApp.xcodeproj` from `app/iosApp/project.yml`,
|
`just ios` regenerates `app/iosApp/iosApp.xcodeproj` from `app/iosApp/project.yml`,
|
||||||
builds the KMP framework (`Shared.framework` from `:app:shared`), links it
|
builds the KMP framework (`Shared.framework` from `:app:shared`), links it
|
||||||
into the SwiftUI host, uninstalls any previous copy, installs, and launches.
|
into the SwiftUI host, uninstalls any previous copy, installs, and launches.
|
||||||
@@ -73,9 +83,9 @@ password: ledger123
|
|||||||
ANDROID_DEVICE=emulator-5554 just test
|
ANDROID_DEVICE=emulator-5554 just test
|
||||||
```
|
```
|
||||||
|
|
||||||
The same naming rule as `just install` applies. If nothing is attached at all,
|
The same naming rule as `just install` applies, and `just test` asks once for
|
||||||
`just test` boots a bootable AVD and runs against that. With multiple AVDs,
|
the whole run. If nothing is attached at all, `just test` boots a bootable AVD
|
||||||
pick one:
|
and runs against that. With multiple AVDs, pick one:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
AVD=Pixel_7 just test
|
AVD=Pixel_7 just test
|
||||||
@@ -90,14 +100,19 @@ DURATION=5m
|
|||||||
|
|
||||||
The device does not have to be attached to this machine. `ADB_SERVER_SOCKET`
|
The device does not have to be attached to this machine. `ADB_SERVER_SOCKET`
|
||||||
aims adb at another host's adb server, and `ANDROID_DEVICE` names the serial
|
aims adb at another host's adb server, and `ANDROID_DEVICE` names the serial
|
||||||
that server reports. A remote server is shared, so `ANDROID_DEVICE` is required
|
that server reports. A remote server is shared, so nothing there is ever picked
|
||||||
there even when it holds only one device:
|
without being named or asked about, one device on it or twenty:
|
||||||
|
|
||||||
```
|
```
|
||||||
ADB_SERVER_SOCKET=tcp:10.0.0.5:5037
|
ADB_SERVER_SOCKET=tcp:10.0.0.5:5037
|
||||||
ANDROID_DEVICE=emulator-5556
|
ANDROID_DEVICE=emulator-5556
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The older `ANDROID_ADB_SERVER_ADDRESS` / `ANDROID_ADB_SERVER_PORT` pair works
|
||||||
|
too, at the same precedence the adb CLI gives it. A remote server is never
|
||||||
|
auto-booted against: when it reports no device, `just test` says so rather than
|
||||||
|
starting a local emulator that server will never see.
|
||||||
|
|
||||||
Gradle only assembles the APK. The install goes through adb, which reads those
|
Gradle only assembles the APK. The install goes through adb, which reads those
|
||||||
variables, so a remote server needs nothing else. Gradle's own `installDebug`
|
variables, so a remote server needs nothing else. Gradle's own `installDebug`
|
||||||
cannot be used here: its adb client only ever dials loopback.
|
cannot be used here: its adb client only ever dials loopback.
|
||||||
@@ -128,13 +143,18 @@ each pick was made.
|
|||||||
## Run a sanderling test (iOS)
|
## Run a sanderling test (iOS)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
just test-ios # default simulator: iPhone 17 Pro
|
just test-ios # asks which simulator, unless one is booted
|
||||||
IOS_DEVICE="iPhone 15" just test-ios # pick a different simulator
|
IOS_DEVICE="iPhone 15" just test-ios # name it, by name or UDID
|
||||||
```
|
```
|
||||||
|
|
||||||
`just test-ios` boots the simulator if needed, runs `just ios` to install
|
`just test-ios` settles on a simulator once for the whole run, boots it if
|
||||||
and launch the app, then invokes `sanderling test --platform ios`. Same
|
needed, runs `just ios` to install and launch the app, then invokes `sanderling
|
||||||
`DURATION`, `SEED`, and `OUTPUT` env vars as the Android target.
|
test --platform ios`. Same `DURATION`, `SEED`, and `OUTPUT` env vars as the
|
||||||
|
Android target.
|
||||||
|
|
||||||
|
A physical iPhone is a different target: `just test-ios-device` requires
|
||||||
|
`IOS_DEVICE` to name it, and says so rather than running, because an empty one
|
||||||
|
resolves to a booted simulator and would fuzz that instead.
|
||||||
|
|
||||||
## How it connects to sanderling
|
## How it connects to sanderling
|
||||||
|
|
||||||
|
|||||||
+193
-34
@@ -7,7 +7,7 @@ android_device := env_var_or_default("ANDROID_DEVICE", "")
|
|||||||
duration := env_var_or_default("DURATION", "1m")
|
duration := env_var_or_default("DURATION", "1m")
|
||||||
seed := env_var_or_default("SEED", "0")
|
seed := env_var_or_default("SEED", "0")
|
||||||
output := env_var_or_default("OUTPUT", justfile_directory() / "sanderling" / "runs")
|
output := env_var_or_default("OUTPUT", justfile_directory() / "sanderling" / "runs")
|
||||||
ios_device := env_var_or_default("IOS_DEVICE", "iPhone 17 Pro")
|
ios_device := env_var_or_default("IOS_DEVICE", "")
|
||||||
ios_app := justfile_directory() / "app" / "iosApp" / "build" / "Build" / "Products" / "Debug-iphonesimulator" / "iosApp.app"
|
ios_app := justfile_directory() / "app" / "iosApp" / "build" / "Build" / "Products" / "Debug-iphonesimulator" / "iosApp.app"
|
||||||
ios_app_device := justfile_directory() / "app" / "iosApp" / "build" / "Build" / "Products" / "Debug-iphoneos" / "iosApp.app"
|
ios_app_device := justfile_directory() / "app" / "iosApp" / "build" / "Build" / "Products" / "Debug-iphoneos" / "iosApp.app"
|
||||||
apk := justfile_directory() / "app" / "androidApp" / "build" / "outputs" / "apk" / "debug" / "androidApp-debug.apk"
|
apk := justfile_directory() / "app" / "androidApp" / "build" / "outputs" / "apk" / "debug" / "androidApp-debug.apk"
|
||||||
@@ -39,9 +39,56 @@ _android-home:
|
|||||||
echo "could not locate Android SDK (set ANDROID_HOME)" >&2
|
echo "could not locate Android SDK (set ANDROID_HOME)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
|
# Ask which of a list of targets to act on. Callers set FOLIO_PICK_ITEMS to
|
||||||
|
# "value<TAB>label" lines and read one line back: "picked <value>", "cancelled"
|
||||||
|
# or "no-tty". The answer goes to stdout rather than an exit code so a caller
|
||||||
|
# reading it does not have to sieve just's own failure line out of the menu.
|
||||||
|
_pick header:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if ! { exec 3</dev/tty; } 2>/dev/null; then
|
||||||
|
echo "no-tty"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
values=()
|
||||||
|
labels=()
|
||||||
|
while IFS=$'\t' read -r value label; do
|
||||||
|
[[ -n "$value" ]] || continue
|
||||||
|
values+=("$value")
|
||||||
|
labels+=("${label:-$value}")
|
||||||
|
done <<<"$FOLIO_PICK_ITEMS"
|
||||||
|
echo "{{header}}" >&2
|
||||||
|
for i in "${!values[@]}"; do
|
||||||
|
printf ' %d) %s\n' "$((i + 1))" "${labels[$i]}" >&2
|
||||||
|
done
|
||||||
|
while :; do
|
||||||
|
printf 'folio: number (enter to cancel): ' >&2
|
||||||
|
read -r -u 3 reply || break
|
||||||
|
[[ -n "$reply" ]] || break
|
||||||
|
if [[ "$reply" =~ ^[0-9]+$ ]] && (( reply >= 1 && reply <= ${#values[@]} )); then
|
||||||
|
echo "picked ${values[$((reply - 1))]}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "folio: not one of the numbers above." >&2
|
||||||
|
done
|
||||||
|
echo "cancelled"
|
||||||
|
|
||||||
|
# Print where adb has been aimed when that is not this machine's default
|
||||||
|
# server, and nothing when it is. Read the way the adb CLI reads it, so the
|
||||||
|
# recipes that only ever guess at a local emulator can tell the difference.
|
||||||
|
_adb-server:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -n "${ADB_SERVER_SOCKET:-}" ]]; then
|
||||||
|
echo "${ADB_SERVER_SOCKET}"
|
||||||
|
elif [[ -n "${ANDROID_ADB_SERVER_ADDRESS:-}${ANDROID_ADB_SERVER_PORT:-}" ]]; then
|
||||||
|
echo "tcp:${ANDROID_ADB_SERVER_ADDRESS:-localhost}:${ANDROID_ADB_SERVER_PORT:-5037}"
|
||||||
|
fi
|
||||||
|
|
||||||
# Ensure an Android device is online. If none is connected and no AVD is
|
# Ensure an Android device is online. If none is connected and no AVD is
|
||||||
# provided, boot the first AVD whose system image is actually installed
|
# provided, boot the first AVD whose system image is actually installed
|
||||||
# (headless) and wait for it to finish booting.
|
# (headless) and wait for it to finish booting. A remote adb server is left
|
||||||
|
# alone: an emulator booted here would never appear on it.
|
||||||
_ensure-device:
|
_ensure-device:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -50,6 +97,11 @@ _ensure-device:
|
|||||||
if "$adb" devices | awk 'NR>1 && $2=="device"{f=1} END{exit !f}'; then
|
if "$adb" devices | awk 'NR>1 && $2=="device"{f=1} END{exit !f}'; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
server="$(just _adb-server)"
|
||||||
|
if [[ -n "$server" ]]; then
|
||||||
|
echo "no device is online on the adb server at $server, and an emulator booted here would not appear on it" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
if [[ -n "{{avd}}" ]]; then
|
if [[ -n "{{avd}}" ]]; then
|
||||||
exit 0 # sanderling boots the named AVD itself
|
exit 0 # sanderling boots the named AVD itself
|
||||||
fi
|
fi
|
||||||
@@ -79,15 +131,16 @@ _ensure-device:
|
|||||||
echo "emulator did not finish booting in time (see /tmp/folio-emulator.log)" >&2
|
echo "emulator did not finish booting in time (see /tmp/folio-emulator.log)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
# Print the serial every device-affecting recipe must act on, or refuse. A run
|
# Print the serial every device-affecting recipe must act on. A run installs
|
||||||
# installs the app, clears its state and fuzzes it, so the target is never
|
# the app, clears its state and fuzzes it, so the target is never inferred from
|
||||||
# inferred from "whatever adb resolved to": the one case it picks on its own is
|
# "whatever adb resolved to": the one case it picks on its own is a single
|
||||||
# a single emulator on the local adb server, which is cheap to rebuild.
|
# emulator on the local adb server, which is cheap to rebuild. Anything else is
|
||||||
|
# asked about when there is a terminal to ask on, and refused when there is not.
|
||||||
_require-device:
|
_require-device:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
adb="$(just _android-home)/platform-tools/adb"
|
adb="$(just _android-home)/platform-tools/adb"
|
||||||
listing="$("$adb" devices)"
|
listing="$("$adb" devices -l)"
|
||||||
online="$(echo "$listing" | awk 'NR>1 && $2=="device"{print $1}')"
|
online="$(echo "$listing" | awk 'NR>1 && $2=="device"{print $1}')"
|
||||||
count="$(printf '%s' "$online" | grep -c . || true)"
|
count="$(printf '%s' "$online" | grep -c . || true)"
|
||||||
|
|
||||||
@@ -106,6 +159,12 @@ _require-device:
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model_of() {
|
||||||
|
printf '%s\n' "$listing" | awk -v serial="$1" '$1 == serial {
|
||||||
|
for (i = 3; i <= NF; i++) if ($i ~ /^model:/) { sub(/^model:/, "", $i); print $i; exit }
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
if [[ -n "{{android_device}}" ]]; then
|
if [[ -n "{{android_device}}" ]]; then
|
||||||
if printf '%s\n' "$online" | grep -qxF "{{android_device}}"; then
|
if printf '%s\n' "$online" | grep -qxF "{{android_device}}"; then
|
||||||
echo "{{android_device}}"
|
echo "{{android_device}}"
|
||||||
@@ -116,13 +175,33 @@ _require-device:
|
|||||||
if [[ "$count" -eq 0 ]]; then
|
if [[ "$count" -eq 0 ]]; then
|
||||||
refuse "no device is online."
|
refuse "no device is online."
|
||||||
fi
|
fi
|
||||||
if [[ -z "${ADB_SERVER_SOCKET:-}" && "$count" -eq 1 && "$online" =~ ^emulator-[0-9]+$ ]]; then
|
if [[ -z "$(just _adb-server)" && "$count" -eq 1 && "$online" =~ ^emulator-[0-9]+$ ]]; then
|
||||||
echo "$online"
|
echo "$online"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
refuse "refusing to install on and fuzz a device nobody named. A run installs
|
|
||||||
the app, clears its state and drives it, so the only target it picks on its
|
items="$(printf '%s\n' "$online" | while read -r serial; do
|
||||||
own is a single emulator on the local adb server."
|
[[ -n "$serial" ]] || continue
|
||||||
|
model="$(model_of "$serial")"
|
||||||
|
printf '%s\t%s%s\n' "$serial" "$serial" "${model:+ $model}"
|
||||||
|
done)"
|
||||||
|
answer="$(FOLIO_PICK_ITEMS="$items" just _pick "folio: a run installs the app, clears its state and drives it. Pick the device:")"
|
||||||
|
case "$answer" in
|
||||||
|
"picked "*)
|
||||||
|
serial="${answer#picked }"
|
||||||
|
echo "folio: using $serial (ANDROID_DEVICE=$serial in examples/folio/.env skips this)" >&2
|
||||||
|
echo "$serial"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
cancelled)
|
||||||
|
echo "folio: cancelled." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
refuse "refusing to install on and fuzz a device nobody named, and there is no
|
||||||
|
terminal here to ask on. A run installs the app, clears its state and drives
|
||||||
|
it, so the only target it picks on its own is a single emulator on the local
|
||||||
|
adb server."
|
||||||
|
|
||||||
# Run folio's own unit tests. Named test-unit because `test` is the fuzz run.
|
# Run folio's own unit tests. Named test-unit because `test` is the fuzz run.
|
||||||
test-unit:
|
test-unit:
|
||||||
@@ -138,15 +217,16 @@ build:
|
|||||||
export ANDROID_HOME="$(just _android-home)"
|
export ANDROID_HOME="$(just _android-home)"
|
||||||
./gradlew :app:androidApp:assembleDebug
|
./gradlew :app:androidApp:assembleDebug
|
||||||
|
|
||||||
# Build and install the folio APK on a running emulator/device. Gradle only
|
# Build and install the folio APK on a running emulator/device, on the serial
|
||||||
# assembles: adb does the install because it reads ADB_SERVER_SOCKET, so the
|
# given or the one picked by _require-device. Gradle only assembles: adb does
|
||||||
# device may live on a remote adb server, which AGP's loopback-only client
|
# the install because it reads ADB_SERVER_SOCKET, so the device may live on a
|
||||||
# cannot reach.
|
# remote adb server, which AGP's loopback-only client cannot reach.
|
||||||
install: _ensure-device
|
install serial="": _ensure-device
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export ANDROID_HOME="$(just _android-home)"
|
export ANDROID_HOME="$(just _android-home)"
|
||||||
serial="$(just _require-device)"
|
serial="{{serial}}"
|
||||||
|
[[ -n "$serial" ]] || serial="$(just _require-device)"
|
||||||
export ANDROID_SERIAL="$serial"
|
export ANDROID_SERIAL="$serial"
|
||||||
./gradlew :app:androidApp:assembleDebug
|
./gradlew :app:androidApp:assembleDebug
|
||||||
"$ANDROID_HOME/platform-tools/adb" install -r "{{apk}}"
|
"$ANDROID_HOME/platform-tools/adb" install -r "{{apk}}"
|
||||||
@@ -168,33 +248,109 @@ clean:
|
|||||||
./gradlew clean
|
./gradlew clean
|
||||||
rm -rf app/iosApp/build
|
rm -rf app/iosApp/build
|
||||||
|
|
||||||
|
# Print the UDID of the simulator the iOS recipes drive. IOS_DEVICE names it by
|
||||||
|
# name or UDID, matched against booted simulators before merely available ones,
|
||||||
|
# the way sanderling matches --ios-device. A UDID rather than a name because the
|
||||||
|
# same iPhone exists under every installed runtime, and a seed only means
|
||||||
|
# something against one of them. Without IOS_DEVICE, a lone booted simulator is
|
||||||
|
# the one case taken without asking.
|
||||||
|
_require-ios-device:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
rows="$(xcrun simctl list devices available | awk '
|
||||||
|
/^-- / { runtime = $0; sub(/^-- /, "", runtime); sub(/ --$/, "", runtime); next }
|
||||||
|
runtime ~ /^iOS/ && match($0, /\([0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}\)/) {
|
||||||
|
name = substr($0, 1, RSTART - 1)
|
||||||
|
gsub(/^[ \t]+|[ \t]+$/, "", name)
|
||||||
|
state = substr($0, RSTART + RLENGTH)
|
||||||
|
gsub(/[()[:space:]]/, "", state)
|
||||||
|
printf "%s\t%s\t%s\t%s\n", substr($0, RSTART + 1, RLENGTH - 2), name, runtime, state
|
||||||
|
}')"
|
||||||
|
|
||||||
|
catalog() {
|
||||||
|
printf '%s\n' "$rows" | awk -F'\t' 'NF { printf " %s (%s) %s%s\n", $2, $3, $1, ($4 == "Booted" ? " booted" : "") }'
|
||||||
|
}
|
||||||
|
|
||||||
|
refuse() {
|
||||||
|
cat >&2 <<EOF
|
||||||
|
folio: $1
|
||||||
|
|
||||||
|
$(catalog)
|
||||||
|
|
||||||
|
Set IOS_DEVICE to the simulator name or UDID to use. It can live in
|
||||||
|
examples/folio/.env:
|
||||||
|
|
||||||
|
IOS_DEVICE="iPhone 17 Pro" just test-ios
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "$rows" ]]; then
|
||||||
|
echo "folio: no iOS simulator is available. Create one in Xcode, or see 'xcrun simctl list devices'." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "{{ios_device}}" ]]; then
|
||||||
|
matches="$(printf '%s\n' "$rows" | awk -F'\t' -v want="{{ios_device}}" '$4 == "Booted" && ($1 == want || $2 == want)')"
|
||||||
|
[[ -n "$matches" ]] || matches="$(printf '%s\n' "$rows" | awk -F'\t' -v want="{{ios_device}}" '$1 == want || $2 == want')"
|
||||||
|
[[ -n "$matches" ]] || refuse "IOS_DEVICE={{ios_device}} is not an available simulator."
|
||||||
|
else
|
||||||
|
matches="$(printf '%s\n' "$rows" | awk -F'\t' '$4 == "Booted"')"
|
||||||
|
[[ -n "$matches" ]] || matches="$rows"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$(printf '%s\n' "$matches" | grep -c .)" -eq 1 ]]; then
|
||||||
|
printf '%s\n' "$matches" | cut -f1
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
items="$(printf '%s\n' "$matches" | awk -F'\t' 'NF { printf "%s\t%s (%s) %s%s\n", $1, $2, $3, $1, ($4 == "Booted" ? " booted" : "") }')"
|
||||||
|
answer="$(FOLIO_PICK_ITEMS="$items" just _pick "folio: a run installs folio on the simulator, clears its state and drives it. Pick one:")"
|
||||||
|
case "$answer" in
|
||||||
|
"picked "*)
|
||||||
|
udid="${answer#picked }"
|
||||||
|
echo "folio: using $udid (IOS_DEVICE=$udid in examples/folio/.env skips this)" >&2
|
||||||
|
echo "$udid"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
cancelled)
|
||||||
|
echo "folio: cancelled." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
refuse "more than one simulator answers to that, and there is no terminal here to ask on."
|
||||||
|
|
||||||
# Regenerate iosApp.xcodeproj from project.yml.
|
# Regenerate iosApp.xcodeproj from project.yml.
|
||||||
ios-gen:
|
ios-gen:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd app/iosApp && xcodegen generate
|
cd app/iosApp && xcodegen generate
|
||||||
|
|
||||||
# Build + install + launch on a booted iOS simulator (boots IOS_DEVICE if none).
|
# Build + install + launch on the simulator given, or the one folio settles on.
|
||||||
ios:
|
ios udid="":
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export ANDROID_HOME="$(just _android-home)"
|
export ANDROID_HOME="$(just _android-home)"
|
||||||
if ! xcrun simctl list devices booted | grep -q Booted; then
|
# Every step addresses this UDID rather than "booted", so the build, the
|
||||||
xcrun simctl boot "{{ios_device}}"
|
# install and the launch cannot land on different simulators.
|
||||||
|
udid="{{udid}}"
|
||||||
|
[[ -n "$udid" ]] || udid="$(just _require-ios-device)"
|
||||||
|
if ! xcrun simctl list devices booted | grep -q "$udid"; then
|
||||||
|
xcrun simctl boot "$udid"
|
||||||
open -a Simulator
|
open -a Simulator
|
||||||
sleep 3
|
sleep 3
|
||||||
fi
|
fi
|
||||||
just ios-gen
|
just ios-gen
|
||||||
xcodebuild -project app/iosApp/iosApp.xcodeproj -scheme iosApp \
|
xcodebuild -project app/iosApp/iosApp.xcodeproj -scheme iosApp \
|
||||||
-destination 'platform=iOS Simulator,name={{ios_device}}' \
|
-destination "platform=iOS Simulator,id=$udid" \
|
||||||
-derivedDataPath app/iosApp/build \
|
-derivedDataPath app/iosApp/build \
|
||||||
build | tail -5
|
build | tail -5
|
||||||
# Installing over the top keeps the data container, and folio's signed-in
|
# Installing over the top keeps the data container, and folio's signed-in
|
||||||
# session with it, so a run started straight after would open on the last
|
# session with it, so a run started straight after would open on the last
|
||||||
# run's Home screen instead of Login and diverge at step 1.
|
# run's Home screen instead of Login and diverge at step 1.
|
||||||
xcrun simctl uninstall booted app.folio || true
|
xcrun simctl uninstall "$udid" app.folio || true
|
||||||
xcrun simctl install booted "{{ios_app}}"
|
xcrun simctl install "$udid" "{{ios_app}}"
|
||||||
xcrun simctl launch booted app.folio
|
xcrun simctl launch "$udid" app.folio
|
||||||
|
|
||||||
# Build the folio app for a connected physical iOS device (signed via .env creds).
|
# Build the folio app for a connected physical iOS device (signed via .env creds).
|
||||||
# Signing reads ASC_API_* and DEVELOPMENT_TEAM/SANDERLING_IOS_TEAM from .env;
|
# Signing reads ASC_API_* and DEVELOPMENT_TEAM/SANDERLING_IOS_TEAM from .env;
|
||||||
@@ -217,12 +373,13 @@ ios-device:
|
|||||||
|
|
||||||
# Run 'sanderling test' against the folio app. Uses a connected device if one is
|
# Run 'sanderling test' against the folio app. Uses a connected device if one is
|
||||||
# online; otherwise boots AVD=<name> when provided, or auto-boots a bootable AVD.
|
# online; otherwise boots AVD=<name> when provided, or auto-boots a bootable AVD.
|
||||||
# Depends on install so the run always fuzzes the current build, matching
|
# Installs before it runs so the run always fuzzes the current build, matching
|
||||||
# test-ios which rebuilds and reinstalls the app first.
|
# test-ios which rebuilds and reinstalls the app first.
|
||||||
test: install
|
test: _ensure-device
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
serial="$(just _require-device)"
|
serial="$(just _require-device)"
|
||||||
|
just install "$serial"
|
||||||
device_flag=(--device "$serial")
|
device_flag=(--device "$serial")
|
||||||
if [[ -n "{{avd}}" ]]; then
|
if [[ -n "{{avd}}" ]]; then
|
||||||
device_flag+=(--avd "{{avd}}")
|
device_flag+=(--avd "{{avd}}")
|
||||||
@@ -239,10 +396,11 @@ test: install
|
|||||||
# Run 'sanderling test' with the LLM action generator instead of the seeded
|
# Run 'sanderling test' with the LLM action generator instead of the seeded
|
||||||
# fuzzer. Needs OPENROUTER_API_KEY (or OPENAI_API_KEY) in the environment; the
|
# fuzzer. Needs OPENROUTER_API_KEY (or OPENAI_API_KEY) in the environment; the
|
||||||
# model is configured by generator = llm({...}) in spec.ts.
|
# model is configured by generator = llm({...}) in spec.ts.
|
||||||
test-llm: install
|
test-llm: _ensure-device
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
serial="$(just _require-device)"
|
serial="$(just _require-device)"
|
||||||
|
just install "$serial"
|
||||||
device_flag=(--device "$serial")
|
device_flag=(--device "$serial")
|
||||||
if [[ -n "{{avd}}" ]]; then
|
if [[ -n "{{avd}}" ]]; then
|
||||||
device_flag+=(--avd "{{avd}}")
|
device_flag+=(--avd "{{avd}}")
|
||||||
@@ -275,17 +433,14 @@ web-build:
|
|||||||
test-ios:
|
test-ios:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
just ios
|
udid="$(just _require-ios-device)"
|
||||||
ios_device_flag=()
|
just ios "$udid"
|
||||||
if [[ -n "{{ios_device}}" ]]; then
|
|
||||||
ios_device_flag=(--ios-device "{{ios_device}}")
|
|
||||||
fi
|
|
||||||
"{{sanderling}}" test \
|
"{{sanderling}}" test \
|
||||||
--platform ios \
|
--platform ios \
|
||||||
--spec "{{justfile_directory()}}/sanderling/spec.ts" \
|
--spec "{{justfile_directory()}}/sanderling/spec.ts" \
|
||||||
--bundle-id app.folio \
|
--bundle-id app.folio \
|
||||||
--ios-app-path "{{ios_app}}" \
|
--ios-app-path "{{ios_app}}" \
|
||||||
"${ios_device_flag[@]}" \
|
--ios-device "$udid" \
|
||||||
--duration "{{duration}}" \
|
--duration "{{duration}}" \
|
||||||
--seed "{{seed}}" \
|
--seed "{{seed}}" \
|
||||||
--output "{{output}}"
|
--output "{{output}}"
|
||||||
@@ -296,6 +451,10 @@ test-ios:
|
|||||||
test-ios-device:
|
test-ios-device:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
if [[ -z "{{ios_device}}" ]]; then
|
||||||
|
echo "folio: set IOS_DEVICE to the connected device's name, UDID or CoreDevice id (see 'xcrun devicectl list devices'). Left empty, sanderling resolves a booted simulator and fuzzes that instead." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
just ios-device
|
just ios-device
|
||||||
"{{sanderling}}" test \
|
"{{sanderling}}" test \
|
||||||
--platform ios \
|
--platform ios \
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"unicode/utf16"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
manifestEntry = "AndroidManifest.xml"
|
||||||
|
|
||||||
|
chunkStringPool = 0x0001
|
||||||
|
chunkStartElement = 0x0102
|
||||||
|
|
||||||
|
stringPoolUTF8 = 1 << 8
|
||||||
|
)
|
||||||
|
|
||||||
|
var errStringPoolRange = errors.New("string pool entry runs past the chunk")
|
||||||
|
|
||||||
|
// PackageName reads an APK's application id out of its compiled manifest,
|
||||||
|
// which carries the id the package manager will know the app by, suffixes and
|
||||||
|
// all. Parsing it here rather than shelling out to `aapt2 dump packagename`
|
||||||
|
// keeps the read working on the many hosts that install platform-tools for adb
|
||||||
|
// and never install the versioned build-tools directory aapt2 lives in.
|
||||||
|
func PackageName(apkPath string) (string, error) {
|
||||||
|
manifest, err := manifestFromAPK(apkPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
name, err := manifestPackage(manifest)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s: %w", apkPath, err)
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifestFromAPK(apkPath string) ([]byte, error) {
|
||||||
|
archive, err := zip.OpenReader(apkPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open %s: %w", apkPath, err)
|
||||||
|
}
|
||||||
|
defer archive.Close()
|
||||||
|
for _, file := range archive.File {
|
||||||
|
if file.Name != manifestEntry {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open %s in %s: %w", manifestEntry, apkPath, err)
|
||||||
|
}
|
||||||
|
defer entry.Close()
|
||||||
|
manifest, err := io.ReadAll(entry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s in %s: %w", manifestEntry, apkPath, err)
|
||||||
|
}
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%s holds no %s", apkPath, manifestEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// manifestPackage walks the compiled manifest's chunks for the <manifest>
|
||||||
|
// element and reports its package attribute. The string pool always precedes
|
||||||
|
// the elements that index into it.
|
||||||
|
func manifestPackage(manifest []byte) (string, error) {
|
||||||
|
if len(manifest) < 8 {
|
||||||
|
return "", errors.New("truncated binary xml")
|
||||||
|
}
|
||||||
|
var pool []string
|
||||||
|
for offset := uint32(8); offset+8 <= uint32(len(manifest)); {
|
||||||
|
size := binary.LittleEndian.Uint32(manifest[offset+4:])
|
||||||
|
if size < 8 || uint64(offset)+uint64(size) > uint64(len(manifest)) {
|
||||||
|
return "", errors.New("truncated chunk in binary xml")
|
||||||
|
}
|
||||||
|
chunk := manifest[offset : offset+size]
|
||||||
|
switch binary.LittleEndian.Uint16(chunk) {
|
||||||
|
case chunkStringPool:
|
||||||
|
var err error
|
||||||
|
if pool, err = poolStrings(chunk); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
case chunkStartElement:
|
||||||
|
name, found, err := packageAttribute(chunk, pool)
|
||||||
|
if err != nil || found {
|
||||||
|
return name, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset += size
|
||||||
|
}
|
||||||
|
return "", errors.New("no <manifest> element in binary xml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// packageAttribute reports the package attribute of a start-element chunk, and
|
||||||
|
// whether that chunk was the <manifest> element at all.
|
||||||
|
func packageAttribute(chunk []byte, pool []string) (string, bool, error) {
|
||||||
|
// The element's own fields start after the chunk header, line number and
|
||||||
|
// comment index, and the attribute offsets are relative to there.
|
||||||
|
const element = 16
|
||||||
|
if len(chunk) < element+20 {
|
||||||
|
return "", false, errors.New("truncated start-element chunk")
|
||||||
|
}
|
||||||
|
if poolString(pool, binary.LittleEndian.Uint32(chunk[element+4:])) != "manifest" {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
start := uint64(binary.LittleEndian.Uint16(chunk[element+8:]))
|
||||||
|
stride := uint64(binary.LittleEndian.Uint16(chunk[element+10:]))
|
||||||
|
count := uint64(binary.LittleEndian.Uint16(chunk[element+12:]))
|
||||||
|
if stride < 20 {
|
||||||
|
return "", false, fmt.Errorf("<manifest> attribute stride is %d bytes, want at least 20", stride)
|
||||||
|
}
|
||||||
|
for index := uint64(0); index < count; index++ {
|
||||||
|
at := element + start + index*stride
|
||||||
|
if at+20 > uint64(len(chunk)) {
|
||||||
|
return "", false, errors.New("<manifest> attribute runs past the chunk")
|
||||||
|
}
|
||||||
|
if poolString(pool, binary.LittleEndian.Uint32(chunk[at+4:])) != "package" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := poolString(pool, binary.LittleEndian.Uint32(chunk[at+8:]))
|
||||||
|
if name == "" {
|
||||||
|
// aapt2 leaves the raw value unset and keeps the string in the
|
||||||
|
// typed value's data word.
|
||||||
|
name = poolString(pool, binary.LittleEndian.Uint32(chunk[at+16:]))
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
return "", false, errors.New("<manifest> package attribute is empty")
|
||||||
|
}
|
||||||
|
return name, true, nil
|
||||||
|
}
|
||||||
|
return "", false, errors.New("<manifest> has no package attribute")
|
||||||
|
}
|
||||||
|
|
||||||
|
func poolString(pool []string, index uint32) string {
|
||||||
|
if uint64(index) >= uint64(len(pool)) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return pool[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
func poolStrings(chunk []byte) ([]string, error) {
|
||||||
|
const header = 28
|
||||||
|
if len(chunk) < header {
|
||||||
|
return nil, errors.New("truncated string pool")
|
||||||
|
}
|
||||||
|
count := uint64(binary.LittleEndian.Uint32(chunk[8:]))
|
||||||
|
flags := binary.LittleEndian.Uint32(chunk[16:])
|
||||||
|
start := uint64(binary.LittleEndian.Uint32(chunk[20:]))
|
||||||
|
if header+4*count > uint64(len(chunk)) {
|
||||||
|
return nil, errors.New("string pool offsets run past the chunk")
|
||||||
|
}
|
||||||
|
pool := make([]string, count)
|
||||||
|
for index := uint64(0); index < count; index++ {
|
||||||
|
at := start + uint64(binary.LittleEndian.Uint32(chunk[header+4*index:]))
|
||||||
|
value, err := poolString8Or16(chunk, at, flags&stringPoolUTF8 != 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pool[index] = value
|
||||||
|
}
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func poolString8Or16(chunk []byte, at uint64, utf8 bool) (string, error) {
|
||||||
|
if utf8 {
|
||||||
|
// The character count precedes the byte count; only the second governs
|
||||||
|
// how far the string reaches.
|
||||||
|
at, _, err := prefixLength8(chunk, at)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
at, length, err := prefixLength8(chunk, at)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if at+length > uint64(len(chunk)) {
|
||||||
|
return "", errStringPoolRange
|
||||||
|
}
|
||||||
|
return string(chunk[at : at+length]), nil
|
||||||
|
}
|
||||||
|
at, length, err := prefixLength16(chunk, at)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if at+2*length > uint64(len(chunk)) {
|
||||||
|
return "", errStringPoolRange
|
||||||
|
}
|
||||||
|
units := make([]uint16, length)
|
||||||
|
for index := range units {
|
||||||
|
units[index] = binary.LittleEndian.Uint16(chunk[at+2*uint64(index):])
|
||||||
|
}
|
||||||
|
return string(utf16.Decode(units)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixLength8 reads a UTF-8 pool string's length prefix: one byte, or two
|
||||||
|
// when the high bit marks the value as wider. It returns the offset past the
|
||||||
|
// prefix.
|
||||||
|
func prefixLength8(chunk []byte, at uint64) (uint64, uint64, error) {
|
||||||
|
if at >= uint64(len(chunk)) {
|
||||||
|
return 0, 0, errStringPoolRange
|
||||||
|
}
|
||||||
|
length := uint64(chunk[at])
|
||||||
|
if length&0x80 == 0 {
|
||||||
|
return at + 1, length, nil
|
||||||
|
}
|
||||||
|
if at+1 >= uint64(len(chunk)) {
|
||||||
|
return 0, 0, errStringPoolRange
|
||||||
|
}
|
||||||
|
return at + 2, (length&0x7f)<<8 | uint64(chunk[at+1]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixLength16 reads a UTF-16 pool string's length prefix, in units rather
|
||||||
|
// than bytes: one 16-bit word, or two when the high bit marks the value as
|
||||||
|
// wider.
|
||||||
|
func prefixLength16(chunk []byte, at uint64) (uint64, uint64, error) {
|
||||||
|
if at+2 > uint64(len(chunk)) {
|
||||||
|
return 0, 0, errStringPoolRange
|
||||||
|
}
|
||||||
|
length := uint64(binary.LittleEndian.Uint16(chunk[at:]))
|
||||||
|
if length&0x8000 == 0 {
|
||||||
|
return at + 2, length, nil
|
||||||
|
}
|
||||||
|
if at+4 > uint64(len(chunk)) {
|
||||||
|
return 0, 0, errStringPoolRange
|
||||||
|
}
|
||||||
|
return at + 4, (length&0x7fff)<<16 | uint64(binary.LittleEndian.Uint16(chunk[at+2:])), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// folioAPK is the folio debug APK stripped to the one entry the reader looks
|
||||||
|
// at, so the parse runs against a manifest aapt2 really produced.
|
||||||
|
const folioAPK = "testdata/folio.apk"
|
||||||
|
|
||||||
|
func TestPackageName_ReadsCompiledManifest(t *testing.T) {
|
||||||
|
name, err := PackageName(folioAPK)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if name != "app.folio" {
|
||||||
|
t.Fatalf("package name: got %q, want app.folio", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageName_UTF8StringPool(t *testing.T) {
|
||||||
|
name, err := PackageName(apkWithManifest(t, utf8PooledManifest("com.example.utf8")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if name != "com.example.utf8" {
|
||||||
|
t.Fatalf("package name: got %q, want com.example.utf8", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageName_NotAnAPK(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "app.apk")
|
||||||
|
if err := os.WriteFile(path, []byte("this is not a zip"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write file: %v", err)
|
||||||
|
}
|
||||||
|
_, err := PackageName(path)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), path) {
|
||||||
|
t.Fatalf("expected an error naming %s, got %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageName_NoManifestEntry(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "app.apk")
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create apk: %v", err)
|
||||||
|
}
|
||||||
|
archive := zip.NewWriter(file)
|
||||||
|
if _, err := archive.Create("classes.dex"); err != nil {
|
||||||
|
t.Fatalf("write entry: %v", err)
|
||||||
|
}
|
||||||
|
if err := archive.Close(); err != nil {
|
||||||
|
t.Fatalf("close archive: %v", err)
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
t.Fatalf("close apk: %v", err)
|
||||||
|
}
|
||||||
|
_, err = PackageName(path)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "AndroidManifest.xml") {
|
||||||
|
t.Fatalf("expected a missing-manifest error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageName_TruncatedManifest(t *testing.T) {
|
||||||
|
manifest, err := manifestFromAPK(folioAPK)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture manifest: %v", err)
|
||||||
|
}
|
||||||
|
for _, keep := range []int{4, 40, 400} {
|
||||||
|
if _, err := PackageName(apkWithManifest(t, manifest[:keep])); err == nil {
|
||||||
|
t.Fatalf("expected an error from a manifest cut to %d bytes", keep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func apkWithManifest(t *testing.T, manifest []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "app.apk")
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create apk: %v", err)
|
||||||
|
}
|
||||||
|
archive := zip.NewWriter(file)
|
||||||
|
entry, err := archive.Create(manifestEntry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create manifest entry: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := entry.Write(manifest); err != nil {
|
||||||
|
t.Fatalf("write manifest entry: %v", err)
|
||||||
|
}
|
||||||
|
if err := archive.Close(); err != nil {
|
||||||
|
t.Fatalf("close archive: %v", err)
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
t.Fatalf("close apk: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// utf8PooledManifest encodes the smallest manifest that carries a package
|
||||||
|
// name, with the UTF-8 string pool that aapt2 does not emit and older builders
|
||||||
|
// do. Without it the UTF-8 decoding path is never exercised: the compiled
|
||||||
|
// manifests aapt2 writes all pool their strings as UTF-16.
|
||||||
|
func utf8PooledManifest(packageName string) []byte {
|
||||||
|
pool := []string{"manifest", "package", packageName}
|
||||||
|
var pooled bytes.Buffer
|
||||||
|
offsets := make([]uint32, len(pool))
|
||||||
|
for index, value := range pool {
|
||||||
|
offsets[index] = uint32(pooled.Len())
|
||||||
|
pooled.WriteByte(byte(len(value)))
|
||||||
|
pooled.WriteByte(byte(len(value)))
|
||||||
|
pooled.WriteString(value)
|
||||||
|
pooled.WriteByte(0)
|
||||||
|
}
|
||||||
|
stringsStart := uint32(28 + 4*len(pool))
|
||||||
|
|
||||||
|
var poolChunk bytes.Buffer
|
||||||
|
write16(&poolChunk, chunkStringPool)
|
||||||
|
write16(&poolChunk, 28)
|
||||||
|
write32(&poolChunk, stringsStart+uint32(pooled.Len()))
|
||||||
|
write32(&poolChunk, uint32(len(pool)))
|
||||||
|
write32(&poolChunk, 0)
|
||||||
|
write32(&poolChunk, stringPoolUTF8)
|
||||||
|
write32(&poolChunk, stringsStart)
|
||||||
|
write32(&poolChunk, 0)
|
||||||
|
for _, offset := range offsets {
|
||||||
|
write32(&poolChunk, offset)
|
||||||
|
}
|
||||||
|
poolChunk.Write(pooled.Bytes())
|
||||||
|
|
||||||
|
var element bytes.Buffer
|
||||||
|
write16(&element, chunkStartElement)
|
||||||
|
write16(&element, 16)
|
||||||
|
write32(&element, 36+20)
|
||||||
|
write32(&element, 1) // line number
|
||||||
|
write32(&element, 0xFFFFFFFF) // comment
|
||||||
|
write32(&element, 0xFFFFFFFF) // namespace
|
||||||
|
write32(&element, 0) // name: "manifest"
|
||||||
|
write16(&element, 20) // attributes start, past this header
|
||||||
|
write16(&element, 20) // attribute stride
|
||||||
|
write16(&element, 1) // attribute count
|
||||||
|
write16(&element, 0) // id index
|
||||||
|
write16(&element, 0) // class index
|
||||||
|
write16(&element, 0) // style index
|
||||||
|
write32(&element, 0xFFFFFFFF) // attribute namespace
|
||||||
|
write32(&element, 1) // attribute name: "package"
|
||||||
|
write32(&element, 2) // attribute raw value: the package name
|
||||||
|
write16(&element, 8) // typed value size
|
||||||
|
write16(&element, 3<<8) // res0, and TYPE_STRING
|
||||||
|
write32(&element, 2)
|
||||||
|
|
||||||
|
var manifest bytes.Buffer
|
||||||
|
write16(&manifest, 3)
|
||||||
|
write16(&manifest, 8)
|
||||||
|
write32(&manifest, uint32(8+poolChunk.Len()+element.Len()))
|
||||||
|
manifest.Write(poolChunk.Bytes())
|
||||||
|
manifest.Write(element.Bytes())
|
||||||
|
return manifest.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func write16(buffer *bytes.Buffer, value uint16) {
|
||||||
|
_ = binary.Write(buffer, binary.LittleEndian, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func write32(buffer *bytes.Buffer, value uint32) {
|
||||||
|
_ = binary.Write(buffer, binary.LittleEndian, value)
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
@@ -667,6 +667,27 @@ func (t *Tree) Transitional() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScreenName names the route this tree shows: the driver-set screen when the
|
||||||
|
// platform reports one (web), otherwise the resource id ending in "Screen" that
|
||||||
|
// marks the route composable. A transitional tree names no screen.
|
||||||
|
func (t *Tree) ScreenName() string {
|
||||||
|
if t == nil || len(t.Elements) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if screen := t.Elements[0].Screen; screen != "" {
|
||||||
|
return screen
|
||||||
|
}
|
||||||
|
if t.Transitional() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, element := range t.Elements {
|
||||||
|
if strings.HasSuffix(element.ResourceID, "Screen") {
|
||||||
|
return element.ResourceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Find returns the first element matching the selector, or nil.
|
// Find returns the first element matching the selector, or nil.
|
||||||
func (t *Tree) Find(selector string) *Element {
|
func (t *Tree) Find(selector string) *Element {
|
||||||
node := t.FindNode(selector)
|
node := t.FindNode(selector)
|
||||||
|
|||||||
@@ -1821,3 +1821,48 @@ func TestTagMatchesTheElementItNames(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScreenNameNamesTheRouteTheTreeShows(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
tree string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"driver-set screen",
|
||||||
|
`{"attributes": {"bounds": "[0,0,10,10]", "sanderling-screen": "/ledger"}, "children": []}`,
|
||||||
|
"/ledger",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route marker",
|
||||||
|
`{"attributes": {"bounds": "[0,0,10,10]"}, "children": [
|
||||||
|
{"attributes": {"resource-id": "HomeScreen", "bounds": "[0,0,10,10]"}, "children": []}
|
||||||
|
]}`,
|
||||||
|
"HomeScreen",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cross-fade names no screen",
|
||||||
|
`{"attributes": {"bounds": "[0,0,10,10]"}, "children": [
|
||||||
|
{"attributes": {"resource-id": "HomeScreen", "bounds": "[0,0,10,10]"}, "children": []},
|
||||||
|
{"attributes": {"resource-id": "LedgerScreen", "bounds": "[0,0,10,10]"}, "children": []}
|
||||||
|
]}`,
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"no marker at all",
|
||||||
|
`{"attributes": {"bounds": "[0,0,10,10]"}, "children": []}`,
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, testCase := range cases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
tree, err := Parse(testCase.tree)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
if got := tree.ScreenName(); got != testCase.want {
|
||||||
|
t.Errorf("ScreenName = %q, want %q", got, testCase.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/verifier"
|
||||||
|
)
|
||||||
|
|
||||||
|
func logStepLine(t *testing.T, action verifier.Action, treeJSON string) string {
|
||||||
|
t.Helper()
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewTextHandler(&buffer, nil))
|
||||||
|
logStep(logger, 7, "LoginScreen", 42, action, nil, "", mustParseTree(t, treeJSON))
|
||||||
|
return buffer.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogStepNamesTheActionItsTargetAndTheTypedValue(t *testing.T) {
|
||||||
|
line := logStepLine(t, typeInto("id:LoginEmail"), iosLoginTreeJSON)
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"index=7", "screen=LoginScreen", "nodes=42",
|
||||||
|
"action=InputText", "target=id:LoginEmail", typedCredential,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(line, want) {
|
||||||
|
t.Errorf("step log = %q, want it to carry %q", line, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogStepRedactsTheTypedValueOfASecureField(t *testing.T) {
|
||||||
|
line := logStepLine(t, typeInto("id:LoginPassword"), iosLoginTreeJSON)
|
||||||
|
|
||||||
|
if strings.Contains(line, typedCredential) {
|
||||||
|
t.Errorf("step log = %q, want the typed credential withheld", line)
|
||||||
|
}
|
||||||
|
if !strings.Contains(line, verifier.RedactedInputText) {
|
||||||
|
t.Errorf("step log = %q, want %q", line, verifier.RedactedInputText)
|
||||||
|
}
|
||||||
|
if !strings.Contains(line, "target=id:LoginPassword") {
|
||||||
|
t.Errorf("step log = %q, want it to still name the field typed into", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogStepReportsAStepThatActedOnNothing(t *testing.T) {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewTextHandler(&buffer, nil))
|
||||||
|
logStep(logger, 3, "", 0, verifier.Action{}, verifier.ErrNoAction, actionSkippedNoActionProduced, nil)
|
||||||
|
|
||||||
|
line := buffer.String()
|
||||||
|
if !strings.Contains(line, "action=none") {
|
||||||
|
t.Errorf("step log = %q, want action=none", line)
|
||||||
|
}
|
||||||
|
if !strings.Contains(line, "skipped="+string(actionSkippedNoActionProduced)) {
|
||||||
|
t.Errorf("step log = %q, want the skip reason", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,9 +40,9 @@ const webLoginTreeJSON = `{
|
|||||||
]
|
]
|
||||||
}`
|
}`
|
||||||
|
|
||||||
// androidLoginTreeJSON is the same form on Android, where the native tree
|
// androidLoginTreeJSON is the same form with the fact missing from both fields,
|
||||||
// mapper drops uiautomator's password attribute and neither field carries the
|
// which is what an Android tree looks like when the sidecar could not match its
|
||||||
// fact.
|
// text fields against the device's own view hierarchy.
|
||||||
const androidLoginTreeJSON = `{
|
const androidLoginTreeJSON = `{
|
||||||
"attributes": {"bounds": "[0,0,1080,2340]"},
|
"attributes": {"bounds": "[0,0,1080,2340]"},
|
||||||
"children": [
|
"children": [
|
||||||
@@ -65,8 +65,8 @@ func TestTraceActionForRedactsTypedValuesTheTargetCannotClear(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"ios secure field", iosLoginTreeJSON, "id:LoginPassword"},
|
{"ios secure field", iosLoginTreeJSON, "id:LoginPassword"},
|
||||||
{"web secure field", webLoginTreeJSON, "id:login-password"},
|
{"web secure field", webLoginTreeJSON, "id:login-password"},
|
||||||
{"android field reported as neither", androidLoginTreeJSON, "id:login_email"},
|
{"field reported as neither", androidLoginTreeJSON, "id:login_email"},
|
||||||
{"android password field", androidLoginTreeJSON, "id:login_password"},
|
{"password field reported as neither", androidLoginTreeJSON, "id:login_password"},
|
||||||
} {
|
} {
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
traceAction := traceActionFor(typeInto(testCase.selector), mustParseTree(t, testCase.treeJSON))
|
traceAction := traceActionFor(typeInto(testCase.selector), mustParseTree(t, testCase.treeJSON))
|
||||||
|
|||||||
@@ -236,10 +236,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
}
|
}
|
||||||
lastLogTime = stepStart
|
lastLogTime = stepStart
|
||||||
|
|
||||||
screen := ""
|
screen := tree.ScreenName()
|
||||||
if tree != nil && len(tree.Elements) > 0 {
|
|
||||||
screen = tree.Elements[0].Screen
|
|
||||||
}
|
|
||||||
|
|
||||||
// A transitional tree is one nothing can vouch for: a NavHost mid
|
// A transitional tree is one nothing can vouch for: a NavHost mid
|
||||||
// cross-fade, a screen that changed shape between two reads, or a
|
// cross-fade, a screen that changed shape between two reads, or a
|
||||||
@@ -332,8 +329,6 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
logger.Warn("unsettled tree; skipping verifier",
|
logger.Warn("unsettled tree; skipping verifier",
|
||||||
"step", stepIndex, "screen", screen, "nodes", treeSize)
|
"step", stepIndex, "screen", screen, "nodes", treeSize)
|
||||||
}
|
}
|
||||||
logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize)
|
|
||||||
|
|
||||||
// A frame the verifier would not look at is not one to act on either.
|
// A frame the verifier would not look at is not one to act on either.
|
||||||
// #75 is the fuzzer tapping into a screen that is still filling in, and
|
// #75 is the fuzzer tapping into a screen that is still filling in, and
|
||||||
// holding the action back is also what keeps the spec's view of the run
|
// holding the action back is also what keeps the spec's view of the run
|
||||||
@@ -474,6 +469,8 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
// the action it points at is still the one the next verified step has to
|
// the action it points at is still the one the next verified step has to
|
||||||
// be told about.
|
// be told about.
|
||||||
|
|
||||||
|
logStep(logger, stepIndex, screen, treeSize, nextAction, nextErr, actionSkipped, tree)
|
||||||
|
|
||||||
step := trace.Step{
|
step := trace.Step{
|
||||||
Index: stepIndex,
|
Index: stepIndex,
|
||||||
Timestamp: stepStart,
|
Timestamp: stepStart,
|
||||||
@@ -1578,6 +1575,35 @@ func driverIsAndroid(ctx context.Context, options Options, logger *slog.Logger)
|
|||||||
return health.Platform == "android"
|
return health.Platform == "android"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// logStep prints the one line a run emits per step: what screen it saw and what
|
||||||
|
// it did there. The typed value goes through the same redaction the trace and
|
||||||
|
// the prompt use, so the console cannot publish a credential the records
|
||||||
|
// withhold.
|
||||||
|
func logStep(
|
||||||
|
logger *slog.Logger,
|
||||||
|
stepIndex int,
|
||||||
|
screen string,
|
||||||
|
treeSize int,
|
||||||
|
action verifier.Action,
|
||||||
|
actionErr error,
|
||||||
|
skipped actionSkipReason,
|
||||||
|
tree *hierarchy.Tree,
|
||||||
|
) {
|
||||||
|
attrs := []any{"index", stepIndex, "screen", screen, "nodes", treeSize}
|
||||||
|
if actionErr != nil {
|
||||||
|
attrs = append(attrs, "action", "none")
|
||||||
|
} else {
|
||||||
|
attrs = append(attrs, "action", string(action.Kind), "target", actionTarget(action))
|
||||||
|
if action.Kind == verifier.ActionKindInputText {
|
||||||
|
attrs = append(attrs, "text", verifier.RecordedActionText(action, tree))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skipped != "" {
|
||||||
|
attrs = append(attrs, "skipped", string(skipped))
|
||||||
|
}
|
||||||
|
logger.Info("step", attrs...)
|
||||||
|
}
|
||||||
|
|
||||||
func traceActionFor(action verifier.Action, tree *hierarchy.Tree) *trace.Action {
|
func traceActionFor(action verifier.Action, tree *hierarchy.Tree) *trace.Action {
|
||||||
traceAction := &trace.Action{
|
traceAction := &trace.Action{
|
||||||
Kind: string(action.Kind),
|
Kind: string(action.Kind),
|
||||||
|
|||||||
Vendored
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
{"extractor_changes":{"extractor_0":{"prev":null,"curr":0}},"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"step":1,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
|
{"extractor_changes":{"extractor_0":{"prev":null,"curr":0}},"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"screen":"HomeScreen","step":1,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
|
||||||
{"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"step":2,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
|
{"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"screen":"HomeScreen","step":2,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
|
||||||
{"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"step":3,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
|
{"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"screen":"HomeScreen","step":3,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
|
||||||
{"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"step":4,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
|
{"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"screen":"HomeScreen","step":4,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
|
||||||
@@ -154,13 +154,7 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
sidecarArgs := []string{"-jar", jarPath,
|
sidecarArgs := sidecarArguments(ctx, jarPath, sidecarPort, options.Platform, options.Device)
|
||||||
"--port", strconv.Itoa(sidecarPort),
|
|
||||||
"--platform", options.Platform,
|
|
||||||
}
|
|
||||||
if options.Device != "" {
|
|
||||||
sidecarArgs = append(sidecarArgs, "--serial", options.Device)
|
|
||||||
}
|
|
||||||
adbPath, err := android.AdbBinary()
|
adbPath, err := android.AdbBinary()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, preflightFailure("android", err)
|
return nil, nil, preflightFailure("android", err)
|
||||||
@@ -212,6 +206,25 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
|||||||
return driverClient, cleanup, nil
|
return driverClient, cleanup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// From JDK 24 the JVM prints a four-line sun.misc.Unsafe deprecation warning
|
||||||
|
// on every sidecar start, because the netty that grpc pulls in still reads
|
||||||
|
// field offsets through it. The flag that silences it did not exist before JDK
|
||||||
|
// 23 and an older JVM refuses to start when handed it, so ask this JVM whether
|
||||||
|
// it takes the flag rather than reading its version string.
|
||||||
|
const unsafeMemoryAccessAllow = "--sun-misc-unsafe-memory-access=allow"
|
||||||
|
|
||||||
|
func sidecarArguments(ctx context.Context, jarPath string, port int, platform, serial string) []string {
|
||||||
|
var args []string
|
||||||
|
if exec.CommandContext(ctx, "java", unsafeMemoryAccessAllow, "-version").Run() == nil {
|
||||||
|
args = append(args, unsafeMemoryAccessAllow)
|
||||||
|
}
|
||||||
|
args = append(args, "-jar", jarPath, "--port", strconv.Itoa(port), "--platform", platform)
|
||||||
|
if serial != "" {
|
||||||
|
args = append(args, "--serial", serial)
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
// watchSidecar reaps the sidecar and publishes its exit status. The channel is
|
// watchSidecar reaps the sidecar and publishes its exit status. The channel is
|
||||||
// closed after the send so the shutdown path can still receive once the startup
|
// closed after the send so the shutdown path can still receive once the startup
|
||||||
// path has taken the status.
|
// path has taken the status.
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -305,3 +308,41 @@ func TestResolveIOSTargetDeviceResolutionErrorSurfaces(t *testing.T) {
|
|||||||
t.Fatal("expected the device-resolution error to surface")
|
t.Fatal("expected the device-resolution error to surface")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fakeJava(t *testing.T, script string) {
|
||||||
|
t.Helper()
|
||||||
|
directory := t.TempDir()
|
||||||
|
path := filepath.Join(directory, "java")
|
||||||
|
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script+"\n"), 0o755); err != nil {
|
||||||
|
t.Fatalf("write fake java: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSidecarArgumentsSilencesTheUnsafeWarningOnAJvmThatTakesTheFlag(t *testing.T) {
|
||||||
|
fakeJava(t, "exit 0")
|
||||||
|
|
||||||
|
args := sidecarArguments(context.Background(), "/tmp/sidecar.jar", 51129, "android", "663c91b1")
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
"--sun-misc-unsafe-memory-access=allow",
|
||||||
|
"-jar", "/tmp/sidecar.jar",
|
||||||
|
"--port", "51129",
|
||||||
|
"--platform", "android",
|
||||||
|
"--serial", "663c91b1",
|
||||||
|
}
|
||||||
|
if !slices.Equal(args, want) {
|
||||||
|
t.Fatalf("sidecar argv = %q, want %q", args, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSidecarArgumentsOmitsTheFlagAJvmBefore23WouldRefuseToStartWith(t *testing.T) {
|
||||||
|
fakeJava(t, `case "$1" in --sun-misc-unsafe-memory-access=*) echo "Unrecognized option: $1" >&2; exit 1;; esac; exit 0`)
|
||||||
|
|
||||||
|
args := sidecarArguments(context.Background(), "/tmp/sidecar.jar", 51129, "android", "")
|
||||||
|
|
||||||
|
want := []string{"-jar", "/tmp/sidecar.jar", "--port", "51129", "--platform", "android"}
|
||||||
|
if !slices.Equal(args, want) {
|
||||||
|
t.Fatalf("sidecar argv = %q, want %q", args, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,11 +76,11 @@ func RecordedAction(action Action, tree *hierarchy.Tree) Action {
|
|||||||
// have produced.
|
// have produced.
|
||||||
//
|
//
|
||||||
// A target the platform reports as a secure entry is redacted, and so is a
|
// A target the platform reports as a secure entry is redacted, and so is a
|
||||||
// target carrying no report at all. iOS and web state the fact on every
|
// target carrying no report at all. All three platforms state the fact on their
|
||||||
// editable element, so a missing one means Android, whose native tree mapper
|
// editable elements, so a missing one is a field none of them could speak for:
|
||||||
// drops uiautomator's password attribute before the sidecar sees it. There a
|
// an action that named no target, or an Android text field the sidecar could
|
||||||
// password field cannot be told from a search box, and the target that cannot
|
// not match against the device's own view hierarchy. The target that cannot be
|
||||||
// be told apart is treated as the credential.
|
// told apart is treated as the credential.
|
||||||
func recordedInputText(text string, target secureFact) string {
|
func recordedInputText(text string, target secureFact) string {
|
||||||
if target.reported && !target.secure {
|
if target.reported && !target.secure {
|
||||||
return text
|
return text
|
||||||
|
|||||||
@@ -948,6 +948,105 @@ internal fun treeWithoutKeyboard(
|
|||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withSecureFacts states, on every text field in the tree, whether the platform
|
||||||
|
// calls it a secure entry. maestro's host-side mapper copies a fixed attribute
|
||||||
|
// list off the device's XML and `password` is not on it, so the tree alone
|
||||||
|
// cannot tell a password field from a search box and everything typed anywhere
|
||||||
|
// gets recorded as a credential. The XML the same read produced does carry the
|
||||||
|
// fact, so it is fetched (lazily: a screen with no text field never pays for
|
||||||
|
// it) and matched back onto the fields by identity, class and bounds.
|
||||||
|
//
|
||||||
|
// A field the XML cannot be matched to is left unstated rather than guessed.
|
||||||
|
// Unstated reads as "may be a credential" downstream, which is the safe way to
|
||||||
|
// be wrong.
|
||||||
|
internal fun withSecureFacts(
|
||||||
|
treeJson: String,
|
||||||
|
viewHierarchyXml: () -> String?,
|
||||||
|
): String {
|
||||||
|
val root = try {
|
||||||
|
jsonMapper.readTree(treeJson)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return treeJson
|
||||||
|
}
|
||||||
|
val fields = mutableListOf<com.fasterxml.jackson.databind.node.ObjectNode>()
|
||||||
|
collectTextFields(root, fields)
|
||||||
|
if (fields.isEmpty()) return treeJson
|
||||||
|
val facts = secureFactsFromXml(viewHierarchyXml() ?: return treeJson)
|
||||||
|
var stated = false
|
||||||
|
for (field in fields) {
|
||||||
|
val key = secureFactKey(
|
||||||
|
nodeAttribute(field, "resource-id"),
|
||||||
|
nodeAttribute(field, "class"),
|
||||||
|
nodeAttribute(field, "bounds"),
|
||||||
|
)
|
||||||
|
field.put("secure", facts[key] ?: continue)
|
||||||
|
stated = true
|
||||||
|
}
|
||||||
|
return if (stated) jsonMapper.writeValueAsString(root) else treeJson
|
||||||
|
}
|
||||||
|
|
||||||
|
// secureFactsFromXml reads the password attribute uiautomator states on every
|
||||||
|
// node. A key two nodes share answers for neither, so it is dropped: matching
|
||||||
|
// the wrong node is how a credential ends up recorded in the clear.
|
||||||
|
internal fun secureFactsFromXml(xml: String): Map<String, Boolean> {
|
||||||
|
val document = try {
|
||||||
|
javax.xml.parsers.DocumentBuilderFactory.newInstance()
|
||||||
|
.newDocumentBuilder()
|
||||||
|
.parse(java.io.ByteArrayInputStream(xml.toByteArray()))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
val facts = mutableMapOf<String, Boolean>()
|
||||||
|
val shared = mutableSetOf<String>()
|
||||||
|
val nodes = document.getElementsByTagName("node")
|
||||||
|
for (index in 0 until nodes.length) {
|
||||||
|
val element = nodes.item(index) as? org.w3c.dom.Element ?: continue
|
||||||
|
val key = secureFactKey(
|
||||||
|
element.getAttribute("resource-id"),
|
||||||
|
element.getAttribute("class"),
|
||||||
|
element.getAttribute("bounds"),
|
||||||
|
)
|
||||||
|
val password = element.getAttribute("password") == "true"
|
||||||
|
if (facts.put(key, password) != null) shared.add(key)
|
||||||
|
}
|
||||||
|
shared.forEach(facts::remove)
|
||||||
|
return facts
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun secureFactKey(id: String, className: String, bounds: String) =
|
||||||
|
"$id@$className@$bounds"
|
||||||
|
|
||||||
|
// A text field is what the Go side calls editable off the same two attributes
|
||||||
|
// (internal/hierarchy): stating the fact on a narrower set would leave fields
|
||||||
|
// the rest of the system treats as typeable answering for nothing.
|
||||||
|
private fun collectTextFields(
|
||||||
|
node: com.fasterxml.jackson.databind.JsonNode,
|
||||||
|
into: MutableList<com.fasterxml.jackson.databind.node.ObjectNode>,
|
||||||
|
) {
|
||||||
|
if (node is com.fasterxml.jackson.databind.node.ObjectNode &&
|
||||||
|
(
|
||||||
|
nodeAttribute(node, "class").contains("EditText") ||
|
||||||
|
nodeAttribute(node, "hintText").isNotEmpty()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
into.add(node)
|
||||||
|
}
|
||||||
|
val children = node.get("children")
|
||||||
|
if (children != null && children.isArray) {
|
||||||
|
for (child in children) collectTextFields(child, into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nodeAttribute(
|
||||||
|
node: com.fasterxml.jackson.databind.JsonNode,
|
||||||
|
key: String,
|
||||||
|
): String {
|
||||||
|
val attributes = node.get("attributes") ?: return ""
|
||||||
|
if (!attributes.isObject) return ""
|
||||||
|
val value = attributes.get(key) ?: return ""
|
||||||
|
return if (value.isNull) "" else value.asText()
|
||||||
|
}
|
||||||
|
|
||||||
// SELECT_ALL_COMMAND selects the focused field's whole content with
|
// SELECT_ALL_COMMAND selects the focused field's whole content with
|
||||||
// CTRL+A (keycodes 113 and 29) and DELETE_KEY_COMMAND then deletes the
|
// CTRL+A (keycodes 113 and 29) and DELETE_KEY_COMMAND then deletes the
|
||||||
// selection (keycode 67). Two key events, whatever the field holds.
|
// selection (keycode 67). Two key events, whatever the field holds.
|
||||||
@@ -1162,7 +1261,11 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
|
|||||||
// can tell the runner the gesture reached nothing.
|
// can tell the runner the gesture reached nothing.
|
||||||
private fun requireOnScreen(x: Int, y: Int) {
|
private fun requireOnScreen(x: Int, y: Int) {
|
||||||
val cached = extent
|
val cached = extent
|
||||||
if (cached != null && !offScreen(x, y, cached.first, cached.second)) return
|
if (cached != null &&
|
||||||
|
!offScreen(x, y, cached.first, cached.second)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
val info = driver.deviceInfo()
|
val info = driver.deviceInfo()
|
||||||
val fresh = Pair(info.widthPixels, info.heightPixels)
|
val fresh = Pair(info.widthPixels, info.heightPixels)
|
||||||
extent = fresh
|
extent = fresh
|
||||||
@@ -1302,13 +1405,40 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
|
|||||||
// not: it fetched the hierarchy ~4 more times on every mutating step. The
|
// not: it fetched the hierarchy ~4 more times on every mutating step. The
|
||||||
// keyboard leg costs nothing either when no IME is standing in the tree,
|
// keyboard leg costs nothing either when no IME is standing in the tree,
|
||||||
// which is what lets the Hierarchy RPC serve this too.
|
// which is what lets the Hierarchy RPC serve this too.
|
||||||
override fun snapshotTree(): String = treeWithoutKeyboard(
|
override fun snapshotTree(): String = withSecureFacts(
|
||||||
awaitSettledTree { hierarchy() },
|
treeWithoutKeyboard(
|
||||||
imePackage,
|
awaitSettledTree { hierarchy() },
|
||||||
dismiss = { runCatching { dadb.shell("input keyevent 4") } },
|
imePackage,
|
||||||
reread = { awaitSettledTree { hierarchy() } },
|
dismiss = { runCatching { dadb.shell("input keyevent 4") } },
|
||||||
|
reread = { awaitSettledTree { hierarchy() } },
|
||||||
|
),
|
||||||
|
::deviceViewHierarchyXml,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// deviceViewHierarchyXml re-reads the device tree in the form maestro parsed
|
||||||
|
// it from, which is the only form still carrying the password attribute
|
||||||
|
// maestro's own mapper drops. The call is private to maestro, so a version
|
||||||
|
// that renames it leaves every typed value redacted rather than exposed;
|
||||||
|
// the warning is what says that happened. The 1 is the attempt count,
|
||||||
|
// maestro's own default for the call.
|
||||||
|
private fun deviceViewHierarchyXml(): String? = runCatching {
|
||||||
|
val attempts = Int::class.javaPrimitiveType
|
||||||
|
val call = maestro.drivers.AndroidDriver::class.java
|
||||||
|
.getDeclaredMethod("callViewHierarchy", attempts)
|
||||||
|
call.isAccessible = true
|
||||||
|
val response = call.invoke(driver, 1)
|
||||||
|
response.javaClass.getMethod("getHierarchy").invoke(response) as String
|
||||||
|
}.onFailure {
|
||||||
|
if (viewHierarchyXmlWarned.compareAndSet(false, true)) {
|
||||||
|
System.err.println(
|
||||||
|
"warn: view hierarchy unreadable; typed values redacted: $it",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private val viewHierarchyXmlWarned =
|
||||||
|
java.util.concurrent.atomic.AtomicBoolean(false)
|
||||||
|
|
||||||
override fun waitForIdle(durationMillis: Long) {
|
override fun waitForIdle(durationMillis: Long) {
|
||||||
// waitForAppToSettle blocks on the View-system animation and maestro's
|
// waitForAppToSettle blocks on the View-system animation and maestro's
|
||||||
// own structural settle. It cannot see a Compose cross-fade: the fade
|
// own structural settle. It cannot see a Compose cross-fade: the fade
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package dev.sanderling.sidecar
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
// The tree maestro hands back for a login form: it names both fields and says
|
||||||
|
// nothing about either being a credential entry, because maestro's mapper does
|
||||||
|
// not copy the password attribute off the device's XML.
|
||||||
|
private const val LOGIN_TREE = """
|
||||||
|
{"attributes":{"bounds":"[0,0,1080,2340]"},"children":[
|
||||||
|
{"attributes":{"resource-id":"LoginEmail","class":"android.widget.EditText","bounds":"[51,130][429,202]"},"children":[]},
|
||||||
|
{"attributes":{"resource-id":"LoginPassword","class":"android.widget.EditText","bounds":"[51,249][429,321]"},"children":[]},
|
||||||
|
{"attributes":{"resource-id":"LoginSubmit","class":"android.widget.Button","bounds":"[51,400][429,470]"},"children":[]}
|
||||||
|
]}
|
||||||
|
"""
|
||||||
|
|
||||||
|
// The same screen as the device reports it, where the fact still exists.
|
||||||
|
private const val LOGIN_XML = """<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" resource-id="" class="android.widget.FrameLayout" password="false" bounds="[0,0,1080,2340]">
|
||||||
|
<node index="0" resource-id="LoginEmail" class="android.widget.EditText" password="false" bounds="[51,130][429,202]" />
|
||||||
|
<node index="1" resource-id="LoginPassword" class="android.widget.EditText" password="true" bounds="[51,249][429,321]" />
|
||||||
|
<node index="2" resource-id="LoginSubmit" class="android.widget.Button" password="false" bounds="[51,400][429,470]" />
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
|
"""
|
||||||
|
|
||||||
|
private fun secureOf(tree: String, resourceId: String): Boolean? {
|
||||||
|
val field = jacksonTree(tree, "resource-id", resourceId) ?: return null
|
||||||
|
val secure = field.get("secure") ?: return null
|
||||||
|
return secure.asBoolean()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jacksonTree(
|
||||||
|
tree: String,
|
||||||
|
attribute: String,
|
||||||
|
value: String,
|
||||||
|
): com.fasterxml.jackson.databind.JsonNode? {
|
||||||
|
val mapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
|
||||||
|
fun walk(
|
||||||
|
node: com.fasterxml.jackson.databind.JsonNode,
|
||||||
|
): com.fasterxml.jackson.databind.JsonNode? {
|
||||||
|
if (node.get("attributes")?.get(attribute)?.asText() == value) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
node.get("children")?.forEach { child ->
|
||||||
|
walk(child)?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return walk(mapper.readTree(tree))
|
||||||
|
}
|
||||||
|
|
||||||
|
class SecureFactsTest {
|
||||||
|
|
||||||
|
// Without this, a password field and a search box look identical in the
|
||||||
|
// tree, and everything downstream that records a typed value has to treat
|
||||||
|
// every field as a credential: the whole run reads "[redacted]".
|
||||||
|
@Test fun aPasswordFieldIsToldApartFromTheFieldBesideIt() {
|
||||||
|
val annotated = withSecureFacts(LOGIN_TREE) { LOGIN_XML }
|
||||||
|
|
||||||
|
assertEquals(true, secureOf(annotated, "LoginPassword"))
|
||||||
|
assertEquals(false, secureOf(annotated, "LoginEmail"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only what a value can be typed into needs the fact, and stating it on a
|
||||||
|
// button would be stating it about something the platform never reported.
|
||||||
|
@Test fun aNonEditableNodeIsLeftAlone() {
|
||||||
|
assertNull(
|
||||||
|
secureOf(
|
||||||
|
withSecureFacts(LOGIN_TREE) {
|
||||||
|
LOGIN_XML
|
||||||
|
},
|
||||||
|
"LoginSubmit",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Go side calls a node editable off its class or its hint, and a field
|
||||||
|
// it will happily type into has to be a field this can speak for.
|
||||||
|
@Test fun aFieldNamedByItsHintAloneIsStatedToo() {
|
||||||
|
val hinted = """
|
||||||
|
{"attributes":{"bounds":"[0,0,1080,2340]"},"children":[
|
||||||
|
{"attributes":{"resource-id":"Search","class":"android.view.View","hintText":"Search","bounds":"[10,10,200,50]"},"children":[]}
|
||||||
|
]}
|
||||||
|
"""
|
||||||
|
val xml = """<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" resource-id="Search" class="android.view.View" password="false" bounds="[10,10,200,50]" />
|
||||||
|
</hierarchy>
|
||||||
|
"""
|
||||||
|
|
||||||
|
assertEquals(false, secureOf(withSecureFacts(hinted) { xml }, "Search"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unstated means "may be a credential" downstream. Every way this can fail
|
||||||
|
// has to land there rather than on a false "not secure".
|
||||||
|
@Test fun aFactThatCannotBeReadIsLeftUnstated() {
|
||||||
|
for (xml in listOf<String?>(null, "", "<hierarchy", "<hierarchy/>")) {
|
||||||
|
val annotated = withSecureFacts(LOGIN_TREE) { xml }
|
||||||
|
assertNull(
|
||||||
|
secureOf(annotated, "LoginPassword"),
|
||||||
|
"xml ${xml ?: "null"}",
|
||||||
|
)
|
||||||
|
assertNull(
|
||||||
|
secureOf(annotated, "LoginEmail"),
|
||||||
|
"xml ${xml ?: "null"}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wrapper drawn to the same bounds as the untagged field inside it shares
|
||||||
|
// the field's empty id and its bounds. The class is what still tells them
|
||||||
|
// apart; without it the two collide, the field answers for nothing, and
|
||||||
|
// every value typed into an untagged field is redacted.
|
||||||
|
@Test fun anUntaggedFieldInsideAWrapperOfItsOwnSizeIsStillStated() {
|
||||||
|
val wrapped = """
|
||||||
|
{"attributes":{"bounds":"[0,0,1080,2340]"},"children":[
|
||||||
|
{"attributes":{"class":"android.view.View","bounds":"[51,249][429,321]"},"children":[
|
||||||
|
{"attributes":{"class":"android.widget.EditText","bounds":"[51,249][429,321]"},"children":[]}
|
||||||
|
]}
|
||||||
|
]}
|
||||||
|
"""
|
||||||
|
val xml = """<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" resource-id="" class="android.view.View" password="false" bounds="[51,249][429,321]">
|
||||||
|
<node index="0" resource-id="" class="android.widget.EditText" password="true" bounds="[51,249][429,321]" />
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
|
"""
|
||||||
|
|
||||||
|
val field = jacksonTree(
|
||||||
|
withSecureFacts(wrapped) { xml },
|
||||||
|
"class",
|
||||||
|
"android.widget.EditText",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(true, field?.get("secure")?.asBoolean())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two nodes sharing a key answer for neither: taking the first would state
|
||||||
|
// "not secure" about a field that may be the other one.
|
||||||
|
@Test fun anAmbiguousMatchIsLeftUnstated() {
|
||||||
|
val duplicated = """<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" resource-id="LoginPassword" class="android.widget.EditText" password="false" bounds="[51,249][429,321]" />
|
||||||
|
<node index="1" resource-id="LoginPassword" class="android.widget.EditText" password="true" bounds="[51,249][429,321]" />
|
||||||
|
</hierarchy>
|
||||||
|
"""
|
||||||
|
assertNull(
|
||||||
|
secureOf(
|
||||||
|
withSecureFacts(LOGIN_TREE) {
|
||||||
|
duplicated
|
||||||
|
},
|
||||||
|
"LoginPassword",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A screen with nothing to type into must not pay a device read for an
|
||||||
|
// answer no field is waiting on.
|
||||||
|
@Test fun aScreenWithNoTextFieldNeverReadsTheDevice() {
|
||||||
|
val listTree =
|
||||||
|
"""{"attributes":{"resource-id":"HomeScreen"},"children":[]}"""
|
||||||
|
var reads = 0
|
||||||
|
|
||||||
|
val annotated = withSecureFacts(listTree) {
|
||||||
|
reads++
|
||||||
|
LOGIN_XML
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(0, reads)
|
||||||
|
assertEquals(listTree, annotated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fact is read through a call maestro keeps private, so a maestro
|
||||||
|
// upgrade that renames it would leave every typed value redacted again with
|
||||||
|
// nothing failing. This fails the build instead.
|
||||||
|
@Test fun maestroStillExposesTheCallTheDeviceXmlComesFrom() {
|
||||||
|
val call = maestro.drivers.AndroidDriver::class.java
|
||||||
|
.getDeclaredMethod(
|
||||||
|
"callViewHierarchy",
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
String::class.java,
|
||||||
|
call.returnType.getMethod("getHierarchy").returnType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rest of the tree has to survive the annotation: it is the same tree
|
||||||
|
// every selector, bounds read and screen classification runs against.
|
||||||
|
@Test fun theTreeIsOtherwiseUnchanged() {
|
||||||
|
val mapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
|
||||||
|
val annotated = mapper.readTree(
|
||||||
|
withSecureFacts(LOGIN_TREE) {
|
||||||
|
LOGIN_XML
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val stated = annotated.findParents("secure")
|
||||||
|
assertEquals(2, stated.size)
|
||||||
|
for (node in stated) {
|
||||||
|
(node as com.fasterxml.jackson.databind.node.ObjectNode)
|
||||||
|
.remove("secure")
|
||||||
|
}
|
||||||
|
assertEquals(mapper.readTree(LOGIN_TREE), annotated)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,10 @@ attached devices with `--device <serial>` as `adb devices` prints it:
|
|||||||
sanderling test --spec spec.ts --bundle-id com.example.app --avd Pixel_7_API_34
|
sanderling test --spec spec.ts --bundle-id com.example.app --avd Pixel_7_API_34
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--android-app-path <apk>` stands in for `--bundle-id`: the applicationId is
|
||||||
|
read out of the APK's compiled manifest, so the id and the file it came from
|
||||||
|
cannot disagree.
|
||||||
|
|
||||||
iOS takes `--platform ios` and `--ios-device`, which accepts a simulator name or
|
iOS takes `--platform ios` and `--ios-device`, which accepts a simulator name or
|
||||||
UDID, or a connected device's name, UDID, or CoreDevice id. `--ios-app-path`
|
UDID, or a connected device's name, UDID, or CoreDevice id. `--ios-app-path`
|
||||||
points at the `.app` bundle and is what makes clear-state real; see section 4.
|
points at the `.app` bundle and is what makes clear-state real; see section 4.
|
||||||
|
|||||||
Reference in new issue
Block a user