diff --git a/Cargo.lock b/Cargo.lock index b0881d82..ccf03abd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4685,6 +4685,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -6277,6 +6288,7 @@ dependencies = [ "leptos", "send_wrapper", "serde", + "serde-wasm-bindgen", "serde_json", "tracing", "tracing-wasm", diff --git a/crates/actor/src/broker.rs b/crates/actor/src/broker.rs index 6dbb5355..8822e2b4 100644 --- a/crates/actor/src/broker.rs +++ b/crates/actor/src/broker.rs @@ -29,6 +29,20 @@ impl> Message for BrokerSubscribe { type Result = SubscriptionId; } +/// Fire-and-forget subscribe. Behaves like [`BrokerSubscribe`] but returns +/// `()` so callers in synchronous contexts can use [`crate::Addr::do_send`] +/// to enqueue the subscription without awaiting confirmation. +/// +/// Because the broker's mailbox is FIFO, any [`Publish`] enqueued after +/// this call is processed after the subscription is registered — no +/// events are lost as long as no publish was enqueued before this call +/// returns. +pub struct BrokerAttach>(pub Recipient); + +impl> Message for BrokerAttach { + type Result = (); +} + /// Unsubscribe by ID. pub struct BrokerUnsubscribe(pub SubscriptionId); @@ -90,6 +104,19 @@ impl + Clone> Handler> for Broker } } +impl + Clone> Handler> for Broker { + fn handle( + &mut self, + msg: BrokerAttach, + _ctx: &mut Context, + ) -> impl Future + Send { + let id = SubscriptionId(self.next_id); + self.next_id += 1; + self.subscribers.push((id, msg.0)); + async {} + } +} + impl + Clone> Handler for Broker { fn handle( &mut self, diff --git a/crates/actor/src/lib.rs b/crates/actor/src/lib.rs index b5338ed0..e87cb8c4 100644 --- a/crates/actor/src/lib.rs +++ b/crates/actor/src/lib.rs @@ -42,7 +42,9 @@ pub mod system; pub use actor::{Actor, Handler, Message, StreamHandler}; pub use addr::{Addr, AnyAddr, Recipient}; -pub use broker::{Broker, BrokerSubscribe, BrokerUnsubscribe, Publish, SubscriptionId}; +pub use broker::{ + Broker, BrokerAttach, BrokerSubscribe, BrokerUnsubscribe, Publish, SubscriptionId, +}; pub use context::{Context, IntervalHandle, TimerHandle}; pub use debounce::{Debounce, Enqueue, Throttle}; pub use derived::{derived, DeriveSource, DerivedActor}; diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 3252370c..a6b6c4c3 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -7,6 +7,11 @@ description = "UI-agnostic client library for the Willow P2P chat network" [features] test-utils = ["willow-network/test-utils"] +# Read-only test instrumentation hooks. Distinct from `test-utils`: +# `test-hooks` exposes narrow accessors for the web crate's +# `WillowTestHooks` (Promise-returning JS pull API) without enabling +# `MemNetwork`. See `docs/specs/2026-04-27-event-based-waits-design.md`. +test-hooks = [] [dependencies] willow-actor = { path = "../actor" } diff --git a/crates/client/src/accessors.rs b/crates/client/src/accessors.rs index c5bb6655..df4ac48d 100644 --- a/crates/client/src/accessors.rs +++ b/crates/client/src/accessors.rs @@ -208,3 +208,31 @@ impl ClientHandle { .await } } + +// ── Test-only address getters (test-hooks feature) ──────────────────────── +// +// Gated behind `test-hooks` so non-test consumers (`willow-agent`, +// `willow-replay`, etc.) never see them. The address itself doesn't grant +// write access without an active mutator — these are a read-only handle +// for `WillowTestHooks` in the web crate, which cannot hold a generic +// `ClientHandle` across the wasm_bindgen boundary. + +#[cfg(feature = "test-hooks")] +impl ClientHandle { + /// Clone the per-author Merkle-DAG actor address. Test-only. + pub fn dag_addr_clone( + &self, + ) -> willow_actor::Addr> { + self.dag_addr.clone() + } + + /// Clone the materialised `ServerState` actor address. Test-only. + /// + /// Used by the snapshot builder in `WillowTestHooks` to read the + /// channels view for assertion-style polling. + pub fn event_state_addr_clone( + &self, + ) -> willow_actor::Addr> { + self.event_state_addr.clone() + } +} diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 5ddf96ca..775adfe0 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -123,7 +123,7 @@ pub enum ClientError { /// Helper to bridge `Broker` into an async stream receiver. pub mod event_receiver { use crate::events::ClientEvent; - use willow_actor::{Actor, Addr, Broker, BrokerSubscribe, Context, Handler}; + use willow_actor::{Actor, Addr, Broker, BrokerAttach, BrokerSubscribe, Context, Handler}; /// Async receiver for [`ClientEvent`]s from a [`Broker`]. /// @@ -147,6 +147,31 @@ pub mod event_receiver { Self { rx } } + /// Subscribe synchronously without awaiting confirmation. + /// + /// Queues `BrokerSubscribe` via `do_send`, returning the receiver + /// immediately. The broker's mailbox is FIFO, so any `Publish` + /// queued AFTER this call is processed AFTER the subscription + /// is registered — no events are lost as long as no publish has + /// been queued before this call returns. + /// + /// Use from synchronous contexts (mount blocks, `Drop`, etc.) + /// where awaiting the async [`subscribe`] would create a window + /// in which events emitted between scheduling and confirmation + /// would be missed. + /// + /// [`subscribe`]: Self::subscribe + pub fn subscribe_now( + broker: &Addr>, + system: &willow_actor::SystemHandle, + ) -> Self { + let (tx, rx) = + willow_actor::runtime::channel(willow_actor::runtime::DEFAULT_MAILBOX_CAPACITY); + let addr = system.spawn(ForwarderActor { tx }); + broker.do_send(BrokerAttach(addr.into())).ok(); + Self { rx } + } + /// Await the next event. Returns `None` if the broker is closed. pub async fn recv(&mut self) -> Option { self.rx.recv().await @@ -2127,4 +2152,41 @@ mod tests { "unmute must remove the channel from the set" ); } + + /// `subscribe_now` registers the recipient before any subsequent + /// `Publish` is processed by the broker, eliminating the race where + /// the async `subscribe` would miss events emitted between scheduling + /// and confirmation. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn subscribe_now_captures_event_published_synchronously_after_subscribe() { + use crate::events::ClientEvent; + use willow_actor::{Broker, Publish, System}; + + let sys = System::new(); + let broker = sys.spawn(Broker::::default()); + + // Subscribe + publish without any await between them. The async + // `EventReceiver::subscribe` would create a window here in which + // the publish could overtake the BrokerSubscribe registration. + let mut rx = EventReceiver::subscribe_now(&broker, &sys.handle()); + broker + .do_send(Publish(ClientEvent::SyncCompleted { ops_applied: 7 })) + .unwrap(); + + let received = tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if let Some(event) = rx.try_recv() { + return event; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("subscribe_now must capture events published synchronously after subscription"); + + match received { + ClientEvent::SyncCompleted { ops_applied } => assert_eq!(ops_applied, 7), + other => panic!("unexpected event: {other:?}"), + } + } } diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 8de0e2f4..278b8fc6 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -52,7 +52,12 @@ futures = { workspace = true } gloo-timers = { version = "0.4", features = ["futures"] } serde = { workspace = true } serde_json = "1" +serde-wasm-bindgen = { version = "0.6", optional = true } blake3 = { workspace = true } [dev-dependencies] wasm-bindgen-test = "0.3" + +[features] +default = [] +test-hooks = ["dep:serde-wasm-bindgen", "willow-client/test-hooks"] diff --git a/crates/web/src/app.rs b/crates/web/src/app.rs index 2b31d1bf..c915ddcb 100644 --- a/crates/web/src/app.rs +++ b/crates/web/src/app.rs @@ -153,6 +153,31 @@ pub fn App() -> impl IntoView { let handle_inner = (*handle).clone().with_trust_store(trust_store.clone()); let handle: WebClientHandle = SendWrapper::new(handle_inner); + #[cfg(feature = "test-hooks")] + { + let inner_for_hooks = (*handle).clone(); + let hooks = crate::test_hooks::WillowTestHooks::new(&inner_for_hooks); + if let Some(window) = web_sys::window() { + let _ = js_sys::Reflect::set( + &window, + &"__willow".into(), + &wasm_bindgen::JsValue::from(hooks), + ); + } + // Subscribe synchronously: any ClientEvent published after this + // call is guaranteed to land in the dispatcher (broker mailbox is + // FIFO). An async subscribe would create a window between mount + // and confirmation in which boot-time events would be lost. + let rx = willow_client::event_receiver::EventReceiver::subscribe_now( + inner_for_hooks.event_broker(), + inner_for_hooks.system(), + ); + let dispatcher = crate::test_hooks::install_push_dispatcher(rx); + // Leak: dispatcher must live for app lifetime; in wasm32 the + // process IS the app, so leaking is fine. + std::mem::forget(dispatcher); + } + // Provide context so child components can access the handle and state. provide_context(handle.clone()); provide_context(app_state); diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index 0565c275..cb8496fa 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -20,6 +20,8 @@ pub mod profile; pub mod service_worker_bridge; pub mod state; pub mod state_bridge; +#[cfg(feature = "test-hooks")] +pub mod test_hooks; pub mod trust_store; pub mod util; pub mod voice; diff --git a/crates/web/src/test_hooks/dispatcher.rs b/crates/web/src/test_hooks/dispatcher.rs new file mode 100644 index 00000000..ce00c422 --- /dev/null +++ b/crates/web/src/test_hooks/dispatcher.rs @@ -0,0 +1,161 @@ +//! Push dispatcher for `WillowTestHooks`. +//! +//! Subscribes to a [`willow_client::EventReceiver`] and forwards each +//! wire-visible [`ClientEvent`] to `window.__willowEvent` (a Playwright +//! `exposeBinding`). On overflow calls `window.__willowOverflow(droppedCount)` +//! so the test fixture can fail the test immediately. +//! +//! When the binding is absent, events are buffered in +//! `window.__willowEventBuffer` (capacity 65,536). The dispatcher performs +//! a three-edge drain: +//! +//! 1. **Init drain** — on `install_push_dispatcher`, drain any buffer left +//! by a prior dispatcher (hot reload, auth re-init). +//! 2. **Per-dispatch drain** — before forwarding each new event, drain the +//! buffer so events arrive in order once the binding appears. +//! 3. **Read-side drain** — handled by the Playwright fixture (JS-only). + +use std::cell::RefCell; +use std::rc::Rc; + +use wasm_bindgen::JsCast; +use wasm_bindgen::JsValue; +use wasm_bindgen_futures::spawn_local; +use willow_client::EventReceiver; + +use super::wire::to_wire; + +const BUFFER_CAPACITY: usize = 65_536; + +/// Returned from [`install_push_dispatcher`]. Dropping aborts the dispatch loop. +pub struct DispatcherHandle { + abort: Rc>, +} + +impl Drop for DispatcherHandle { + fn drop(&mut self) { + *self.abort.borrow_mut() = true; + } +} + +/// Install the push dispatcher. +/// +/// Spawns a `wasm_bindgen_futures` task that loops on the broker `recv()`, +/// converts each [`ClientEvent`] to its wire shape, and forwards to +/// `window.__willowEvent`. +/// +/// Returns a [`DispatcherHandle`] — dropping it stops the dispatch loop. +pub fn install_push_dispatcher(mut rx: EventReceiver) -> DispatcherHandle { + let abort = Rc::new(RefCell::new(false)); + let abort_clone = abort.clone(); + + spawn_local(async move { + // Edge 1: drain on dispatcher init — covers prior-dispatcher buffer leftovers. + drain_buffer_into_callback(); + + while !*abort_clone.borrow() { + let Some(event) = rx.recv().await else { break }; + let Some(wire) = to_wire(&event) else { + continue; + }; + + let js = match serde_wasm_bindgen::to_value(&wire) { + Ok(v) => v, + Err(e) => { + web_sys::console::error_1( + &format!("test-hooks: serialize failed: {e:?}").into(), + ); + continue; + } + }; + + // Edge 2: drain on every dispatch — covers binding-becomes-available case. + drain_buffer_into_callback(); + dispatch_or_buffer(js); + } + }); + + DispatcherHandle { abort } +} + +fn dispatch_or_buffer(js: JsValue) { + let Some(window) = web_sys::window() else { + return; + }; + + if let Ok(callback) = js_sys::Reflect::get(&window, &"__willowEvent".into()) { + if let Some(func) = callback.dyn_ref::() { + if let Err(e) = func.call1(&JsValue::NULL, &js) { + web_sys::console::warn_1(&format!("test-hooks: __willowEvent threw: {e:?}").into()); + } + return; + } + } + + push_into_buffer(&window, js); +} + +fn drain_buffer_into_callback() { + let Some(window) = web_sys::window() else { + return; + }; + + let Ok(callback) = js_sys::Reflect::get(&window, &"__willowEvent".into()) else { + return; + }; + let Some(func) = callback.dyn_ref::() else { + return; + }; + + let Ok(buffer) = js_sys::Reflect::get(&window, &"__willowEventBuffer".into()) else { + return; + }; + let Some(arr) = buffer.dyn_ref::() else { + return; + }; + + while arr.length() > 0 { + let item = arr.shift(); + if let Err(e) = func.call1(&JsValue::NULL, &item) { + web_sys::console::warn_1( + &format!("test-hooks: __willowEvent (drain) threw: {e:?}").into(), + ); + } + } +} + +fn push_into_buffer(window: &web_sys::Window, js: JsValue) { + let buffer = match js_sys::Reflect::get(window, &"__willowEventBuffer".into()) { + Ok(b) if b.is_object() && b.dyn_ref::().is_some() => b, + _ => { + let arr = js_sys::Array::new(); + if let Err(e) = js_sys::Reflect::set(window, &"__willowEventBuffer".into(), &arr) { + web_sys::console::error_1( + &format!("test-hooks: failed to install __willowEventBuffer: {e:?}").into(), + ); + } + arr.into() + } + }; + + let arr: js_sys::Array = buffer.unchecked_into(); + + if arr.length() as usize >= BUFFER_CAPACITY { + // Overflow: drop oldest, signal the test fixture. + arr.shift(); + signal_overflow(window, 1); + } + + arr.push(&js); +} + +fn signal_overflow(window: &web_sys::Window, dropped: u32) { + if let Ok(cb) = js_sys::Reflect::get(window, &"__willowOverflow".into()) { + if let Some(func) = cb.dyn_ref::() { + let _ = func.call1(&JsValue::NULL, &JsValue::from_f64(dropped as f64)); + } + } + web_sys::console::error_1( + &format!("test-hooks: __willow buffer overflow ({dropped} dropped)").into(), + ); +} diff --git a/crates/web/src/test_hooks/mod.rs b/crates/web/src/test_hooks/mod.rs new file mode 100644 index 00000000..62d10b7d --- /dev/null +++ b/crates/web/src/test_hooks/mod.rs @@ -0,0 +1,130 @@ +//! Test instrumentation for the Willow web UI. +//! +//! This module is gated behind the `test-hooks` cargo feature and is +//! **never compiled into production builds**. It exposes +//! `WillowTestHooks` to JavaScript via `wasm_bindgen` so Playwright +//! e2e tests can synchronise on real signals (applied events, DAG +//! heads, snapshot fields) instead of arbitrary `waitForTimeout`s. +//! +//! See `docs/specs/2026-04-27-event-based-waits-design.md`. + +#![cfg(feature = "test-hooks")] + +mod dispatcher; +pub use dispatcher::{install_push_dispatcher, DispatcherHandle}; + +mod snapshot; +pub use snapshot::{AuthorHeadDto, ChannelDto, SnapshotDto}; + +mod wire; +pub use wire::{to_wire, WireEvent}; + +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::future_to_promise; +use willow_actor::{Addr, StateActor}; +use willow_client::state_actors::DagState; +use willow_client::ClientHandle; +use willow_network::Network; +use willow_state::ServerState; + +/// Read-only test instrumentation handle exposed to JS as `window.__willow`. +/// +/// Stores the actor addresses extracted from the `ClientHandle` at +/// construction time so the `#[wasm_bindgen]` struct stays monomorphic. +/// All exposed methods return `js_sys::Promise` — the WASM async runtime +/// drives the actor-ask round-trip rather than blocking on it. +#[wasm_bindgen] +pub struct WillowTestHooks { + dag_addr: Addr>, + state_addr: Addr>, +} + +impl WillowTestHooks { + /// Construct from a `ClientHandle` (production path: `app.rs` mount). + /// + /// Borrows the handle so callers don't need to `.clone()` just to + /// construct the hooks; the underlying actor addresses are cloned + /// internally. + pub fn new(handle: &ClientHandle) -> Self { + Self { + dag_addr: handle.dag_addr_clone(), + state_addr: handle.event_state_addr_clone(), + } + } + + /// Construct directly from raw actor addresses (test path). + /// + /// Bypasses `ClientHandle` entirely so wasm32 browser tests don't + /// need `MemNetwork` (which depends on `tokio::sync::broadcast`, + /// native-only). + pub fn from_actors( + dag_addr: Addr>, + state_addr: Addr>, + ) -> Self { + Self { + dag_addr, + state_addr, + } + } +} + +#[wasm_bindgen] +impl WillowTestHooks { + /// Total events applied to the local DAG. Resolves to a `number`. + /// + /// Returned as a `Promise` so the underlying actor ask can complete + /// asynchronously on the WASM cooperative scheduler. + pub fn event_count(&self) -> js_sys::Promise { + let addr = self.dag_addr.clone(); + future_to_promise(async move { + let count = + willow_actor::state::select(&addr, |ds| ds.managed.dag().len() as u32).await; + Ok(JsValue::from_f64(count as f64)) + }) + } + + /// Hex-encoded hash of the most recently applied event, or `null`. + /// + /// "Most recently applied" is defined as the last element of the + /// deterministic topological sort of the DAG. Resolves to a hex + /// `string` or `null`. + pub fn last_event(&self) -> js_sys::Promise { + let addr = self.dag_addr.clone(); + future_to_promise(async move { + let maybe_hash = willow_actor::state::select(&addr, |ds| { + ds.managed + .dag() + .topological_sort() + .last() + .map(|e| e.hash.to_string()) + }) + .await; + match maybe_hash { + Some(hex) => Ok(JsValue::from_str(&hex)), + None => Ok(JsValue::NULL), + } + }) + } + + /// Per-author DAG heads, keyed by `EndpointId` hex string. Resolves to + /// `Record`. + pub fn heads(&self) -> js_sys::Promise { + let addr = self.dag_addr.clone(); + future_to_promise(async move { + let map: std::collections::BTreeMap = + willow_actor::state::select(&addr, snapshot::build_heads).await; + serde_wasm_bindgen::to_value(&map).map_err(Into::into) + }) + } + + /// Aggregated state snapshot. Resolves to an object matching the spec's + /// `Snapshot` interface: `{ eventCount, heads, lastEvent, channels }`. + pub fn snapshot(&self) -> js_sys::Promise { + let dag_addr = self.dag_addr.clone(); + let state_addr = self.state_addr.clone(); + future_to_promise(async move { + let snap = snapshot::build(&dag_addr, &state_addr).await; + serde_wasm_bindgen::to_value(&snap).map_err(Into::into) + }) + } +} diff --git a/crates/web/src/test_hooks/snapshot.rs b/crates/web/src/test_hooks/snapshot.rs new file mode 100644 index 00000000..4a693a23 --- /dev/null +++ b/crates/web/src/test_hooks/snapshot.rs @@ -0,0 +1,120 @@ +//! DTOs for the `WillowTestHooks` pull API. +//! +//! These mirror the TypeScript `Snapshot` / `AuthorHead` types defined +//! in `e2e/test-hooks.ts`. Field names are camelCase to match TS +//! convention; the kind discriminator on `ClientEvent` (a separate +//! module) stays PascalCase. + +use serde::{Deserialize, Serialize}; +use willow_state::ChannelKind; + +/// One author's DAG head, as exposed to JS. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthorHeadDto { + pub seq: u64, + pub hash: String, +} + +/// One channel's summary, as exposed to JS. +/// +/// `kind` is forwarded directly through `ChannelKind`'s own `Serialize` +/// impl so the wire form (`"Text"` / `"Voice"`) is contracted by the +/// state crate, not by `Debug` formatting. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelDto { + pub name: String, + pub kind: ChannelKind, +} + +/// Aggregated state snapshot for `expect.poll` matchers. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SnapshotDto { + pub event_count: u32, + /// Per-author DAG heads, keyed by `EndpointId` hex string. + pub heads: std::collections::BTreeMap, + pub last_event: Option, + pub channels: Vec, +} + +// ── Builder functions ────────────────────────────────────────────────── +// +// These take raw actor addresses (`Addr>` etc.) so +// they don't depend on the generic `ClientHandle` — the wasm_bindgen +// boundary in `WillowTestHooks` is monomorphic. Reads go through +// `willow_actor::state::select` (the standard async ask path). +// +// `mod.rs::heads()` calls `build_heads` from inside a `state::select` +// closure on the DAG actor; `mod.rs::snapshot()` calls `build` directly +// (which itself does the two `state::select` round-trips). + +use std::collections::BTreeMap; +use willow_actor::{Addr, StateActor}; +use willow_client::state_actors::DagState; +use willow_state::ServerState; + +/// Build the per-author heads map from the in-memory `DagState`. +/// +/// Synchronous helper invoked from inside a `state::select` closure on +/// the DAG actor. Keys are `EndpointId` hex strings; values are +/// `AuthorHeadDto { seq, hash }`. +pub(crate) fn build_heads(ds: &DagState) -> BTreeMap { + ds.managed + .dag() + .heads_summary() + .heads + .into_iter() + .map(|(endpoint, head)| { + ( + endpoint.to_string(), + AuthorHeadDto { + seq: head.seq, + hash: head.hash.to_string(), + }, + ) + }) + .collect() +} + +/// Build a full `SnapshotDto` by reading both the DAG actor and the +/// materialised `ServerState` actor. +/// +/// Two actor-asks (cheap, sub-ms each on local mailbox dispatch). The +/// snapshot is consistent within each ask but not across the pair — +/// the gap is acceptable for `expect.poll`-style tests, which retry +/// until the predicate stabilises. +pub(crate) async fn build( + dag_addr: &Addr>, + state_addr: &Addr>, +) -> SnapshotDto { + let (event_count, heads, last_event) = willow_actor::state::select(dag_addr, |ds| { + ( + ds.managed.dag().len() as u32, + build_heads(ds), + ds.managed + .dag() + .topological_sort() + .last() + .map(|e| e.hash.to_string()), + ) + }) + .await; + let channels = willow_actor::state::select(state_addr, |ss| { + ss.channels + .values() + .map(|ch| ChannelDto { + name: ch.name.clone(), + kind: ch.kind.clone(), + }) + .collect::>() + }) + .await; + SnapshotDto { + event_count, + heads, + last_event, + channels, + } +} diff --git a/crates/web/src/test_hooks/wire.rs b/crates/web/src/test_hooks/wire.rs new file mode 100644 index 00000000..9779f070 --- /dev/null +++ b/crates/web/src/test_hooks/wire.rs @@ -0,0 +1,218 @@ +//! Stable JSON wire shape for `ClientEvent`. +//! +//! `to_wire(event)` returns `Some(WireEvent)` for variants exposed to +//! e2e tests, and `None` for internal-only variants. The `WireEvent` +//! shape is `{kind: , ...camelCase fields}` per the spec. + +use serde::Serialize; +use willow_client::events::ClientEvent; + +/// JSON-stable representation of a `ClientEvent` for the test surface. +#[derive(Serialize)] +#[serde(tag = "kind")] +pub enum WireEvent { + SyncCompleted { + #[serde(rename = "opsApplied")] + ops_applied: u32, + }, + MessageReceived { + channel: String, + #[serde(rename = "messageId")] + message_id: String, + #[serde(rename = "isLocal")] + is_local: bool, + }, + PeerConnected { + #[serde(rename = "peerId")] + peer_id: String, + }, + PeerDisconnected { + #[serde(rename = "peerId")] + peer_id: String, + }, + ChannelCreated { + name: String, + }, + ChannelDeleted { + name: String, + }, + PeerTrusted { + #[serde(rename = "peerId")] + peer_id: String, + }, + PeerUntrusted { + #[serde(rename = "peerId")] + peer_id: String, + }, + ProfileUpdated { + #[serde(rename = "peerId")] + peer_id: String, + #[serde(rename = "displayName")] + display_name: String, + }, + RoleCreated { + #[serde(rename = "roleId")] + role_id: String, + name: String, + }, +} + +/// Convert a `ClientEvent` to its wire shape, or `None` if the variant +/// is internal-only and should not be surfaced to e2e tests. +pub fn to_wire(event: &ClientEvent) -> Option { + match event { + ClientEvent::SyncCompleted { ops_applied } => Some(WireEvent::SyncCompleted { + ops_applied: *ops_applied as u32, + }), + ClientEvent::MessageReceived { + channel, + message_id, + is_local, + } => Some(WireEvent::MessageReceived { + channel: channel.clone(), + message_id: message_id.clone(), + is_local: *is_local, + }), + ClientEvent::PeerConnected(id) => Some(WireEvent::PeerConnected { + peer_id: id.to_string(), + }), + ClientEvent::PeerDisconnected(id) => Some(WireEvent::PeerDisconnected { + peer_id: id.to_string(), + }), + ClientEvent::ChannelCreated(name) => Some(WireEvent::ChannelCreated { name: name.clone() }), + ClientEvent::ChannelDeleted(name) => Some(WireEvent::ChannelDeleted { name: name.clone() }), + ClientEvent::PeerTrusted(id) => Some(WireEvent::PeerTrusted { + peer_id: id.to_string(), + }), + ClientEvent::PeerUntrusted(id) => Some(WireEvent::PeerUntrusted { + peer_id: id.to_string(), + }), + ClientEvent::ProfileUpdated { + peer_id, + display_name, + } => Some(WireEvent::ProfileUpdated { + peer_id: peer_id.to_string(), + display_name: display_name.clone(), + }), + ClientEvent::RoleCreated { name, role_id } => Some(WireEvent::RoleCreated { + role_id: role_id.clone(), + name: name.clone(), + }), + // Internal-only variants are filtered out. + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use willow_identity::SecretKey; + + /// Stable test fixture: deterministic `EndpointId` derived from a + /// 32-byte all-ones secret key. + fn endpoint_a() -> willow_identity::EndpointId { + // SecretKey::from_bytes takes a `&[u8; 32]` value (not a Result). + // .public() returns the corresponding Ed25519 public key = EndpointId. + SecretKey::from_bytes(&[1u8; 32]).public() + } + + #[test] + fn sync_completed_serializes_to_stable_shape() { + let ev = ClientEvent::SyncCompleted { ops_applied: 5 }; + let wire = to_wire(&ev).expect("SyncCompleted must convert"); + let json = serde_json::to_string(&wire).unwrap(); + assert_eq!(json, r#"{"kind":"SyncCompleted","opsApplied":5}"#); + } + + #[test] + fn message_received() { + let ev = ClientEvent::MessageReceived { + channel: "general".into(), + message_id: "m1".into(), + is_local: false, + }; + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert_eq!( + json, + r#"{"kind":"MessageReceived","channel":"general","messageId":"m1","isLocal":false}"#, + ); + } + + #[test] + fn peer_connected() { + let ev = ClientEvent::PeerConnected(endpoint_a()); + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert!(json.starts_with(r#"{"kind":"PeerConnected","peerId":"#)); + } + + #[test] + fn peer_disconnected() { + let ev = ClientEvent::PeerDisconnected(endpoint_a()); + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert!(json.starts_with(r#"{"kind":"PeerDisconnected","peerId":"#)); + } + + #[test] + fn channel_created() { + let ev = ClientEvent::ChannelCreated("general".into()); + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert_eq!(json, r#"{"kind":"ChannelCreated","name":"general"}"#); + } + + #[test] + fn channel_deleted() { + let ev = ClientEvent::ChannelDeleted("general".into()); + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert_eq!(json, r#"{"kind":"ChannelDeleted","name":"general"}"#); + } + + #[test] + fn peer_trusted() { + let ev = ClientEvent::PeerTrusted(endpoint_a()); + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert!(json.starts_with(r#"{"kind":"PeerTrusted","peerId":"#)); + } + + #[test] + fn peer_untrusted() { + let ev = ClientEvent::PeerUntrusted(endpoint_a()); + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert!(json.starts_with(r#"{"kind":"PeerUntrusted","peerId":"#)); + } + + #[test] + fn profile_updated() { + let ev = ClientEvent::ProfileUpdated { + peer_id: endpoint_a(), + display_name: "alice".into(), + }; + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert!(json.contains(r#""kind":"ProfileUpdated""#)); + assert!(json.contains(r#""displayName":"alice""#)); + } + + #[test] + fn role_created() { + let ev = ClientEvent::RoleCreated { + name: "moderator".into(), + role_id: "r1".into(), + }; + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert_eq!( + json, + r#"{"kind":"RoleCreated","roleId":"r1","name":"moderator"}"#, + ); + } + + /// Task 3.4: ensure internal-only variants are filtered. + #[test] + fn internal_variants_are_filtered() { + // Pick any internal-only variant. RelayStatusChanged is a good choice + // (simple tuple variant with single field). + let ev = ClientEvent::RelayStatusChanged(willow_client::queue::RelayStatus::Reachable); + assert!( + to_wire(&ev).is_none(), + "internal-only variants must not leak to the wire" + ); + } +} diff --git a/crates/web/tests/browser.rs b/crates/web/tests/browser.rs index f114ea1a..11c9d1d2 100644 --- a/crates/web/tests/browser.rs +++ b/crates/web/tests/browser.rs @@ -12140,6 +12140,27 @@ mod phase_2d_ephemeral_channels { } } +// ── test-hooks mount verification ──────────────────────────────────────────── + +/// Verify that `window.__willow` is set when the `test-hooks` feature is on +/// and `` has been mounted. The mount block in `app.rs` sets the property +/// synchronously (before the async dispatcher subscription), so it is already +/// present by the time this assertion runs. +#[wasm_bindgen_test] +#[cfg(feature = "test-hooks")] +async fn window_willow_is_mounted_under_test_hooks_feature() { + use willow_web::app::App; + + let _container = mount_test(|| leptos::view! { }); + + let window = web_sys::window().unwrap(); + let willow = js_sys::Reflect::get(&window, &"__willow".into()).unwrap(); + assert!( + !willow.is_undefined(), + "window.__willow must be present when test-hooks feature is on" + ); +} + // ── Issue #350: handler error reporting ───────────────────────────────────── // // `crates/web/src/handlers.rs` previously discarded every async-action diff --git a/crates/web/tests/test_hooks_browser.rs b/crates/web/tests/test_hooks_browser.rs new file mode 100644 index 00000000..5a92c530 --- /dev/null +++ b/crates/web/tests/test_hooks_browser.rs @@ -0,0 +1,337 @@ +//! In-browser tests for `WillowTestHooks`. +//! +//! Run with: +//! wasm-pack test crates/web --headless --chrome --features test-hooks --test test_hooks_browser +//! +//! ## Why no `MemNetwork`? +//! `MemNetwork` uses `tokio::sync::broadcast` which is native-only. +//! These tests therefore construct just the actor addresses they need +//! (`StateActor` + `StateActor`) and hand them +//! to `WillowTestHooks::from_actors` — no `ClientHandle` required. +//! +//! The dispatcher tests construct a `Broker` directly and +//! subscribe an `EventReceiver` to it. This avoids `ClientHandle` and +//! `MemNetwork` entirely, keeping all tests WASM-compatible. +//! +//! ## What's covered +//! Empty-DAG invariants of the JS-exposed pull API: +//! `event_count()` resolves to `0`, `last_event()` resolves to `null`. +//! Push dispatcher: emit, drop/stop, buffer-drain, overflow signalling. + +#![cfg(feature = "test-hooks")] + +use std::cell::RefCell; +use std::rc::Rc; + +use wasm_bindgen::closure::Closure; +use wasm_bindgen::JsCast; +use wasm_bindgen::JsValue; +use wasm_bindgen_futures::JsFuture; +use wasm_bindgen_test::*; +use willow_actor::{Broker, Publish, StateActor, System}; +use willow_client::event_receiver::EventReceiver; +use willow_client::events::ClientEvent; +use willow_client::state_actors::DagState; +use willow_identity::Identity; +use willow_state::ServerState; +use willow_web::test_hooks::WillowTestHooks; + +wasm_bindgen_test_configure!(run_in_browser); + +/// Construct a `WillowTestHooks` instance backed by empty actor state. +/// +/// The `DagState` uses `ManagedDag::empty(...)` (no genesis seeded), +/// and the `ServerState` is a freshly-constructed shell with a +/// throwaway endpoint id. Neither carries any events. +/// +/// Returns the `System` alongside the hooks so the caller's scope owns +/// the system lifetime. Per the `System` docs, dropping the system +/// without `shutdown()` does not stop already-spawned actors — they +/// stay alive as long as their `Addr<_>` references (held inside +/// `WillowTestHooks`) are alive. +fn empty_hooks() -> (WillowTestHooks, System) { + let sys = System::new(); + let dag_addr = sys.spawn(StateActor::new(DagState::default())); + let throwaway = Identity::generate().endpoint_id(); + let state_addr = sys.spawn(StateActor::new(ServerState::new("test", "Test", throwaway))); + let hooks = WillowTestHooks::from_actors(dag_addr, state_addr); + (hooks, sys) +} + +#[wasm_bindgen_test] +async fn empty_hooks_event_count_is_zero() { + let (hooks, _sys) = empty_hooks(); + + let count_js: JsValue = JsFuture::from(hooks.event_count()) + .await + .expect("event_count"); + let count = count_js.as_f64().expect("event_count is a number") as u32; + + assert_eq!(count, 0, "empty DAG should have event_count = 0"); +} + +#[wasm_bindgen_test] +async fn empty_hooks_last_event_is_null() { + let (hooks, _sys) = empty_hooks(); + + let last_js: JsValue = JsFuture::from(hooks.last_event()) + .await + .expect("last_event"); + + assert!( + last_js.is_null(), + "last_event on empty DAG must be null, got {last_js:?}" + ); +} + +#[wasm_bindgen_test] +async fn heads_returns_empty_map_on_empty_dag() { + let (hooks, _sys) = empty_hooks(); + let p = hooks.heads(); + let value = JsFuture::from(p).await.unwrap(); + let map: std::collections::BTreeMap = + serde_wasm_bindgen::from_value(value).expect("deserialize heads"); + assert!( + map.is_empty(), + "empty DAG must produce empty heads map; got {:?}", + map.keys().collect::>() + ); +} + +#[wasm_bindgen_test] +async fn snapshot_returns_empty_dto_on_empty_fixture() { + let (hooks, _sys) = empty_hooks(); + let p = hooks.snapshot(); + let value = JsFuture::from(p).await.unwrap(); + let snap: willow_web::test_hooks::SnapshotDto = + serde_wasm_bindgen::from_value(value).expect("deserialize snapshot"); + + assert_eq!(snap.event_count, 0, "empty DAG => event_count == 0"); + assert!(snap.heads.is_empty(), "empty DAG => heads map empty"); + assert!(snap.last_event.is_none(), "empty DAG => last_event None"); + assert!( + snap.channels.is_empty(), + "empty ServerState => channels empty" + ); +} + +// ───── Push-dispatcher tests (Phase 4) ─────────────────────────────────────── + +/// Build a broker + dispatcher without `ClientHandle` or `MemNetwork`. +/// +/// Returns the broker address, the `DispatcherHandle` (dropping it +/// stops the loop), and the `System`. The system is returned to the +/// caller so its lifetime is scoped to the test — dropping it does not +/// stop the already-spawned actors (broker, forwarder); those stay +/// alive as long as their `Addr<_>` references are held by the +/// returned values. +async fn fresh_dispatcher_setup() -> ( + willow_actor::Addr>, + willow_web::test_hooks::DispatcherHandle, + System, +) { + let sys = System::new(); + let broker_addr = sys.spawn(Broker::::default()); + let rx = EventReceiver::subscribe(&broker_addr, &sys.handle()).await; + let dispatcher = willow_web::test_hooks::install_push_dispatcher(rx); + (broker_addr, dispatcher, sys) +} + +#[wasm_bindgen_test] +async fn dispatcher_emits_sync_completed_to_window_callback() { + let captured: Rc>> = Rc::new(RefCell::new(Vec::new())); + let captured_clone = captured.clone(); + + let cb = Closure::wrap(Box::new(move |ev: JsValue| { + captured_clone.borrow_mut().push(ev); + }) as Box); + + let window = web_sys::window().unwrap(); + js_sys::Reflect::set( + &window, + &"__willowEvent".into(), + cb.as_ref().unchecked_ref(), + ) + .unwrap(); + cb.forget(); + + let (broker_addr, _dispatcher, _sys) = fresh_dispatcher_setup().await; + + // Send a SyncCompleted event through the broker. + broker_addr + .do_send(Publish(ClientEvent::SyncCompleted { ops_applied: 5 })) + .unwrap(); + + // Yield to let the dispatcher loop run. + gloo_timers::future::TimeoutFuture::new(50).await; + + let events = captured.borrow(); + assert!( + events.iter().any(|ev| { + let s = js_sys::JSON::stringify(ev) + .ok() + .and_then(|js| js.as_string()) + .unwrap_or_default(); + s.contains(r#""kind":"SyncCompleted""#) + }), + "expected at least one SyncCompleted event; got {:?}", + events + .iter() + .map(|ev| js_sys::JSON::stringify(ev) + .ok() + .and_then(|js| js.as_string()) + .unwrap_or_default()) + .collect::>() + ); + + // Cleanup. + js_sys::Reflect::delete_property(&window, &"__willowEvent".into()).unwrap(); +} + +#[wasm_bindgen_test] +async fn dropping_dispatcher_handle_stops_emissions() { + let captured: Rc>> = Rc::new(RefCell::new(Vec::new())); + let captured_clone = captured.clone(); + + let cb = Closure::wrap(Box::new(move |ev: JsValue| { + captured_clone.borrow_mut().push(ev); + }) as Box); + + let window = web_sys::window().unwrap(); + js_sys::Reflect::set( + &window, + &"__willowEvent".into(), + cb.as_ref().unchecked_ref(), + ) + .unwrap(); + cb.forget(); + + let (broker_addr, _sys) = { + let (broker_addr, _dispatcher, sys) = fresh_dispatcher_setup().await; + + broker_addr + .do_send(Publish(ClientEvent::SyncCompleted { ops_applied: 1 })) + .unwrap(); + gloo_timers::future::TimeoutFuture::new(50).await; + + (broker_addr, sys) + // _dispatcher dropped here, abort flag set to true. + }; + + let count_after_drop = captured.borrow().len(); + + // Send a second event after the handle is dropped. + broker_addr + .do_send(Publish(ClientEvent::SyncCompleted { ops_applied: 2 })) + .unwrap(); + gloo_timers::future::TimeoutFuture::new(50).await; + + let count_after_post_drop_event = captured.borrow().len(); + + // The dispatcher loop checks the abort flag at the top of each iteration, + // AFTER `recv().await` returns. So one already-dequeued event can still + // dispatch before the next iteration sees the flag. Anything more than +1 + // means the abort flag isn't being checked. + assert!( + count_after_post_drop_event <= count_after_drop + 1, + "dispatcher should deliver at most 1 in-flight event after handle drop \ + (got {count_after_post_drop_event} after drop, was {count_after_drop} at drop)" + ); + + js_sys::Reflect::delete_property(&window, &"__willowEvent".into()).unwrap(); +} + +#[wasm_bindgen_test] +async fn buffer_drains_on_first_dispatch_after_binding_appears() { + let window = web_sys::window().unwrap(); + + // Remove any stale callback so dispatch_or_buffer goes to the buffer path. + js_sys::Reflect::delete_property(&window, &"__willowEvent".into()).unwrap(); + + // Pre-seed the buffer as if a dispatcher had run before the + // binding existed. + let pre_buffer = js_sys::Array::new(); + pre_buffer.push(&JsValue::from_str("PREEXISTING")); + js_sys::Reflect::set(&window, &"__willowEventBuffer".into(), &pre_buffer).unwrap(); + + let captured: Rc>> = Rc::new(RefCell::new(Vec::new())); + let captured_clone = captured.clone(); + + let cb = Closure::wrap(Box::new(move |ev: JsValue| { + captured_clone.borrow_mut().push(ev); + }) as Box); + js_sys::Reflect::set( + &window, + &"__willowEvent".into(), + cb.as_ref().unchecked_ref(), + ) + .unwrap(); + cb.forget(); + + let (broker_addr, _dispatcher, _sys) = fresh_dispatcher_setup().await; + + // Send a real event — this triggers the per-dispatch drain of the pre-seeded buffer. + broker_addr + .do_send(Publish(ClientEvent::SyncCompleted { ops_applied: 7 })) + .unwrap(); + gloo_timers::future::TimeoutFuture::new(50).await; + + let events = captured.borrow(); + let strs: Vec = events + .iter() + .map(|ev| ev.as_string().unwrap_or_default()) + .collect(); + assert!( + strs.contains(&"PREEXISTING".to_string()), + "buffered pre-existing event should be drained; got {strs:?}" + ); + + js_sys::Reflect::delete_property(&window, &"__willowEvent".into()).unwrap(); + js_sys::Reflect::delete_property(&window, &"__willowEventBuffer".into()).unwrap(); +} + +#[wasm_bindgen_test] +async fn buffer_overflow_calls_willow_overflow_callback() { + let window = web_sys::window().unwrap(); + + // Set up the overflow hook first. + let overflow_count: Rc> = Rc::new(RefCell::new(0)); + let overflow_clone = overflow_count.clone(); + let overflow_cb = Closure::wrap(Box::new(move |dropped: f64| { + *overflow_clone.borrow_mut() += dropped as u32; + }) as Box); + js_sys::Reflect::set( + &window, + &"__willowOverflow".into(), + overflow_cb.as_ref().unchecked_ref(), + ) + .unwrap(); + overflow_cb.forget(); + + // Pre-fill the buffer to capacity (65_536 entries) so the next push overflows. + let pre_buffer = js_sys::Array::new(); + for i in 0..65_536u32 { + pre_buffer.push(&JsValue::from_f64(i as f64)); + } + js_sys::Reflect::set(&window, &"__willowEventBuffer".into(), &pre_buffer).unwrap(); + + // Do NOT bind __willowEvent — we want push_into_buffer to be the path under test. + js_sys::Reflect::delete_property(&window, &"__willowEvent".into()).unwrap(); + + let (broker_addr, _dispatcher, _sys) = fresh_dispatcher_setup().await; + + // Triggering a new event causes the dispatcher to push into a full buffer. + broker_addr + .do_send(Publish(ClientEvent::SyncCompleted { ops_applied: 99 })) + .unwrap(); + gloo_timers::future::TimeoutFuture::new(50).await; + + assert!( + *overflow_count.borrow() >= 1, + "expected at least one overflow signal, got {}", + *overflow_count.borrow() + ); + + js_sys::Reflect::delete_property(&window, &"__willowOverflow".into()).unwrap(); + js_sys::Reflect::delete_property(&window, &"__willowEventBuffer".into()).unwrap(); +} diff --git a/docs/plans/2026-04-27-event-based-waits-pr1-test-hooks-foundation.md b/docs/plans/2026-04-27-event-based-waits-pr1-test-hooks-foundation.md new file mode 100644 index 00000000..2e71dbbc --- /dev/null +++ b/docs/plans/2026-04-27-event-based-waits-pr1-test-hooks-foundation.md @@ -0,0 +1,1849 @@ +# Event-Based Waits — PR 1: test-hooks Foundation + +> **2026-04-28: ERRATA APPLIES.** Read `docs/plans/2026-04-28-event-based-waits-pr1-errata.md` alongside this plan. Where they conflict, the errata wins. Several API assumptions in the original plan were wrong (no sync DAG read path, MemNetwork is native-only, `to_hex()` doesn't exist, `member_count` field doesn't exist). The errata documents the corrected pattern with file:line citations. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land the cargo-feature-gated `test-hooks` infrastructure (`WillowTestHooks` WASM API + push dispatcher), the symbol-leak guard, the justfile `FEATURES` parameterisation, the ESLint rule for `waitForTimeout`, and the per-spec allowlist headers. No spec migrations yet; that work is PR 2. + +**Architecture:** New module `crates/web/src/test_hooks.rs` (gated `#[cfg(feature = "test-hooks")]`) exposes `WillowTestHooks` to JS via `#[wasm_bindgen]` for pull-based snapshot/heads/event-count queries, and spawns a push dispatcher that subscribes to `ClientHandle::subscribe_events()` and forwards a stable wire-shape JSON to `window.__willowEvent` (a Playwright `exposeBinding`). Mount happens in `app.rs` after `with_trust_store`. The `test-hooks` feature is **off in production**: a CI script greps the `dist/*.js` output of the default `trunk build --release` to assert `WillowTestHooks` is absent. + +**Tech Stack:** Rust + `wasm-bindgen` + `serde_wasm_bindgen`, Leptos, `wasm-pack` browser tests, justfile, ESLint. + +**Spec reference:** `docs/specs/2026-04-27-event-based-waits-design.md`. The spec is the source of truth for the wire shape, the data-state lifecycle, and the rejected alternatives. This plan implements the PR-1 scope only. + +--- + +## Pre-flight + +### Task 0.1: Audit iroh for `performance.now` usage + +Per spec section "iroh timer verification (PR 1 acceptance gate)". This is a one-off check: if iroh's WASM transport reads `performance.now()`, `page.clock` install would freeze JS time but not iroh, causing silent divergence in tests that install the clock. + +**Files:** +- Modify: PR description (record audit result) + +- [ ] **Step 1: Run the audit** + +```bash +cargo metadata --format-version 1 \ + | jq -r '.packages[] | select(.name | startswith("iroh")) | .manifest_path' \ + | xargs -I{} dirname {} \ + | xargs -I{} grep -rn 'performance' {} 2>/dev/null \ + | grep -v test \ + | head -50 +``` + +Expected: Either no matches (clean — `page.clock` covers iroh) or matches in retry/backoff code (constrain `page.clock` install to single-peer scopes only). + +- [ ] **Step 2: Record the audit result in the PR description** + +Paste the output (or "no matches") into the PR description under a heading "iroh `performance.now` audit". This survives review and is referenced by the spec. + +### Task 0.2: Open the GitHub tracking issue + +Per spec section "Tracking issue". The URL is needed by Phase 7's `eslint-disable` headers, so the issue must exist before PR 1 lands. + +**Files:** +- (External: GitHub) + +- [ ] **Step 1: Create the issue** + +Title: `e2e: migrate remaining specs to event-based waits` + +Body: +```markdown +Tracks migration of the 7 remaining Playwright spec files from time-based +to event-based waits. Spec: `docs/specs/2026-04-27-event-based-waits-design.md`. + +**Sunset: 2026-09-30.** After this date the ratchet script flips to +hard-fail at 0 `waitForTimeout` calls. + +- [ ] `e2e/permissions.spec.ts` +- [ ] `e2e/mobile.spec.ts` +- [ ] `e2e/mobile-actions.spec.ts` +- [ ] `e2e/multi-peer-mobile.spec.ts` +- [ ] `e2e/cross-browser-sync.spec.ts` +- [ ] `e2e/join-links.spec.ts` +- [ ] `e2e/worker-nodes.spec.ts` +``` + +- [ ] **Step 2: Capture the issue URL** + +Save the URL (e.g. `https://github.com/intendednull/willow/issues/N`). Used in Phase 7 `eslint-disable` headers. + +--- + +## Phase 1: Cargo feature scaffold + +### Task 1.1: Add the `test-hooks` cargo feature + +**Files:** +- Modify: `crates/web/Cargo.toml` + +- [ ] **Step 1: Add the `[features]` section** + +Insert at the bottom of the file (after `[dev-dependencies]`): + +```toml +[features] +default = [] +test-hooks = ["dep:serde_wasm_bindgen"] +``` + +And add the optional dependency to `[dependencies]`: + +```toml +serde_wasm_bindgen = { version = "0.6", optional = true } +``` + +(`serde_wasm_bindgen` is gated as an optional dep so the prod build doesn't pay for it.) + +- [ ] **Step 2: Verify both build configurations compile** + +Run: +```bash +cargo check -p willow-web +cargo check -p willow-web --features test-hooks +``` + +Expected: both succeed with zero warnings. + +- [ ] **Step 3: Commit** + +```bash +git add crates/web/Cargo.toml +git commit -m "feat(web): add test-hooks cargo feature" +``` + +### Task 1.2: Create the gated module skeleton + +**Files:** +- Create: `crates/web/src/test_hooks.rs` +- Modify: `crates/web/src/lib.rs` + +- [ ] **Step 1: Write `crates/web/src/test_hooks.rs`** + +```rust +//! Test instrumentation for the Willow web UI. +//! +//! This module is gated behind the `test-hooks` cargo feature and is +//! **never compiled into production builds**. It exposes +//! `WillowTestHooks` to JavaScript via `wasm_bindgen` so Playwright +//! e2e tests can synchronise on real signals (applied events, DAG +//! heads, snapshot fields) instead of arbitrary `waitForTimeout`s. +//! +//! See `docs/specs/2026-04-27-event-based-waits-design.md`. + +#![cfg(feature = "test-hooks")] + +use wasm_bindgen::prelude::*; + +/// Read-only test instrumentation handle exposed to JS as `window.__willow`. +#[wasm_bindgen] +pub struct WillowTestHooks { + // ClientHandle field added in Task 2.2. + _placeholder: (), +} +``` + +- [ ] **Step 2: Wire the module into `crates/web/src/lib.rs`** + +Find the existing `pub mod` declarations and add: + +```rust +#[cfg(feature = "test-hooks")] +pub mod test_hooks; +``` + +- [ ] **Step 3: Verify both build configurations still compile** + +```bash +cargo check -p willow-web +cargo check -p willow-web --features test-hooks +``` + +Expected: both pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/web/src/test_hooks.rs crates/web/src/lib.rs +git commit -m "feat(web): add gated test_hooks module skeleton" +``` + +--- + +## Phase 2: Pull API — `WillowTestHooks` snapshot / heads / event_count / last_event + +The pull API serializes a `SnapshotDto` (defined in this phase) plus the existing `HeadsSummary` from `crates/state/src/sync.rs:22`. The DTO uses `#[serde(rename_all = "camelCase")]` so the JS-side field names match the spec's TypeScript `Snapshot` interface without modifying the state crate. + +### Task 2.1: Define the snapshot DTO + +**Files:** +- Create: `crates/web/src/test_hooks/mod.rs` (renamed from `test_hooks.rs`) +- Create: `crates/web/src/test_hooks/snapshot.rs` +- Modify: `crates/web/src/test_hooks.rs` → move to `crates/web/src/test_hooks/mod.rs` + +- [ ] **Step 1: Convert `test_hooks.rs` to a module directory** + +```bash +mkdir -p crates/web/src/test_hooks +git mv crates/web/src/test_hooks.rs crates/web/src/test_hooks/mod.rs +``` + +- [ ] **Step 2: Add the snapshot DTO file** + +Write `crates/web/src/test_hooks/snapshot.rs`: + +```rust +//! DTOs for the `WillowTestHooks` pull API. +//! +//! These mirror the TypeScript `Snapshot` / `AuthorHead` types defined +//! in `e2e/test-hooks.ts`. Field names are camelCase to match TS +//! convention; the kind discriminator on `ClientEvent` (a separate +//! module) stays PascalCase. + +use serde::Serialize; + +/// One author's DAG head, as exposed to JS. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthorHeadDto { + pub seq: u64, + pub hash: String, +} + +/// One channel's summary, as exposed to JS. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelDto { + pub name: String, + pub member_count: u32, +} + +/// Aggregated state snapshot for `expect.poll` matchers. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SnapshotDto { + pub event_count: u32, + /// Per-author DAG heads, keyed by `EndpointId` hex string. + pub heads: std::collections::BTreeMap, + pub last_event: Option, + pub channels: Vec, +} +``` + +- [ ] **Step 3: Wire the submodule into `crates/web/src/test_hooks/mod.rs`** + +At the top of `mod.rs`: + +```rust +mod snapshot; +pub use snapshot::{AuthorHeadDto, ChannelDto, SnapshotDto}; +``` + +- [ ] **Step 4: Verify it compiles under both configurations** + +```bash +cargo check -p willow-web +cargo check -p willow-web --features test-hooks +``` + +Expected: both pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/web/src/test_hooks/ +git commit -m "feat(web): add SnapshotDto / AuthorHeadDto / ChannelDto" +``` + +### Task 2.2: Failing browser test for `event_count` and `last_event` + +The pull API's first observable behaviour: after the client applies its `CreateServer` genesis event, `event_count == 1` and `last_event == Some()`. + +**Files:** +- Create: `crates/web/tests/test_hooks_browser.rs` + +- [ ] **Step 1: Write the test file** + +```rust +//! In-browser tests for `WillowTestHooks`. +//! +//! Run with: +//! wasm-pack test crates/web --headless --chrome --features test-hooks +//! +//! These tests construct a real `ClientHandle` (no networking — uses +//! `MemNetwork`) and assert the test-hooks API observes the expected +//! shape after applying known events. + +#![cfg(feature = "test-hooks")] + +use wasm_bindgen_test::*; +use willow_client::ClientHandle; +use willow_network::mem::MemNetwork; +use willow_web::test_hooks::WillowTestHooks; + +wasm_bindgen_test_configure!(run_in_browser); + +async fn fresh_client() -> ClientHandle { + // Helper: spin up a ClientHandle backed by MemNetwork and apply + // CreateServer so the DAG is non-empty. Returns the handle. + let network = MemNetwork::new(); + let config = willow_client::ClientConfig::ephemeral_with_network(network); + let (handle, _event_loop) = ClientHandle::new(config); + handle.create_server("test-server").await.unwrap(); + handle +} + +#[wasm_bindgen_test] +async fn snapshot_event_count_and_last_event_after_create_server() { + let handle = fresh_client().await; + let hooks = WillowTestHooks::new(handle); + + assert_eq!(hooks.event_count(), 1, "CreateServer should be event #1"); + assert!( + hooks.last_event().is_some(), + "last_event should be Some after CreateServer" + ); +} +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +```bash +wasm-pack test crates/web --headless --chrome --features test-hooks --test test_hooks_browser +``` + +Expected: compile error or test failure — `WillowTestHooks::new` does not yet accept a `ClientHandle`. + +### Task 2.3: Implement `event_count` and `last_event` + +**Files:** +- Modify: `crates/web/src/test_hooks/mod.rs` + +- [ ] **Step 1: Replace the placeholder struct** + +Update `crates/web/src/test_hooks/mod.rs`: + +```rust +#![cfg(feature = "test-hooks")] + +mod snapshot; +pub use snapshot::{AuthorHeadDto, ChannelDto, SnapshotDto}; + +use wasm_bindgen::prelude::*; +use willow_client::ClientHandle; +use willow_network::Network; + +/// Read-only test instrumentation handle exposed to JS as `window.__willow`. +/// +/// Generic over `Network` so unit tests can construct it with `MemNetwork` +/// and the production mount in `app.rs` uses `IrohNetwork`. +#[wasm_bindgen] +pub struct WillowTestHooks { + // SendWrapper not needed here: WillowTestHooks is only ever held on + // the main browser thread (single-threaded WASM). The handle is a + // pure data accessor; no async work runs through this type directly. + inner: WillowTestHooksInner, +} + +struct WillowTestHooksInner { + /// Type-erased handle. Only the methods used by the pull API are + /// invoked; the dispatcher (Phase 4) holds its own clone of the + /// concrete-typed handle. + event_count_fn: Box u32>, + last_event_fn: Box Option>, + snapshot_fn: Box SnapshotDto>, + heads_fn: Box std::collections::BTreeMap>, +} + +impl WillowTestHooks { + /// Construct from any `ClientHandle`. Captures the handle in + /// closures so the wasm_bindgen-exposed methods can stay + /// monomorphic. + pub fn new(handle: ClientHandle) -> Self { + let h_event_count = handle.clone(); + let h_last_event = handle.clone(); + let h_snapshot = handle.clone(); + let h_heads = handle.clone(); + Self { + inner: WillowTestHooksInner { + event_count_fn: Box::new(move || { + h_event_count.dag_event_count() as u32 + }), + last_event_fn: Box::new(move || { + h_last_event.dag_last_event_hash().map(|h| h.to_hex()) + }), + snapshot_fn: Box::new(move || snapshot::build(&h_snapshot)), + heads_fn: Box::new(move || snapshot::build_heads(&h_heads)), + }, + } + } +} + +#[wasm_bindgen] +impl WillowTestHooks { + /// Total events applied to the local DAG. + pub fn event_count(&self) -> u32 { + (self.inner.event_count_fn)() + } + + /// Hex-encoded hash of the most recently applied event, or `None`. + pub fn last_event(&self) -> Option { + (self.inner.last_event_fn)() + } +} +``` + +- [ ] **Step 2: Add the supporting accessors `dag_event_count` + `dag_last_event_hash` to `ClientHandle`** + +These are pure read accessors over the existing DAG. Add to `crates/client/src/accessors.rs`: + +```rust +impl ClientHandle { + /// Total events applied to the local DAG. Used by test-hooks; cheap + /// O(1) read of an actor-held counter. + pub fn dag_event_count(&self) -> usize { + self.shared.dag_event_count() + } + + /// Hash of the most recently applied event across all authors, or + /// `None` if the DAG is empty. + pub fn dag_last_event_hash(&self) -> Option { + self.shared.dag_last_event_hash() + } +} +``` + +(The `shared` field is the existing `Arc`; it already exposes the DAG via the actor read-side. Mirror an existing accessor pattern from the same file for the underlying impl.) + +- [ ] **Step 3: Add the two minimal `snapshot::build` / `build_heads` stubs that the test in Task 2.2 will exercise** + +Append to `crates/web/src/test_hooks/snapshot.rs`: + +```rust +use willow_client::ClientHandle; +use willow_network::Network; + +pub(crate) fn build(handle: &ClientHandle) -> SnapshotDto { + SnapshotDto { + event_count: handle.dag_event_count() as u32, + heads: build_heads(handle), + last_event: handle.dag_last_event_hash().map(|h| h.to_hex()), + channels: handle + .channels_view() + .into_iter() + .map(|ch| ChannelDto { + name: ch.name, + member_count: ch.member_count as u32, + }) + .collect(), + } +} + +pub(crate) fn build_heads( + handle: &ClientHandle, +) -> std::collections::BTreeMap { + handle + .dag_heads_summary() + .heads + .into_iter() + .map(|(endpoint, head)| { + ( + endpoint.to_hex(), + AuthorHeadDto { + seq: head.seq, + hash: head.hash.to_hex(), + }, + ) + }) + .collect() +} +``` + +- [ ] **Step 4: Add `dag_heads_summary()` and `channels_view()` accessors if not already public** + +Check `crates/client/src/accessors.rs`. If `heads_summary` and a channel-list accessor are not already public, expose them now (mirror the spec's intent — the data is already computed; this just publishes it). + +- [ ] **Step 5: Run the test from Task 2.2 again** + +```bash +wasm-pack test crates/web --headless --chrome --features test-hooks --test test_hooks_browser +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/web/src/test_hooks/ crates/web/tests/test_hooks_browser.rs crates/client/src/accessors.rs +git commit -m "feat(web): add WillowTestHooks event_count + last_event" +``` + +### Task 2.4: Failing test for `heads()` + +**Files:** +- Modify: `crates/web/tests/test_hooks_browser.rs` + +- [ ] **Step 1: Add the test** + +```rust +#[wasm_bindgen_test] +async fn heads_returns_one_author_after_create_server() { + let handle = fresh_client().await; + let hooks = WillowTestHooks::new(handle); + + let heads_js = hooks.heads().expect("heads should serialize"); + let heads: std::collections::BTreeMap = + serde_wasm_bindgen::from_value(heads_js).expect("deserialize"); + + assert_eq!(heads.len(), 1, "exactly one author after CreateServer"); + let (_id, head) = heads.iter().next().unwrap(); + assert_eq!(head.seq, 0, "genesis seq is 0"); + assert!(!head.hash.is_empty(), "hash is set"); +} +``` + +- [ ] **Step 2: Run, expect compile error / fail** + +```bash +wasm-pack test crates/web --headless --chrome --features test-hooks --test test_hooks_browser +``` + +Expected: FAIL — `WillowTestHooks::heads` is not yet exposed to JS. + +### Task 2.5: Implement `heads()` on the JS surface + +**Files:** +- Modify: `crates/web/src/test_hooks/mod.rs` + +- [ ] **Step 1: Add the method to the `#[wasm_bindgen] impl` block** + +```rust +#[wasm_bindgen] +impl WillowTestHooks { + // …existing methods… + + /// Per-author DAG heads. Stable across calls when the DAG is unchanged. + pub fn heads(&self) -> Result { + let heads = (self.inner.heads_fn)(); + serde_wasm_bindgen::to_value(&heads).map_err(Into::into) + } +} +``` + +- [ ] **Step 2: Run the test, expect PASS** + +```bash +wasm-pack test crates/web --headless --chrome --features test-hooks --test test_hooks_browser +``` + +Expected: PASS for both `snapshot_event_count_and_last_event_after_create_server` and `heads_returns_one_author_after_create_server`. + +### Task 2.6: Failing test for full `snapshot()` + +**Files:** +- Modify: `crates/web/tests/test_hooks_browser.rs` + +- [ ] **Step 1: Add the test** + +```rust +#[wasm_bindgen_test] +async fn snapshot_returns_full_dto_with_one_channel() { + let handle = fresh_client().await; + handle.create_channel("general").await.unwrap(); + let hooks = WillowTestHooks::new(handle); + + let snap_js = hooks.snapshot().expect("snapshot should serialize"); + let snap: willow_web::test_hooks::SnapshotDto = + serde_wasm_bindgen::from_value(snap_js).expect("deserialize"); + + assert_eq!(snap.event_count, 2, "CreateServer + CreateChannel = 2"); + assert_eq!(snap.heads.len(), 1, "still one author"); + assert!(snap.last_event.is_some()); + assert_eq!(snap.channels.len(), 1); + assert_eq!(snap.channels[0].name, "general"); +} +``` + +- [ ] **Step 2: Run, expect FAIL** — `snapshot()` not yet exposed. + +### Task 2.7: Implement `snapshot()` on the JS surface + +**Files:** +- Modify: `crates/web/src/test_hooks/mod.rs` + +- [ ] **Step 1: Add the method** + +```rust +#[wasm_bindgen] +impl WillowTestHooks { + // …existing methods… + + /// Aggregated state snapshot for `expect.poll` matchers. + pub fn snapshot(&self) -> Result { + let snap = (self.inner.snapshot_fn)(); + serde_wasm_bindgen::to_value(&snap).map_err(Into::into) + } +} +``` + +- [ ] **Step 2: Run, expect PASS** + +- [ ] **Step 3: Commit** + +```bash +git add crates/web/src/test_hooks/ crates/web/tests/test_hooks_browser.rs +git commit -m "feat(web): add WillowTestHooks heads + snapshot pull API" +``` + +--- + +## Phase 3: `ClientEvent` wire-shape conversion + +Per spec section "Stable JSON wire shape for `ClientEvent`", `test_hooks` defines a hand-written conversion from the Rust `ClientEvent` enum to a stable `{kind: , ...flat camelCase fields}` JSON shape. Internal-only variants (`QueueChanged`, `VoiceSignal`, etc.) are filtered out — `to_wire()` returns `None` for them. + +### Task 3.1: Failing unit test for `SyncCompleted` conversion + +**Files:** +- Create: `crates/web/src/test_hooks/wire.rs` +- Modify: `crates/web/src/test_hooks/mod.rs` + +- [ ] **Step 1: Add `pub mod wire;` to `crates/web/src/test_hooks/mod.rs`** + +Insert near the top: +```rust +mod wire; +pub use wire::{to_wire, WireEvent}; +``` + +- [ ] **Step 2: Write `crates/web/src/test_hooks/wire.rs` with a stub + a unit test** + +```rust +//! Stable JSON wire shape for `ClientEvent`. +//! +//! `to_wire(event)` returns `Some(WireEvent)` for variants exposed to +//! e2e tests, and `None` for internal-only variants. The `WireEvent` +//! shape is `{kind: , ...camelCase fields}` per the spec. + +use serde::Serialize; +use willow_client::events::ClientEvent; + +/// JSON-stable representation of a `ClientEvent` for the test surface. +/// +/// Each variant flattens into `{kind, ...fields}`. The `kind` +/// discriminator is PascalCase (matches Rust variant names); other +/// fields are camelCase (matches TypeScript convention). +#[derive(Serialize)] +#[serde(tag = "kind")] +pub enum WireEvent { + SyncCompleted { + #[serde(rename = "opsApplied")] + ops_applied: u32, + }, + // …other variants added in Task 3.3… +} + +/// Convert a `ClientEvent` to its wire shape, or `None` if the variant +/// is internal-only. +pub fn to_wire(event: &ClientEvent) -> Option { + match event { + ClientEvent::SyncCompleted { ops_applied } => Some(WireEvent::SyncCompleted { + ops_applied: *ops_applied as u32, + }), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sync_completed_serializes_to_stable_shape() { + let ev = ClientEvent::SyncCompleted { ops_applied: 5 }; + let wire = to_wire(&ev).expect("SyncCompleted must convert"); + let json = serde_json::to_string(&wire).unwrap(); + assert_eq!( + json, + r#"{"kind":"SyncCompleted","opsApplied":5}"#, + ); + } +} +``` + +- [ ] **Step 3: Run the test** + +```bash +cargo test -p willow-web --features test-hooks --lib test_hooks::wire +``` + +Expected: PASS (this task already includes the implementation; the test verifies the shape). + +### Task 3.2: Failing tests for the remaining 9 wire-visible variants + +The spec lists 10 variants total. We just covered `SyncCompleted`; this task adds tests for the other 9, all expected to fail until Task 3.3 implements them. + +**Files:** +- Modify: `crates/web/src/test_hooks/wire.rs` + +- [ ] **Step 1: Add 9 failing tests in the existing `mod tests`** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use willow_identity::EndpointId; + + fn endpoint_a() -> EndpointId { + // 32-byte all-ones for a stable test fixture. + EndpointId::from_bytes([1u8; 32]) + } + + #[test] + fn message_received() { + let ev = ClientEvent::MessageReceived { + channel: "general".into(), + message_id: "m1".into(), + is_local: false, + }; + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert_eq!( + json, + r#"{"kind":"MessageReceived","channel":"general","messageId":"m1","isLocal":false}"#, + ); + } + + #[test] + fn peer_connected() { + let ev = ClientEvent::PeerConnected(endpoint_a()); + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert!(json.starts_with(r#"{"kind":"PeerConnected","peerId":"#)); + } + + #[test] + fn peer_disconnected() { + let ev = ClientEvent::PeerDisconnected(endpoint_a()); + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert!(json.starts_with(r#"{"kind":"PeerDisconnected","peerId":"#)); + } + + #[test] + fn channel_created() { + let ev = ClientEvent::ChannelCreated("general".into()); + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert_eq!(json, r#"{"kind":"ChannelCreated","name":"general"}"#); + } + + #[test] + fn channel_deleted() { + let ev = ClientEvent::ChannelDeleted("general".into()); + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert_eq!(json, r#"{"kind":"ChannelDeleted","name":"general"}"#); + } + + #[test] + fn peer_trusted() { + let ev = ClientEvent::PeerTrusted(endpoint_a()); + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert!(json.starts_with(r#"{"kind":"PeerTrusted","peerId":"#)); + } + + #[test] + fn peer_untrusted() { + let ev = ClientEvent::PeerUntrusted(endpoint_a()); + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert!(json.starts_with(r#"{"kind":"PeerUntrusted","peerId":"#)); + } + + #[test] + fn profile_updated() { + let ev = ClientEvent::ProfileUpdated { + peer_id: endpoint_a(), + display_name: "alice".into(), + }; + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert!(json.contains(r#""kind":"ProfileUpdated""#)); + assert!(json.contains(r#""displayName":"alice""#)); + } + + #[test] + fn role_created() { + let ev = ClientEvent::RoleCreated { + name: "moderator".into(), + role_id: "r1".into(), + }; + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert_eq!( + json, + r#"{"kind":"RoleCreated","roleId":"r1","name":"moderator"}"#, + ); + } + + // Existing test — keep it. + #[test] + fn sync_completed_serializes_to_stable_shape() { + let ev = ClientEvent::SyncCompleted { ops_applied: 5 }; + let json = serde_json::to_string(&to_wire(&ev).unwrap()).unwrap(); + assert_eq!( + json, + r#"{"kind":"SyncCompleted","opsApplied":5}"#, + ); + } +} +``` + +- [ ] **Step 2: Run, expect 9 failures** + +```bash +cargo test -p willow-web --features test-hooks --lib test_hooks::wire +``` + +Expected: 9 of 10 tests fail because the variants are not yet implemented. + +### Task 3.3: Implement the 9 remaining wire variants + +**Files:** +- Modify: `crates/web/src/test_hooks/wire.rs` + +- [ ] **Step 1: Replace the `WireEvent` enum and `to_wire` body** + +```rust +#[derive(Serialize)] +#[serde(tag = "kind")] +pub enum WireEvent { + SyncCompleted { + #[serde(rename = "opsApplied")] + ops_applied: u32, + }, + MessageReceived { + channel: String, + #[serde(rename = "messageId")] + message_id: String, + #[serde(rename = "isLocal")] + is_local: bool, + }, + PeerConnected { + #[serde(rename = "peerId")] + peer_id: String, + }, + PeerDisconnected { + #[serde(rename = "peerId")] + peer_id: String, + }, + ChannelCreated { + name: String, + }, + ChannelDeleted { + name: String, + }, + PeerTrusted { + #[serde(rename = "peerId")] + peer_id: String, + }, + PeerUntrusted { + #[serde(rename = "peerId")] + peer_id: String, + }, + ProfileUpdated { + #[serde(rename = "peerId")] + peer_id: String, + #[serde(rename = "displayName")] + display_name: String, + }, + RoleCreated { + #[serde(rename = "roleId")] + role_id: String, + name: String, + }, +} + +pub fn to_wire(event: &ClientEvent) -> Option { + match event { + ClientEvent::SyncCompleted { ops_applied } => Some(WireEvent::SyncCompleted { + ops_applied: *ops_applied as u32, + }), + ClientEvent::MessageReceived { + channel, + message_id, + is_local, + } => Some(WireEvent::MessageReceived { + channel: channel.clone(), + message_id: message_id.clone(), + is_local: *is_local, + }), + ClientEvent::PeerConnected(id) => Some(WireEvent::PeerConnected { + peer_id: id.to_hex(), + }), + ClientEvent::PeerDisconnected(id) => Some(WireEvent::PeerDisconnected { + peer_id: id.to_hex(), + }), + ClientEvent::ChannelCreated(name) => Some(WireEvent::ChannelCreated { + name: name.clone(), + }), + ClientEvent::ChannelDeleted(name) => Some(WireEvent::ChannelDeleted { + name: name.clone(), + }), + ClientEvent::PeerTrusted(id) => Some(WireEvent::PeerTrusted { + peer_id: id.to_hex(), + }), + ClientEvent::PeerUntrusted(id) => Some(WireEvent::PeerUntrusted { + peer_id: id.to_hex(), + }), + ClientEvent::ProfileUpdated { + peer_id, + display_name, + } => Some(WireEvent::ProfileUpdated { + peer_id: peer_id.to_hex(), + display_name: display_name.clone(), + }), + ClientEvent::RoleCreated { name, role_id } => Some(WireEvent::RoleCreated { + role_id: role_id.clone(), + name: name.clone(), + }), + // Internal-only variants are filtered out. + _ => None, + } +} +``` + +- [ ] **Step 2: Run all wire tests, expect PASS** + +```bash +cargo test -p willow-web --features test-hooks --lib test_hooks::wire +``` + +Expected: 10 of 10 pass. + +### Task 3.4: Failing test that internal variants are filtered + +**Files:** +- Modify: `crates/web/src/test_hooks/wire.rs` + +- [ ] **Step 1: Add the test** + +```rust +#[test] +fn internal_variants_are_filtered() { + use willow_client::queue::RelayStatus; + let ev = ClientEvent::RelayStatusChanged(RelayStatus::Connected); + assert!( + to_wire(&ev).is_none(), + "internal-only variants must not leak to the wire" + ); +} +``` + +- [ ] **Step 2: Run, expect PASS** (the catch-all `_ => None` arm already covers this; the test guards against future regressions where someone accidentally adds a wire-visible arm for an internal variant). + +- [ ] **Step 3: Commit** + +```bash +git add crates/web/src/test_hooks/ +git commit -m "feat(web): add ClientEvent wire-shape conversion" +``` + +--- + +## Phase 4: Push dispatcher + +The dispatcher subscribes to `ClientHandle::subscribe_events()` (`crates/client/src/accessors.rs:10`, returns `EventReceiver` from `crates/client/src/lib.rs:120`), converts each `ClientEvent` to `WireEvent`, and forwards to `window.__willowEvent`. Buffer with capacity 65 536; overflow calls `window.__willowOverflow(droppedCount)`. Drain on three edges per spec: dispatcher init, every dispatch, and the Playwright fixture's read-side binding. + +### Task 4.1: Failing browser test that the dispatcher emits to a JS callback + +**Files:** +- Modify: `crates/web/tests/test_hooks_browser.rs` + +- [ ] **Step 1: Add the test** + +```rust +use std::cell::RefCell; +use std::rc::Rc; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::JsValue; + +#[wasm_bindgen_test] +async fn dispatcher_emits_sync_completed_to_window_callback() { + let captured: Rc>> = Rc::new(RefCell::new(Vec::new())); + let captured_clone = captured.clone(); + + let cb = Closure::wrap(Box::new(move |ev: JsValue| { + captured_clone.borrow_mut().push(ev); + }) as Box); + + let window = web_sys::window().unwrap(); + js_sys::Reflect::set(&window, &"__willowEvent".into(), cb.as_ref()).unwrap(); + cb.forget(); + + let handle = fresh_client().await; + let _dispatcher = + willow_web::test_hooks::install_push_dispatcher(handle.clone()); + + // Trigger a SyncCompleted by applying another event. + handle.create_channel("general").await.unwrap(); + + // Yield to let the dispatcher loop run. + gloo_timers::future::TimeoutFuture::new(50).await; + + let events = captured.borrow(); + assert!( + events.iter().any(|ev| { + let s = js_sys::JSON::stringify(ev).unwrap().as_string().unwrap(); + s.contains(r#""kind":"SyncCompleted""#) + }), + "expected at least one SyncCompleted event; got {:?}", + events + .iter() + .map(|ev| js_sys::JSON::stringify(ev).unwrap().as_string().unwrap()) + .collect::>() + ); + + // Cleanup. + js_sys::Reflect::delete_property(&window, &"__willowEvent".into()).unwrap(); +} +``` + +- [ ] **Step 2: Run, expect FAIL** (compile error — `install_push_dispatcher` does not exist). + +```bash +wasm-pack test crates/web --headless --chrome --features test-hooks --test test_hooks_browser +``` + +### Task 4.2: Implement the dispatcher with init + per-dispatch drain + +**Files:** +- Create: `crates/web/src/test_hooks/dispatcher.rs` +- Modify: `crates/web/src/test_hooks/mod.rs` + +- [ ] **Step 1: Add the dispatcher module** + +Write `crates/web/src/test_hooks/dispatcher.rs`: + +```rust +//! Push dispatcher for `WillowTestHooks`. +//! +//! Subscribes to `ClientHandle::subscribe_events()`, converts each +//! `ClientEvent` to its wire shape, and forwards to +//! `window.__willowEvent` (a Playwright `exposeBinding`). On overflow +//! calls `window.__willowOverflow(droppedCount)` so the test fixture +//! can fail the test immediately. + +use std::cell::RefCell; +use std::rc::Rc; + +use wasm_bindgen::JsValue; +use wasm_bindgen_futures::spawn_local; +use willow_client::ClientHandle; +use willow_network::Network; + +use super::wire::to_wire; + +const BUFFER_CAPACITY: usize = 65_536; + +/// Returned from `install_push_dispatcher`. Dropping aborts the loop. +pub struct DispatcherHandle { + abort: Rc>, +} + +impl Drop for DispatcherHandle { + fn drop(&mut self) { + *self.abort.borrow_mut() = true; + } +} + +/// Install the push dispatcher. Spawns a `wasm_bindgen_futures` task +/// that loops on the broker `recv()`, converts events, and forwards +/// them to `window.__willowEvent`. +pub fn install_push_dispatcher( + handle: ClientHandle, +) -> DispatcherHandle { + let abort = Rc::new(RefCell::new(false)); + let abort_clone = abort.clone(); + + spawn_local(async move { + let mut rx = handle.subscribe_events().await; + + // Drain on dispatcher init: covers the case where a previous + // dispatcher buffered events into __willowEventBuffer before + // being aborted (hot reload, auth re-init). + drain_buffer_into_callback(); + + while !*abort_clone.borrow() { + let Some(event) = rx.recv().await else { break }; + let Some(wire) = to_wire(&event) else { continue }; + + let js = match serde_wasm_bindgen::to_value(&wire) { + Ok(v) => v, + Err(e) => { + web_sys::console::error_1( + &format!("test-hooks: serialize failed: {e:?}").into(), + ); + continue; + } + }; + + // Drain on every dispatch: covers the case where the + // binding became available after some events were already + // buffered. + drain_buffer_into_callback(); + dispatch_or_buffer(js); + } + }); + + DispatcherHandle { abort } +} + +/// Try to call `window.__willowEvent(js)`. If the binding is absent, +/// push the value into `window.__willowEventBuffer` so a future drain +/// can deliver it. +fn dispatch_or_buffer(js: JsValue) { + let Some(window) = web_sys::window() else { return }; + + if let Ok(callback) = js_sys::Reflect::get(&window, &"__willowEvent".into()) { + if let Some(func) = callback.dyn_ref::() { + let _ = func.call1(&JsValue::NULL, &js); + return; + } + } + + push_into_buffer(&window, js); +} + +fn drain_buffer_into_callback() { + let Some(window) = web_sys::window() else { return }; + + let Ok(callback) = js_sys::Reflect::get(&window, &"__willowEvent".into()) else { + return; + }; + let Some(func) = callback.dyn_ref::() else { + return; + }; + + let Ok(buffer) = js_sys::Reflect::get(&window, &"__willowEventBuffer".into()) else { + return; + }; + let Some(arr) = buffer.dyn_ref::() else { + return; + }; + + while arr.length() > 0 { + let item = arr.shift(); + let _ = func.call1(&JsValue::NULL, &item); + } +} + +fn push_into_buffer(window: &web_sys::Window, js: JsValue) { + let buffer = match js_sys::Reflect::get(window, &"__willowEventBuffer".into()) { + Ok(b) if b.is_object() => b, + _ => { + let arr = js_sys::Array::new(); + let _ = js_sys::Reflect::set(window, &"__willowEventBuffer".into(), &arr); + arr.into() + } + }; + + let arr: js_sys::Array = buffer.unchecked_into(); + + if arr.length() as usize >= BUFFER_CAPACITY { + // Overflow: drop oldest, signal the test fixture. + arr.shift(); + signal_overflow(window, 1); + } + + arr.push(&js); +} + +fn signal_overflow(window: &web_sys::Window, dropped: u32) { + if let Ok(cb) = js_sys::Reflect::get(window, &"__willowOverflow".into()) { + if let Some(func) = cb.dyn_ref::() { + let _ = func.call1(&JsValue::NULL, &JsValue::from_f64(dropped as f64)); + } + } + web_sys::console::error_1( + &format!("test-hooks: __willow buffer overflow ({dropped} dropped)").into(), + ); +} +``` + +- [ ] **Step 2: Re-export from `crates/web/src/test_hooks/mod.rs`** + +```rust +mod dispatcher; +pub use dispatcher::{install_push_dispatcher, DispatcherHandle}; +``` + +- [ ] **Step 3: Run the test from Task 4.1, expect PASS** + +```bash +wasm-pack test crates/web --headless --chrome --features test-hooks --test test_hooks_browser +``` + +Expected: PASS. + +### Task 4.3: Failing test that dispatcher abort halts the loop + +**Files:** +- Modify: `crates/web/tests/test_hooks_browser.rs` + +- [ ] **Step 1: Add the test** + +```rust +#[wasm_bindgen_test] +async fn dropping_dispatcher_handle_stops_emissions() { + let captured: Rc>> = Rc::new(RefCell::new(Vec::new())); + let captured_clone = captured.clone(); + + let cb = Closure::wrap(Box::new(move |ev: JsValue| { + captured_clone.borrow_mut().push(ev); + }) as Box); + + let window = web_sys::window().unwrap(); + js_sys::Reflect::set(&window, &"__willowEvent".into(), cb.as_ref()).unwrap(); + cb.forget(); + + let handle = fresh_client().await; + { + let _dispatcher = + willow_web::test_hooks::install_push_dispatcher(handle.clone()); + handle.create_channel("a").await.unwrap(); + gloo_timers::future::TimeoutFuture::new(50).await; + } // <- DispatcherHandle dropped here. + + let count_after_drop = captured.borrow().len(); + handle.create_channel("b").await.unwrap(); + gloo_timers::future::TimeoutFuture::new(50).await; + let count_after_post_drop_event = captured.borrow().len(); + + assert!( + count_after_post_drop_event <= count_after_drop + 1, + "dispatcher should not deliver events after handle drop \ + (got {count_after_post_drop_event} after drop, was {count_after_drop} at drop)" + ); + + js_sys::Reflect::delete_property(&window, &"__willowEvent".into()).unwrap(); +} +``` + +(The `<= count_after_drop + 1` allows for one in-flight event delivered between the create_channel("b") triggering a SyncCompleted and the Drop's `*abort.borrow_mut() = true` taking effect on the next loop iteration. If you see > +1, the abort is not being checked.) + +- [ ] **Step 2: Run, expect PASS** (Drop already implemented in Task 4.2). + +### Task 4.4: Failing test for buffer drain on dispatch + +Verifies the spec's "drain on every dispatch" edge: events buffered before the binding was registered get flushed when the next event triggers a dispatch. + +**Files:** +- Modify: `crates/web/tests/test_hooks_browser.rs` + +- [ ] **Step 1: Add the test** + +```rust +#[wasm_bindgen_test] +async fn buffer_drains_on_first_dispatch_after_binding_appears() { + let window = web_sys::window().unwrap(); + + // Pre-seed the buffer as if a dispatcher had run before the + // binding existed. + let pre_buffer = js_sys::Array::new(); + pre_buffer.push(&JsValue::from_str("PREEXISTING")); + js_sys::Reflect::set(&window, &"__willowEventBuffer".into(), &pre_buffer).unwrap(); + + let captured: Rc>> = Rc::new(RefCell::new(Vec::new())); + let captured_clone = captured.clone(); + + let cb = Closure::wrap(Box::new(move |ev: JsValue| { + captured_clone.borrow_mut().push(ev); + }) as Box); + js_sys::Reflect::set(&window, &"__willowEvent".into(), cb.as_ref()).unwrap(); + cb.forget(); + + let handle = fresh_client().await; + let _dispatcher = + willow_web::test_hooks::install_push_dispatcher(handle.clone()); + + handle.create_channel("trigger-drain").await.unwrap(); + gloo_timers::future::TimeoutFuture::new(50).await; + + let events = captured.borrow(); + let strs: Vec = events + .iter() + .map(|ev| ev.as_string().unwrap_or_default()) + .collect(); + assert!( + strs.contains(&"PREEXISTING".to_string()), + "buffered pre-existing event should be drained; got {strs:?}" + ); + + js_sys::Reflect::delete_property(&window, &"__willowEvent".into()).unwrap(); + js_sys::Reflect::delete_property(&window, &"__willowEventBuffer".into()).unwrap(); +} +``` + +- [ ] **Step 2: Run, expect PASS** (drain logic already in Task 4.2). + +### Task 4.5: Failing test for buffer overflow signalling + +**Files:** +- Modify: `crates/web/tests/test_hooks_browser.rs` + +- [ ] **Step 1: Add the test** + +```rust +#[wasm_bindgen_test] +async fn buffer_overflow_calls_willow_overflow_callback() { + let window = web_sys::window().unwrap(); + + // Set up the overflow hook FIRST. + let overflow_count: Rc> = Rc::new(RefCell::new(0)); + let overflow_clone = overflow_count.clone(); + let overflow_cb = Closure::wrap(Box::new(move |dropped: f64| { + *overflow_clone.borrow_mut() += dropped as u32; + }) as Box); + js_sys::Reflect::set(&window, &"__willowOverflow".into(), overflow_cb.as_ref()) + .unwrap(); + overflow_cb.forget(); + + // Pre-fill the buffer past capacity. Use 65_537 entries: capacity + // + 1 forces exactly one overflow drop. + let pre_buffer = js_sys::Array::new(); + for i in 0..65_537u32 { + pre_buffer.push(&JsValue::from_f64(i as f64)); + } + js_sys::Reflect::set(&window, &"__willowEventBuffer".into(), &pre_buffer).unwrap(); + + // Do NOT bind __willowEvent — we want push_into_buffer to be the + // path under test. + js_sys::Reflect::delete_property(&window, &"__willowEvent".into()).unwrap(); + + let handle = fresh_client().await; + let _dispatcher = + willow_web::test_hooks::install_push_dispatcher(handle.clone()); + + // Triggering a new event causes the dispatcher to push into a + // full buffer. + handle.create_channel("overflow-trigger").await.unwrap(); + gloo_timers::future::TimeoutFuture::new(50).await; + + assert!( + *overflow_count.borrow() >= 1, + "expected at least one overflow signal, got {}", + overflow_count.borrow() + ); + + js_sys::Reflect::delete_property(&window, &"__willowOverflow".into()).unwrap(); + js_sys::Reflect::delete_property(&window, &"__willowEventBuffer".into()).unwrap(); +} +``` + +- [ ] **Step 2: Run, expect PASS** (overflow signalling already in Task 4.2). + +- [ ] **Step 3: Commit** + +```bash +git add crates/web/src/test_hooks/ crates/web/tests/test_hooks_browser.rs +git commit -m "feat(web): add push dispatcher with three-edge buffer drain" +``` + +--- + +## Phase 5: Mount `WillowTestHooks` in `app.rs` + +Per spec section "Mounted from `app.rs`": mount must happen **after** `with_trust_store` (so the same handle the UI uses is captured) and the dispatcher handle must be bound (not discarded) so the loop survives the function scope. + +### Task 5.1: Add the cfg-gated mount block + +**Files:** +- Modify: `crates/web/src/app.rs` + +- [ ] **Step 1: Insert the mount block after `with_trust_store` and before `provide_context`** + +Find the line in `crates/web/src/app.rs` (around line 161-165) that reads: + +```rust +let handle_inner = (*handle).clone().with_trust_store(trust_store.clone()); +let handle: WebClientHandle = SendWrapper::new(handle_inner); + +// Provide context so child components can access the handle and state. +provide_context(handle.clone()); +``` + +Insert the mount block immediately between the `SendWrapper::new(handle_inner)` line and the `provide_context` call: + +```rust +let handle_inner = (*handle).clone().with_trust_store(trust_store.clone()); +let handle: WebClientHandle = SendWrapper::new(handle_inner); + +#[cfg(feature = "test-hooks")] +{ + use wasm_bindgen::JsCast; + let inner_for_hooks = (*handle).clone(); + let hooks = crate::test_hooks::WillowTestHooks::new(inner_for_hooks.clone()); + if let Some(window) = web_sys::window() { + let _ = js_sys::Reflect::set( + &window, + &"__willow".into(), + &wasm_bindgen::JsValue::from(hooks), + ); + } + let dispatcher = crate::test_hooks::install_push_dispatcher(inner_for_hooks); + // Bind the handle so it lives for the App component's scope. The + // StoredValue does not drop until the owning Leptos scope is + // disposed; binding (rather than discarding) keeps the dispatcher + // loop alive for the app's lifetime. + let _dispatcher_handle = leptos::StoredValue::new(send_wrapper::SendWrapper::new(dispatcher)); +} + +// Provide context so child components can access the handle and state. +provide_context(handle.clone()); +``` + +- [ ] **Step 2: Verify both build configurations compile** + +```bash +cargo check -p willow-web +cargo check -p willow-web --features test-hooks +``` + +Expected: both pass with zero warnings. + +### Task 5.2: Browser test that `window.__willow` exists under feature + +**Files:** +- Modify: `crates/web/tests/browser.rs` + +- [ ] **Step 1: Add the test** + +```rust +#[wasm_bindgen_test] +#[cfg(feature = "test-hooks")] +fn window_willow_is_mounted_under_test_hooks_feature() { + use willow_web::App; + + let _container = mount_test(|| leptos::view! { }); + + let window = web_sys::window().unwrap(); + let willow = js_sys::Reflect::get(&window, &"__willow".into()).unwrap(); + assert!( + !willow.is_undefined(), + "window.__willow must be present when test-hooks feature is on" + ); +} +``` + +- [ ] **Step 2: Run, expect PASS** + +```bash +wasm-pack test crates/web --headless --chrome --features test-hooks +``` + +Expected: PASS (along with all earlier tests). + +### Task 5.3: Verify default build does not export `__willow` + +This is a compile-time absence check rather than a run-time test (the default-feature browser test cannot reference `WillowTestHooks` because the symbol does not exist). The CI symbol-leak script in Phase 6 covers the production-build case more authoritatively. + +- [ ] **Step 1: Run the standard browser tests without `--features test-hooks`** + +```bash +wasm-pack test crates/web --headless --chrome +``` + +Expected: existing tests pass; the test-hooks-gated test from Task 5.2 is excluded by `cfg`. + +- [ ] **Step 2: Commit** + +```bash +git add crates/web/src/app.rs crates/web/tests/browser.rs +git commit -m "feat(web): mount WillowTestHooks under test-hooks feature" +``` + +--- + +## Phase 6: Symbol-leak guard + justfile `FEATURES` forwarding + +### Task 6.1: Add `FEATURES` variable to relevant justfile recipes + +Per spec section "`test-hooks` cargo feature": `dev`, `setup-e2e`, `test-e2e-*`, and `check-all` recipes accept a `FEATURES` variable forwarded to `trunk build`. E2e recipes hardcode `FEATURES=test-hooks` internally; everything else defaults to empty. + +**Files:** +- Modify: `justfile` + +- [ ] **Step 1: Add the `FEATURES` parameter to existing recipes** + +Identify the recipes (search the justfile for `dev:`, `setup-e2e:`, `test-e2e-ui:`, `test-e2e-sync:`, `test-e2e-perms:`, `test-e2e-full:`, `check-all:`). For each, add a `FEATURES=""` parameter and forward it to `trunk build` calls. + +Example for `dev`: + +```just +# Start full local dev stack (relay + workers + web) +dev FEATURES="": + # ...existing recipe body... + # Wherever it calls `trunk build` or `trunk serve`, change to: + trunk serve {{ if FEATURES != "" { "--features " + FEATURES } else { "" } }} +``` + +Example for an e2e recipe that hardcodes the feature: + +```just +test-e2e-ui: + @just setup-e2e FEATURES=test-hooks + npx playwright test e2e/ +``` + +- [ ] **Step 2: Verify the recipes still parse** + +```bash +just --list +``` + +Expected: all recipes listed; no parse errors. + +- [ ] **Step 3: Verify a feature build runs** + +```bash +just dev FEATURES=test-hooks --help 2>&1 | head -5 +``` + +(If `dev` runs a long-lived server, smoke-test by ctrl-C'ing after seeing the build succeed.) + +- [ ] **Step 4: Commit** + +```bash +git add justfile +git commit -m "build: add FEATURES variable to dev / e2e / check-all recipes" +``` + +### Task 6.2: Failing test (a script that should fail without the symbol-leak guard, then pass with it) + +**Files:** +- Create: `scripts/check-no-test-hooks-in-prod.sh` + +- [ ] **Step 1: Create the script** + +```bash +#!/usr/bin/env bash +# Asserts that a default `trunk build --release` does NOT include the +# WillowTestHooks symbol. Run as part of `just check-all` to catch +# accidental `default = ["test-hooks"]` regressions. +# +# Per docs/specs/2026-04-27-event-based-waits-design.md: the test-hooks +# feature must remain off in production for privacy reasons (third-party +# JS in prod could otherwise read DAG heads). + +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==> Building release (no features) ..." +(cd crates/web && trunk build --release --offline 2>&1) > /dev/null + +DIST=crates/web/dist +if grep -q "WillowTestHooks" "$DIST"/*.js 2>/dev/null; then + echo "FAIL: WillowTestHooks symbol leaked into prod JS shim:" + grep -l "WillowTestHooks" "$DIST"/*.js + exit 1 +fi + +# Defence-in-depth: check the wasm name section if wasm-objdump is +# available. If not present, skip (CI image may install on demand). +if command -v wasm-objdump >/dev/null 2>&1; then + if wasm-objdump --section=name "$DIST"/*.wasm 2>/dev/null | grep -q "WillowTestHooks"; then + echo "FAIL: WillowTestHooks symbol leaked into prod wasm name section" + exit 1 + fi +fi + +echo "==> Building with --features test-hooks (sanity check) ..." +(cd crates/web && trunk build --release --features test-hooks --offline 2>&1) > /dev/null + +if ! grep -q "WillowTestHooks" "$DIST"/*.js 2>/dev/null; then + echo "FAIL: WillowTestHooks symbol absent from feature build — gating broken?" + exit 1 +fi + +echo "PASS: test-hooks gating verified." +``` + +- [ ] **Step 2: Make it executable** + +```bash +chmod +x scripts/check-no-test-hooks-in-prod.sh +``` + +- [ ] **Step 3: Run it** + +```bash +./scripts/check-no-test-hooks-in-prod.sh +``` + +Expected: PASS (`==> Building release ...` then `==> Building with --features ...` then `PASS: test-hooks gating verified.`). + +If FAIL on the first build — `WillowTestHooks` is leaking. Investigate `crates/web/Cargo.toml` `default = []` line and the `#[cfg(feature = "test-hooks")]` gates; one of them is wrong. + +### Task 6.3: Wire the script into `just check-all` + +**Files:** +- Modify: `justfile` + +- [ ] **Step 1: Add the script invocation to `check-all`** + +Find the `check-all` recipe and append: + +```just +check-all: fmt-check clippy test check-wasm + # ...existing body... + ./scripts/check-no-test-hooks-in-prod.sh +``` + +- [ ] **Step 2: Run `just check-all`** + +```bash +just check-all +``` + +Expected: passes (slow — full builds — but green). + +- [ ] **Step 3: Commit** + +```bash +git add scripts/check-no-test-hooks-in-prod.sh justfile +git commit -m "ci: add test-hooks symbol-leak guard to check-all" +``` + +--- + +## Phase 7: ESLint rule + per-spec `eslint-disable` headers + +Per spec section "Lint window note": the ESLint rule lands in PR 1 (this PR), and every existing spec gets a per-file disable header referencing the tracking issue. PR 4 lands the count-based ratchet; PR 1 only enforces "no new offences" via the rule + headers. + +### Task 7.1: Verify the e2e directory has an ESLint config + +- [ ] **Step 1: Inspect for existing config** + +```bash +ls -la /home/user/willow/e2e/.eslintrc* /home/user/willow/.eslintrc* /home/user/willow/eslint.config.* 2>/dev/null +``` + +If a config already exists at the repo root or in `e2e/`, the rule will be added there (Step 2 of Task 7.2). If none exists, the new file is created in this task (continue to Step 2 of Task 7.1). + +- [ ] **Step 2: If no config exists, install ESLint and create a base config** + +```bash +cd /home/user/willow +npm install --save-dev --no-save eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin +``` + +(Use `--no-save` only if no `package.json` exists. If one exists, drop `--no-save`.) + +### Task 7.2: Add the `no-restricted-syntax` rule + +**Files:** +- Create or modify: `e2e/.eslintrc.cjs` (the most local config takes precedence) + +- [ ] **Step 1: Write `e2e/.eslintrc.cjs`** + +```js +// ESLint configuration for Playwright e2e tests. +// +// Bans `page.waitForTimeout(...)` (and any `*.waitForTimeout(...)` call) +// in favour of event-based waits. See: +// docs/specs/2026-04-27-event-based-waits-design.md +// +// Existing un-migrated specs carry per-file `eslint-disable` headers +// referencing the tracking issue. Those headers are removed file by +// file as each spec migrates to event-based waits. + +module.exports = { + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: "CallExpression[callee.property.name='waitForTimeout']", + message: + 'Use event-based waits (Peer.nextEvent / waitUntilHeadsEqual / data-state). See docs/specs/2026-04-27-event-based-waits-design.md.', + }, + ], + }, +}; +``` + +- [ ] **Step 2: Verify the rule fires on existing code** + +```bash +npx eslint e2e/multi-peer-sync.spec.ts +``` + +Expected: errors on every `waitForTimeout` line in that file. + +### Task 7.3: Add the per-spec disable headers + +The headers point at the tracking-issue URL captured in Task 0.2. Each header is a single line at the top of the file (after the `import` block if any, but per-line ESLint disable headers must be at file top to suppress on all subsequent lines). + +**Files:** +- Modify: `e2e/cross-browser-sync.spec.ts` +- Modify: `e2e/join-links.spec.ts` +- Modify: `e2e/mobile-actions.spec.ts` +- Modify: `e2e/mobile.spec.ts` +- Modify: `e2e/multi-peer-mobile.spec.ts` +- Modify: `e2e/multi-peer-sync.spec.ts` +- Modify: `e2e/permissions.spec.ts` +- Modify: `e2e/worker-nodes.spec.ts` +- Modify: `e2e/helpers.ts` + +- [ ] **Step 1: Add the disable header to each file** + +Substitute `` with the URL from Task 0.2. + +For each of the 9 files, add as the very first line: + +```ts +/* eslint-disable no-restricted-syntax -- migration tracked at */ +``` + +- [ ] **Step 2: Run ESLint over all e2e files** + +```bash +npx eslint 'e2e/**/*.ts' +``` + +Expected: zero errors. The disable headers suppress all current `waitForTimeout` offences; new calls in any other location would fail. + +- [ ] **Step 3: Verify the rule still bites new offences** + +Add a temporary `await page.waitForTimeout(100);` to a fresh file (e.g. a new throwaway `e2e/_lint-probe.spec.ts`) and re-run lint — must FAIL. Then delete the probe file. This step proves the disable headers do not silently disable the rule globally. + +```bash +# Create the probe. +cat > e2e/_lint-probe.spec.ts <<'EOF' +import { test } from '@playwright/test'; +test('probe', async ({ page }) => { + await page.waitForTimeout(100); +}); +EOF + +npx eslint e2e/_lint-probe.spec.ts && { + echo "FAIL: probe should have errored"; exit 1; +} || echo "PASS: rule fires on new offences" + +rm e2e/_lint-probe.spec.ts +``` + +- [ ] **Step 4: Commit** + +```bash +git add e2e/.eslintrc.cjs e2e/*.spec.ts e2e/helpers.ts +git commit -m "ci(e2e): forbid new waitForTimeout calls; allowlist existing" +``` + +--- + +## Phase 8: Final verification + push + +### Task 8.1: Run `just check` + +- [ ] **Step 1: Run** + +```bash +just check +``` + +Expected: zero warnings across fmt, clippy, test, and WASM check. + +If clippy fires on the new code, fix inline before committing. The bar is "zero warnings", per CLAUDE.md. + +### Task 8.2: Run `just test-browser` for both feature configurations + +- [ ] **Step 1: Default features** + +```bash +just test-browser +``` + +Expected: existing browser tests pass. + +- [ ] **Step 2: With `test-hooks`** + +```bash +wasm-pack test crates/web --headless --chrome --features test-hooks +``` + +Expected: existing tests + 6 new test-hooks tests pass (snapshot, heads, event_count/last_event, dispatcher emission, dispatcher abort, buffer drain on dispatch, buffer overflow signalling). + +### Task 8.3: Run `just check-all` + +- [ ] **Step 1: Run** + +```bash +just check-all +``` + +Expected: includes the symbol-leak guard from Phase 6; passes end-to-end. + +### Task 8.4: Push the branch + +- [ ] **Step 1: Verify branch** + +```bash +git branch --show-current +``` + +Expected: `claude/event-based-waits-RNFZ9`. + +- [ ] **Step 2: Push** + +```bash +git push -u origin claude/event-based-waits-RNFZ9 +``` + +Expected: push succeeds. + +- [ ] **Step 3: Open the PR** + +PR title: `feat: test-hooks foundation for event-based Playwright waits` + +PR body should include: +- Link to spec: `docs/specs/2026-04-27-event-based-waits-design.md` +- Link to tracking issue (from Task 0.2) +- iroh `performance.now` audit result (from Task 0.1) +- Note that this is PR 1 of 4: `Peer` wrapper + helpers split + first pilot land in PR 2. + +--- + +## Plan self-review + +This section is run **once**, by the implementer (or you if you stayed inline). Compare against `docs/specs/2026-04-27-event-based-waits-design.md`. + +- [ ] **Spec coverage check.** Walk the "In scope" bullets in the spec. Each must map to at least one task above. Specifically: + - Cargo feature `test-hooks` off in production → Task 1.1 + Task 6.2 (symbol-leak guard). + - WASM-exported `WillowTestHooks` API (snapshot, heads, event count, last event) → Phase 2 tasks. + - Push instrumentation via `exposeBinding` → Phase 4 (the WASM dispatcher; the Playwright-side fixture lands in PR 2). + - TypeScript wrapper `Peer` → **NOT in PR 1** (PR 2 scope, called out in Phase 8 PR body). + - `data-state` attribute pattern → **NOT in PR 1** (PR 3 scope). + - `page.clock` adoption → **NOT in PR 1** (PR 2 scope). + - Helpers split → **NOT in PR 1** (PR 2 scope). + - Pilot conversions → **NOT in PR 1** (PR 2 scope). + - ESLint rule blocking new `page.waitForTimeout` calls + per-file allowlist → Phase 7. + - CI symbol-leak check + flake harness → Symbol-leak in Phase 6; flake harness in PR 4. + - GitHub tracking issue → Task 0.2. + +- [ ] **Placeholder scan.** Search the plan above for "TBD", "TODO", "fill in", "similar to", "appropriate". No matches expected. Code blocks must be complete (no `// ...` ellipses representing un-shown code). + +- [ ] **Type / signature consistency.** `WillowTestHooks::new` signature is referenced from Tasks 2.2, 2.3, 4.1, 5.1 — all use ``. `install_push_dispatcher` returns `DispatcherHandle` (Tasks 4.2, 5.1). `to_wire` returns `Option` (Tasks 3.1, 3.2, 3.3). `WireEvent` is `#[serde(tag = "kind")]` (Task 3.1, 3.3). + +- [ ] **Acceptance verification.** Phase 8's `just check`, `just test-browser`, `wasm-pack test --features test-hooks`, `just check-all` cover compile + test for both feature configurations + symbol-leak gating. + +If any of the above fails, fix inline and re-run the corresponding tasks. + +--- + +## Out-of-scope (subsequent PRs) + +- **PR 2 — Playwright `Peer` wrapper + helpers split + first pilot.** Plan: `docs/plans/2026-04-27-event-based-waits-pr2-playwright-wrapper.md` (to be written when PR 1 lands). +- **PR 3 — `data-state` lifecycle on five animated components.** Plan: `docs/plans/2026-04-27-event-based-waits-pr3-data-state-lifecycle.md`. +- **PR 4 — Ratchet script + flake harness + cleanup.** Plan: `docs/plans/2026-04-27-event-based-waits-pr4-ratchet-and-cleanup.md`. + +The 7 remaining spec migrations are tracked in the GitHub issue from Task 0.2. Each gets its own small PR; ESLint disable headers are removed in those PRs. diff --git a/docs/plans/2026-04-28-event-based-waits-pr1-errata.md b/docs/plans/2026-04-28-event-based-waits-pr1-errata.md new file mode 100644 index 00000000..e77ab589 --- /dev/null +++ b/docs/plans/2026-04-28-event-based-waits-pr1-errata.md @@ -0,0 +1,344 @@ +# PR-1 Plan Errata — 2026-04-28 + +> **Supersedes specific sections of `2026-04-27-event-based-waits-pr1-test-hooks-foundation.md`.** Read this file alongside the original plan. Where they conflict, this file wins. + +## Why this exists + +During PR-1 execution an investigation pass surfaced concrete API mismatches between the original plan and the real Willow codebase. The plan was written against speculative API signatures that don't exist. This errata records the corrections so subsequent implementer agents have one accurate source. + +## Investigation findings (verified against the codebase) + +1. **No synchronous DAG read path on `ClientHandle`.** Every read goes through async actor-ask via `willow_actor::state::select(&addr, |state| ...).await`. There is no `Arc` or equivalent. (`crates/client/src/lib.rs:216-285`, `crates/client/src/accessors.rs` — all 23 accessors are async.) +2. **`MemNetwork` is native-only.** `crates/network/src/mem.rs:35` does `use tokio::sync::broadcast;` unconditionally, and `crates/network/Cargo.toml:28-30` gates `tokio` to `cfg(not(target_arch = "wasm32"))`. Confirmed: `cargo check --target wasm32-unknown-unknown -p willow-network --features test-utils` fails. +3. **No `EventHash::to_hex()` / `EndpointId::to_hex()`.** Both types implement `Display` producing 64-char lowercase hex. Use `.to_string()` or `format!("{}", x)`. +4. **No `ChannelView` / `channels_view()`.** Real types: `ChannelsView { channels: Vec }` (`crates/client/src/views.rs:117-119`) and `ChannelInfo { name: String, kind: ChannelKind }` (`:122-126`). No `member_count` field exists; the plan's `ChannelDto.member_count` must be replaced with `kind` (or dropped). +5. **wasm-streams duplicate-version is not a compile blocker.** `Cargo.lock` shows `wasm-streams 0.4.2` (via `leptos→server_fn`) and `0.5.0` (via `iroh→reqwest`) coexisting. `cargo check --tests --target wasm32-unknown-unknown -p willow-web` succeeds. Whether `wasm-pack test` link-step trips a duplicate-symbol error remains unverified in this CI/sandbox env (no `wasm-pack` installed). Not blocking PR-1; out-of-scope to redesign around. +6. **`subscribe_events` signature verified correct.** `crates/client/src/accessors.rs:10` returns `EventReceiver` (defined in `lib.rs:112-166`). `try_recv()` exists at `:144` for non-blocking polling. +7. **`test-utils` feature transitively enables `MemNetwork`.** Reusing it for our purposes breaks WASM. We need a NEW `test-hooks` feature, distinct from `test-utils`. + +## Section-by-section corrections + +### Phase 1 — Cargo feature scaffold + +**Task 1.1 corrections.** Add the feature on **two** crates (`willow-client` AND `willow-web`): + +`crates/client/Cargo.toml` — append: +```toml +[features] +test-hooks = [] +``` +(Distinct from existing `test-utils`. `test-hooks` is narrow read-only instrumentation; `test-utils` pulls `MemNetwork`.) + +`crates/web/Cargo.toml` — append: +```toml +[features] +default = [] +test-hooks = ["dep:serde-wasm-bindgen", "willow-client/test-hooks"] +``` + +And in `[dependencies]` (place alphabetically near `serde_json`): +```toml +serde-wasm-bindgen = { version = "0.6", optional = true } +``` + +Verification commands stay the same: +```bash +cargo check -p willow-client +cargo check -p willow-client --features test-hooks +cargo check -p willow-web +cargo check -p willow-web --features test-hooks +``` + +All four must succeed, zero warnings. Commit message: `feat: add test-hooks feature on client + web` (singular commit covering both crates). + +**Task 1.2 corrections.** `lib.rs` declaration must remain alphabetically sorted (rustfmt enforces). The cfg-gated `pub mod test_hooks;` belongs **before** `pub mod trust_store;` (since `test_hooks < trust_store` lexicographically). Otherwise unchanged. + +### Phase 2 — Pull API + +**Task 2.1 corrections.** `ChannelDto` field rename: drop `member_count` (no source field exists), use `kind` instead. + +```rust +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelDto { + pub name: String, + pub kind: String, // ChannelKind serialised as string +} +``` + +`AuthorHeadDto` and `SnapshotDto` are unchanged from the plan. + +**Task 2.2 corrections — failing browser test.** The plan's `fresh_client()` helper that builds a `ClientHandle` does not work (MemNetwork won't compile on wasm32). Replace with a fixture that constructs the actor system directly: + +```rust +//! In-browser tests for `WillowTestHooks`. +//! +//! Run with: +//! wasm-pack test crates/web --headless --chrome --features test-hooks +//! +//! Bypasses `ClientHandle` entirely — `MemNetwork` won't compile on +//! wasm32 (it depends on `tokio::sync::broadcast`). Constructs +//! `Addr>` and `Addr>` +//! directly, then feeds them to `WillowTestHooks::from_actors`. + +#![cfg(feature = "test-hooks")] + +use wasm_bindgen_test::*; +use willow_actor::{StateActor, System}; +use willow_client::state_actors::DagState; +use willow_state::ServerState; +use willow_web::test_hooks::WillowTestHooks; + +wasm_bindgen_test_configure!(run_in_browser); + +/// Construct a WillowTestHooks instance backed by empty actor state. +async fn empty_hooks() -> WillowTestHooks { + let sys = System::new(); + let dag_addr = sys.spawn(StateActor::new(DagState::default())); + let state_addr = sys.spawn(StateActor::new(ServerState::default())); + WillowTestHooks::from_actors(dag_addr, state_addr) +} + +#[wasm_bindgen_test] +async fn empty_hooks_event_count_is_zero() { + let hooks = empty_hooks().await; + let p = hooks.event_count(); + let count = wasm_bindgen_futures::JsFuture::from(p).await.unwrap(); + assert_eq!(count.as_f64(), Some(0.0)); +} + +#[wasm_bindgen_test] +async fn empty_hooks_last_event_is_null() { + let hooks = empty_hooks().await; + let p = hooks.last_event(); + let last = wasm_bindgen_futures::JsFuture::from(p).await.unwrap(); + assert!(last.is_null(), "last_event on empty DAG must be null, got {last:?}"); +} +``` + +The implementer can drive a non-empty DAG fixture later (e.g., by `state::mutate(&dag_addr, |ds| ds.managed.append_local(...).unwrap())` once a signing identity is wired). For PR-1 the empty-DAG assertions are sufficient — they verify the plumbing. + +**Task 2.3 corrections — `WillowTestHooks` impl.** Replace the plan's closure-erasure pattern with direct actor-address storage. `event_count`, `last_event`, `heads`, `snapshot` all return `js_sys::Promise` and use `wasm_bindgen_futures::future_to_promise` + `willow_actor::state::select`: + +```rust +#![cfg(feature = "test-hooks")] + +mod snapshot; +pub use snapshot::{AuthorHeadDto, ChannelDto, SnapshotDto}; + +use std::collections::BTreeMap; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::future_to_promise; +use willow_actor::{Addr, StateActor}; +use willow_client::state_actors::DagState; +use willow_client::ClientHandle; +use willow_network::Network; +use willow_state::ServerState; + +#[wasm_bindgen] +pub struct WillowTestHooks { + dag_addr: Addr>, + state_addr: Addr>, +} + +impl WillowTestHooks { + /// Construct from a ClientHandle (production path: `app.rs` mount). + pub fn new(handle: &ClientHandle) -> Self { + Self { + dag_addr: handle.dag_addr_clone(), + state_addr: handle.event_state_addr_clone(), + } + } + + /// Construct from raw actor addresses (test path: bypasses ClientHandle + /// so wasm32 tests don't need MemNetwork). + pub fn from_actors( + dag_addr: Addr>, + state_addr: Addr>, + ) -> Self { + Self { dag_addr, state_addr } + } +} + +#[wasm_bindgen] +impl WillowTestHooks { + pub fn event_count(&self) -> js_sys::Promise { + let addr = self.dag_addr.clone(); + future_to_promise(async move { + let n = willow_actor::state::select(&addr, |ds| ds.managed.dag().len() as u32).await; + Ok(JsValue::from_f64(n as f64)) + }) + } + + pub fn last_event(&self) -> js_sys::Promise { + let addr = self.dag_addr.clone(); + future_to_promise(async move { + let hex = willow_actor::state::select(&addr, |ds| { + ds.managed + .dag() + .topological_sort() + .last() + .map(|e| e.hash.to_string()) + }) + .await; + Ok(match hex { + Some(s) => JsValue::from_str(&s), + None => JsValue::NULL, + }) + }) + } + + pub fn heads(&self) -> js_sys::Promise { + let addr = self.dag_addr.clone(); + future_to_promise(async move { + let map: BTreeMap = + willow_actor::state::select(&addr, snapshot::build_heads).await; + serde_wasm_bindgen::to_value(&map).map_err(Into::into) + }) + } + + pub fn snapshot(&self) -> js_sys::Promise { + let dag_addr = self.dag_addr.clone(); + let state_addr = self.state_addr.clone(); + future_to_promise(async move { + let snap = snapshot::build(&dag_addr, &state_addr).await; + serde_wasm_bindgen::to_value(&snap).map_err(Into::into) + }) + } +} +``` + +The plan's closure-pattern (`Box`) is replaced because storing actor addresses is simpler, and the reads must be async anyway. + +**Task 2.3 corrections — `crates/client/src/accessors.rs` additions.** The plan adds `dag_event_count` etc. as bespoke accessors. **DON'T.** Instead expose a single sync getter for the DAG actor address, gated on `test-hooks`: + +```rust +// At the end of crates/client/src/accessors.rs: + +#[cfg(feature = "test-hooks")] +impl ClientHandle { + /// Clone the DAG actor address. Test-only (read-only) access surface + /// for `WillowTestHooks`; the address itself doesn't grant write access + /// without an active mutator. + pub fn dag_addr_clone(&self) -> willow_actor::Addr> { + self.dag_addr.clone() + } + + /// Clone the materialised ServerState actor address. Test-only. + pub fn event_state_addr_clone(&self) -> willow_actor::Addr> { + self.event_state_addr.clone() + } +} +``` + +Both methods are gated, so non-test consumers (`willow-agent`, `willow-replay`, etc.) never see them. + +**Task 2.3 corrections — `snapshot.rs` build helpers.** + +```rust +use std::collections::BTreeMap; +use willow_actor::{Addr, StateActor}; +use willow_client::state_actors::DagState; +use willow_state::ServerState; + +pub(crate) fn build_heads(ds: &DagState) -> BTreeMap { + ds.managed + .heads_summary() + .heads + .iter() + .map(|(endpoint, head)| { + ( + endpoint.to_string(), + AuthorHeadDto { + seq: head.seq, + hash: head.hash.to_string(), + }, + ) + }) + .collect() +} + +pub(crate) async fn build( + dag_addr: &Addr>, + state_addr: &Addr>, +) -> SnapshotDto { + // Two actor-asks (cheap, sub-ms each on local mailbox dispatch). + let (event_count, heads, last_event) = + willow_actor::state::select(dag_addr, |ds| { + ( + ds.managed.dag().len() as u32, + build_heads(ds), + ds.managed + .dag() + .topological_sort() + .last() + .map(|e| e.hash.to_string()), + ) + }) + .await; + let channels = willow_actor::state::select(state_addr, |ss| { + // ServerState's channels live on `ss.channels` (HashMap) + // — the implementer must verify the exact accessor; falling back to + // computing via crates/client/src/views.rs::compute_channels_view if needed. + ss.channels + .iter() + .map(|(name, info)| ChannelDto { + name: name.clone(), + kind: format!("{:?}", info.kind), + }) + .collect::>() + }) + .await; + SnapshotDto { + event_count, + heads, + last_event, + channels, + } +} +``` + +The implementer should verify the exact `ServerState.channels` field access — if it's not a direct `HashMap`, reuse `compute_channels_view` from `crates/client/src/views.rs:656-674`. The shape `Vec` is what matters. + +**Task 2.4-2.7 corrections.** The browser tests for `heads()` and `snapshot()` use the same `from_actors` pattern. Since methods return `js_sys::Promise`, tests must `JsFuture::from(p).await`. Drop any `serde_wasm_bindgen::from_value::` that assumes a sync return. + +### Phase 3 — Wire-shape conversion + +No corrections needed. `ClientEvent` enum reference (`crates/client/src/events.rs:19`) and `EndpointId.to_hex()` are the same shape — except: replace `id.to_hex()` with `id.to_string()` in the `to_wire` body. + +### Phase 4 — Push dispatcher + +No structural corrections. `subscribe_events()` is async; the dispatcher already runs inside `wasm_bindgen_futures::spawn_local` so `let mut rx = handle.subscribe_events().await;` works. The plan's code is correct. + +`install_push_dispatcher` signature stays generic over `N: Network` and returns `DispatcherHandle`. + +### Phase 5 — Mount in `app.rs` + +Mount block stays mostly the same. Two small changes: + +1. `WillowTestHooks::new(&handle)` takes `&ClientHandle` (borrow) not by value. Update accordingly: + ```rust + let hooks = crate::test_hooks::WillowTestHooks::new(&inner_for_hooks); + ``` +2. `install_push_dispatcher(handle.clone())` still takes ownership of a clone (the dispatcher needs a long-lived clone for the spawn_local task). + +### Phases 6, 7, 8 + +No corrections. + +## What stays unchanged + +- Buffer drain on three edges (init / per-dispatch / read-side) — Phase 4 logic is correct. +- Symbol-leak guard in Phase 6 (`grep WillowTestHooks dist/*.js`). +- ESLint `no-restricted-syntax` rule and per-file disable headers. +- TDD red-green flow. +- Commit boundary structure (one commit per Task or TDD pair). +- The whole of Phase 8 verification. + +## Browser-test environment caveat + +`wasm-pack` and `just` are not available in the Claude Code sandbox. The implementer should still attempt `cargo check --tests --target wasm32-unknown-unknown -p willow-web --features test-hooks` to verify compile. Actual `wasm-pack test` runs must happen on the developer's machine or in CI; this PR's acceptance gate notes this explicitly so reviewers know to run tests locally before approving. + +## Tracking + +The tracking issue created in plan Task 0.2 is **#458** — `https://github.com/intendednull/willow/issues/458` — already exists. Don't recreate. diff --git a/docs/specs/2026-04-27-event-based-waits-design.md b/docs/specs/2026-04-27-event-based-waits-design.md new file mode 100644 index 00000000..41bd6d1d --- /dev/null +++ b/docs/specs/2026-04-27-event-based-waits-design.md @@ -0,0 +1,614 @@ +# Event-Based Waits in Playwright Suite — Design + +**Date:** 2026-04-27 +**Status:** draft +**Branch:** `claude/event-based-waits-RNFZ9` + +> **2026-04-28 erratum.** Investigation during PR-1 execution found that +> several API assumptions below are wrong. `ClientHandle` has no +> synchronous DAG read path (everything is actor-mediated), `MemNetwork` +> won't compile on `wasm32`, and the `test-hooks` feature must span +> `willow-client` + `willow-web`. The `WillowTestHooks` pull API methods +> therefore return `js_sys::Promise`, not synchronous values. The +> sections below have been updated; the old shape is preserved in +> `docs/plans/2026-04-28-event-based-waits-pr1-errata.md`. + +## Problem + +The Playwright suite leans on time-based waits as flake compensation. Audit of `e2e/` (8 spec files, 1814 LOC): + +- **53** `waitForTimeout(ms)` calls in helpers and specs (200ms–2000ms each). +- **71** `{ timeout: }` overrides on `expect`/`locator` assertions, including 23 occurrences of `30_000ms`, 8 of `60_000ms`, and 8 of `120_000ms`. +- **3** polling loops that sleep 300ms between iterations, gating on UI visibility rather than driving on a real signal. +- **0** uses of `waitForFunction`, `expect.poll`, `waitForResponse`, or any app-emitted event. + +Two consequences: arbitrary sleeps mask race conditions instead of fixing them (per Playwright's own guidance, replacing `waitForTimeout` removes ~45% of flake), and the suite's wall-clock is dominated by sleeps that succeed long before they expire. + +The underlying cause is that the web crate exposes nothing for tests to synchronise on — no `#[wasm_bindgen] pub` exports, no `data-testid` attributes, no readiness events. So tests guess at delays. The Willow client *does* already publish a `ClientEvent::SyncCompleted { ops_applied }` after every applied `SyncBatch` (`crates/client/src/listeners.rs:290`); it is not currently visible to JS. + +## Goal + +Every wait in the Playwright suite gates on a real signal: a DOM state, an applied `ClientEvent`, or a deterministic fake-clock advance. No wait is a guess. + +## Scope + +**In scope:** +- New cargo feature `test-hooks` on `willow-web`, off in production builds. +- WASM-exported `WillowTestHooks` API (snapshot, heads, event count, last event). +- Push-side instrumentation: WASM dispatches every `ClientEvent` to a Playwright `exposeBinding('__willowEvent', …)` callback. +- TypeScript wrapper `Peer` in `e2e/test-hooks.ts` providing `nextEvent(predicate)`, `snapshot()`, `heads()`, `eventCount()`, `waitUntilHeadsEqual(other)`. +- `data-state=""` attribute pattern on five animated UI elements (mobile drawer, grove drawer, confirm dialog, bottom sheet, tab bar) plus the action-sheet overlay in `message.rs`, tied to CSS `transitionend` with reduced-motion fallback. +- `page.clock` adoption for the few legitimate real-duration waits (longPress, debounce). +- Helpers split: `e2e/helpers/{peers,ui,touch}.ts` replacing the current 702-line monolith. +- Pilot conversions: `helpers.ts` (full) and `multi-peer-sync.spec.ts`. +- ESLint rule blocking new `page.waitForTimeout` calls plus per-file allowlist for un-migrated specs. +- CI symbol-leak check (`! grep WillowTestHooks dist/*.wasm` for prod build) and flake harness (`just test-e2e-flake N=10`). +- GitHub tracking issue listing remaining specs for follow-up migration. + +**Out of scope:** +- Migrating `permissions.spec.ts`, `mobile.spec.ts`, `mobile-actions.spec.ts`, `multi-peer-mobile.spec.ts`, `cross-browser-sync.spec.ts`, `join-links.spec.ts`, `worker-nodes.spec.ts` (tracked, migrated incrementally). +- Browser tests under `crates/web/tests/browser.rs` (already deterministic via Leptos signals). +- Rust `state` / `client` tests (already synchronous). +- Adding new test coverage for behaviours not already exercised. +- Replacing the relay/worker docker-compose harness. + +## Three categories of wait, three tools + +Time-based waits in the suite today fall into three buckets. The spec assigns one canonical tool per bucket. No silver bullet replaces all three. + +| Bucket | What the suite waits for today | Tool | Rationale | +|---|---|---|---| +| **State convergence** | Peer B applied event H emitted by peer A; gossip settled; channel membership updated after a remote mutation | Push (`__willowEvent`) for ordered events; pull (`expect.poll(snapshot)`) for "eventually X" | Push has zero polling latency and matches multi-peer assertions. Pull runs on the test runner, supports typed matchers, has built-in failure messages, and can call across peers. | +| **DOM / animation settle** | Drawer slide, dropdown fade, modal open, tab transition | `data-state=""` attribute on the element, flipped on `transitionend`. Tests assert via `expect(el).toHaveAttribute('data-state', 'open')` | Driven by the CSS transition itself, not a guess. Auto-retried by Playwright's web-first assertions. | +| **Real durations** | `longPress` 600ms, debounce timers, HLC drift simulation | `page.clock.runFor('600ms')` or `clock.fastForward('05:00')` | Native Playwright since 1.45. Patches `Date`, `setTimeout`, `setInterval`, `requestAnimationFrame`. Covers `js_sys::Date::now()` calls inside WASM. | + +Anti-patterns explicitly forbidden in the migrated suite: +- `page.waitForTimeout(ms)` — banned by ESLint rule (see CI section). +- `waitForLoadState('networkidle')` — flagged by Playwright docs as unsafe for gossip apps; not used today and must not be introduced. +- `expect(await locator.isVisible()).toBe(true)` — defeats the auto-retry. Always `await expect(locator).toBeVisible()`. +- Setting up `waitForResponse` *after* the action that triggers the response — race; promise must precede the trigger. + +## `test-hooks` cargo feature + +The feature spans two crates so the test-only `dag_addr_clone()` accessor can be gated cleanly on `willow-client` (which is depended on by other consumers like `willow-agent` that must NOT see test-hooks symbols): + +```toml +# crates/client/Cargo.toml +[features] +test-hooks = [] # NEW — distinct from existing test-utils +``` + +```toml +# crates/web/Cargo.toml +[features] +default = [] +test-hooks = ["dep:serde-wasm-bindgen", "willow-client/test-hooks"] +``` + +`serde-wasm-bindgen` is gated as an optional dep so the prod build pays no cost. The `test-hooks` feature is intentionally distinct from the existing workspace `test-utils` feature: `test-utils` transitively pulls `MemNetwork`, which uses `tokio::sync::broadcast` and **will not compile on wasm32** (verified `crates/network/src/mem.rs:35`). `test-hooks` is narrow read-only instrumentation that builds clean on both targets. + +All new instrumentation code lives behind `#[cfg(feature = "test-hooks")]`. Production `trunk build --release` is unchanged: no exported symbols, no event subscription, no `window.__willow`. + +The e2e build switches to `trunk build --features test-hooks`. Just recipes affected: `setup-e2e`, `test-e2e-ui`, `test-e2e-sync`, `test-e2e-perms`, `test-e2e-full`, `test-e2e-flake` (new), `check-all`. + +**`just dev`** stays on the default feature set (no test-hooks). Developers who want to poke at `window.__willow` from devtools run `just dev FEATURES=test-hooks`. This requires modifying the `dev`, `setup-e2e`, `test-e2e-*`, and `check-all` recipes to accept an optional `FEATURES` variable that is forwarded to `trunk build`. The justfile changes are scoped explicitly into PR 1 alongside the cargo feature flag — the spec assumes no pre-existing `FEATURES` parameterisation. Default value is empty (prod build); e2e recipes hardcode `FEATURES=test-hooks` internally. + +## WASM API surface + +New module `crates/web/src/test_hooks/`, gated: + +```rust +#![cfg(feature = "test-hooks")] + +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::future_to_promise; +use willow_actor::Addr; +use willow_actor::StateActor; +use willow_client::state_actors::DagState; +use willow_client::ClientHandle; +use willow_state::ServerState; + +/// Read-only test instrumentation handle. Stores the DAG and ServerState +/// actor addresses (cheaply cloneable). All methods are async because +/// the underlying actor read path is async (`willow_actor::state::select`). +#[wasm_bindgen] +pub struct WillowTestHooks { + dag_addr: Addr>, + state_addr: Addr>, +} + +impl WillowTestHooks { + /// Construct from any `ClientHandle`. Captures the actor addresses + /// so the wasm_bindgen-exposed methods stay monomorphic. + pub fn new(handle: &ClientHandle) -> Self { + Self { + dag_addr: handle.dag_addr_clone(), // gated test-hooks + state_addr: handle.event_state_addr_clone(),// gated test-hooks + } + } +} + +#[wasm_bindgen] +impl WillowTestHooks { + /// Aggregated state snapshot for `expect.poll` matchers. + /// Resolves to: { eventCount, heads: { authorIdHex: { seq, hash }, ... }, + /// lastEvent: string | null, channels: [{ name, kind }] } + pub fn snapshot(&self) -> js_sys::Promise; + + /// Per-author DAG heads. Resolves to Record. + pub fn heads(&self) -> js_sys::Promise; + + /// Total events applied to local DAG. Resolves to a number. + pub fn event_count(&self) -> js_sys::Promise; + + /// Hex hash of the most recently applied event (Display-formatted, 64 + /// chars), or null. Resolves to string | null. + pub fn last_event(&self) -> js_sys::Promise; +} +``` + +All read methods return `js_sys::Promise`. The actor-ask round-trip in WASM is sub-millisecond mailbox dispatch — fine for `expect.poll` tick rates. JS callers `await` the promise: + +```ts +const count = await window.__willow.event_count(); // number +const snap = await window.__willow.snapshot(); // Snapshot +``` + +The TypeScript `Peer` wrapper hides the await from test authors. + +`ClientHandle` exposes two new sync getters under `#[cfg(feature = "test-hooks")]`: + +```rust +// crates/client/src/accessors.rs +#[cfg(feature = "test-hooks")] +impl ClientHandle { + pub fn dag_addr_clone(&self) -> Addr> { self.dag_addr.clone() } + pub fn event_state_addr_clone(&self) -> Addr> { self.event_state_addr.clone() } +} +``` + +These are the only client-side surface additions. Both are sync (just clone an `Addr`) and behind the same feature gate. They are NOT visible to non-test consumers of `willow-client`. + +Push side, in the same module: + +```rust +/// Subscribes to `client.subscribe_events()` and dispatches every +/// wire-visible `ClientEvent` to `window.__willowEvent` (a Playwright +/// binding). If the binding is not yet wired, events accumulate in a +/// page-local buffer (`window.__willowEventBuffer`) and are drained by +/// the binding on first call. +/// +/// Capacity: 65_536 (per-page; never shared across peers). On overflow +/// the dispatcher calls `window.__willowOverflow(droppedCount)` if +/// defined and emits an error to the console. Test fixtures install +/// `__willowOverflow` and fail the test on any call — overflow is +/// always a correctness bug, not backpressure. +/// +/// Lifecycle: returns a `DispatcherHandle` that aborts the spawned +/// loop on drop. `app.rs` stores it in a Leptos `StoredValue` keyed +/// to the same lifetime as the `ClientHandle`. Re-init replaces the +/// handle (drop aborts the previous loop). No leak on hot reload. +pub fn install_push_dispatcher(client: ClientHandle) -> DispatcherHandle; +``` + +Mounted from `app.rs` immediately **after** the `with_trust_store` clone (so the same handle the UI uses is captured), behind the same `cfg`: + +```rust +#[cfg(feature = "test-hooks")] +{ + let hooks = test_hooks::WillowTestHooks::new(client_handle.clone()); + js_sys::Reflect::set(&window, &"__willow".into(), &hooks.into()).unwrap(); + let dispatcher = test_hooks::install_push_dispatcher(client_handle.clone()); + // Bind the handle so it lives for the component's scope. `StoredValue` + // does not drop until its owning Leptos scope is disposed; binding + // its return value (rather than discarding it) ensures the dispatcher + // loop runs for the app's lifetime. Without the binding, the value + // would be dropped at end of expression and the loop would abort. + let _dispatcher_handle = leptos::StoredValue::new(dispatcher); +} +``` + +The pull API does **not** serialize `HeadsSummary` directly. Instead, `test_hooks` defines a web-only DTO `SnapshotDto` that reads from the materialised `ServerState` (held by the client) and the DAG `HeadsSummary` (`crates/state/src/sync.rs:22`, with `AuthorHead { seq, hash }` per author at `:28`). The DTO is `#[serde(rename_all = "camelCase")]` so the JS-side shape matches the TypeScript `Snapshot` interface without modifying the state crate. + +The push dispatcher reuses `ClientHandle::subscribe_events()` (`crates/client/src/accessors.rs:10`), which returns an `EventReceiver` (`crates/client/src/lib.rs:120`) — a custom actor-broker forwarder, not a `futures::Stream`. The dispatcher spawns a `wasm_bindgen_futures::spawn_local` task that loops on `rx.recv().await`, converts each `ClientEvent` to the stable JSON wire shape (see below), and dispatches via `window.__willowEvent`. No new emit points are added inside `willow-client` or `willow-state`. + +### Stable JSON wire shape for `ClientEvent` + +The Rust `ClientEvent` enum (`crates/client/src/events.rs:19`) has 30+ variants mixing tuple-style (`PeerConnected(EndpointId)`, `ChannelCreated(String)`) and struct-style (`SyncCompleted { ops_applied }`). Default serde would produce inconsistent JSON shapes per variant. + +`test_hooks` defines a stable wire shape `{ kind: , ...flattened fields in camelCase }` and hand-writes the conversion. The `kind` discriminator stays PascalCase (matches Rust variant names); all other field names are camelCase (matches TypeScript convention). Tests see only the variants the suite cares about today; new variants are added explicitly. Initial wire-visible set: + +- `{ kind: "SyncCompleted", opsApplied: number }` +- `{ kind: "MessageReceived", channel: string, messageId: string, isLocal: boolean }` +- `{ kind: "PeerConnected", peerId: string }` +- `{ kind: "PeerDisconnected", peerId: string }` +- `{ kind: "ChannelCreated", name: string }` +- `{ kind: "ChannelDeleted", name: string }` +- `{ kind: "PeerTrusted", peerId: string }` +- `{ kind: "PeerUntrusted", peerId: string }` +- `{ kind: "ProfileUpdated", peerId: string, displayName: string }` +- `{ kind: "RoleCreated", roleId: string, name: string }` + +Internal-only variants (`QueueChanged`, `VoiceSignal`, etc.) are **not** dispatched to the test side — the dispatcher filters them out. This keeps the test surface narrow and stable across internal client changes. + +## Playwright wrapper — `Peer` + +New file `e2e/test-hooks.ts`: + +```ts +// Mirror of the wire-visible subset of willow-client's ClientEvent. +// Hand-maintained against test_hooks' conversion table; codegen is a +// follow-up if drift becomes painful. +export type ClientEvent = + | { kind: 'SyncCompleted'; opsApplied: number } + | { kind: 'MessageReceived'; channel: string; messageId: string; isLocal: boolean } + | { kind: 'PeerConnected'; peerId: string } + | { kind: 'PeerDisconnected'; peerId: string } + | { kind: 'ChannelCreated'; name: string } + | { kind: 'ChannelDeleted'; name: string } + | { kind: 'PeerTrusted'; peerId: string } + | { kind: 'PeerUntrusted'; peerId: string } + | { kind: 'ProfileUpdated'; peerId: string; displayName: string } + | { kind: 'RoleCreated'; roleId: string; name: string }; + +export interface AuthorHead { + seq: number; + hash: string; +} + +export interface Snapshot { + eventCount: number; + /// Per-author heads. Keys are EndpointId hex strings. + heads: Record; + lastEvent: string | null; + /// Channels in the materialised ServerState. + channels: Array<{ name: string; kind: string }>; +} + +export class Peer { + constructor(public readonly page: Page, public readonly label: string); + + /** Drain the next event matching `predicate` from the per-page push queue. */ + async nextEvent( + predicate: (e: ClientEvent) => boolean, + opts?: { timeout?: number }, + ): Promise; + + async snapshot(): Promise; + async heads(): Promise>; + async eventCount(): Promise; + + /** Wait until this peer's heads equal `other`'s heads. Uses expect.poll. */ + async waitUntilHeadsEqual( + other: Peer, + opts?: { timeout?: number }, + ): Promise; + + /** Wait until this peer's heads equal each peer in `others`. */ + async waitUntilAllHeadsEqual( + others: Peer[], + opts?: { timeout?: number }, + ): Promise; +} +``` + +Per-page push queue is set up in a Playwright fixture. The order is critical: + +1. `context.exposeBinding('__willowEvent', cb)` registers the binding. +2. `context.exposeBinding('__willowOverflow', cb)` registers an overflow hook that fails the test on call. +3. `page.addInitScript(...)` installs the JS-side buffer that the WASM dispatcher writes into when the binding is not yet present (a defensive guard for the narrow window before the page's `__willowEvent` proxy is bound). +4. `page.goto(...)` — only after all three. + +```ts +test.beforeEach(async ({ page, context }) => { + const queue: ClientEvent[] = []; + await context.exposeBinding('__willowEvent', (_src, ev: ClientEvent) => { + // Drain on read side too: pulls anything the WASM dispatcher pushed + // into the buffer while the binding was momentarily absent (hot + // reload, dispatcher restart, etc.). Drain on read covers the case + // where no further event arrives to trigger a write-side drain. + queue.push(ev); + }); + await context.exposeBinding('__willowOverflow', (_src, dropped: number) => { + throw new Error(`__willow event queue overflow: ${dropped} events dropped`); + }); + await page.addInitScript(() => { + // Buffer for events the WASM dispatcher emits before `__willowEvent` + // is callable. Defence-in-depth: under normal Playwright ordering + // (exposeBinding before goto) the buffer is empty; under restart / + // hot-reload it covers the gap. + (window as any).__willowEventBuffer = []; + }); + // Peer construction stores `queue` for nextEvent to drain. +}); +``` + +The WASM dispatcher (`test_hooks::install_push_dispatcher`) implements the drain on **two** edges: + +```rust +// On dispatcher init: drain anything left from a prior dispatcher. +if let Some(fn_) = window.get("__willowEvent") { + if let Some(buf) = window.get("__willowEventBuffer") { + for buffered in drain_array(buf) { fn_.call(buffered); } + } +} + +// Per-event: +let event_js = serialize_event(&event); +match window.get("__willowEvent") { + Some(fn_) => { + // Drain buffer first (covers the case where binding became + // available between events). + if let Some(buf) = window.get("__willowEventBuffer") { + for buffered in drain_array(buf) { fn_.call(buffered); } + } + fn_.call(event_js); + } + None => push_to_buffer(event_js), +} +``` + +The combination — **drain on dispatcher init, drain on every dispatch, plus the Playwright fixture's read-side drain on each binding invocation** — closes the stale-buffer hazard in all three failure modes (dispatcher restart, binding-present-but-no-new-events, hot reload). + +`waitUntilHeadsEqual` uses `expect.poll` with default intervals `[100, 250, 500, 1000]` and a 30s timeout. Heads are serialized with sorted keys so equality is engine-independent: + +```ts +function canonical(heads: Record): string { + return JSON.stringify( + Object.keys(heads).sort().map(k => [k, heads[k].seq, heads[k].hash]), + ); +} + +async waitUntilHeadsEqual(other: Peer, opts?: { timeout?: number }) { + const target = canonical(await other.heads()); + await expect.poll( + async () => canonical(await this.heads()), + { + timeout: opts?.timeout ?? 30_000, + message: `${this.label} converge with ${other.label}`, + }, + ).toBe(target); +} + +async waitUntilAllHeadsEqual(others: Peer[], opts?) { + for (const other of others) await this.waitUntilHeadsEqual(other, opts); +} +``` + +**Naming caveat.** `waitUntilHeadsEqual` is named for what it verifies, not the abstract concept of "convergence." Two peers can be heads-equal yet both still missing an event from a third peer C — the assertion does not protect against that. Tests that need true N-peer convergence call `waitUntilAllHeadsEqual([peerA, peerB, peerC])` to verify every peer has reached the same head set, which is the standard CRDT-suite check (head-set equality across all observed peers). Single-peer tests, or two-peer tests where the peer pair is the entire universe, can use `waitUntilHeadsEqual` directly. + +**Partial-equality footgun.** If peer A's head set and peer C's head set name *different* author keys (A has `{x, y}`, C has `{x, y, z}`), `waitUntilHeadsEqual` will hang until timeout because canonical equality requires identical key sets. The timeout error message includes a structured author-key diff (`A missing authors: [z]; C missing authors: []`) so the failure is debuggable without a manual `console.log` round-trip. Required for the helper to be usable in mixed-membership tests. + +## `data-state` attribute pattern + +For animated UI elements with a settling phase, the component sets a `data-state` attribute reflecting the transition phase and tests gate on the attribute rather than sleeping. This is the canonical replacement for every `waitForTimeout` after a UI transition. + +**Lifecycle states.** `closed`, `opening`, `open`, `closing`. The `opening`/`closing` phases are set imperatively when the transition starts; `open`/`closed` are flipped on `transitionend`. + +**Three failure modes that must be handled** to prevent false flake: + +1. **`prefers-reduced-motion: reduce`** — CSS transition is zeroed; `transitionend` may not fire. The component reads `getComputedStyle(el).transitionDuration`; if `0s`, the terminal state is set synchronously without waiting for the event. +2. **Component unmount mid-transition** — Leptos cleanup must abort the pending state. The signal is owned by the component and is dropped with it; test simply observes the locator detach. +3. **Overlapping transitions** — only the last `transitionend` fires reliably for a given property. The component listens for the *specific* property (`transitionend` filtered by `event.propertyName === `). Each of the five components documents its driving property in a comment at the listener site (`mobile_shell.rs` and `grove_drawer.rs` use `transform`; `confirm_dialog.rs` and `bottom_sheet.rs` use `opacity`; `tab_bar.rs` uses `transform`). A browser test asserts the chosen property is what advances `data-state` — guards against future CSS edits silently breaking the lifecycle. + +```rust +// crates/web/src/components/grove_drawer.rs (illustrative) +let state = RwSignal::new("closed"); + +let advance = move || { + state.set(match state.get_untracked() { + "opening" => "open", + "closing" => "closed", + other => other, + }); +}; + +let on_transition_end = move |ev: web_sys::TransitionEvent| { + if ev.property_name() == "transform" { + advance(); + } +}; + +let trigger_open = move |_| { + state.set("opening"); + // Reduced-motion shortcut: if the element has zero-duration + // transition, skip the wait and snap to terminal state. + if computed_transition_duration_is_zero(&drawer_ref) { + advance(); + } +}; +``` + +Tests: + +```ts +await openSidebarBtn.click(); +await expect(drawer).toHaveAttribute('data-state', 'open'); +``` + +**Components receiving the lifecycle.** `mobile_shell.rs` (mobile drawer), `grove_drawer.rs`, `confirm_dialog.rs`, `bottom_sheet.rs`, `tab_bar.rs` (active-tab transition). The mobile action sheet markup lives in `message.rs`; the `data-state` attribute is added to the existing `mobile-action-sheet-overlay` div there. Five physical edits. + +**Existing `data-state` usages on `status_dot.rs` and `grove_rail.rs` are NOT brought into the four-phase lifecycle.** They use `data-state` for orthogonal categorical states (`online`/`offline`, etc.). The lifecycle contract applies only to the listed five animated components. The shared attribute name is a convention; tests that gate on it must know which component they target. `e2e/README.md` documents this distinction. + +## `page.clock` for real durations + +Three legitimate real-duration waits exist today and are migrated to `page.clock`: + +1. **`longPress(locator, duration)`** in `e2e/helpers.ts:264`. Today: `mouse.down()` + `waitForTimeout(duration)` + `mouse.up()`. New: `clock.runFor(duration)` between down and up. Test must `await page.clock.install()` in the relevant `beforeEach`. +2. **Debounce timers in the input box** (typing-indicator throttle, message-edit autosave). Tests that exercise these flows install the clock and `runFor` past the debounce window. +3. **HLC drift simulation** (no current test, but a likely future need): `clock.setFixedTime(date)` per peer. + +**API correction.** Playwright's clock is `page.clock.install()`, not `context.clock.install()`. The clock is per-page (which means per-`BrowserContext` in the common one-page-per-context Playwright fixture, but the call goes through the page). + +**Multi-peer caveat.** `page.clock` is per-page. Two peers in the same Playwright test run in two separate `BrowserContext`s (and therefore two separate pages), so each peer can install an independent clock and the drift simulation works. Tests that share a context across peers (rare in this suite but possible) cannot use independent clocks; the spec's two-peer fixture (`setupTwoPeers`) creates two contexts and is unaffected. + +**WASM coverage.** `page.clock` patches the JS `Date`/`setTimeout`/`setInterval`/`requestAnimationFrame` globals. It does **not** patch `performance.now()`. Willow's WASM HLC reads time via `js_sys::Date::now()` (`crates/messaging/src/hlc.rs:96`), which calls the patched global, so HLC behaviour is controlled by the fake clock when installed. **Native** HLC (`hlc.rs:87`) uses `SystemTime::now()` directly and is unaffected — and is exercised in Rust tests, not Playwright, so this is a non-issue for the e2e suite. + +**iroh timer verification (PR 1 acceptance gate).** Before merging PR 1, a one-off audit checks whether iroh's WASM transport uses `performance.now()` for retry backoff or gossip heartbeats. Method: `git grep -n 'performance\|Performance::now\|web_sys::window().*performance' iroh*`-vendored sources used by `willow-network`. If iroh uses `performance.now`, installing `page.clock` would freeze UI/HLC time but iroh would keep running on real time — silent divergence. In that case `page.clock` install is constrained to scopes where iroh activity is irrelevant (longPress within a single peer, debounce within a stable connection). The audit result and the resulting constraint are recorded in PR 1's description and inlined back into this section once known. + +**Opt-in.** Clock install is per test (or `describe` block), not global. Default e2e tests run with real time so iroh background timers (gossip heartbeats, retry backoff) are unaffected. + +## Helpers redesign + +`e2e/helpers.ts` (702 LOC) is split into three focused modules. Magic-number sleeps are removed in the same change. + +``` +e2e/ +├── helpers/ +│ ├── peers.ts -- setupTwoPeers, joinViaInvite, getPeerId, freshStart +│ ├── ui.ts -- openSidebar, switchChannel, switchTab, messageAction +│ └── touch.ts -- longPress, tap, swipe +├── test-hooks.ts -- Peer wrapper, ClientEvent type, Snapshot type +└── ...spec.ts +``` + +Three patterns purged: + +1. `while (await locator.isVisible()) { click; waitForTimeout(300) }` loops at `helpers.ts:178`, `:358`, `:471` → recursive `clickBack` with bounded depth gating on `expect(backBtn).toBeHidden()`. +2. Bare `waitForTimeout` after navigation (e.g. `helpers.ts:118` after Settings nav) → assert on a landing-page locator instead. +3. `{ timeout: 30_000 }` overrides on `toBeVisible` for cross-peer assertions (23 occurrences) → `await peer.waitUntilHeadsEqual(other)` before the assertion, then default 5s timeout. + +**Targeted exception rule.** A small number of waits may have no event-based equivalent (e.g. a CSS transition on a third-party component without `transitionend`). These require a `// time-wait: ` comment plus a per-line `eslint-disable-next-line` referencing the rule and the tracking issue. No blanket allowlist. + +## Implementation phasing + +The work is decomposed into four sequential PRs against the `claude/event-based-waits-RNFZ9` branch (or successor branch). Each PR is independently reviewable and ships green CI. + +**PR 1 — `test-hooks` feature + WASM API + push dispatcher.** +- `crates/web/Cargo.toml` feature flag. +- `crates/web/src/test_hooks.rs` (`WillowTestHooks`, `SnapshotDto`, `install_push_dispatcher`). +- Mount in `app.rs` post-`with_trust_store`. +- `crates/web/tests/browser.rs` self-tests for `snapshot()`, `heads()`, `event_count()`, `last_event()`. +- `just check-all` symbol-leak grep additions. +- Smoke test: nothing in e2e converted yet; just verifies `window.__willow` exists in the e2e build and not in the prod build. + +**PR 2 — Playwright `Peer` wrapper + helpers split + first pilot.** +- `e2e/test-hooks.ts` with `Peer`, `Snapshot`, `ClientEvent` types and the fixture. +- `e2e/helpers.ts` → `e2e/helpers/{peers,ui,touch}.ts`. Magic numbers stripped where the equivalent locator-or-event wait suffices. +- `e2e/test-hooks.spec.ts` smoke tests for the wrapper. +- Pilot: `e2e/multi-peer-sync.spec.ts` converted. +- Other 7 specs continue to use the legacy import paths through a shim (`e2e/helpers.ts` becomes a re-export barrel). No semantic change to un-migrated specs. PR 2 includes an exhaustive enumeration of legacy exports (every name imported by any spec under `e2e/*.spec.ts`) verified by a TypeScript test that imports each one through the barrel; missing exports fail the build. + +**PR 3 — `data-state` lifecycle on the five animated components.** +- Lifecycle on `mobile_shell.rs`, `grove_drawer.rs`, `confirm_dialog.rs`, `bottom_sheet.rs`, `tab_bar.rs` and the action-sheet markup in `message.rs`. +- Reduced-motion fallback per the spec section. +- Browser tests in `crates/web/tests/browser.rs` covering reduced-motion and unmount cases. + +**Lint window note.** The ESLint rule lands in PR 4, leaving PRs 1–3 unprotected against new `waitForTimeout` calls. To narrow the window: PR 1 also adds the ESLint rule and a `// eslint-disable` header on every existing spec referencing the tracking issue. The full ratchet script + baseline file ship with PR 4 since the baseline value depends on the post-pilot count. This way the rule blocks new offences from PR 1 onward while the count-based ratchet starts when there's a stable count to ratchet against. + +**PR 4 — Ratchet script + flake harness + cleanup.** +- `scripts/check-wait-timeout-count.sh` + `e2e/.wait-timeout-baseline` (initial value: post-pilot count, computed at PR-4 land time; script also enforces sunset cutoff). +- `just test-e2e-flake N=5` recipe. +- Removal of any `eslint-disable` headers from specs migrated in PRs 2–3. + +The tracking issue is opened **before PR 1** so the URL is stable for `eslint-disable` references in PR 4. The issue's body lists the 7 un-migrated specs, the sunset date (2026-09-30), and links to this spec. + +## Pilot conversions + +Two pilots ship in PR 2 so the API is exercised under real load: + +1. **`e2e/helpers.ts` → `e2e/helpers/{peers,ui,touch}.ts`.** Highest leverage: every spec re-imports through the new modules. Validates the `data-state` pattern (UI helpers) and `page.clock` (touch helpers) end-to-end. +2. **`e2e/multi-peer-sync.spec.ts`.** Worst gossip-pad offender: 30s timeout overrides on every cross-peer assertion plus two `waitForTimeout(2000)` calls. Validates `Peer.nextEvent` and `Peer.waitUntilHeadsEqual` in their dominant use case. + +Acceptance for **both** pilots: `just test-e2e-flake N=10` must pass with zero failures, measured in CI. Wall-clock for `multi-peer-sync.spec.ts` should drop measurably (current ~45s wall-clock per local run, sampled best-of-3; target <20s, sampled the same way once gossip-pads are removed). The hard requirement is zero flake; speed is a follow indicator. + +## Tracking issue + +A GitHub issue `e2e: migrate remaining specs to event-based waits` is opened **before PR 1 lands**, so its URL is stable and can be cited from `eslint-disable` headers added in PR 4. Body lists each remaining file as a checklist plus the 2026-09-30 sunset date: + +- [ ] `e2e/permissions.spec.ts` +- [ ] `e2e/mobile.spec.ts` +- [ ] `e2e/mobile-actions.spec.ts` +- [ ] `e2e/multi-peer-mobile.spec.ts` +- [ ] `e2e/cross-browser-sync.spec.ts` +- [ ] `e2e/join-links.spec.ts` +- [ ] `e2e/worker-nodes.spec.ts` + +Each file gets its own small PR; each PR removes that file's entry from the ESLint allowlist. The tracking issue is referenced by every `eslint-disable-next-line` comment in un-migrated files. + +## CI gate + +**Build verification.** `just check-all` adds two steps: + +1. `trunk build --release` (no features) → grep the resulting `dist/*.js` (the wasm-bindgen-emitted JS shim, which retains class names regardless of `wasm-opt` symbol stripping in the `.wasm`) for `WillowTestHooks`. Must not find. Fails CI if leaked. Catches accidental `default = ["test-hooks"]` regressions. As a defence-in-depth check, also runs `wasm-objdump --section=name dist/*.wasm | grep -q WillowTestHooks` and asserts no match. +2. `trunk build --features test-hooks` → grep `dist/*.js` for `WillowTestHooks`. Must find. Sanity check the gating actually compiles. + +**Lint.** New `e2e/.eslintrc.cjs` (or extension of root config) adds: + +```js +{ + rules: { + 'no-restricted-syntax': ['error', { + selector: "CallExpression[callee.property.name='waitForTimeout']", + message: 'Use event-based waits. See docs/specs/2026-04-27-event-based-waits-design.md.', + }], + }, +} +``` + +Un-migrated specs receive `/* eslint-disable no-restricted-syntax -- tracked: */` at file top. As each file migrates, the disable comment is removed in the same PR. + +**Ratchet.** A small `scripts/check-wait-timeout-count.sh` counts `waitForTimeout` occurrences in `e2e/`, **excluding** lines tagged with the `// time-wait:` exemption marker, compares to a baseline file `e2e/.wait-timeout-baseline`, and fails CI if the count increases. Decreases update the baseline (manually committed; not auto-rewritten). The exemption-marker carve-out is the escape hatch for legitimate real-duration waits that no event can replace; each exemption requires a justification comment and survives review. + +**Sunset.** The ESLint allowlist and ratchet baseline are scheduled for removal by **2026-Q3** (concretely: by 2026-09-30). The ratchet script enforces this: after the cutoff date it requires the baseline to be `0` and exits non-zero otherwise, regardless of whether the baseline file still exists. On 2026-09-30 the rule flips to hard-fail at 0, the baseline file is deleted, and any remaining `// time-wait:` exemptions stand on their justification alone. The sunset date is recorded in the tracking issue. If migration is incomplete by then, a brief written extension (PR amending this spec and updating the script's date constant) is required. + +**Flake harness.** New just recipe: + +``` +test-e2e-flake N=5: + for i in $(seq 1 {{N}}); do \ + just test-e2e-ui || exit 1; \ + done +``` + +Migrated specs must pass `N=10` in CI on every PR that modifies `e2e/`. + +## Risks and tradeoffs + +| Risk | Mitigation | +|---|---| +| Push queue grows unbounded if a test forgets to drain | Bounded buffer (capacity 65 536) inside the WASM dispatcher. Overflow is a hard error: dispatcher calls `window.__willowOverflow(droppedCount)`, which the test fixture wires to fail the test immediately. Overflow is treated as a correctness bug, not backpressure. | +| `exposeBinding` registered after the app boots → first events dropped | `exposeBinding` is registered in `beforeEach` *before* `goto`, so the binding is normally present when WASM dispatches. As defence-in-depth, `addInitScript` declares an empty `__willowEventBuffer`; the dispatcher checks for `__willowEvent` on the window and falls back to pushing into the buffer if absent. The first `__willowEvent` call drains the buffer. | +| `page.clock` interferes with iroh's WASM timers (gossip heartbeats, retry backoff) | Clock install is opt-in per test; default e2e tests run with real time. Tests that install the clock are explicit about which timers they advance. | +| `data-state` attributes proliferate across markup | Only on elements tests gate on (drawer, modal, dropdown, tab, action sheet — finite, listed above). Documented in the `e2e/README.md`. | +| Cargo feature drift: e2e build differs from prod build behaviour | `#[cfg]` only adds inert mounting code; no behaviour change. Validated by running existing `just test-browser` once with `--features test-hooks` and asserting the same pass set. | +| Migration stalls; ESLint allowlist becomes permanent | Tracking issue + ratchet script (count must monotonically decrease). Renewed on every file migration PR. | +| `ClientEvent` schema drifts between Rust and TS `Peer` wrapper | TS type kept in `e2e/test-hooks.ts` with a `// keep in sync with crates/client/src/lib.rs ClientEvent` marker. A small Rust integration test serializes every `ClientEvent` variant and asserts the TS type covers it (codegen check is a follow-up if drift becomes painful). | + +**Runner-up rejected — DOM-only (`data-testid` + attribute polling, no WASM API).** Rejected for the *state-convergence* bucket because gossip convergence is non-DOM: peer B can apply role/permission events that change no UI, but downstream assertions still depend on those events being applied. The `data-state` lifecycle pattern is essentially the DOM-only approach for the *DOM-settle* bucket — that's intentional and not a contradiction; the rejection is scoped to the convergence bucket only. + +**Runner-up rejected — always-on `window.__willow` API (no cargo feature).** Rejected on privacy: third-party JS in production could read DAG heads and event counts. Cost of the feature gate is one cargo feature, one CI line, and one symbol-leak grep — small and one-time. + +**Runner-up rejected — keep `waitForTimeout` and just lengthen.** Rejected because longer sleeps mask the race rather than fix it; the suite still spends real wall-clock waiting after the system has settled, and any new flake source raises the timeout further. The flake is an information-theoretic problem (no signal) and time can't substitute. + +**Runner-up rejected — Rust-driven test harness in place of Playwright.** Rejected because the e2e tier exists specifically to validate real iroh + browser behaviour, and that fidelity is exactly what the lower tiers cannot cover. The `MemNetwork` already serves the Rust-driven multi-peer testing role at the `client` tier (per `docs/specs/2026-04-21-e2e-test-architecture-design.md`). + +**Runner-up rejected — synchronous iroh mock in tests.** Rejected for the same reason: `MemNetwork` already exists for the Rust tier; e2e exists because the network-effect path matters. + +## Testing the test infrastructure + +**WASM-side.** New tests in `crates/web/tests/browser.rs` (gated `#[cfg(feature = "test-hooks")]`) construct a `ClientHandle`, apply known events, and assert: +- `snapshot()`, `heads()`, `event_count()`, `last_event()` return the expected shape and values. +- Bounded-buffer overflow path: synthetic stress that pushes 65 537 events triggers the overflow callback exactly once. +- Buffer drain on first binding call: events queued before binding wiring are delivered in order on first call. +- `data-state` lifecycle: reduced-motion shortcut sets terminal state synchronously; component unmount mid-transition does not leak state. + +**Playwright-side.** New `e2e/test-hooks.spec.ts` smoke-tests the `Peer` wrapper: +- `peer.snapshot()` returns the expected fields after a fresh start. +- `peer.eventCount()` equals 1 after `CreateServer`. +- `peer.nextEvent(e => e.kind === 'SyncCompleted')` resolves on the first heartbeat, and rejects with a clear error after `opts.timeout` if no matching event arrives. +- `peer.waitUntilHeadsEqual(self)` is a no-op (single peer trivially converges). +- Three-peer test: `waitUntilAllHeadsEqual([peerB, peerC])` blocks until *all* three peers reach the same head set. + +**Pilot acceptance.** `multi-peer-sync.spec.ts` is run 10× via `just test-e2e-flake N=10` and `helpers/*` exercised through every spec it underpins. Both must pass with zero failures in CI. The "before" baseline (legacy 30s-timeout version) is captured in the same PR via a one-time CI run on the parent commit; numbers recorded in the PR description for review. + +## Cross-references + +- [`docs/specs/2026-04-21-e2e-test-architecture-design.md`](./2026-04-21-e2e-test-architecture-design.md) — three-tier test pyramid; this spec slots into Tier 3 (Playwright) and references the "rewrite trigger" rule for migrating tests downwards when selectors drift. + +## Open questions + +- Do we need a `Peer.events()` accessor that returns the full applied-event log (filtered by predicate)? Probably yes for permissions tests; defer until those migrate. +- Should the `data-state` attribute pattern be lifted into a shared Leptos helper component to enforce consistency? Defer; first apply the pattern manually to the listed components and refactor if duplication accumulates. +- WebSocket-frame-level waits via `page.waitForEvent('websocket')` are powerful for relay tests but couple to wire format. Out of scope for this spec; revisit if relay-specific flakes appear. +- Auto-generation of the TS `ClientEvent` mirror from the Rust enum (e.g. via `ts-rs`). Cheap to add later; not scoped here. diff --git a/e2e/cross-browser-sync.spec.ts b/e2e/cross-browser-sync.spec.ts index 539eb69b..94478f90 100644 --- a/e2e/cross-browser-sync.spec.ts +++ b/e2e/cross-browser-sync.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-syntax -- migration tracked at https://github.com/intendednull/willow/issues/458 */ import { test, expect, chromium, firefox, devices } from '@playwright/test'; // Custom Firefox context options — avoids flakiness seen with the full diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 67c10b1a..51b6e595 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-syntax -- migration tracked at https://github.com/intendednull/willow/issues/458 */ import { Page, Browser, BrowserContext, Locator, expect } from '@playwright/test'; /** Wait for the WASM app to load (loading spinner disappears). */ diff --git a/e2e/mobile-actions.spec.ts b/e2e/mobile-actions.spec.ts index 90dcc34b..5373962c 100644 --- a/e2e/mobile-actions.spec.ts +++ b/e2e/mobile-actions.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-syntax -- migration tracked at https://github.com/intendednull/willow/issues/458 */ import { test, expect } from '@playwright/test'; import { freshStart, diff --git a/e2e/mobile.spec.ts b/e2e/mobile.spec.ts index f4ea4742..fc432d0c 100644 --- a/e2e/mobile.spec.ts +++ b/e2e/mobile.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-syntax -- migration tracked at https://github.com/intendednull/willow/issues/458 */ import { test, expect } from '@playwright/test'; import { freshStart, diff --git a/e2e/multi-peer-mobile.spec.ts b/e2e/multi-peer-mobile.spec.ts index 2ae85da5..63271820 100644 --- a/e2e/multi-peer-mobile.spec.ts +++ b/e2e/multi-peer-mobile.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-syntax -- migration tracked at https://github.com/intendednull/willow/issues/458 */ import { test, expect } from '@playwright/test'; import { sendMessage, diff --git a/e2e/permissions.spec.ts b/e2e/permissions.spec.ts index b08f4d11..8df242f6 100644 --- a/e2e/permissions.spec.ts +++ b/e2e/permissions.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-syntax -- migration tracked at https://github.com/intendednull/willow/issues/458 */ import { test, expect } from '@playwright/test'; import { sendMessage, diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..52310285 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,32 @@ +const js = require('@eslint/js'); +const tsPlugin = require('@typescript-eslint/eslint-plugin'); +const tsParser = require('@typescript-eslint/parser'); + +module.exports = [ + { + ignores: ['node_modules/**', 'dist/**', '.dev/**'], + }, + { + files: ['e2e/**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + }, + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: "CallExpression[callee.property.name='waitForTimeout']", + message: + 'Use event-based waits (Peer.nextEvent / waitUntilHeadsEqual / data-state). See docs/specs/2026-04-27-event-based-waits-design.md.', + }, + ], + }, + }, +]; diff --git a/justfile b/justfile index d01b8399..8fb15453 100644 --- a/justfile +++ b/justfile @@ -56,8 +56,8 @@ test-browser: wasm-pack test --headless --firefox crates/web # Bootstrap the E2E test environment (install tooling, build, start services) -setup-e2e: - ./scripts/setup-e2e.sh +setup-e2e FEATURES="": + WILLOW_FEATURES="{{FEATURES}}" ./scripts/setup-e2e.sh # Install/build for E2E but don't start services setup-e2e-no-start: @@ -68,29 +68,31 @@ teardown-e2e: ./scripts/teardown-e2e.sh # Run the full E2E flow: setup, run tests, teardown (teardown runs even on failure) -test-e2e-full: +test-e2e-full FEATURES="test-hooks": #!/usr/bin/env bash set -u - ./scripts/setup-e2e.sh + WILLOW_FEATURES="{{FEATURES}}" ./scripts/setup-e2e.sh EXIT_CODE=0 npx playwright test --project=desktop-chrome --project=mobile-chrome || EXIT_CODE=$? ./scripts/teardown-e2e.sh exit $EXIT_CODE # Run Playwright E2E tests against deployed site -test-e2e-ui: +test-e2e-ui FEATURES="test-hooks": + @just setup-e2e FEATURES={{FEATURES}} npx playwright test --project=desktop-chrome --project=mobile-chrome # Full-suite gate: lint + Rust + wasm-pack browser + Playwright, in # order, fail-fast. This is the single command a PR must go green on. -check-all: +check-all FEATURES="test-hooks": #!/usr/bin/env bash set -euo pipefail just fmt just clippy just test just test-browser - just test-e2e-ui + just test-e2e-ui FEATURES={{FEATURES}} + ./scripts/check-no-test-hooks-in-prod.sh # Run Playwright E2E tests on all browsers test-e2e-ui-all: @@ -101,11 +103,13 @@ test-e2e-ui-headed: npx playwright test --headed # Run multi-peer sync tests (desktop-chrome for quick iteration) -test-e2e-sync: +test-e2e-sync FEATURES="test-hooks": + @just setup-e2e FEATURES={{FEATURES}} npx playwright test e2e/multi-peer-sync.spec.ts --project=desktop-chrome # Run permission tests -test-e2e-perms: +test-e2e-perms FEATURES="test-hooks": + @just setup-e2e FEATURES={{FEATURES}} npx playwright test e2e/permissions.spec.ts --project=desktop-chrome # Run agent unit + integration tests @@ -179,8 +183,8 @@ docker-ids: @docker compose exec storage-2 willow-storage --print-peer-id 2>/dev/null || echo "storage-2: not running" # Start all services for local development (relay, workers, web UI) -dev: - ./scripts/dev.sh +dev FEATURES="": + WILLOW_FEATURES="{{FEATURES}}" ./scripts/dev.sh # Start all services, skipping the build step dev-quick: diff --git a/package-lock.json b/package-lock.json index 469aecc7..96a8a65c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,192 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@playwright/test": "^1.58.2" + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.58.2", + "@typescript-eslint/eslint-plugin": "^8.59.1", + "@typescript-eslint/parser": "^8.59.1", + "eslint": "^10.2.1" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, "node_modules/@playwright/test": { @@ -19,15 +204,663 @@ "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.2" + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", + "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/type-utils": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", + "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", + "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.1", + "@typescript-eslint/types": "^8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", + "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", + "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", + "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", + "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" }, - "bin": { - "playwright": "cli.js" + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" }, "engines": { - "node": ">=18" + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" } }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -43,6 +876,243 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", @@ -74,6 +1144,169 @@ "engines": { "node": ">=18" } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 3539d633..13868953 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ }, "homepage": "https://github.com/intendednull/willow#readme", "devDependencies": { - "@playwright/test": "^1.58.2" + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.58.2", + "@typescript-eslint/eslint-plugin": "^8.59.1", + "@typescript-eslint/parser": "^8.59.1", + "eslint": "^10.2.1" } } diff --git a/scripts/check-no-test-hooks-in-prod.sh b/scripts/check-no-test-hooks-in-prod.sh new file mode 100755 index 00000000..f169773b --- /dev/null +++ b/scripts/check-no-test-hooks-in-prod.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Asserts that a default `trunk build --release` does NOT include the +# WillowTestHooks symbol. Run as part of `just check-all` to catch +# accidental `default = ["test-hooks"]` regressions. +# +# Per docs/specs/2026-04-27-event-based-waits-design.md: the test-hooks +# feature must remain off in production for privacy reasons (third-party +# JS in prod could otherwise read DAG heads). + +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==> Building release (no features) ..." +(cd crates/web && trunk build --release --offline 2>&1) > /dev/null + +DIST=crates/web/dist +if grep -q "WillowTestHooks" "$DIST"/*.js 2>/dev/null; then + echo "FAIL: WillowTestHooks symbol leaked into prod JS shim:" + grep -l "WillowTestHooks" "$DIST"/*.js + exit 1 +fi + +# Defence-in-depth: check the wasm name section if wasm-objdump is +# available. If not present, skip (CI image may install on demand). +if command -v wasm-objdump >/dev/null 2>&1; then + if wasm-objdump --section=name "$DIST"/*.wasm 2>/dev/null | grep -q "WillowTestHooks"; then + echo "FAIL: WillowTestHooks symbol leaked into prod wasm name section" + exit 1 + fi +fi + +echo "==> Building with --features test-hooks (sanity check) ..." +(cd crates/web && trunk build --release --features test-hooks --offline 2>&1) > /dev/null + +if ! grep -q "WillowTestHooks" "$DIST"/*.js 2>/dev/null; then + echo "FAIL: WillowTestHooks symbol absent from feature build — gating broken?" + exit 1 +fi + +echo "PASS: test-hooks gating verified." diff --git a/scripts/dev.sh b/scripts/dev.sh index 748157e1..00ce666f 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -147,8 +147,15 @@ PIDS+=($!) # --- Web UI ------------------------------------------------------------------- +FEATURES="${WILLOW_FEATURES:-}" +FEATURES_FLAG="" +if [ -n "$FEATURES" ]; then + FEATURES_FLAG="--features $FEATURES" +fi + echo -e "${GREEN}Starting web UI (trunk serve)...${NC}" -(cd "$ROOT/crates/web" && trunk serve) 2>&1 | while IFS= read -r line; do +# shellcheck disable=SC2086 +(cd "$ROOT/crates/web" && trunk serve $FEATURES_FLAG) 2>&1 | while IFS= read -r line; do echo -e "${GREEN}[web]${NC} $line" done & PIDS+=($!) diff --git a/scripts/setup-e2e.sh b/scripts/setup-e2e.sh index 7dd55630..e1963a70 100755 --- a/scripts/setup-e2e.sh +++ b/scripts/setup-e2e.sh @@ -75,8 +75,15 @@ info "Tooling ready: trunk=$(trunk --version 2>/dev/null || echo missing), just= step "Building relay, replay, and storage..." cargo build -p willow-relay -p willow-replay -p willow-storage 2>&1 | tail -1 +FEATURES="${WILLOW_FEATURES:-}" +FEATURES_FLAG="" +if [ -n "$FEATURES" ]; then + FEATURES_FLAG="--features $FEATURES" +fi + step "Building web UI (WASM)..." -(cd "$ROOT/crates/web" && trunk build 2>&1 | tail -1) +# shellcheck disable=SC2086 +(cd "$ROOT/crates/web" && trunk build $FEATURES_FLAG 2>&1 | tail -1) info "All builds complete." @@ -142,7 +149,8 @@ info "Storage node started (PID $!)" # Web UI step "Starting web UI (trunk serve)..." -(cd "$ROOT/crates/web" && trunk serve) > "$LOG_DIR/web.log" 2>&1 & +# shellcheck disable=SC2086 +(cd "$ROOT/crates/web" && trunk serve $FEATURES_FLAG) > "$LOG_DIR/web.log" 2>&1 & WEB_PID=$! # Wait for web UI