mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
Remove in-app SDK (#43)
* chore: delete internal/agent package
* chore(build): remove sdk-android from gradle settings
* chore(makefile): remove sdk-android targets
* chore(ci): remove release-android job from release workflow
* chore(folio): remove sdk-android dependency
* chore(folio): remove SDK initialization from FolioApplication
* chore(folio): delete snapshot extractor files
* feat(folio): add balance to account card content description
* feat(folio): add hierarchy content descriptions to LedgerScreen
* refactor(folio): rewrite spec.ts to use ax extractors
* docs: remove in-app SDK from README
* feat(folio): add focused_input indicator to App
* docs: remove in-app SDK from index
* refactor(runner): remove agent SDK connection and snapshot step
* test(runner): update tests for SDK removal
* docs: remove Android SDK section from getting-started
* refactor(testrun): remove agent SDK connection setup
* docs: remove snapshots from writing-specs
* docs: remove in-app SDK from architecture doc
* docs(folio): update README for SDK removal
* docs: update per-step cycle diagram in architecture doc
* fix(folio): detect screens from unique element presence, not id: selectors
testTag() in Compose is not exposed as resource-id without testTagsAsResourceId.
Use desc: selectors for elements unique to each screen instead of id: path queries.
* feat(folio): add screen root contentDescription for scoped ax selection
Each screen root gets semantics { contentDescription = "ScreenName" } so
sanderling specs can scope element lookups through the screen: desc:LoginScreen > desc:login_submit.
* fix(folio): scope all ax selectors through screen root nodes
Use desc:ScreenName > desc:element path queries so every selector is
rooted at the screen level. focusedInput stays unscoped since it lives
in the app root, outside any screen.
* fix(folio): guard newAccountBalanceIsZero against navigation false positives
Scoped selectors return [] when not on HomeScreen so accounts vanish and
reappear as apparently-new on each visit. Skip the check when prev was empty.
* chore(folio): link @sanderling/spec to local pkg/spec for IDE type checking
* feat(spec): add desc, class, clickable, enabled, checked, focused, selected to AccessibilityElement
Runtime fields set by the verifier were missing from the TypeScript type,
causing linting errors on el.desc and related accesses in specs.
* chore(folio): switch to bun, add tsconfig.json for IDE type checking
- Remove package-lock.json, add bun.lock
- Add tsconfig.json so VSCode resolves @sanderling/spec types
- Fix parseAccount/parseLedgerRow to accept string | undefined
This commit is contained in:
60 files changed
+242
-3298
No files matched your search
@@ -16,47 +16,6 @@ concurrency:
|
|||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-android:
|
|
||||||
name: Publish sdk-android to Maven Central
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
|
|
||||||
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
|
|
||||||
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }}
|
|
||||||
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ inputs.tag || github.ref }}
|
|
||||||
|
|
||||||
- name: Resolve version
|
|
||||||
id: ver
|
|
||||||
run: |
|
|
||||||
raw="${{ inputs.tag || github.ref_name }}"
|
|
||||||
echo "version=${raw#v}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Set up JDK 17
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "17"
|
|
||||||
|
|
||||||
- name: Set up Android SDK
|
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
|
|
||||||
- name: Cache Gradle
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches
|
|
||||||
~/.gradle/wrapper
|
|
||||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
|
||||||
restore-keys: |
|
|
||||||
gradle-${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Publish to Maven Central
|
|
||||||
run: ./gradlew :sdk-android:publishAndReleaseToMavenCentral -Psanderling.version=${{ steps.ver.outputs.version }}
|
|
||||||
|
|
||||||
release-npm:
|
release-npm:
|
||||||
name: Publish @sanderling/spec to npm
|
name: Publish @sanderling/spec to npm
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ BUF := buf
|
|||||||
GO_PACKAGES := ./...
|
GO_PACKAGES := ./...
|
||||||
SIDECAR_JAR := sidecar/build/libs/sidecar-all.jar
|
SIDECAR_JAR := sidecar/build/libs/sidecar-all.jar
|
||||||
SIDECAR_EMBED := internal/sidecar/assets/sidecar-all.jar
|
SIDECAR_EMBED := internal/sidecar/assets/sidecar-all.jar
|
||||||
SDK_AAR := sdk/android/build/outputs/aar/sdk-android-release.aar
|
|
||||||
SANDERLING_BIN := bin/sanderling
|
SANDERLING_BIN := bin/sanderling
|
||||||
|
|
||||||
DOCS_SRC := $(shell find docs -type f -name '*.md' -not -path 'docs/_*')
|
DOCS_SRC := $(shell find docs -type f -name '*.md' -not -path 'docs/_*')
|
||||||
@@ -22,7 +21,7 @@ DOCS_TEMPLATE := docs/_template/page.html
|
|||||||
INSPECT_DIST := internal/inspect/dist
|
INSPECT_DIST := internal/inspect/dist
|
||||||
WEB_DIST := inspect-ui/dist
|
WEB_DIST := inspect-ui/dist
|
||||||
|
|
||||||
.PHONY: bootstrap proto sidecar sdk-android sdk-android-publish sanderling install test test-go test-kotlin test-spec-api web-typecheck web-build web-dev inspect-dev docs clean release-cli release-android-local release-npm-dry
|
.PHONY: bootstrap proto sidecar sanderling install test test-go test-kotlin test-spec-api web-typecheck web-build web-dev inspect-dev docs clean release-cli release-npm-dry
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
$(GO) mod download
|
$(GO) mod download
|
||||||
@@ -35,12 +34,6 @@ proto:
|
|||||||
|
|
||||||
sidecar: $(SIDECAR_JAR)
|
sidecar: $(SIDECAR_JAR)
|
||||||
|
|
||||||
sdk-android:
|
|
||||||
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sdk-android:assembleRelease
|
|
||||||
|
|
||||||
sdk-android-publish:
|
|
||||||
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sdk-android:publishToMavenLocal
|
|
||||||
|
|
||||||
sanderling: $(SANDERLING_BIN)
|
sanderling: $(SANDERLING_BIN)
|
||||||
|
|
||||||
$(SANDERLING_BIN): $(SIDECAR_EMBED) web-build
|
$(SANDERLING_BIN): $(SIDECAR_EMBED) web-build
|
||||||
@@ -75,13 +68,13 @@ $(SIDECAR_EMBED): $(SIDECAR_JAR)
|
|||||||
mkdir -p $(dir $@)
|
mkdir -p $(dir $@)
|
||||||
cp $< $@
|
cp $< $@
|
||||||
|
|
||||||
test: test-go test-kotlin test-spec-api web-typecheck
|
test: test-go test-spec-api web-typecheck
|
||||||
|
|
||||||
test-go:
|
test-go:
|
||||||
$(GO) test $(GO_PACKAGES)
|
$(GO) test $(GO_PACKAGES)
|
||||||
|
|
||||||
test-kotlin:
|
test-kotlin:
|
||||||
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:test :sdk-android:testDebugUnitTest
|
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:test
|
||||||
|
|
||||||
test-spec-api:
|
test-spec-api:
|
||||||
cd pkg/spec && npm test --silent
|
cd pkg/spec && npm test --silent
|
||||||
@@ -118,9 +111,5 @@ clean:
|
|||||||
release-cli: $(SIDECAR_JAR)
|
release-cli: $(SIDECAR_JAR)
|
||||||
goreleaser release --snapshot --clean
|
goreleaser release --snapshot --clean
|
||||||
|
|
||||||
release-android-local:
|
|
||||||
@if [ -f .env.local ]; then set -a; . ./.env.local; set +a; fi; \
|
|
||||||
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sdk-android:publishToMavenLocal -Psanderling.version=0.0.0-local
|
|
||||||
|
|
||||||
release-npm-dry:
|
release-npm-dry:
|
||||||
cd pkg/spec && npm ci && npm run build && npm pack --dry-run
|
cd pkg/spec && npm ci && npm run build && npm pack --dry-run
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# sanderling
|
# sanderling
|
||||||
|
|
||||||
Autonomous property-based testing for mobile apps. Specs in TypeScript. Core in Go. Drives the app under test through Maestro and an in-app SDK.
|
Autonomous property-based testing for mobile apps. Specs in TypeScript. Core in Go. Drives the app under test through Maestro.
|
||||||
|
|
||||||
> Alpha. Android emulator only. Full scope in the [v0.1.0 roadmap](https://github.com/priyanshujain/sanderling/issues/4).
|
> Alpha. Android emulator only. Full scope in the [v0.1.0 roadmap](https://github.com/priyanshujain/sanderling/issues/4).
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,12 @@ flowchart TB
|
|||||||
end
|
end
|
||||||
|
|
||||||
SC["Maestro sidecar (JVM)"]
|
SC["Maestro sidecar (JVM)"]
|
||||||
SDK["in-app SDK\n(Device / Emulator)"]
|
|
||||||
CH["Chrome (CDP)"]
|
CH["Chrome (CDP)"]
|
||||||
RD[("runs/")]
|
RD[("runs/")]
|
||||||
IN["sanderling inspect\nHTTP + SSE"]
|
IN["sanderling inspect\nHTTP + SSE"]
|
||||||
UI["Web UI (React)"]
|
UI["Web UI (React)"]
|
||||||
|
|
||||||
D -->|gRPC| SC
|
D -->|gRPC| SC
|
||||||
SC -->|UIAutomator / XCTest| SDK
|
|
||||||
R -->|"Unix socket<br/>(pause / state / logs)"| SDK
|
|
||||||
D -->|CDP| CH
|
D -->|CDP| CH
|
||||||
|
|
||||||
T --> RD --> IN --> UI
|
T --> RD --> IN --> UI
|
||||||
@@ -35,16 +32,13 @@ flowchart TB
|
|||||||
|
|
||||||
**Maestro sidecar (JVM).** A Kotlin process that wraps `maestro-client` and exposes a gRPC surface matching the `DeviceDriver` interface. Handles UI input, screenshots, the system accessibility tree, and OS-level alerts. Native platforms only.
|
**Maestro sidecar (JVM).** A Kotlin process that wraps `maestro-client` and exposes a gRPC surface matching the `DeviceDriver` interface. Handles UI input, screenshots, the system accessibility tree, and OS-level alerts. Native platforms only.
|
||||||
|
|
||||||
**In-app SDK.** A Kotlin (or Swift for iOS) library linked into the app under test. Exposes a Unix socket to the runner. Provides pause and resume, view-hierarchy dumps, coverage reads, log capture, and user-registered state extractors. Native platforms only.
|
**Chrome (CDP).** For web targets, the Go binary drives Chrome directly over the Chrome DevTools Protocol. No sidecar is involved.
|
||||||
|
|
||||||
**Chrome (CDP).** For web targets, the Go binary drives Chrome directly over the Chrome DevTools Protocol. No sidecar or in-app SDK is involved.
|
|
||||||
|
|
||||||
## Transports
|
## Transports
|
||||||
|
|
||||||
| Channel | Platform | Transport | Purpose |
|
| Channel | Platform | Transport | Purpose |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Go to Maestro sidecar | Native | gRPC (localhost TCP) | UI input, screenshots, system alerts |
|
| Go to Maestro sidecar | Native | gRPC (localhost TCP) | UI input, screenshots, system alerts |
|
||||||
| Go to in-app SDK | Native | Unix domain socket | Pause / resume, hierarchy, coverage, logs, extractors |
|
|
||||||
| Go to Chrome | Web | Chrome DevTools Protocol | UI input, screenshots, DOM hierarchy, console logs |
|
| Go to Chrome | Web | Chrome DevTools Protocol | UI input, screenshots, DOM hierarchy, console logs |
|
||||||
|
|
||||||
On native, the transport split exists because only real UI events need to cross process and OS-API boundaries. Introspection is cheap, frequent, and lives on a fast local socket directly to the app. On web, CDP handles both.
|
On native, the transport split exists because only real UI events need to cross process and OS-API boundaries. Introspection is cheap, frequent, and lives on a fast local socket directly to the app. On web, CDP handles both.
|
||||||
@@ -58,23 +52,21 @@ On native, the transport split exists because only real UI events need to cross
|
|||||||
The heart of the system is:
|
The heart of the system is:
|
||||||
|
|
||||||
```
|
```
|
||||||
pause ─► capture state ─► evaluate properties ─► pick action ─► resume ─► dispatch
|
fetch state ─► evaluate properties ─► pick action ─► dispatch
|
||||||
```
|
```
|
||||||
|
|
||||||
**Native (Android / iOS):**
|
**Native (Android / iOS):**
|
||||||
|
|
||||||
1. The runner asks the driver to wait until the UI is idle.
|
1. The runner asks the driver to wait until the UI is idle.
|
||||||
2. The runner sends `PAUSE` to the SDK over the Unix socket. The SDK freezes the main runloop at a safe point.
|
2. The runner fetches the UI hierarchy and logs from the sidecar.
|
||||||
3. The SDK sends back a `STATE` message: view hierarchy, coverage delta, logs since last step, exception list, snapshot values.
|
3. The runner feeds state into goja. Extractors re-read; properties re-evaluate; the action generator returns a weighted tree.
|
||||||
4. The runner feeds state into goja. Extractors re-read; properties re-evaluate; the action generator returns a weighted tree.
|
4. The runner writes the trace entry for this step.
|
||||||
5. The runner writes the trace entry for this step.
|
5. The runner picks an action by weight and dispatches it through the driver (gRPC to sidecar -> Maestro -> UIAutomator or XCTest).
|
||||||
6. The runner picks an action by weight.
|
6. Loop.
|
||||||
7. The runner sends `RESUME` to the SDK, then dispatches the action through the driver (gRPC to sidecar → Maestro → UIAutomator or XCTest).
|
|
||||||
8. Loop.
|
|
||||||
|
|
||||||
**Web (Chrome):**
|
**Web (Chrome):**
|
||||||
|
|
||||||
Steps 2-3 use CDP to capture the DOM hierarchy and console logs directly; there is no SDK pause/resume. The rest of the cycle is identical.
|
CDP captures the DOM hierarchy and console logs directly. The rest of the cycle is identical.
|
||||||
|
|
||||||
The cycle runs hundreds of times per minute. Every step produces one row in `trace.jsonl` and one screenshot.
|
The cycle runs hundreds of times per minute. Every step produces one row in `trace.jsonl` and one screenshot.
|
||||||
|
|
||||||
+1
-1
@@ -4,7 +4,7 @@ title: Sanderling Manual
|
|||||||
|
|
||||||
# Sanderling Manual
|
# Sanderling Manual
|
||||||
|
|
||||||
Autonomous property-based testing for mobile/web apps. Specs in TypeScript. Core in Go. Drives the app under test through UIAutomation/XCTest and an in-app SDK on Android/iOS and CDP on web.
|
Autonomous property-based testing for mobile/web apps. Specs in TypeScript. Core in Go. Drives the app under test through UIAutomation/XCTest on Android/iOS and CDP on web.
|
||||||
|
|
||||||
Alpha: Scope of v0.1.0 is tracked in [issue #4](https://github.com/priyanshujain/sanderling/issues/4).
|
Alpha: Scope of v0.1.0 is tracked in [issue #4](https://github.com/priyanshujain/sanderling/issues/4).
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,18 @@ title: Getting started
|
|||||||
|
|
||||||
# Getting started
|
# Getting started
|
||||||
|
|
||||||
Install the CLI, link the SDK into your debug build, run a spec.
|
Install the CLI, run a spec.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
**Android / iOS:**
|
**Android / iOS:**
|
||||||
|
|
||||||
- An Android emulator with API level 30 or newer (or a connected device).
|
- An Android emulator with API level 30 or newer (or a connected device).
|
||||||
- The app under test built as a debug variant with the sanderling Android SDK linked in.
|
|
||||||
- `adb` on your PATH.
|
- `adb` on your PATH.
|
||||||
|
|
||||||
**Web:**
|
**Web:**
|
||||||
|
|
||||||
- Chrome installed. sanderling drives it via CDP; no other setup required.
|
- Chrome installed. sanderling drives it via CDP; no other setup required.
|
||||||
- No in-app SDK needed.
|
|
||||||
|
|
||||||
Run `sanderling doctor` to check the host environment.
|
Run `sanderling doctor` to check the host environment.
|
||||||
|
|
||||||
@@ -35,14 +33,6 @@ curl -fsSL https://raw.githubusercontent.com/priyanshujain/sanderling/master/ins
|
|||||||
npm install --save-dev @sanderling/spec
|
npm install --save-dev @sanderling/spec
|
||||||
```
|
```
|
||||||
|
|
||||||
### Android SDK ([Maven Central](https://central.sonatype.com/artifact/io.github.priyanshujain.sanderling/sdk-android))
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
dependencies {
|
|
||||||
implementation("io.github.priyanshujain.sanderling:sdk-android:<version>")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Your first run
|
## Your first run
|
||||||
|
|
||||||
### Android
|
### Android
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { extract, always, now, actions, weighted, Tap, taps, swipes } from "@san
|
|||||||
|
|
||||||
// 1. Extractors pull values from each observed state.
|
// 1. Extractors pull values from each observed state.
|
||||||
const loggedIn = extract((s) => !!s.ax.find("id:home-tab-bar"));
|
const loggedIn = extract((s) => !!s.ax.find("id:home-tab-bar"));
|
||||||
const cartCount = extract<number>((s) => (s.snapshots.cart_count as number) ?? 0);
|
|
||||||
|
|
||||||
// 2. Properties are LTL formulas evaluated every step.
|
// 2. Properties are LTL formulas evaluated every step.
|
||||||
export const properties = {
|
export const properties = {
|
||||||
@@ -34,7 +33,6 @@ What extractors see:
|
|||||||
```ts
|
```ts
|
||||||
interface State {
|
interface State {
|
||||||
ax: AccessibilityTree; // view hierarchy
|
ax: AccessibilityTree; // view hierarchy
|
||||||
snapshots: Record<string, unknown>; // values registered by the in-app SDK
|
|
||||||
screen: { id: string; hash: string };
|
screen: { id: string; hash: string };
|
||||||
lastAction: Action | null;
|
lastAction: Action | null;
|
||||||
logs: LogEntry[]; // since previous state
|
logs: LogEntry[]; // since previous state
|
||||||
@@ -45,8 +43,6 @@ interface State {
|
|||||||
|
|
||||||
`ax.find("text:Click me")`, `ax.find("id:login-form")`, `ax.findAll("role:todo-row")` are the common accessors. Prefer stable testID-style identifiers over positional selectors, for the same reason you would in Espresso or XCUITest.
|
`ax.find("text:Click me")`, `ax.find("id:login-form")`, `ax.findAll("role:todo-row")` are the common accessors. Prefer stable testID-style identifiers over positional selectors, for the same reason you would in Espresso or XCUITest.
|
||||||
|
|
||||||
`snapshots` is populated by the in-app SDK via `Sanderling.extract("name") { value }`. Use it when the UI does not expose a value you need, such as business-logic state or hidden fields.
|
|
||||||
|
|
||||||
## Pattern: preconditions (login, onboarding)
|
## Pattern: preconditions (login, onboarding)
|
||||||
|
|
||||||
sanderling has no setup phase and no fixtures. Preconditions are action generators with two properties:
|
sanderling has no setup phase and no fixtures. Preconditions are action generators with two properties:
|
||||||
@@ -129,29 +125,6 @@ loginSucceedsWithin30s: eventually(() => loggedIn.current).within(30, "seconds")
|
|||||||
|
|
||||||
`within` takes `"milliseconds"`, `"seconds"`, or `"steps"`. Useful for liveness checks: the loading spinner eventually goes away, the deep link eventually lands on `/home`.
|
`within` takes `"milliseconds"`, `"seconds"`, or `"steps"`. Useful for liveness checks: the loading spinner eventually goes away, the deep link eventually lands on `/home`.
|
||||||
|
|
||||||
## Pattern: snapshot-backed properties
|
|
||||||
|
|
||||||
When the UI does not expose a value but the app knows it, use the SDK's extractor registry:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// in the app (Android)
|
|
||||||
Sanderling.extract("cart_count") { store.cart.size }
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// in the spec
|
|
||||||
const cartCount = extract<number>((s) => (s.snapshots.cart_count as number) ?? 0);
|
|
||||||
|
|
||||||
export const properties = {
|
|
||||||
cartMonotonicAfterAdd: always(() => {
|
|
||||||
const previous = cartCount.previous;
|
|
||||||
return previous === undefined || cartCount.current >= previous;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
This pattern lets you write properties against business logic that no UI element exposes.
|
|
||||||
|
|
||||||
## Pattern: weighted exploration sub-trees
|
## Pattern: weighted exploration sub-trees
|
||||||
|
|
||||||
Nest `weighted` to group related actions and tune their collective rate:
|
Nest `weighted` to group related actions and tune their collective rate:
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ sanderling runs its property-based specs against.
|
|||||||
- Kotlin Multiplatform + Compose Multiplatform (shared UI)
|
- Kotlin Multiplatform + Compose Multiplatform (shared UI)
|
||||||
- kotlinx.serialization for file-backed persistence
|
- kotlinx.serialization for file-backed persistence
|
||||||
- kotlinx.coroutines for state flows
|
- kotlinx.coroutines for state flows
|
||||||
- sanderling `sdk-android` for harness integration on Android
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -70,9 +69,9 @@ Traces land in `./sanderling/runs/<timestamp>/`.
|
|||||||
|
|
||||||
## How it connects to sanderling
|
## How it connects to sanderling
|
||||||
|
|
||||||
- `composeApp/src/androidMain/.../FolioApplication.kt` calls `Sanderling.start(this)`
|
- UI elements expose state via content descriptions (`account:$id:$balance`,
|
||||||
and registers snapshot extractors (`logged_in`, `account_count`,
|
`ledger_row:$id:$signed`, `ledger_balance:$cents`, `active_account:$id`,
|
||||||
`total_balance`, `route`)
|
`focused_input:$field`)
|
||||||
- `sanderling/spec.ts` imports `@sanderling/spec`, reads those snapshots, asserts
|
- `sanderling/spec.ts` imports `@sanderling/spec`, reads state via `s.ax.*`,
|
||||||
properties, and weights the actions the fuzzer picks from
|
asserts properties, and weights the actions the fuzzer picks from
|
||||||
- `just test` invokes `sanderling test` against the installed APK
|
- `just test` invokes `sanderling test` against the installed APK
|
||||||
@@ -83,7 +83,6 @@ kotlin {
|
|||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation("androidx.activity:activity-compose:1.13.0")
|
implementation("androidx.activity:activity-compose:1.13.0")
|
||||||
implementation("app.cash.sqldelight:android-driver:$sqldelightVersion")
|
implementation("app.cash.sqldelight:android-driver:$sqldelightVersion")
|
||||||
implementation("io.github.priyanshujain.sanderling:sdk-android:$sanderlingVersion")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
iosMain.dependencies {
|
iosMain.dependencies {
|
||||||
|
|||||||
@@ -3,21 +3,11 @@ package app.folio
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import app.folio.data.AndroidLedgerContext
|
import app.folio.data.AndroidLedgerContext
|
||||||
import app.folio.data.Repository
|
import app.folio.data.Repository
|
||||||
import app.folio.sanderling.AccountSnapshots
|
|
||||||
import app.folio.sanderling.AuthSnapshots
|
|
||||||
import app.folio.sanderling.LedgerSnapshots
|
|
||||||
import app.folio.sanderling.NavigationSnapshots
|
|
||||||
import dev.sanderling.sdk.Sanderling
|
|
||||||
|
|
||||||
class FolioApplication : Application() {
|
class FolioApplication : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
AndroidLedgerContext.context = applicationContext
|
AndroidLedgerContext.context = applicationContext
|
||||||
Repository.init()
|
Repository.init()
|
||||||
Sanderling.start(this)
|
|
||||||
AuthSnapshots
|
|
||||||
AccountSnapshots
|
|
||||||
LedgerSnapshots
|
|
||||||
NavigationSnapshots
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
-19
@@ -1,19 +0,0 @@
|
|||||||
package app.folio.sanderling
|
|
||||||
|
|
||||||
import app.folio.data.Repository
|
|
||||||
import app.folio.feature.account.AddAccountUiState
|
|
||||||
import app.folio.platform.balanceOf
|
|
||||||
import dev.sanderling.sdk.Sanderling
|
|
||||||
|
|
||||||
object AccountSnapshots {
|
|
||||||
val accountCount by Sanderling.snapshot { Repository.accounts.value.size }
|
|
||||||
val totalBalance by Sanderling.snapshot { balanceOf(Repository.transactions.value) }
|
|
||||||
val accounts by Sanderling.snapshot {
|
|
||||||
val txns = Repository.transactions.value
|
|
||||||
Repository.accounts.value.map { a ->
|
|
||||||
val rows = txns.filter { it.accountId == a.id }
|
|
||||||
mapOf("id" to a.id, "name" to a.name, "balance" to balanceOf(rows), "txnCount" to rows.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val addAccountError by Sanderling.snapshot { AddAccountUiState.addAccountError.value }
|
|
||||||
}
|
|
||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
package app.folio.sanderling
|
|
||||||
|
|
||||||
import app.folio.data.Repository
|
|
||||||
import app.folio.feature.auth.LoginUiState
|
|
||||||
import dev.sanderling.sdk.Sanderling
|
|
||||||
|
|
||||||
object AuthSnapshots {
|
|
||||||
val loggedIn by Sanderling.snapshot { Repository.session.value != null }
|
|
||||||
val authStatus by Sanderling.snapshot { if (Repository.session.value != null) "logged-in" else "logged-out" }
|
|
||||||
val loginError by Sanderling.snapshot { LoginUiState.loginError.value }
|
|
||||||
}
|
|
||||||
-42
@@ -1,42 +0,0 @@
|
|||||||
package app.folio.sanderling
|
|
||||||
|
|
||||||
import app.folio.FocusTracker
|
|
||||||
import app.folio.data.Repository
|
|
||||||
import app.folio.data.TxnType
|
|
||||||
import app.folio.feature.ledger.AddTransactionUiState
|
|
||||||
import app.folio.navigation.Navigator
|
|
||||||
import app.folio.navigation.Route
|
|
||||||
import app.folio.platform.balanceOf
|
|
||||||
import app.folio.platform.signedAmount
|
|
||||||
import dev.sanderling.sdk.Sanderling
|
|
||||||
|
|
||||||
object LedgerSnapshots {
|
|
||||||
private val activeId
|
|
||||||
get() = when (val r = Navigator.current.value) {
|
|
||||||
is Route.Ledger -> r.accountId
|
|
||||||
is Route.AddTransaction -> r.accountId
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
val activeAccountId by Sanderling.snapshot { activeId }
|
|
||||||
val ledgerRows by Sanderling.snapshot {
|
|
||||||
val active = activeId ?: return@snapshot emptyList<Any>()
|
|
||||||
Repository.transactions.value.filter { it.accountId == active }.map {
|
|
||||||
mapOf(
|
|
||||||
"id" to it.id,
|
|
||||||
"accountId" to it.accountId,
|
|
||||||
"type" to if (it.type == TxnType.credit) "credit" else "debit",
|
|
||||||
"amount" to it.amount,
|
|
||||||
"signed" to signedAmount(it),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val ledgerBalance by Sanderling.snapshot {
|
|
||||||
val active = activeId ?: return@snapshot 0L
|
|
||||||
balanceOf(Repository.transactions.value.filter { it.accountId == active })
|
|
||||||
}
|
|
||||||
val focusedInput by Sanderling.snapshot { FocusTracker.current.value }
|
|
||||||
val txnFormType by Sanderling.snapshot { AddTransactionUiState.txnFormType.value }
|
|
||||||
val txnFormAccountId by Sanderling.snapshot { (Navigator.current.value as? Route.AddTransaction)?.accountId }
|
|
||||||
val txnError by Sanderling.snapshot { AddTransactionUiState.txnError.value }
|
|
||||||
}
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
package app.folio.sanderling
|
|
||||||
|
|
||||||
import app.folio.navigation.Navigator
|
|
||||||
import app.folio.navigation.Route
|
|
||||||
import dev.sanderling.sdk.Sanderling
|
|
||||||
|
|
||||||
object NavigationSnapshots {
|
|
||||||
val screen by Sanderling.snapshot {
|
|
||||||
when (Navigator.current.value) {
|
|
||||||
Route.Login -> "login"
|
|
||||||
Route.Home -> "home"
|
|
||||||
Route.AddAccount -> "add-account"
|
|
||||||
is Route.Ledger -> "ledger"
|
|
||||||
is Route.AddTransaction -> "add-transaction"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -13,6 +14,9 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import app.folio.data.Repository
|
import app.folio.data.Repository
|
||||||
import app.folio.feature.account.AddAccountScreen
|
import app.folio.feature.account.AddAccountScreen
|
||||||
import app.folio.feature.auth.LoginScreen
|
import app.folio.feature.auth.LoginScreen
|
||||||
@@ -21,6 +25,7 @@ import app.folio.feature.ledger.AddTransactionScreen
|
|||||||
import app.folio.feature.ledger.LedgerScreen
|
import app.folio.feature.ledger.LedgerScreen
|
||||||
import app.folio.navigation.Navigator
|
import app.folio.navigation.Navigator
|
||||||
import app.folio.navigation.Route
|
import app.folio.navigation.Route
|
||||||
|
import app.folio.FocusTracker
|
||||||
import app.folio.ui.theme.LedgerTheme
|
import app.folio.ui.theme.LedgerTheme
|
||||||
import app.folio.ui.theme.LocalTokens
|
import app.folio.ui.theme.LocalTokens
|
||||||
import app.folio.ui.theme.Tokens
|
import app.folio.ui.theme.Tokens
|
||||||
@@ -29,6 +34,7 @@ import app.folio.ui.theme.Tokens
|
|||||||
fun App() {
|
fun App() {
|
||||||
val session by Repository.session.collectAsState()
|
val session by Repository.session.collectAsState()
|
||||||
val route by Navigator.current.collectAsState()
|
val route by Navigator.current.collectAsState()
|
||||||
|
val focusedInput by FocusTracker.current.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(session, route) {
|
LaunchedEffect(session, route) {
|
||||||
if (session == null && route !is Route.Login) {
|
if (session == null && route !is Route.Login) {
|
||||||
@@ -47,6 +53,9 @@ fun App() {
|
|||||||
.background(t.bg)
|
.background(t.bg)
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||||
) {
|
) {
|
||||||
|
if (focusedInput != null) {
|
||||||
|
Box(Modifier.size(1.dp).semantics { contentDescription = "focused_input:$focusedInput" })
|
||||||
|
}
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
when (val r = route) {
|
when (val r = route) {
|
||||||
Route.Login -> LoginScreen(onLoggedIn = { Navigator.replace(Route.Home) })
|
Route.Login -> LoginScreen(onLoggedIn = { Navigator.replace(Route.Home) })
|
||||||
|
|||||||
+3
-1
@@ -14,6 +14,8 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.folio.data.Repository
|
import app.folio.data.Repository
|
||||||
import app.folio.navigation.Navigator
|
import app.folio.navigation.Navigator
|
||||||
@@ -60,7 +62,7 @@ fun AddAccountScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Screen(
|
Screen(
|
||||||
modifier = Modifier.testTag("AddAccountScreen"),
|
modifier = Modifier.testTag("AddAccountScreen").semantics { contentDescription = "AddAccountScreen" },
|
||||||
header = {
|
header = {
|
||||||
Header(title = "New account", left = { BackButton(onClick = { Navigator.back(Route.Home) }) })
|
Header(title = "New account", left = { BackButton(onClick = { Navigator.back(Route.Home) }) })
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-1
@@ -16,6 +16,8 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.folio.data.Repository
|
import app.folio.data.Repository
|
||||||
@@ -60,7 +62,7 @@ fun LoginScreen(onLoggedIn: (String) -> Unit) {
|
|||||||
onLoggedIn(user)
|
onLoggedIn(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
Screen(modifier = Modifier.testTag("LoginScreen")) {
|
Screen(modifier = Modifier.testTag("LoginScreen").semantics { contentDescription = "LoginScreen" }) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
|||||||
+2
-2
@@ -50,7 +50,7 @@ fun HomeScreen(user: String, onLogout: () -> Unit) {
|
|||||||
val total = balanceOf(txns)
|
val total = balanceOf(txns)
|
||||||
|
|
||||||
Screen(
|
Screen(
|
||||||
modifier = Modifier.testTag("HomeScreen"),
|
modifier = Modifier.testTag("HomeScreen").semantics { contentDescription = "HomeScreen" },
|
||||||
header = {
|
header = {
|
||||||
Header(
|
Header(
|
||||||
title = "Accounts",
|
title = "Accounts",
|
||||||
@@ -123,7 +123,7 @@ private fun AccountCard(
|
|||||||
.clip(RoundedCornerShape(RadiusLg))
|
.clip(RoundedCornerShape(RadiusLg))
|
||||||
.background(t.surface)
|
.background(t.surface)
|
||||||
.border(1.dp, t.border, RoundedCornerShape(RadiusLg))
|
.border(1.dp, t.border, RoundedCornerShape(RadiusLg))
|
||||||
.semantics(mergeDescendants = true) { contentDescription = "account_card:$id" }
|
.semantics(mergeDescendants = true) { contentDescription = "account:$id:$balance" }
|
||||||
.clickable(role = Role.Button, onClick = onClick)
|
.clickable(role = Role.Button, onClick = onClick)
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|||||||
+3
-1
@@ -13,6 +13,8 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -91,7 +93,7 @@ fun AddTransactionScreen(accountId: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Screen(
|
Screen(
|
||||||
modifier = Modifier.testTag("AddTransactionScreen"),
|
modifier = Modifier.testTag("AddTransactionScreen").semantics { contentDescription = "AddTransactionScreen" },
|
||||||
header = {
|
header = {
|
||||||
Header(
|
Header(
|
||||||
title = "Add transaction",
|
title = "Add transaction",
|
||||||
|
|||||||
+4
-2
@@ -75,7 +75,7 @@ fun LedgerScreen(accountId: String) {
|
|||||||
val balance = balanceOf(txns)
|
val balance = balanceOf(txns)
|
||||||
|
|
||||||
Screen(
|
Screen(
|
||||||
modifier = Modifier.testTag("LedgerScreen"),
|
modifier = Modifier.testTag("LedgerScreen").semantics { contentDescription = "LedgerScreen" },
|
||||||
header = {
|
header = {
|
||||||
Header(
|
Header(
|
||||||
title = account.name,
|
title = account.name,
|
||||||
@@ -92,6 +92,8 @@ fun LedgerScreen(accountId: String) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
Box(Modifier.size(1.dp).semantics { contentDescription = "active_account:$accountId" })
|
||||||
|
Box(Modifier.size(1.dp).semantics { contentDescription = "ledger_balance:$balance" })
|
||||||
Card {
|
Card {
|
||||||
Text("BALANCE", style = Type.label, color = t.textMuted)
|
Text("BALANCE", style = Type.label, color = t.textMuted)
|
||||||
Text(formatCents(balance), style = Type.balance, color = t.text)
|
Text(formatCents(balance), style = Type.balance, color = t.text)
|
||||||
@@ -136,7 +138,7 @@ private fun TxnRow(id: String, type: TxnType, amount: Long, note: String, date:
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 14.dp)
|
.padding(vertical = 14.dp)
|
||||||
.semantics(mergeDescendants = true) { contentDescription = "txn_row:$id" },
|
.semantics(mergeDescendants = true) { contentDescription = "ledger_row:$id:$signed" },
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
package app.folio.sanderling
|
|
||||||
|
|
||||||
import app.folio.data.Repository
|
|
||||||
import app.folio.feature.account.AddAccountUiState
|
|
||||||
import app.folio.platform.balanceOf
|
|
||||||
|
|
||||||
object AccountSnapshots {
|
|
||||||
val accountCount by SanderlingIos.snapshot { Repository.accounts.value.size }
|
|
||||||
val totalBalance by SanderlingIos.snapshot { balanceOf(Repository.transactions.value) }
|
|
||||||
val accounts by SanderlingIos.snapshot {
|
|
||||||
val txns = Repository.transactions.value
|
|
||||||
Repository.accounts.value.map { a ->
|
|
||||||
val rows = txns.filter { it.accountId == a.id }
|
|
||||||
mapOf("id" to a.id, "name" to a.name, "balance" to balanceOf(rows), "txnCount" to rows.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val addAccountError by SanderlingIos.snapshot { AddAccountUiState.addAccountError.value }
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package app.folio.sanderling
|
|
||||||
|
|
||||||
import app.folio.data.Repository
|
|
||||||
import app.folio.feature.auth.LoginUiState
|
|
||||||
|
|
||||||
object AuthSnapshots {
|
|
||||||
val loggedIn by SanderlingIos.snapshot { Repository.session.value != null }
|
|
||||||
val authStatus by SanderlingIos.snapshot { if (Repository.session.value != null) "logged-in" else "logged-out" }
|
|
||||||
val loginError by SanderlingIos.snapshot { LoginUiState.loginError.value }
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
package app.folio.sanderling
|
|
||||||
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
|
||||||
import platform.Foundation.NSBundle
|
|
||||||
import platform.Foundation.NSThread
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
internal object IosAgent {
|
|
||||||
private const val VERSION = "0.0.1"
|
|
||||||
private const val PROTOCOL_VERSION = 1
|
|
||||||
|
|
||||||
fun start(host: String, port: Int) {
|
|
||||||
val thread = NSThread { runLoop(host, port) }
|
|
||||||
thread.name = "sanderling-agent"
|
|
||||||
thread.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun runLoop(host: String, port: Int) {
|
|
||||||
var delayMs = 0L
|
|
||||||
while (true) {
|
|
||||||
if (delayMs > 0) NSThread.sleepForTimeInterval(delayMs / 1000.0)
|
|
||||||
try {
|
|
||||||
val conn = TcpConnection.connect(host, port)
|
|
||||||
delayMs = 0L
|
|
||||||
try { serve(conn) } finally { conn.close() }
|
|
||||||
} catch (_: Exception) {
|
|
||||||
delayMs = if (delayMs <= 0L) 500L else minOf(delayMs * 2, 10_000L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun serve(conn: TcpConnection) {
|
|
||||||
val appPackage = (NSBundle.mainBundle.infoDictionary?.get("CFBundleIdentifier") as? String) ?: "unknown"
|
|
||||||
conn.writeFrame(
|
|
||||||
"""{"type":"HELLO","protocol_version":$PROTOCOL_VERSION,"version":${jsonString(VERSION)},"platform":"ios","app_package":${jsonString(appPackage)}}""".encodeToByteArray()
|
|
||||||
)
|
|
||||||
while (true) {
|
|
||||||
val frame = conn.readFrame()
|
|
||||||
val text = frame.decodeToString()
|
|
||||||
val type = extractJsonField(text, "type") ?: break
|
|
||||||
val id = extractJsonLong(text, "id") ?: 0L
|
|
||||||
when (type) {
|
|
||||||
"PAUSE" -> handlePause(conn, id)
|
|
||||||
"RESUME" -> IosPauser.release()
|
|
||||||
"GOODBYE" -> return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handlePause(conn: TcpConnection, id: Long) {
|
|
||||||
val snapshots = IosPauser.pauseAndSnapshot {
|
|
||||||
val snap = SanderlingIos.extractors.toMap()
|
|
||||||
buildMap { for ((name, extractor) in snap) put(name, runCatching { extractor() }.getOrNull()) }
|
|
||||||
}
|
|
||||||
val snapshotsJson = snapshots.entries.joinToString(",") { (k, v) -> "${jsonString(k)}:${jsonValue(v)}" }
|
|
||||||
conn.writeFrame("""{"type":"STATE","id":$id,"snapshots":{$snapshotsJson}}""".encodeToByteArray())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun extractJsonField(json: String, key: String): String? =
|
|
||||||
Regex("\"${Regex.escape(key)}\"\\s*:\\s*\"([^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"").find(json)?.groupValues?.get(1)
|
|
||||||
|
|
||||||
internal fun extractJsonLong(json: String, key: String): Long? =
|
|
||||||
Regex("\"${Regex.escape(key)}\"\\s*:\\s*(-?\\d+)").find(json)?.groupValues?.get(1)?.toLongOrNull()
|
|
||||||
|
|
||||||
internal fun jsonString(s: String): String = buildString {
|
|
||||||
append('"')
|
|
||||||
for (c in s) when (c) {
|
|
||||||
'"' -> append("\\\"")
|
|
||||||
'\\' -> append("\\\\")
|
|
||||||
'\n' -> append("\\n")
|
|
||||||
'\r' -> append("\\r")
|
|
||||||
'\t' -> append("\\t")
|
|
||||||
else -> if (c.code < 0x20) append("\\u${c.code.toString(16).padStart(4, '0')}") else append(c)
|
|
||||||
}
|
|
||||||
append('"')
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun jsonValue(value: Any?): String = when (value) {
|
|
||||||
null -> "null"
|
|
||||||
is Boolean -> if (value) "true" else "false"
|
|
||||||
is Int -> value.toString()
|
|
||||||
is Long -> value.toString()
|
|
||||||
is Float -> value.toString()
|
|
||||||
is Double -> value.toString()
|
|
||||||
is String -> jsonString(value)
|
|
||||||
is Map<*, *> -> "{${value.entries.joinToString(",") { (k, v) -> "${jsonString(k.toString())}:${jsonValue(v)}" }}}"
|
|
||||||
is List<*> -> "[${value.joinToString(",") { jsonValue(it) }}]"
|
|
||||||
else -> jsonString(value.toString())
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package app.folio.sanderling
|
|
||||||
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
|
||||||
import platform.darwin.*
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
internal object IosPauser {
|
|
||||||
private val snapshotReady = dispatch_semaphore_create(0)
|
|
||||||
private val resumeGate = dispatch_semaphore_create(0)
|
|
||||||
private var capturedSnapshot: Map<String, Any?> = emptyMap()
|
|
||||||
|
|
||||||
fun pauseAndSnapshot(extractors: () -> Map<String, Any?>): Map<String, Any?> {
|
|
||||||
dispatch_async(dispatch_get_main_queue()) {
|
|
||||||
capturedSnapshot = runCatching { extractors() }.getOrElse { emptyMap() }
|
|
||||||
dispatch_semaphore_signal(snapshotReady)
|
|
||||||
dispatch_semaphore_wait(resumeGate, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L))
|
|
||||||
}
|
|
||||||
val waited = dispatch_semaphore_wait(snapshotReady, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L))
|
|
||||||
check(waited == 0L) { "snapshot timed out: main thread did not signal within 5s" }
|
|
||||||
return capturedSnapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
fun release() {
|
|
||||||
dispatch_semaphore_signal(resumeGate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package app.folio.sanderling
|
|
||||||
|
|
||||||
import app.folio.FocusTracker
|
|
||||||
import app.folio.data.Repository
|
|
||||||
import app.folio.data.TxnType
|
|
||||||
import app.folio.feature.ledger.AddTransactionUiState
|
|
||||||
import app.folio.navigation.Navigator
|
|
||||||
import app.folio.navigation.Route
|
|
||||||
import app.folio.platform.balanceOf
|
|
||||||
import app.folio.platform.signedAmount
|
|
||||||
|
|
||||||
object LedgerSnapshots {
|
|
||||||
private val activeId
|
|
||||||
get() = when (val r = Navigator.current.value) {
|
|
||||||
is Route.Ledger -> r.accountId
|
|
||||||
is Route.AddTransaction -> r.accountId
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
val activeAccountId by SanderlingIos.snapshot { activeId }
|
|
||||||
val ledgerRows by SanderlingIos.snapshot {
|
|
||||||
val active = activeId ?: return@snapshot emptyList<Any>()
|
|
||||||
Repository.transactions.value.filter { it.accountId == active }.map {
|
|
||||||
mapOf(
|
|
||||||
"id" to it.id,
|
|
||||||
"accountId" to it.accountId,
|
|
||||||
"type" to if (it.type == TxnType.credit) "credit" else "debit",
|
|
||||||
"amount" to it.amount,
|
|
||||||
"signed" to signedAmount(it),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val ledgerBalance by SanderlingIos.snapshot {
|
|
||||||
val active = activeId ?: return@snapshot 0L
|
|
||||||
balanceOf(Repository.transactions.value.filter { it.accountId == active })
|
|
||||||
}
|
|
||||||
val focusedInput by SanderlingIos.snapshot { FocusTracker.current.value }
|
|
||||||
val txnFormType by SanderlingIos.snapshot { AddTransactionUiState.txnFormType.value }
|
|
||||||
val txnFormAccountId by SanderlingIos.snapshot { (Navigator.current.value as? Route.AddTransaction)?.accountId }
|
|
||||||
val txnError by SanderlingIos.snapshot { AddTransactionUiState.txnError.value }
|
|
||||||
}
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
package app.folio.sanderling
|
|
||||||
|
|
||||||
import app.folio.navigation.Navigator
|
|
||||||
import app.folio.navigation.Route
|
|
||||||
|
|
||||||
object NavigationSnapshots {
|
|
||||||
val screen by SanderlingIos.snapshot {
|
|
||||||
when (Navigator.current.value) {
|
|
||||||
Route.Login -> "login"
|
|
||||||
Route.Home -> "home"
|
|
||||||
Route.AddAccount -> "add-account"
|
|
||||||
is Route.Ledger -> "ledger"
|
|
||||||
is Route.AddTransaction -> "add-transaction"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package app.folio.sanderling
|
|
||||||
|
|
||||||
import kotlin.properties.ReadOnlyProperty
|
|
||||||
import kotlin.reflect.KProperty
|
|
||||||
import platform.Foundation.NSProcessInfo
|
|
||||||
|
|
||||||
private fun String.camelToSnakeCase(): String = buildString {
|
|
||||||
for ((i, c) in this@camelToSnakeCase.withIndex()) {
|
|
||||||
if (c.isUpperCase() && i > 0) append('_')
|
|
||||||
append(c.lowercaseChar())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object SanderlingIos {
|
|
||||||
internal val extractors = mutableMapOf<String, () -> Any?>()
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
val port = resolvePort() ?: return
|
|
||||||
IosAgent.start("127.0.0.1", port)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolvePort(): Int? {
|
|
||||||
// Env var set via SIMCTL_CHILD_SANDERLING_PORT (simctl direct launch).
|
|
||||||
(NSProcessInfo.processInfo.environment["SANDERLING_PORT"] as? String)
|
|
||||||
?.toIntOrNull()?.let { return it }
|
|
||||||
// Launch argument -SANDERLING_PORT <value> (Maestro simctl launch).
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val args = NSProcessInfo.processInfo.arguments as? List<String> ?: return null
|
|
||||||
val idx = args.indexOfFirst { it == "-SANDERLING_PORT" }
|
|
||||||
if (idx >= 0 && idx + 1 < args.size) {
|
|
||||||
return args[idx + 1].toIntOrNull()
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun extract(name: String, block: () -> Any?) {
|
|
||||||
extractors[name] = block
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T> snapshot(block: () -> T): SnapshotDelegate<T> = SnapshotDelegate(block)
|
|
||||||
}
|
|
||||||
|
|
||||||
class SnapshotDelegate<T>(private val block: () -> T) {
|
|
||||||
operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): ReadOnlyProperty<Any?, T> {
|
|
||||||
SanderlingIos.extract(prop.name.camelToSnakeCase(), block as () -> Any?)
|
|
||||||
return ReadOnlyProperty { _, _ -> block() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package app.folio.sanderling
|
|
||||||
|
|
||||||
import kotlinx.cinterop.*
|
|
||||||
import platform.posix.*
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
internal class TcpConnection private constructor(private val fd: Int) {
|
|
||||||
companion object {
|
|
||||||
fun connect(host: String, port: Int): TcpConnection {
|
|
||||||
val sock = socket(AF_INET, SOCK_STREAM, 0)
|
|
||||||
check(sock >= 0) { "socket() failed" }
|
|
||||||
memScoped {
|
|
||||||
val addr = alloc<sockaddr_in>()
|
|
||||||
addr.sin_family = AF_INET.convert()
|
|
||||||
addr.sin_port = networkShort(port)
|
|
||||||
addr.sin_addr.s_addr = networkAddress(host)
|
|
||||||
val result = platform.posix.connect(sock, addr.ptr.reinterpret(), sizeOf<sockaddr_in>().convert())
|
|
||||||
if (result < 0) {
|
|
||||||
close(sock)
|
|
||||||
error("connect() to $host:$port failed: errno=$errno")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return TcpConnection(sock)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert port to network byte order (big-endian bytes in memory on little-endian iOS).
|
|
||||||
private fun networkShort(value: Int): UShort =
|
|
||||||
(((value ushr 8) and 0xFF) or ((value and 0xFF) shl 8)).toUShort()
|
|
||||||
|
|
||||||
// Parse "a.b.c.d" → UInt with bytes [a,b,c,d] in memory (little-endian storage).
|
|
||||||
private fun networkAddress(host: String): UInt {
|
|
||||||
val parts = host.split(".").map { it.toInt() }
|
|
||||||
check(parts.size == 4) { "expected IPv4 address, got: $host" }
|
|
||||||
return (parts[0] or (parts[1] shl 8) or (parts[2] shl 16) or (parts[3] shl 24)).toUInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeFrame(data: ByteArray) {
|
|
||||||
val len = data.size
|
|
||||||
writeAll(byteArrayOf((len ushr 24).toByte(), (len ushr 16).toByte(), (len ushr 8).toByte(), len.toByte()))
|
|
||||||
writeAll(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readFrame(): ByteArray {
|
|
||||||
val header = readAll(4)
|
|
||||||
val len = ((header[0].toInt() and 0xFF) shl 24) or
|
|
||||||
((header[1].toInt() and 0xFF) shl 16) or
|
|
||||||
((header[2].toInt() and 0xFF) shl 8) or
|
|
||||||
(header[3].toInt() and 0xFF)
|
|
||||||
check(len in 0..16_777_216) { "bad frame length: $len" }
|
|
||||||
return readAll(len)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun writeAll(data: ByteArray) {
|
|
||||||
data.usePinned { pinned ->
|
|
||||||
var offset = 0
|
|
||||||
while (offset < data.size) {
|
|
||||||
val n = send(fd, pinned.addressOf(offset), (data.size - offset).convert(), 0).toInt()
|
|
||||||
check(n > 0) { "send() failed: errno=$errno" }
|
|
||||||
offset += n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readAll(count: Int): ByteArray {
|
|
||||||
val buf = ByteArray(count)
|
|
||||||
buf.usePinned { pinned ->
|
|
||||||
var offset = 0
|
|
||||||
while (offset < count) {
|
|
||||||
val n = recv(fd, pinned.addressOf(offset), (count - offset).convert(), 0).toInt()
|
|
||||||
check(n > 0) { "recv() returned $n" }
|
|
||||||
offset += n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return buf
|
|
||||||
}
|
|
||||||
|
|
||||||
fun close() {
|
|
||||||
platform.posix.close(fd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "folio",
|
||||||
|
"devDependencies": {
|
||||||
|
"@sanderling/spec": "file:../../../pkg/spec",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
|
||||||
|
|
||||||
|
"@sanderling/spec": ["@sanderling/spec@file:../../../pkg/spec", { "devDependencies": { "@types/node": "^22.7.5", "tsx": "^4.21.0", "typescript": "^5.9.3" } }],
|
||||||
|
|
||||||
|
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
|
||||||
|
|
||||||
|
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
|
||||||
|
|
||||||
|
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"get-tsconfig": ["[email protected]", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||||
|
|
||||||
|
"resolve-pkg-maps": ["[email protected]", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
|
"tsx": ["[email protected]", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||||
|
|
||||||
|
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["[email protected]", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "folio",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "folio",
|
|
||||||
"devDependencies": {
|
|
||||||
"@sanderling/spec": "^0.0.1-rc4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sanderling/spec": {
|
|
||||||
"version": "0.0.1-rc4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sanderling/spec/-/spec-0.0.1-rc4.tgz",
|
|
||||||
"integrity": "sha512-iv08d2duxQMowqEnDtOeWRGLmiv7188D68g/5ei+cU1BL33cVXwGscLYznfsUv4PgK7XI5Vihwbnp7ME4+Lh1w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"description": "Minimal sanderling integration example.",
|
"description": "Minimal sanderling integration example.",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sanderling/spec": "^0.0.1-rc4"
|
"@sanderling/spec": "file:../../../pkg/spec"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
|
|
||||||
interface Account {
|
interface Account {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
|
||||||
balance: number;
|
balance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,31 +20,70 @@ interface LedgerRow {
|
|||||||
signed: number;
|
signed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loggedIn = extract<boolean>(s => (s.snapshots.logged_in as boolean) ?? false);
|
function parseAccount(desc: string | undefined): Account {
|
||||||
const route = extract<string>(s => (s.snapshots.screen as string) ?? "");
|
const parts = (desc ?? "").split(":");
|
||||||
const accounts = extract<Account[]>(s => (s.snapshots.accounts as Account[]) ?? []);
|
return { id: parts[1] ?? "", balance: Number(parts[2]) };
|
||||||
const ledgerRows = extract<LedgerRow[]>(s => (s.snapshots.ledger_rows as LedgerRow[]) ?? []);
|
}
|
||||||
const ledgerBalance = extract<number>(s => (s.snapshots.ledger_balance as number) ?? 0);
|
|
||||||
const activeAccountId = extract<string | null>(s => (s.snapshots.active_account_id as string | null) ?? null);
|
|
||||||
const focusedInput = extract<string | null>(s => (s.snapshots.focused_input as string | null) ?? null);
|
|
||||||
|
|
||||||
const loginEmailField = extract(s => s.ax.find("id:LoginScreen > desc:login_email"));
|
function parseLedgerRow(desc: string | undefined): LedgerRow {
|
||||||
const loginPasswordField = extract(s => s.ax.find("id:LoginScreen > desc:login_password"));
|
const parts = (desc ?? "").split(":");
|
||||||
const loginSubmit = extract(s => s.ax.find("id:LoginScreen > desc:login_submit"));
|
return { id: parts[1] ?? "", signed: Number(parts[2]) };
|
||||||
const addAccountButton = extract(s => s.ax.find("id:HomeScreen > desc:add_account_button"));
|
}
|
||||||
const accountNameField = extract(s => s.ax.find("id:AddAccountScreen > desc:account_name_field"));
|
|
||||||
const addAccountSubmit = extract(s => s.ax.find("id:AddAccountScreen > desc:add_account_submit"));
|
function parseCents(desc: string | null | undefined): number {
|
||||||
const addTxnButton = extract(s => s.ax.find("id:LedgerScreen > desc:add_txn_button"));
|
if (!desc) return 0;
|
||||||
const txnAmountField = extract(s => s.ax.find("id:AddTransactionScreen > desc:txn_amount"));
|
const parts = desc.split(":");
|
||||||
const txnSubmit = extract(s => s.ax.find("id:AddTransactionScreen > desc:txn_submit"));
|
return Number(parts[1]) || 0;
|
||||||
const accountCards = extract(s => s.ax.findAll("descPrefix:account_card:"));
|
}
|
||||||
|
|
||||||
|
// Route and auth state derived from screen root nodes
|
||||||
|
const loggedIn = extract(s => s.ax.find("desc:LoginScreen") == null);
|
||||||
|
const route = extract<string | null>(s => {
|
||||||
|
if (s.ax.find("desc:LoginScreen")) return "login";
|
||||||
|
if (s.ax.find("desc:HomeScreen")) return "home";
|
||||||
|
if (s.ax.find("desc:AddAccountScreen")) return "add-account";
|
||||||
|
if (s.ax.find("desc:LedgerScreen")) return "ledger";
|
||||||
|
if (s.ax.find("desc:AddTransactionScreen")) return "add-transaction";
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// All element lookups scoped through their screen root
|
||||||
|
const accounts = extract(s => s.ax.findAll("desc:HomeScreen > descPrefix:account:")
|
||||||
|
.map(el => parseAccount(el.desc)));
|
||||||
|
const ledgerRows = extract(s => s.ax.findAll("desc:LedgerScreen > descPrefix:ledger_row:")
|
||||||
|
.map(el => parseLedgerRow(el.desc)));
|
||||||
|
const ledgerBalance = extract(s =>
|
||||||
|
parseCents(s.ax.find("desc:LedgerScreen > descPrefix:ledger_balance:")?.desc));
|
||||||
|
const activeAccountId = extract(s =>
|
||||||
|
s.ax.find("desc:LedgerScreen > descPrefix:active_account:")?.desc?.split(":")[1] ?? null);
|
||||||
|
|
||||||
|
// focusedInput lives in the app root (not inside any screen), so unscoped
|
||||||
|
const focusedInput = extract(s =>
|
||||||
|
s.ax.find("descPrefix:focused_input:")?.desc?.split(":")[1] ?? null);
|
||||||
|
|
||||||
|
const loginEmailField = extract(s => s.ax.find("desc:LoginScreen > desc:login_email"));
|
||||||
|
const loginPasswordField = extract(s => s.ax.find("desc:LoginScreen > desc:login_password"));
|
||||||
|
const loginSubmit = extract(s => s.ax.find("desc:LoginScreen > desc:login_submit"));
|
||||||
|
const addAccountButton = extract(s => s.ax.find("desc:HomeScreen > desc:add_account_button"));
|
||||||
|
const accountNameField = extract(s => s.ax.find("desc:AddAccountScreen > desc:account_name_field"));
|
||||||
|
const addAccountSubmit = extract(s => s.ax.find("desc:AddAccountScreen > desc:add_account_submit"));
|
||||||
|
const addTxnButton = extract(s => s.ax.find("desc:LedgerScreen > desc:add_txn_button"));
|
||||||
|
const txnAmountField = extract(s => s.ax.find("desc:AddTransactionScreen > desc:txn_amount"));
|
||||||
|
const txnSubmit = extract(s => s.ax.find("desc:AddTransactionScreen > desc:txn_submit"));
|
||||||
|
const accountCards = extract(s => s.ax.findAll("desc:HomeScreen > descPrefix:account:"));
|
||||||
const backButton = extract(s => s.ax.find("desc:Back"));
|
const backButton = extract(s => s.ax.find("desc:Back"));
|
||||||
|
|
||||||
// Property 1: every new account starts with balance === 0
|
// Property 1: every new account starts with balance === 0
|
||||||
|
// Guard: only check when accounts were visible in the previous step too.
|
||||||
|
// Without this, navigating away from HomeScreen (accounts=[]) then back
|
||||||
|
// makes every account look "new", causing false positives on pre-existing balances.
|
||||||
const newAccountBalanceIsZero = always(
|
const newAccountBalanceIsZero = always(
|
||||||
next(() => {
|
next(() => {
|
||||||
const prevIds = new Set((accounts.previous ?? []).map(a => a.id));
|
const prev = accounts.previous ?? [];
|
||||||
const newAccounts = accounts.current.filter(a => !prevIds.has(a.id));
|
const curr = accounts.current;
|
||||||
|
if (prev.length === 0 || curr.length === 0) return true;
|
||||||
|
const prevIds = new Set(prev.map(a => a.id));
|
||||||
|
const newAccounts = curr.filter(a => !prevIds.has(a.id));
|
||||||
return newAccounts.every(a => a.balance === 0);
|
return newAccounts.every(a => a.balance === 0);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -69,7 +107,7 @@ const newTxnChangesBalance = always(
|
|||||||
const DEMO_EMAIL = "[email protected]";
|
const DEMO_EMAIL = "[email protected]";
|
||||||
const DEMO_PASSWORD = "ledger123";
|
const DEMO_PASSWORD = "ledger123";
|
||||||
|
|
||||||
// Login if not already in — step by step based on which field has focus
|
// Login if not already in - step by step based on which field has focus
|
||||||
const login = actions(() => {
|
const login = actions(() => {
|
||||||
if (loggedIn.current) return [];
|
if (loggedIn.current) return [];
|
||||||
const focus = focusedInput.current;
|
const focus = focusedInput.current;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["spec.ts"]
|
||||||
|
}
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MessageType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
MessageTypeHello MessageType = "HELLO"
|
|
||||||
MessageTypePause MessageType = "PAUSE"
|
|
||||||
MessageTypeResume MessageType = "RESUME"
|
|
||||||
MessageTypeState MessageType = "STATE"
|
|
||||||
MessageTypeExtractResult MessageType = "EXTRACT_RESULT"
|
|
||||||
MessageTypeGoodbye MessageType = "GOODBYE"
|
|
||||||
)
|
|
||||||
|
|
||||||
const MaxFrameSize = 16 * 1024 * 1024
|
|
||||||
|
|
||||||
// ProtocolVersion is the wire-format version. Bump on any breaking change
|
|
||||||
// to the message schema or framing. Independent of the SDK release version.
|
|
||||||
const ProtocolVersion = 1
|
|
||||||
|
|
||||||
type Message struct {
|
|
||||||
Type MessageType `json:"type"`
|
|
||||||
ID uint64 `json:"id,omitempty"`
|
|
||||||
|
|
||||||
ProtocolVersion int `json:"protocol_version,omitempty"`
|
|
||||||
Version string `json:"version,omitempty"`
|
|
||||||
Platform string `json:"platform,omitempty"`
|
|
||||||
AppPackage string `json:"app_package,omitempty"`
|
|
||||||
|
|
||||||
Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"`
|
|
||||||
Exceptions []Exception `json:"exceptions,omitempty"`
|
|
||||||
|
|
||||||
Extractor string `json:"extractor,omitempty"`
|
|
||||||
Result json.RawMessage `json:"result,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exception mirrors an uncaught throwable captured by the SDK.
|
|
||||||
type Exception struct {
|
|
||||||
Class string `json:"class"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
StackTrace string `json:"stack_trace,omitempty"`
|
|
||||||
UnixMillis int64 `json:"unix_millis,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func Hello(version, platform, appPackage string) Message {
|
|
||||||
return Message{
|
|
||||||
Type: MessageTypeHello,
|
|
||||||
ProtocolVersion: ProtocolVersion,
|
|
||||||
Version: version,
|
|
||||||
Platform: platform,
|
|
||||||
AppPackage: appPackage,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Pause(id uint64) Message { return Message{Type: MessageTypePause, ID: id} }
|
|
||||||
|
|
||||||
func Resume(id uint64) Message { return Message{Type: MessageTypeResume, ID: id} }
|
|
||||||
|
|
||||||
func State(id uint64, snapshots map[string]json.RawMessage) Message {
|
|
||||||
return Message{Type: MessageTypeState, ID: id, Snapshots: snapshots}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExtractResult(id uint64, extractor string, result json.RawMessage, extractorError string) Message {
|
|
||||||
return Message{
|
|
||||||
Type: MessageTypeExtractResult,
|
|
||||||
ID: id,
|
|
||||||
Extractor: extractor,
|
|
||||||
Result: result,
|
|
||||||
Error: extractorError,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Goodbye(reason string) Message {
|
|
||||||
return Message{Type: MessageTypeGoodbye, Reason: reason}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WriteMessage(writer io.Writer, message Message) error {
|
|
||||||
payload, err := json.Marshal(message)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal: %w", err)
|
|
||||||
}
|
|
||||||
if len(payload) > MaxFrameSize {
|
|
||||||
return fmt.Errorf("frame of %d bytes exceeds maximum %d", len(payload), MaxFrameSize)
|
|
||||||
}
|
|
||||||
var header [4]byte
|
|
||||||
binary.BigEndian.PutUint32(header[:], uint32(len(payload)))
|
|
||||||
if _, err := writer.Write(header[:]); err != nil {
|
|
||||||
return fmt.Errorf("write header: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := writer.Write(payload); err != nil {
|
|
||||||
return fmt.Errorf("write payload: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ReadMessage(reader io.Reader) (Message, error) {
|
|
||||||
var header [4]byte
|
|
||||||
if _, err := io.ReadFull(reader, header[:]); err != nil {
|
|
||||||
return Message{}, err
|
|
||||||
}
|
|
||||||
length := binary.BigEndian.Uint32(header[:])
|
|
||||||
if length > MaxFrameSize {
|
|
||||||
return Message{}, fmt.Errorf("frame of %d bytes exceeds maximum %d", length, MaxFrameSize)
|
|
||||||
}
|
|
||||||
payload := make([]byte, length)
|
|
||||||
if _, err := io.ReadFull(reader, payload); err != nil {
|
|
||||||
return Message{}, fmt.Errorf("read payload: %w", err)
|
|
||||||
}
|
|
||||||
var message Message
|
|
||||||
if err := json.Unmarshal(payload, &message); err != nil {
|
|
||||||
return Message{}, fmt.Errorf("unmarshal: %w", err)
|
|
||||||
}
|
|
||||||
if message.Type == "" {
|
|
||||||
return Message{}, errors.New("missing type")
|
|
||||||
}
|
|
||||||
return message, nil
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func roundTrip(t *testing.T, message Message) Message {
|
|
||||||
t.Helper()
|
|
||||||
var buffer bytes.Buffer
|
|
||||||
if err := WriteMessage(&buffer, message); err != nil {
|
|
||||||
t.Fatalf("WriteMessage: %v", err)
|
|
||||||
}
|
|
||||||
got, err := ReadMessage(&buffer)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadMessage: %v", err)
|
|
||||||
}
|
|
||||||
return got
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRoundTrip_Hello(t *testing.T) {
|
|
||||||
got := roundTrip(t, Hello("0.0.1", "android", "in.okcredit.merchant"))
|
|
||||||
if got.Type != MessageTypeHello || got.Version != "0.0.1" || got.Platform != "android" || got.AppPackage != "in.okcredit.merchant" {
|
|
||||||
t.Fatalf("hello round-trip failed: %+v", got)
|
|
||||||
}
|
|
||||||
if got.ProtocolVersion != ProtocolVersion {
|
|
||||||
t.Errorf("protocol_version: got %d, want %d", got.ProtocolVersion, ProtocolVersion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRoundTrip_PauseResume(t *testing.T) {
|
|
||||||
for _, builder := range []func(uint64) Message{Pause, Resume} {
|
|
||||||
got := roundTrip(t, builder(42))
|
|
||||||
if got.ID != 42 {
|
|
||||||
t.Errorf("id round-trip failed: %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRoundTrip_State(t *testing.T) {
|
|
||||||
snapshots := map[string]json.RawMessage{
|
|
||||||
"screen": json.RawMessage(`"customer_ledger"`),
|
|
||||||
"ledger.balance": json.RawMessage(`1500`),
|
|
||||||
"is_signed_in": json.RawMessage(`true`),
|
|
||||||
}
|
|
||||||
got := roundTrip(t, State(7, snapshots))
|
|
||||||
if got.Type != MessageTypeState || got.ID != 7 {
|
|
||||||
t.Fatalf("state envelope wrong: %+v", got)
|
|
||||||
}
|
|
||||||
if string(got.Snapshots["screen"]) != `"customer_ledger"` {
|
|
||||||
t.Errorf("screen snapshot wrong: %s", got.Snapshots["screen"])
|
|
||||||
}
|
|
||||||
if string(got.Snapshots["ledger.balance"]) != `1500` {
|
|
||||||
t.Errorf("balance snapshot wrong: %s", got.Snapshots["ledger.balance"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRoundTrip_ExtractResult(t *testing.T) {
|
|
||||||
got := roundTrip(t, ExtractResult(1, "ledger.balance", json.RawMessage(`2500`), ""))
|
|
||||||
if got.Extractor != "ledger.balance" || string(got.Result) != `2500` {
|
|
||||||
t.Fatalf("extract result round-trip failed: %+v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
failed := roundTrip(t, ExtractResult(2, "ledger.balance", nil, "no active customer"))
|
|
||||||
if failed.Error != "no active customer" {
|
|
||||||
t.Errorf("extract error round-trip failed: %+v", failed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRoundTrip_Goodbye(t *testing.T) {
|
|
||||||
got := roundTrip(t, Goodbye("app terminated"))
|
|
||||||
if got.Type != MessageTypeGoodbye || got.Reason != "app terminated" {
|
|
||||||
t.Fatalf("goodbye round-trip failed: %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteMessage_FrameFormat(t *testing.T) {
|
|
||||||
var buffer bytes.Buffer
|
|
||||||
if err := WriteMessage(&buffer, Pause(99)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
raw := buffer.Bytes()
|
|
||||||
if len(raw) < 4 {
|
|
||||||
t.Fatalf("frame too short: %d bytes", len(raw))
|
|
||||||
}
|
|
||||||
length := binary.BigEndian.Uint32(raw[:4])
|
|
||||||
if int(length) != len(raw)-4 {
|
|
||||||
t.Errorf("header length %d mismatches payload length %d", length, len(raw)-4)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(raw[4:]), `"type":"PAUSE"`) {
|
|
||||||
t.Errorf("payload does not contain PAUSE type: %s", raw[4:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadMessage_ShortReaderReturnsEOF(t *testing.T) {
|
|
||||||
_, err := ReadMessage(bytes.NewReader(nil))
|
|
||||||
if !errors.Is(err, io.EOF) {
|
|
||||||
t.Errorf("expected EOF on empty reader, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadMessage_OversizedFrameRejected(t *testing.T) {
|
|
||||||
var header [4]byte
|
|
||||||
binary.BigEndian.PutUint32(header[:], uint32(MaxFrameSize+1))
|
|
||||||
_, err := ReadMessage(bytes.NewReader(header[:]))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "exceeds maximum") {
|
|
||||||
t.Errorf("expected oversized-frame error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadMessage_MissingTypeRejected(t *testing.T) {
|
|
||||||
var buffer bytes.Buffer
|
|
||||||
payload := []byte(`{"id":1}`)
|
|
||||||
var header [4]byte
|
|
||||||
binary.BigEndian.PutUint32(header[:], uint32(len(payload)))
|
|
||||||
buffer.Write(header[:])
|
|
||||||
buffer.Write(payload)
|
|
||||||
|
|
||||||
_, err := ReadMessage(&buffer)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "missing type") {
|
|
||||||
t.Errorf("expected missing-type error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteMessage_StreamsMultipleFrames(t *testing.T) {
|
|
||||||
var buffer bytes.Buffer
|
|
||||||
messages := []Message{
|
|
||||||
Hello("v", "android", "com.x"),
|
|
||||||
Pause(1),
|
|
||||||
State(1, map[string]json.RawMessage{"x": json.RawMessage(`42`)}),
|
|
||||||
Resume(1),
|
|
||||||
Goodbye("done"),
|
|
||||||
}
|
|
||||||
for _, message := range messages {
|
|
||||||
if err := WriteMessage(&buffer, message); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for index, want := range messages {
|
|
||||||
got, err := ReadMessage(&buffer)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("frame %d: %v", index, err)
|
|
||||||
}
|
|
||||||
if got.Type != want.Type {
|
|
||||||
t.Errorf("frame %d: got type %q, want %q", index, got.Type, want.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Server struct {
|
|
||||||
listener net.Listener
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewServer(listener net.Listener) *Server {
|
|
||||||
return &Server{listener: listener}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) Addr() net.Addr { return s.listener.Addr() }
|
|
||||||
|
|
||||||
// Accept waits for the next SDK client and performs the HELLO handshake.
|
|
||||||
// Only one Conn may be active at a time; subsequent Accepts block until the
|
|
||||||
// current connection closes.
|
|
||||||
func (s *Server) Accept(ctx context.Context) (*Conn, error) {
|
|
||||||
cancelCloser := closeListenerOnCancel(ctx, s.listener)
|
|
||||||
defer cancelCloser()
|
|
||||||
|
|
||||||
rawConn, err := s.listener.Accept()
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("accept: %w", err)
|
|
||||||
}
|
|
||||||
hello, err := readWithDeadline(ctx, rawConn)
|
|
||||||
if err != nil {
|
|
||||||
rawConn.Close()
|
|
||||||
return nil, fmt.Errorf("read hello: %w", err)
|
|
||||||
}
|
|
||||||
if hello.Type != MessageTypeHello {
|
|
||||||
rawConn.Close()
|
|
||||||
return nil, fmt.Errorf("expected HELLO, got %q", hello.Type)
|
|
||||||
}
|
|
||||||
if hello.ProtocolVersion != ProtocolVersion {
|
|
||||||
rawConn.Close()
|
|
||||||
return nil, fmt.Errorf("protocol version mismatch: host=%d sdk=%d", ProtocolVersion, hello.ProtocolVersion)
|
|
||||||
}
|
|
||||||
return &Conn{rawConn: rawConn, hello: hello}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) Close() error { return s.listener.Close() }
|
|
||||||
|
|
||||||
type Conn struct {
|
|
||||||
rawConn net.Conn
|
|
||||||
hello Message
|
|
||||||
nextID uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Conn) Hello() Message { return c.hello }
|
|
||||||
|
|
||||||
func (c *Conn) RemoteAddr() net.Addr { return c.rawConn.RemoteAddr() }
|
|
||||||
|
|
||||||
// Snapshot sends PAUSE with a fresh id and blocks until the SDK returns the
|
|
||||||
// matching STATE. The SDK's main thread stays paused until Release is called.
|
|
||||||
func (c *Conn) Snapshot(ctx context.Context) (Message, error) {
|
|
||||||
c.nextID++
|
|
||||||
id := c.nextID
|
|
||||||
|
|
||||||
if err := writeWithDeadline(ctx, c.rawConn, Pause(id)); err != nil {
|
|
||||||
return Message{}, fmt.Errorf("send pause: %w", err)
|
|
||||||
}
|
|
||||||
message, err := readWithDeadline(ctx, c.rawConn)
|
|
||||||
if err != nil {
|
|
||||||
return Message{}, fmt.Errorf("read state: %w", err)
|
|
||||||
}
|
|
||||||
if message.Type != MessageTypeState {
|
|
||||||
return Message{}, fmt.Errorf("expected STATE, got %q", message.Type)
|
|
||||||
}
|
|
||||||
if message.ID != id {
|
|
||||||
return Message{}, fmt.Errorf("state id mismatch: sent %d, got %d", id, message.ID)
|
|
||||||
}
|
|
||||||
return message, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release sends RESUME, freeing the SDK's paused main thread.
|
|
||||||
func (c *Conn) Release(ctx context.Context) error {
|
|
||||||
return writeWithDeadline(ctx, c.rawConn, Resume(c.nextID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close sends GOODBYE (best effort) and closes the underlying connection.
|
|
||||||
func (c *Conn) Close() error {
|
|
||||||
_ = writeWithDeadline(context.Background(), c.rawConn, Goodbye("shutdown"))
|
|
||||||
return c.rawConn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func readWithDeadline(ctx context.Context, conn net.Conn) (Message, error) {
|
|
||||||
if deadline, ok := ctx.Deadline(); ok {
|
|
||||||
_ = conn.SetReadDeadline(deadline)
|
|
||||||
}
|
|
||||||
done := make(chan struct{})
|
|
||||||
exited := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(exited)
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
_ = conn.SetReadDeadline(time.Unix(1, 0))
|
|
||||||
case <-done:
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
message, err := ReadMessage(conn)
|
|
||||||
close(done)
|
|
||||||
<-exited
|
|
||||||
_ = conn.SetReadDeadline(time.Time{})
|
|
||||||
if err != nil && ctx.Err() != nil {
|
|
||||||
return Message{}, ctx.Err()
|
|
||||||
}
|
|
||||||
return message, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeWithDeadline(ctx context.Context, conn net.Conn, message Message) error {
|
|
||||||
if deadline, ok := ctx.Deadline(); ok {
|
|
||||||
_ = conn.SetWriteDeadline(deadline)
|
|
||||||
defer conn.SetWriteDeadline(time.Time{})
|
|
||||||
}
|
|
||||||
err := WriteMessage(conn, message)
|
|
||||||
if err != nil && ctx.Err() != nil {
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func closeListenerOnCancel(ctx context.Context, listener net.Listener) (cancel func()) {
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
_ = listener.Close()
|
|
||||||
case <-done:
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return func() { close(done) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrClosed is returned when a Conn method is called after Close.
|
|
||||||
var ErrClosed = errors.New("agent: connection closed")
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeSDK drives the client side of an agent connection the way the real SDK
|
|
||||||
// would: HELLO on connect, then respond to PAUSE with STATE, honor RESUME,
|
|
||||||
// and close on GOODBYE.
|
|
||||||
type fakeSDK struct {
|
|
||||||
conn net.Conn
|
|
||||||
snapshotFunc func(id uint64) map[string]json.RawMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeSDK) sendHello(version, platform, appPackage string) error {
|
|
||||||
return WriteMessage(f.conn, Hello(version, platform, appPackage))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeSDK) serveOne() error {
|
|
||||||
message, err := ReadMessage(f.conn)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
switch message.Type {
|
|
||||||
case MessageTypePause:
|
|
||||||
snapshots := f.snapshotFunc(message.ID)
|
|
||||||
return WriteMessage(f.conn, State(message.ID, snapshots))
|
|
||||||
case MessageTypeResume:
|
|
||||||
return nil
|
|
||||||
case MessageTypeGoodbye:
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newLoopbackServer(t *testing.T) *Server {
|
|
||||||
t.Helper()
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { listener.Close() })
|
|
||||||
return NewServer(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_AcceptHandshake(t *testing.T) {
|
|
||||||
server := newLoopbackServer(t)
|
|
||||||
|
|
||||||
connectErr := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
client, err := net.Dial("tcp", server.Addr().String())
|
|
||||||
if err != nil {
|
|
||||||
connectErr <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sdk := &fakeSDK{conn: client}
|
|
||||||
connectErr <- sdk.sendHello("0.0.1", "android", "com.example")
|
|
||||||
}()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
conn, err := server.Accept(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Accept: %v", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
if got := conn.Hello(); got.Type != MessageTypeHello || got.Version != "0.0.1" || got.AppPackage != "com.example" {
|
|
||||||
t.Errorf("unexpected hello: %+v", got)
|
|
||||||
}
|
|
||||||
if err := <-connectErr; err != nil {
|
|
||||||
t.Fatalf("client side: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_SnapshotAndRelease(t *testing.T) {
|
|
||||||
server := newLoopbackServer(t)
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Go(func() {
|
|
||||||
client, err := net.Dial("tcp", server.Addr().String())
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("dial: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sdk := &fakeSDK{
|
|
||||||
conn: client,
|
|
||||||
snapshotFunc: func(id uint64) map[string]json.RawMessage {
|
|
||||||
return map[string]json.RawMessage{
|
|
||||||
"screen": json.RawMessage(`"home"`),
|
|
||||||
"ledger.balance": json.RawMessage(`1500`),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if err := sdk.sendHello("0.0.1", "android", "com.x"); err != nil {
|
|
||||||
t.Errorf("hello: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for range 2 {
|
|
||||||
if err := sdk.serveOne(); err != nil {
|
|
||||||
t.Errorf("pause: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := sdk.serveOne(); err != nil {
|
|
||||||
t.Errorf("resume: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
conn, err := server.Accept(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Accept: %v", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
for expected := uint64(1); expected <= 2; expected++ {
|
|
||||||
state, err := conn.Snapshot(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Snapshot #%d: %v", expected, err)
|
|
||||||
}
|
|
||||||
if state.ID != expected {
|
|
||||||
t.Errorf("snapshot #%d: id=%d", expected, state.ID)
|
|
||||||
}
|
|
||||||
if string(state.Snapshots["screen"]) != `"home"` {
|
|
||||||
t.Errorf("snapshot #%d: screen=%s", expected, state.Snapshots["screen"])
|
|
||||||
}
|
|
||||||
if err := conn.Release(ctx); err != nil {
|
|
||||||
t.Fatalf("Release #%d: %v", expected, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_AcceptRejectsProtocolVersionMismatch(t *testing.T) {
|
|
||||||
server := newLoopbackServer(t)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
client, err := net.Dial("tcp", server.Addr().String())
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
mismatched := Hello("0.0.1", "android", "com.x")
|
|
||||||
mismatched.ProtocolVersion = ProtocolVersion + 99
|
|
||||||
_ = WriteMessage(client, mismatched)
|
|
||||||
}()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
_, err := server.Accept(ctx)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "protocol version mismatch") {
|
|
||||||
t.Fatalf("expected protocol-version-mismatch error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_AcceptRequiresHello(t *testing.T) {
|
|
||||||
server := newLoopbackServer(t)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
client, err := net.Dial("tcp", server.Addr().String())
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
// Send a PAUSE instead of HELLO — server should reject.
|
|
||||||
_ = WriteMessage(client, Pause(1))
|
|
||||||
}()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
_, err := server.Accept(ctx)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "expected HELLO") {
|
|
||||||
t.Fatalf("expected HELLO-required error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_AcceptCancelsOnContext(t *testing.T) {
|
|
||||||
server := newLoopbackServer(t)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
acceptErr := make(chan error, 1)
|
|
||||||
go func() { _, err := server.Accept(ctx); acceptErr <- err }()
|
|
||||||
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-acceptErr:
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("expected error after cancel, got nil")
|
|
||||||
}
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Errorf("accept did not return after cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConn_SnapshotRejectsIDMismatch(t *testing.T) {
|
|
||||||
server := newLoopbackServer(t)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
client, _ := net.Dial("tcp", server.Addr().String())
|
|
||||||
defer client.Close()
|
|
||||||
_ = WriteMessage(client, Hello("0.0.1", "android", "com.x"))
|
|
||||||
// Read the PAUSE but respond with a wrong id.
|
|
||||||
msg, _ := ReadMessage(client)
|
|
||||||
_ = WriteMessage(client, State(msg.ID+99, map[string]json.RawMessage{}))
|
|
||||||
}()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
conn, err := server.Accept(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Accept: %v", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
_, err = conn.Snapshot(ctx)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "id mismatch") {
|
|
||||||
t.Errorf("expected id-mismatch error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConn_CloseSendsGoodbye(t *testing.T) {
|
|
||||||
server := newLoopbackServer(t)
|
|
||||||
|
|
||||||
received := make(chan Message, 1)
|
|
||||||
go func() {
|
|
||||||
client, _ := net.Dial("tcp", server.Addr().String())
|
|
||||||
defer client.Close()
|
|
||||||
_ = WriteMessage(client, Hello("0.0.1", "android", "com.x"))
|
|
||||||
// Drain until GOODBYE.
|
|
||||||
for {
|
|
||||||
msg, err := ReadMessage(client)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if msg.Type == MessageTypeGoodbye {
|
|
||||||
received <- msg
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
conn, err := server.Accept(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := conn.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case msg := <-received:
|
|
||||||
if msg.Reason != "shutdown" {
|
|
||||||
t.Errorf("expected reason=shutdown, got %q", msg.Reason)
|
|
||||||
}
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Error("client did not receive GOODBYE")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConn_SnapshotAfterAcceptContextCancel guards against a race in
|
|
||||||
// readWithDeadline where the watcher goroutine from Accept could clobber the
|
|
||||||
// conn's read deadline with a past time after Accept returned, causing the
|
|
||||||
// next read on the same conn (Snapshot) to time out instantly.
|
|
||||||
func TestConn_SnapshotAfterAcceptContextCancel(t *testing.T) {
|
|
||||||
for iteration := range 50 {
|
|
||||||
server := newLoopbackServer(t)
|
|
||||||
|
|
||||||
clientDone := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(clientDone)
|
|
||||||
client, err := net.Dial("tcp", server.Addr().String())
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
if err := WriteMessage(client, Hello("0.0.1", "android", "com.x")); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
msg, err := ReadMessage(client)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = WriteMessage(client, State(msg.ID, map[string]json.RawMessage{"ok": json.RawMessage(`true`)}))
|
|
||||||
}()
|
|
||||||
|
|
||||||
acceptCtx, acceptCancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
conn, err := server.Accept(acceptCtx)
|
|
||||||
acceptCancel()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("iteration %d: Accept: %v", iteration, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
snapCtx, snapCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
state, err := conn.Snapshot(snapCtx)
|
|
||||||
snapCancel()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("iteration %d: Snapshot: %v", iteration, err)
|
|
||||||
}
|
|
||||||
if string(state.Snapshots["ok"]) != `true` {
|
|
||||||
t.Errorf("iteration %d: unexpected snapshots: %v", iteration, state.Snapshots)
|
|
||||||
}
|
|
||||||
conn.Close()
|
|
||||||
<-clientDone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConn_SnapshotTimesOutIfSDKSilent(t *testing.T) {
|
|
||||||
server := newLoopbackServer(t)
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
t.Cleanup(func() { close(done) })
|
|
||||||
go func() {
|
|
||||||
client, _ := net.Dial("tcp", server.Addr().String())
|
|
||||||
defer client.Close()
|
|
||||||
_ = WriteMessage(client, Hello("0.0.1", "android", "com.x"))
|
|
||||||
// Never respond to PAUSE; stay alive until the test ends.
|
|
||||||
<-done
|
|
||||||
}()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
conn, err := server.Accept(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
fastCtx, fastCancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
|
||||||
defer fastCancel()
|
|
||||||
_, err = conn.Snapshot(fastCtx)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("expected timeout error, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
"github.com/priyanshujain/sanderling/internal/agent"
|
|
||||||
"github.com/priyanshujain/sanderling/internal/driver"
|
"github.com/priyanshujain/sanderling/internal/driver"
|
||||||
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||||
"github.com/priyanshujain/sanderling/internal/ltl"
|
"github.com/priyanshujain/sanderling/internal/ltl"
|
||||||
@@ -21,11 +20,9 @@ import (
|
|||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
SnapshotTimeout time.Duration
|
|
||||||
IdleTimeout time.Duration
|
IdleTimeout time.Duration
|
||||||
|
|
||||||
BundleID string
|
BundleID string
|
||||||
Connection *agent.Conn
|
|
||||||
Driver driver.DeviceDriver
|
Driver driver.DeviceDriver
|
||||||
Verifier *verifier.Verifier
|
Verifier *verifier.Verifier
|
||||||
TraceWriter *trace.Writer
|
TraceWriter *trace.Writer
|
||||||
@@ -44,10 +41,9 @@ type ViolationRecord struct {
|
|||||||
Properties []string
|
Properties []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run drives the snapshot/evaluate/release/act loop until the duration
|
// Run drives the evaluate/act loop until the duration elapses or the context
|
||||||
// elapses or the context is canceled. The caller is responsible for
|
// is canceled. The caller is responsible for launching the app before Run is
|
||||||
// launching the app and connecting the SDK before Run is called, and for
|
// called and for terminating it afterwards.
|
||||||
// terminating the app afterwards.
|
|
||||||
func Run(ctx context.Context, options Options) (Summary, error) {
|
func Run(ctx context.Context, options Options) (Summary, error) {
|
||||||
if err := validate(options); err != nil {
|
if err := validate(options); err != nil {
|
||||||
return Summary{}, err
|
return Summary{}, err
|
||||||
@@ -71,10 +67,8 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
stepIndex++
|
stepIndex++
|
||||||
stepStart := time.Now()
|
stepStart := time.Now()
|
||||||
|
|
||||||
// Hierarchy, metrics, and logs are independent device reads. Run
|
// Hierarchy, metrics, and logs are independent device reads — run
|
||||||
// them concurrently so metrics+logs hide behind the hierarchy
|
// them concurrently so metrics+logs hide behind the hierarchy fetch.
|
||||||
// fetch (~2s). All three must finish before snapshotStep pauses
|
|
||||||
// the SDK.
|
|
||||||
var tree *hierarchy.Tree
|
var tree *hierarchy.Tree
|
||||||
var hierarchyErr error
|
var hierarchyErr error
|
||||||
var metrics *trace.Metrics
|
var metrics *trace.Metrics
|
||||||
@@ -115,31 +109,20 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
if tree != nil {
|
if tree != nil {
|
||||||
treeSize = len(tree.Elements)
|
treeSize = len(tree.Elements)
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot, err := snapshotStep(ctx, options)
|
|
||||||
if err != nil {
|
|
||||||
return summary, fmt.Errorf("step %d snapshot: %w", stepIndex, err)
|
|
||||||
}
|
|
||||||
lastLogTime = stepStart
|
lastLogTime = stepStart
|
||||||
|
|
||||||
exceptions := decodeExceptions(snapshot)
|
|
||||||
|
|
||||||
if err := options.Verifier.PushSnapshot(verifier.SnapshotInput{
|
if err := options.Verifier.PushSnapshot(verifier.SnapshotInput{
|
||||||
Snapshots: verifier.Snapshots(snapshot.Snapshots),
|
|
||||||
Tree: tree,
|
Tree: tree,
|
||||||
LastAction: lastAction,
|
LastAction: lastAction,
|
||||||
StepTime: stepStart,
|
StepTime: stepStart,
|
||||||
RunStart: summary.StartTime,
|
RunStart: summary.StartTime,
|
||||||
Logs: logs,
|
Logs: logs,
|
||||||
Exceptions: exceptions,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return summary, fmt.Errorf("step %d push: %w", stepIndex, err)
|
return summary, fmt.Errorf("step %d push: %w", stepIndex, err)
|
||||||
}
|
}
|
||||||
screen, screenErr := screenFromSnapshot(snapshot.Snapshots)
|
|
||||||
if screenErr != nil {
|
screen := ""
|
||||||
logger.Warn("screen snapshot decode failed", "step", stepIndex, "err", screenErr)
|
if tree != nil && len(tree.Elements) > 0 {
|
||||||
}
|
|
||||||
if screen == "" && tree != nil && len(tree.Elements) > 0 {
|
|
||||||
screen = tree.Elements[0].Screen
|
screen = tree.Elements[0].Screen
|
||||||
}
|
}
|
||||||
logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize)
|
logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize)
|
||||||
@@ -168,9 +151,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
Index: stepIndex,
|
Index: stepIndex,
|
||||||
Timestamp: stepStart,
|
Timestamp: stepStart,
|
||||||
Screen: screen,
|
Screen: screen,
|
||||||
Snapshots: snapshot.Snapshots,
|
|
||||||
Action: traceAction,
|
Action: traceAction,
|
||||||
Exceptions: traceExceptions(exceptions),
|
|
||||||
Violations: violations,
|
Violations: violations,
|
||||||
Hierarchy: tree,
|
Hierarchy: tree,
|
||||||
Residuals: residuals,
|
Residuals: residuals,
|
||||||
@@ -188,12 +169,6 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.Connection != nil {
|
|
||||||
if err := options.Connection.Release(ctx); err != nil {
|
|
||||||
return summary, fmt.Errorf("step %d release: %w", stepIndex, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if nextErr == nil {
|
if nextErr == nil {
|
||||||
if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil {
|
if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil {
|
||||||
if isWDADrop(err) {
|
if isWDADrop(err) {
|
||||||
@@ -240,28 +215,12 @@ func validate(options Options) error {
|
|||||||
if options.Duration <= 0 {
|
if options.Duration <= 0 {
|
||||||
return errors.New("runner: Duration must be positive")
|
return errors.New("runner: Duration must be positive")
|
||||||
}
|
}
|
||||||
if options.SnapshotTimeout <= 0 {
|
|
||||||
options.SnapshotTimeout = 5 * time.Second
|
|
||||||
}
|
|
||||||
if options.IdleTimeout <= 0 {
|
if options.IdleTimeout <= 0 {
|
||||||
options.IdleTimeout = 2 * time.Second
|
options.IdleTimeout = 2 * time.Second
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func snapshotStep(ctx context.Context, options Options) (agent.Message, error) {
|
|
||||||
if options.Connection == nil {
|
|
||||||
return agent.Message{}, nil
|
|
||||||
}
|
|
||||||
snapshotTimeout := options.SnapshotTimeout
|
|
||||||
if snapshotTimeout <= 0 {
|
|
||||||
snapshotTimeout = 5 * time.Second
|
|
||||||
}
|
|
||||||
snapshotCtx, snapshotCancel := context.WithTimeout(ctx, snapshotTimeout)
|
|
||||||
defer snapshotCancel()
|
|
||||||
return options.Connection.Snapshot(snapshotCtx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func violationNames(verdicts map[string]ltl.Verdict) []string {
|
func violationNames(verdicts map[string]ltl.Verdict) []string {
|
||||||
var names []string
|
var names []string
|
||||||
for name, verdict := range verdicts {
|
for name, verdict := range verdicts {
|
||||||
@@ -272,18 +231,6 @@ func violationNames(verdicts map[string]ltl.Verdict) []string {
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
func screenFromSnapshot(snapshots map[string]json.RawMessage) (string, error) {
|
|
||||||
raw, ok := snapshots["screen"]
|
|
||||||
if !ok {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
var screen string
|
|
||||||
if err := json.Unmarshal(raw, &screen); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return screen, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree) error {
|
func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree) error {
|
||||||
switch action.Kind {
|
switch action.Kind {
|
||||||
case verifier.ActionKindTap:
|
case verifier.ActionKindTap:
|
||||||
@@ -355,22 +302,6 @@ func collectLogs(ctx context.Context, drv driver.DeviceDriver, since time.Time)
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeExceptions(snapshot agent.Message) []verifier.Exception {
|
|
||||||
if len(snapshot.Exceptions) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result := make([]verifier.Exception, 0, len(snapshot.Exceptions))
|
|
||||||
for _, e := range snapshot.Exceptions {
|
|
||||||
result = append(result, verifier.Exception{
|
|
||||||
Class: e.Class,
|
|
||||||
Message: e.Message,
|
|
||||||
StackTrace: e.StackTrace,
|
|
||||||
UnixMillis: e.UnixMillis,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, bool) {
|
func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, bool) {
|
||||||
if action.X > 0 && action.Y > 0 {
|
if action.X > 0 && action.Y > 0 {
|
||||||
return action.X, action.Y, true
|
return action.X, action.Y, true
|
||||||
@@ -421,9 +352,7 @@ func traceActionFor(action verifier.Action, tree *hierarchy.Tree) *trace.Action
|
|||||||
}
|
}
|
||||||
|
|
||||||
// stampSelectorTarget mirrors applyAction's coordinate-resolution rule so the
|
// stampSelectorTarget mirrors applyAction's coordinate-resolution rule so the
|
||||||
// trace records the same point the runner taps. When the spec passed an ax
|
// trace records the same point the runner taps.
|
||||||
// element directly, action.X/Y are already populated and we use them; when the
|
|
||||||
// spec passed a string selector, we resolve it against the captured hierarchy.
|
|
||||||
func stampSelectorTarget(traceAction *trace.Action, action verifier.Action, tree *hierarchy.Tree) {
|
func stampSelectorTarget(traceAction *trace.Action, action verifier.Action, tree *hierarchy.Tree) {
|
||||||
if action.X > 0 && action.Y > 0 {
|
if action.X > 0 && action.Y > 0 {
|
||||||
traceAction.TapPoint = &trace.PointRecord{X: action.X, Y: action.Y}
|
traceAction.TapPoint = &trace.PointRecord{X: action.X, Y: action.Y}
|
||||||
@@ -512,19 +441,3 @@ func isWDADrop(err error) bool {
|
|||||||
return strings.Contains(msg, "ConnectException") ||
|
return strings.Contains(msg, "ConnectException") ||
|
||||||
(strings.Contains(msg, "code = Internal") && strings.Contains(msg, "SocketException"))
|
(strings.Contains(msg, "code = Internal") && strings.Contains(msg, "SocketException"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func traceExceptions(exceptions []verifier.Exception) []trace.Exception {
|
|
||||||
if len(exceptions) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result := make([]trace.Exception, 0, len(exceptions))
|
|
||||||
for _, e := range exceptions {
|
|
||||||
result = append(result, trace.Exception{
|
|
||||||
Class: e.Class,
|
|
||||||
Message: e.Message,
|
|
||||||
StackTrace: e.StackTrace,
|
|
||||||
UnixMillis: e.UnixMillis,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
+18
-195
@@ -3,20 +3,16 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/priyanshujain/sanderling/internal/agent"
|
|
||||||
"github.com/priyanshujain/sanderling/internal/driver"
|
"github.com/priyanshujain/sanderling/internal/driver"
|
||||||
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
|
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
|
||||||
"github.com/priyanshujain/sanderling/internal/trace"
|
"github.com/priyanshujain/sanderling/internal/trace"
|
||||||
@@ -24,7 +20,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const fixtureSpec = `
|
const fixtureSpec = `
|
||||||
const screen = __sanderling__.extract(state => state.snapshots.screen ?? "");
|
|
||||||
const balance = __sanderling__.extract(state => state.snapshots.balance ?? 0);
|
const balance = __sanderling__.extract(state => state.snapshots.balance ?? 0);
|
||||||
globalThis.properties = {
|
globalThis.properties = {
|
||||||
balanceNonNegative: __sanderling__.always(() => balance.current >= 0),
|
balanceNonNegative: __sanderling__.always(() => balance.current >= 0),
|
||||||
@@ -32,28 +27,25 @@ globalThis.properties = {
|
|||||||
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:next" })]);
|
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:next" })]);
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const violationSpec = `
|
||||||
|
globalThis.properties = {
|
||||||
|
balanceNonNegative: __sanderling__.always(() => false),
|
||||||
|
};
|
||||||
|
globalThis.actions = __sanderling__.actions(() => []);
|
||||||
|
`
|
||||||
|
|
||||||
type harness struct {
|
type harness struct {
|
||||||
server *agent.Server
|
|
||||||
listener net.Listener
|
|
||||||
clientWG sync.WaitGroup
|
|
||||||
conn *agent.Conn
|
|
||||||
mock *mockdriver.Driver
|
mock *mockdriver.Driver
|
||||||
verifier *verifier.Verifier
|
verifier *verifier.Verifier
|
||||||
writer *trace.Writer
|
writer *trace.Writer
|
||||||
snapshot []map[string]json.RawMessage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHarness(t *testing.T, snapshots []map[string]json.RawMessage) *harness {
|
func newHarness(t *testing.T) *harness {
|
||||||
return newHarnessWithSpec(t, snapshots, fixtureSpec)
|
return newHarnessWithSpec(t, fixtureSpec)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHarnessWithSpec(t *testing.T, snapshots []map[string]json.RawMessage, spec string) *harness {
|
func newHarnessWithSpec(t *testing.T, spec string) *harness {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
server := agent.NewServer(listener)
|
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
writer, err := trace.NewWriter(directory)
|
writer, err := trace.NewWriter(directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -67,81 +59,22 @@ func newHarnessWithSpec(t *testing.T, snapshots []map[string]json.RawMessage, sp
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
state := &harness{
|
state := &harness{
|
||||||
server: server,
|
|
||||||
listener: listener,
|
|
||||||
mock: mockdriver.New(),
|
mock: mockdriver.New(),
|
||||||
verifier: verifierInstance,
|
verifier: verifierInstance,
|
||||||
writer: writer,
|
writer: writer,
|
||||||
snapshot: snapshots,
|
|
||||||
}
|
}
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() { _ = writer.Close() })
|
||||||
_ = listener.Close()
|
|
||||||
_ = writer.Close()
|
|
||||||
})
|
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *harness) startSDK(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
h.clientWG.Go(func() {
|
|
||||||
conn, err := net.Dial("tcp", h.listener.Addr().String())
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("dial: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
if err := agent.WriteMessage(conn, agent.Hello("0.0.1", "android", "com.fixture")); err != nil {
|
|
||||||
t.Errorf("hello: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
index := 0
|
|
||||||
for {
|
|
||||||
message, err := agent.ReadMessage(conn)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if message.Type == agent.MessageTypePause {
|
|
||||||
snapshots := map[string]json.RawMessage{}
|
|
||||||
if index < len(h.snapshot) {
|
|
||||||
snapshots = h.snapshot[index]
|
|
||||||
}
|
|
||||||
if err := agent.WriteMessage(conn, agent.State(message.ID, snapshots)); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *harness) acceptConnection(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
connection, err := h.server.Accept(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Accept: %v", err)
|
|
||||||
}
|
|
||||||
h.conn = connection
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunner_HappyPathStepsAndTraces(t *testing.T) {
|
func TestRunner_HappyPathStepsAndTraces(t *testing.T) {
|
||||||
snapshots := []map[string]json.RawMessage{
|
state := newHarness(t)
|
||||||
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`100`)},
|
|
||||||
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`200`)},
|
|
||||||
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`300`)},
|
|
||||||
}
|
|
||||||
state := newHarness(t, snapshots)
|
|
||||||
state.startSDK(t)
|
|
||||||
state.acceptConnection(t)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
summary, err := Run(ctx, Options{
|
summary, err := Run(ctx, Options{
|
||||||
Duration: 100 * time.Millisecond,
|
Duration: 100 * time.Millisecond,
|
||||||
SnapshotTimeout: 2 * time.Second,
|
|
||||||
IdleTimeout: 50 * time.Millisecond,
|
IdleTimeout: 50 * time.Millisecond,
|
||||||
Connection: state.conn,
|
|
||||||
Driver: state.mock,
|
Driver: state.mock,
|
||||||
Verifier: state.verifier,
|
Verifier: state.verifier,
|
||||||
TraceWriter: state.writer,
|
TraceWriter: state.writer,
|
||||||
@@ -163,22 +96,13 @@ func TestRunner_HappyPathStepsAndTraces(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunner_ViolationSurfacesInSummary(t *testing.T) {
|
func TestRunner_ViolationSurfacesInSummary(t *testing.T) {
|
||||||
snapshots := []map[string]json.RawMessage{
|
state := newHarnessWithSpec(t, violationSpec)
|
||||||
{"balance": json.RawMessage(`100`)},
|
|
||||||
{"balance": json.RawMessage(`-1`)},
|
|
||||||
{"balance": json.RawMessage(`50`)},
|
|
||||||
}
|
|
||||||
state := newHarness(t, snapshots)
|
|
||||||
state.startSDK(t)
|
|
||||||
state.acceptConnection(t)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
summary, err := Run(ctx, Options{
|
summary, err := Run(ctx, Options{
|
||||||
Duration: 100 * time.Millisecond,
|
Duration: 100 * time.Millisecond,
|
||||||
SnapshotTimeout: 2 * time.Second,
|
|
||||||
IdleTimeout: 50 * time.Millisecond,
|
IdleTimeout: 50 * time.Millisecond,
|
||||||
Connection: state.conn,
|
|
||||||
Driver: state.mock,
|
Driver: state.mock,
|
||||||
Verifier: state.verifier,
|
Verifier: state.verifier,
|
||||||
TraceWriter: state.writer,
|
TraceWriter: state.writer,
|
||||||
@@ -201,9 +125,7 @@ globalThis.properties = {
|
|||||||
};
|
};
|
||||||
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:next" })]);
|
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:next" })]);
|
||||||
`
|
`
|
||||||
state := newHarnessWithSpec(t, []map[string]json.RawMessage{{}, {}}, throwingSpec)
|
state := newHarnessWithSpec(t, throwingSpec)
|
||||||
state.startSDK(t)
|
|
||||||
state.acceptConnection(t)
|
|
||||||
|
|
||||||
var buffer bytes.Buffer
|
var buffer bytes.Buffer
|
||||||
logger := slog.New(slog.NewTextHandler(&buffer, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
logger := slog.New(slog.NewTextHandler(&buffer, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||||
@@ -212,9 +134,7 @@ globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
summary, err := Run(ctx, Options{
|
summary, err := Run(ctx, Options{
|
||||||
Duration: 100 * time.Millisecond,
|
Duration: 100 * time.Millisecond,
|
||||||
SnapshotTimeout: 2 * time.Second,
|
|
||||||
IdleTimeout: 50 * time.Millisecond,
|
IdleTimeout: 50 * time.Millisecond,
|
||||||
Connection: state.conn,
|
|
||||||
Driver: state.mock,
|
Driver: state.mock,
|
||||||
Verifier: state.verifier,
|
Verifier: state.verifier,
|
||||||
TraceWriter: state.writer,
|
TraceWriter: state.writer,
|
||||||
@@ -240,7 +160,6 @@ func TestRunner_RejectsMissingFields(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunner_RejectsZeroDuration(t *testing.T) {
|
func TestRunner_RejectsZeroDuration(t *testing.T) {
|
||||||
_, err := Run(context.Background(), Options{
|
_, err := Run(context.Background(), Options{
|
||||||
Connection: &agent.Conn{},
|
|
||||||
Driver: mockdriver.New(),
|
Driver: mockdriver.New(),
|
||||||
Verifier: mustNewVerifier(t),
|
Verifier: mustNewVerifier(t),
|
||||||
TraceWriter: mustNewTraceWriter(t),
|
TraceWriter: mustNewTraceWriter(t),
|
||||||
@@ -250,86 +169,15 @@ func TestRunner_RejectsZeroDuration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunner_RecordsScreenFieldFromSnapshot(t *testing.T) {
|
|
||||||
snapshots := []map[string]json.RawMessage{
|
|
||||||
{"screen": json.RawMessage(`"customer_ledger"`), "balance": json.RawMessage(`1`)},
|
|
||||||
}
|
|
||||||
state := newHarness(t, snapshots)
|
|
||||||
state.startSDK(t)
|
|
||||||
state.acceptConnection(t)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if _, err := Run(ctx, Options{
|
|
||||||
Duration: 100 * time.Millisecond,
|
|
||||||
SnapshotTimeout: 2 * time.Second,
|
|
||||||
IdleTimeout: 50 * time.Millisecond,
|
|
||||||
Connection: state.conn,
|
|
||||||
Driver: state.mock,
|
|
||||||
Verifier: state.verifier,
|
|
||||||
TraceWriter: state.writer,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(body), `"screen":"customer_ledger"`) {
|
|
||||||
t.Errorf("screen field not in trace: %s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScreenFromSnapshot(t *testing.T) {
|
|
||||||
t.Run("string value returns screen", func(t *testing.T) {
|
|
||||||
snapshots := map[string]json.RawMessage{"screen": json.RawMessage(`"home"`)}
|
|
||||||
screen, err := screenFromSnapshot(snapshots)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if screen != "home" {
|
|
||||||
t.Errorf("screen = %q, want %q", screen, "home")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("missing key returns empty with no error", func(t *testing.T) {
|
|
||||||
screen, err := screenFromSnapshot(map[string]json.RawMessage{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if screen != "" {
|
|
||||||
t.Errorf("screen = %q, want empty", screen)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("non-string value returns error", func(t *testing.T) {
|
|
||||||
snapshots := map[string]json.RawMessage{"screen": json.RawMessage(`{"nested":1}`)}
|
|
||||||
screen, err := screenFromSnapshot(snapshots)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error for non-string screen, got nil")
|
|
||||||
}
|
|
||||||
if screen != "" {
|
|
||||||
t.Errorf("screen = %q, want empty on error", screen)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunner_StampsHierarchyResolvedBoundsAndResiduals(t *testing.T) {
|
func TestRunner_StampsHierarchyResolvedBoundsAndResiduals(t *testing.T) {
|
||||||
snapshots := []map[string]json.RawMessage{
|
state := newHarness(t)
|
||||||
{"balance": json.RawMessage(`100`)},
|
|
||||||
{"balance": json.RawMessage(`200`)},
|
|
||||||
}
|
|
||||||
state := newHarness(t, snapshots)
|
|
||||||
state.startSDK(t)
|
|
||||||
state.acceptConnection(t)
|
|
||||||
|
|
||||||
state.mock.HierarchyJSON = `{"attributes":{"resource-id":"com.fixture:id/next","bounds":"[40,80,240,160]"},"children":[],"clickable":true,"enabled":true}`
|
state.mock.HierarchyJSON = `{"attributes":{"resource-id":"com.fixture:id/next","bounds":"[40,80,240,160]"},"children":[],"clickable":true,"enabled":true}`
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if _, err := Run(ctx, Options{
|
if _, err := Run(ctx, Options{
|
||||||
Duration: 100 * time.Millisecond,
|
Duration: 100 * time.Millisecond,
|
||||||
SnapshotTimeout: 2 * time.Second,
|
|
||||||
IdleTimeout: 50 * time.Millisecond,
|
IdleTimeout: 50 * time.Millisecond,
|
||||||
Connection: state.conn,
|
|
||||||
Driver: state.mock,
|
Driver: state.mock,
|
||||||
Verifier: state.verifier,
|
Verifier: state.verifier,
|
||||||
TraceWriter: state.writer,
|
TraceWriter: state.writer,
|
||||||
@@ -359,12 +207,7 @@ func TestRunner_StampsHierarchyResolvedBoundsAndResiduals(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) {
|
func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) {
|
||||||
snapshots := []map[string]json.RawMessage{
|
state := newHarness(t)
|
||||||
{"balance": json.RawMessage(`100`)},
|
|
||||||
}
|
|
||||||
state := newHarness(t, snapshots)
|
|
||||||
state.startSDK(t)
|
|
||||||
state.acceptConnection(t)
|
|
||||||
state.mock.Failures[mockdriver.ActionWaitForIdle] = errors.New("sidecar lost gRPC stream")
|
state.mock.Failures[mockdriver.ActionWaitForIdle] = errors.New("sidecar lost gRPC stream")
|
||||||
|
|
||||||
var logBuf bytes.Buffer
|
var logBuf bytes.Buffer
|
||||||
@@ -374,9 +217,7 @@ func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
if _, err := Run(ctx, Options{
|
if _, err := Run(ctx, Options{
|
||||||
Duration: 100 * time.Millisecond,
|
Duration: 100 * time.Millisecond,
|
||||||
SnapshotTimeout: 2 * time.Second,
|
|
||||||
IdleTimeout: 50 * time.Millisecond,
|
IdleTimeout: 50 * time.Millisecond,
|
||||||
Connection: state.conn,
|
|
||||||
Driver: state.mock,
|
Driver: state.mock,
|
||||||
Verifier: state.verifier,
|
Verifier: state.verifier,
|
||||||
TraceWriter: state.writer,
|
TraceWriter: state.writer,
|
||||||
@@ -423,25 +264,18 @@ func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) {
|
func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) {
|
||||||
snapshots := []map[string]json.RawMessage{
|
state := newHarness(t)
|
||||||
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`100`)},
|
|
||||||
}
|
|
||||||
state := newHarness(t, snapshots)
|
|
||||||
state.mock.MetricsData = driver.Metrics{CPUPercent: 5.0, HeapBytes: 1024, TotalMemoryBytes: 4096}
|
state.mock.MetricsData = driver.Metrics{CPUPercent: 5.0, HeapBytes: 1024, TotalMemoryBytes: 4096}
|
||||||
state.mock.LogEntries = []driver.LogEntry{
|
state.mock.LogEntries = []driver.LogEntry{
|
||||||
{UnixMillis: 1000, Level: "E", Tag: "test", Message: "boom"},
|
{UnixMillis: 1000, Level: "E", Tag: "test", Message: "boom"},
|
||||||
}
|
}
|
||||||
state.startSDK(t)
|
|
||||||
state.acceptConnection(t)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_, err := Run(ctx, Options{
|
_, err := Run(ctx, Options{
|
||||||
Duration: 100 * time.Millisecond,
|
Duration: 100 * time.Millisecond,
|
||||||
SnapshotTimeout: 2 * time.Second,
|
|
||||||
IdleTimeout: 50 * time.Millisecond,
|
IdleTimeout: 50 * time.Millisecond,
|
||||||
BundleID: "com.fixture",
|
BundleID: "com.fixture",
|
||||||
Connection: state.conn,
|
|
||||||
Driver: state.mock,
|
Driver: state.mock,
|
||||||
Verifier: state.verifier,
|
Verifier: state.verifier,
|
||||||
TraceWriter: state.writer,
|
TraceWriter: state.writer,
|
||||||
@@ -474,23 +308,14 @@ func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunner_PipelinedPostScreenshotWritten(t *testing.T) {
|
func TestRunner_PipelinedPostScreenshotWritten(t *testing.T) {
|
||||||
snapshots := []map[string]json.RawMessage{
|
state := newHarness(t)
|
||||||
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`100`)},
|
|
||||||
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`200`)},
|
|
||||||
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`300`)},
|
|
||||||
}
|
|
||||||
state := newHarness(t, snapshots)
|
|
||||||
state.mock.ImageData = driver.Image{PNG: []byte("fakepng"), Width: 100, Height: 200}
|
state.mock.ImageData = driver.Image{PNG: []byte("fakepng"), Width: 100, Height: 200}
|
||||||
state.startSDK(t)
|
|
||||||
state.acceptConnection(t)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
summary, err := Run(ctx, Options{
|
summary, err := Run(ctx, Options{
|
||||||
Duration: 200 * time.Millisecond,
|
Duration: 200 * time.Millisecond,
|
||||||
SnapshotTimeout: 2 * time.Second,
|
|
||||||
IdleTimeout: 50 * time.Millisecond,
|
IdleTimeout: 50 * time.Millisecond,
|
||||||
Connection: state.conn,
|
|
||||||
Driver: state.mock,
|
Driver: state.mock,
|
||||||
Verifier: state.verifier,
|
Verifier: state.verifier,
|
||||||
TraceWriter: state.writer,
|
TraceWriter: state.writer,
|
||||||
@@ -509,13 +334,11 @@ func TestRunner_PipelinedPostScreenshotWritten(t *testing.T) {
|
|||||||
t.Errorf("expected pre-screenshot for step 1: %s", preFile)
|
t.Errorf("expected pre-screenshot for step 1: %s", preFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1's post-screenshot is pipelined into step 2's errgroup
|
|
||||||
postFile := filepath.Join(screenshotDir, "step-00001-after.png")
|
postFile := filepath.Join(screenshotDir, "step-00001-after.png")
|
||||||
if _, err := os.Stat(postFile); os.IsNotExist(err) {
|
if _, err := os.Stat(postFile); os.IsNotExist(err) {
|
||||||
t.Errorf("expected pipelined post-screenshot for step 1: %s", postFile)
|
t.Errorf("expected pipelined post-screenshot for step 1: %s", postFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last step's post-screenshot is flushed after the loop
|
|
||||||
lastAfter := filepath.Join(screenshotDir, fmt.Sprintf("step-%05d-after.png", summary.Steps))
|
lastAfter := filepath.Join(screenshotDir, fmt.Sprintf("step-%05d-after.png", summary.Steps))
|
||||||
if _, err := os.Stat(lastAfter); os.IsNotExist(err) {
|
if _, err := os.Stat(lastAfter); os.IsNotExist(err) {
|
||||||
t.Errorf("expected flushed post-screenshot for last step %d: %s", summary.Steps, lastAfter)
|
t.Errorf("expected flushed post-screenshot for last step %d: %s", summary.Steps, lastAfter)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -80,3 +81,12 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
|||||||
}
|
}
|
||||||
return driverClient, cleanup, nil
|
return driverClient, cleanup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pickFreePort() (int, error) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
return listener.Addr().(*net.TCPAddr).Port, nil
|
||||||
|
}
|
||||||
+2
-112
@@ -5,13 +5,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/priyanshujain/sanderling/internal/agent"
|
|
||||||
"github.com/priyanshujain/sanderling/internal/android"
|
"github.com/priyanshujain/sanderling/internal/android"
|
||||||
"github.com/priyanshujain/sanderling/internal/bundler"
|
"github.com/priyanshujain/sanderling/internal/bundler"
|
||||||
"github.com/priyanshujain/sanderling/internal/ios"
|
"github.com/priyanshujain/sanderling/internal/ios"
|
||||||
@@ -20,11 +17,7 @@ import (
|
|||||||
"github.com/priyanshujain/sanderling/internal/verifier"
|
"github.com/priyanshujain/sanderling/internal/verifier"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const sidecarStartupTimeout = 30 * time.Second
|
||||||
socketName = "sanderling-agent"
|
|
||||||
sidecarStartupTimeout = 30 * time.Second
|
|
||||||
sdkAcceptTimeout = 60 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// Options are the parameters for a single test pipeline run.
|
// Options are the parameters for a single test pipeline run.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
@@ -38,7 +31,7 @@ type Options struct {
|
|||||||
Output string
|
Output string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute runs the full test pipeline: bundle, connect SDK, verify properties.
|
// Execute runs the full test pipeline: bundle, launch app, verify properties.
|
||||||
func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
||||||
switch options.Platform {
|
switch options.Platform {
|
||||||
case "android":
|
case "android":
|
||||||
@@ -75,101 +68,9 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
|||||||
}
|
}
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
var connection *agent.Conn
|
|
||||||
|
|
||||||
switch options.Platform {
|
|
||||||
case "android":
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("agent listener: %w", err)
|
|
||||||
}
|
|
||||||
defer listener.Close()
|
|
||||||
agentPort := listener.Addr().(*net.TCPAddr).Port
|
|
||||||
|
|
||||||
if err := android.AdbReverse(socketName, agentPort); err != nil {
|
|
||||||
return fmt.Errorf("adb reverse: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := android.AdbReverseRemove(socketName); err != nil {
|
|
||||||
fmt.Fprintf(stdout, "warning: adb reverse cleanup: %v\n", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
fmt.Fprintf(stdout, "forwarded localabstract:%s -> tcp:%d\n", socketName, agentPort)
|
|
||||||
|
|
||||||
agentServer := agent.NewServer(listener)
|
|
||||||
|
|
||||||
type acceptResult struct {
|
|
||||||
conn *agent.Conn
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
acceptChannel := make(chan acceptResult, 1)
|
|
||||||
go func() {
|
|
||||||
acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout)
|
|
||||||
defer cancel()
|
|
||||||
conn, acceptErr := agentServer.Accept(acceptCtx)
|
|
||||||
acceptChannel <- acceptResult{conn: conn, err: acceptErr}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err := activeDriver.Launch(ctx, options.BundleID, false, nil); err != nil {
|
if err := activeDriver.Launch(ctx, options.BundleID, false, nil); err != nil {
|
||||||
return fmt.Errorf("launch app: %w", err)
|
return fmt.Errorf("launch app: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.BundleID, sdkAcceptTimeout.Seconds())
|
|
||||||
|
|
||||||
result := <-acceptChannel
|
|
||||||
if result.err != nil {
|
|
||||||
return fmt.Errorf("accept SDK: %w", result.err)
|
|
||||||
}
|
|
||||||
connection = result.conn
|
|
||||||
defer connection.Close()
|
|
||||||
hello := connection.Hello()
|
|
||||||
fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version)
|
|
||||||
|
|
||||||
case "ios":
|
|
||||||
// iOS simulator shares the Mac's loopback — no port forwarding needed.
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("agent listener: %w", err)
|
|
||||||
}
|
|
||||||
defer listener.Close()
|
|
||||||
agentPort := listener.Addr().(*net.TCPAddr).Port
|
|
||||||
|
|
||||||
agentServer := agent.NewServer(listener)
|
|
||||||
|
|
||||||
type acceptResult struct {
|
|
||||||
conn *agent.Conn
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
acceptChannel := make(chan acceptResult, 1)
|
|
||||||
go func() {
|
|
||||||
acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout)
|
|
||||||
defer cancel()
|
|
||||||
conn, acceptErr := agentServer.Accept(acceptCtx)
|
|
||||||
acceptChannel <- acceptResult{conn: conn, err: acceptErr}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Launch app via XCTest with SANDERLING_PORT so the SDK can connect.
|
|
||||||
if err := activeDriver.Launch(ctx, options.BundleID, false, map[string]string{
|
|
||||||
"SANDERLING_PORT": strconv.Itoa(agentPort),
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("launch app: %w", err)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(stdout, "iOS app launched with SANDERLING_PORT=%d; waiting for SDK (%.0fs timeout)\n", agentPort, sdkAcceptTimeout.Seconds())
|
|
||||||
|
|
||||||
result := <-acceptChannel
|
|
||||||
if result.err != nil {
|
|
||||||
return fmt.Errorf("accept SDK: %w", result.err)
|
|
||||||
}
|
|
||||||
connection = result.conn
|
|
||||||
defer connection.Close()
|
|
||||||
hello := connection.Hello()
|
|
||||||
fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version)
|
|
||||||
|
|
||||||
case "web":
|
|
||||||
fmt.Fprintln(stdout, "web mode: skipping SDK")
|
|
||||||
if err := activeDriver.Launch(ctx, options.BundleID, false, nil); err != nil {
|
|
||||||
return fmt.Errorf("launch app: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
seed := options.Seed
|
seed := options.Seed
|
||||||
if seed == 0 {
|
if seed == 0 {
|
||||||
@@ -212,10 +113,8 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
|||||||
fmt.Fprintf(stdout, "running for %s (seed=%d)\n", options.Duration, seed)
|
fmt.Fprintf(stdout, "running for %s (seed=%d)\n", options.Duration, seed)
|
||||||
summary, err := runner.Run(ctx, runner.Options{
|
summary, err := runner.Run(ctx, runner.Options{
|
||||||
Duration: options.Duration,
|
Duration: options.Duration,
|
||||||
SnapshotTimeout: 5 * time.Second,
|
|
||||||
IdleTimeout: 1 * time.Second,
|
IdleTimeout: 1 * time.Second,
|
||||||
BundleID: options.BundleID,
|
BundleID: options.BundleID,
|
||||||
Connection: connection,
|
|
||||||
Driver: activeDriver,
|
Driver: activeDriver,
|
||||||
Verifier: verifierInstance,
|
Verifier: verifierInstance,
|
||||||
TraceWriter: traceWriter,
|
TraceWriter: traceWriter,
|
||||||
@@ -269,12 +168,3 @@ func resolveSpecAPIPath(specPath string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func pickFreePort() (int, error) {
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer listener.Close()
|
|
||||||
return listener.Addr().(*net.TCPAddr).Port, nil
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,13 @@ export type Snapshots = Record<string, unknown>;
|
|||||||
export interface AccessibilityElement {
|
export interface AccessibilityElement {
|
||||||
id?: string;
|
id?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
desc?: string;
|
||||||
|
class?: string;
|
||||||
|
clickable?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
checked?: boolean;
|
||||||
|
focused?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
bounds?: { left: number; top: number; right: number; bottom: number };
|
bounds?: { left: number; top: number; right: number; bottom: number };
|
||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import com.vanniktech.maven.publish.AndroidSingleVariantLibrary
|
|
||||||
import com.vanniktech.maven.publish.JavadocJar
|
|
||||||
import com.vanniktech.maven.publish.SourcesJar
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id("com.android.library") version "8.13.0"
|
|
||||||
kotlin("android") version "2.1.21"
|
|
||||||
id("com.vanniktech.maven.publish") version "0.36.0"
|
|
||||||
id("org.jetbrains.dokka") version "2.2.0"
|
|
||||||
id("org.jetbrains.dokka-javadoc") version "2.2.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
version = findProperty("sanderling.version") as String? ?: "0.0.0-dev"
|
|
||||||
group = "io.github.priyanshujain.sanderling"
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "dev.sanderling.sdk"
|
|
||||||
compileSdk = 35
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
minSdk = 24
|
|
||||||
consumerProguardFiles("consumer-rules.pro")
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
|
|
||||||
testOptions {
|
|
||||||
unitTests.isReturnDefaultValues = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mavenPublishing {
|
|
||||||
publishToMavenCentral(automaticRelease = true)
|
|
||||||
|
|
||||||
// Sign only when a release-signing key is provided (env or Gradle
|
|
||||||
// property). Unsigned runs are useful for `publishToMavenLocal` dry-runs;
|
|
||||||
// CI always has the key set so the actual Central push is always signed.
|
|
||||||
if (findProperty("signingInMemoryKey") != null) {
|
|
||||||
signAllPublications()
|
|
||||||
}
|
|
||||||
|
|
||||||
configure(
|
|
||||||
AndroidSingleVariantLibrary(
|
|
||||||
javadocJar = JavadocJar.Dokka("dokkaGeneratePublicationJavadoc"),
|
|
||||||
sourcesJar = SourcesJar.Sources(),
|
|
||||||
variant = "release",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
coordinates(
|
|
||||||
groupId = "io.github.priyanshujain.sanderling",
|
|
||||||
artifactId = "sdk-android",
|
|
||||||
version = version.toString(),
|
|
||||||
)
|
|
||||||
|
|
||||||
pom {
|
|
||||||
name.set("sanderling sdk-android")
|
|
||||||
description.set(
|
|
||||||
"Android runtime SDK for sanderling, a property-based UI fuzzer for mobile apps. " +
|
|
||||||
"Exposes a content-provider accessibility bridge consumed by the sanderling CLI at test time.",
|
|
||||||
)
|
|
||||||
url.set("https://github.com/priyanshujain/sanderling")
|
|
||||||
|
|
||||||
licenses {
|
|
||||||
license {
|
|
||||||
name.set("Apache License, Version 2.0")
|
|
||||||
url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
|
|
||||||
distribution.set("repo")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
developers {
|
|
||||||
developer {
|
|
||||||
id.set("priyanshujain")
|
|
||||||
name.set("Priyanshu Jain")
|
|
||||||
url.set("https://github.com/priyanshujain")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scm {
|
|
||||||
url.set("https://github.com/priyanshujain/sanderling")
|
|
||||||
connection.set("scm:git:git://github.com/priyanshujain/sanderling.git")
|
|
||||||
developerConnection.set("scm:git:ssh://[email protected]/priyanshujain/sanderling.git")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
|
||||||
testImplementation("org.json:json:20240303")
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# ProGuard rules shipped to consumers of dev.sanderling:sdk-android.
|
|
||||||
# None needed for v0.1. Socket and semaphore APIs are all reflection-free.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package dev.sanderling.sdk
|
|
||||||
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.view.Choreographer
|
|
||||||
|
|
||||||
class ChoreographerPoster : FrameCallbackPoster {
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
|
||||||
|
|
||||||
override fun postFrameCallback(callback: () -> Unit) {
|
|
||||||
mainHandler.post {
|
|
||||||
Choreographer.getInstance().postFrameCallback { callback() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package dev.sanderling.sdk
|
|
||||||
|
|
||||||
import java.io.PrintWriter
|
|
||||||
import java.io.StringWriter
|
|
||||||
|
|
||||||
internal class ExceptionRecorder(private val capacity: Int = DEFAULT_CAPACITY) {
|
|
||||||
data class Entry(
|
|
||||||
val className: String,
|
|
||||||
val message: String,
|
|
||||||
val stackTrace: String,
|
|
||||||
val unixMillis: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val buffer: ArrayDeque<Entry> = ArrayDeque()
|
|
||||||
private var chainedHandler: Thread.UncaughtExceptionHandler? = null
|
|
||||||
@Volatile private var installed: Boolean = false
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun install() {
|
|
||||||
if (installed) return
|
|
||||||
chainedHandler = Thread.getDefaultUncaughtExceptionHandler()
|
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
|
||||||
record(throwable)
|
|
||||||
chainedHandler?.uncaughtException(thread, throwable)
|
|
||||||
}
|
|
||||||
installed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun uninstall() {
|
|
||||||
if (!installed) return
|
|
||||||
Thread.setDefaultUncaughtExceptionHandler(chainedHandler)
|
|
||||||
chainedHandler = null
|
|
||||||
installed = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun record(throwable: Throwable, now: Long = System.currentTimeMillis()) {
|
|
||||||
val stackTrace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString()
|
|
||||||
val entry = Entry(
|
|
||||||
className = throwable.javaClass.name,
|
|
||||||
message = throwable.message ?: "",
|
|
||||||
stackTrace = stackTrace,
|
|
||||||
unixMillis = now,
|
|
||||||
)
|
|
||||||
if (buffer.size >= capacity) {
|
|
||||||
buffer.removeFirst()
|
|
||||||
}
|
|
||||||
buffer.addLast(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun drain(): List<Entry> {
|
|
||||||
val snapshot = buffer.toList()
|
|
||||||
buffer.clear()
|
|
||||||
return snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val DEFAULT_CAPACITY: Int = 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package dev.sanderling.sdk
|
|
||||||
|
|
||||||
import android.net.LocalSocket
|
|
||||||
import android.net.LocalSocketAddress
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
|
|
||||||
class LocalAbstractTransport(private val socketName: String) : AgentTransport {
|
|
||||||
@Throws(IOException::class)
|
|
||||||
override fun connect(): AgentConnection {
|
|
||||||
val socket = LocalSocket()
|
|
||||||
socket.connect(LocalSocketAddress(socketName, LocalSocketAddress.Namespace.ABSTRACT))
|
|
||||||
return LocalSocketConnection(socket)
|
|
||||||
}
|
|
||||||
|
|
||||||
private class LocalSocketConnection(private val socket: LocalSocket) : AgentConnection {
|
|
||||||
override val input: InputStream = socket.inputStream
|
|
||||||
override val output: OutputStream = socket.outputStream
|
|
||||||
override fun close() {
|
|
||||||
try { socket.shutdownInput() } catch (_: IOException) {}
|
|
||||||
try { socket.shutdownOutput() } catch (_: IOException) {}
|
|
||||||
socket.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package dev.sanderling.sdk
|
|
||||||
|
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.Semaphore
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import java.util.concurrent.TimeoutException
|
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
|
||||||
|
|
||||||
fun interface FrameCallbackPoster {
|
|
||||||
fun postFrameCallback(callback: () -> Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Pauser(
|
|
||||||
private val poster: FrameCallbackPoster,
|
|
||||||
private val pauseTimeoutMillis: Long = 5_000L,
|
|
||||||
) {
|
|
||||||
@Volatile private var currentGate: Semaphore? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedules extractors to run on the frame-callback thread (the SDK's
|
|
||||||
* "main thread" analogue) and blocks that thread after they complete
|
|
||||||
* until release() is called or pauseTimeoutMillis elapses.
|
|
||||||
* Returns the extractor output. Must be called from a worker thread.
|
|
||||||
*/
|
|
||||||
@Throws(TimeoutException::class)
|
|
||||||
fun pauseAndSnapshot(extractors: () -> Map<String, Any?>): Map<String, Any?> {
|
|
||||||
val gate = Semaphore(0)
|
|
||||||
val ready = CountDownLatch(1)
|
|
||||||
val captured = AtomicReference<Result<Map<String, Any?>>>()
|
|
||||||
|
|
||||||
poster.postFrameCallback {
|
|
||||||
captured.set(runCatching { extractors() })
|
|
||||||
ready.countDown()
|
|
||||||
try {
|
|
||||||
gate.tryAcquire(pauseTimeoutMillis, TimeUnit.MILLISECONDS)
|
|
||||||
} catch (_: InterruptedException) {
|
|
||||||
Thread.currentThread().interrupt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentGate = gate
|
|
||||||
|
|
||||||
if (!ready.await(pauseTimeoutMillis, TimeUnit.MILLISECONDS)) {
|
|
||||||
throw TimeoutException("extractors did not run within ${pauseTimeoutMillis}ms")
|
|
||||||
}
|
|
||||||
return captured.get().getOrThrow()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun release() {
|
|
||||||
currentGate?.release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
package dev.sanderling.sdk
|
|
||||||
|
|
||||||
import java.io.DataInputStream
|
|
||||||
import java.io.DataOutputStream
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
enum class MessageType(val wire: String) {
|
|
||||||
HELLO("HELLO"),
|
|
||||||
PAUSE("PAUSE"),
|
|
||||||
RESUME("RESUME"),
|
|
||||||
STATE("STATE"),
|
|
||||||
EXTRACT_RESULT("EXTRACT_RESULT"),
|
|
||||||
GOODBYE("GOODBYE");
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromWire(wire: String): MessageType =
|
|
||||||
values().firstOrNull { it.wire == wire }
|
|
||||||
?: throw IOException("unknown message type: $wire")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Message(
|
|
||||||
val type: MessageType,
|
|
||||||
val id: Long = 0L,
|
|
||||||
val protocolVersion: Int = 0,
|
|
||||||
val version: String? = null,
|
|
||||||
val platform: String? = null,
|
|
||||||
val appPackage: String? = null,
|
|
||||||
val snapshots: Map<String, Any?>? = null,
|
|
||||||
val exceptions: List<Map<String, Any?>>? = null,
|
|
||||||
val extractor: String? = null,
|
|
||||||
val result: Any? = null,
|
|
||||||
val error: String? = null,
|
|
||||||
val reason: String? = null,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun hello(version: String, platform: String, appPackage: String): Message =
|
|
||||||
Message(
|
|
||||||
MessageType.HELLO,
|
|
||||||
protocolVersion = Protocol.PROTOCOL_VERSION,
|
|
||||||
version = version,
|
|
||||||
platform = platform,
|
|
||||||
appPackage = appPackage,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun pause(id: Long): Message = Message(MessageType.PAUSE, id = id)
|
|
||||||
|
|
||||||
fun resume(id: Long): Message = Message(MessageType.RESUME, id = id)
|
|
||||||
|
|
||||||
fun state(
|
|
||||||
id: Long,
|
|
||||||
snapshots: Map<String, Any?>,
|
|
||||||
exceptions: List<Map<String, Any?>>? = null,
|
|
||||||
): Message = Message(
|
|
||||||
MessageType.STATE,
|
|
||||||
id = id,
|
|
||||||
snapshots = snapshots,
|
|
||||||
exceptions = exceptions,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun extractResult(id: Long, extractor: String, result: Any?, error: String? = null): Message =
|
|
||||||
Message(MessageType.EXTRACT_RESULT, id = id, extractor = extractor, result = result, error = error)
|
|
||||||
|
|
||||||
fun goodbye(reason: String): Message = Message(MessageType.GOODBYE, reason = reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object Protocol {
|
|
||||||
const val MAX_FRAME_SIZE: Int = 16 * 1024 * 1024
|
|
||||||
|
|
||||||
// Wire-format version. Must match agent.ProtocolVersion on the Go side.
|
|
||||||
const val PROTOCOL_VERSION: Int = 1
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun write(output: OutputStream, message: Message) {
|
|
||||||
val bytes = toJson(message).toString().toByteArray(Charsets.UTF_8)
|
|
||||||
if (bytes.size > MAX_FRAME_SIZE) {
|
|
||||||
throw IOException("frame of ${bytes.size} bytes exceeds maximum $MAX_FRAME_SIZE")
|
|
||||||
}
|
|
||||||
DataOutputStream(output).apply {
|
|
||||||
writeInt(bytes.size)
|
|
||||||
write(bytes)
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun read(input: InputStream): Message {
|
|
||||||
val dataInput = DataInputStream(input)
|
|
||||||
val length = dataInput.readInt()
|
|
||||||
if (length < 0 || length > MAX_FRAME_SIZE) {
|
|
||||||
throw IOException("frame of $length bytes exceeds maximum $MAX_FRAME_SIZE")
|
|
||||||
}
|
|
||||||
val bytes = ByteArray(length)
|
|
||||||
dataInput.readFully(bytes)
|
|
||||||
return fromJson(JSONObject(String(bytes, Charsets.UTF_8)))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toJson(message: Message): JSONObject {
|
|
||||||
val json = JSONObject()
|
|
||||||
json.put("type", message.type.wire)
|
|
||||||
if (message.id != 0L) json.put("id", message.id)
|
|
||||||
if (message.protocolVersion != 0) json.put("protocol_version", message.protocolVersion)
|
|
||||||
message.version?.let { json.put("version", it) }
|
|
||||||
message.platform?.let { json.put("platform", it) }
|
|
||||||
message.appPackage?.let { json.put("app_package", it) }
|
|
||||||
message.snapshots?.let { snapshots ->
|
|
||||||
val snapshotsJson = JSONObject()
|
|
||||||
for ((key, value) in snapshots) {
|
|
||||||
snapshotsJson.put(key, wrap(value))
|
|
||||||
}
|
|
||||||
json.put("snapshots", snapshotsJson)
|
|
||||||
}
|
|
||||||
message.exceptions?.let { exceptions ->
|
|
||||||
val array = JSONArray()
|
|
||||||
for (entry in exceptions) {
|
|
||||||
val entryJson = JSONObject()
|
|
||||||
for ((key, value) in entry) entryJson.put(key, wrap(value))
|
|
||||||
array.put(entryJson)
|
|
||||||
}
|
|
||||||
json.put("exceptions", array)
|
|
||||||
}
|
|
||||||
message.extractor?.let { json.put("extractor", it) }
|
|
||||||
message.result?.let { json.put("result", wrap(it)) }
|
|
||||||
message.error?.let { json.put("error", it) }
|
|
||||||
message.reason?.let { json.put("reason", it) }
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fromJson(json: JSONObject): Message {
|
|
||||||
val typeString = json.optString("type", "")
|
|
||||||
if (typeString.isEmpty()) throw IOException("missing type")
|
|
||||||
return Message(
|
|
||||||
type = MessageType.fromWire(typeString),
|
|
||||||
id = json.optLong("id", 0L),
|
|
||||||
protocolVersion = json.optInt("protocol_version", 0),
|
|
||||||
version = json.optStringOrNull("version"),
|
|
||||||
platform = json.optStringOrNull("platform"),
|
|
||||||
appPackage = json.optStringOrNull("app_package"),
|
|
||||||
snapshots = json.optJSONObject("snapshots")?.let { snapshotsJson ->
|
|
||||||
snapshotsJson.keys().asSequence().associateWith { unwrap(snapshotsJson.get(it)) }
|
|
||||||
},
|
|
||||||
exceptions = json.optJSONArray("exceptions")?.let { array ->
|
|
||||||
buildList {
|
|
||||||
for (index in 0 until array.length()) {
|
|
||||||
val item = array.optJSONObject(index) ?: continue
|
|
||||||
add(item.keys().asSequence().associateWith { unwrap(item.get(it)) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
extractor = json.optStringOrNull("extractor"),
|
|
||||||
result = if (json.has("result") && !json.isNull("result")) unwrap(json.get("result")) else null,
|
|
||||||
error = json.optStringOrNull("error"),
|
|
||||||
reason = json.optStringOrNull("reason"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun wrap(value: Any?): Any = when (value) {
|
|
||||||
null -> JSONObject.NULL
|
|
||||||
is Number, is Boolean, is String -> value
|
|
||||||
is Map<*, *> -> JSONObject().also { json ->
|
|
||||||
for ((key, nested) in value) json.put(key.toString(), wrap(nested))
|
|
||||||
}
|
|
||||||
is List<*> -> JSONArray().also { array ->
|
|
||||||
for (item in value) array.put(wrap(item))
|
|
||||||
}
|
|
||||||
else -> value.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unwrap(value: Any?): Any? = when (value) {
|
|
||||||
JSONObject.NULL, null -> null
|
|
||||||
is JSONObject -> value.keys().asSequence().associateWith { unwrap(value.get(it)) }
|
|
||||||
is JSONArray -> buildList { for (index in 0 until value.length()) add(unwrap(value.get(index))) }
|
|
||||||
else -> value
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JSONObject.optStringOrNull(key: String): String? =
|
|
||||||
if (has(key) && !isNull(key)) getString(key) else null
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package dev.sanderling.sdk
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.util.Log
|
|
||||||
import kotlin.properties.ReadOnlyProperty
|
|
||||||
import kotlin.reflect.KProperty
|
|
||||||
|
|
||||||
internal fun String.camelToSnakeCase(): String = buildString {
|
|
||||||
for ((i, c) in this@camelToSnakeCase.withIndex()) {
|
|
||||||
if (c.isUpperCase() && i > 0) append('_')
|
|
||||||
append(c.lowercaseChar())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Configuration(
|
|
||||||
val socketName: String = "sanderling-agent",
|
|
||||||
val pauseTimeoutMillis: Long = 5_000L,
|
|
||||||
)
|
|
||||||
|
|
||||||
object Sanderling {
|
|
||||||
const val VERSION: String = "0.0.1"
|
|
||||||
private const val LOG_TAG = "Sanderling"
|
|
||||||
|
|
||||||
@Volatile private var runtime: SanderlingRuntime? = null
|
|
||||||
|
|
||||||
@JvmOverloads
|
|
||||||
@Synchronized
|
|
||||||
fun start(application: Application, configuration: Configuration = Configuration()) {
|
|
||||||
if (runtime != null) return
|
|
||||||
val newRuntime = SanderlingRuntime(
|
|
||||||
transport = LocalAbstractTransport(configuration.socketName),
|
|
||||||
pauser = Pauser(ChoreographerPoster(), configuration.pauseTimeoutMillis),
|
|
||||||
version = VERSION,
|
|
||||||
platform = "android",
|
|
||||||
appPackage = application.packageName,
|
|
||||||
)
|
|
||||||
newRuntime.start()
|
|
||||||
runtime = newRuntime
|
|
||||||
Log.i(LOG_TAG, "SDK started (package=${application.packageName} socket=${configuration.socketName})")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun extract(name: String, function: () -> Any?) {
|
|
||||||
val activeRuntime = runtime
|
|
||||||
?: throw IllegalStateException("Sanderling.start must be called before registering extractors")
|
|
||||||
activeRuntime.register(name, function)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T> snapshot(function: () -> T): SnapshotDelegate<T> = SnapshotDelegate(function)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Records a caught [Throwable] so it surfaces in the next STATE message's
|
|
||||||
* exceptions field. Useful for coroutine CoroutineExceptionHandler,
|
|
||||||
* OkHttp interceptors, or anywhere else the host app catches errors it
|
|
||||||
* still wants verified against properties like noUncaughtExceptions.
|
|
||||||
*/
|
|
||||||
fun reportError(throwable: Throwable) {
|
|
||||||
runtime?.reportError(throwable)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
internal fun stopForTest() {
|
|
||||||
runtime?.stop()
|
|
||||||
runtime = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SnapshotDelegate<T>(private val function: () -> T) {
|
|
||||||
operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): ReadOnlyProperty<Any?, T> {
|
|
||||||
Sanderling.extract(prop.name.camelToSnakeCase(), function as () -> Any?)
|
|
||||||
return ReadOnlyProperty { _, _ -> function() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
package dev.sanderling.sdk
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
|
|
||||||
internal class SanderlingRuntime(
|
|
||||||
transport: AgentTransport,
|
|
||||||
private val pauser: Pauser,
|
|
||||||
private val version: String,
|
|
||||||
private val platform: String,
|
|
||||||
private val appPackage: String,
|
|
||||||
private val exceptionRecorder: ExceptionRecorder = ExceptionRecorder(),
|
|
||||||
) {
|
|
||||||
private val extractors = LinkedHashMap<String, () -> Any?>()
|
|
||||||
@Volatile private var sender: SocketClient.MessageSender? = null
|
|
||||||
private val socketClient = SocketClient(transport, AgentHandler())
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
exceptionRecorder.install()
|
|
||||||
socketClient.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
socketClient.stop()
|
|
||||||
exceptionRecorder.uninstall()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun register(name: String, extractor: () -> Any?) {
|
|
||||||
synchronized(extractors) { extractors[name] = extractor }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reportError(throwable: Throwable) {
|
|
||||||
exceptionRecorder.record(throwable)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun snapshot(): Map<String, Any?> {
|
|
||||||
val drained = synchronized(extractors) { LinkedHashMap(extractors) }
|
|
||||||
val result = LinkedHashMap<String, Any?>(drained.size)
|
|
||||||
for ((name, extractor) in drained) {
|
|
||||||
result[name] = runCatching { extractor() }
|
|
||||||
.onFailure { cause -> Log.w(LOG_TAG, "extractor $name threw: $cause") }
|
|
||||||
.getOrNull()
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class AgentHandler : SocketClient.Handler {
|
|
||||||
override fun onConnected(sender: SocketClient.MessageSender) {
|
|
||||||
this@SanderlingRuntime.sender = sender
|
|
||||||
try {
|
|
||||||
sender.send(Message.hello(version, platform, appPackage))
|
|
||||||
} catch (cause: Exception) {
|
|
||||||
Log.w(LOG_TAG, "failed to send HELLO: $cause")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessage(message: Message) {
|
|
||||||
when (message.type) {
|
|
||||||
MessageType.PAUSE -> handlePause(message.id)
|
|
||||||
MessageType.RESUME -> pauser.release()
|
|
||||||
MessageType.GOODBYE -> socketClient.stop()
|
|
||||||
else -> Log.w(LOG_TAG, "unexpected message type ${message.type} from host")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDisconnected(cause: Throwable?) {
|
|
||||||
sender = null
|
|
||||||
pauser.release()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handlePause(id: Long) {
|
|
||||||
val snapshots = try {
|
|
||||||
pauser.pauseAndSnapshot { snapshot() }
|
|
||||||
} catch (cause: Exception) {
|
|
||||||
Log.w(LOG_TAG, "snapshot failed: $cause")
|
|
||||||
emptyMap()
|
|
||||||
}
|
|
||||||
val exceptions = exceptionRecorder.drain().map { entry ->
|
|
||||||
mapOf(
|
|
||||||
"class" to entry.className,
|
|
||||||
"message" to entry.message,
|
|
||||||
"stack_trace" to entry.stackTrace,
|
|
||||||
"unix_millis" to entry.unixMillis,
|
|
||||||
)
|
|
||||||
}.takeIf { it.isNotEmpty() }
|
|
||||||
val activeSender = sender ?: return
|
|
||||||
try {
|
|
||||||
activeSender.send(Message.state(id, snapshots, exceptions))
|
|
||||||
} catch (cause: Exception) {
|
|
||||||
Log.w(LOG_TAG, "failed to send STATE: $cause")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val LOG_TAG = "Sanderling"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package dev.sanderling.sdk
|
|
||||||
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
|
|
||||||
interface AgentTransport {
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun connect(): AgentConnection
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AgentConnection {
|
|
||||||
val input: InputStream
|
|
||||||
val output: OutputStream
|
|
||||||
fun close()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Backoff(
|
|
||||||
val initialDelayMillis: Long = 500L,
|
|
||||||
val maxDelayMillis: Long = 10_000L,
|
|
||||||
val multiplier: Double = 2.0,
|
|
||||||
) {
|
|
||||||
fun next(previousDelayMillis: Long): Long =
|
|
||||||
if (previousDelayMillis <= 0L) initialDelayMillis
|
|
||||||
else minOf((previousDelayMillis * multiplier).toLong(), maxDelayMillis)
|
|
||||||
}
|
|
||||||
|
|
||||||
class SocketClient(
|
|
||||||
private val transport: AgentTransport,
|
|
||||||
private val handler: Handler,
|
|
||||||
private val backoff: Backoff = Backoff(),
|
|
||||||
private val threadFactory: (Runnable) -> Thread = { runnable -> Thread(runnable, "sanderling-agent-reader") },
|
|
||||||
private val sleeper: (Long) -> Unit = { millis -> if (millis > 0L) Thread.sleep(millis) },
|
|
||||||
) {
|
|
||||||
interface Handler {
|
|
||||||
fun onConnected(sender: MessageSender)
|
|
||||||
fun onMessage(message: Message)
|
|
||||||
fun onDisconnected(cause: Throwable?)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun interface MessageSender {
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun send(message: Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val running = AtomicBoolean(false)
|
|
||||||
@Volatile private var workerThread: Thread? = null
|
|
||||||
@Volatile private var connection: AgentConnection? = null
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
if (!running.compareAndSet(false, true)) return
|
|
||||||
val thread = threadFactory { runLoop() }
|
|
||||||
thread.isDaemon = true
|
|
||||||
workerThread = thread
|
|
||||||
thread.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
if (!running.compareAndSet(true, false)) return
|
|
||||||
try { connection?.close() } catch (_: IOException) {}
|
|
||||||
workerThread?.interrupt()
|
|
||||||
try { workerThread?.join(1_000L) } catch (_: InterruptedException) { Thread.currentThread().interrupt() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun runLoop() {
|
|
||||||
var delayMillis = 0L
|
|
||||||
while (running.get()) {
|
|
||||||
val connection = try {
|
|
||||||
transport.connect()
|
|
||||||
} catch (e: IOException) {
|
|
||||||
handler.onDisconnected(e)
|
|
||||||
if (!running.get()) return
|
|
||||||
delayMillis = backoff.next(delayMillis)
|
|
||||||
try { sleeper(delayMillis) } catch (_: InterruptedException) { return }
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
this.connection = connection
|
|
||||||
delayMillis = 0L
|
|
||||||
serve(connection)
|
|
||||||
if (!running.get()) return
|
|
||||||
delayMillis = backoff.next(delayMillis)
|
|
||||||
try { sleeper(delayMillis) } catch (_: InterruptedException) { return }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun serve(connection: AgentConnection) {
|
|
||||||
val sender = MessageSender { message ->
|
|
||||||
synchronized(connection.output) {
|
|
||||||
Protocol.write(connection.output, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handler.onConnected(sender)
|
|
||||||
var disconnectCause: Throwable? = null
|
|
||||||
try {
|
|
||||||
while (running.get()) {
|
|
||||||
val message = Protocol.read(connection.input)
|
|
||||||
handler.onMessage(message)
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
disconnectCause = e
|
|
||||||
} finally {
|
|
||||||
try { connection.close() } catch (_: IOException) {}
|
|
||||||
this.connection = null
|
|
||||||
handler.onDisconnected(disconnectCause)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package dev.sanderling.sdk
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class ExceptionRecorderTest {
|
|
||||||
|
|
||||||
@Test fun recordsClassMessageAndStackTrace() {
|
|
||||||
val recorder = ExceptionRecorder()
|
|
||||||
recorder.record(RuntimeException("boom"))
|
|
||||||
|
|
||||||
val drained = recorder.drain()
|
|
||||||
assertEquals(1, drained.size)
|
|
||||||
val entry = drained[0]
|
|
||||||
assertEquals("java.lang.RuntimeException", entry.className)
|
|
||||||
assertEquals("boom", entry.message)
|
|
||||||
assertTrue(
|
|
||||||
"stackTrace should include the class name, got: ${entry.stackTrace}",
|
|
||||||
entry.stackTrace.contains("RuntimeException"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun drainClearsBuffer() {
|
|
||||||
val recorder = ExceptionRecorder()
|
|
||||||
recorder.record(RuntimeException("first"))
|
|
||||||
recorder.record(RuntimeException("second"))
|
|
||||||
assertEquals(2, recorder.drain().size)
|
|
||||||
assertEquals(0, recorder.drain().size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun dropsOldestWhenOverCapacity() {
|
|
||||||
val recorder = ExceptionRecorder(capacity = 2)
|
|
||||||
recorder.record(RuntimeException("a"))
|
|
||||||
recorder.record(RuntimeException("b"))
|
|
||||||
recorder.record(RuntimeException("c"))
|
|
||||||
|
|
||||||
val drained = recorder.drain()
|
|
||||||
assertEquals(2, drained.size)
|
|
||||||
assertEquals("b", drained[0].message)
|
|
||||||
assertEquals("c", drained[1].message)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun installChainsExistingHandler() {
|
|
||||||
val recorder = ExceptionRecorder()
|
|
||||||
val original = Thread.getDefaultUncaughtExceptionHandler()
|
|
||||||
var chainedInvoked = false
|
|
||||||
Thread.setDefaultUncaughtExceptionHandler { _, _ -> chainedInvoked = true }
|
|
||||||
try {
|
|
||||||
recorder.install()
|
|
||||||
// Simulate an uncaught exception by invoking the installed handler
|
|
||||||
// directly — we don't need to actually terminate a thread.
|
|
||||||
Thread.getDefaultUncaughtExceptionHandler()!!.uncaughtException(
|
|
||||||
Thread.currentThread(),
|
|
||||||
IllegalStateException("chain me"),
|
|
||||||
)
|
|
||||||
assertTrue("chained handler should have fired", chainedInvoked)
|
|
||||||
assertEquals(1, recorder.drain().size)
|
|
||||||
} finally {
|
|
||||||
recorder.uninstall()
|
|
||||||
Thread.setDefaultUncaughtExceptionHandler(original)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
package dev.sanderling.sdk
|
|
||||||
|
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import java.util.concurrent.TimeoutException
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Assert.fail
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class PauserTest {
|
|
||||||
|
|
||||||
// Runs posted callbacks on a dedicated "main thread" executor, modelling
|
|
||||||
// Choreographer's contract that callbacks fire off-thread from the caller.
|
|
||||||
class FakeFrameThread : FrameCallbackPoster {
|
|
||||||
val executor = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "fake-frame-thread") }
|
|
||||||
val threadRef = AtomicReference<Thread>()
|
|
||||||
|
|
||||||
override fun postFrameCallback(callback: () -> Unit) {
|
|
||||||
executor.submit {
|
|
||||||
threadRef.compareAndSet(null, Thread.currentThread())
|
|
||||||
callback()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shutdown() { executor.shutdownNow() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var frameThread: FakeFrameThread
|
|
||||||
|
|
||||||
@After fun tearDown() {
|
|
||||||
if (::frameThread.isInitialized) frameThread.shutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun extractorsRunOnFrameThreadAndSnapshotReturns() {
|
|
||||||
frameThread = FakeFrameThread()
|
|
||||||
val pauser = Pauser(frameThread, pauseTimeoutMillis = 2_000L)
|
|
||||||
val extractorThread = AtomicReference<Thread>()
|
|
||||||
|
|
||||||
// Run on a worker thread so we can observe the separation.
|
|
||||||
val snapshot = runOnWorker {
|
|
||||||
pauser.pauseAndSnapshot {
|
|
||||||
extractorThread.set(Thread.currentThread())
|
|
||||||
mapOf("screen" to "home", "count" to 3)
|
|
||||||
}.also { snapshot ->
|
|
||||||
// Immediately release so the frame thread can exit the callback.
|
|
||||||
pauser.release()
|
|
||||||
snapshot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("home", snapshot["screen"])
|
|
||||||
assertEquals(3, snapshot["count"])
|
|
||||||
assertNotNull("extractor must have run", extractorThread.get())
|
|
||||||
assertEquals("fake-frame-thread", extractorThread.get().name)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun frameThreadStaysBlockedUntilRelease() {
|
|
||||||
frameThread = FakeFrameThread()
|
|
||||||
val pauser = Pauser(frameThread, pauseTimeoutMillis = 2_000L)
|
|
||||||
val releasedMarker = AtomicBoolean(false)
|
|
||||||
|
|
||||||
val latch = CountDownLatch(1)
|
|
||||||
val worker = Thread {
|
|
||||||
pauser.pauseAndSnapshot { emptyMap() }
|
|
||||||
// Now the frame thread is blocked inside the callback. Verify by
|
|
||||||
// posting another callback and checking it does NOT run until we release.
|
|
||||||
val secondCallbackRan = CountDownLatch(1)
|
|
||||||
frameThread.postFrameCallback { secondCallbackRan.countDown() }
|
|
||||||
assertFalse("second callback should be queued, not run",
|
|
||||||
secondCallbackRan.await(200, TimeUnit.MILLISECONDS))
|
|
||||||
|
|
||||||
pauser.release()
|
|
||||||
assertTrue("second callback should run after release",
|
|
||||||
secondCallbackRan.await(2, TimeUnit.SECONDS))
|
|
||||||
releasedMarker.set(true)
|
|
||||||
latch.countDown()
|
|
||||||
}
|
|
||||||
worker.start()
|
|
||||||
|
|
||||||
assertTrue("worker must finish", latch.await(5, TimeUnit.SECONDS))
|
|
||||||
assertTrue("release must have happened", releasedMarker.get())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun timeoutPropagatesWhenFrameThreadNeverRuns() {
|
|
||||||
val stuck = FrameCallbackPoster { /* never invokes callback */ }
|
|
||||||
val pauser = Pauser(stuck, pauseTimeoutMillis = 150L)
|
|
||||||
|
|
||||||
try {
|
|
||||||
pauser.pauseAndSnapshot { emptyMap() }
|
|
||||||
fail("expected TimeoutException")
|
|
||||||
} catch (_: TimeoutException) {
|
|
||||||
// pass
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun extractorExceptionBubbles() {
|
|
||||||
frameThread = FakeFrameThread()
|
|
||||||
val pauser = Pauser(frameThread, pauseTimeoutMillis = 2_000L)
|
|
||||||
|
|
||||||
try {
|
|
||||||
runOnWorker {
|
|
||||||
pauser.pauseAndSnapshot {
|
|
||||||
pauser.release() // drop the lock before throwing so main can unwind
|
|
||||||
throw IllegalStateException("extractor boom")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fail("expected IllegalStateException")
|
|
||||||
} catch (e: IllegalStateException) {
|
|
||||||
assertEquals("extractor boom", e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun releaseWithoutActivePauseIsNoOp() {
|
|
||||||
frameThread = FakeFrameThread()
|
|
||||||
val pauser = Pauser(frameThread, pauseTimeoutMillis = 2_000L)
|
|
||||||
pauser.release() // should not throw
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <T> runOnWorker(block: () -> T): T {
|
|
||||||
val result = AtomicReference<Result<T>>()
|
|
||||||
val thread = Thread { result.set(runCatching { block() }) }
|
|
||||||
thread.start()
|
|
||||||
thread.join(5_000L)
|
|
||||||
return result.get().getOrThrow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
package dev.sanderling.sdk
|
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.io.DataInputStream
|
|
||||||
import java.io.EOFException
|
|
||||||
import java.io.IOException
|
|
||||||
import org.junit.Assert.assertArrayEquals
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertThrows
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class ProtocolTest {
|
|
||||||
|
|
||||||
private fun roundTrip(message: Message): Message {
|
|
||||||
val output = ByteArrayOutputStream()
|
|
||||||
Protocol.write(output, message)
|
|
||||||
return Protocol.read(ByteArrayInputStream(output.toByteArray()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun roundTripHello() {
|
|
||||||
val got = roundTrip(Message.hello("0.0.1", "android", "in.okcredit.merchant"))
|
|
||||||
assertEquals(MessageType.HELLO, got.type)
|
|
||||||
assertEquals(Protocol.PROTOCOL_VERSION, got.protocolVersion)
|
|
||||||
assertEquals("0.0.1", got.version)
|
|
||||||
assertEquals("android", got.platform)
|
|
||||||
assertEquals("in.okcredit.merchant", got.appPackage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun roundTripPauseResume() {
|
|
||||||
val pause = roundTrip(Message.pause(42))
|
|
||||||
assertEquals(MessageType.PAUSE, pause.type)
|
|
||||||
assertEquals(42L, pause.id)
|
|
||||||
|
|
||||||
val resume = roundTrip(Message.resume(43))
|
|
||||||
assertEquals(MessageType.RESUME, resume.type)
|
|
||||||
assertEquals(43L, resume.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun roundTripState() {
|
|
||||||
val snapshots = mapOf<String, Any?>(
|
|
||||||
"screen" to "customer_ledger",
|
|
||||||
"ledger.balance" to 1500,
|
|
||||||
"is_signed_in" to true,
|
|
||||||
)
|
|
||||||
val got = roundTrip(Message.state(7, snapshots))
|
|
||||||
assertEquals(MessageType.STATE, got.type)
|
|
||||||
assertEquals(7L, got.id)
|
|
||||||
assertNotNull(got.snapshots)
|
|
||||||
assertEquals("customer_ledger", got.snapshots!!["screen"])
|
|
||||||
assertEquals(1500, got.snapshots["ledger.balance"])
|
|
||||||
assertEquals(true, got.snapshots["is_signed_in"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun roundTripExtractResult() {
|
|
||||||
val ok = roundTrip(Message.extractResult(1, "ledger.balance", 2500))
|
|
||||||
assertEquals("ledger.balance", ok.extractor)
|
|
||||||
assertEquals(2500, ok.result)
|
|
||||||
assertNull(ok.error)
|
|
||||||
|
|
||||||
val failed = roundTrip(Message.extractResult(2, "ledger.balance", null, "no active customer"))
|
|
||||||
assertEquals("no active customer", failed.error)
|
|
||||||
assertNull(failed.result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun roundTripGoodbye() {
|
|
||||||
val got = roundTrip(Message.goodbye("app terminated"))
|
|
||||||
assertEquals(MessageType.GOODBYE, got.type)
|
|
||||||
assertEquals("app terminated", got.reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun frameFormatIsBigEndianLengthPlusJson() {
|
|
||||||
val output = ByteArrayOutputStream()
|
|
||||||
Protocol.write(output, Message.pause(99))
|
|
||||||
val raw = output.toByteArray()
|
|
||||||
assertTrue("frame must have 4-byte header plus body", raw.size > 4)
|
|
||||||
val length = DataInputStream(ByteArrayInputStream(raw.copyOfRange(0, 4))).readInt()
|
|
||||||
assertEquals(raw.size - 4, length)
|
|
||||||
val payload = String(raw.copyOfRange(4, raw.size), Charsets.UTF_8)
|
|
||||||
assertTrue("payload should contain PAUSE type, got $payload", payload.contains("\"type\":\"PAUSE\""))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun emptyReaderThrowsEof() {
|
|
||||||
assertThrows(EOFException::class.java) {
|
|
||||||
Protocol.read(ByteArrayInputStream(ByteArray(0)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun oversizedFrameRejected() {
|
|
||||||
val header = ByteArray(4)
|
|
||||||
val tooBig = Protocol.MAX_FRAME_SIZE + 1
|
|
||||||
header[0] = (tooBig ushr 24).toByte()
|
|
||||||
header[1] = (tooBig ushr 16).toByte()
|
|
||||||
header[2] = (tooBig ushr 8).toByte()
|
|
||||||
header[3] = tooBig.toByte()
|
|
||||||
val error = assertThrows(IOException::class.java) {
|
|
||||||
Protocol.read(ByteArrayInputStream(header))
|
|
||||||
}
|
|
||||||
assertTrue("expected size error, got: ${error.message}", error.message!!.contains("exceeds maximum"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun missingTypeRejected() {
|
|
||||||
val payload = "{\"id\":1}".toByteArray(Charsets.UTF_8)
|
|
||||||
val header = ByteArray(4)
|
|
||||||
header[0] = (payload.size ushr 24).toByte()
|
|
||||||
header[1] = (payload.size ushr 16).toByte()
|
|
||||||
header[2] = (payload.size ushr 8).toByte()
|
|
||||||
header[3] = payload.size.toByte()
|
|
||||||
val frame = header + payload
|
|
||||||
val error = assertThrows(IOException::class.java) {
|
|
||||||
Protocol.read(ByteArrayInputStream(frame))
|
|
||||||
}
|
|
||||||
assertTrue("expected missing-type error, got: ${error.message}", error.message!!.contains("missing type"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun roundTripStateWithExceptions() {
|
|
||||||
val exceptions = listOf(
|
|
||||||
mapOf<String, Any?>(
|
|
||||||
"class" to "java.lang.RuntimeException",
|
|
||||||
"message" to "boom",
|
|
||||||
"stack_trace" to "at Foo.bar(Foo.kt:42)",
|
|
||||||
"unix_millis" to 1_700_000_000_000L,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val got = roundTrip(Message.state(3, mapOf("screen" to "home"), exceptions))
|
|
||||||
assertNotNull(got.exceptions)
|
|
||||||
assertEquals(1, got.exceptions!!.size)
|
|
||||||
assertEquals("java.lang.RuntimeException", got.exceptions[0]["class"])
|
|
||||||
assertEquals("boom", got.exceptions[0]["message"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun streamsMultipleFrames() {
|
|
||||||
val messages = listOf(
|
|
||||||
Message.hello("v", "android", "com.x"),
|
|
||||||
Message.pause(1),
|
|
||||||
Message.state(1, mapOf("x" to 42)),
|
|
||||||
Message.resume(1),
|
|
||||||
Message.goodbye("done"),
|
|
||||||
)
|
|
||||||
val output = ByteArrayOutputStream()
|
|
||||||
for (message in messages) Protocol.write(output, message)
|
|
||||||
|
|
||||||
val input = ByteArrayInputStream(output.toByteArray())
|
|
||||||
for (want in messages) {
|
|
||||||
val got = Protocol.read(input)
|
|
||||||
assertEquals(want.type, got.type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun sharedWireFormatMatchesGoEncoder() {
|
|
||||||
// Fixture encoded by the Go side (see internal/agent/protocol.go).
|
|
||||||
// Ensures both encoders agree on field names and ordering conventions.
|
|
||||||
val output = ByteArrayOutputStream()
|
|
||||||
Protocol.write(output, Message.hello("0.0.1", "android", "com.x"))
|
|
||||||
val payload = String(output.toByteArray().copyOfRange(4, output.size()), Charsets.UTF_8)
|
|
||||||
assertTrue(payload.contains("\"type\":\"HELLO\""))
|
|
||||||
assertTrue(payload.contains("\"protocol_version\":1"))
|
|
||||||
assertTrue(payload.contains("\"version\":\"0.0.1\""))
|
|
||||||
assertTrue(payload.contains("\"platform\":\"android\""))
|
|
||||||
assertTrue(payload.contains("\"app_package\":\"com.x\""))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun bytesAreConsumedInOrder() {
|
|
||||||
// Regression guard: a second read shouldn't see stale bytes.
|
|
||||||
val output = ByteArrayOutputStream()
|
|
||||||
Protocol.write(output, Message.pause(1))
|
|
||||||
Protocol.write(output, Message.pause(2))
|
|
||||||
val bytes = output.toByteArray()
|
|
||||||
val input = ByteArrayInputStream(bytes)
|
|
||||||
assertEquals(1L, Protocol.read(input).id)
|
|
||||||
assertEquals(2L, Protocol.read(input).id)
|
|
||||||
// ByteArrayInputStream should be drained.
|
|
||||||
val leftover = ByteArray(bytes.size)
|
|
||||||
val remaining = input.read(leftover)
|
|
||||||
assertEquals(-1, remaining)
|
|
||||||
assertArrayEquals(ByteArray(bytes.size), leftover)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
package dev.sanderling.sdk
|
|
||||||
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertArrayEquals
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class SanderlingRuntimeTest {
|
|
||||||
|
|
||||||
private lateinit var transport: SocketClientTest.FakeTransport
|
|
||||||
private lateinit var frameThread: PauserTest.FakeFrameThread
|
|
||||||
private lateinit var runtime: SanderlingRuntime
|
|
||||||
|
|
||||||
private fun newRuntime(): SanderlingRuntime {
|
|
||||||
transport = SocketClientTest.FakeTransport()
|
|
||||||
frameThread = PauserTest.FakeFrameThread()
|
|
||||||
val pauser = Pauser(frameThread, pauseTimeoutMillis = 2_000L)
|
|
||||||
return SanderlingRuntime(
|
|
||||||
transport = transport,
|
|
||||||
pauser = pauser,
|
|
||||||
version = "0.0.1",
|
|
||||||
platform = "android",
|
|
||||||
appPackage = "com.example.sanderling_test",
|
|
||||||
).also { runtime = it }
|
|
||||||
}
|
|
||||||
|
|
||||||
@After fun tearDown() {
|
|
||||||
if (::runtime.isInitialized) runtime.stop()
|
|
||||||
if (::frameThread.isInitialized) frameThread.shutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun startSendsHelloWithSdkMetadata() {
|
|
||||||
newRuntime().start()
|
|
||||||
val server = transport.nextServerEndpoint()
|
|
||||||
val hello = Protocol.read(server.input)
|
|
||||||
assertEquals(MessageType.HELLO, hello.type)
|
|
||||||
assertEquals("0.0.1", hello.version)
|
|
||||||
assertEquals("android", hello.platform)
|
|
||||||
assertEquals("com.example.sanderling_test", hello.appPackage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun pauseTriggersExtractorsAndReturnsState() {
|
|
||||||
newRuntime().start()
|
|
||||||
val server = transport.nextServerEndpoint()
|
|
||||||
Protocol.read(server.input) // drain HELLO
|
|
||||||
|
|
||||||
runtime.register("screen") { "customer_ledger" }
|
|
||||||
runtime.register("ledger.balance") { 1500 }
|
|
||||||
|
|
||||||
Protocol.write(server.output, Message.pause(7))
|
|
||||||
val state = Protocol.read(server.input)
|
|
||||||
assertEquals(MessageType.STATE, state.type)
|
|
||||||
assertEquals(7L, state.id)
|
|
||||||
val snapshots = state.snapshots ?: error("state.snapshots must not be null")
|
|
||||||
assertEquals("customer_ledger", snapshots["screen"])
|
|
||||||
assertEquals(1500, snapshots["ledger.balance"])
|
|
||||||
|
|
||||||
Protocol.write(server.output, Message.resume(7))
|
|
||||||
// After resume, the frame thread can accept subsequent callbacks.
|
|
||||||
Protocol.write(server.output, Message.pause(8))
|
|
||||||
val nextState = Protocol.read(server.input)
|
|
||||||
assertEquals(MessageType.STATE, nextState.type)
|
|
||||||
assertEquals(8L, nextState.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun extractorInvocationOrderMatchesRegistration() {
|
|
||||||
val runtime = newRuntime()
|
|
||||||
val order = CopyOnWriteArrayList<String>()
|
|
||||||
runtime.register("first") { order += "first"; 1 }
|
|
||||||
runtime.register("second") { order += "second"; 2 }
|
|
||||||
runtime.register("third") { order += "third"; 3 }
|
|
||||||
|
|
||||||
val snapshot = runtime.snapshot()
|
|
||||||
assertEquals(listOf("first", "second", "third"), order)
|
|
||||||
assertEquals(listOf("first", "second", "third"), snapshot.keys.toList())
|
|
||||||
assertArrayEquals(arrayOf(1, 2, 3), snapshot.values.toList().toTypedArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun extractorThrowIsIsolatedAndReportsNull() {
|
|
||||||
val runtime = newRuntime()
|
|
||||||
runtime.register("ok") { "value" }
|
|
||||||
runtime.register("boom") { throw IllegalStateException("oops") }
|
|
||||||
runtime.register("later") { 42 }
|
|
||||||
|
|
||||||
val snapshot = runtime.snapshot()
|
|
||||||
assertEquals("value", snapshot["ok"])
|
|
||||||
assertEquals(null, snapshot["boom"])
|
|
||||||
assertEquals(42, snapshot["later"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun concurrentExtractorRegistrationIsSafe() {
|
|
||||||
val runtime = newRuntime()
|
|
||||||
val registrations = 500
|
|
||||||
val pool = Executors.newFixedThreadPool(8)
|
|
||||||
val latch = CountDownLatch(registrations)
|
|
||||||
val index = AtomicInteger(0)
|
|
||||||
repeat(registrations) {
|
|
||||||
pool.submit {
|
|
||||||
val id = index.getAndIncrement()
|
|
||||||
runtime.register("ext-$id") { id }
|
|
||||||
latch.countDown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assertTrue(latch.await(5, TimeUnit.SECONDS))
|
|
||||||
pool.shutdown()
|
|
||||||
|
|
||||||
val snapshot = runtime.snapshot()
|
|
||||||
assertEquals(registrations, snapshot.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun helloIsSentOnReconnect() {
|
|
||||||
val runtime = newRuntime()
|
|
||||||
val shortBackoff = Backoff(initialDelayMillis = 10L, maxDelayMillis = 10L, multiplier = 1.0)
|
|
||||||
// Swap in a client with short backoff by re-creating the runtime's client indirectly.
|
|
||||||
// We'll re-use the existing runtime instead and just close the first connection.
|
|
||||||
runtime.start()
|
|
||||||
val firstServer = transport.nextServerEndpoint()
|
|
||||||
Protocol.read(firstServer.input) // drain first HELLO
|
|
||||||
firstServer.close()
|
|
||||||
|
|
||||||
val secondServer = transport.nextServerEndpoint(timeoutMillis = 3_000L)
|
|
||||||
val secondHello = Protocol.read(secondServer.input)
|
|
||||||
assertEquals(MessageType.HELLO, secondHello.type)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun registerBeforeStartQueuesCorrectlyOnceStarted() {
|
|
||||||
val runtime = newRuntime()
|
|
||||||
runtime.register("early") { 1 }
|
|
||||||
runtime.register("middle") { 2 }
|
|
||||||
runtime.start()
|
|
||||||
runtime.register("late") { 3 }
|
|
||||||
|
|
||||||
val snapshot = runtime.snapshot()
|
|
||||||
assertEquals(3, snapshot.size)
|
|
||||||
assertEquals(1, snapshot["early"])
|
|
||||||
assertEquals(2, snapshot["middle"])
|
|
||||||
assertEquals(3, snapshot["late"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package dev.sanderling.sdk
|
|
||||||
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class SnapshotDelegateTest {
|
|
||||||
|
|
||||||
@After fun tearDown() = Sanderling.stopForTest()
|
|
||||||
|
|
||||||
@Test fun camelToSnakeCaseConvertsKnownNames() {
|
|
||||||
assertEquals("logged_in", "loggedIn".camelToSnakeCase())
|
|
||||||
assertEquals("total_balance", "totalBalance".camelToSnakeCase())
|
|
||||||
assertEquals("txn_form_type", "txnFormType".camelToSnakeCase())
|
|
||||||
assertEquals("active_account_id", "activeAccountId".camelToSnakeCase())
|
|
||||||
assertEquals("add_account_error", "addAccountError".camelToSnakeCase())
|
|
||||||
assertEquals("ledger_balance", "ledgerBalance".camelToSnakeCase())
|
|
||||||
assertEquals("ledger_rows", "ledgerRows".camelToSnakeCase())
|
|
||||||
assertEquals("auth_status", "authStatus".camelToSnakeCase())
|
|
||||||
assertEquals("login_error", "loginError".camelToSnakeCase())
|
|
||||||
assertEquals("txn_error", "txnError".camelToSnakeCase())
|
|
||||||
assertEquals("account_count", "accountCount".camelToSnakeCase())
|
|
||||||
assertEquals("focused_input", "focusedInput".camelToSnakeCase())
|
|
||||||
assertEquals("txn_form_account_id", "txnFormAccountId".camelToSnakeCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun camelToSnakeCaseLeavesAlreadyLowercase() {
|
|
||||||
assertEquals("screen", "screen".camelToSnakeCase())
|
|
||||||
assertEquals("accounts", "accounts".camelToSnakeCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun snapshotDelegateRegistersWithDerivedKey() {
|
|
||||||
val transport = SocketClientTest.FakeTransport()
|
|
||||||
val frameThread = PauserTest.FakeFrameThread()
|
|
||||||
val runtime = SanderlingRuntime(
|
|
||||||
transport = transport,
|
|
||||||
pauser = Pauser(frameThread, pauseTimeoutMillis = 2_000L),
|
|
||||||
version = "0.0.1",
|
|
||||||
platform = "android",
|
|
||||||
appPackage = "com.example.test",
|
|
||||||
)
|
|
||||||
runtime.start()
|
|
||||||
// Inject runtime into Sanderling via reflection so snapshot() can register
|
|
||||||
val runtimeField = Sanderling::class.java.getDeclaredField("runtime")
|
|
||||||
runtimeField.isAccessible = true
|
|
||||||
runtimeField.set(Sanderling, runtime)
|
|
||||||
|
|
||||||
var callCount = 0
|
|
||||||
val obj = object {
|
|
||||||
val loggedIn by Sanderling.snapshot { callCount++; true }
|
|
||||||
}
|
|
||||||
|
|
||||||
val snapshot = runtime.snapshot()
|
|
||||||
assertEquals(true, snapshot["logged_in"])
|
|
||||||
assertEquals(1, callCount)
|
|
||||||
|
|
||||||
// getValue delegates back to lambda
|
|
||||||
callCount = 0
|
|
||||||
val value = obj.loggedIn
|
|
||||||
assertEquals(true, value)
|
|
||||||
assertEquals(1, callCount)
|
|
||||||
|
|
||||||
runtime.stop()
|
|
||||||
frameThread.shutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun snapshotDelegateGetValueReturnsFreshResult() {
|
|
||||||
val transport = SocketClientTest.FakeTransport()
|
|
||||||
val frameThread = PauserTest.FakeFrameThread()
|
|
||||||
val runtime = SanderlingRuntime(
|
|
||||||
transport = transport,
|
|
||||||
pauser = Pauser(frameThread, pauseTimeoutMillis = 2_000L),
|
|
||||||
version = "0.0.1",
|
|
||||||
platform = "android",
|
|
||||||
appPackage = "com.example.test",
|
|
||||||
)
|
|
||||||
runtime.start()
|
|
||||||
val runtimeField = Sanderling::class.java.getDeclaredField("runtime")
|
|
||||||
runtimeField.isAccessible = true
|
|
||||||
runtimeField.set(Sanderling, runtime)
|
|
||||||
|
|
||||||
var counter = 0
|
|
||||||
val obj = object {
|
|
||||||
val accountCount by Sanderling.snapshot { counter }
|
|
||||||
}
|
|
||||||
|
|
||||||
counter = 5
|
|
||||||
assertEquals(5, obj.accountCount)
|
|
||||||
counter = 10
|
|
||||||
assertEquals(10, obj.accountCount)
|
|
||||||
|
|
||||||
runtime.stop()
|
|
||||||
frameThread.shutdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
package dev.sanderling.sdk
|
|
||||||
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.PipedInputStream
|
|
||||||
import java.io.PipedOutputStream
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.LinkedBlockingQueue
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertSame
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class SocketClientTest {
|
|
||||||
|
|
||||||
/** In-memory transport that pairs each connect() call with a matching
|
|
||||||
* server endpoint a test can read/write on. */
|
|
||||||
class FakeTransport : AgentTransport {
|
|
||||||
data class Endpoints(val client: AgentConnection, val server: AgentConnection)
|
|
||||||
|
|
||||||
private val pending = LinkedBlockingQueue<Endpoints>()
|
|
||||||
val connectCount = AtomicInteger(0)
|
|
||||||
@Volatile var failNextConnect: Throwable? = null
|
|
||||||
|
|
||||||
override fun connect(): AgentConnection {
|
|
||||||
connectCount.incrementAndGet()
|
|
||||||
failNextConnect?.let { cause ->
|
|
||||||
failNextConnect = null
|
|
||||||
throw IOException("connect failed", cause)
|
|
||||||
}
|
|
||||||
val toClient = PipedOutputStream()
|
|
||||||
val fromServer = PipedInputStream(toClient, 64 * 1024)
|
|
||||||
val toServer = PipedOutputStream()
|
|
||||||
val fromClient = PipedInputStream(toServer, 64 * 1024)
|
|
||||||
val client = pipedConnection(fromServer, toServer)
|
|
||||||
val server = pipedConnection(fromClient, toClient)
|
|
||||||
pending.offer(Endpoints(client, server))
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
fun nextServerEndpoint(timeoutMillis: Long = 2_000L): AgentConnection {
|
|
||||||
val endpoints = pending.poll(timeoutMillis, TimeUnit.MILLISECONDS)
|
|
||||||
?: error("no server endpoint emerged within $timeoutMillis ms")
|
|
||||||
return endpoints.server
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pipedConnection(input: PipedInputStream, output: PipedOutputStream): AgentConnection {
|
|
||||||
return object : AgentConnection {
|
|
||||||
override val input = input
|
|
||||||
override val output = output
|
|
||||||
override fun close() {
|
|
||||||
try { input.close() } catch (_: IOException) {}
|
|
||||||
try { output.close() } catch (_: IOException) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RecordingHandler : SocketClient.Handler {
|
|
||||||
val connected = CountDownLatch(1)
|
|
||||||
val disconnected = LinkedBlockingQueue<Throwable?>()
|
|
||||||
val messages = CopyOnWriteArrayList<Message>()
|
|
||||||
@Volatile var sender: SocketClient.MessageSender? = null
|
|
||||||
|
|
||||||
override fun onConnected(sender: SocketClient.MessageSender) {
|
|
||||||
this.sender = sender
|
|
||||||
connected.countDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessage(message: Message) {
|
|
||||||
messages += message
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDisconnected(cause: Throwable?) {
|
|
||||||
disconnected.offer(cause ?: SentinelDisconnect)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun waitForMessages(expected: Int, timeoutMillis: Long = 2_000L) {
|
|
||||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
|
||||||
while (messages.size < expected && System.currentTimeMillis() < deadline) Thread.sleep(5L)
|
|
||||||
assertEquals("expected $expected messages, got ${messages.size}", expected, messages.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private object SentinelDisconnect : Throwable()
|
|
||||||
|
|
||||||
private lateinit var client: SocketClient
|
|
||||||
|
|
||||||
@After fun tearDown() {
|
|
||||||
if (::client.isInitialized) client.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun connectsAndDeliversIncomingMessages() {
|
|
||||||
val transport = FakeTransport()
|
|
||||||
val handler = RecordingHandler()
|
|
||||||
client = SocketClient(transport, handler).also { it.start() }
|
|
||||||
|
|
||||||
val server = transport.nextServerEndpoint()
|
|
||||||
Protocol.write(server.output, Message.pause(1))
|
|
||||||
Protocol.write(server.output, Message.resume(1))
|
|
||||||
handler.waitForMessages(2)
|
|
||||||
|
|
||||||
assertEquals(MessageType.PAUSE, handler.messages[0].type)
|
|
||||||
assertEquals(1L, handler.messages[0].id)
|
|
||||||
assertEquals(MessageType.RESUME, handler.messages[1].type)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun sendGoesThroughOutputStream() {
|
|
||||||
val transport = FakeTransport()
|
|
||||||
val handler = RecordingHandler()
|
|
||||||
client = SocketClient(transport, handler).also { it.start() }
|
|
||||||
|
|
||||||
assertTrue("connected latch must fire", handler.connected.await(2, TimeUnit.SECONDS))
|
|
||||||
val sender = handler.sender ?: error("sender not set after onConnected")
|
|
||||||
|
|
||||||
sender.send(Message.hello("0.0.1", "android", "com.x"))
|
|
||||||
|
|
||||||
val server = transport.nextServerEndpoint()
|
|
||||||
val received = Protocol.read(server.input)
|
|
||||||
assertEquals(MessageType.HELLO, received.type)
|
|
||||||
assertEquals("com.x", received.appPackage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun reconnectsWithBackoffAfterConnectFailure() {
|
|
||||||
val transport = FakeTransport()
|
|
||||||
transport.failNextConnect = RuntimeException("no socket yet")
|
|
||||||
val handler = RecordingHandler()
|
|
||||||
|
|
||||||
val observedSleeps = CopyOnWriteArrayList<Long>()
|
|
||||||
val shortBackoff = Backoff(initialDelayMillis = 25L, maxDelayMillis = 25L, multiplier = 1.0)
|
|
||||||
client = SocketClient(
|
|
||||||
transport,
|
|
||||||
handler,
|
|
||||||
backoff = shortBackoff,
|
|
||||||
sleeper = { millis -> observedSleeps += millis },
|
|
||||||
).also { it.start() }
|
|
||||||
|
|
||||||
assertTrue("connected latch must fire after retry", handler.connected.await(2, TimeUnit.SECONDS))
|
|
||||||
assertTrue("should have retried at least once", transport.connectCount.get() >= 2)
|
|
||||||
assertTrue("sleeper should have been called with backoff delay, got $observedSleeps",
|
|
||||||
observedSleeps.any { it > 0L })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun reconnectsAfterServerClosesConnection() {
|
|
||||||
val transport = FakeTransport()
|
|
||||||
val handler = RecordingHandler()
|
|
||||||
val shortBackoff = Backoff(initialDelayMillis = 10L, maxDelayMillis = 10L, multiplier = 1.0)
|
|
||||||
client = SocketClient(transport, handler, backoff = shortBackoff).also { it.start() }
|
|
||||||
|
|
||||||
val firstServer = transport.nextServerEndpoint()
|
|
||||||
assertTrue(handler.connected.await(2, TimeUnit.SECONDS))
|
|
||||||
firstServer.close()
|
|
||||||
|
|
||||||
// Handler.onDisconnected should fire.
|
|
||||||
val cause = handler.disconnected.poll(2, TimeUnit.SECONDS)
|
|
||||||
assertNotNull("expected disconnect notification", cause)
|
|
||||||
|
|
||||||
// A second connect() should happen — fetch the new server endpoint to prove it.
|
|
||||||
val secondServer = transport.nextServerEndpoint(timeoutMillis = 2_000L)
|
|
||||||
assertSame("second endpoint must exist", secondServer, secondServer)
|
|
||||||
assertTrue("connect count should be >= 2, got ${transport.connectCount.get()}",
|
|
||||||
transport.connectCount.get() >= 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun stopClosesConnection() {
|
|
||||||
val transport = FakeTransport()
|
|
||||||
val handler = RecordingHandler()
|
|
||||||
client = SocketClient(transport, handler).also { it.start() }
|
|
||||||
|
|
||||||
transport.nextServerEndpoint()
|
|
||||||
assertTrue(handler.connected.await(2, TimeUnit.SECONDS))
|
|
||||||
client.stop()
|
|
||||||
|
|
||||||
val cause = handler.disconnected.poll(2, TimeUnit.SECONDS)
|
|
||||||
assertNotNull("stop() should trigger onDisconnected", cause)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun backoffGrowsExponentially() {
|
|
||||||
val backoff = Backoff(initialDelayMillis = 100L, maxDelayMillis = 800L, multiplier = 2.0)
|
|
||||||
assertEquals(100L, backoff.next(0L))
|
|
||||||
assertEquals(200L, backoff.next(100L))
|
|
||||||
assertEquals(400L, backoff.next(200L))
|
|
||||||
assertEquals(800L, backoff.next(400L))
|
|
||||||
assertEquals(800L, backoff.next(800L))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,5 +17,3 @@ dependencyResolutionManagement {
|
|||||||
rootProject.name = "sanderling"
|
rootProject.name = "sanderling"
|
||||||
|
|
||||||
include(":sidecar")
|
include(":sidecar")
|
||||||
include(":sdk-android")
|
|
||||||
project(":sdk-android").projectDir = file("sdk/android")
|
|
||||||
Reference in new issue
Block a user