Skip to content

network: Network.emulateNetworkConditions not implemented #2470

@navidemad

Description

@navidemad

Summary

Network.emulateNetworkConditions is the standard CDP entry point for toggling a target offline (or applying latency / bandwidth throttling). Lightpanda's Network domain doesn't dispatch the method, so every call rejects with -31998 UnknownMethod. Clients that rely on the offline toggle to assert PWA / service-worker / "you are offline" UI behaviour can't drive that branch through Lightpanda — they get neither real offline emulation nor a "best-effort" no-op, just an outright dispatch error that aborts the test.

Today's behavior

Network.emulateNetworkConditions is missing from the action enum in src/cdp/domains/network.zig (processMessage). Any CDP client (chrome-remote-interface, Puppeteer, Playwright, Selenium's execute_cdp, raw websocket) sending the method receives -31998 UnknownMethod and can neither emulate offline conditions nor branch on whether the method exists — the rejection is indistinguishable from any other unknown-method response.

sequenceDiagram
    participant Client as CDP Client
    participant LP as Lightpanda
    participant Net as HttpClient + curl
    Client->>LP: Network.emulateNetworkConditions offline=true
    LP-->>Client: error -31998 UnknownMethod
    Note over LP: action enum has no entry, processMessage rejects
    Client->>LP: fetch via Runtime.evaluate
    LP->>Net: HTTP GET /api/foo
    Net-->>LP: 200 OK
    LP-->>Client: response, offline toggle ignored
Loading

Expected behavior

Per the CDP protocol reference, Network.emulateNetworkConditions takes {offline, latency, downloadThroughput, uploadThroughput, connectionType?}. The offline flag is the load-bearing field for tests asserting PWA / offline-fallback UI: when true, subsequent HTTP transfers must fail as if the network were unreachable (Chrome surfaces this through Network.loadingFailed with errorText: "net::ERR_INTERNET_DISCONNECTED"; the in-page fetch / XHR reject with a TypeError).

Latency / bandwidth / connectionType are best-effort hints in Chrome and don't have to be honoured in a headless engine without a layout/timing pipeline — accepting them for protocol compatibility (and logging via .not_implemented) is sufficient. The offline toggle, however, must actually block transfers; otherwise the method is observationally a no-op that lies about success.

sequenceDiagram
    participant Client as CDP Client
    participant LP as Lightpanda
    participant Net as HttpClient + curl
    Client->>LP: Network.emulateNetworkConditions offline=true
    LP->>Net: set HttpClient.offline = true
    LP-->>Client: success
    Client->>LP: fetch via Runtime.evaluate
    LP->>Net: request()
    Net-->>LP: error.InternetDisconnected, curl not called
    LP-->>Client: Network.loadingFailed and JS TypeError
    Client->>LP: Network.emulateNetworkConditions offline=false
    LP->>Net: set HttpClient.offline = false
    LP-->>Client: success
    Client->>LP: fetch via Runtime.evaluate
    LP->>Net: HTTP GET /api/foo, curl path restored
    Net-->>LP: 200 OK
    LP-->>Client: response
Loading

Reproducer

Self-contained: one HTML fixture, one shell harness, one Node CDP driver. The harness starts lightpanda serve and a Python HTTP server, then drives the browser via raw websocket (ws) to (1) confirm a baseline fetch succeeds, (2) call Network.emulateNetworkConditions {offline: true}, (3) re-attempt the fetch and assert it now rejects, (4) toggle back online and assert recovery.

repro.html:

<!doctype html>
<title>emulateNetworkConditions repro</title>
<body>
  <p id="status">idle</p>
  <script>
    window.__attempt = async function attempt(url) {
      const out = document.getElementById('status');
      try {
        const r = await fetch(url, { cache: 'no-store' });
        out.textContent = 'ok:' + r.status;
        return { ok: true, status: r.status };
      } catch (e) {
        out.textContent = 'err:' + e.message;
        return { ok: false, error: String(e) };
      }
    };
  </script>
</body>

probe.js:

const { connect } = require('./cdp.js');

(async () => {
  const c = await connect();
  await c.navigate('http://127.0.0.1:8765/repro.html');

  const baseline = JSON.parse(await c.eval(
    "window.__attempt('http://127.0.0.1:8765/repro.html').then(r => JSON.stringify(r))",
    { awaitPromise: true },
  ));
  if (!baseline.ok) { console.error('UNEXPECTED: baseline failed'); c.close(); process.exit(2); }

  try {
    await c.send('Network.emulateNetworkConditions', {
      offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1,
    });
  } catch (e) {
    console.error('FAIL: Network.emulateNetworkConditions rejected:', e.message);
    c.close(); process.exit(1);
  }

  const offline = JSON.parse(await c.eval(
    "window.__attempt('http://127.0.0.1:8765/repro.html').then(r => JSON.stringify(r))",
    { awaitPromise: true },
  ));
  if (offline.ok) { console.error('FAIL: fetch succeeded while offline'); c.close(); process.exit(1); }

  await c.send('Network.emulateNetworkConditions', {
    offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1,
  });
  const recovered = JSON.parse(await c.eval(
    "window.__attempt('http://127.0.0.1:8765/repro.html').then(r => JSON.stringify(r))",
    { awaitPromise: true },
  ));
  if (!recovered.ok) { console.error('FAIL: did not recover'); c.close(); process.exit(1); }

  console.log('PASS:', { baseline, offline, recovered });
  c.close(); process.exit(0);
})().catch((e) => { console.error('PROBE ERROR:', e); process.exit(2); });

repro.sh (orchestrator — kills any prior listener on the CDP/HTTP ports, starts lightpanda serve + python3 -m http.server, asserts /json/version reports a Lightpanda listener, then runs node probe.js):

#!/usr/bin/env bash
set -euo pipefail
REPRO_DIR="$(cd "$(dirname "$0")" && pwd)"
LIGHTPANDA_BIN="${LIGHTPANDA_BIN:-lightpanda}"
HTTP_PORT="${HTTP_PORT:-8765}"
CDP_PORT="${CDP_PORT:-9222}"
lsof -ti:"$CDP_PORT"  2>/dev/null | xargs -r kill 2>/dev/null || true
lsof -ti:"$HTTP_PORT" 2>/dev/null | xargs -r kill 2>/dev/null || true
PIDS=()
cleanup() { for pid in "${PIDS[@]:-}"; do kill "$pid" 2>/dev/null || true; done; }
trap cleanup EXIT
cd "$REPRO_DIR"
python3 -m http.server "$HTTP_PORT" >http.log 2>&1 & PIDS+=("$!")
LIGHTPANDA_DISABLE_TELEMETRY=true "$LIGHTPANDA_BIN" serve --host 127.0.0.1 --port "$CDP_PORT" >lightpanda.log 2>&1 & PIDS+=("$!")
for _ in $(seq 1 50); do
  body="$(curl -fsS "http://127.0.0.1:${CDP_PORT}/json/version" 2>/dev/null || true)"
  if [[ "$body" == *'"Browser": "Lightpanda'* ]]; then break; fi
  sleep 0.1
done
[ -d node_modules/ws ] || npm install --no-save --silent ws
exec node probe.js

cdp.js is a ~150-LOC websocket wrapper (CDP create-target + attach-session + auto-sessionId per call); happy to inline it on request.

Run (against a stock build, no fix applied):

$ bash repro.sh
FAIL: Network.emulateNetworkConditions rejected: -31998: UnknownMethod
$ echo $?
1

Run (against a build with the fix):

$ LIGHTPANDA_BIN=./zig-out/bin/lightpanda bash repro.sh
PASS: offline toggle blocks fetch, online toggle restores it.
  baseline   : { ok: true, status: 200 }
  offline=true: { ok: false, error: 'TypeError: fetch error' }
  offline=false: { ok: true, status: 200 }
$ echo $?
0

Likely fix location

src/cdp/domains/network.zigprocessMessage action enum + a new emulateNetworkConditions handler. The offline toggle itself is a single boolean checked at the top of Client.request in src/browser/HttpClient.zig before the layer chain. Latency / bandwidth / connectionType params can be accepted for protocol compatibility and logged via .not_implemented until a future PR wires them through.

Environment

  • Lightpanda: nightly 1.0.0-dev.6246+4f33d64c5 (HEAD of main as of filing)
  • OS: macOS 14 (darwin aarch64)
  • CDP client: raw ws Node module over the standard CDP websocket

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions