Skip to content

Network

The observed-request model the request / event / requestSequence assertions match against.

bajutsu.evidence.network

Network observation — the exchange model and the in-process collector.

How traffic is observed (DESIGN: network): a Simulator app runs as a host process and shares the Mac's loopback, so the app POSTs each request/response it makes to a small collector bajutsu runs on 127.0.0.1:<port> (the port is injected into the app via launch env, BAJUTSU_COLLECTOR, and a per-run shared token via BAJUTSU_COLLECTOR_TOKEN — the collector accepts only POSTs bearing that token, so another local process can't inject fabricated exchanges). The collector keeps the exchanges in memory so a step's request assertion can be evaluated in real time, and dumps them to network.json as scenario evidence.

The same receiver also accepts screen-transition reports on /transitions (BE-0310): the opt-in BajutsuScreen observer in BajutsuKit (a UIViewController.viewDidAppear hook) POSTs one record per completed appearance. They are kept in an independent store from the network exchanges — the readiness gate and the settled wait read only this one, never network-capture state, so the two stay independent as documented.

The same receiver also carries the in-app control channel (BE-0365): bajutsu queues a command naming one piece of its own in-app instrumentation and the state that piece should take, the app drains the queue over an authenticated GET /commands, and reports back on /commands/ack whether it applied the command. That direction is what lets a capability change within a scenario rather than only at launch, and it needs no new server, port, or authentication scheme — the app opens no socket, and the per-run token above guards the commands exactly as it guards the reports. The channel carries no judgement: nothing on it may influence whether a step passes, and no assertion reads from it.

The in-app side that captures and POSTs the exchanges is a separate Swift package (BajutsuKit); this module is only the bajutsu-side receiver and data model.

NetworkExchange

Bases: BaseModel

One request/response the app reported.

Extra keys from the SDK are ignored (forward-compatible); field names accept their JSON aliases.

ScreenTransition

Bases: BaseModel

One screen-transition event the app's BajutsuScreen observer reported (BE-0310).

Minimal by design: no screen content, only what a positive "the transition finished" signal needs. Extra keys are ignored and the app's own timestamp is informational only — the collector stamps its own receive time (snapshot_timed), the same monotonic clock domain the readiness gate and the settled wait already poll in, so nothing here depends on the app process's separate clock.

InAppCapability

Bases: StrEnum

A piece of bajutsu's own in-app instrumentation the control channel may address (BE-0365).

Closed on purpose, and that is the boundary rather than a comment about it: the channel controls what bajutsu put inside the app, never the application's own state. A command that seeded app data or drove navigation would move per-app knowledge into the tool (prime directive 3), so a new capability is argued for here instead of being named as a free string at a call site.

AppCommand

Bases: BaseModel

One command bajutsu asks the running app to apply (BE-0365).

bajutsu-side only — the collector serializes these out and never parses one back, so this model is strict and frozen rather than forward-compatible like the reports the app POSTs. It carries no judgement: nothing here may influence whether a step passes, and no assertion reads it.

enabled is the whole state a capability takes today, because the instrumentation the channel reaches is a toggle. A capability whose state is not a toggle (a mid-scenario stub table, BE-0365 unit 4) arrives as a sibling model discriminated on capability, not as another optional field here: widening this one would make the invalid cross-product — a stub table with no table, a toggle carrying one — representable, and leave a validator to rule out what a union rules out structurally (the shape config/effective.py already argues for).

AppCommandReport

Bases: BaseModel

The app's report on one command it drained (BE-0365).

Inbound, so forward-compatible like the exchange and transition reports — but applied carries no default, because "applied it" and "drained it and could not apply it" must not reach the acknowledgement wait as the same message, and a default would quietly make one of them the other. An app whose capability was compiled out, or whose handler raised, says so here with its own reason, so the wait fails with the cause rather than timing out blind (BE-0365 unit 3).

Collector

Bases: Protocol

The exchange source the run loop and evidence writer drive.

Independent of how it observed the traffic: the iOS NetworkCollector receives POSTs over HTTP; the web WebNetworkCollector hooks Playwright events — both satisfy this, so the pipeline stays backend-agnostic.

NetworkCollector

Receives exchanges POSTed by the app and holds them for assertion + evidence.

Thread-safe: the HTTP server runs on a background thread while the run loop reads snapshot() on the main thread. clear() between scenarios scopes the exchanges.

add(data)

Validate and store one reported exchange.

A payload that fails validation is dropped rather than raised, so an SDK change can't break the run mid-flight (forward-compatible, matching NetworkExchange's extra="ignore").

add_transition(data)

Validate and store one reported screen-transition event (BE-0310).

Same forward-compatible drop-on-failure behavior as add, and stored in its own list so the readiness/settled signal never depends on network-capture state.

enqueue_command(capability, *, enabled)

Queue one command for the app to drain, and return the id that identifies it.

Parameters:

Name Type Description Default
capability InAppCapability

which piece of bajutsu's in-app instrumentation the command addresses.

required
enabled bool

the state that capability should take.

required

Returns:

Type Description
str

The command's id, to condition-wait on through report_for (BE-0365 unit 3).

drain_commands()

Take every pending command, leaving the queue empty.

Draining under the lock bounds delivery at at most once: two polls racing cannot both take the same command and have the app apply it twice. It buys nothing about the reply — the queue is emptied before the response is written, so a reply lost in flight (a killed app, a client timeout, a reset peer) is not redelivered. That loss surfaces as the acknowledgement wait's loud timeout (BE-0365 unit 3), never as a second application, so a caller must not read a successful drain as proof the app received anything.

record_report(data)

Store the app's report on one command; false when it names no command this run issued.

Refusing a payload rather than dropping it is the one place this collector departs from add / add_transition's forward-compatible drop: a report is the only news the acknowledgement wait ever gets, so one bajutsu cannot read has to fail visibly (the handler answers 400) instead of leaving the wait to time out as though the app had stayed silent. Requiring the id to be one this run issued is the same guarantee against a stale report straggling in across a clear() and releasing the next scenario's wait.

Neither refusal is recorded anywhere else, so the wait a refused report was meant for still fails by timing out rather than by naming the report bajutsu turned away.

report_for(command_id)

The app's report on this command, or None while none has arrived.

Three answers, none of them collapsed into another: None keeps the acknowledgement wait waiting, applied=False fails it at once with the app's own reason, and applied=True releases it (BE-0365 unit 3).

check_token(candidate)

Constant-time compare of a presented token against this run's token.

Mirrors serve's own token check; false before start() mints a token.

snapshot()

The exchanges received so far, in arrival order.

snapshot_timed()

Each exchange with its receive time (monotonic), in arrival order.

transitions_snapshot_timed()

Each observed screen-transition event with its receive time, in arrival order.

clear()

Drop everything scoped to one scenario — exchanges, transitions, and channel state.

start(port=0)

Start the receiver on the loopback interface and begin accepting the app's POSTs.

Parameters:

Name Type Description Default
port int

TCP port to bind on 127.0.0.1; 0 requests an ephemeral port.

0

Returns:

Type Description
int

The actual bound port (resolved when port is 0), to inject into the app via

int

BAJUTSU_COLLECTOR.

start_bridgeable()

Start the receiver on a port a leased device can mirror, for the adb reverse bridge.

start()'s OS-chosen port is host-local, but the adb backend tunnels the collector with adb reverse tcp:<port> tcp:<port> (BE-0283) — the emulator has to bind the same number. An OS-chosen port comes from the host's ephemeral range, which is the guest's as well (32768-60999 on Linux), so it lands where the emulator is already handing ports out to its own sockets; when the guest happens to hold that one, adbd's bind fails and the bridge dies with cannot bind listener: Address already in use, taking the lease with it. Any guest socket collides, not just a listener — an outbound connection, or one left in TIME_WAIT.

Preferring a band below both ephemeral ranges removes that collision class rather than narrowing it: nothing on either side allocates these ports by chance, so the number is free on the device precisely because it was free on the host.

Returns:

Type Description
int

The bound port, as start() does.

Raises:

Type Description
OSError

no reserved-band port was bindable. An occupancy error (EADDRINUSE) on one port advances to the next; any other bind error, and exhausting the whole band, raise rather than fall back to an OS-chosen ephemeral port — that fallback would sit in the shared range and reopen the guest-side collision this method removes.

stop()

Stop the receiver and release its socket. Idempotent — a no-op if never started.