Make the web app work offline and update itself (#239)

This commit is contained in:
Jan Szumiec authored and GitHub committed 2026-10-13 23:40:19 -04:00
1 parent 09b5da3e9f
commit 4bea4c7785
14 files changed
+1246 -6

No files matched your search

+2
View File
@@ -100,6 +100,8 @@ jobs:
run: cargo xtask licences --check
- name: Dependency layers
run: cargo xtask layers
- name: Web app offline use and updates (headless Chrome)
run: cargo xtask web-offline-test
clippy:
name: Native Linux Clippy
+2
View File
@@ -50,6 +50,8 @@ jobs:
- name: Build the web app
working-directory: apps/solidcraft-web
run: trunk build --release --public-url /solidcraft/app/
- name: Test the web app offline and across an update (headless Chrome)
run: cargo xtask web-offline-test --site dist/web
- name: Assemble the site
run: |
mkdir -p site
+4 -1
View File
@@ -75,7 +75,10 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.start(id)`. Colours come from `theme::Tokens`.
- **Rust only.** Units are millimetres and radians internally; Z is up.
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
assets, layers). Commit after every feature arc that builds.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
+4 -1
View File
@@ -75,7 +75,10 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.start(id)`. Colours come from `theme::Tokens`.
- **Rust only.** Units are millimetres and radians internally; Z is up.
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
assets, layers). Commit after every feature arc that builds.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
Generated
+1
View File
@@ -4865,6 +4865,7 @@ name = "xtask"
version = "0.9.0"
dependencies = [
"serde_json",
"sha2",
]
[[package]]
+6
View File
@@ -8,3 +8,9 @@ filehash = true
[serve]
addresses = ["127.0.0.1"]
port = 8781
# Offline use and updates: release builds get sw.js listing this build's files (xtask/src/web_offline.rs).
[[hooks]]
stage = "post_build"
command = "cargo"
command_arguments = ["xtask", "web-offline"]
+6
View File
@@ -32,5 +32,11 @@
<body>
<canvas id="solidcraft_canvas"></canvas>
<div id="solidcraft_loading">Loading SolidCraft&hellip;</div>
<script>
// Offline use and updates (sw.js; written into release builds by `cargo xtask web-offline`).
if ("serviceWorker" in navigator) {
addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(() => {}));
}
</script>
</body>
</html>
+227
View File
@@ -0,0 +1,227 @@
// SolidCraft's service worker: the web app keeps working offline and updates itself.
//
// `cargo xtask web-offline` (Trunk's post_build hook) writes the build into the line below: the
// version (workspace version + a hash of every built file) and the files to keep, each with its
// SHA-256, and the same as `build.json` next to it. Files the app only fetches when it needs
// them (the CJK font) aren't kept: they come from the network. Every download is checked
// against its SHA-256: a deployment that lands mid-download (another build's `index.html`) fails
// the download instead of mixing into the kept build. A new build changes this file, so the
// browser installs the new worker, which keeps the whole new build before taking over.
//
// A launch only ever starts a build whose every file is kept, so a half-downloaded update can't
// break it. It asks for `build.json` (waiting at most NAVIGATE_TIMEOUT_MS): offline, slow or
// unchanged, the kept build starts; when a new build is deployed, it is downloaded first
// (waiting at most UPDATE_TIMEOUT_MS; the download goes on in the background after that) and
// started. When the site has no `build.json` (the app was removed), launches go to the network,
// the kept build answering when that fails. Each page then gets its files from the build it
// started (remembered in PAGES), never from another build with a file of the same name.
//
// A cache is only deleted when nothing uses it: a download holds its lock (Web Locks, shared,
// in any tab or worker; the browser releases it when the download ends or its worker stops), and
// an open page uses the build it started. Then unfinished downloads are dropped, and so are
// builds replaced by a newer worker (RETIRED). The origin is shared with other apps (github.io)
// and other copies of this one (the release zip works from any path): only caches named after
// this worker's path (CACHE_PREFIX…, PAGES) are ever touched.
//
// Unbuilt (VERSION "dev", as debug builds get it), the worker retires: it drops what a release
// build kept and unregisters, so `trunk serve` after `trunk serve --release --no-autoreload`
// shows the development build. Deploying this file unbuilt switches the worker off for everyone.
/* @build */ const VERSION = "dev", FILES = [];
const RETIRE = VERSION === "dev";
const PATH = new URL(self.registration.scope).pathname;
const CACHE_PREFIX = `solidcraft-app ${PATH} `;
const CACHE = CACHE_PREFIX + VERSION;
/** Which build each open page started: page id → cache name (or NETWORK). */
const PAGES = `solidcraft-app-pages ${PATH}`;
/** A page that came from the network gets its files from there too. */
const NETWORK = "network";
const NAVIGATE_TIMEOUT_MS = 4000;
const UPDATE_TIMEOUT_MS = 20000;
/** The entry that marks a cache as holding its whole build. */
const COMPLETE = "build-complete";
/** The entry that marks a build replaced by a newer worker. */
const RETIRED = "build-retired";
/** A page remembered this recently may still be loading (not yet listed among the clients). */
const PAGE_LOADING_MS = 30 * 1000;
const url = (file) => new URL(file, self.registration.scope).href;
const has = (name, entry) => caches.match(url(entry), { cacheName: name }).then(Boolean);
/** Run `fn` while using the cache `name`: it isn't deleted meanwhile. */
const using = (name, fn) => navigator.locks.request(name, { mode: "shared" }, fn);
/** Delete the cache `name` if nothing is using it and `unwanted()` (asked once no one can start to) agrees. */
const drop = (name, unwanted) =>
navigator.locks.request(name, { ifAvailable: true }, async (free) => free && (await unwanted()) && caches.delete(name));
const hex = (buffer) => Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, "0")).join("");
/**
* Keep every file of a build (an earlier partial download is resumed); resolves to its cache's
* name. The first file that fails cancels the others.
*/
function keep(version, files) {
const name = CACHE_PREFIX + version;
return using(name, async () => {
const cache = await caches.open(name);
await cache.delete(url(RETIRED)); // Back in use (a rollback).
if (await has(name, COMPLETE)) return name;
const cancel = new AbortController();
const results = await Promise.allSettled(
files.map(async ({ file, sha256 }) => {
try {
if (await cache.match(url(file))) return;
// `no-cache`: revalidate with the server, so a stale HTTP-cached page never mixes builds.
const response = await fetch(new Request(url(file), { cache: "no-cache" }), { signal: cancel.signal });
if (!response.ok) throw new Error(`${file}: HTTP ${response.status}`);
const body = await response.arrayBuffer();
if (hex(await crypto.subtle.digest("SHA-256", body)) !== sha256) throw new Error(`${file}: not this build's`);
await cache.put(url(file), new Response(body, { headers: response.headers }));
} catch (error) {
cancel.abort();
throw error;
}
}),
);
const failed = results.find((r) => r.status === "rejected");
if (failed) throw failed.reason;
await cache.put(url(COMPLETE), new Response(version));
return name;
});
}
const pageEntry = (id) => url(`~page/${encodeURIComponent(id)}`);
/** Remember which build a page started. */
async function started(id, build) {
if (!id) return;
const record = JSON.stringify({ build, at: Date.now() });
await (await caches.open(PAGES)).put(pageEntry(id), new Response(record));
}
/** The build a page started; a page started before it was remembered gets this worker's. */
async function startedBy(id) {
const response = id ? await caches.match(pageEntry(id), { cacheName: PAGES }) : null;
return response ? (await response.json()).build : CACHE;
}
/** The builds open pages use (pages closed since are forgotten). */
async function inUse() {
const open = new Set((await self.clients.matchAll({ includeUncontrolled: true })).map((c) => c.id));
const pages = await caches.open(PAGES);
const builds = new Set();
for (const request of await pages.keys()) {
const id = decodeURIComponent(request.url.split("~page/")[1] ?? "");
const record = await pages.match(request).then((r) => r?.json()).catch(() => null);
if (record && (open.has(id) || Date.now() - record.at < PAGE_LOADING_MS)) builds.add(record.build);
else await pages.delete(request);
}
return builds;
}
/** Drop unfinished downloads nothing is using, and replaced builds no open page uses. */
async function sweep() {
for (const name of await caches.keys()) {
if (!name.startsWith(CACHE_PREFIX) || name === CACHE) continue;
await drop(name, async () => !(await has(name, COMPLETE)) || ((await has(name, RETIRED)) && !(await inUse()).has(name)));
}
}
self.addEventListener("install", (event) => {
// Clean up first: a page open for days sees updates (and failed ones) without any launch.
const install = () => sweep().catch(() => {}).then(() => keep(VERSION, FILES));
event.waitUntil(RETIRE ? self.skipWaiting() : install().then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
if (RETIRE) {
for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX) || name === PAGES) await caches.delete(name);
return self.registration.unregister();
}
for (const name of await caches.keys()) {
if (name.startsWith(CACHE_PREFIX) && name !== CACHE) await (await caches.open(name)).put(url(RETIRED), new Response(""));
}
await sweep();
await self.clients.claim();
})(),
);
});
/** The promise's value, or null when it fails or takes longer than `ms`. */
function within(ms, promise) {
return Promise.race([promise.catch(() => null), new Promise((resolve) => setTimeout(resolve, ms, null))]);
}
/** No app is deployed here any more (the server says `build.json` doesn't exist). */
const GONE = "gone";
/** The deployed build's version and files, GONE, or null (unknown: offline, failing, invalid). */
async function deployed() {
const response = await fetch(url("build.json"), { cache: "no-cache" });
if (response.status === 404 || response.status === 410) return GONE;
const build = response.ok ? await response.json() : null;
const valid = build && typeof build.version === "string" && Array.isArray(build.files);
return valid && build.files.every((f) => typeof f.file === "string" && typeof f.sha256 === "string") ? build : null;
}
/** Keep the worker alive for `promise` (allowed while the launch is being answered). */
function extend(event, promise) {
try {
event.waitUntil(promise.catch(() => {}));
} catch {
// The event has ended; the work goes on while the worker runs.
}
}
/** A launch: the start page of a complete build (whatever query the URL has: `?sample`), and that build. */
async function launch(event) {
const kept = await caches.match(url("index.html"), { cacheName: CACHE });
if (!kept) return [await fetch(event.request), NETWORK]; // Nothing kept yet (the first visit).
extend(event, sweep());
const build = await within(NAVIGATE_TIMEOUT_MS, deployed());
if (build === GONE) {
const page = await fetch(event.request).catch(() => null);
return page && page.ok ? [page, NETWORK] : [kept, CACHE];
}
if (!build || build.version === VERSION) return [kept, CACHE];
const download = keep(build.version, build.files);
extend(event, download);
const name = await within(UPDATE_TIMEOUT_MS, download);
const page = name && (await caches.match(url("index.html"), { cacheName: name }));
return page ? [page, name] : [kept, CACHE];
}
/**
* A launch that fails unexpectedly still starts the kept build (or asks the network). A newer
* worker only takes over once the launch has ended, by when its page is remembered (in use).
*/
async function launchOrKept(event) {
let page, build;
try {
[page, build] = await launch(event);
} catch {
page = await caches.match(url("index.html"), { cacheName: CACHE }).catch(() => null);
build = page ? CACHE : NETWORK;
page = page || (await fetch(event.request));
}
await started(event.resultingClientId, build).catch(() => {});
return page;
}
/** A page's files come from the build it started, else the network. */
async function asset(event) {
const build = await startedBy(event.clientId).catch(() => CACHE);
const kept = build === NETWORK ? null : await caches.match(event.request, { cacheName: build, ignoreSearch: true });
return kept || fetch(event.request);
}
self.addEventListener("fetch", (event) => {
const request = event.request;
if (RETIRE || request.method !== "GET" || new URL(request.url).origin !== self.location.origin) return;
const path = request.url.split(/[?#]/)[0];
const start = request.mode === "navigate" && (path === url("") || path === url("index.html"));
event.respondWith(start ? launchOrKept(event) : asset(event));
});
+11 -2
View File
@@ -74,13 +74,22 @@ doesn't update automatically.
The same app runs in the browser: [open SolidCraft on the web](../app/) (`?sample` opens the sample
part, `?webgl` forces WebGL2 where WebGPU is missing). Designs are saved in the browser's private
file storage, which needs HTTPS or localhost. To build it yourself:
file storage, which needs HTTPS or localhost.
After the first visit the web app works offline: the browser keeps a copy of it. Online, each
launch gets the latest published build (the browser downloads it once when it changes); offline,
when the network doesn't answer within a few seconds, or when a new build can't be downloaded
whole, the kept copy starts (and the download carries on for the next launch). To build it
yourself:
```sh
rustup target add wasm32-unknown-unknown
cd apps/solidcraft-web && trunk serve --release
cd apps/solidcraft-web && trunk serve --release --no-autoreload
```
Offline use needs `--no-autoreload`: Trunk's live reload changes the page as it serves it, so it
no longer matches the build the browser would keep. Debug builds (`trunk serve`) never keep a copy.
## CLI
`solidcraft-cli` runs the same commands without a window:
+5
View File
@@ -27,6 +27,11 @@ repository under `/solidcraft/`. Do not add a separate owner-site publisher or p
old-name Pages/Flatpak aliases. GitHub repository redirects are separate from Pages hosting;
verify the new project URLs after deployment.
The browser app keeps itself for offline use (`apps/solidcraft-web/sw.js`). Browsers keep that
service worker after the code that registered it is gone, so to switch it off deploy
`apps/solidcraft-web/sw.js` unbuilt (as `trunk serve` builds do): it deletes what it kept and
unregisters. A site without `build.json` is also safe: launches then go to the network.
## Nightly prereleases
`release.yml` also runs at midnight in `America/New_York` (Eastern time, following daylight
+1
View File
@@ -7,3 +7,4 @@ publish = false
[dependencies]
serde_json = { workspace = true }
sha2 = { workspace = true }
+17 -2
View File
@@ -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(())
}
+175
View File
@@ -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");
}
}
+785
View File
@@ -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 { &registration })?;
}
"['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());
}
}