diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc950447..b2e03528 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: key: clippy-${{ hashFiles('**/Cargo.lock') }} restore-keys: clippy- - id: clippy + # Steps with `id:` end up with `bash -e {0}` (no pipefail) on this runner, + # so `... | tee` would silently mask failures. Force pipefail on. + shell: bash --noprofile --norc -eo pipefail {0} run: cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tee /tmp/clippy.log - name: Surface clippy failure if: failure() && steps.clippy.conclusion == 'failure' @@ -73,6 +76,7 @@ jobs: key: test-${{ hashFiles('**/Cargo.lock') }} restore-keys: test- - id: test + shell: bash --noprofile --norc -eo pipefail {0} run: cargo test --workspace 2>&1 | tee /tmp/test.log - name: Surface test failure if: failure() && steps.test.conclusion == 'failure' @@ -130,6 +134,10 @@ jobs: with: tool: wasm-pack - id: browser + # `bash -e {0}` (default) does NOT enable pipefail, so `wasm-pack ... | tee` + # would silently mask failures (tee always exits 0). Use an explicit shell + # invocation that turns pipefail on so the link/test exit code surfaces. + shell: bash --noprofile --norc -eo pipefail {0} run: wasm-pack test --headless --firefox crates/web 2>&1 | tee /tmp/browser.log - name: Surface browser-test failure if: failure() && steps.browser.conclusion == 'failure' diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9f2beab1..7d897de8 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -42,6 +42,7 @@ jobs: - name: Run full E2E flow (setup + tests + teardown) id: e2e + shell: bash --noprofile --norc -eo pipefail {0} run: just test-e2e-full 2>&1 | tee /tmp/e2e.log - name: Surface E2E failure if: failure() && steps.e2e.conclusion == 'failure' diff --git a/Cargo.lock b/Cargo.lock index 7273ddc7..e0dff6a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -126,9 +126,9 @@ dependencies = [ [[package]] name = "any_spawner" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41058deaa38c9d9dd933d6d238d825227cffa668e2839b52879f6619c63eee3b" +checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d" dependencies = [ "futures", "thiserror 2.0.18", @@ -212,6 +212,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + [[package]] name = "async-trait" version = "0.1.89" @@ -379,6 +385,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + [[package]] name = "base64" version = "0.22.1" @@ -700,6 +712,12 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + [[package]] name = "const_format" version = "0.2.35" @@ -743,22 +761,31 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.7.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] [[package]] name = "convert_case" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case_extras" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589c70f0faf8aa9d17787557d5eae854d7755cac50f5c3d12c81d3d57661cebb" +dependencies = [ + "convert_case 0.11.0", +] + [[package]] name = "cordyceps" version = "0.3.4" @@ -1263,9 +1290,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "either_of" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14f7f86eef3a7e4b9c2107583dbbbe3d9535c4b800796faf1774b82ba22033da" +checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd" dependencies = [ "paste", "pin-project-lite", @@ -1300,6 +1327,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472" + [[package]] name = "errno" version = "0.3.14" @@ -1970,9 +2003,9 @@ dependencies = [ [[package]] name = "hydration_context" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35485b3dcbf7e044b8f28c73f04f13e7b509c2466fd10cb2a8a447e38f8a93a" +checksum = "e8714ae4adeaa846d838f380fbd72f049197de629948f91bf045329e0cf0a283" dependencies = [ "futures", "once_cell", @@ -2712,14 +2745,15 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "leptos" -version = "0.7.8" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b8731cb00f3f0894058155410b95c8955b17273181d2bc72600ab84edd24f1" +checksum = "efa3982e7fe36c1de68f91f3c9083124f389a975523881f3d7e3363362feda41" dependencies = [ "any_spawner", "cfg-if", "either_of", "futures", + "getrandom 0.4.2", "hydration_context", "leptos_config", "leptos_dom", @@ -2731,8 +2765,10 @@ dependencies = [ "paste", "reactive_graph", "rustc-hash", + "rustc_version", "send_wrapper", "serde", + "serde_json", "serde_qs", "server_fn", "slotmap", @@ -2742,14 +2778,16 @@ dependencies = [ "typed-builder", "typed-builder-macro", "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_split_helpers", "web-sys", ] [[package]] name = "leptos_config" -version = "0.7.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bae3e0ead5a7a814c8340eef7cb8b6cba364125bd8174b15dc9fe1b3cab7e03" +checksum = "0c06f751315bccc0d193fab302ac01d25bcfcd97474d4676440e7e3250dc3fc3" dependencies = [ "config", "regex", @@ -2760,9 +2798,9 @@ dependencies = [ [[package]] name = "leptos_dom" -version = "0.7.8" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89d4eb263bd5a9e7c49f780f17063f15aca56fd638c90b9dfd5f4739152e87d" +checksum = "35742e9ed8f8aaf9e549b454c68a7ac0992536e06856365639b111f72ab07884" dependencies = [ "js-sys", "or_poisoned", @@ -2775,14 +2813,14 @@ dependencies = [ [[package]] name = "leptos_hot_reload" -version = "0.7.8" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e80219388501d99b246f43b6e7d08a28f327cdd34ba630a35654d917f3e1788e" +checksum = "9d2a0f220c8a5ef3c51199dfb9cdd702bc0eb80d52fbe70c7890adfaaae8a4b1" dependencies = [ "anyhow", "camino", "indexmap", - "parking_lot", + "or_poisoned", "proc-macro2", "quote", "rstml", @@ -2793,13 +2831,14 @@ dependencies = [ [[package]] name = "leptos_macro" -version = "0.7.9" +version = "0.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e621f8f5342b9bdc93bb263b839cee7405027a74560425a2dabea9de7952b1fd" +checksum = "9360df573fb57582384a8b7640a3de94ce6501d49be3b69f637cf11a42da484b" dependencies = [ "attribute-derive", "cfg-if", - "convert_case 0.7.1", + "convert_case 0.11.0", + "convert_case_extras", "html-escape", "itertools", "leptos_hot_reload", @@ -2808,6 +2847,7 @@ dependencies = [ "proc-macro2", "quote", "rstml", + "rustc_version", "server_fn_macro", "syn 2.0.117", "uuid", @@ -2815,9 +2855,9 @@ dependencies = [ [[package]] name = "leptos_server" -version = "0.7.8" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66985242812ec95e224fb48effe651ba02728beca92c461a9464c811a71aab11" +checksum = "da974775c5ccbb6bd64be7f53f75e8321542e28f21563a416574dbe4d5447eae" dependencies = [ "any_spawner", "base64", @@ -2865,12 +2905,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linear-map" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfae20f6b19ad527b550c223fddc3077a547fc70cda94b9b566575423fd303ee" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4085,18 +4119,21 @@ dependencies = [ [[package]] name = "reactive_graph" -version = "0.1.8" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a0ccddbc11a648bd09761801dac9e3f246ef7641130987d6120fced22515e6" +checksum = "00c5a025366836190c7030e883cc2bcd9e384ff555336e3c7954741ca411b177" dependencies = [ "any_spawner", "async-lock", "futures", "guardian", "hydration_context", + "indexmap", "or_poisoned", + "paste", "pin-project-lite", "rustc-hash", + "rustc_version", "send_wrapper", "serde", "slotmap", @@ -4106,26 +4143,28 @@ dependencies = [ [[package]] name = "reactive_stores" -version = "0.1.8" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aadc7c19e3a360bf19cd595d2dc8b58ce67b9240b95a103fbc1317a8ff194237" +checksum = "c30fd35b7d299c591293bb69fed47a703eb2703b1cff0493e78b16ed007e5382" dependencies = [ "guardian", + "indexmap", "itertools", "or_poisoned", "paste", "reactive_graph", "reactive_stores_macro", "rustc-hash", + "send_wrapper", ] [[package]] name = "reactive_stores_macro" -version = "0.1.8" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "221095cb028dc51fbc2833743ea8b1a585da1a2af19b440b3528027495bf1f2d" +checksum = "e5d8e790a5ae5ddf9b7fa380c728375b06858e0cca7d063a73b3408320c523e1" dependencies = [ - "convert_case 0.7.1", + "convert_case 0.11.0", "proc-macro-error2", "proc-macro2", "quote", @@ -4310,7 +4349,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", ] @@ -4752,13 +4791,13 @@ dependencies = [ [[package]] name = "serde_qs" -version = "0.13.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd34f36fe4c5ba9654417139a9b3a20d2e1de6012ee678ad14d240c22c78d8d6" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" dependencies = [ "percent-encoding", "serde", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] @@ -4784,19 +4823,22 @@ dependencies = [ [[package]] name = "server_fn" -version = "0.7.8" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d05a9e3fd8d7404985418db38c6617cc793a1a27f398d4fbc9dfe8e41b804e6" +checksum = "5d60e4c1dfccd91fe0990141f69f1d5cf5679797ad53aa1b45e5bd658eb119f0" dependencies = [ + "base64", "bytes", + "const-str", "const_format", - "dashmap", "futures", "gloo-net", "http", "js-sys", - "once_cell", + "or_poisoned", "pin-project-lite", + "rustc_version", + "rustversion", "send_wrapper", "serde", "serde_json", @@ -4807,30 +4849,31 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.4.2", + "wasm-streams", "web-sys", "xxhash-rust", ] [[package]] name = "server_fn_macro" -version = "0.7.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "504b35e883267b3206317b46d02952ed7b8bf0e11b2e209e2eb453b609a5e052" +checksum = "1295b54815397d30d986b63f93cfd515fa86d5e528e0bb589ce9d530502f9e0f" dependencies = [ "const_format", - "convert_case 0.6.0", + "convert_case 0.11.0", "proc-macro2", "quote", + "rustc_version", "syn 2.0.117", "xxhash-rust", ] [[package]] name = "server_fn_macro_default" -version = "0.7.8" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb8b274f568c94226a8045668554aace8142a59b8bca5414ac5a79627c825568" +checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00" dependencies = [ "server_fn_macro", "syn 2.0.117", @@ -5153,31 +5196,29 @@ dependencies = [ [[package]] name = "tachys" -version = "0.1.9" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66c3b70c32844a6f1e2943c72a33ebb777ad6acbeb20d1329d62e3a7806d6ec" +checksum = "2989c94c59db8497727875aa561d4d0daa3cc79b5774d5ced48263f7091beff1" dependencies = [ "any_spawner", "async-trait", "const_str_slice_concat", "drain_filter_polyfill", - "dyn-clone", "either_of", + "erased", "futures", "html-escape", "indexmap", "itertools", "js-sys", - "linear-map", "next_tuple", "oco_ref", - "once_cell", "or_poisoned", - "parking_lot", "paste", "reactive_graph", "reactive_stores", "rustc-hash", + "rustc_version", "send_wrapper", "slotmap", "throw_error", @@ -5255,9 +5296,9 @@ dependencies = [ [[package]] name = "throw_error" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4ef8bf264c6ae02a065a4a16553283f0656bd6266fc1fcb09fd2e6b5e91427b" +checksum = "dc0ed6038fcbc0795aca7c92963ddda636573b956679204e044492d2b13c8f64" dependencies = [ "pin-project-lite", ] @@ -5614,18 +5655,18 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typed-builder" -version = "0.20.1" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" dependencies = [ "typed-builder-macro", ] [[package]] name = "typed-builder-macro" -version = "0.20.1" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" dependencies = [ "proc-macro2", "quote", @@ -5942,9 +5983,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -5954,16 +5995,25 @@ dependencies = [ ] [[package]] -name = "wasm-streams" -version = "0.5.0" +name = "wasm_split_helpers" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +checksum = "d0cb6d1008be3c4c5abc31a407bfb8c8449ae14efc8561c1db821f79b9614b0a" dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", + "async-once-cell", + "wasm_split_macros", +] + +[[package]] +name = "wasm_split_macros" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a659ffe5c7f4538aa6357c07e3d73221cc61eba03bd9a081e14bc91ed09b8c" +dependencies = [ + "base16", + "quote", + "sha2 0.10.9", + "syn 2.0.117", ] [[package]] diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 8de0e2f4..1e5416eb 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -12,7 +12,7 @@ willow-crypto = { path = "../crypto" } willow-identity = { path = "../identity" } willow-network = { path = "../network" } willow-state = { path = "../state" } -leptos = { version = "0.7", features = ["csr"] } +leptos = { version = "0.8", features = ["csr"] } tracing = { workspace = true } wasm-bindgen = "0.2" web-sys = { version = "0.3", features = [ diff --git a/crates/web/src/app.rs b/crates/web/src/app.rs index 06c15bb7..a4246bbd 100644 --- a/crates/web/src/app.rs +++ b/crates/web/src/app.rs @@ -1211,6 +1211,15 @@ pub fn App() -> impl IntoView { write.ui.set_show_members.set(true); write.ui.set_show_palette.set(false); }) + on_open_sync_queue=Callback::new(move |_| { + // Phase 2b — palette → sync-queue + // surface. The right-rail watches + // `app.queue.open` and mounts the + // SyncQueueView when it flips + // true. + app_state.queue.open.set(true); + write.ui.set_show_palette.set(false); + }) on_search=Callback::new(move |q: String| { // Palette bridge per // `local-search.md` §Command- diff --git a/crates/web/style.css b/crates/web/style.css index 72496951..f0693e38 100644 --- a/crates/web/style.css +++ b/crates/web/style.css @@ -43,8 +43,11 @@ --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4); --shadow-inset: inset 0 1px 3px rgba(0, 0, 0, 0.2); - /* Keep legacy focus-ring shape but re-seat on the foundation token. */ - --focus-ring: var(--focus-ring, 0 0 0 2px var(--moss-1), 0 0 0 3px rgba(106, 141, 94, 0.6)); + /* --focus-ring is owned by foundation.css; redeclaring it here as + `var(--focus-ring, ...)` introduces a same-selector self-reference + cycle that resolves to the guaranteed-invalid value, blanking the + focus ring on every element that consumes it. Foundation already + defines the token, so style.css inherits it without a redeclare. */ --overlay-bg: rgba(0, 0, 0, 0.6); --backdrop-blur: blur(4px); diff --git a/crates/web/tests/browser.rs b/crates/web/tests/browser.rs index be717932..da3922d5 100644 --- a/crates/web/tests/browser.rs +++ b/crates/web/tests/browser.rs @@ -7963,8 +7963,14 @@ mod phase_2a_message_row { }); tick().await; - let hint = query(&container, ".queue-note.queue-note--late") - .expect("LateArrival must render .queue-note.queue-note--late"); + // Phase 2b routes the inline hint through the shared + // `` component (`crates/web/src/components/inline_queue_note.rs`), + // which renders `.inline-note.inline-note--inbound-held` for the + // late-arrival state. The literal copy is sourced from + // `sync_queue_copy::MSG_NOTE_INBOUND_HELD`, matching the Copy + // table in `docs/specs/2026-04-19-ui-design/sync-queue.md` §Copy. + let hint = query(&container, ".inline-note.inline-note--inbound-held") + .expect("LateArrival must render .inline-note.inline-note--inbound-held"); assert!( text(&hint).contains("sent earlier · arrived now"), "LateArrival hint must carry the literal spec copy, got: {:?}", @@ -8010,11 +8016,18 @@ mod phase_2a_message_row { }); tick().await; - let hint = query(&container, ".queue-note.queue-note--pending") - .expect("Pending must render .queue-note.queue-note--pending"); + // Phase 2b routes the inline hint through the shared + // `` component, which renders + // `.inline-note.inline-note--queued` for the local-pending state. + // The literal copy comes from `sync_queue_copy::msg_note_queued` + // (spec §Copy), interpolating the author display name as the + // peer-or-grove placeholder. + let hint = query(&container, ".inline-note.inline-note--queued") + .expect("Pending must render .inline-note.inline-note--queued"); + let expected = willow_web::components::sync_queue_copy::msg_note_queued("Mira"); assert!( - text(&hint).contains("queued · will send on reconnect"), - "Pending hint must carry the literal spec copy, got: {:?}", + text(&hint).contains(&expected), + "Pending hint must carry spec copy ({expected:?}), got: {:?}", text(&hint) ); assert!( @@ -9564,6 +9577,25 @@ async fn phase_2e_recent_chip_has_listitem_role() { mod foundation_tokens { use super::*; + /// Strip `@import` rules from a CSS source. The headless Firefox + /// harness has no network access, and an `@import url(...)` pointing + /// at Google Fonts (the only `@import` we ship) stalls the entire + /// stylesheet's `CSSStyleSheet` until the fetch fails, leaving every + /// `:root` custom property unresolved under `getComputedStyle` while + /// the test runs. Fonts are irrelevant to token resolution, so we + /// drop those rules before injecting the sheet. + fn css_without_imports(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + for line in src.lines() { + if line.trim_start().starts_with("@import") { + continue; + } + out.push_str(line); + out.push('\n'); + } + out + } + /// Inject `foundation.css` into the test document once per page load /// so `:root` design tokens resolve under `getComputedStyle`. Dedupes /// via a fixed element id. @@ -9575,7 +9607,9 @@ mod foundation_tokens { } let style = doc.create_element("style").unwrap(); style.set_id(STYLE_ID); - style.set_text_content(Some(include_str!("../foundation.css"))); + style.set_text_content(Some(&css_without_imports(include_str!( + "../foundation.css" + )))); let head = doc.head().expect("document has "); head.append_child(&style).unwrap(); } @@ -9591,7 +9625,7 @@ mod foundation_tokens { } let style = doc.create_element("style").unwrap(); style.set_id(STYLE_ID); - style.set_text_content(Some(include_str!("../style.css"))); + style.set_text_content(Some(&css_without_imports(include_str!("../style.css")))); let head = doc.head().expect("document has "); head.append_child(&style).unwrap(); } @@ -10886,7 +10920,13 @@ mod phase_2b_sync_queue { }); view! { } }); + // Wait for the queued RAF to fire (driving the device-online flip), + // then tick once more to flush the resulting reactive effect. A + // bare `tick()` is just `setTimeout(0)` and can resolve before + // the browser dispatches RAF callbacks queued by other tests in + // the same tab — see `await_animation_frame` for context. tick().await; + await_animation_frame().await; tick().await; assert!( @@ -10921,6 +10961,7 @@ mod phase_2b_sync_queue { view! { } }); tick().await; + await_animation_frame().await; tick().await; let toast = query(&container, ".reconnection-toast") @@ -10956,6 +10997,7 @@ mod phase_2b_sync_queue { view! { } }); tick().await; + await_animation_frame().await; tick().await; let dismiss = query(&container, ".reconnection-toast__dismiss") @@ -11021,6 +11063,7 @@ mod phase_2b_sync_queue { view! { } }); tick().await; + await_animation_frame().await; tick().await; assert!( @@ -11059,6 +11102,7 @@ mod phase_2b_sync_queue { view! { } }); tick().await; + await_animation_frame().await; tick().await; let banner = query(&container, ".welcome-back-banner") @@ -11100,6 +11144,7 @@ mod phase_2b_sync_queue { view! { } }); tick().await; + await_animation_frame().await; tick().await; let dismiss = query(&container, ".welcome-back-banner__dismiss") @@ -11240,10 +11285,20 @@ mod phase_2b_sync_queue { ); } - /// `request_animation_frame` wrapper used to schedule signal - /// updates after mount so the `Effect` subscribing to - /// `device_online` has already run once with the `prev == true` - /// default before the test drives the transition. + /// Schedule a closure for the next animation frame. Used by the + /// reconnection-toast / welcome-back-banner tests to flip + /// `device_online` *after* the component's `Effect` has run once + /// with `prev == true`, so the `false → true` transition fires. + /// + /// Pairs with [`await_animation_frame`] — call this to enqueue the + /// transition, then await one or more animation frames to make sure + /// the callback has actually fired before `tick()`-ing the reactive + /// effects. Headless Firefox under wasm-pack runs every `#[wasm_bindgen_test]` + /// in the same tab; previously-mounted components leave RAF-bound + /// closures and timers behind, so a pure `tick()` (which is just a + /// `setTimeout(0)`) can resolve before the new test's RAF has been + /// dispatched. Awaiting an explicit animation frame is the + /// deterministic synchronization point for these tests. fn request_animation_frame(f: impl FnOnce() + 'static) { let closure = wasm_bindgen::closure::Closure::once_into_js(Box::new(f) as Box); @@ -11252,6 +11307,24 @@ mod phase_2b_sync_queue { .request_animation_frame(closure.as_ref().unchecked_ref()) .expect("request_animation_frame"); } + + /// Resolves on the next animation frame. Call this in tests *after* + /// scheduling work via [`request_animation_frame`] to wait until the + /// browser has actually dispatched the frame callback. + async fn await_animation_frame() { + use wasm_bindgen::closure::Closure; + use wasm_bindgen::{JsCast, JsValue}; + let promise = js_sys::Promise::new(&mut |resolve, _reject| { + let cb = Closure::once_into_js(Box::new(move || { + let _ = resolve.call0(&JsValue::NULL); + }) as Box); + web_sys::window() + .expect("window") + .request_animation_frame(cb.as_ref().unchecked_ref()) + .expect("request_animation_frame"); + }); + let _ = wasm_bindgen_futures::JsFuture::from(promise).await; + } } // ────────────────────── Phase 2d — Ephemeral channels ────────────────────── diff --git a/e2e/cross-browser-sync.spec.ts b/e2e/cross-browser-sync.spec.ts index 539eb69b..b2417824 100644 --- a/e2e/cross-browser-sync.spec.ts +++ b/e2e/cross-browser-sync.spec.ts @@ -57,7 +57,7 @@ test.describe('Cross-browser peer sync', () => { expect(mobilePeerId).toBeTruthy(); // Desktop Firefox: generate invite for mobile peer. - await desktopPage.locator('.server-gear-btn').click(); + await desktopPage.locator(`${visibleShell(desktopPage)} [aria-label="grove menu"]`).click(); await desktopPage.waitForTimeout(500); await desktopPage.locator('input[placeholder*="12D3KooW"]').fill(mobilePeerId); await desktopPage.locator('button', { hasText: 'Generate Invite' }).click(); @@ -133,7 +133,7 @@ test.describe('Cross-browser peer sync', () => { // Mobile Chrome: open settings to generate invite. await openSidebar(mobilePage); - await mobilePage.locator('.server-gear-btn').click(); + await mobilePage.locator(`${visibleShell(mobilePage)} [aria-label="grove menu"]`).click(); await mobilePage.waitForTimeout(500); await mobilePage.locator('input[placeholder*="12D3KooW"]').fill(desktopPeerId); await mobilePage.locator('button', { hasText: 'Generate Invite' }).click(); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 67c10b1a..4fe37218 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -89,13 +89,18 @@ export async function createServer(page: Page, name: string, displayName?: strin } } -/** Get the full peer ID from the welcome screen or settings. */ -export async function getPeerId(page: Page): Promise { - // Welcome screen: advance past step 1 (no name), then switch to the - // Join tab — the peer id lives inside the Join step list, hidden by - // default and revealed by the eye-toggle icon. +/** Get the full peer ID from the welcome screen or settings. + * If `displayName` is provided, fills it into the welcome step-1 name + * input before advancing — required when the same page will later + * invoke `joinViaInvite`, since step 1 only renders once. Without + * this, the joiner ends up with display name "anonymous" and member- + * list lookups by name fail. */ +export async function getPeerId(page: Page, displayName?: string): Promise { + // Welcome screen: advance past step 1, then switch to the Join tab — + // the peer id lives inside the Join step list, hidden by default and + // revealed by the eye-toggle icon. if (await page.locator('.welcome-card').isVisible().catch(() => false)) { - await advancePastNameStep(page); + await advancePastNameStep(page, displayName); const joinTab = page.locator('.welcome-tab-btn', { hasText: 'Join' }); if (await joinTab.isVisible().catch(() => false)) { await joinTab.click(); @@ -347,13 +352,46 @@ export async function closeMemberList(page: Page) { } } +/** Opens the sync-queue panel via the command palette. + * + * The sync-queue surface (`SyncQueueView`) mounts inside the right-rail + * on desktop when `app.queue.open == true`. The user-facing trigger is + * the "open sync queue" action in the command palette (⌘K / Ctrl+K). + * The `OfflineStrip` is the only other in-app entry point but it gates + * on `peer_count > 0`, which isn't reliable immediately after server + * creation when no peers are queued yet. + * + * Idempotent — short-circuits when the queue panel is already mounted. + */ +export async function openSyncQueue(page: Page) { + const alreadyOpen = await page + .locator(`${visibleShell(page)} .sync-queue-view`) + .first() + .isVisible() + .catch(() => false); + if (alreadyOpen) return; + + // Open the command palette. The global keydown listener in + // `crates/web/src/keybindings.rs` toggles `show_palette` on Ctrl/⌘+K. + await page.keyboard.press('Control+K'); + const row = page.locator('.palette-row', { hasText: 'open sync queue' }).first(); + await row.waitFor({ timeout: 5_000 }); + await row.click(); + + // Wait for the surface to mount in the visible shell. + await page + .locator(`${visibleShell(page)} .sync-queue-view`) + .first() + .waitFor({ timeout: 5_000 }); +} + // ── Invite flow ─────────────────────────────────────────────────────── /** Opens the server settings panel (opens sidebar first on mobile). */ export async function openServerSettings(page: Page) { if (isMobile(page)) { - // Channel list is on the home tab; the gear lives in the sidebar - // header rendered inside `.mobile-home`. No drawer needed. + // Channel list is on the home tab; the grove header lives in the + // sidebar rendered inside `.mobile-home`. No drawer needed. const backSlot = page.locator('.mobile-top-bar .top-slot-left .top-back'); while (await backSlot.isVisible().catch(() => false)) { await page.locator('.mobile-top-bar .top-slot-left').click(); @@ -362,7 +400,7 @@ export async function openServerSettings(page: Page) { await page.locator('.mobile-tab-bar .tab[data-tab="home"]').click(); await page.waitForTimeout(200); } - await page.locator(`${visibleShell(page)} .server-gear-btn`).first().click(); + await page.locator(`${visibleShell(page)} [aria-label="grove menu"]`).first().click(); await page.locator('.settings-panel, .settings-overlay').first() .waitFor({ timeout: 5_000 }); } @@ -426,9 +464,11 @@ export async function setupTwoPeers( await freshStart(page1); await createServer(page1, serverName, peer1Name); - // Peer 2: Get peer ID from welcome screen. + // Peer 2: Get peer ID from welcome screen. Pass peer2Name so step 1 + // commits with the correct display name — `joinViaInvite` below cannot + // re-set it because step 1 has already been advanced. await freshStart(page2); - const peer2Id = await getPeerId(page2); + const peer2Id = await getPeerId(page2, peer2Name); // Peer 1: Generate invite for peer 2. const inviteCode = await generateInvite(page1, peer2Id); @@ -442,15 +482,12 @@ export async function setupTwoPeers( // just as reliably via gossip events consumed by other helpers. if (peer2Name && !isMobile(page1)) { await openMemberList(page1); - try { - await page1 - .locator('.member-item', { hasText: peer2Name }) - .waitFor({ timeout: 20_000 }); - } catch { - // Display name sync may be slow; proceed anyway — but warn so failures - // here don't produce misleading timeouts in downstream assertions. - console.warn('[setupTwoPeers] peer2 display name did not sync in time — P2P may be slow'); - } + // Throw on timeout instead of swallowing — silent fallback hides + // genuine sync regressions and produces misleading downstream + // failures (e.g. trustPeer/kickPeer can't find the member row). + await page1 + .locator('.member-item', { hasText: peer2Name }) + .waitFor({ timeout: 60_000 }); await closeMemberList(page1); } else if (peer2Name) { // On mobile, just sleep a bit to let gossip propagate. diff --git a/e2e/join-links.spec.ts b/e2e/join-links.spec.ts index 96f02462..4c6a096f 100644 --- a/e2e/join-links.spec.ts +++ b/e2e/join-links.spec.ts @@ -22,7 +22,7 @@ test.describe('Join via shareable link', () => { // Generate join link from settings. await openSidebar(pageA); - await pageA.locator(`${visibleShell(pageA)} .server-gear-btn`).first().click(); + await pageA.locator(`${visibleShell(pageA)} [aria-label="grove menu"]`).first().click(); // Settings panel appears. await pageA.locator('.settings-panel, .settings-overlay').first() .waitFor({ timeout: 5_000 }); diff --git a/e2e/multi-peer-mobile.spec.ts b/e2e/multi-peer-mobile.spec.ts index 2ae85da5..fc720314 100644 --- a/e2e/multi-peer-mobile.spec.ts +++ b/e2e/multi-peer-mobile.spec.ts @@ -16,7 +16,8 @@ import { test.describe.configure({ mode: 'serial' }); test.describe('Multi-peer mobile', () => { - // Mobile two-peer tests need extra time for setup + P2P sync + mobile navigation. + // Mobile two-peer tests need extra time for setup + P2P sync + mobile + // navigation. test.setTimeout(120_000); test.beforeEach(({}, testInfo) => { diff --git a/e2e/multi-peer-sync.spec.ts b/e2e/multi-peer-sync.spec.ts index b94ed6cd..8bcb3aa9 100644 --- a/e2e/multi-peer-sync.spec.ts +++ b/e2e/multi-peer-sync.spec.ts @@ -61,9 +61,11 @@ test.describe('Multi-peer state synchronization', () => { await createChannel(page1, 'announcements'); await createChannel(page1, 'random'); - // Peer 2: Get peer ID. + // Peer 2: Get peer ID. Pass the display name so welcome step 1 + // commits with 'Bob' — `joinViaInvite` below cannot re-set it + // because step 1 has already been advanced past. await freshStart(page2); - const peer2Id = await getPeerId(page2); + const peer2Id = await getPeerId(page2, 'Bob'); // Peer 1: Generate invite. const inviteCode = await generateInvite(page1, peer2Id); diff --git a/e2e/permissions.spec.ts b/e2e/permissions.spec.ts index b08f4d11..fb257165 100644 --- a/e2e/permissions.spec.ts +++ b/e2e/permissions.spec.ts @@ -4,6 +4,7 @@ import { waitForMessage, setupTwoPeers, kickPeer, + openMemberList, openServerSettings, openCompareFingerprints, markFingerprintsMatch, @@ -45,15 +46,23 @@ test.describe('Permissions and trust', () => { test('owner kicks member — member count drops', async ({ browser }) => { const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser, 'Kick Server', 'Alice', 'Bob'); try { - // Record initial member count (includes relay + peers). - await page1.waitForTimeout(1000); - const initialCount = await page1.locator(`${visibleShell(page1)} .member-item`).count(); - expect(initialCount).toBeGreaterThanOrEqual(2); - - // Alice kicks Bob. + // The members pane is closed by default — `setupTwoPeers` opens it + // briefly to wait for display-name sync and then closes it again. + // Open it before counting so `.member-item` rows are mounted (the + // right-rail `match which.get()` only renders MemberList when the + // pane is open). Then poll for the membership-sync-completed state + // (>= 2 members) instead of taking a single fixed-delay snapshot. + await openMemberList(page1); + const memberItems = page1.locator(`${visibleShell(page1)} .member-item`); + await expect.poll(() => memberItems.count(), { timeout: 30_000 }) + .toBeGreaterThanOrEqual(2); + const initialCount = await memberItems.count(); + + // Alice kicks Bob (helper toggles the pane open/closed itself). await kickPeer(page1, 'Bob'); - // Member count should drop by 1. + // Re-open the pane so we can re-count after the kick lands. + await openMemberList(page1); await expect(page1.locator(`${visibleShell(page1)} .member-item`)) .toHaveCount(initialCount - 1, { timeout: 30_000 }); } finally { diff --git a/e2e/worker-nodes.spec.ts b/e2e/worker-nodes.spec.ts index 28aa2ef5..d8619bac 100644 --- a/e2e/worker-nodes.spec.ts +++ b/e2e/worker-nodes.spec.ts @@ -3,6 +3,7 @@ import { freshStart, createServer, openMemberList, + openSyncQueue, visibleShell, } from './helpers'; @@ -21,17 +22,39 @@ test.describe('Worker nodes infrastructure', () => { await freshStart(page); await createServer(page, 'Relay Test', 'Alice'); - // Wait for relay connection to establish. - // App indicates reachability by rendering either the desktop net - // status footer or the mobile top bar once the client has a peer - // id and peer count signal. `:visible` scopes to the active shell. + // Smoke check: app shell mounted. Proves the WASM client booted, + // joined a server, and rendered the channel surface. Desktop + // renders `.main-pane-header`; mobile renders `.mobile-top-bar`. + // `:visible` scopes to the active shell so we don't match the + // hidden inactive copy on the other side of the 720 px split. + // On its own this only proves the UI mounted — the actual relay + // reachability assertion is below. await expect( - page.locator('.net-status-footer:visible, .mobile-top-bar:visible').first() + page.locator('.main-pane-header:visible, .mobile-top-bar:visible').first() ).toBeVisible({ timeout: 20_000 }); - // Alice should always be in the member list on desktop. Mobile - // defers peer member-rendering to a later phase. + // Mobile shell does not yet expose the sync-queue surface or the + // command palette — the relay-reachability assertion runs on + // desktop only. The shell smoke check above still runs in both + // projects. if (!testInfo.project.name.startsWith('mobile')) { + // Real relay-reachability assertion. The `--ok` modifier on + // `.relay-signal-button` is set ONLY when the live iroh handshake + // reports `RelayStatus::Reachable` (data flow: + // `Network::relay_status` → `state_actors.rs` → + // `mutations.rs` → `RelaySignalButton::class_for`). The button + // is mounted only inside `` (gated on + // `app.queue.open == true`), so we open the panel first. + // 30 s ceiling covers CI cold-start: trunk-served WASM load + + // iroh handshake to a freshly-spawned relay can comfortably + // exceed Playwright's default 5 s. + await openSyncQueue(page); + await expect( + page.locator('.relay-signal-button.relay-signal-button--ok:visible').first() + ).toBeVisible({ timeout: 30_000 }); + + // Alice is the sole member on desktop after server creation. + // Mobile defers peer member-rendering to a later phase. await openMemberList(page); const members = page.locator(`${visibleShell(page)} .member-item`); await expect(members).toHaveCount(1, { timeout: 5_000 }); diff --git a/scripts/setup-e2e.sh b/scripts/setup-e2e.sh index 7dd55630..a44a8c72 100755 --- a/scripts/setup-e2e.sh +++ b/scripts/setup-e2e.sh @@ -68,6 +68,14 @@ if ! ls "$HOME/.cache/ms-playwright" 2>/dev/null | grep -q '^chromium-'; then npx playwright install chromium fi +# Firefox is required by `e2e/cross-browser-sync.spec.ts`, which launches +# both Chromium and Firefox to verify cross-browser P2P connectivity. Use +# the same filesystem guard as Chromium so re-runs skip the download. +if ! ls "$HOME/.cache/ms-playwright" 2>/dev/null | grep -q '^firefox-'; then + step "Installing Playwright Firefox..." + npx playwright install firefox +fi + info "Tooling ready: trunk=$(trunk --version 2>/dev/null || echo missing), just=$(just --version 2>/dev/null || echo missing)" # ── 2. Build all services ───────────────────────────────────────────────