use std::path::Path;
use super::emit::Plan;
use super::schema_edit::update_main_rs;
use super::system_test::patch_cargo_toml as patch_system_test_cargo_toml;
use super::{GenerateError, ensure_project_root};
fn plan_pwa_shared(project_root: &Path) -> Plan {
let mut plan = Plan::new(project_root);
plan.create(
project_root.join("static").join("manifest.webmanifest"),
render_manifest(),
);
plan.create(
project_root.join("static").join("service-worker.js"),
render_service_worker(),
);
plan.create(
project_root.join("static").join("pwa-register.js"),
render_pwa_register_js(),
);
plan.create(
project_root.join("static").join("icons").join("icon.svg"),
render_icon_svg(),
);
plan.create(
project_root
.join("static")
.join("icons")
.join("maskable-icon.svg"),
render_maskable_icon_svg(),
);
let main_path = project_root.join("src").join("main.rs");
plan.push_revert(crate::generate::emit::Revert::PwaMainRsInjection {
path: main_path.clone(),
});
plan.push_revert(crate::generate::emit::Revert::RoutesEntries {
path: main_path,
entries: vec![
"pwa_manifest".to_owned(),
"pwa_service_worker".to_owned(),
"pwa_register_js".to_owned(),
"pwa_offline".to_owned(),
],
});
let system_test_path = project_root
.join("tests")
.join("system")
.join("pwa_smoke.rs");
plan.create(system_test_path, render_pwa_system_test());
let cargo_path = project_root.join("Cargo.toml");
plan.push_revert(crate::generate::emit::Revert::SystemTestCargoPatch {
path: cargo_path,
snake_name: "pwa_smoke".to_owned(),
});
plan
}
pub fn plan_pwa(project_root: &Path) -> Result<Plan, GenerateError> {
ensure_project_root(project_root)?;
let main_path = project_root.join("src").join("main.rs");
let main_existing = std::fs::read_to_string(&main_path).map_err(|_| {
GenerateError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("missing {}", main_path.display()),
))
})?;
if layout_missing_current_path(&main_existing) {
return Err(GenerateError::Config(format!(
"{}'s layout() takes fewer than 4 parameters — the current \
nav_bar-based scaffold needs layout(title: &str, current_path: &str, \
flash: Markup, content: Markup) (see autumn-cli/src/templates/main.rs.tmpl \
for the current shape; the path parameter's name doesn't matter) \
before running `autumn generate pwa`",
main_path.display()
)));
}
let mut plan = plan_pwa_shared(project_root);
let updated_main = inject_pwa_into_main(&main_existing);
if updated_main != main_existing {
plan.modify(main_path, updated_main);
}
let cargo_path = project_root.join("Cargo.toml");
let cargo_existing = std::fs::read_to_string(&cargo_path).map_err(GenerateError::Io)?;
let patched_cargo = patch_system_test_cargo_toml(&cargo_existing, "pwa_smoke");
if patched_cargo != cargo_existing {
plan.modify(cargo_path, patched_cargo);
}
Ok(plan)
}
pub fn plan_pwa_destroy_fallback(project_root: &Path) -> Result<Plan, GenerateError> {
ensure_project_root(project_root)?;
Ok(plan_pwa_shared(project_root))
}
fn render_manifest() -> String {
concat!(
"{\n",
" \"name\": \"My App\",\n",
" \"short_name\": \"My App\",\n",
" \"description\": \"Built with Autumn\",\n",
" \"start_url\": \"/\",\n",
" \"display\": \"standalone\",\n",
" \"background_color\": \"#ffffff\",\n",
" \"theme_color\": \"#ffffff\",\n",
" \"icons\": [\n",
" {\n",
" \"src\": \"/static/icons/icon.svg\",\n",
" \"sizes\": \"any\",\n",
" \"type\": \"image/svg+xml\",\n",
" \"purpose\": \"any\"\n",
" },\n",
" {\n",
" \"src\": \"/static/icons/maskable-icon.svg\",\n",
" \"sizes\": \"any\",\n",
" \"type\": \"image/svg+xml\",\n",
" \"purpose\": \"maskable\"\n",
" }\n",
" ]\n",
"}\n",
)
.to_owned()
}
fn render_service_worker() -> String {
r"const CACHE_NAME = 'autumn-pwa-v1';
const OFFLINE_URL = '/offline';
const PRECACHE_URLS = [OFFLINE_URL];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((names) => Promise.all(
names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n))
))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
}
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() =>
caches.match(OFFLINE_URL).then((r) => r || new Response('Offline', { status: 503 }))
)
);
return;
}
if (event.request.url.includes('/static/')) {
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
}
return response;
});
})
);
}
});
"
.to_owned()
}
fn render_icon_svg() -> String {
concat!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 192 192\">\n",
" <!-- Replace this placeholder with your app icon (PNG recommended for broad compatibility) -->\n",
" <rect width=\"192\" height=\"192\" rx=\"24\" fill=\"#4F7942\"/>\n",
" <text x=\"96\" y=\"140\" font-size=\"110\" text-anchor=\"middle\" font-family=\"system-ui\">🍂</text>\n",
"</svg>\n",
)
.to_owned()
}
fn render_maskable_icon_svg() -> String {
concat!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 192 192\">\n",
" <!-- Maskable icon: keep important content within the inner 116x116 safe zone -->\n",
" <!-- Replace this placeholder with your app icon (PNG recommended for broad compatibility) -->\n",
" <rect width=\"192\" height=\"192\" fill=\"#4F7942\"/>\n",
" <text x=\"96\" y=\"124\" font-size=\"72\" text-anchor=\"middle\" font-family=\"system-ui\">🍂</text>\n",
"</svg>\n",
)
.to_owned()
}
fn render_pwa_register_js() -> String {
"if('serviceWorker'in navigator)\
navigator.serviceWorker\
.register('/service-worker.js',{scope:'/'})\
.then(()=>document.documentElement.dataset.swRegistered='true')\
.catch(console.error);\n"
.to_owned()
}
fn render_pwa_system_test() -> String {
let manifest_selector = r#"link[rel="manifest"]"#;
let stubs = concat!(
"#[get(\"/manifest.webmanifest\")]\n",
"async fn pwa_manifest() -> impl IntoResponse {\n",
" ([(\"content-type\", \"application/manifest+json\")], \"\")\n",
"}\n",
"\n",
"#[get(\"/service-worker.js\")]\n",
"async fn pwa_service_worker() -> impl IntoResponse {\n",
" (\n",
" [\n",
" (\"content-type\", \"text/javascript; charset=utf-8\"),\n",
" (\"service-worker-allowed\", \"/\"),\n",
" ],\n",
" \"\",\n",
" )\n",
"}\n",
"\n",
"#[get(\"/pwa-register.js\")]\n",
"async fn pwa_register_js() -> impl IntoResponse {\n",
" ([(\"content-type\", \"text/javascript; charset=utf-8\")], \"\")\n",
"}\n",
"\n",
"#[get(\"/offline\")]\n",
"async fn pwa_offline() -> impl IntoResponse {\n",
" autumn_web::reexports::axum::response::Html(\n",
" \"<html><head><link rel=\\\"manifest\\\" href=\\\"/manifest.webmanifest\\\"></head><body></body></html>\",\n",
" )\n",
"}\n",
"\n",
);
format!(
"//! PWA smoke test \u{2014} manifest content-type + service-worker registration.\n\
//!\n\
//! Run with:\n\
//! cargo test --features system-tests --test pwa_smoke -- --include-ignored\n\
\n\
#![cfg(feature = \"system-tests\")]\n\
\n\
use autumn_web::prelude::*;\n\
use autumn_web::system_test::SystemTest;\n\
\n\
{stubs}\
/// Checks that `GET /manifest.webmanifest` returns `application/manifest+json`\n\
/// and that the `<link rel=\"manifest\">` tag is present in the page DOM.\n\
#[tokio::test]\n\
#[ignore = \"requires Chromium; run with --include-ignored\"]\n\
async fn pwa_manifest_loads_with_correct_content_type() {{\n\
let runner = SystemTest::new()\n\
.routes(routes![pwa_manifest, pwa_service_worker, pwa_register_js, pwa_offline])\n\
.build()\n\
.await\n\
.expect(\"test runner\");\n\
let base_url = runner.base_url();\n\
let page = runner.page().await.expect(\"page\");\n\
\n\
// Verify HTTP content-type via raw TCP to avoid a reqwest dev-dependency.\n\
{{\n\
use std::io::{{Read, Write}};\n\
let host_port = base_url\n\
.trim_start_matches(\"http://\")\n\
.trim_start_matches(\"https://\");\n\
let mut stream = std::net::TcpStream::connect(host_port)\n\
.expect(\"connect to test server\");\n\
let req = format!(\"GET /manifest.webmanifest HTTP/1.1\\r\\nHost: {{host_port}}\\r\\nConnection: close\\r\\n\\r\\n\");\n\
stream.write_all(req.as_bytes()).expect(\"write request\");\n\
let mut response = String::new();\n\
stream.read_to_string(&mut response).expect(\"read response\");\n\
assert!(\n\
response.starts_with(\"HTTP/1.1 200\") || response.starts_with(\"HTTP/1.0 200\"),\n\
\"manifest must return 200, got: {{response}}\"\n\
);\n\
assert!(\n\
response.contains(\"application/manifest+json\"),\n\
\"manifest content-type must be application/manifest+json\"\n\
);\n\
}}\n\
\n\
// Browser check: <link rel=\"manifest\"> is in <head>\n\
page.visit(\"/offline\").await.expect(\"offline page loaded\");\n\
page.expect_attribute({manifest_selector:?}, \"href\", \"/manifest.webmanifest\")\n\
.await\n\
.expect(\"manifest link present in DOM\");\n\
}}\n\
\n\
/// Verifies that the service worker registers successfully (scope covers the whole app).\n\
/// The `/offline` page is used as the test shell since it is always available.\n\
#[tokio::test]\n\
#[ignore = \"requires Chromium; run with --include-ignored\"]\n\
async fn service_worker_registers_successfully() {{\n\
let runner = SystemTest::new()\n\
.routes(routes![pwa_manifest, pwa_service_worker, pwa_register_js, pwa_offline])\n\
.build()\n\
.await\n\
.expect(\"test runner\");\n\
let page = runner.page().await.expect(\"page\");\n\
\n\
// `/pwa-register.js` sets `data-sw-registered=\"true\"` on `<html>`\n\
// after the SW registers. Visiting `/offline` (which uses layout)\n\
// loads the script without needing the user's root route.\n\
page.visit(\"/offline\").await.expect(\"offline page loaded\");\n\
page.expect_attribute(\"html\", \"data-sw-registered\", \"true\")\n\
.await\n\
.expect(\"service worker registered and controlling page\");\n\
}}\n"
)
}
pub fn inject_pwa_into_main(source: &str) -> String {
let with_meta = inject_pwa_meta_into_head(source);
let with_handlers = inject_pwa_handlers(&with_meta);
let route_entries = vec![
"pwa_manifest".to_owned(),
"pwa_service_worker".to_owned(),
"pwa_register_js".to_owned(),
"pwa_offline".to_owned(),
];
update_main_rs(&with_handlers, &[], &route_entries)
}
fn inject_pwa_meta_into_head(source: &str) -> String {
if source.contains("/pwa-register.js") {
return source.to_owned();
}
let lines: Vec<&str> = source.lines().collect();
let Some(head_idx) = lines.iter().position(|l| {
let t = l.trim();
t == "head {" || t == "head{"
}) else {
return source.to_owned();
};
let head_indent = indent_count(lines[head_idx]);
let Some(close_rel) = lines[head_idx + 1..]
.iter()
.position(|l| indent_count(l) == head_indent && l.trim() == "}")
else {
return source.to_owned();
};
let close_idx = head_idx + 1 + close_rel;
let inner_indent = " ".repeat(head_indent + 4);
let meta_block = format!(
"{inner_indent}link rel=\"manifest\" href=\"/manifest.webmanifest\";\n\
{inner_indent}meta name=\"theme-color\" content=\"#ffffff\";\n\
{inner_indent}link rel=\"apple-touch-icon\" href=\"/static/icons/icon.svg\";\n\
{inner_indent}script src=\"/pwa-register.js\" {{}}\n"
);
let mut result = lines[..close_idx].join("\n");
result.push('\n');
result.push_str(&meta_block);
result.push_str(lines[close_idx]);
if close_idx + 1 < lines.len() {
result.push('\n');
result.push_str(&lines[close_idx + 1..].join("\n"));
}
if source.ends_with('\n') {
result.push('\n');
}
result
}
fn layout_missing_current_path(source: &str) -> bool {
let Some(start) = source.find("fn layout(") else {
return false;
};
let after_paren = &source[start + "fn layout(".len()..];
let Some(params) = balanced_prefix(after_paren) else {
return false;
};
count_params(params) < 4
}
fn count_params(params: &str) -> usize {
let trimmed = params.trim();
if trimmed.is_empty() {
return 0;
}
let commas = count_top_level_commas(trimmed);
if trimmed.ends_with(',') {
commas
} else {
commas + 1
}
}
fn balanced_prefix(s: &str) -> Option<&str> {
let mut depth = 0i32;
for (i, c) in s.char_indices() {
match c {
'(' | '[' => depth += 1,
')' if depth == 0 => return Some(&s[..i]),
')' | ']' => depth -= 1,
_ => {}
}
}
None
}
fn count_top_level_commas(s: &str) -> usize {
let mut depth = 0i32;
let mut count = 0;
for c in s.chars() {
match c {
'(' | '[' => depth += 1,
')' | ']' => depth -= 1,
',' if depth == 0 => count += 1,
_ => {}
}
}
count
}
const PWA_HANDLERS_BLOCK: &str = "\
#[get(\"/manifest.webmanifest\")]\n\
async fn pwa_manifest() -> impl IntoResponse {\n\
(\n\
[\n\
(\"content-type\", \"application/manifest+json\"),\n\
(\"cache-control\", \"public, max-age=3600\"),\n\
],\n\
include_str!(\"../static/manifest.webmanifest\"),\n\
)\n\
}\n\
\n\
#[get(\"/service-worker.js\")]\n\
async fn pwa_service_worker() -> impl IntoResponse {\n\
(\n\
[\n\
(\"content-type\", \"text/javascript; charset=utf-8\"),\n\
(\"service-worker-allowed\", \"/\"),\n\
(\"cache-control\", \"no-cache\"),\n\
],\n\
include_str!(\"../static/service-worker.js\"),\n\
)\n\
}\n\
\n\
#[get(\"/pwa-register.js\")]\n\
async fn pwa_register_js() -> impl IntoResponse {\n\
(\n\
[\n\
(\"content-type\", \"text/javascript; charset=utf-8\"),\n\
(\"cache-control\", \"public, max-age=3600\"),\n\
],\n\
include_str!(\"../static/pwa-register.js\"),\n\
)\n\
}\n\
\n\
#[get(\"/offline\")]\n\
async fn pwa_offline(flash: Flash, path: CurrentPath) -> maud::Markup {\n\
layout(\n\
\"Offline\",\n\
path.as_str(),\n\
flash.render().await,\n\
maud::html! {\n\
h1 { \"You are offline\" }\n\
p { \"Check your internet connection and try again.\" }\n\
},\n\
)\n\
}\n\
\n";
fn inject_pwa_handlers(source: &str) -> String {
if source.contains("async fn pwa_manifest()") {
return source.to_owned();
}
find_main_attr_line_start(source).map_or_else(
|| {
let mut result = source.to_owned();
if !result.ends_with('\n') {
result.push('\n');
}
result.push('\n');
result.push_str(PWA_HANDLERS_BLOCK);
result
},
|pos| {
let mut result = String::with_capacity(source.len() + PWA_HANDLERS_BLOCK.len());
result.push_str(&source[..pos]);
result.push_str(PWA_HANDLERS_BLOCK);
result.push_str(&source[pos..]);
result
},
)
}
fn find_main_attr_line_start(source: &str) -> Option<usize> {
let mut offset = 0;
for line in source.split_inclusive('\n') {
if line.trim() == "#[autumn_web::main]" {
return Some(offset);
}
offset += line.len();
}
None
}
fn indent_count(line: &str) -> usize {
line.len() - line.trim_start().len()
}
pub(super) fn remove_pwa_injection(existing: &str) -> String {
let without_handlers = remove_pwa_handlers(existing);
remove_pwa_meta_from_head(&without_handlers)
}
fn remove_pwa_meta_from_head(existing: &str) -> String {
const SCRIPT_LINE: &str = "script src=\"/pwa-register.js\" {}";
const PRECEDING_LINES: [&str; 3] = [
"link rel=\"manifest\" href=\"/manifest.webmanifest\";",
"meta name=\"theme-color\" content=\"#ffffff\";",
"link rel=\"apple-touch-icon\" href=\"/static/icons/icon.svg\";",
];
let lines: Vec<&str> = existing.lines().collect();
let Some(script_idx) = lines.iter().position(|l| l.trim() == SCRIPT_LINE) else {
return existing.to_owned();
};
if script_idx < PRECEDING_LINES.len() {
return existing.to_owned();
}
let start = script_idx - PRECEDING_LINES.len();
let matches = PRECEDING_LINES
.iter()
.enumerate()
.all(|(offset, expected)| lines[start + offset].trim() == *expected);
if !matches {
return existing.to_owned();
}
let mut new_lines: Vec<&str> = Vec::with_capacity(lines.len());
new_lines.extend_from_slice(&lines[..start]);
new_lines.extend_from_slice(&lines[script_idx + 1..]);
let mut out = new_lines.join("\n");
if existing.ends_with('\n') {
out.push('\n');
}
out
}
fn remove_pwa_handlers(existing: &str) -> String {
let Some(pos) = existing.find(PWA_HANDLERS_BLOCK) else {
return existing.to_owned();
};
let mut out = String::with_capacity(existing.len() - PWA_HANDLERS_BLOCK.len());
out.push_str(&existing[..pos]);
out.push_str(&existing[pos + PWA_HANDLERS_BLOCK.len()..]);
out
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
use crate::generate::Flags;
fn project_with_main(main_rs: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"my-app\"\nversion=\"0.1.0\"\nedition=\"2024\"\n\n[dependencies]\nautumn-web = \"0.5.0\"\n",
)
.unwrap();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src/main.rs"), main_rs).unwrap();
tmp
}
const DEFAULT_MAIN: &str = "\
use autumn_web::form::skip_link;
use autumn_web::migrate::{EmbeddedMigrations, embed_migrations};
use autumn_web::prelude::*;
const MIGRATIONS: EmbeddedMigrations = embed_migrations!();
pub fn layout(title: &str, current_path: &str, flash: maud::Markup, content: maud::Markup) -> maud::Markup {
let nav = NavBarConfig::new()
.brand(\"My App\", \"/\")
.aria_label(\"Main navigation\")
.item(NavItem::link(\"/\", \"Home\"));
maud::html! {
(maud::DOCTYPE)
html lang=\"en\" {
head {
meta charset=\"utf-8\";
meta name=\"viewport\" content=\"width=device-width, initial-scale=1\";
title { (title) }
link rel=\"stylesheet\" href=(autumn_web::flash::FLASH_CSS_PATH);
link rel=\"stylesheet\" href=\"/static/css/app.css\";
script src=(autumn_web::htmx::AUTUMN_WIDGETS_JS_PATH) defer {}
}
body {
(skip_link(\"#main-content\", \"Skip to main content\"))
header role=\"banner\" {
(nav_bar(current_path, &nav))
}
main id=\"main-content\" role=\"main\" {
(flash)
(content)
}
footer role=\"contentinfo\" {
p { \"Built with Autumn\" }
}
}
}
}
}
#[get(\"/\")]
async fn index(flash: Flash, path: CurrentPath) -> maud::Markup {
layout(\"Welcome\", path.as_str(), flash.render().await, maud::html! {
h1 { \"Welcome!\" }
})
}
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![index])
.migrations(MIGRATIONS)
.run()
.await;
}
";
const OLD_SHAPE_MAIN: &str = "\
use autumn_web::form::skip_link;
use autumn_web::migrate::{EmbeddedMigrations, embed_migrations};
use autumn_web::prelude::*;
const MIGRATIONS: EmbeddedMigrations = embed_migrations!();
pub fn layout(title: &str, flash: maud::Markup, content: maud::Markup) -> maud::Markup {
maud::html! {
(maud::DOCTYPE)
html lang=\"en\" {
head {
meta charset=\"utf-8\";
title { (title) }
}
body {
(skip_link(\"#main-content\", \"Skip to main content\"))
main id=\"main-content\" role=\"main\" {
(flash)
(content)
}
}
}
}
}
#[get(\"/\")]
async fn index(flash: Flash) -> maud::Markup {
layout(\"Welcome\", flash.render().await, maud::html! {
h1 { \"Welcome!\" }
})
}
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![index])
.migrations(MIGRATIONS)
.run()
.await;
}
";
#[test]
fn plan_pwa_rejects_layout_missing_current_path() {
let tmp = project_with_main(OLD_SHAPE_MAIN);
let err = plan_pwa(tmp.path()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("current_path"), "{msg}");
assert!(msg.contains("layout"), "{msg}");
}
#[test]
fn plan_pwa_accepts_rustfmt_wrapped_layout_signature() {
let wrapped_main = DEFAULT_MAIN.replace(
"pub fn layout(title: &str, current_path: &str, flash: maud::Markup, content: maud::Markup) -> maud::Markup {",
"pub fn layout(\n title: &str,\n current_path: &str,\n flash: maud::Markup,\n content: maud::Markup,\n) -> maud::Markup {",
);
assert_ne!(
wrapped_main, DEFAULT_MAIN,
"replacement must actually match"
);
let tmp = project_with_main(&wrapped_main);
plan_pwa(tmp.path())
.expect("rustfmt-wrapped current_path parameter must still be detected");
}
#[test]
fn plan_pwa_accepts_layout_with_renamed_path_parameter() {
let renamed_main = DEFAULT_MAIN.replace(
"pub fn layout(title: &str, current_path: &str, flash: maud::Markup, content: maud::Markup) -> maud::Markup {",
"pub fn layout(title: &str, request_path: &str, flash: maud::Markup, content: maud::Markup) -> maud::Markup {",
);
assert_ne!(
renamed_main, DEFAULT_MAIN,
"replacement must actually match"
);
let tmp = project_with_main(&renamed_main);
plan_pwa(tmp.path())
.expect("a differently-named 4th parameter must still be accepted (arity, not name)");
}
#[test]
fn plan_pwa_requires_project_root() {
let tmp = TempDir::new().unwrap();
let err = plan_pwa(tmp.path()).unwrap_err();
assert!(matches!(err, GenerateError::NotInProject));
}
#[test]
fn plan_creates_manifest_webmanifest() {
let tmp = project_with_main(DEFAULT_MAIN);
let plan = plan_pwa(tmp.path()).unwrap();
assert!(
plan.actions
.iter()
.any(|a| a.path().ends_with("manifest.webmanifest")),
"plan must include manifest.webmanifest"
);
}
#[test]
fn plan_creates_service_worker_js() {
let tmp = project_with_main(DEFAULT_MAIN);
let plan = plan_pwa(tmp.path()).unwrap();
assert!(
plan.actions
.iter()
.any(|a| a.path().ends_with("service-worker.js")),
"plan must include service-worker.js"
);
}
#[test]
fn plan_creates_pwa_register_js() {
let tmp = project_with_main(DEFAULT_MAIN);
let plan = plan_pwa(tmp.path()).unwrap();
assert!(
plan.actions
.iter()
.any(|a| a.path().ends_with("pwa-register.js")),
"plan must include pwa-register.js"
);
}
#[test]
fn plan_creates_icons() {
let tmp = project_with_main(DEFAULT_MAIN);
let plan = plan_pwa(tmp.path()).unwrap();
assert!(
plan.actions.iter().any(|a| a.path().ends_with("icon.svg")),
"plan must include icon.svg"
);
assert!(
plan.actions
.iter()
.any(|a| a.path().ends_with("maskable-icon.svg")),
"plan must include maskable-icon.svg"
);
}
#[test]
fn plan_creates_system_test() {
let tmp = project_with_main(DEFAULT_MAIN);
let plan = plan_pwa(tmp.path()).unwrap();
assert!(
plan.actions
.iter()
.any(|a| a.path().ends_with("pwa_smoke.rs")),
"plan must include pwa_smoke.rs"
);
}
#[test]
fn manifest_is_valid_json() {
let content = render_manifest();
let parsed: serde_json::Value =
serde_json::from_str(&content).expect("manifest.webmanifest must be valid JSON");
assert!(parsed["name"].is_string(), "manifest must have a name");
assert!(parsed["icons"].is_array(), "manifest must have icons array");
}
#[test]
fn manifest_has_required_fields_for_installability() {
let content = render_manifest();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(parsed["start_url"].is_string());
assert!(
["fullscreen", "standalone", "minimal-ui"]
.contains(&parsed["display"].as_str().unwrap_or(""))
);
let icons = parsed["icons"].as_array().unwrap();
assert!(!icons.is_empty(), "at least one icon required");
}
#[test]
fn manifest_has_both_icon_purposes() {
let content = render_manifest();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
let icons = parsed["icons"].as_array().unwrap();
let purposes: Vec<&str> = icons.iter().filter_map(|i| i["purpose"].as_str()).collect();
assert!(
purposes
.iter()
.any(|p| p.contains("any") || p.contains("monochrome")),
"must have a general-purpose icon"
);
assert!(
purposes.iter().any(|p| p.contains("maskable")),
"must have a maskable icon"
);
}
#[test]
fn service_worker_has_offline_fallback_for_navigation() {
let sw = render_service_worker();
assert!(
sw.contains("navigate"),
"SW must handle navigation requests"
);
assert!(sw.contains("offline"), "SW must have offline fallback");
}
#[test]
fn service_worker_precaches_offline_url() {
let sw = render_service_worker();
assert!(
sw.contains("PRECACHE_URLS"),
"SW must declare precache list"
);
assert!(
sw.contains("/offline"),
"SW must precache the offline shell"
);
}
#[test]
fn service_worker_has_install_and_activate_handlers() {
let sw = render_service_worker();
assert!(sw.contains("install"), "SW must have install handler");
assert!(sw.contains("activate"), "SW must have activate handler");
}
#[test]
fn service_worker_caches_static_assets_first() {
let sw = render_service_worker();
assert!(sw.contains("/static/"), "SW must cache static assets");
}
#[test]
fn inject_adds_manifest_link() {
let updated = inject_pwa_meta_into_head(DEFAULT_MAIN);
assert!(
updated.contains(r#"rel="manifest""#),
"must add rel=manifest link"
);
assert!(
updated.contains("/manifest.webmanifest"),
"must reference /manifest.webmanifest"
);
}
#[test]
fn inject_adds_external_register_script() {
let updated = inject_pwa_meta_into_head(DEFAULT_MAIN);
assert!(
updated.contains("src=\"/pwa-register.js\""),
"must add external SW registration script (avoids CSP inline-script issues)"
);
assert!(
!updated.contains("serviceWorker"),
"must not embed inline serviceWorker JS"
);
}
#[test]
fn inject_adds_theme_color_meta() {
let updated = inject_pwa_meta_into_head(DEFAULT_MAIN);
assert!(
updated.contains("theme-color"),
"must add theme-color meta tag"
);
}
#[test]
fn inject_adds_apple_touch_icon() {
let updated = inject_pwa_meta_into_head(DEFAULT_MAIN);
assert!(
updated.contains("apple-touch-icon"),
"must add apple-touch-icon link"
);
}
#[test]
fn inject_meta_is_idempotent() {
let once = inject_pwa_meta_into_head(DEFAULT_MAIN);
let twice = inject_pwa_meta_into_head(&once);
assert_eq!(once, twice, "inject_pwa_meta_into_head must be idempotent");
}
#[test]
fn inject_meta_preserves_existing_content() {
let updated = inject_pwa_meta_into_head(DEFAULT_MAIN);
assert!(updated.contains(r#"meta charset="utf-8""#));
assert!(updated.contains(r#"meta name="viewport""#));
assert!(updated.contains(r#"link rel="stylesheet""#));
}
#[test]
fn inject_meta_no_op_when_head_absent() {
let src = "fn main() { println!(\"hello\"); }\n";
let result = inject_pwa_meta_into_head(src);
assert_eq!(result, src, "must be unchanged if no head {{}} block found");
}
#[test]
fn inject_handlers_adds_manifest_route() {
let updated = inject_pwa_handlers(DEFAULT_MAIN);
assert!(
updated.contains("async fn pwa_manifest()"),
"must add pwa_manifest handler"
);
assert!(
updated.contains("application/manifest+json"),
"pwa_manifest must set correct content-type"
);
}
#[test]
fn inject_handlers_adds_service_worker_route() {
let updated = inject_pwa_handlers(DEFAULT_MAIN);
assert!(
updated.contains("async fn pwa_service_worker()"),
"must add pwa_service_worker handler"
);
assert!(
updated.contains("service-worker-allowed"),
"service-worker handler must set Service-Worker-Allowed header"
);
}
#[test]
fn inject_handlers_adds_register_js_route() {
let updated = inject_pwa_handlers(DEFAULT_MAIN);
assert!(
updated.contains("async fn pwa_register_js()"),
"must add pwa_register_js handler"
);
assert!(
updated.contains("include_str!(\"../static/pwa-register.js\")"),
"pwa_register_js must embed file at compile time via include_str!"
);
}
#[test]
fn inject_handlers_adds_offline_route() {
let updated = inject_pwa_handlers(DEFAULT_MAIN);
assert!(
updated.contains("async fn pwa_offline("),
"must add pwa_offline handler"
);
}
#[test]
fn inject_handlers_is_idempotent() {
let once = inject_pwa_handlers(DEFAULT_MAIN);
let twice = inject_pwa_handlers(&once);
assert_eq!(once, twice, "inject_pwa_handlers must be idempotent");
}
#[test]
fn inject_handlers_places_before_main() {
let updated = inject_pwa_handlers(DEFAULT_MAIN);
let handler_pos = updated.find("async fn pwa_manifest()").unwrap();
let main_pos = updated.find("#[autumn_web::main]").unwrap();
assert!(
handler_pos < main_pos,
"PWA handlers must appear before #[autumn_web::main]"
);
}
#[test]
fn inject_handlers_ignores_main_attr_mentioned_in_comment() {
let source = "\
use autumn_web::prelude::*;
// Route handlers are registered under #[autumn_web::main] via routes![].
fn helper() {}
#[autumn_web::main]
async fn main() {
autumn_web::app().routes(routes![]).run().await;
}
";
let updated = inject_pwa_handlers(source);
assert!(
updated.contains(
"// Route handlers are registered under #[autumn_web::main] via routes![]."
),
"comment must be untouched: {updated}"
);
let handler_pos = updated.find("async fn pwa_manifest()").unwrap();
let helper_pos = updated.find("fn helper()").unwrap();
let real_main_pos = updated.find("\n#[autumn_web::main]\n").unwrap();
assert!(
helper_pos < handler_pos && handler_pos < real_main_pos,
"handlers must be inserted immediately before the real attribute line: {updated}"
);
}
#[test]
fn inject_handlers_appends_when_only_comment_mentions_main_attr() {
let source = "// see #[autumn_web::main]\nfn main() {}\n";
let updated = inject_pwa_handlers(source);
assert!(
updated.starts_with("// see #[autumn_web::main]\nfn main() {}\n"),
"original source must be untouched: {updated}"
);
assert!(
updated.ends_with(PWA_HANDLERS_BLOCK),
"handlers must be appended at end as fallback: {updated}"
);
}
#[test]
fn pwa_manifest_handler_uses_include_str() {
let updated = inject_pwa_handlers(DEFAULT_MAIN);
assert!(
updated.contains("include_str!(\"../static/manifest.webmanifest\")"),
"pwa_manifest must embed file at compile time via include_str!"
);
}
#[test]
fn pwa_service_worker_handler_uses_include_str() {
let updated = inject_pwa_handlers(DEFAULT_MAIN);
assert!(
updated.contains("include_str!(\"../static/service-worker.js\")"),
"pwa_service_worker must embed file at compile time via include_str!"
);
}
#[test]
fn full_inject_adds_routes_to_routes_macro() {
let updated = inject_pwa_into_main(DEFAULT_MAIN);
assert!(
updated.contains("pwa_manifest"),
"routes![] must include pwa_manifest"
);
assert!(
updated.contains("pwa_service_worker"),
"routes![] must include pwa_service_worker"
);
assert!(
updated.contains("pwa_register_js"),
"routes![] must include pwa_register_js"
);
assert!(
updated.contains("pwa_offline"),
"routes![] must include pwa_offline"
);
}
#[test]
fn full_inject_is_idempotent() {
let once = inject_pwa_into_main(DEFAULT_MAIN);
let twice = inject_pwa_into_main(&once);
assert_eq!(once, twice, "inject_pwa_into_main must be idempotent");
}
#[test]
fn full_inject_does_not_duplicate_manifest_link() {
let once = inject_pwa_into_main(DEFAULT_MAIN);
let twice = inject_pwa_into_main(&once);
let count = twice.matches(r#"rel="manifest""#).count();
assert_eq!(count, 1, "must not duplicate rel=manifest link");
}
#[test]
fn full_inject_does_not_duplicate_pwa_routes() {
let once = inject_pwa_into_main(DEFAULT_MAIN);
let twice = inject_pwa_into_main(&once);
let handler_count = twice.matches("async fn pwa_manifest()").count();
assert_eq!(handler_count, 1, "must not duplicate pwa_manifest handler");
}
#[test]
fn pwa_offline_handler_matches_current_layout_arity() {
let updated = inject_pwa_into_main(DEFAULT_MAIN);
assert!(
updated.contains("async fn pwa_offline(flash: Flash, path: CurrentPath)"),
"pwa_offline must extract CurrentPath alongside Flash: {updated}"
);
let offline_fn_start = updated
.find("async fn pwa_offline")
.expect("pwa_offline handler must be present");
let offline_fn = &updated[offline_fn_start..offline_fn_start + 200];
assert!(
offline_fn.contains("\"Offline\"") && offline_fn.contains("path.as_str()"),
"pwa_offline must pass path.as_str() as layout()'s second argument: {offline_fn}"
);
}
#[test]
fn plan_execute_creates_manifest_file() {
let tmp = project_with_main(DEFAULT_MAIN);
plan_pwa(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
let manifest_path = tmp.path().join("static/manifest.webmanifest");
assert!(
manifest_path.exists(),
"static/manifest.webmanifest must exist"
);
let content = fs::read_to_string(&manifest_path).unwrap();
let _: serde_json::Value = serde_json::from_str(&content)
.expect("manifest.webmanifest must be valid JSON after execution");
}
#[test]
fn plan_execute_creates_service_worker_file() {
let tmp = project_with_main(DEFAULT_MAIN);
plan_pwa(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
assert!(
tmp.path().join("static/service-worker.js").exists(),
"static/service-worker.js must exist"
);
}
#[test]
fn plan_execute_creates_pwa_register_js_file() {
let tmp = project_with_main(DEFAULT_MAIN);
plan_pwa(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
assert!(
tmp.path().join("static/pwa-register.js").exists(),
"static/pwa-register.js must exist"
);
let content = fs::read_to_string(tmp.path().join("static/pwa-register.js")).unwrap();
assert!(
content.contains("serviceWorker"),
"pwa-register.js must contain SW registration code"
);
}
#[test]
fn plan_execute_creates_icon_files() {
let tmp = project_with_main(DEFAULT_MAIN);
plan_pwa(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
assert!(
tmp.path().join("static/icons/icon.svg").exists(),
"static/icons/icon.svg must exist"
);
assert!(
tmp.path().join("static/icons/maskable-icon.svg").exists(),
"static/icons/maskable-icon.svg must exist"
);
}
#[test]
fn plan_execute_creates_system_test_file() {
let tmp = project_with_main(DEFAULT_MAIN);
plan_pwa(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
assert!(
tmp.path().join("tests/system/pwa_smoke.rs").exists(),
"tests/system/pwa_smoke.rs must exist"
);
}
#[test]
fn plan_execute_updates_main_rs_with_pwa_meta_and_handlers() {
let tmp = project_with_main(DEFAULT_MAIN);
plan_pwa(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
let main_rs = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(main_rs.contains(r#"rel="manifest""#));
assert!(main_rs.contains("src=\"/pwa-register.js\""));
assert!(main_rs.contains("async fn pwa_manifest()"));
assert!(main_rs.contains("async fn pwa_service_worker()"));
assert!(main_rs.contains("async fn pwa_register_js()"));
assert!(main_rs.contains("async fn pwa_offline("));
}
#[test]
fn generate_then_destroy_pwa_round_trips_to_original_project_state() {
let tmp = project_with_main(DEFAULT_MAIN);
let cargo_path = tmp.path().join("Cargo.toml");
let main_path = tmp.path().join("src/main.rs");
let original_cargo = fs::read_to_string(&cargo_path).unwrap();
let original_main = fs::read_to_string(&main_path).unwrap();
plan_pwa(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
assert!(tmp.path().join("static/manifest.webmanifest").exists());
assert!(
fs::read_to_string(&main_path)
.unwrap()
.contains("async fn pwa_manifest()")
);
assert!(
fs::read_to_string(&cargo_path)
.unwrap()
.contains("system-tests")
);
plan_pwa(tmp.path())
.unwrap()
.revert(Flags::default())
.unwrap();
assert!(!tmp.path().join("static/manifest.webmanifest").exists());
assert!(!tmp.path().join("static").exists());
assert!(!tmp.path().join("tests/system/pwa_smoke.rs").exists());
assert_eq!(fs::read_to_string(&main_path).unwrap(), original_main);
assert_eq!(fs::read_to_string(&cargo_path).unwrap(), original_cargo);
}
#[test]
fn destroy_pwa_still_works_after_main_rs_reverted_to_pre_nav_bar_shape() {
let tmp = project_with_main(DEFAULT_MAIN);
plan_pwa(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
assert!(tmp.path().join("static/manifest.webmanifest").exists());
let main_path = tmp.path().join("src/main.rs");
let with_pwa = fs::read_to_string(&main_path).unwrap();
let narrowed = with_pwa.replace(
"pub fn layout(title: &str, current_path: &str, flash: maud::Markup, content: maud::Markup) -> maud::Markup {",
"pub fn layout(title: &str, flash: maud::Markup, content: maud::Markup) -> maud::Markup {",
);
assert_ne!(narrowed, with_pwa, "replacement must actually match");
fs::write(&main_path, &narrowed).unwrap();
assert!(plan_pwa(tmp.path()).is_err());
plan_pwa_destroy_fallback(tmp.path())
.unwrap()
.revert(Flags::default())
.unwrap();
assert!(!tmp.path().join("static/manifest.webmanifest").exists());
assert!(!tmp.path().join("static").exists());
assert!(!tmp.path().join("tests/system/pwa_smoke.rs").exists());
}
#[test]
fn plan_execute_is_idempotent_with_force() {
let tmp = project_with_main(DEFAULT_MAIN);
plan_pwa(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
plan_pwa(tmp.path())
.unwrap()
.execute(Flags {
force: true,
dry_run: false,
})
.unwrap();
let main_rs = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert_eq!(
main_rs.matches(r#"rel="manifest""#).count(),
1,
"re-running must not duplicate manifest link"
);
assert_eq!(
main_rs.matches("async fn pwa_manifest()").count(),
1,
"re-running must not duplicate pwa_manifest handler"
);
}
#[test]
fn dry_run_writes_nothing() {
let tmp = project_with_main(DEFAULT_MAIN);
let original_main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
plan_pwa(tmp.path())
.unwrap()
.execute(Flags {
dry_run: true,
force: false,
})
.unwrap();
assert!(
!tmp.path().join("static/manifest.webmanifest").exists(),
"dry-run must not create manifest"
);
let after = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert_eq!(original_main, after, "dry-run must not modify main.rs");
}
}