Skip to content

Architecture Overview

spana uses a layered architecture where all logic lives in TypeScript on the host machine. Platform drivers are thin HTTP clients with no embedded intelligence.

graph TD
    A[flow files .flow.ts] --> B[CLI: spana test]
    B --> C[TestRunner / runner.ts]
    C --> D[PlatformOrchestrator]
    D -->|parallel platforms| E1[Web: Playwright CDP]
    D -->|parallel platforms| E2[Android: UiAutomator2]
    D -->|parallel platforms| E3[iOS: WebDriverAgent]
    E1 --> F[Reporter]
    E2 --> F
    E3 --> F
    F --> G1[console]
    F --> G2[json]
    F --> G3[junit]
    F --> G4[html]

Platforms run in parallel. Within each platform, flows run serially. Results are collected and passed to the configured reporter(s).

graph TD
    subgraph User API
        A1["flow() — define test"]
        A2["app — launch, tap, swipe, type"]
        A3["expect — toBeVisible, toHaveText, toBeHidden"]
    end

    subgraph Smart Layer [Smart Layer — src/smart/]
        B1[coordinator.ts — action dispatch]
        B2[auto-wait.ts — poll until element appears]
        B3[element-matcher.ts — client-side tree search + centerOf]
    end

    subgraph Raw Driver [Raw Driver — src/drivers/raw-driver.ts]
        C[RawDriverService interface]
        C1[tapAtCoordinate / swipe / inputText]
        C2[dumpHierarchy — returns raw XML or JSON]
        C3[launchApp / stopApp / takeScreenshot]
    end

    subgraph Companion Drivers
        D1[playwright.ts — CDP via Playwright]
        D2[uiautomator2/driver.ts — HTTP to APK server]
        D3[wda/driver.ts — HTTP to XCTest runner]
    end

    subgraph Devices
        E1[Browser / Chromium]
        E2[Android emulator or device]
        E3[iOS simulator or device]
    end

    A2 --> B1
    A3 --> B1
    B1 --> B2
    B2 --> C2
    B2 --> B3
    B3 --> B1
    B1 --> C1
    C --> D1
    C --> D2
    C --> D3
    D1 --> E1
    D2 --> E2
    D3 --> E3

The Smart Layer never sees raw coordinates. It resolves selectors to elements, derives center coordinates, then calls the thin RawDriver interface. The RawDriver knows nothing about selectors.

How app.tap({ testID: "login" }) executes end-to-end:

sequenceDiagram
    participant Flow as flow.ts
    participant Coord as coordinator.ts
    participant Wait as auto-wait.ts
    participant Driver as RawDriverService
    participant Parser as platform parser
    participant Companion as companion server

    Flow->>Coord: tap({ testID: "login" })
    Coord->>Wait: waitForElement(selector)
    loop poll until found or timeout
        Wait->>Driver: dumpHierarchy()
        Driver->>Companion: HTTP GET /source
        Companion-->>Driver: raw XML / JSON string
        Driver-->>Wait: RawHierarchy
        Wait->>Parser: parse(raw) -> Element tree
        Wait->>Wait: findElement(tree, { testID: "login" })
    end
    Wait-->>Coord: Element (with bounds)
    Coord->>Coord: centerOf(element) -> {x, y}
    Coord->>Driver: tapAtCoordinate(x, y)
    Driver->>Companion: HTTP POST /touch/perform

The parser is platform-specific (XML for Android/iOS, JSON for web) but always produces the same unified Element tree shape. All matching logic runs client-side in TypeScript.

graph TD
    subgraph Flow Queue [Shared flow queue]
        Q1[flow 1]
        Q2[flow 2]
        Q3[flow 3]
        Q4[flow 4]
        Q5[flow 5]
    end

    subgraph Workers
        W1[Worker A — Pixel 7]
        W2[Worker B — Galaxy S23]
        W3[Worker C — iPhone 15 sim]
    end

    Q1 --> W1
    Q2 --> W2
    Q3 --> W3
    Q4 --> W1
    Q5 --> W2

    W1 --> R[Aggregated Results]
    W2 --> R
    W3 --> R

Workers share a single atomic index into the flow array. Because Bun is single-threaded, the increment is naturally safe with no mutex. A faster device finishes sooner and picks up the next flow immediately, draining the queue at the speed of the fastest workers.

  • parallel.tsrunParallel() launches all workers with Promise.all, each looping over the queue until exhausted.
  • orchestrator.tsorchestrate() runs one worker per platform with all flows for that platform in series, platforms in parallel.
graph LR
    A[Agent] -->|spana selectors --platform android| B[Discover elements\nJSON list with suggestedSelector]
    B -->|generate flow code| C[Write flow.ts]
    C -->|spana validate| D[Validate schema\nno device needed]
    D -->|spana test --reporter json| E[Execute on device]
    E -->|read JSON errors| F[Fix and retry]
    F --> C

session.ts backs spana hierarchy and spana selectors. Session.selectors() dumps the element tree, flattens it, and returns only visible elements with a testID, accessibilityLabel, or text — each annotated with the best-priority selector to use in a flow file.

graph TD
    Root[spana/]

    Root --> Packages[packages/]
    Root --> Apps[apps/]

    Packages --> PProvPkg[spana/ — core library and CLI]
    Packages --> Config[config/ — shared TS/lint config]
    Packages --> Env[env/ — environment helpers]
    Packages --> UI[ui/ — shared UI components]

    Apps --> Web[web/ — web app under test]
    Apps --> Native[native/ — React Native app under test]
    Apps --> TUI[tui/ — terminal UI]
    Apps --> Docs[docs/]

    PProvPkg --> Src[src/]
    Src --> API[api/ — flow, app, expect user API]
    Src --> Core[core/ — engine, orchestrator, parallel, runner]
    Src --> Smart[smart/ — coordinator, auto-wait, element-matcher]
    Src --> Drivers[drivers/ — raw-driver interface + platform impls]
    Src --> Agent[agent/ — session.ts for agent/CLI inspection]
    Src --> CLI2[cli/ — CLI entry point]
    Src --> Device[device/ — ADB, simctl device discovery]
    Src --> Report[report/ — console, json, junit, html reporters]
    Src --> Schemas[schemas/ — Element, Selector, DeviceInfo types]