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 |
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 |
0
|
Returns:
| Type | Description |
|---|---|
int
|
The actual bound port (resolved when |
int
|
|
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 |
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.