diff --git a/.codacy.yaml b/.codacy.yaml index b31ea6e..68d2b2b 100644 --- a/.codacy.yaml +++ b/.codacy.yaml @@ -22,6 +22,10 @@ # - services/ws-modules/wasi-{comm1,data1}/src/coverage.rs: each guest's minicov coverage dump, whose unsafe # Codacy flags for audit and cannot suppress per line. Isolated into these one-function files so the exclude # stays minimal; rationale in each file's header. The rest of each guest stays analyzed. +# - services/ws-test-server/src/bin/cov-server.rs: the wasm-agent-cov launcher reads its marker-file path from +# argv (args_os); Codacy's Rust security rule flags args_os-into-a-file-operation and cannot suppress per line. +# A false positive -- the path comes from the trusted mise task -- and taking it as an argument beats hardcoding +# one. Already this one minimal file; rationale lives in its header. Still covered by clippy + DeepSource Rust. # - utilities/wasm-cov-wrapper/src/main.rs: a RUSTC_WORKSPACE_WRAPPER that reads its own argv (args_os) and forwards # it to rustc; Codacy's Rust security rule flags args_os-into-a-subprocess as a command-injection shape and cannot # suppress per line. It is a false positive -- the args are cargo's own trusted rustc invocation -- and forwarding @@ -39,6 +43,7 @@ exclude_paths: - "services/ws-modules/dotnet-data1/Program.cs" - "services/ws-modules/wasi-comm1/src/coverage.rs" - "services/ws-modules/wasi-data1/src/coverage.rs" + - "services/ws-test-server/src/bin/cov-server.rs" - "services/ws-web-runner/mingw-shim/msvc_crt_alloc.c" - "services/ws-web-runner/mingw-shim/msvc_crt_locale_cache.c" - "utilities/wasm-cov-wrapper/src/main.rs" diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 33f5016..5ce3543 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -79,6 +79,16 @@ jobs: timeout-minutes: 10 run: mise run wasm-cov + # The browser ws-wasm-agent has no native tests, so it is invisible to the cargo-llvm-cov run above. + # This runs its wasm-bindgen tests in the runner's headless Chrome against an in-process ws-server and folds + # the agent lib's coverage into the same lcov.info. CHROMEWEBDRIVER is the GitHub image's chromedriver that + # matches its preinstalled Chrome; the task falls back to the mise-pinned http:chromedriver when it is unset. + - name: Merge ws-wasm-agent coverage into lcov.info + timeout-minutes: 20 + run: | + export CHROMEDRIVER="${CHROMEWEBDRIVER:+$CHROMEWEBDRIVER/chromedriver}" + mise run wasm-agent-cov + - name: Collect Python coverage timeout-minutes: 15 env: diff --git a/.mise/config.coverage.toml b/.mise/config.coverage.toml index 8e0afeb..ddcc4bf 100644 --- a/.mise/config.coverage.toml +++ b/.mise/config.coverage.toml @@ -161,3 +161,113 @@ shell = "bash -euo pipefail -c" [tasks.pytest-cov.env] COVERAGE_RCFILE = "{{ config_root }}/config/coverage.toml" UV_PYTHON = "{% if os() == 'windows' %}{{ vars.py3_win }}{% else %}{{ vars.py3_unix }}{% endif %}" + +[tasks.wasm-agent-cov] +depends = ["build-wasm-cov-wrapper"] +description = "Coverage for the browser ws-wasm-agent: run its wasm-bindgen tests headless, emit lcov into lcov.info" +# The agent is a browser wasm client with no native tests, so its lib is invisible to the cargo-llvm-cov run. +# This drives its wasm-bindgen tests (tests/client.rs) in a real headless Chrome and folds the result into the +# same lcov.info the other coverage tasks feed. Two pieces make it work: +# 1. A live backend. tests/client.rs covers the offline paths on its own, but the connected paths (onopen, +# the connect-ack dispatch, the online send/flush/keepalive) need a server, so we start the in-process +# et-ws-test-server (its cov-server bin) on the fixed port the tests dial, wait for its readiness marker, +# and kill it after. It is built without the coverage wrapper, so it stays uninstrumented. +# 2. Instrumentation. wasm-bindgen-test 0.3 has built-in coverage under --cfg wasm_bindgen_unstable_test_coverage +# (it pulls minicov and exports __wbgtest_cov_dump, which the test runner POSTs back and writes to +# LLVM_PROFILE_FILE). The workspace wrapper adds -Cinstrument-coverage --emit=llvm-ir to our crates only, +# and --features coverage links minicov's profiler runtime the same way the browser modules do. +# The .profraw -> lcov conversion mirrors wasm-cov (gut every .ll body to `unreachable`, keep the covmap, llc to a +# fixed x86_64 ELF object, then llvm-cov), with one addition: the test binary links several workspace crates and +# the agent's generic message helpers monomorphize into the test crate's object, so we compile every workspace +# .ll (not one) and keep only the agent lib's records. Gutting can leave an attribute group empty, which llc +# rejects, so emptied groups get a benign `nounwind`. +run = """ +covdir=target/agent-cov +coreutils mkdir -p "$covdir" +coreutils rm -f "$covdir"/*.profraw "$covdir"/*.o "$covdir"/*.g.ll "$covdir/server-ready" +# Force the agent crate (and its wasm test binary) to recompile so this run emits a fresh instrumented .ll. +# Its covmap must match the .profraw we are about to capture, but cargo only re-emits `--emit=llvm-ir` when a +# crate actually compiles, so on a warm cache a stale or missing .ll would otherwise make llvm-cov drop the +# agent's functions (a bare `cargo test` cache hit yields only the generic helpers' covmap, which monomorphize +# into the test crate's object). Only the agent's .ll is needed; other workspace crates' .ll are dropped by the +# lib-only keep filter below whether fresh, stale, or absent. +rustup target add wasm32-unknown-unknown +cargo clean -p et-ws-wasm-agent --target wasm32-unknown-unknown + +# Resolve the webdriver, honouring a CHROMEDRIVER already in the environment before the mise-pinned one. +# CI points CHROMEDRIVER at the runner's chromedriver (matched to its preinstalled Chrome); locally this falls +# back to the mise-managed http:chromedriver. +driver="${CHROMEDRIVER:-$(mise which chromedriver)}" + +# Build + run cov-server itself instrumented so the launcher is not reported as untested. +# It uses native -Cinstrument-coverage and flushes its counters to this .profraw on the clean exit that removing +# the marker below triggers. +RUSTFLAGS="-Cinstrument-coverage" cargo build -q -p et-ws-test-server --bin cov-server +LLVM_PROFILE_FILE="{{ config_root }}/$covdir/cov-server-%p.profraw" ./target/debug/cov-server "$covdir/server-ready" & +server_pid=$! +trap 'kill "$server_pid" 2>/dev/null || true' EXIT +for _ in $(coreutils seq 1 100); do [ -s "$covdir/server-ready" ] && break; coreutils sleep 0.1; done +[ -s "$covdir/server-ready" ] || { echo "cov-server never became ready"; exit 1; } + +# Export the instrumentation env for the coverage test build. +# Trailing-backslash line continuations are banned repo-wide, so each variable is set on its own line; no later +# step invokes cargo, so these staying set for the rest of the script is harmless. +runner="$(mise which wasm-bindgen-test-runner)" +export RUSTC_WORKSPACE_WRAPPER="{{ config_root }}/target/debug/int-wasm-cov-wrapper" +export RUSTFLAGS="--cfg wasm_bindgen_unstable_test_coverage" +export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER="$runner" +export CHROMEDRIVER="$driver" +export LLVM_PROFILE_FILE="{{ config_root }}/$covdir/wasm-agent-%p.profraw" +cargo test -p et-ws-wasm-agent --features coverage --target wasm32-unknown-unknown --test client + +# Remove the marker so cov-server returns from main and flushes its coverage, then reap it (a kill would drop it). +coreutils rm -f "$covdir/server-ready" +wait "$server_pid" 2>/dev/null || true +trap - EXIT + +host="$(rustc +nightly -vV | goawk '/^host:/ { print $2 }')" +llbin="$(rustc +nightly --print sysroot)/lib/rustlib/$host/bin" +gut="$covdir/gut.awk" +coreutils cat > "$gut" <<'AWK' +/^target datalayout/ { next } +/^target triple/ { next } +/^define/ { print; print "start:"; print " unreachable"; print "}"; skip = 1; next } +skip && /^}/ { skip = 0; next } +skip { next } +{ + gsub(/"target-cpu"="[^"]*"/, "") + gsub(/"target-features"="[^"]*"/, "") + if ($0 ~ /^attributes #/ && $0 ~ /\\{[[:space:]]*\\}/) sub(/\\{[[:space:]]*\\}/, "{ nounwind }") + print +} +AWK +"$llbin/llvm-profdata" merge -sparse -o "$covdir/agent.profdata" "$covdir"/wasm-agent-*.profraw +objs=() +for ll in target/wasm32-unknown-unknown/debug/deps/*.ll; do + name="$(coreutils basename "$ll" .ll)" + goawk -f "$gut" "$ll" > "$covdir/$name.g.ll" + "$llbin/llc" -filetype=obj -mtriple=x86_64-unknown-linux-gnu -o "$covdir/$name.o" "$covdir/$name.g.ll" + objs+=("-object" "$covdir/$name.o") +done +"$llbin/llvm-cov" export --format=lcov --instr-profile "$covdir/agent.profdata" "${objs[@]}" > "$covdir/all.lcov" +keep="$covdir/keep.awk" +coreutils cat > "$keep" <<'AWK' +{ buf = buf $0 ORS } +/^SF:/ { keep = index($0, want) > 0 } +/^end_of_record$/ { if (keep) printf "%s", buf; buf = ""; keep = 0 } +AWK +coreutils touch lcov.info +goawk -v want="ws-wasm-agent/src/" -f "$keep" "$covdir/all.lcov" >> lcov.info + +# Fold cov-server's own native coverage in from the instrumented run above (its cov-server-*.profraw). +# The export object links its whole dep tree, so keep only the cov-server.rs launcher records. +csp="$covdir/cov-server" +"$llbin/llvm-profdata" merge -sparse -o "$csp.profdata" "$covdir"/cov-server-*.profraw +"$llbin/llvm-cov" export --format=lcov --instr-profile "$csp.profdata" -object target/debug/cov-server > "$csp.lcov" +goawk -v want="ws-test-server/src/bin/cov-server.rs" -f "$keep" "$csp.lcov" >> lcov.info + +rpt="$covdir/report.txt" +"$llbin/llvm-cov" report --instr-profile "$covdir/agent.profdata" "${objs[@]}" > "$rpt" 2>/dev/null || true +rg "ws-wasm-agent/src/lib.rs|Filename|TOTAL" "$rpt" || true +""" +shell = "bash -euo pipefail -c" diff --git a/.mise/config.toml b/.mise/config.toml index aee35db..c63708a 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -65,17 +65,12 @@ cargo-binstall = "latest" "cargo:cargo-expand" = { version = "latest", os = ["linux", "macos"] } "cargo:open" = "latest" "cargo:wasm-opt" = { version = "latest", os = ["linux", "macos"] } -"github:nextest-rs/nextest" = "latest" -taplo = "latest" -watchexec = "latest" -# Chromedriver disabled (commented out below). -# Only used by `test-ws-wasm-agent-chrome` / `ws-e2e-chrome`, neither of which run in the standard CI `test` -# flow. Disabling chromedriver lets us also drop the vfox backend (see `[settings] disable_backends` above) -# since chromedriver was the only [tools] entry that defaulted to vfox. -# "chromedriver" = { version = "146", os = ["linux", "macos"] } cmake = "latest" "conda:openssl" = "3" conftest = "latest" +"github:nextest-rs/nextest" = "latest" +taplo = "latest" +watchexec = "latest" # uutils coreutils: the Rust multicall binary, invoked as `coreutils ` in tasks. # Pinned to 0.6.0 (NOT the latest 0.9.0) because uutils 0.7.0+ ships the Windows release as 85 per-utility # .exes with no multicall coreutils.exe (see uutils/coreutils#11268). 0.6.0 keeps the multicall layout @@ -243,8 +238,26 @@ url = "https://downloads.openobserve.ai/releases/openobserve/v0.70.3/openobserve url = "https://downloads.openobserve.ai/releases/openobserve/v0.70.3/openobserve-v0.70.3-linux-amd64.tar.gz" [tools."http:openobserve".platforms.macos-arm64] url = "https://downloads.openobserve.ai/releases/openobserve/v0.70.3/openobserve-v0.70.3-darwin-arm64.tar.gz" -[tools."http:openobserve".platforms.macos-x64] -url = "https://downloads.openobserve.ai/releases/openobserve/v0.70.3/openobserve-v0.70.3-darwin-amd64.tar.gz" + +# chromedriver from Google's Chrome for Testing CDN, for the headless-browser wasm-bindgen tests. +# The `os` list uses mise's os/arch compound syntax (mise >= 2026.4, jdx/mise#9088) to skip linux-arm64: Chrome +# for Testing publishes no linux-arm64 chromedriver, and a bare `linux` entry would hard-fail `mise install` on +# the linux-arm64 lanes ("No URL for platform linux-arm64"). `linux/x64` matches only that arch; bare `macos` +# matches both. Immutable per-version URLs mean no checksum -- the same direct-upstream posture as +# http:openobserve. mise auto-strips the single `chromedriver-/` archive dir, leaving the `chromedriver` +# binary at the install root. Consumers: test-ws-wasm-agent-chrome / ws-e2e-chrome and the wasm-agent-cov coverage +# task (which in CI drives Chrome via the runner's matched CHROMEWEBDRIVER instead). The major version must track +# the Chrome it drives (a >1-major skew makes chromedriver refuse the session); bump this pin when that moves. +[tools."http:chromedriver"] +bin = "chromedriver" +os = ["linux/x64", "macos"] +version = "150.0.7871.124" +[tools."http:chromedriver".platforms.linux-x64] +url = "https://storage.googleapis.com/chrome-for-testing-public/150.0.7871.124/linux64/chromedriver-linux64.zip" +[tools."http:chromedriver".platforms.macos-arm64] +url = "https://storage.googleapis.com/chrome-for-testing-public/150.0.7871.124/mac-arm64/chromedriver-mac-arm64.zip" +[tools."http:chromedriver".platforms.macos-x64] +url = "https://storage.googleapis.com/chrome-for-testing-public/150.0.7871.124/mac-x64/chromedriver-mac-x64.zip" [tools."http:augeas"] bin = "augtool" @@ -1228,7 +1241,7 @@ description = "Run both the ws-server and ws-wasm-agent using Firefox" [tasks.test-ws-wasm-agent-chrome] description = "Run headless Chrome tests for the WebSocket WASM client" dir = "services/ws-wasm-agent" -run = "env CHROMEDRIVER=\"$(mise where chromedriver)/bin/chromedriver\" wasm-pack test --headless --chrome" +run = "env CHROMEDRIVER=\"$(mise which chromedriver)\" wasm-pack test --headless --chrome" [tasks.ws-e2e-chrome] depends = ["test-ws-wasm-agent-chrome", "ws-server"] diff --git a/Cargo.lock b/Cargo.lock index eeb325d..23dd0fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4670,6 +4670,7 @@ dependencies = [ "et-storage-service", "et-test-helpers", "et-ws-service", + "fs-err", "futures-util", "int-otlp-mock", "retry", diff --git a/config/conftest/policy/mise/mise.rego b/config/conftest/policy/mise/mise.rego index 07639cb..94a065a 100644 --- a/config/conftest/policy/mise/mise.rego +++ b/config/conftest/policy/mise/mise.rego @@ -148,7 +148,7 @@ allowed_os_scoped_tool := { # action-validator (aqua) has no Windows build, so it is os-scoped off Windows. # config.windows.toml installs cargo:action-validator there instead (cargo-quickinstall msvc prebuilt). "action-validator", - "chromedriver", + "http:chromedriver", "pipx", "pipx:torch", "npm:pnpm", diff --git a/services/ws-test-server/Cargo.toml b/services/ws-test-server/Cargo.toml index 077808c..f46d8a1 100644 --- a/services/ws-test-server/Cargo.toml +++ b/services/ws-test-server/Cargo.toml @@ -18,6 +18,7 @@ et-modules-service.workspace = true et-storage-service.workspace = true et-test-helpers.workspace = true et-ws-service.workspace = true +fs-err.workspace = true futures-util.workspace = true serde_json.workspace = true tempfile.workspace = true diff --git a/services/ws-test-server/src/bin/cov-server.rs b/services/ws-test-server/src/bin/cov-server.rs new file mode 100644 index 0000000..9578a49 --- /dev/null +++ b/services/ws-test-server/src/bin/cov-server.rs @@ -0,0 +1,38 @@ +//! Long-lived in-process ws-server for the `wasm-agent-cov` coverage task. +//! +//! Starts the same hub the integration tests use on a fixed port (the one the browser wasm-agent tests connect +//! to), writes a readiness marker to the file named by the first CLI argument once the server is accepting, then +//! stays up until the task deletes that marker -- its stop signal. Returning from `main` (rather than being +//! killed) lets the coverage-instrumented build flush its counters on a clean exit; a SIGKILL would drop them. +//! +//! Excluded from Codacy analysis (`.codacy.yaml`): Codacy's Rust security rule flags `args_os()` flowing into a +//! file operation as a path-traversal shape and cannot suppress it per line. That is a false positive here -- the +//! single argument is the marker path the trusted `wasm-agent-cov` mise task passes -- and taking that path as an +//! argument (rather than hardcoding one) is the point. The crate exposes this as its own minimal file so the path +//! exclude stays narrow; it is still covered by clippy and `DeepSource`'s Rust analyzer. + +use std::error::Error; +use std::path::PathBuf; +use std::time::Duration; + +use fs_err as fs; + +/// Fixed port the browser wasm-agent tests dial (`ws://127.0.0.1:8080/ws`), matching the ws-server port the +/// existing `web.rs` end-to-end test and `ws-e2e-chrome` use. +const COV_SERVER_PORT: u16 = 8080; + +fn main() -> Result<(), Box> { + let ready_path = std::env::args_os() + .nth(1) + .map(PathBuf::from) + .ok_or("usage: cov-server ")?; + + let server = et_ws_test_server::start_on(COV_SERVER_PORT); + fs::write(&ready_path, server.ws_url.as_bytes())?; + + // Stay up until the task removes the readiness marker, then fall through to a clean exit so coverage flushes. + while ready_path.exists() { + std::thread::sleep(Duration::from_millis(100)); + } + Ok(()) +} diff --git a/services/ws-test-server/src/lib.rs b/services/ws-test-server/src/lib.rs index 639498a..7e820ed 100644 --- a/services/ws-test-server/src/lib.rs +++ b/services/ws-test-server/src/lib.rs @@ -33,11 +33,18 @@ pub struct TestServer { /// Serves modules from the default module paths (same as production). #[must_use] pub fn start() -> TestServer { + start_on(et_test_helpers::reserve_port()) +} + +/// Start an in-process ws-server bound to a specific `port` with a temporary storage directory. +/// +/// Like [`start`], but for callers that must know the port ahead of time (e.g. a fixed-port launcher a separate +/// process connects to). Panics if the port is already in use. +#[must_use] +pub fn start_on(port: u16) -> TestServer { let storage_dir = TempDir::new().unwrap(); let storage_path = storage_dir.path().to_path_buf(); - let port = et_test_helpers::reserve_port(); - let storage_config = StorageConfig::new(storage_path); let modules_config = ModulesConfig::default(); let addr = format!("127.0.0.1:{port}"); diff --git a/services/ws-wasm-agent/Cargo.toml b/services/ws-wasm-agent/Cargo.toml index 470d5a5..6533bfd 100644 --- a/services/ws-wasm-agent/Cargo.toml +++ b/services/ws-wasm-agent/Cargo.toml @@ -38,5 +38,11 @@ web-sys = { workspace = true, features = [ wasm-bindgen-futures.workspace = true wasm-bindgen-test.workspace = true +# Coverage instrumentation, off by default and enabled only by the `wasm-agent-cov` mise task's test build. +# Forwards to et-web/coverage, which links minicov's profiler runtime (`__llvm_profile_runtime`) that the +# instrumented workspace crates reference -- the same mechanism the browser ws-modules use for coverage. +[features] +coverage = ["et-web/coverage"] + [lints] workspace = true diff --git a/services/ws-wasm-agent/tests/client.rs b/services/ws-wasm-agent/tests/client.rs new file mode 100644 index 0000000..d656862 --- /dev/null +++ b/services/ws-wasm-agent/tests/client.rs @@ -0,0 +1,266 @@ +//! Server-independent coverage of the WASM agent's client logic. +//! +//! `web.rs` drives a live end-to-end connection (needs a running ws-server); this file exercises everything the +//! client does without a server -- configuration, connection state transitions, the offline send queue, the DOM +//! textarea helpers, and the JS reflection helpers -- so it runs headless with no backend. It is what the +//! `wasm-agent-cov` mise task builds instrumented to measure the agent's coverage. +#![cfg(test)] +#![cfg(target_arch = "wasm32")] + +use std::cell::RefCell; +use std::rc::Rc; + +use et_ws_wasm_agent::{ + WsClient, WsClientConfig, append_to_textarea, create_and_connect, init_tracing, js_bool_field, js_nested_object, + js_number_field, set_textarea_value, +}; +use js_sys::{Object, Promise, Reflect}; +use serde_json::json; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::Closure; +use wasm_bindgen_futures::JsFuture; +use wasm_bindgen_test::*; + +wasm_bindgen_test_configure!(run_in_browser); + +fn new_client(url: &str) -> WsClient { + WsClient::new(WsClientConfig::new(url.to_string())) +} + +/// The live ws-server URL for the connected-path tests. +/// +/// A backend must be listening here: the `wasm-agent-cov` mise task starts the in-process et-ws-test-server on +/// this port, and `ws-e2e-chrome` runs the real ws-server on it too. +const SERVER_URL: &str = "ws://127.0.0.1:8080/ws"; + +async fn sleep(ms: i32) { + let promise = Promise::new(&mut |resolve, _reject| { + let window = web_sys::window().unwrap(); + let _id = window + .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms) + .unwrap(); + }); + let _resolved = JsFuture::from(promise).await.unwrap(); +} + +#[wasm_bindgen_test] +fn config_setters_and_initial_state() { + let mut config = WsClientConfig::new("ws://127.0.0.1:8080/ws".to_string()); + config.set_alive_interval(2_000); + config.set_max_reconnect_attempts(3); + config.set_initial_reconnect_delay(500); + + // Clear any agent id a prior test's connect may have persisted so the fresh-client assertion is stable. + let storage = web_sys::window().unwrap().local_storage().unwrap().unwrap(); + storage.clear().unwrap(); + + let client = WsClient::new(config); + assert_eq!(client.get_state(), "disconnected"); + assert_eq!(client.get_agent_id(), ""); +} + +#[wasm_bindgen_test] +fn connect_moves_to_connecting() { + let mut client = new_client("ws://127.0.0.1:8080/ws"); + client.connect().unwrap(); + assert_eq!(client.get_state(), "connecting"); +} + +#[wasm_bindgen_test] +fn create_and_connect_helper_builds_connecting_client() { + let client = create_and_connect("ws://127.0.0.1:8080/ws".to_string()).unwrap(); + assert_eq!(client.get_state(), "connecting"); +} + +#[wasm_bindgen_test] +fn offline_sends_are_queued() { + let client = new_client("ws://127.0.0.1:8080/ws"); + // Every send path enqueues while disconnected rather than erroring. + client.send("plain text").unwrap(); + client.request_list_agents().unwrap(); + client.broadcast_message(json!({ "hello": "world" })).unwrap(); + client.send_agent_message("agent-x", json!({ "k": 1 })).unwrap(); + client + .send_client_event("capability", "action", json!({ "detail": true })) + .unwrap(); +} + +#[wasm_bindgen_test] +fn send_alive_errors_when_not_connected() { + let client = new_client("ws://127.0.0.1:8080/ws"); + assert!(client.send_alive().is_err()); +} + +#[wasm_bindgen_test] +fn offline_queue_drops_oldest_past_capacity() { + let client = new_client("ws://127.0.0.1:8080/ws"); + // MAX_OFFLINE_QUEUE_LEN is 1000; the 1001st enqueue exercises the drop-oldest branch. + for i in 0..1_001 { + client.send(&format!("message {i}")).unwrap(); + } +} + +#[wasm_bindgen_test] +fn state_change_callback_receives_transitions() { + let recorded = Rc::new(RefCell::new(Vec::::new())); + let sink = Rc::clone(&recorded); + let callback = Closure::wrap(Box::new(move |state: JsValue| { + sink.borrow_mut().push(state.as_string().unwrap_or_default()); + }) as Box); + + let mut client = new_client("ws://127.0.0.1:8080/ws"); + client.set_on_state_change(callback.as_ref().clone()); + // A message callback is only invoked with a live server; setting it still needs coverage. + let noop = Closure::wrap(Box::new(|_msg: JsValue| {}) as Box); + client.set_on_message(noop.as_ref().clone()); + + client.connect().unwrap(); + client.disconnect(); + assert_eq!(client.get_state(), "disconnected"); + + let seen = recorded.borrow(); + assert!( + seen.iter().any(|s| s == "connecting"), + "expected a connecting transition" + ); + assert!( + seen.iter().any(|s| s == "disconnected"), + "expected a disconnected transition" + ); + + callback.forget(); + noop.forget(); +} + +#[wasm_bindgen_test] +async fn connection_error_schedules_reconnect() { + // Nothing listens on this high port, so the browser fires onerror/onclose, driving handle_disconnect and + // its exponential-backoff reconnect scheduling. + let mut client = new_client("ws://127.0.0.1:47111/ws"); + client.connect().unwrap(); + + let mut saw_reconnecting = false; + for _ in 0..40 { + if client.get_state() == "reconnecting" { + saw_reconnecting = true; + break; + } + sleep(250).await; + } + assert!( + saw_reconnecting, + "expected the failed connection to enter the reconnecting state" + ); + client.disconnect(); +} + +#[wasm_bindgen_test] +fn textarea_helpers_set_and_append() { + let document = web_sys::window().unwrap().document().unwrap(); + let body = document.body().unwrap(); + + let target = document.create_element("textarea").unwrap(); + target.set_id("ta-target"); + let _appended = body.append_child(&target).unwrap(); + + set_textarea_value("ta-target", "first value").unwrap(); + let value = Reflect::get(target.as_ref(), &JsValue::from_str("value")).unwrap(); + assert_eq!(value.as_string().unwrap(), "first value"); + + // Empty textarea -> first append replaces; a second append joins with a newline. + let appendable = document.create_element("textarea").unwrap(); + appendable.set_id("ta-append"); + let _appended2 = body.append_child(&appendable).unwrap(); + append_to_textarea("ta-append", "line one").unwrap(); + append_to_textarea("ta-append", "line two").unwrap(); + let joined = Reflect::get(appendable.as_ref(), &JsValue::from_str("value")) + .unwrap() + .as_string() + .unwrap(); + assert_eq!(joined, "line one\nline two"); + + // The "Workflow module" placeholder is treated like empty, so the next append replaces it. + let _reset: bool = Reflect::set( + appendable.as_ref(), + &JsValue::from_str("value"), + &JsValue::from_str("Workflow module loading..."), + ) + .unwrap(); + append_to_textarea("ta-append", "fresh").unwrap(); + let replaced = Reflect::get(appendable.as_ref(), &JsValue::from_str("value")) + .unwrap() + .as_string() + .unwrap(); + assert_eq!(replaced, "fresh"); + + // Missing element ids are a no-op, not an error. + set_textarea_value("no-such-textarea", "x").unwrap(); + append_to_textarea("no-such-textarea", "x").unwrap(); +} + +#[wasm_bindgen_test] +fn js_reflection_helpers_read_fields() { + let obj = Object::new(); + let _num: bool = Reflect::set(&obj, &JsValue::from_str("num"), &JsValue::from_f64(1.5)).unwrap(); + let _flag: bool = Reflect::set(&obj, &JsValue::from_str("flag"), &JsValue::TRUE).unwrap(); + let _nested: bool = Reflect::set(&obj, &JsValue::from_str("nested"), Object::new().as_ref()).unwrap(); + let _null: bool = Reflect::set(&obj, &JsValue::from_str("empty"), &JsValue::NULL).unwrap(); + + assert_eq!(js_number_field(&obj, "num"), Some(1.5)); + assert_eq!(js_number_field(&obj, "flag"), None); // present but not a number + assert_eq!(js_number_field(&obj, "missing"), None); + + assert_eq!(js_bool_field(&obj, "flag"), Some(true)); + assert_eq!(js_bool_field(&obj, "missing"), None); + + assert!(js_nested_object(&obj, "nested").is_some()); + assert!(js_nested_object(&obj, "empty").is_none()); // null is filtered out + assert!(js_nested_object(&obj, "missing").is_none()); +} + +#[wasm_bindgen_test] +async fn connects_flushes_queue_and_sends() { + let mut client = new_client(SERVER_URL); + // Queue a message while still offline so the onopen handler's flush path runs on connect. + client.send("queued-before-connect").unwrap(); + + client.connect().unwrap(); + let mut connected = false; + for _ in 0..40 { + if client.get_state() == "connected" { + connected = true; + break; + } + sleep(250).await; + } + assert!( + connected, + "client should reach the connected state against the live server" + ); + + // The server's et-connect-ack assigns (and the client persists) an agent id. + let agent_id = client.get_agent_id(); + assert!(!agent_id.is_empty(), "server should assign an agent id"); + + // Online success paths: the alive keepalive, a raw send, and each typed message helper. + client.send_alive().unwrap(); + client.send("online-message").unwrap(); + client.broadcast_message(json!({ "broadcast": 1 })).unwrap(); + client.request_list_agents().unwrap(); + client.send_agent_message(agent_id, json!({ "self": true })).unwrap(); + client + .send_client_event("capability", "action", json!({ "online": true })) + .unwrap(); + + // Let the server answer so the onmessage handler dispatches the response frames. + sleep(500).await; + + client.disconnect(); + assert_eq!(client.get_state(), "disconnected"); +} + +#[wasm_bindgen_test] +fn tracing_initializes() { + // tracing_wasm's global default can only be installed once per wasm instance, so this is the only caller. + init_tracing(); +}