//! `cargo xtask web-offline-test [--site DIR]`: prove that the web app works offline and updates. //! //! Serves six release builds, a site without the app and a development build at //! `/solidcraft/app/` (as on GitHub Pages, with its 10-minute HTTP caching) from a local server //! that can also act offline, flaky, hung or mid-deployment, plus another copy of the app at //! `/solidcraft/elsewhere/` on the same origin, and drives headless Chrome through the DevTools //! protocol through these steps (twenty-one page launches, which failures are numbered by): //! //! 1. online, build v1: the page loads, the service worker takes over and keeps every file; //! 2. offline: v1 starts from the kept copy; //! 3. the other copy (build v2) is opened and kept; offline, v1 still starts (copies at //! different paths keep their own builds); //! 4. build v2 deployed, but only `build.json` and `sw.js` download: v1 still starts (never a //! page whose code can't be fetched); //! 5. the same for build v3: v1 starts, and v2's unfinished download is dropped (failed updates //! don't pile up); //! 6. build v2 deployed again: its page fails at once while its other files are still coming: //! v1 starts, and its download doesn't stay in progress (so it can be dropped); //! 7. v3's files download, but v4 is deployed meanwhile (its `index.html` comes): v1 starts, and //! v3 isn't marked complete (a kept build never mixes deployments); //! 8. online: the first launch already gets v3 (resuming its download), and only v3 stays kept; //! 9. offline: v3 starts; //! 10. network hangs: after waiting for it a few seconds, v3 starts from the kept copy; //! 11. build v4 deployed, but the check for it is slow while v4's worker takes over (as when //! another tab updates): the kept v3 starts and works, its copy retired but not deleted; //! 12. no open page uses the retired copies any more: a launch (v4) deletes them; //! 13. build v5's worker downloads slowly while a launch finds v6 deployed and downloads it: //! v6 starts, v5's download isn't dropped from under its worker, then v6's worker takes over; //! 14. with no launch in between (a page left open), updates to v2 and then v3 both fail: the //! second one's install drops the first's unfinished download; //! 15. the app removed (no `sw.js`, no `build.json`): the site's page starts, from the network; //! 16. nothing deployed at all (every file 404): v6 starts from the kept copy; //! 17. offline: v6 still starts from the kept copy; //! 18. a development build (`trunk serve`) on the same origin: it starts (no `build.json`) //! while the worker retires, dropping everything kept; //! 19. the development build starts, from the network, with no worker. //! //! The site is a small stand-in app (default) or a real web build (`--site dist/web`, after //! `trunk build --release`); a real build must hold the `sw.js` and `build.json` the generator //! writes for its files. Each build then gets its own `sw.js` and `build.json` from //! `cargo xtask web-offline`'s generator and a `data-build` marker on ``. Chrome comes from //! `CHROME` or the usual install locations; `cargo xtask ci` skips this test when there is none. //! //! The stand-in app has a content-named script, a `shared.js` whose name is the same in every //! build (a page must get its own build's), and an on-demand file under `fonts/` (not kept). use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use serde_json::{Value, json}; use crate::web_offline; const PREFIX: &str = "/solidcraft/app/"; /// Another copy of the app on the same origin. const ELSEWHERE: &str = "/solidcraft/elsewhere/"; /// How long one launch may take (a real build compiles ~30 MB of wasm). const LAUNCH_TIMEOUT: Duration = Duration::from_secs(90); /// sw.js's NAVIGATE_TIMEOUT_MS, less a margin: a hung network is waited for at least this long. const SLOW_NETWORK_WAIT: Duration = Duration::from_secs(3); /// How long a file takes in Mode::SlowFiles. const SLOW_FILE: Duration = Duration::from_secs(6); /// Run the test; `required: false` (from `cargo xtask ci`) skips it when Chrome is missing. pub fn run(root: &Path, site: Option<&str>, required: bool) -> Result<(), String> { let Some(chrome) = find_chrome() else { if required { return Err("web-offline-test: Chrome not found (set CHROME to its executable)".into()); } println!("web-offline-test: skipped (no Chrome; set CHROME to its executable)"); return Ok(()); }; let work = TempDir::new()?; let builds = ["v1", "v2", "v3", "v4", "v5", "v6", "gone", "dev"].map(|b| (work.0.join(b), b)); let started = match site { Some(dir) => { check_site(root, Path::new(dir), &work.0.join("check"))?; for (to, build) in &builds { copy_dir(Path::new(dir), to)?; let index = to.join("index.html"); let html = std::fs::read_to_string(&index).map_err(|e| format!("{}: {e}", index.display()))?; std::fs::write(&index, mark(&html, build)?).map_err(|e| format!("{}: {e}", index.display()))?; } // The wasm ran: the loading message is gone (started) or says why it couldn't start. "(() => { const el = document.getElementById('solidcraft_loading'); return !el || !el.innerText.startsWith('Loading'); })()" } None => { let registration = registration_script(root)?; for (to, build) in &builds { // The site without the app doesn't register a worker. stand_in(to, build, if *build == "gone" { "" } else { ®istration })?; } "['started', 'shared'].every((k) => document.documentElement.dataset[k] === document.documentElement.dataset.build)" } }; let mut kept = Vec::new(); let [(v1, _), (v2, _), (v3, _), (v4, _), (v5, _), (v6, _), (gone, _), (dev, _)] = builds; for dir in [&v1, &v2, &v3, &v4, &v5, &v6] { kept.push(web_offline::generate(root, dir)?); } // The site without the app: no worker, no build.json. for file in ["sw.js", "build.json"] { let _ = std::fs::remove_file(gone.join(file)); } // A development build, as `trunk serve` writes it: no build.json, the retiring worker. let _ = std::fs::remove_file(dev.join("build.json")); web_offline::retire(root, &dev)?; let server = Server::start(v2.clone())?; let browser = Chrome::launch(&chrome, &work.0.join("profile"))?; let mut cdp = browser.connect()?; // Every file any build keeps, by name (the state counts a cache's entries among them). let mut files = std::collections::BTreeSet::new(); for dir in [&v1, &v2, &v3, &v4, &v5, &v6] { let path = dir.join("build.json"); let build: Value = std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).ok_or_else(|| format!("{}: not readable", path.display()))?; files.extend(build["files"].as_array().into_iter().flatten().filter_map(|f| f["file"].as_str().map(str::to_string))); } let state_js = STATE_JS.replace("STARTED", started).replace("BUILD_FILES", &json!(files.into_iter().collect::>()).to_string()); let count = std::cell::Cell::new(0); let launch_at = |cdp: &mut Cdp, at: &str, build: &str| -> Result { let n = count.get() + 1; count.set(n); let t = Instant::now(); let r = cdp.call("Page.navigate", json!({ "url": format!("http://127.0.0.1:{}{at}?launch={n}", server.port) }))?; if let Some(e) = r["errorText"].as_str().filter(|e| !e.is_empty()) { return Err(format!("launch {n} (build {build}): the page didn't load: {e}")); } let search = format!("?launch={n}"); wait_for(cdp, &state_js, &format!("launch {n} starts build {build}"), |s| { s["search"] == search.as_str() && s["ready"] == "complete" && s["build"] == build && s["started"] == true })?; Ok(t.elapsed()) }; let launch = |cdp: &mut Cdp, build: &str| launch_at(cdp, PREFIX, build); // The page is controlled by the worker, which keeps exactly this build's files (and the mark // that they are all there). let keeps = |i: usize| { let (name, n) = kept.get(i).map(|(v, n)| (cache_name(PREFIX, v), n + 1)).unwrap_or_default(); move |s: &Value| s["controlled"] == true && s["kept"] == json!({ name.clone(): n }) }; let step = |what: &str, t: Duration| println!("ok {what} ({:.1}s)", t.as_secs_f64()); server.set(Mode::Serve(v1)); let t = launch(&mut cdp, "v1")?; wait_for(&mut cdp, &state_js, "the worker keeps build v1", keeps(0))?; step("online: build v1 starts and is kept for offline use", t); server.set(Mode::Refuse); step("offline: build v1 starts from the kept copy", launch(&mut cdp, "v1")?); // The other copy is always online, with build v2. let t = launch_at(&mut cdp, ELSEWHERE, "v2")?; let elsewhere = kept.get(1).map(|(v, n)| (cache_name(ELSEWHERE, v), n + 1)).unwrap_or_default(); wait_for(&mut cdp, &state_js, "the other copy keeps build v2", |s| { s["controlled"] == true && s["kept"] == json!({ elsewhere.0.clone(): elsewhere.1 }) })?; launch(&mut cdp, "v1")?; step("another copy at another path keeps its own build; offline, v1 still starts here", t); server.set(Mode::Flaky(v2.clone())); step("flaky: build v2 can't be downloaded whole, so v1 starts", launch(&mut cdp, "v1")?); server.set(Mode::Flaky(v3.clone())); let t = launch(&mut cdp, "v1")?; let [kept1, kept3] = [0, 2].map(|i| kept.get(i).map(|(v, _)| cache_name(PREFIX, v)).unwrap_or_default()); wait_for(&mut cdp, &state_js, "only v1 and v3's unfinished download are kept", |s| { s["caches"].as_object().is_some_and(|c| c.len() == 2 && c.contains_key(&kept1) && c.contains_key(&kept3)) })?; step("flaky again: build v3 neither, so v1 starts; v2's unfinished download is dropped", t); server.set(Mode::FailFast(v2.clone())); let t = launch(&mut cdp, "v1")?; // Past the time the slow files would have arrived. std::thread::sleep(SLOW_FILE + Duration::from_secs(1)); wait_for(&mut cdp, &state_js, "no download is left marked in progress", |s| s["filling"] == json!([]))?; step("one file fails at once, others are still coming: v1 starts; v2's isn't left in progress", t); server.set(Mode::Mixed(v3.clone(), v4.clone())); let t = launch(&mut cdp, "v1")?; let only_v1 = kept1.clone(); wait_for(&mut cdp, &state_js, "v3 isn't marked complete", |s| s["complete"] == json!([only_v1.clone()]))?; step("deployed mid-download: v4's page doesn't fit v3's files, so v1 starts", t); server.set(Mode::Serve(v3.clone())); let t = launch(&mut cdp, "v3")?; wait_for(&mut cdp, &state_js, "the worker keeps build v3 only", keeps(2))?; step("update: build v3 starts on the first launch; only it stays kept", t); server.set(Mode::Refuse); step("offline: build v3 starts from the kept copy", launch(&mut cdp, "v3")?); server.set(Mode::Hang); let t = launch(&mut cdp, "v3")?; if t < SLOW_NETWORK_WAIT { return Err(format!("slow network: the kept copy started after {t:?}, before the network was given a chance")); } step("slow network: build v3 starts from the kept copy", t); // Every page was remembered long ago. let age = "caches.keys().then((ks) => Promise.all(ks.map(async (k) => { const c = await caches.open(k); if (k.startsWith('solidcraft-app-pages ')) for (const r of await c.keys()) { const page = await (await c.match(r)).json(); await c.put(r, new Response(JSON.stringify({ ...page, at: 0 }))); } }))).then(() => true)"; // A second tab runs v3. Build v4 is deployed, past the HTTP cache's ten minutes (only the // worker's copies hold v3); while this tab's launch waits for build.json, the other tab // updates the worker to v4 and leaves. No other page uses v3 then: only the launched page // keeps it (v4's worker takes over once the launch has ended). server.set(Mode::Serve(v3.clone())); let mut other = browser.new_tab()?; launch(&mut other, "v3")?; server.set(Mode::SlowCheck(v4.clone())); cdp.call("Network.clearBrowserCache", json!({}))?; other.eval(age)?; other.eval( "navigator.serviceWorker.getRegistration().then((r) => { setTimeout(() => { r.update(); location.replace('about:blank'); }, 1000); return true; })", )?; cdp.call("Page.navigate", json!({ "url": "about:blank" }))?; let t = launch(&mut cdp, "v3")?; let [name3, name4] = [2, 3].map(|i| kept.get(i).map(|(v, _)| cache_name(PREFIX, v)).unwrap_or_default()); wait_for(&mut cdp, &state_js, "v4's worker keeps v4, v3's copy retired", |s| { s["kept"].as_object().is_some_and(|k| k.len() == 1 && k.contains_key(&name4)) && s["retired"].as_array().is_some_and(|r| r.contains(&json!(name3))) })?; step("update in another tab: the kept v3 still starts; its copy is retired, not deleted", t); other.call("Page.close", json!({}))?; server.set(Mode::Serve(v4)); cdp.eval(age)?; // The page on screen still uses v3 while the next one loads; the launch after that deletes it. let t = launch(&mut cdp, "v4")?; launch(&mut cdp, "v4")?; wait_for(&mut cdp, &state_js, "the retired copies are deleted", |s| s["retired"] == json!([]))?; step("no open page uses them any more: the retired copies are deleted; v4 starts", t); // v5's worker installs, its files coming slowly; meanwhile v6 is deployed and launched. let update = "navigator.serviceWorker.getRegistration().then((r) => { r.update(); return true; })"; let name5 = kept.get(4).map(|(v, _)| cache_name(PREFIX, v)).unwrap_or_default(); let asked = server.slow.load(Ordering::SeqCst); server.set(Mode::SlowFiles(v5)); cdp.eval(update)?; wait_for(&mut cdp, &state_js, "v5's worker starts downloading", |s| s["caches"].get(&name5).is_some())?; // Chrome sends a server at most six requests at once: a build with more files has the rest // wait for the first ones. v6 is deployed once all of v5's are asked for (any asked later // would get v6's file, which rightly ends v5's download, but that isn't this step). let files5 = kept.get(4).map_or(0, |(_, n)| *n); server.wait_asked_slowly(asked + files5)?; server.set(Mode::Serve(v6)); let t = launch(&mut cdp, "v6")?; wait_for(&mut cdp, &state_js, "v5's download is finished, not dropped", |s| s["complete"].as_array().is_some_and(|c| c.contains(&json!(name5))))?; cdp.eval(update)?; wait_for(&mut cdp, &state_js, "the worker keeps build v6", keeps(5))?; step("overlapping downloads: v6 starts; v5's worker still finishes its own; v6 takes over", t); // The page stays open while two updates fail in a row: only installs run, no launch. let [name2, name3] = [1, 2].map(|i| kept.get(i).map(|(v, _)| cache_name(PREFIX, v)).unwrap_or_default()); let t = Instant::now(); server.set(Mode::Flaky(v2.clone())); cdp.eval(update)?; wait_for(&mut cdp, &state_js, "v2's update fails, leaving an unfinished download", |s| { s["caches"].get(&name2).is_some() && s["filling"] == json!([]) })?; server.set(Mode::Flaky(v3.clone())); cdp.eval(update)?; wait_for(&mut cdp, &state_js, "v3's install drops v2's unfinished download", |s| { s["caches"].get(&name3).is_some() && s["caches"].get(&name2).is_none() && s["filling"] == json!([]) })?; step("two failed updates with no launch: the second's install drops the first's leftovers", t.elapsed()); server.set(Mode::Serve(gone)); step("app removed (no build.json): the site's page starts from the network", launch(&mut cdp, "gone")?); server.set(Mode::Serve(work.0.join("nothing"))); step("nothing deployed (every file 404): v6 starts from the kept copy", launch(&mut cdp, "v6")?); server.set(Mode::Refuse); step("offline: build v6 still starts from the kept copy", launch(&mut cdp, "v6")?); server.set(Mode::Serve(dev)); let t = launch(&mut cdp, "dev")?; wait_for(&mut cdp, &state_js, "the worker retires", |s| s["registered"] == 0 && s["caches"] == json!({}))?; step("development build deployed: it starts while the worker retires and drops what it kept", t); let t = launch(&mut cdp, "dev")?; wait_for(&mut cdp, &state_js, "no worker", |s| s["controlled"] == false && s["registered"] == 0)?; step("development build: starts from the network", t); println!("web-offline-test: passed"); Ok(()) } /// What a launch shows: the page, which build it is, whether it started, the worker's caches. const STATE_JS: &str = "(async () => { const scope = new URL('.', location.href).href; const prefix = 'solidcraft-app ' + new URL(scope).pathname + ' '; const caches_ = {}, kept = {}, retired = [], complete = []; // Each cache is read with `caches.match`, never opened: opening a cache that a worker has just // deleted makes it again (empty), and nothing would delete it after that. const marks = ['build-complete', 'build-retired']; for (const k of await caches.keys()) { if (!k.startsWith(prefix)) continue; const here = async (file) => Boolean(await caches.match(new URL(file, scope).href, { cacheName: k })); let n = 0; for (const file of BUILD_FILES.concat(marks)) if (await here(file)) n += 1; caches_[k] = n; if (await here('build-complete')) complete.push(k); if (await here('build-retired')) retired.push(k); else kept[k] = n; } const filling = (await navigator.locks.query()).held.map((l) => l.name).filter((n) => n.startsWith(prefix)); return { search: location.search, ready: document.readyState, build: document.documentElement.dataset.build ?? null, started: STARTED, controlled: !!navigator.serviceWorker.controller, caches: caches_, kept, retired, complete, filling, registered: (await navigator.serviceWorker.getRegistrations()).filter((r) => r.scope === scope).length }; })()"; /// The worker's cache for a build of the copy at `path` (sw.js's CACHE_PREFIX + version). fn cache_name(path: &str, version: &str) -> String { format!("solidcraft-app {path} {version}") } /// A real build must hold what the generator writes for its files: `build.json`, and a `sw.js` /// with them (not the retiring one a debug build gets). fn check_site(root: &Path, site: &Path, scratch: &Path) -> Result<(), String> { copy_dir(site, scratch)?; web_offline::generate(root, scratch)?; for file in ["sw.js", "build.json"] { let built = std::fs::read(site.join(file)).map_err(|e| format!("{}: {e} (build it with `trunk build --release`)", site.join(file).display()))?; let expected = std::fs::read(scratch.join(file)).map_err(|e| format!("{}: {e}", scratch.join(file).display()))?; if built != expected { return Err(format!("{}: not what `cargo xtask web-offline` writes for this build (was it a release build?)", site.join(file).display())); } } Ok(()) } /// Poll the page's state until `done` (errors while a page is loading count as not yet). fn wait_for(cdp: &mut Cdp, state_js: &str, what: &str, done: impl Fn(&Value) -> bool) -> Result<(), String> { let t = Instant::now(); let mut last = Value::Null; while t.elapsed() < LAUNCH_TIMEOUT { match cdp.eval(state_js) { Ok(s) if done(&s) => return Ok(()), Ok(s) => last = s, Err(e) => last = Value::String(e), } std::thread::sleep(Duration::from_millis(200)); } Err(format!("{what}: timed out after {LAUNCH_TIMEOUT:?}; last state: {last}")) } /// `` with `data-build="build"`. fn mark(html: &str, build: &str) -> Result { if !html.contains(" tag".into()); } Ok(html.replacen(" Result { let path = root.join("apps/solidcraft-web/index.html"); let html = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; let at = html.find("serviceWorker").ok_or("apps/solidcraft-web/index.html doesn't register the service worker")?; let start = html.get(..at).and_then(|h| h.rfind("")).map(|e| at + e + "".len()).ok_or("index.html: unclosed \n\n{registration}\n\n\n" ); write("index.html", mark(&page, build)?)?; write(&format!("app-{build}.js"), format!("document.documentElement.dataset.started = \"{build}\";\n"))?; write("shared.js", format!("document.documentElement.dataset.shared = \"{build}\";\n"))?; std::fs::create_dir_all(dir.join("fonts")).map_err(|e| format!("fonts: {e}"))?; write("fonts/on-demand.txt", format!("{build}\n")) } fn copy_dir(from: &Path, to: &Path) -> Result<(), String> { std::fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?; for e in std::fs::read_dir(from).map_err(|e| format!("{}: {e}", from.display()))? { let e = e.map_err(|e| format!("{}: {e}", from.display()))?; let (src, dst) = (e.path(), to.join(e.file_name())); if src.is_dir() { copy_dir(&src, &dst)?; } else { std::fs::copy(&src, &dst).map_err(|e| format!("{}: {e}", src.display()))?; } } Ok(()) } /// A scratch directory, removed when dropped. struct TempDir(PathBuf); impl TempDir { fn new() -> Result { let dir = std::env::temp_dir().join(format!("solidcraft-web-offline-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; Ok(TempDir(dir)) } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } // ---- The site's server ----------------------------------------------------------------------- #[derive(Clone)] enum Mode { /// Online, serving this build. Serve(PathBuf), /// Offline: connections close unanswered. Refuse, /// Flaky: the build's `build.json` and `sw.js` come; its files fail. Flaky(PathBuf), /// Mid-deployment: the first build, but the second one's `index.html`. Mixed(PathBuf, PathBuf), /// The network hangs: connections stay open, unanswered (until the mode changes). Hang, /// Online, serving this build, but `build.json` hangs (until the mode changes). SlowCheck(PathBuf), /// Online, serving this build, but its files (not `build.json`, `sw.js`) take SLOW_FILE. SlowFiles(PathBuf), /// Like SlowFiles, but `index.html` fails at once. FailFast(PathBuf), } struct Server { port: u16, mode: Arc>, /// How many files were asked for in Mode::SlowFiles. slow: Arc, } impl Server { /// Serves the app at PREFIX in the server's mode, and `elsewhere` at ELSEWHERE (always online). fn start(elsewhere: PathBuf) -> Result { let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| format!("server: {e}"))?; let port = listener.local_addr().map_err(|e| format!("server: {e}"))?.port(); let mode = Arc::new(Mutex::new(Mode::Refuse)); let slow = Arc::new(AtomicUsize::new(0)); let (shared, asked) = (mode.clone(), slow.clone()); std::thread::spawn(move || { for stream in listener.incoming().flatten() { let (shared, asked, elsewhere) = (shared.clone(), asked.clone(), elsewhere.clone()); std::thread::spawn(move || answer(stream, &shared, &asked, &elsewhere)); } }); Ok(Server { port, mode, slow }) } /// Wait (at most 30 s) until `n` files in all were asked for in Mode::SlowFiles. fn wait_asked_slowly(&self, n: usize) -> Result<(), String> { for _ in 0..300 { if self.slow.load(Ordering::SeqCst) >= n { return Ok(()); } std::thread::sleep(Duration::from_millis(100)); } Err(format!("only {} of {n} slow files were asked for", self.slow.load(Ordering::SeqCst))) } fn set(&self, mode: Mode) { if let Ok(mut m) = self.mode.lock() { *m = mode; } } } /// Hold a connection while the server's mode stays as it was (at most 20 s). fn hold(shared: &Mutex, still: impl Fn(&Mode) -> bool) { for _ in 0..200 { std::thread::sleep(Duration::from_millis(100)); if !shared.lock().is_ok_and(|m| still(&m)) { return; } } } fn answer(mut stream: TcpStream, shared: &Mutex, slow: &AtomicUsize, elsewhere: &Path) { let Ok(read) = stream.try_clone() else { return }; let mut reader = BufReader::new(read); let mut request = String::new(); if reader.read_line(&mut request).is_err() { return; } loop { let mut header = String::new(); match reader.read_line(&mut header) { Ok(0) | Err(_) => return, Ok(_) if header == "\r\n" => break, Ok(_) => {} } } let path = request.split_whitespace().nth(1).unwrap_or("/"); let path = path.split('?').next().unwrap_or_default(); let (file, other) = match path.strip_prefix(ELSEWHERE) { Some(f) => (Some(f), true), None => (path.strip_prefix(PREFIX), false), }; let file = file.map(|f| if f.is_empty() { "index.html" } else { f }).filter(|f| !f.contains("..")); // The mode when the request comes, not when the connection was opened: Chrome opens // connections ahead of use. let mode = if other { Mode::Serve(elsewhere.to_path_buf()) } else { shared.lock().map(|m| m.clone()).unwrap_or(Mode::Refuse) }; let small = file.is_some_and(|f| ["build.json", "sw.js"].contains(&f)); let (dir, flaky, slow_check) = match mode { Mode::Serve(dir) => (dir, false, false), Mode::Flaky(dir) => (dir, true, false), Mode::SlowCheck(dir) => (dir, false, true), Mode::SlowFiles(dir) => { if !small { slow.fetch_add(1, Ordering::SeqCst); std::thread::sleep(SLOW_FILE); } (dir, false, false) } Mode::FailFast(dir) => { if file == Some("index.html") { return; } if !small { std::thread::sleep(SLOW_FILE); } (dir, false, false) } Mode::Mixed(dir, next) => (if file == Some("index.html") { next } else { dir }, false, false), Mode::Refuse => return, Mode::Hang => return hold(shared, |m| matches!(m, Mode::Hang)), }; if flaky && !small { return; } if slow_check && file == Some("build.json") { return hold(shared, |m| matches!(m, Mode::SlowCheck(_))); } let body = file.and_then(|f| std::fs::read(dir.join(f)).ok()); let kind = match file.and_then(|f| f.rsplit('.').next()) { Some("html") => "text/html; charset=utf-8", Some("js") => "text/javascript", Some("json") => "application/json", Some("wasm") => "application/wasm", _ => "application/octet-stream", }; let (status, body) = match body { Some(b) => ("200 OK", b), None => ("404 Not Found", b"not found".to_vec()), }; // GitHub Pages caches everything for ten minutes. The stand-in's shared.js is the exception: // with that, the browser would reuse an older build's copy without asking the worker (a real // build names its files after their contents), and the test checks the worker's choice. let cache = if file == Some("shared.js") { "no-cache" } else { "max-age=600" }; let head = format!("HTTP/1.1 {status}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nCache-Control: {cache}\r\nConnection: close\r\n\r\n", body.len()); let _ = stream.write_all(head.as_bytes()).and_then(|()| stream.write_all(&body)); } // ---- Headless Chrome ---------------------------------------------------------------------------- fn find_chrome() -> Option { if let Some(p) = std::env::var_os("CHROME") { return Some(PathBuf::from(p)); } let installed = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", r"C:\Program Files\Google\Chrome\Application\chrome.exe", r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", ]; if let Some(p) = installed.iter().map(PathBuf::from).find(|p| p.is_file()) { return Some(p); } let path = std::env::var_os("PATH")?; std::env::split_paths(&path) .flat_map(|d| ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"].map(|n| d.join(n))) .find(|p| p.is_file()) } struct Chrome { child: Child, profile: PathBuf, } impl Chrome { fn launch(exe: &Path, profile: &Path) -> Result { let mut c = Command::new(exe); c.arg("--headless=new") .arg(format!("--user-data-dir={}", profile.display())) .args(["--remote-debugging-port=0", "--no-first-run", "--no-default-browser-check", "about:blank"]) .stdout(Stdio::null()) .stderr(Stdio::null()); // Linux CI runners don't allow Chrome's sandbox; the browser only opens this test's pages. if cfg!(target_os = "linux") { c.arg("--no-sandbox"); } let child = c.spawn().map_err(|e| format!("{}: {e}", exe.display()))?; Ok(Chrome { child, profile: profile.to_path_buf() }) } /// The DevTools connection to Chrome's page. fn connect(&self) -> Result { let port = self.port()?; let list: Value = serde_json::from_slice(&http(port, "GET", "/json/list")?).map_err(|e| format!("/json/list: {e}"))?; let page = list.as_array().and_then(|l| l.iter().find(|t| t["type"] == "page")).ok_or("Chrome has no page to drive")?; Cdp::to_target(port, page) } /// The DevTools connection to a new tab. fn new_tab(&self) -> Result { let port = self.port()?; let tab: Value = serde_json::from_slice(&http(port, "PUT", "/json/new?about:blank")?).map_err(|e| format!("/json/new: {e}"))?; Cdp::to_target(port, &tab) } fn port(&self) -> Result { let t = Instant::now(); loop { // Chrome writes its DevTools port here once it listens. let file = std::fs::read_to_string(self.profile.join("DevToolsActivePort")).unwrap_or_default(); if let Some(port) = file.lines().next().and_then(|l| l.trim().parse::().ok()) { return Ok(port); } if t.elapsed() > Duration::from_secs(30) { return Err("Chrome didn't start its DevTools server within 30 s".into()); } std::thread::sleep(Duration::from_millis(100)); } } } impl Drop for Chrome { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } fn http(port: u16, method: &str, path: &str) -> Result, String> { let mut s = TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("DevTools: {e}"))?; s.set_read_timeout(Some(Duration::from_secs(10))).map_err(|e| e.to_string())?; write!(s, "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n").map_err(|e| format!("DevTools: {e}"))?; let mut r = BufReader::new(s); let mut length = 0; loop { let mut line = String::new(); r.read_line(&mut line).map_err(|e| format!("DevTools: {e}"))?; if line == "\r\n" || line.is_empty() { break; } if let Some((k, v)) = line.split_once(':') && k.eq_ignore_ascii_case("content-length") { length = v.trim().parse().map_err(|_| "DevTools: bad Content-Length")?; } } let mut body = vec![0; length]; r.read_exact(&mut body).map_err(|e| format!("DevTools: {e}"))?; Ok(body) } /// A minimal DevTools protocol client: JSON messages over a WebSocket. struct Cdp { stream: TcpStream, next: u64, } impl Cdp { /// Connect to a target from Chrome's `/json` listing. fn to_target(port: u16, target: &Value) -> Result { let ws = target["webSocketDebuggerUrl"].as_str().ok_or("DevTools target without a WebSocket URL")?; let path = ws.split_once("//").and_then(|(_, rest)| rest.find('/').map(|i| &rest[i..])).ok_or("bad DevTools URL")?; Cdp::connect(port, path) } fn connect(port: u16, path: &str) -> Result { let mut stream = TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("DevTools: {e}"))?; stream.set_read_timeout(Some(LAUNCH_TIMEOUT)).map_err(|e| e.to_string())?; write!( stream, "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\ Sec-WebSocket-Key: c29saWRjcmFmdC10ZXN0IQ==\r\nSec-WebSocket-Version: 13\r\n\r\n" ) .map_err(|e| format!("DevTools: {e}"))?; let mut head = Vec::new(); let mut byte = [0u8; 1]; while !head.ends_with(b"\r\n\r\n") { stream.read_exact(&mut byte).map_err(|e| format!("DevTools handshake: {e}"))?; head.push(byte[0]); if head.len() > 8192 { return Err("DevTools handshake: no end of headers".into()); } } if !head.starts_with(b"HTTP/1.1 101") { return Err(format!("DevTools handshake refused: {}", String::from_utf8_lossy(&head))); } Ok(Cdp { stream, next: 0 }) } fn send_frame(&mut self, opcode: u8, payload: &[u8]) -> Result<(), String> { // Client frames are masked (RFC 6455 §5.3). const MASK: [u8; 4] = [0x5a, 0x17, 0xc3, 0x8e]; let mut frame = vec![0x80 | opcode]; match payload.len() { n if n < 126 => frame.push(0x80 | n as u8), n if n <= 0xffff => { frame.push(0x80 | 126); frame.extend((n as u16).to_be_bytes()); } n => { frame.push(0x80 | 127); frame.extend((n as u64).to_be_bytes()); } } frame.extend(MASK); frame.extend(payload.iter().enumerate().map(|(i, b)| b ^ MASK[i % 4])); self.stream.write_all(&frame).map_err(|e| format!("DevTools: {e}")) } fn recv(&mut self) -> Result { let mut message = Vec::new(); loop { let mut h = [0u8; 2]; self.stream.read_exact(&mut h).map_err(|e| format!("DevTools: {e}"))?; let (fin, opcode) = (h[0] & 0x80 != 0, h[0] & 0x0f); let len = match h[1] & 0x7f { 126 => { let mut b = [0u8; 2]; self.stream.read_exact(&mut b).map_err(|e| format!("DevTools: {e}"))?; u64::from(u16::from_be_bytes(b)) } 127 => { let mut b = [0u8; 8]; self.stream.read_exact(&mut b).map_err(|e| format!("DevTools: {e}"))?; u64::from_be_bytes(b) } n => u64::from(n), }; if len > 64 << 20 { return Err("DevTools: message too large".into()); } let mut mask = [0u8; 4]; if h[1] & 0x80 != 0 { self.stream.read_exact(&mut mask).map_err(|e| format!("DevTools: {e}"))?; } let mut payload = vec![0u8; len as usize]; self.stream.read_exact(&mut payload).map_err(|e| format!("DevTools: {e}"))?; payload.iter_mut().enumerate().for_each(|(i, b)| *b ^= mask[i % 4]); match opcode { 0x8 => return Err("DevTools closed the connection".into()), 0x9 => self.send_frame(0xa, &payload)?, 0x0..=0x2 => { message.extend(payload); if fin { return String::from_utf8(message).map_err(|e| format!("DevTools: {e}")); } } _ => {} } } } /// Call a DevTools method and return its result. fn call(&mut self, method: &str, params: Value) -> Result { self.next += 1; let id = self.next; self.send_frame(0x1, json!({ "id": id, "method": method, "params": params }).to_string().as_bytes())?; loop { let reply: Value = serde_json::from_str(&self.recv()?).map_err(|e| format!("DevTools: {e}"))?; if reply["id"] == id { if let Some(e) = reply.get("error") { return Err(format!("{method}: {e}")); } return Ok(reply["result"].clone()); } } } /// Evaluate a JavaScript expression in the page (awaiting a promise) and return its value. fn eval(&mut self, expr: &str) -> Result { let r = self.call("Runtime.evaluate", json!({ "expression": expr, "awaitPromise": true, "returnByValue": true }))?; if let Some(e) = r.get("exceptionDetails") { return Err(e["exception"]["description"].as_str().unwrap_or("exception").to_string()); } Ok(r["result"]["value"].clone()) } } #[cfg(test)] mod tests { use super::*; #[test] fn the_registration_comes_from_the_web_apps_page() { let s = registration_script(&crate::root()).unwrap_or_default(); assert!(s.starts_with("") && s.contains("register(\"sw.js\")"), "{s}"); } #[test] fn mark_tags_the_html_element() { assert_eq!(mark("\n", "v2").ok().as_deref(), Some("\n")); assert!(mark("", "v1").is_err()); } }