Skip to content

Commit 2bc7ee2

Browse files
committed
feat: add SSR e2e hydration tests for simple_ssr and ssr_router
Introduces ssr-e2e orchestrator (trunk build, server start, wasm-bindgen-test runner) and ssr-e2e-harness (shared wasm32 test utilities). Adds e2e tests that fetch SSR-rendered HTML, inject it into the wasm-bindgen-test output element, and hydrate against it to verify no hydration panics. Also adds CORS support to both SSR servers for cross-origin test fetches and a CI workflow job.
1 parent 457d1d6 commit 2bc7ee2

13 files changed

Lines changed: 546 additions & 11 deletions

File tree

.github/workflows/main-checks.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ on:
66
- ".github/workflows/main-checks.yml"
77
- "ci/**"
88
- "packages/**/*"
9+
- "examples/**/*"
10+
- "tools/**/*"
911
- "Cargo.toml"
1012
- "Cargo.lock"
1113
push:
@@ -290,3 +292,53 @@ jobs:
290292
run: |
291293
cargo build --target wasm32-wasip1 -p ${{ matrix.package }}
292294
wasmtime -W unknown-imports-trap=y target/wasm32-wasip1/debug/${{ matrix.package }}.wasm
295+
296+
ssr_e2e_tests:
297+
name: SSR E2E Tests (${{ matrix.example }})
298+
runs-on: ubuntu-latest
299+
300+
strategy:
301+
fail-fast: false
302+
matrix:
303+
include:
304+
- example: ssr_router
305+
server_bin: ssr_router_server
306+
trunk_dir: examples/ssr_router
307+
- example: simple_ssr
308+
server_bin: simple_ssr_server
309+
trunk_dir: examples/simple_ssr
310+
311+
steps:
312+
- uses: actions/checkout@v6
313+
314+
- name: Setup toolchain
315+
uses: dtolnay/rust-toolchain@master
316+
with:
317+
toolchain: stable
318+
targets: wasm32-unknown-unknown
319+
320+
- uses: Swatinem/rust-cache@v2
321+
with:
322+
save-if: ${{ github.ref == 'refs/heads/master' }}
323+
324+
- name: Install wasm-bindgen-cli
325+
shell: bash
326+
run: ./ci/install-wasm-bindgen-cli.sh
327+
328+
- name: Install trunk
329+
uses: jetli/trunk-action@v0.5.1
330+
with:
331+
version: "latest"
332+
333+
- uses: browser-actions/setup-geckodriver@latest
334+
with:
335+
token: ${{ secrets.GITHUB_TOKEN }}
336+
337+
- name: Run SSR E2E tests
338+
run: |
339+
GECKODRIVER=$(which geckodriver) cargo run -p ssr-e2e -- \
340+
--trunk-dir ${{ matrix.trunk_dir }} \
341+
--server-cmd "cargo run -p ${{ matrix.example }} --bin ${{ matrix.server_bin }} --features ssr -- --dir ${{ matrix.trunk_dir }}/dist" \
342+
--health-url http://127.0.0.1:8080/ \
343+
--test-pkg ${{ matrix.example }} \
344+
-- --target wasm32-unknown-unknown --test e2e

Cargo.lock

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ trybuild = "1"
3939
clap = { version = "4", features = ["derive"] }
4040
wasm-bindgen = "0.2"
4141
wasm-bindgen-futures = "0.4"
42+
wasm-bindgen-test = "0.3"
4243
js-sys = "0.3"
4344
web-sys = "0.3"
4445
gloo = "0.11"

examples/simple_ssr/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs"] }
2929
warp = { git = "https://github.com/seanmonstar/warp.git", rev = "de1ccd8", features = ["server"] }
3030
clap = { workspace = true }
3131

32+
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
33+
wasm-bindgen-test.workspace = true
34+
ssr-e2e-harness = { path = "../../tools/ssr-e2e-harness" }
35+
36+
[dev-dependencies]
37+
yew = { path = "../../packages/yew", features = ["hydration"] }
38+
3239
[features]
3340
hydration = ["yew/hydration"]
3441
ssr = ["yew/ssr"]

examples/simple_ssr/src/bin/simple_ssr_server.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@ async fn main() {
5252
}
5353
});
5454

55-
let routes = html.or(warp::fs::dir(opts.dir));
55+
let cors = warp::cors()
56+
.allow_any_origin()
57+
.allow_methods(vec!["GET"])
58+
.allow_headers(vec!["content-type"]);
59+
60+
let routes = html.or(warp::fs::dir(opts.dir)).with(cors);
5661

5762
println!("You can view the website at: http://localhost:8080/");
5863
warp::serve(routes).run(([127, 0, 0, 1], 8080)).await;

examples/simple_ssr/tests/e2e.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
2+
3+
use simple_ssr::App;
4+
use ssr_e2e_harness::{fetch_ssr_html, output_element, wait_for};
5+
use wasm_bindgen_test::*;
6+
7+
wasm_bindgen_test_configure!(run_in_browser);
8+
9+
const SERVER_BASE: &str = "http://127.0.0.1:8080";
10+
11+
#[wasm_bindgen_test]
12+
async fn hydration_succeeds() {
13+
let body_html = fetch_ssr_html(SERVER_BASE, "/").await;
14+
output_element().set_inner_html(&body_html);
15+
yew::Renderer::<App>::with_root(output_element()).hydrate();
16+
17+
wait_for(
18+
|| {
19+
let html = output_element().inner_html();
20+
html.contains("Random UUID:")
21+
},
22+
5000,
23+
"SSR content with UUID",
24+
)
25+
.await;
26+
27+
let html = output_element().inner_html();
28+
assert!(
29+
html.contains("Random UUID:"),
30+
"hydrated content should contain the UUID text"
31+
);
32+
}

examples/ssr_router/Cargo.toml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,22 @@ yew-router = { path = "../../packages/yew-router" }
2929
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "fs"] }
3030
axum = "0.8"
3131
tower = { version = "0.5", features = ["make"] }
32-
tower-http = { version = "0.6", features = ["fs"] }
32+
tower-http = { version = "0.6", features = ["fs", "cors"] }
3333
env_logger = "0.11"
3434
clap = { workspace = true }
3535
hyper = { version = "1.4", features = ["server", "http1"] }
3636

3737
[target.'cfg(unix)'.dependencies]
3838
jemallocator = "0.5"
3939

40+
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
41+
wasm-bindgen-test.workspace = true
42+
gloo = { workspace = true }
43+
ssr-e2e-harness = { path = "../../tools/ssr-e2e-harness" }
44+
45+
[dev-dependencies]
46+
yew = { path = "../../packages/yew", features = ["hydration"] }
47+
4048
[features]
4149
ssr = ["yew/ssr"]
4250
hydration = ["yew/hydration"]

examples/ssr_router/src/bin/ssr_router_server.rs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use hyper_util::rt::TokioIo;
1919
use hyper_util::server;
2020
use tokio::net::TcpListener;
2121
use tower::Service;
22+
use tower_http::cors::CorsLayer;
2223
use tower_http::services::ServeDir;
2324
use yew::platform::Runtime;
2425
use yew_router::prelude::Routable;
@@ -108,15 +109,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
108109

109110
let index_html_after = index_html_after.to_owned();
110111

111-
let app = Router::new().fallback_service(
112-
ServeDir::new(opts.dir)
113-
.append_index_html_on_directories(false)
114-
.fallback(
115-
get(render)
116-
.with_state((index_html_before.clone(), index_html_after.clone()))
117-
.into_service(),
118-
),
119-
);
112+
let app = Router::new()
113+
.fallback_service(
114+
ServeDir::new(opts.dir)
115+
.append_index_html_on_directories(false)
116+
.fallback(
117+
get(render)
118+
.with_state((index_html_before.clone(), index_html_after.clone()))
119+
.into_service(),
120+
),
121+
)
122+
.layer(CorsLayer::permissive());
120123

121124
let addr: SocketAddr = ([0, 0, 0, 0], 8080).into();
122125

examples/ssr_router/tests/e2e.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
2+
3+
use std::time::Duration;
4+
5+
use function_router::App;
6+
use gloo::utils::document;
7+
use ssr_e2e_harness::{fetch_ssr_html, output_element, push_route, wait_for};
8+
use wasm_bindgen_test::*;
9+
use yew::platform::time::sleep;
10+
11+
wasm_bindgen_test_configure!(run_in_browser);
12+
13+
const SERVER_BASE: &str = "http://127.0.0.1:8080";
14+
15+
fn get_title_text() -> Option<String> {
16+
document()
17+
.query_selector("h1.title")
18+
.ok()
19+
.flatten()
20+
.map(|el| el.text_content().unwrap_or_default())
21+
}
22+
23+
#[wasm_bindgen_test]
24+
async fn hydrate_post_page() {
25+
let body_html = fetch_ssr_html(SERVER_BASE, "/posts/0").await;
26+
27+
output_element().set_inner_html(&body_html);
28+
push_route("/posts/0");
29+
yew::Renderer::<App>::with_root(output_element()).hydrate();
30+
31+
wait_for(
32+
|| {
33+
let html = output_element().inner_html();
34+
html.contains("<h1 class=\"title\">") && !html.contains("Loading post...")
35+
},
36+
5000,
37+
"post page content",
38+
)
39+
.await;
40+
41+
let title = get_title_text().expect("h1.title should be present on the post page");
42+
assert!(!title.is_empty(), "post title should not be empty");
43+
}
44+
45+
#[wasm_bindgen_test]
46+
async fn hydrate_posts_list() {
47+
let body_html = fetch_ssr_html(SERVER_BASE, "/posts").await;
48+
49+
output_element().set_inner_html(&body_html);
50+
push_route("/posts");
51+
yew::Renderer::<App>::with_root(output_element()).hydrate();
52+
53+
wait_for(
54+
|| {
55+
document()
56+
.query_selector("a.title.is-block")
57+
.ok()
58+
.flatten()
59+
.is_some()
60+
},
61+
10000,
62+
"post links to appear on /posts",
63+
)
64+
.await;
65+
}
66+
67+
#[wasm_bindgen_test]
68+
async fn hydrate_home() {
69+
let body_html = fetch_ssr_html(SERVER_BASE, "/").await;
70+
71+
output_element().set_inner_html(&body_html);
72+
push_route("/");
73+
yew::Renderer::<App>::with_root(output_element()).hydrate();
74+
75+
sleep(Duration::from_secs(2)).await;
76+
let html = output_element().inner_html();
77+
assert!(
78+
html.contains("Welcome"),
79+
"home page should have content after hydration"
80+
);
81+
}

tools/ssr-e2e-harness/Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[package]
2+
name = "ssr-e2e-harness"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[dependencies]
7+
gloo = { workspace = true, features = ["futures"] }
8+
web-sys = { workspace = true }
9+
wasm-bindgen = { workspace = true }
10+
js-sys = { workspace = true }

0 commit comments

Comments
 (0)