Make the web app work offline and update itself (#239)
This commit is contained in:
1 parent
09b5da3e9f
commit
4bea4c7785
14 files changed
+1246
-6
No files matched your search
@@ -7,3 +7,4 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
+17
-2
@@ -1,6 +1,6 @@
|
||||
//! Workspace tooling: `cargo xtask <command>`.
|
||||
//!
|
||||
//! Pure Rust (std + serde_json). External tools (`cargo`) are invoked through
|
||||
//! Pure Rust (std, serde_json and sha2). External tools (`cargo`) are invoked through
|
||||
//! `std::process::Command`.
|
||||
|
||||
mod assets;
|
||||
@@ -10,6 +10,8 @@ mod i18n;
|
||||
mod layers;
|
||||
mod licences;
|
||||
mod test;
|
||||
mod web_offline;
|
||||
mod web_offline_test;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
@@ -45,8 +47,15 @@ commands:
|
||||
build the tests with the ci profile, then run every test binary at once
|
||||
(and the doc tests alongside)
|
||||
wasm cargo check every library crate and the web app for wasm32-unknown-unknown
|
||||
web-offline [DIR]
|
||||
write the web build's service worker (sw.js: offline use, updates); run by
|
||||
Trunk after release builds of apps/solidcraft-web, or on a built site in DIR
|
||||
web-offline-test [--site DIR]
|
||||
check in headless Chrome that the web app works offline and updates to a new
|
||||
build (a stand-in app, or the web build in DIR such as dist/web)
|
||||
ci fmt --check, assets, licences --check, layers, clippy -D warnings, then the tests
|
||||
with the wasm check alongside (stops at the first failing step)
|
||||
with the wasm check alongside, then web-offline-test when Chrome is installed
|
||||
(stops at the first failing step)
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -69,6 +78,11 @@ fn main() -> ExitCode {
|
||||
}
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("web-offline") => web_offline::run(&root(), rest.first().copied()),
|
||||
Some("web-offline-test") => {
|
||||
let site = rest.iter().position(|a| *a == "--site").and_then(|i| rest.get(i + 1)).copied();
|
||||
web_offline_test::run(&root(), site, true)
|
||||
}
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
@@ -340,6 +354,7 @@ fn cmd_ci() -> Result<(), String> {
|
||||
run(c, "cargo clippy -D warnings")?;
|
||||
let (wasm, n) = wasm_check()?;
|
||||
test::run(&[], &[], vec![(format!("wasm32-unknown-unknown check ({n} crates)"), wasm), test::doc_tests(&[], &[])])?;
|
||||
web_offline_test::run(&root(), None, false)?;
|
||||
eprintln!("ci: all gates passed");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! `cargo xtask web-offline [DIR]`: let a web build work offline and update itself.
|
||||
//!
|
||||
//! Runs as Trunk's post_build hook (apps/solidcraft-web/Trunk.toml) on the staging directory
|
||||
//! (`TRUNK_STAGING_DIR`), or on DIR. It writes `sw.js` from apps/solidcraft-web/sw.js with this
|
||||
//! build's version and files (each with its SHA-256, which the worker checks every download
|
||||
//! against, so a deployment that changes mid-download never mixes into a kept build), and the
|
||||
//! same as `build.json` (which launches check to find a newly deployed build). The version is the
|
||||
//! workspace version plus a hash of all that, so any change to the build changes `sw.js`, which
|
||||
//! is what makes browsers install the update.
|
||||
//! Debug builds (`trunk serve`) get the unbuilt template instead, a worker that keeps nothing and
|
||||
//! retires one a release build left on the same origin.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// The template line the build is written into.
|
||||
const MARKER: &str = "/* @build */";
|
||||
const SW: &str = "sw.js";
|
||||
const BUILD: &str = "build.json";
|
||||
/// Files the app fetches only when it needs them (the CJK font, ~4.7 MB: `JAPANESE_FONT_PATH`
|
||||
/// in apps/solidcraft-web/src/main.rs): not kept for offline use, so not every user downloads
|
||||
/// them. The worker leaves them to the network.
|
||||
const ON_DEMAND: &[&str] = &["fonts/"];
|
||||
/// The version that makes sw.js retire (the template's own).
|
||||
const RETIRE_VERSION: &str = "dev";
|
||||
|
||||
pub fn run(root: &Path, dir: Option<&str>) -> Result<(), String> {
|
||||
let dir = match dir {
|
||||
Some(d) => PathBuf::from(d),
|
||||
None => {
|
||||
let dir = PathBuf::from(
|
||||
std::env::var("TRUNK_STAGING_DIR").map_err(|_| "web-offline: give the built site's directory (or run as Trunk's hook)")?,
|
||||
);
|
||||
if std::env::var("TRUNK_PROFILE").is_ok_and(|p| p != "release") {
|
||||
retire(root, &dir)?;
|
||||
println!("web-offline: debug build, {SW} retires a kept release build");
|
||||
return Ok(());
|
||||
}
|
||||
dir
|
||||
}
|
||||
};
|
||||
let (version, n) = generate(root, &dir)?;
|
||||
println!("web-offline: {SW} keeps {n} files (version {version})");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `dir/sw.js` and `dir/build.json` for the site in `dir`; returns its version and how many
|
||||
/// files it keeps.
|
||||
pub fn generate(root: &Path, dir: &Path) -> Result<(String, usize), String> {
|
||||
let files = list(dir)?;
|
||||
if !files.iter().any(|(name, _)| name == "index.html") {
|
||||
return Err(format!("web-offline: {} has no index.html", dir.display()));
|
||||
}
|
||||
let manifest: Vec<Value> = files.iter().map(|(name, bytes)| json!({ "file": name, "sha256": sha256(bytes) })).collect();
|
||||
let manifest = Value::Array(manifest);
|
||||
let digest = sha256(manifest.to_string().as_bytes());
|
||||
let version = format!("{}-{}", env!("CARGO_PKG_VERSION"), digest.get(..16).unwrap_or(&digest));
|
||||
let template = template(root)?;
|
||||
let sw = render(&template, &version, &manifest)?;
|
||||
std::fs::write(dir.join(SW), sw).map_err(|e| format!("{}: {e}", dir.join(SW).display()))?;
|
||||
let build = json!({ "version": version, "files": manifest }).to_string();
|
||||
std::fs::write(dir.join(BUILD), build).map_err(|e| format!("{}: {e}", dir.join(BUILD).display()))?;
|
||||
Ok((version, files.len()))
|
||||
}
|
||||
|
||||
/// Write `dir/sw.js` as the worker that keeps nothing and retires a kept release build.
|
||||
pub fn retire(root: &Path, dir: &Path) -> Result<(), String> {
|
||||
let template = template(root)?;
|
||||
let sw = render(&template, RETIRE_VERSION, &json!([]))?;
|
||||
std::fs::write(dir.join(SW), sw).map_err(|e| format!("{}: {e}", dir.join(SW).display()))
|
||||
}
|
||||
|
||||
fn template(root: &Path) -> Result<String, String> {
|
||||
let path = root.join("apps/solidcraft-web").join(SW);
|
||||
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Every file under `dir` (but `sw.js`, `build.json` and ON_DEMAND ones) as (URL path relative to `dir`, contents), sorted.
|
||||
fn list(dir: &Path) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
fn walk(dir: &Path, prefix: &str, out: &mut Vec<(String, Vec<u8>)>) -> Result<(), String> {
|
||||
let rd = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
for e in rd {
|
||||
let e = e.map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let name = format!("{prefix}{}", e.file_name().to_string_lossy());
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
walk(&p, &format!("{name}/"), out)?;
|
||||
} else if name != SW && name != BUILD && !ON_DEMAND.iter().any(|p| name.starts_with(p)) {
|
||||
out.push((name, std::fs::read(&p).map_err(|e| format!("{}: {e}", p.display()))?));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(dir, "", &mut out)?;
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Lower-case hex SHA-256, as the worker's `crypto.subtle.digest` check computes it.
|
||||
fn sha256(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// The template with its marker line replaced by the version and files.
|
||||
fn render(template: &str, version: &str, files: &Value) -> Result<String, String> {
|
||||
let build = format!("{MARKER} const VERSION = {}, FILES = {files};", Value::from(version));
|
||||
let mut found = false;
|
||||
let lines: Vec<&str> = template
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.starts_with(MARKER) && !found {
|
||||
found = true;
|
||||
build.as_str()
|
||||
} else {
|
||||
l
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !found {
|
||||
return Err(format!("web-offline: the {SW} template has no `{MARKER}` line"));
|
||||
}
|
||||
Ok(lines.join("\n") + "\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn render_replaces_the_marker_line_only() {
|
||||
let t = "// head\n/* @build */ const VERSION = \"dev\", FILES = [];\nconst X = 1;\n";
|
||||
let files = json!([{ "file": "a \"b\".js", "sha256": "00" }]);
|
||||
let out = render(t, "0.3.2-00ff", &files).unwrap_or_default();
|
||||
assert_eq!(
|
||||
out,
|
||||
"// head\n/* @build */ const VERSION = \"0.3.2-00ff\", FILES = [{\"file\":\"a \\\"b\\\".js\",\"sha256\":\"00\"}];\nconst X = 1;\n"
|
||||
);
|
||||
assert!(render("no marker\n", "v", &json!([])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_template_has_the_marker() {
|
||||
let t = std::fs::read_to_string(crate::root().join("apps/solidcraft-web").join(SW)).unwrap_or_default();
|
||||
assert!(render(&t, "v", &json!(["x"])).is_ok_and(|s| s.contains("FILES = [\"x\"]")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_is_lower_case_hex() {
|
||||
// FIPS 180-2's "abc" example.
|
||||
assert_eq!(sha256(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_lists_every_file_with_its_digest() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-web-offline-unit-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let ok = std::fs::create_dir_all(dir.join("snippets"))
|
||||
.and_then(|()| std::fs::create_dir_all(dir.join("fonts")))
|
||||
.and_then(|()| std::fs::write(dir.join("fonts/big.ttf"), "on demand"))
|
||||
.and_then(|()| std::fs::write(dir.join("index.html"), "<html>"))
|
||||
.and_then(|()| std::fs::write(dir.join("snippets/a.js"), "abc"))
|
||||
.and_then(|()| std::fs::write(dir.join(SW), "old"));
|
||||
assert!(ok.is_ok());
|
||||
let (version, n) = generate(&crate::root(), &dir).unwrap_or_default();
|
||||
let build: Value = std::fs::read(dir.join(BUILD)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(n, 2, "on-demand files aren't listed");
|
||||
assert_eq!(build["version"], version.as_str());
|
||||
assert_eq!(build["files"][1], json!({ "file": "snippets/a.js", "sha256": sha256(b"abc") }));
|
||||
assert_eq!(build["files"][0]["file"], "index.html");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
//! `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 `<html>`. 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::{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()?;
|
||||
let state_js = STATE_JS.replace("STARTED", started);
|
||||
let count = std::cell::Cell::new(0);
|
||||
let launch_at = |cdp: &mut Cdp, at: &str, build: &str| -> Result<Duration, String> {
|
||||
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();
|
||||
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())?;
|
||||
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 = [];
|
||||
for (const k of await caches.keys()) {
|
||||
if (!k.startsWith(prefix)) continue;
|
||||
const c = await caches.open(k);
|
||||
caches_[k] = (await c.keys()).length;
|
||||
if (await c.match(new URL('build-complete', location.href).href)) complete.push(k);
|
||||
if (await c.match(new URL('build-retired', location.href).href)) retired.push(k);
|
||||
else kept[k] = caches_[k];
|
||||
}
|
||||
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}"))
|
||||
}
|
||||
|
||||
/// `<html …>` with `data-build="build"`.
|
||||
fn mark(html: &str, build: &str) -> Result<String, String> {
|
||||
if !html.contains("<html") {
|
||||
return Err("index.html has no <html> tag".into());
|
||||
}
|
||||
Ok(html.replacen("<html", &format!("<html data-build=\"{build}\""), 1))
|
||||
}
|
||||
|
||||
/// The service worker registration from the web app's index.html (so the stand-in uses it).
|
||||
fn registration_script(root: &Path) -> Result<String, String> {
|
||||
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("<script>")).ok_or("index.html: no <script> around the registration")?;
|
||||
let end = html.get(at..).and_then(|h| h.find("</script>")).map(|e| at + e + "</script>".len()).ok_or("index.html: unclosed <script>")?;
|
||||
html.get(start..end).map(str::to_string).ok_or_else(|| "index.html: bad registration script".into())
|
||||
}
|
||||
|
||||
/// A stand-in app: a page and a content-named script, as Trunk builds them.
|
||||
fn stand_in(dir: &Path, build: &str, registration: &str) -> Result<(), String> {
|
||||
let write = |name: &str, text: String| std::fs::write(dir.join(name), text).map_err(|e| format!("{name}: {e}"));
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let page = format!(
|
||||
"<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><title>Offline test</title></head>\n<body>\n<script src=\"app-{build}.js\"></script>\n<script src=\"shared.js\"></script>\n{registration}\n</body>\n</html>\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<TempDir, String> {
|
||||
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<Mutex<Mode>>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Serves the app at PREFIX in the server's mode, and `elsewhere` at ELSEWHERE (always online).
|
||||
fn start(elsewhere: PathBuf) -> Result<Server, String> {
|
||||
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 shared = mode.clone();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let (shared, elsewhere) = (shared.clone(), elsewhere.clone());
|
||||
std::thread::spawn(move || answer(stream, &shared, &elsewhere));
|
||||
}
|
||||
});
|
||||
Ok(Server { port, mode })
|
||||
}
|
||||
|
||||
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<Mode>, 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<Mode>, 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 {
|
||||
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<PathBuf> {
|
||||
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<Chrome, String> {
|
||||
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<Cdp, String> {
|
||||
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<Cdp, String> {
|
||||
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<u16, String> {
|
||||
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::<u16>().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<Vec<u8>, 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<Cdp, String> {
|
||||
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<Cdp, String> {
|
||||
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<String, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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("<script>") && s.ends_with("</script>") && s.contains("register(\"sw.js\")"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_tags_the_html_element() {
|
||||
assert_eq!(mark("<!DOCTYPE html>\n<html lang=\"en\">", "v2").ok().as_deref(), Some("<!DOCTYPE html>\n<html data-build=\"v2\" lang=\"en\">"));
|
||||
assert!(mark("<body>", "v1").is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user