use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use autumn_web::config::DatabaseBackend;
use autumn_web::push::PUSH_SUBSCRIPTIONS_TABLE;
use super::dsl::{Field, FieldConstraints, FieldKind, IdType};
use super::emit::Plan;
use super::schema_edit::{
create_table_sql_with_metadata_and_id_for, drop_table_sql, update_main_rs,
};
use super::system_test::patch_cargo_toml as patch_system_test_cargo_toml;
use super::{GenerateError, detect_backend, ensure_project_root, timestamp_now};
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 backend = detect_backend(project_root);
let migration_dir = existing_push_migration_dir(project_root).unwrap_or_else(|| {
project_root.join("migrations").join(format!(
"{}_create_{PUSH_SUBSCRIPTIONS_TABLE}",
timestamp_now()
))
});
plan.create(
migration_dir.join("up.sql"),
push_subscriptions_up_sql(backend),
);
plan.create(
migration_dir.join("down.sql"),
drop_table_sql(PUSH_SUBSCRIPTIONS_TABLE),
);
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 !push_router_already_mounted(&updated_main) {
plan.warn(
"could not find the app builder in src/main.rs, so the Web Push routes were not \
mounted. Add this line to your builder chain by hand:\n \
.merge(autumn_web::push::router())\n \
Without it the generated subscribe snippet will 404. See \
docs/guide/web-push.md.",
);
}
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 existing_push_migration_dir(project_root: &Path) -> Option<PathBuf> {
let suffix = format!("_create_{PUSH_SUBSCRIPTIONS_TABLE}");
let mut matches: Vec<PathBuf> = std::fs::read_dir(project_root.join("migrations"))
.ok()?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.is_dir()
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(&suffix))
})
.collect();
matches.sort();
matches.into_iter().next()
}
fn push_subscription_fields() -> Vec<Field> {
let field = |name: &str, unique: bool| Field {
name: name.to_owned(),
kind: FieldKind::String,
nullable: false,
variants: Vec::new(),
unique,
constraints: FieldConstraints::default(),
state_machine: None,
};
vec![
field("tenant_id", false),
field("principal_id", false),
field("endpoint", true),
field("p256dh", false),
field("auth", false),
]
}
fn push_subscriptions_up_sql(backend: DatabaseBackend) -> String {
let sql = create_table_sql_with_metadata_and_id_for(
backend,
PUSH_SUBSCRIPTIONS_TABLE,
&push_subscription_fields(),
&BTreeSet::new(),
&BTreeMap::new(),
IdType::BigSerial,
);
let sql = match backend {
DatabaseBackend::Postgres => sql.replace(
"created_at TIMESTAMP NOT NULL DEFAULT NOW()",
"created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()",
),
DatabaseBackend::Sqlite => sql,
};
let sql = sql.replace(
"tenant_id TEXT NOT NULL",
"tenant_id TEXT NOT NULL DEFAULT ''",
);
format!(
"{sql}CREATE INDEX idx_push_subscriptions_tenant_principal ON push_subscriptions \
(tenant_id, principal_id);\n"
)
}
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;
});
})
);
}
});
// ── Web Push ────────────────────────────────────────────────────────────────
// Fired by the browser even when every tab of this site is closed. The payload
// is what `autumn_web::push::PushMessage` serializes: {title, body, url?, icon?}.
self.addEventListener('push', (event) => {
// A push can legitimately arrive with no payload, and a payload from any
// other sender need not be JSON. Throwing here makes the browser show a
// generic 'site updated in the background' notice instead, so always fall
// back to something readable.
let payload = {};
if (event.data) {
try {
payload = event.data.json();
} catch (e) {
payload = { body: event.data.text() };
}
}
const title = payload.title || 'Notification';
const options = {
body: payload.body || '',
icon: payload.icon || '/static/icons/icon.svg',
// Carried through to `notificationclick` below.
data: { url: payload.url || '/' },
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
// Cross-origin targets are dropped: the payload travels through a third-party
// push service, so a notification must never be able to navigate this app's
// users off-origin. A malformed URL makes `new URL()` throw and the click
// does nothing. The handler falls back to the app root.
let url = self.location.origin + '/';
try {
const target = new URL(
(event.notification.data && event.notification.data.url) || '/',
self.location.origin
);
if (target.origin === self.location.origin) {
url = target.href;
}
} catch (e) {
// Use the root fallback.
}
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windows) => {
// Prefer focusing a tab that is already on the target rather than
// opening a duplicate one.
for (const client of windows) {
if (client.url === url && 'focus' in client) {
return client.focus();
}
}
if (windows.length > 0 && 'navigate' in windows[0] && 'focus' in windows[0]) {
// `navigate()` REJECTS for a client this service worker does not
// control — and `includeUncontrolled: true` above is exactly how such
// clients get into this list. An unhandled rejection inside
// `waitUntil` means the click does nothing at all, so fall back.
return windows[0]
.navigate(url)
.then((client) => (client ? client.focus() : undefined))
.catch(() => self.clients.openWindow(url));
}
return self.clients.openWindow(url);
})
);
});
"
.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 {
format!(
"{}\n{}",
render_service_worker_registration(),
render_push_opt_in()
)
}
fn render_service_worker_registration() -> String {
r"if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/service-worker.js', { scope: '/' })
.then(() => { document.documentElement.dataset.swRegistered = 'true'; })
.catch(console.error);
}
"
.to_owned()
}
fn render_push_opt_in() -> String {
format!("{}{}", render_push_helpers(), render_push_entry_points())
}
fn render_push_helpers() -> String {
format!(
r"// Autumn's CSRF layer rejects an unaccompanied mutating request, so both POSTs
// below must carry a token. Without one they 403 the moment
// `[security.csrf] enabled = true` — which the production smart defaults do —
// and the tempting workaround (exempting `/push/`) would be far worse than the
// bug it hides: a forced subscribe would register an ATTACKER's keys under the
// victim's session, letting them decrypt every notification sent to that user.
//
// The CSRF cookie is HttpOnly, so this cannot read it. Two sources, in order:
// the public-key response (which the subscribe flow fetches anyway, and which
// carries the token for exactly this purpose), then `<meta name=csrf-token>`
// for an app that already publishes it the way `autumn generate auth` does.
let autumnPushCsrf = null;
function autumnPushHeaders() {{
const headers = {{ 'content-type': 'application/json' }};
if (autumnPushCsrf && autumnPushCsrf.token) {{
headers[autumnPushCsrf.header || 'X-CSRF-Token'] = autumnPushCsrf.token;
return headers;
}}
// Unquoted attribute values (both are valid CSS identifiers) so this stays
// inside a Rust raw string that cannot contain a double quote.
const token = document.querySelector('meta[name=csrf-token]');
if (token && token.content) {{
const header = document.querySelector('meta[name=csrf-token-header]');
headers[(header && header.content) || 'X-CSRF-Token'] = token.content;
}}
return headers;
}}
// Whether an existing subscription was created with `key`.
//
// `options.applicationServerKey` is the ArrayBuffer the browser recorded at
// subscribe time; comparing bytes is the only way to notice a server-side key
// rotation before `subscribe()` throws over it.
function autumnPushKeyMatches(subscription, key) {{
const recorded = subscription.options && subscription.options.applicationServerKey;
if (!recorded) {{
// The browser does not expose it (older Safari): assume it still matches
// rather than churning a working subscription on every page load.
return true;
}}
const bytes = new Uint8Array(recorded);
if (bytes.length !== key.length) {{
return false;
}}
for (let i = 0; i < bytes.length; i += 1) {{
if (bytes[i] !== key[i]) {{
return false;
}}
}}
return true;
}}
// Read the CSRF token off a public-key response. Same-origin only: a
// cross-origin reader cannot see these headers without the app opting in via
// CORS, which is the property double-submit CSRF already depends on.
function autumnPushCaptureCsrf(response) {{
const token = response.headers.get('{csrf_token_header}');
if (token) {{
autumnPushCsrf = {{
token: token,
header: response.headers.get('{csrf_header_name_header}'),
}};
}}
}}
// `applicationServerKey` takes a BufferSource, not the base64url string the
// endpoint serves, and `atob` only understands standard base64 — so translate
// base64url's `-` and `_` back to `+` and `/` and re-pad before decoding.
function autumnDecodeVapidKey(base64url) {{
const padding = '='.repeat((4 - (base64url.length % 4)) % 4);
const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) {{
bytes[i] = raw.charCodeAt(i);
}}
return bytes;
}}
",
csrf_token_header = autumn_web::push::CSRF_TOKEN_HEADER,
csrf_header_name_header = autumn_web::push::CSRF_TOKEN_HEADER_NAME_HEADER,
)
}
fn render_push_entry_points() -> String {
format!("{}{}", render_push_subscribe(), render_push_teardown())
}
fn render_push_subscribe() -> String {
format!(
r"// Opt the current visitor in to Web Push. Call it from a click handler — it
// prompts for notification permission, so it needs a user gesture.
// Resolves `true` once the subscription has been recorded server-side.
window.autumnPushSubscribe = async function autumnPushSubscribe() {{
// Safari on older iOS has service workers but no Push API at all.
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {{
return false;
}}
if ((await Notification.requestPermission()) !== 'granted') {{
return false;
}}
const response = await fetch('{key_path}', {{ credentials: 'same-origin' }});
if (!response.ok) {{
// 503 means the app has not configured `[push] private_key` yet.
console.error('web push is not configured on this server');
return false;
}}
autumnPushCaptureCsrf(response);
const registration = await navigator.serviceWorker.ready;
const applicationServerKey = autumnDecodeVapidKey((await response.text()).trim());
// A browser keeps its subscription across deployments. If this deployment
// rotated `[push] private_key`, that stored subscription was created with
// the OLD applicationServerKey, and `subscribe()` then REJECTS rather than
// replacing it — leaving the user unable to opt back in through the button
// without manually clearing site data. Drop a subscription whose key no
// longer matches and subscribe afresh.
const existing = await registration.pushManager.getSubscription();
if (existing && !autumnPushKeyMatches(existing, applicationServerKey)) {{
await fetch('{unsubscribe_path}', {{
method: 'POST',
headers: autumnPushHeaders(),
credentials: 'same-origin',
body: JSON.stringify({{ endpoint: existing.endpoint }}),
}}).catch(() => {{}});
await existing.unsubscribe();
}}
const subscription = await registration.pushManager.subscribe({{
// Chrome refuses a subscription without this, and it is also the honest
// contract: every push this app sends raises a visible notification.
userVisibleOnly: true,
applicationServerKey,
}});
const recorded = await fetch('{subscribe_path}', {{
method: 'POST',
headers: autumnPushHeaders(),
// Send the session cookie so the server knows whose subscription this is.
credentials: 'same-origin',
body: JSON.stringify(subscription),
}});
return recorded.ok;
}};
// Undo the above: forget this browser's subscription, server-side and locally.
// Drop this browser's subscription SERVER-SIDE, leaving the browser's own
// subscription intact.
//
// This is what sign-out wants, and revoking the browser subscription there
// would be actively wrong: an origin has ONE `PushSubscription` shared by
// every tab, so with two accounts open the row belongs to whichever signed in
// last. A stale tab signing out gets a `204` from the scoped delete whether or
// not it owned the row — the route is deliberately indistinguishable, so it
// cannot be used to probe who owns an endpoint — and revoking on the strength
// of that `204` would invalidate the OTHER account's endpoint. Its next push
// would `410`, be pruned, and that account would silently stop receiving
// notifications until it opted in again.
window.autumnPushForget = async function autumnPushForget() {{
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {{
return false;
}}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {{
return true;
}}
// This can be the first call of the page, before anything primed the token;
// one cheap GET is enough to obtain it.
if (!autumnPushCsrf) {{
autumnPushCaptureCsrf(
await fetch('{key_path}', {{ credentials: 'same-origin' }})
);
}}
const response = await fetch('{unsubscribe_path}', {{
method: 'POST',
headers: autumnPushHeaders(),
credentials: 'same-origin',
body: JSON.stringify({{ endpoint: subscription.endpoint }}),
}});
return response.ok;
}};
",
key_path = autumn_web::push::VAPID_PUBLIC_KEY_PATH,
subscribe_path = autumn_web::push::SUBSCRIBE_PATH,
unsubscribe_path = autumn_web::push::UNSUBSCRIBE_PATH,
)
}
fn render_push_teardown() -> String {
format!(
r"// Fully opt this browser out: forget the row server-side AND revoke the
// browser's subscription.
//
// Revoking is correct HERE and only here — the visitor is explicitly asking to
// stop receiving on this device, so taking the shared subscription down with
// them is the intent, not a side effect.
window.autumnPushUnsubscribe = async function autumnPushUnsubscribe() {{
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {{
return false;
}}
try {{
await window.autumnPushForget();
}} catch (e) {{
// Offline or a transient error: the server row may stay. The push service
// reports 410 on the next send, and the row is pruned.
console.error(e);
}}
// Always revoke. The visitor asked to stop receiving here.
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
return subscription ? subscription.unsubscribe() : true;
}};
// Unsubscribe on the way out of a session.
//
// A subscription outliving the session that created it is a privacy leak, not
// just untidiness: the row stays bound to the user who logged out, so the app
// keeps pushing their notifications to that browser — in front of whoever uses
// the device next. Nothing in a logout flow knows about push, so this hooks
// the submit instead, while the session is still valid enough for the
// server-side unsubscribe to authorize.
//
// Matches any form posting to `/logout` (what `autumn generate auth` emits) and
// anything explicitly marked `data-autumn-push-unsubscribe`, for an app whose
// sign-out is shaped differently.
document.addEventListener('submit', (event) => {{
const form = event.target;
if (!(form instanceof HTMLFormElement) || form.dataset.autumnPushHandled) {{
return;
}}
const action = form.getAttribute('action') || '';
const isSignOut =
form.hasAttribute('data-autumn-push-unsubscribe') || /\/logout\/?$/.test(action);
if (!isSignOut) {{
return;
}}
event.preventDefault();
// Re-entrancy guard: `form.submit()` below does not re-fire this handler in
// any current browser, but the flag makes that independent of that detail.
form.dataset.autumnPushHandled = 'true';
// Raced against a deadline — `catch` alone is not enough here.
//
// `autumnPushUnsubscribe` awaits `navigator.serviceWorker.ready`, which by
// specification NEVER REJECTS: it stays pending until a worker becomes
// active. So if the service worker fails to install, a bare
// `.catch().finally()` never runs — and since this handler has already
// called `preventDefault()`, the user cannot log out at all. Blocking a
// sign-out is far worse than leaving a subscription behind, which the push
// service prunes with a `410` soon enough anyway.
// `autumnPushForget`, NOT `autumnPushUnsubscribe`: see that function's
// comment — revoking the shared browser subscription on sign-out would take
// another still-signed-in account's notifications down with it.
const cleanup = window.autumnPushForget().catch(console.error);
const deadline = new Promise((resolve) => setTimeout(resolve, {LOGOUT_UNSUBSCRIBE_TIMEOUT_MS}));
Promise.race([cleanup, deadline]).then(() => form.submit());
}});
// Declarative opt-in: no application JavaScript required.
document.addEventListener('click', (event) => {{
// A synthetic `document.dispatchEvent(new MouseEvent('click'))` — the common
// close-the-open-dropdown idiom — has `document` as its target, which has no
// `closest`. Guard rather than throw on every such click.
if (!(event.target instanceof Element)) {{
return;
}}
const trigger = event.target.closest('[data-autumn-push-subscribe]');
if (trigger) {{
event.preventDefault();
window.autumnPushSubscribe().catch(console.error);
}}
}});",
)
}
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 with_push_router = inject_push_router(&with_handlers);
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_push_router, &[], &route_entries)
}
const PUSH_ROUTER_MARKER: &str = "// added by `autumn generate pwa`";
const LOGOUT_UNSUBSCRIBE_TIMEOUT_MS: u32 = 2_000;
const PUSH_ROUTER_LINE: &str =
" .merge(autumn_web::push::router()) // added by `autumn generate pwa`";
fn inject_push_router(source: &str) -> String {
if push_router_already_mounted(source) {
return source.to_owned();
}
let Some(anchor_end) = push_router_anchor(source) else {
return source.to_owned();
};
let mut result = String::with_capacity(source.len() + PUSH_ROUTER_LINE.len() + 1);
result.push_str(&source[..anchor_end]);
result.push('\n');
result.push_str(PUSH_ROUTER_LINE);
result.push_str(&source[anchor_end..]);
result
}
fn push_router_already_mounted(source: &str) -> bool {
crate::rust_source::for_each_code_line(source, |line, _offset| {
line.contains("autumn_web::push::router()").then_some(())
})
.is_some()
}
fn push_router_anchor(source: &str) -> Option<usize> {
let mut seen_main = false;
crate::rust_source::for_each_code_line(source, |line, offset| {
if line.trim().contains("async fn main") {
seen_main = true;
}
(seen_main && line.trim_end().ends_with("autumn_web::app()"))
.then(|| offset + line.trim_end().len())
})
}
fn remove_push_router(existing: &str) -> String {
if !existing.contains(PUSH_ROUTER_MARKER) {
return existing.to_owned();
}
let mut out = String::with_capacity(existing.len());
for line in existing.split_inclusive('\n') {
let trimmed = line.trim();
if trimmed.contains("autumn_web::push::router()") && trimmed.ends_with(PUSH_ROUTER_MARKER) {
continue;
}
out.push_str(line);
}
out
}
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_router = remove_push_router(existing);
let without_handlers = remove_pwa_handlers(&without_router);
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.6.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");
}
fn action_contents(action: &crate::generate::emit::Action) -> String {
match action {
crate::generate::emit::Action::Create { contents, .. }
| crate::generate::emit::Action::Modify { contents, .. }
| crate::generate::emit::Action::CreateIfAbsent { contents, .. } => contents.clone(),
crate::generate::emit::Action::CreateBytes { bytes, .. } => {
String::from_utf8_lossy(bytes).into_owned()
}
}
}
#[test]
fn service_worker_has_a_push_handler() {
let sw = render_service_worker();
assert!(
sw.contains("addEventListener('push'"),
"without a `push` handler the browser shows nothing when the tab is closed — \
the entire point of Web Push:\n{sw}"
);
assert!(
sw.contains("showNotification"),
"the push handler must actually raise a notification:\n{sw}"
);
}
#[test]
fn push_handler_reads_the_frameworks_json_payload() {
let sw = render_service_worker();
for key in ["title", "body", "icon"] {
assert!(
sw.contains(key),
"the push handler must read `{key}` from the payload:\n{sw}"
);
}
}
#[test]
fn push_handler_survives_a_payload_that_is_not_json() {
let sw = render_service_worker();
assert!(
sw.contains("catch"),
"the push handler must guard payload parsing:\n{sw}"
);
}
#[test]
fn service_worker_has_a_notificationclick_handler_that_focuses_or_opens() {
let sw = render_service_worker();
assert!(
sw.contains("addEventListener('notificationclick'"),
"clicking a notification must do something:\n{sw}"
);
assert!(
sw.contains("clients.matchAll"),
"clicking must FOCUS an already-open tab rather than always opening \
a duplicate one:\n{sw}"
);
assert!(
sw.contains("openWindow"),
"…and open a new window when none is focusable:\n{sw}"
);
assert!(
sw.contains("notification.close()"),
"the notification must be dismissed on click:\n{sw}"
);
}
#[test]
fn notificationclick_guards_url_parsing_with_a_root_fallback() {
let sw = render_service_worker();
let handler = sw
.split_once("addEventListener('notificationclick'")
.expect("handler")
.1;
let (before_wait, _) = handler.split_once("event.waitUntil").expect("waitUntil");
let parse = before_wait.find("= new URL(").expect("URL parse");
let guard = before_wait.find("try {").expect("a try block");
assert!(
guard < parse,
"the parse must sit inside a try block:\n{handler}"
);
assert!(before_wait.contains("catch"), "{handler}");
assert!(
before_wait[..guard].contains("self.location.origin + '/'"),
"the root fallback must be set before the try:\n{handler}"
);
}
#[test]
fn client_snippet_subscribes_against_the_frameworks_public_key_endpoint() {
let js = render_pwa_register_js();
assert!(
js.contains(autumn_web::push::VAPID_PUBLIC_KEY_PATH),
"the snippet must fetch the key from the built-in endpoint so the two \
can never drift:\n{js}"
);
assert!(
js.contains(autumn_web::push::SUBSCRIBE_PATH),
"the snippet must POST the subscription to the built-in endpoint:\n{js}"
);
assert!(
js.contains("pushManager.subscribe"),
"the snippet must actually subscribe:\n{js}"
);
assert!(
js.contains("userVisibleOnly"),
"Chrome refuses a subscription without `userVisibleOnly: true`:\n{js}"
);
}
#[test]
fn client_snippet_converts_the_key_to_the_uint8array_the_api_requires() {
let js = render_pwa_register_js();
assert!(
js.contains("Uint8Array"),
"the base64url key must be decoded to a Uint8Array:\n{js}"
);
assert!(js.contains("atob"), "…via base64 decoding:\n{js}");
assert!(
js.contains("replace"),
"…after translating base64url's -/_ back to +/ for atob:\n{js}"
);
}
#[test]
fn client_snippet_never_prompts_for_permission_on_page_load() {
let js = render_pwa_register_js();
let prompts: Vec<&str> = js
.lines()
.filter(|line| line.contains("requestPermission"))
.collect();
assert!(
!prompts.is_empty(),
"the snippet must request permission somewhere:\n{js}"
);
for line in prompts {
assert!(
line.starts_with(' '),
"`requestPermission` must be called inside the opt-in function, never at \
top level where it would run on page load:\n{line}"
);
}
assert!(
js.contains("window.autumnPushSubscribe = "),
"the snippet must expose a callable opt-in entry point:\n{js}"
);
assert!(
js.contains("data-autumn-push-subscribe"),
"…and wire it to a declarative opt-in attribute so an app needs no JS \
of its own:\n{js}"
);
}
#[test]
fn client_snippet_sends_a_csrf_token_with_both_push_posts() {
let js = render_push_opt_in();
assert!(
js.contains(autumn_web::push::CSRF_TOKEN_HEADER),
"the snippet must read the token the public-key response serves:\n{js}"
);
assert!(
js.contains(autumn_web::push::CSRF_TOKEN_HEADER_NAME_HEADER),
"…and the configured header name to send it back in:\n{js}"
);
let posts = js.matches("method: 'POST'").count();
assert!(
posts >= 2,
"expected at least the subscribe and unsubscribe POSTs:\n{js}"
);
assert_eq!(
js.matches("headers: autumnPushHeaders()").count(),
posts,
"every push POST must attach the CSRF token:\n{js}"
);
}
#[test]
fn client_snippet_replaces_a_subscription_made_with_a_rotated_key() {
let js = render_push_opt_in();
assert!(
js.contains("autumnPushKeyMatches"),
"the snippet must compare the stored key against the current one:\n{js}"
);
assert!(
js.contains("applicationServerKey"),
"…by reading `options.applicationServerKey`:\n{js}"
);
assert!(
js.contains("existing.unsubscribe()"),
"…and drop the stale subscription before re-subscribing:\n{js}"
);
assert!(
js.contains("existing.endpoint"),
"…telling the server to forget the old endpoint as well:\n{js}"
);
}
#[test]
fn a_browser_that_hides_the_recorded_key_is_left_alone() {
let js = render_push_opt_in();
assert!(
js.contains("return true;"),
"an unavailable recorded key must be treated as matching:\n{js}"
);
}
#[test]
fn the_registration_half_stays_free_of_push_concerns() {
let js = render_service_worker_registration();
assert!(js.contains("serviceWorker"), "{js}");
assert!(
!js.contains("pushManager") && !js.contains("autumnPush"),
"service-worker registration must not depend on the push half:\n{js}"
);
}
#[test]
fn a_router_mount_inside_a_comment_does_not_count_as_mounted() {
let commented = DEFAULT_MAIN.replace(
"#[autumn_web::main]",
"// Example from the guide:\n // .merge(autumn_web::push::router())\n #[autumn_web::main]",
);
assert!(
!push_router_already_mounted(&commented),
"a commented-out mount must not count as mounted"
);
let injected = inject_pwa_into_main(&commented);
assert!(
push_router_already_mounted(&injected),
"…so injection must still happen:\n{injected}"
);
}
#[test]
fn a_router_mount_inside_a_string_literal_does_not_count_as_mounted() {
for literal in [
"const HELP: &str = \"add .merge(autumn_web::push::router())\";",
"const HELP: &str = r#\"add .merge(autumn_web::push::router())\"#;",
] {
let source = DEFAULT_MAIN.replace(
"#[autumn_web::main]",
&format!("{literal}\n#[autumn_web::main]"),
);
assert!(
!push_router_already_mounted(&source),
"a mount inside a string literal must not count:\n{source}"
);
assert!(
push_router_already_mounted(&inject_pwa_into_main(&source)),
"injection must still happen"
);
}
}
#[test]
fn a_real_router_mount_does_count_as_mounted() {
let injected = inject_pwa_into_main(DEFAULT_MAIN);
assert!(push_router_already_mounted(&injected));
assert_eq!(
inject_pwa_into_main(&injected)
.matches("autumn_web::push::router()")
.count(),
1,
"and a second run must not add another"
);
}
#[test]
fn signing_out_forgets_the_row_without_revoking_a_shared_subscription() {
let js = render_push_opt_in();
assert!(
js.contains("window.autumnPushForget = "),
"sign-out needs a server-side-only path:\n{js}"
);
assert!(
js.contains("window.autumnPushForget().catch"),
"…and the sign-out hook must use it:\n{js}"
);
let opt_out = js
.split_once("window.autumnPushUnsubscribe = ")
.expect("the full opt-out exists")
.1;
assert!(
opt_out.contains("subscription.unsubscribe()"),
"an explicit opt-out must still revoke the browser subscription:\n{opt_out}"
);
let forget = js
.split_once("window.autumnPushForget = ")
.expect("forget exists")
.1
.split_once("window.autumnPushUnsubscribe = ")
.expect("…and ends where the opt-out begins")
.0;
assert!(
!forget.contains("unsubscribe()"),
"the sign-out path must never revoke the shared subscription:\n{forget}"
);
}
#[test]
fn a_failed_server_cleanup_still_revokes_the_local_subscription() {
let js = render_push_opt_in();
let opt_out = js
.split_once("window.autumnPushUnsubscribe = ")
.expect("opt-out")
.1;
let try_at = opt_out.find("try {").expect("forget runs in a try block");
let forget_at = opt_out.find("autumnPushForget()").expect("forget call");
let catch_at = opt_out.find("catch").expect("a catch block");
let revoke_at = opt_out.find("subscription.unsubscribe()").expect("revoke");
assert!(try_at < forget_at && forget_at < catch_at, "{opt_out}");
assert!(
catch_at < revoke_at,
"revoke must follow the catch:\n{opt_out}"
);
}
#[test]
fn client_snippet_unsubscribes_when_the_user_signs_out() {
let js = render_push_opt_in();
assert!(
js.contains("addEventListener('submit'"),
"sign-out must be hooked; nothing in a logout flow knows about push:\n{js}"
);
assert!(
js.contains("/logout"),
"…matching what `autumn generate auth` emits:\n{js}"
);
assert!(
js.contains("data-autumn-push-unsubscribe"),
"…plus an opt-in attribute for a differently-shaped sign-out:\n{js}"
);
assert!(
js.contains("autumnPushForget()"),
"…and it must actually drop the server-side row:\n{js}"
);
}
#[test]
fn a_stalled_unsubscribe_never_traps_the_user_in_their_session() {
let js = render_push_opt_in();
assert!(
js.contains("Promise.race("),
"the submit must be raced against a deadline, not chained off a \
promise that may never settle:\n{js}"
);
assert!(
js.contains("setTimeout(resolve"),
"…with an actual timer as the other racer:\n{js}"
);
assert!(
js.contains(&format!("{LOGOUT_UNSUBSCRIBE_TIMEOUT_MS}")),
"…bounded by the documented timeout:\n{js}"
);
assert!(
js.contains("form.submit()"),
"…and the form must actually be submitted:\n{js}"
);
assert!(
js.contains("autumnPushHandled"),
"…guarded against re-entering the handler:\n{js}"
);
}
#[test]
fn client_snippet_still_falls_back_to_the_csrf_meta_tag() {
let js = render_pwa_register_js();
assert!(
js.contains("meta[name=csrf-token]"),
"the meta-tag fallback must remain:\n{js}"
);
}
#[test]
fn client_snippet_sends_credentials_on_every_push_request() {
let js = render_push_opt_in();
assert_eq!(
js.matches("credentials: 'same-origin'").count(),
js.matches("fetch(").count(),
"every push request must carry the session cookie:\n{js}"
);
}
#[test]
fn client_snippet_skips_push_entirely_when_the_browser_lacks_support() {
let js = render_pwa_register_js();
assert!(
js.contains("PushManager"),
"the snippet must feature-detect before touching the Push API — Safari \
on older iOS has service workers but no PushManager:\n{js}"
);
}
#[test]
fn plan_creates_the_push_subscriptions_migration() {
let tmp = project_with_main(DEFAULT_MAIN);
let plan = plan_pwa(tmp.path()).unwrap();
let up = plan
.actions
.iter()
.find(|a| {
a.path()
.to_string_lossy()
.contains("create_push_subscriptions")
&& a.path().ends_with("up.sql")
})
.expect("the pwa generator must scaffold the push_subscriptions table");
let sql = action_contents(up);
assert!(sql.contains("CREATE TABLE"), "{sql}");
for column in ["principal_id", "endpoint", "p256dh", "auth"] {
assert!(
sql.contains(column),
"the DDL must match the framework store's columns; missing `{column}`:\n{sql}"
);
}
assert!(
sql.to_uppercase().contains("UNIQUE"),
"`endpoint` must be UNIQUE — it is what makes the store's upsert atomic \
rather than a racy select-then-insert:\n{sql}"
);
}
#[test]
fn generated_push_ddl_matches_the_ddl_the_framework_store_is_tested_against() {
let sql = push_subscriptions_up_sql(DatabaseBackend::Postgres);
for column in [
"tenant_id TEXT NOT NULL DEFAULT ''",
"principal_id TEXT NOT NULL",
"endpoint TEXT NOT NULL",
"p256dh TEXT NOT NULL",
"auth TEXT NOT NULL",
] {
assert!(
sql.contains(column),
"the store's diesel `table!` expects `{column}`:\n{sql}"
);
}
assert!(
sql.contains("id BIGSERIAL PRIMARY KEY"),
"the store selects `id` as BigInt:\n{sql}"
);
assert!(
sql.contains("created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()"),
"`created_at` must be TIMESTAMPTZ to match the store's mapping:\n{sql}"
);
assert!(
!sql.contains("created_at TIMESTAMP NOT NULL"),
"the TIMESTAMP → TIMESTAMPTZ rewrite did not apply:\n{sql}"
);
}
#[test]
fn push_migration_makes_endpoint_unique_specifically() {
let sql = push_subscriptions_up_sql(DatabaseBackend::Postgres);
let unique_line = sql
.lines()
.find(|line| line.to_uppercase().contains("UNIQUE"))
.unwrap_or_else(|| panic!("no UNIQUE constraint in:\n{sql}"));
assert!(
unique_line.contains("(endpoint)"),
"UNIQUE must be on `endpoint`, not another column: {unique_line}"
);
}
#[test]
fn push_migration_indexes_tenant_and_principal_for_the_send_path() {
let sql = push_subscriptions_up_sql(DatabaseBackend::Postgres);
assert!(
sql.contains("(tenant_id, principal_id)"),
"`(tenant_id, principal_id)` must be indexed:\n{sql}"
);
}
#[test]
fn push_migration_is_emitted_for_sqlite_too() {
let sql = push_subscriptions_up_sql(DatabaseBackend::Sqlite);
assert!(sql.contains("CREATE TABLE"), "{sql}");
assert!(
!sql.contains("BIGSERIAL"),
"SQLite has no BIGSERIAL:\n{sql}"
);
assert!(
!sql.contains("TIMESTAMPTZ"),
"the SQLite lane stores RFC 3339 TEXT, matching TimestamptzSqlite:\n{sql}"
);
}
#[test]
fn push_migration_has_a_matching_down() {
let tmp = project_with_main(DEFAULT_MAIN);
let plan = plan_pwa(tmp.path()).unwrap();
let down = plan
.actions
.iter()
.find(|a| {
a.path()
.to_string_lossy()
.contains("create_push_subscriptions")
&& a.path().ends_with("down.sql")
})
.expect("every migration needs a down");
let sql = action_contents(down);
assert!(sql.contains("DROP TABLE"), "{sql}");
}
#[test]
fn re_running_reuses_the_existing_push_migration_directory() {
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,
..Flags::default()
})
.unwrap();
let dirs: Vec<_> = fs::read_dir(tmp.path().join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_string_lossy()
.ends_with("_create_push_subscriptions")
})
.collect();
assert_eq!(
dirs.len(),
1,
"re-running must reuse the existing migration directory, not mint a second"
);
}
#[test]
fn inject_mounts_the_built_in_push_router() {
let injected = inject_pwa_into_main(DEFAULT_MAIN);
assert!(
injected.contains("autumn_web::push::router()"),
"without the router mounted the generated client snippet 404s on every \
call:\n{injected}"
);
}
#[test]
fn inject_push_router_is_idempotent() {
let once = inject_pwa_into_main(DEFAULT_MAIN);
let twice = inject_pwa_into_main(&once);
assert_eq!(once, twice);
assert_eq!(
twice.matches("autumn_web::push::router()").count(),
1,
"a second run must not mount the router twice:\n{twice}"
);
}
#[test]
fn generate_then_destroy_removes_the_push_router_mount_too() {
let tmp = project_with_main(DEFAULT_MAIN);
let main_path = tmp.path().join("src/main.rs");
let original_main = fs::read_to_string(&main_path).unwrap();
plan_pwa(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
assert!(
fs::read_to_string(&main_path)
.unwrap()
.contains("autumn_web::push::router()")
);
plan_pwa(tmp.path())
.unwrap()
.revert(Flags::default())
.unwrap();
assert_eq!(
fs::read_to_string(&main_path).unwrap(),
original_main,
"destroy must restore main.rs byte-for-byte, router mount included"
);
}
#[test]
fn dry_run_writes_no_push_files() {
let tmp = project_with_main(DEFAULT_MAIN);
plan_pwa(tmp.path())
.unwrap()
.execute(Flags {
dry_run: true,
..Flags::default()
})
.unwrap();
assert!(
!tmp.path().join("migrations").exists(),
"--dry-run must write nothing"
);
assert!(
!fs::read_to_string(tmp.path().join("src/main.rs"))
.unwrap()
.contains("autumn_web::push::router()")
);
}
}