Skip to content

About

Read and drive an app's @observable stores without the UI, over stdin/stdout (NDJSON).

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

4 Commits

Folders and files

Repository files navigation

Headless

Read and drive an app's @Observable stores from outside the UI, over stdin/stdout or a local socket. A CLI target links the same stores the app uses (with fake services) and calls HeadlessHost.runStandardIO(). A test harness in any language then starts the CLI and runs a flow: get the state, call an operation, check the state.

Stores stay as they are. The headless side is an adapter over each store's public properties and methods. It does not turn stores into reducers.

Swift 6, iOS 17 / macOS 14, no dependencies.

Protocol (version 1)

One JSON object per line. The first line out is {"ready":true,"protocol":1}. Each response echoes the request id. Logs go to stderr.

Request Result
{"id":1,"cmd":"ls"} {"protocol":1,"stores":[{"name","state","operations":[{"name","input"}]}]}
{"id":2,"cmd":"get","store":"home"} {"name":"home","state":{...}}
{"id":3,"cmd":"do","store":"home","op":"achieve","args":{...}} Waits for the operation to finish, then {"name","state"}
{"id":4,"cmd":"quit"} {}, then the loop ends (it also ends at EOF)

Success: {"id":1,"ok":true,"result":...}. Failure: {"id":1,"ok":false,"error":{"code","message"}}, where code is one of bad_request, unknown_store, unknown_op, bad_args, op_failed. Object keys are sorted, so the same state always gives the same line.

Adapter example

import Headless

extension HomeStore: HeadlessStore {
    public var headlessName: String { "home" }

    public func headlessState() -> JSONValue {
        ["balance": .number(Double(balance)),
         "habits": (try? JSONValue(encoding: habits)) ?? [],
         "errorMessage": errorMessage.map(JSONValue.string) ?? .null]
    }

    struct AchieveArgs: Decodable { let habitId: String }

    public var operations: [HeadlessOperation] {
        [HeadlessOperation("load") { await self.load() },
         HeadlessOperation("achieve", args: AchieveArgs.self,
             input: JSONSchema.object(["habitId": JSONSchema.string], required: ["habitId"])) { args in
             guard let habit = self.habits.first(where: { $0.uuid == args.habitId }) else {
                 throw HeadlessError.badArgs("no habit \(args.habitId)")
             }
             await self.achieve(habit)
         }]
    }
}

CLI entry point:

let host = HeadlessHost(stores: { [root.home, root.rewards] })
try await host.runStandardIO()

HeadlessHost.handle(line:) answers one request line without I/O, for unit tests.

Driving a running app (DEBUG only)

HeadlessSocketServer serves the same protocol over TCP, so a harness can drive the real app (for example one running in the simulator) instead of a CLI:

#if DEBUG
// launched with: xcrun simctl launch booted <bundle-id> -HeadlessPort 9500
let port = UserDefaults.standard.integer(forKey: "HeadlessPort")
if port > 0 {
    let server = HeadlessSocketServer(host: HeadlessHost(stores: { [root.home, root.rewards] }), port: UInt16(port))
    Task { try await server.start() }   // keep `server` alive for the app's lifetime
}
#endif
  • HeadlessSocketServer(host:port:bindAddress:): bindAddress defaults to "127.0.0.1". Port 0 picks a free port; start() returns the bound one (also in port).
  • Every connection first receives the ready line, then one response line per request line. Requests on one connection run one at a time, in order, on the main actor.
  • quit or closing the socket ends that connection; the server keeps listening. stop() closes the listener and every connection.
  • Use it in DEBUG builds only. Anyone who can reach the port can read and change app state. Wrap the call site in #if DEBUG.

The simulator shares the Mac's loopback interface, so a harness on the Mac connects to 127.0.0.1:<port>. With headless-flow: const app = await connect({ port: 9500 }).

About

Read and drive an app's @observable stores without the UI, over stdin/stdout (NDJSON).

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages