warden

Device conditions

Change the network, GPS location and clock of a simulator or emulator from a spec, a suite or warden ui, and have it undone on release.

warden ui network set offline, edge, online: the app's own requests are shaped, nothing else on the Mac slows down.

A spec can put the device into a condition (offline, 3G, a flapping connection, one host failing, a moving GPS fix, a clock two weeks ahead) and check what the app does. Everything a test changes belongs to its lease: when the test ends, or the lease is released or reclaimed, warden puts it back, so the next agent never inherits a shaped device.

e2e/network-offline.e2e.ts
import { expect, test } from "@delacour/warden-e2e";

test("cold start offline shows the banner, going online recovers the fetch", async ({ app, device, screen }) => {
  await device.network.set("offline"); // before the app opens: a cold start offline
  await app.open();
  await expect(screen.getByTestId("conditions-offline-banner")).toBeVisible({ timeout: 15_000 });

  await device.network.set("online");
  await expect(screen.getByTestId("conditions-offline-banner")).toBeHidden({ timeout: 15_000 });
});

The example app has a Conditions screen (warden-example://conditions) and a spec for each area in apps/example/e2e.

Network

device.network.set(condition) takes "online", "offline", a preset or a custom shape.

PresetLatencyJitterDownUpLoss
edge400 ms25 ms240 kbps200 kbps0
3g100 ms10 ms780 kbps330 kbps0
4g170 ms15 ms9 Mbps9 Mbps0
lte50 ms5 ms50 Mbps10 Mbps0
wifi2 ms040 Mbps33 Mbps0
slow2000 ms100 ms400 kbps400 kbps0
lossy500 ms50 ms1 Mbps1 Mbps10%

{ kind: "custom", latencyMs?, jitterMs?, downKbps?, upKbps?, lossPct? } sets your own.

await device.network.set("3g");
await device.network.during("offline", async () => {
  // the previous condition is back afterwards, also when this throws
});
await device.network.reset(); // back to the suite's conditions.network (online without one)
MemberDoes
set(c, { relaunch? })change the network. A running flap stops first
during(c, fn)run fn under c, then restore
flap({ up, down, jitterPct?, seed?, base? })alternate base (default online) and offline until stop() or the end of the test; returns { stop(), done, status() }
fault(rule | rules)per-host faults; [] clears
status(){ condition, faults, flapping }
reset()stop the flap, clear faults, back to the baseline

Flapping

const flap = await device.network.flap({ up: "1s", down: "1s" });
// ... exercise the retry path ...
await flap.stop();

A fixed seed replays the same sequence. A flap and faults do not combine: reset one before setting the other.

Faults

A fault applies to every request whose host matches a glob.

await device.network.fault({ match: "api.example.com", action: { kind: "reset" } });

Actions: { kind: "status", code }, { kind: "reset" }, { kind: "delay", ms }, { kind: "blackhole" }.

HTTPS requests are tunnelled without being opened, so a status fault cannot answer one: an https:// request matching it is reset, and fault() returns a note saying so. Use reset (or delay / blackhole) for HTTPS hosts and status for plain HTTP.

How it reaches the app

iOS simulatorAndroid emulator
Speed, delay, offlineper-app proxy (a dylib routes the app's NSURLSession traffic through the lease's shaping proxy) and a reachability signalnative: adb emu network, airplane mode
Loss, faultsthe proxythe proxy, through the system http_proxy

A simulator shares the Mac's network, so on iOS a condition reaches only an app that was launched with warden's network dylib. A running app without it fails E_NETWORK_RELAUNCH. Either declare conditions.network for the suite (below), so every launch carries the dylib, or pass { relaunch: true }.

Location

await device.location.set({ lat: 37.3349, lon: -122.009 });
await device.location.route(
  [{ lat: 37.3398, lon: -122.009 }, { lat: 37.3349, lon: -122.009 }, { lat: 37.33, lon: -122.009 }],
  { speedMps: 40, intervalS: 1 },
);
await device.location.route({ gpx: "fixtures/commute.gpx" });
await device.location.scenario("city-run"); // "city-run" | "city-bike" | "freeway"
await device.location.clear();
CallDoes
set({ lat, lon, altM? })a point
route(points | { gpx }, { speedMps?, intervalS?, loop? })waypoints, or a GPX file (relative to the working directory), played at a speed
scenario(name)one of the bundled routes
clear()iOS clears the simulated location. Android stops the ticker and keeps the last fix (the emulator console cannot unset one)

On iOS simctl plays a route once. On Android a ticker owned by the lease sends a fix every interval until the next location call.

Clock

await device.clock.advance("15d");
await device.clock.set("2027-01-01T00:00:00Z", { frozen: true }); // iOS only
await device.clock.during("2027-01-01T00:00:00Z", async () => { /* ... */ });
await device.clock.reset();

Durations are milliseconds or "10s", "2m", "2d". The wall clock moves; monotonic clocks do not, so timers and animations keep running.

  • iOS: the clock changes for an app launched with the network dylib (same rule as the network). A frozen clock holds the wall clock at one instant.
  • Android: the emulator's clock changes. It cannot be frozen (E_UNSUPPORTED).
  • A large offset breaks TLS: certificates look expired or not yet valid, so HTTPS fails while the clock is far from real time. Do not make requests in a spec that advances the clock by weeks.
  • A JS timer armed while the clock was ahead waits for the shifted time once the clock goes back. Re-read the time on demand (the example's clock card has a Refresh button) or relaunch the app after reset().

Timeline

const run = await device.timeline([
  { at: "0s", network: "offline" },
  { at: "5s", network: "online", location: { lat: 37.33, lon: -122.0 } },
]);

at is an offset from now. A step sets a condition, not a flap. The returned run has the same stop(), done and status() as a flap.

Suite baseline: the conditions key

conditions at the top level of warden.config.ts, or inside a suite, is the device state every spec starts from. It is applied before the app launches and put back after each spec.

warden.config.ts
e2e: {
  conditions: {
    ...exampleSuite,
    include: ["{network-*,location-*,clock-*}"],
    conditions: { network: "online" },
  },
},
AreaValue
network"online", "offline", a preset or { latencyMs, jitterMs, downKbps, upKbps, lossPct }
location{ lat, lon, altM? } or a scenario name
clockan ISO instant (it keeps running), { at, frozen? } or { offset: "2d" }

A suite's key replaces the top-level one for that area. On iOS, declaring network or clock launches the app with the dylib, which is why the example runs its condition specs in a separate conditions suite: the other specs keep a plain launch.

warden e2e conditions --specs network-offline,network-slow,location-route,clock-trial

By hand: warden ui

The same conditions are available while you drive a device, on your lease.

warden ui network set 3g
warden ui network flap --up 10s --down 3s
warden ui network fault api.example.com reset
warden ui network status
warden ui network reset
warden ui location set 37.3349,-122.009
warden ui location route 37.3398,-122.009 37.3349,-122.009 --speed 20
warden ui location gpx ./commute.gpx
warden ui location scenario city-run
warden ui location clear
warden ui clock advance 15d
warden ui clock reset

warden ui network log is the request log of the dev client. See warden ui for every flag, including --relaunch.

What is undone on release

Release, gc and the end of a spec put the device back: network online, faults cleared, the shaping proxy, the flap scheduler and the route ticker stopped, the location cleared on iOS, and the clock real again. A still-running injected iOS app is reset to online and the real clock. Android keeps the last GPS fix, because the emulator cannot unset one.

Limitations

  • iOS shapes the app's NSURLSession traffic only. Raw sockets, NWConnection and CFStream bypass the proxy.
  • Android loss and faults go through the system http_proxy: an app that ignores it (a raw socket, Proxy.NO_PROXY) is not covered. Speed, delay and offline are native and reach everything.
  • location.route with loop is unsupported on iOS (E_UNSUPPORTED): simctl has no loop.
  • A frozen clock is iOS only.
  • HTTPS fails while the clock is far from real time.
  • A status fault cannot answer an HTTPS request (it is reset).
  • Physical devices fail E_UNSUPPORTED.
  • Shaping applies to new traffic through the proxy, and an open connection keeps its cost: after warming a connection, a 3g request pays one round trip (the preset's latency), not the three or four of a cold connection. Assert an ordering with a margin, not an absolute number.

On this page