diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md
index 1a60d32ce..8299af776 100644
--- a/docs/e2e-testing.md
+++ b/docs/e2e-testing.md
@@ -42,6 +42,33 @@ before the first `make dev` run.
uv run pytest -m e2e tests/e2e
```
+## Inertia v3 fixture pages
+
+The framework's own pages never use partial reloads, deferred, merge or `once`
+props, infinite scroll, error bags, history encryption, `WhenVisible`, polling
+or prefetch. The dev host therefore ships fixture pages that do, so
+`tests/e2e/inertia/` can drive those client features in a real
+browser:
+
+- Routes: [`host/e2e_inertia/`](../host/e2e_inertia/) under
+ `/__e2e/inertia/*`, plus loader counters at `GET /__e2e/inertia/_probes`
+ (reset with `POST /__e2e/inertia/_probes/reset`).
+- Pages: `host/client_app/pages/E2eInertia/*.tsx`, rendered as
+ `E2eInertia/`. They are marked `i18n-exempt-file` and never shown to
+ users.
+- Gating: `host/main.py` mounts the router only when `SM_ENVIRONMENT` is
+ `development`, and every route needs a signed-in user. In any other
+ environment the paths do not exist (`host/tests/test_e2e_inertia_gating.py`).
+- Fixtures: `tests/e2e/inertia/conftest.py` (scoped to that directory, so every other e2e test keeps the plain `page`) signs in once per session through
+ the API and fails a test on any console or page error. Mark a test
+ `allow_resource_errors` when it provokes a 4xx/5xx on purpose.
+
+Run them against a development server (Vite required, as for every e2e test):
+
+```bash
+uv run pytest -m e2e tests/e2e/inertia
+```
+
## Configuration
When you write e2e tests, read these environment variables (all optional):
diff --git a/host/client_app/pages.ts b/host/client_app/pages.ts
index 7bd776878..ace4ab5ae 100644
--- a/host/client_app/pages.ts
+++ b/host/client_app/pages.ts
@@ -22,11 +22,17 @@ type PageLoader = () => Promise;
// into the production bundle, shipping the test and dragging testing-library
// into a vendor chunk. Mirrors TEST_FILE_SUFFIXES in the manifest generator,
// which emits the same negations for module pages.
-const hostPages = import.meta.glob([
- './pages/**/*.tsx',
- '!./pages/**/*.test.tsx',
- '!./pages/**/*.spec.tsx',
-]);
+const hostPages = {
+ ...import.meta.glob([
+ './pages/**/*.tsx',
+ '!./pages/**/*.test.tsx',
+ '!./pages/**/*.spec.tsx',
+ '!./pages/E2eInertia/**',
+ ]),
+ // The e2e fixture pages back routes the host mounts only in development
+ // (host/e2e_inertia); a production build constant-folds DEV and drops them.
+ ...(import.meta.env.DEV ? import.meta.glob(['./pages/E2eInertia/*.tsx']) : {}),
+};
const pages: Record = {};
diff --git a/host/client_app/pages/E2eInertia/Deferred.tsx b/host/client_app/pages/E2eInertia/Deferred.tsx
new file mode 100644
index 000000000..1a32881a8
--- /dev/null
+++ b/host/client_app/pages/E2eInertia/Deferred.tsx
@@ -0,0 +1,23 @@
+// i18n-exempt-file: e2e fixture page, never shown to users
+import { Deferred as InertiaDeferred } from '@inertiajs/react';
+import { E2ePage, Value } from './components/E2ePage';
+
+export default function Deferred({ fast, slow }: { fast?: number; slow?: number }) {
+ return (
+
+ Loading…
}>
+
+
+ Loading…}>
+
+
+ Loading…}
+ rescue={Loader failed safely
}
+ >
+ Loader ready
+
+
+ );
+}
diff --git a/host/client_app/pages/E2eInertia/Forms.tsx b/host/client_app/pages/E2eInertia/Forms.tsx
new file mode 100644
index 000000000..e73d2b424
--- /dev/null
+++ b/host/client_app/pages/E2eInertia/Forms.tsx
@@ -0,0 +1,68 @@
+// i18n-exempt-file: e2e fixture page, never shown to users
+import { useForm } from '@inertiajs/react';
+import { buttonClass, E2ePage } from './components/E2ePage';
+
+function DemoForm({ bag }: { bag: 'default' | 'second' }) {
+ const form = useForm({ name: '', email: '' });
+ return (
+
+ );
+}
+
+export default function Forms({ success }: { success: string }) {
+ return (
+
+ {success ? `Saved ${success} form` : ''}
+
+
+
+ );
+}
diff --git a/host/client_app/pages/E2eInertia/History.tsx b/host/client_app/pages/E2eInertia/History.tsx
new file mode 100644
index 000000000..999b9ebf6
--- /dev/null
+++ b/host/client_app/pages/E2eInertia/History.tsx
@@ -0,0 +1,43 @@
+// i18n-exempt-file: e2e fixture page, never shown to users
+import { Link, router } from '@inertiajs/react';
+import { buttonClass, E2ePage, Value } from './components/E2ePage';
+
+export default function History({ mode }: { mode: string }) {
+ return (
+
+
+
+
+ Encrypt history
+
+
+ Clear history
+
+
+ Redirect keeping fragment
+
+
+ Redirect without preserveFragment
+
+ router.reload()}
+ data-testid="e2e-history-preserve"
+ >
+ Reload preserving fragment
+
+
+
+
+ History section
+
+
+ );
+}
diff --git a/host/client_app/pages/E2eInertia/Index.tsx b/host/client_app/pages/E2eInertia/Index.tsx
new file mode 100644
index 000000000..e37a2fa66
--- /dev/null
+++ b/host/client_app/pages/E2eInertia/Index.tsx
@@ -0,0 +1,38 @@
+// i18n-exempt-file: e2e fixture page, never shown to users
+import { Link, usePage } from '@inertiajs/react';
+import { E2ePage } from './components/E2ePage';
+
+export default function Index() {
+ const { auth } = usePage<{ auth: { user: { email: string } | null } }>().props;
+ const features = [
+ 'partial',
+ 'deferred',
+ 'merge',
+ 'once',
+ 'scroll',
+ 'forms',
+ 'redirects',
+ 'history',
+ 'visible',
+ ] as const;
+ return (
+
+
+ Signed in: {auth.user?.email}
+
+
+ {features.map((slug) => (
+
+
+ {slug}
+
+
+ ))}
+
+
+ );
+}
diff --git a/host/client_app/pages/E2eInertia/Merge.tsx b/host/client_app/pages/E2eInertia/Merge.tsx
new file mode 100644
index 000000000..0f6441f77
--- /dev/null
+++ b/host/client_app/pages/E2eInertia/Merge.tsx
@@ -0,0 +1,68 @@
+// i18n-exempt-file: e2e fixture page, never shown to users
+import { router } from '@inertiajs/react';
+import { useState } from 'react';
+import { buttonClass, E2ePage, Value } from './components/E2ePage';
+
+type Props = {
+ page: number;
+ items: number[];
+ prepended: number[];
+ tree: Record;
+ matched: { id: number; revision: number }[];
+};
+export default function Merge({ page: initialPage, ...props }: Props) {
+ const [loading, setLoading] = useState(false);
+ const [pages, setPages] = useState({
+ items: initialPage,
+ prepended: initialPage,
+ tree: initialPage,
+ matched: initialPage,
+ });
+ function more(name: keyof typeof props) {
+ const page = pages[name] + 1;
+ router.reload({
+ onStart: () => setLoading(true),
+ onFinish: () => setLoading(false),
+ only: [name],
+ data: { page },
+ onSuccess: () => setPages((previous) => ({ ...previous, [name]: page })),
+ });
+ }
+ return (
+
+ {(['items', 'prepended', 'tree', 'matched'] as const).map((name) => (
+
+ {name}
+
+ more(name)}
+ >
+ Load more
+
+
+ ))}
+
+ router.reload({
+ onStart: () => setLoading(true),
+ onFinish: () => setLoading(false),
+ only: ['items'],
+ data: { page: 1 },
+ reset: ['items'],
+ onSuccess: () => setPages((previous) => ({ ...previous, items: 1 })),
+ })
+ }
+ >
+ Reset items
+
+
+ );
+}
diff --git a/host/client_app/pages/E2eInertia/Once.tsx b/host/client_app/pages/E2eInertia/Once.tsx
new file mode 100644
index 000000000..8de323a9b
--- /dev/null
+++ b/host/client_app/pages/E2eInertia/Once.tsx
@@ -0,0 +1,31 @@
+// i18n-exempt-file: e2e fixture page, never shown to users
+import { Link, router } from '@inertiajs/react';
+import { buttonClass, E2ePage, Value } from './components/E2ePage';
+
+export default function Once({ cached, expiring }: { cached: number; expiring: number }) {
+ return (
+
+
+
+
+ Visit index and return
+
+ router.reload({ only: ['cached'] })}
+ >
+ Refresh once
+
+ router.reload({ only: ['expiring'] })}
+ >
+ Refresh expiring value
+
+
+ );
+}
diff --git a/host/client_app/pages/E2eInertia/Partial.tsx b/host/client_app/pages/E2eInertia/Partial.tsx
new file mode 100644
index 000000000..40cdacfae
--- /dev/null
+++ b/host/client_app/pages/E2eInertia/Partial.tsx
@@ -0,0 +1,47 @@
+// i18n-exempt-file: e2e fixture page, never shown to users
+import { router } from '@inertiajs/react';
+import { buttonClass, E2ePage, Value } from './components/E2ePage';
+
+type Props = {
+ stamp: number;
+ a?: number;
+ b?: number;
+ nested?: { x?: number; y?: number };
+};
+export default function Partial({ stamp, a, b, nested }: Props) {
+ return (
+
+
+ router.reload({ only: ['a'] })}
+ >
+ Reload only a
+
+ router.reload({ except: ['b'] })}
+ >
+ Reload except b
+
+ router.reload({ only: ['nested.x'] })}
+ >
+ Reload only nested.x
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/host/client_app/pages/E2eInertia/Redirects.tsx b/host/client_app/pages/E2eInertia/Redirects.tsx
new file mode 100644
index 000000000..64be21e57
--- /dev/null
+++ b/host/client_app/pages/E2eInertia/Redirects.tsx
@@ -0,0 +1,50 @@
+// i18n-exempt-file: e2e fixture page, never shown to users
+import { Link, router } from '@inertiajs/react';
+import { buttonClass, E2ePage, Value } from './components/E2ePage';
+
+export default function Redirects({ done }: { done: string }) {
+ return (
+
+
+
+ {(['put', 'patch', 'delete'] as const).map((method) => (
+ router[method]('/__e2e/inertia/redirects/mutate')}
+ >
+ {method.toUpperCase()} redirect
+
+ ))}
+ router.post('/__e2e/inertia/redirects/empty')}
+ >
+ Empty response
+
+ router.post('/__e2e/inertia/redirects/back')}
+ >
+ Redirect back
+
+
+ Go to section 2
+
+
+ External location
+
+
+
+
+ Section 2
+
+
+ );
+}
diff --git a/host/client_app/pages/E2eInertia/Scroll.tsx b/host/client_app/pages/E2eInertia/Scroll.tsx
new file mode 100644
index 000000000..f887d72a2
--- /dev/null
+++ b/host/client_app/pages/E2eInertia/Scroll.tsx
@@ -0,0 +1,40 @@
+// i18n-exempt-file: e2e fixture page, never shown to users
+import { InfiniteScroll, router } from '@inertiajs/react';
+import { buttonClass, E2ePage } from './components/E2ePage';
+
+type Props = { posts: { data: { id: number }[] }; filter: 'all' | 'even' };
+export default function Scroll({ posts, filter }: Props) {
+ return (
+
+ {filter === 'all' ? 'All posts' : 'Even posts'}
+
+ router.reload({
+ only: ['posts', 'filter'],
+ reset: ['posts'],
+ data: { page: 1, filter: filter === 'all' ? 'even' : 'all' },
+ })
+ }
+ >
+ Toggle filter and reset
+
+ Loading…}
+ >
+
+ {posts.data.map((post) => (
+
+ Post {post.id}
+
+ ))}
+
+
+
+ );
+}
diff --git a/host/client_app/pages/E2eInertia/Visible.tsx b/host/client_app/pages/E2eInertia/Visible.tsx
new file mode 100644
index 000000000..4c1f5750c
--- /dev/null
+++ b/host/client_app/pages/E2eInertia/Visible.tsx
@@ -0,0 +1,27 @@
+// i18n-exempt-file: e2e fixture page, never shown to users
+import { Link, usePoll, WhenVisible } from '@inertiajs/react';
+import { buttonClass, E2ePage, Value } from './components/E2ePage';
+
+export default function Visible({ tick, below }: { tick: number; below?: number }) {
+ const poll = usePoll(1000, { only: ['tick'] });
+ return (
+
+
+ (poll.polling ? poll.stop() : poll.start())}
+ >
+ {poll.polling ? 'Stop polling' : 'Start polling'}
+
+
+ Prefetch partial reloads
+
+
+ Loading…}>
+
+
+
+ );
+}
diff --git a/host/client_app/pages/E2eInertia/components/E2ePage.tsx b/host/client_app/pages/E2eInertia/components/E2ePage.tsx
new file mode 100644
index 000000000..29604bc88
--- /dev/null
+++ b/host/client_app/pages/E2eInertia/components/E2ePage.tsx
@@ -0,0 +1,34 @@
+// i18n-exempt-file: e2e fixture page, never shown to users
+import { Head, Link, router } from '@inertiajs/react';
+import { type ReactNode, useEffect, useState } from 'react';
+
+export function E2ePage({ title, children }: { title: string; children: ReactNode }) {
+ const [requests, setRequests] = useState(0);
+ useEffect(() => router.on('finish', () => setRequests((value) => value + 1)), []);
+ return (
+ <>
+
+
+
+ Inertia feature lab
+
+
+ Completed requests: {requests}
+
+
+
+ {children}
+
+ >
+ );
+}
+
+export function Value({ name, value }: { name: string; value: unknown }) {
+ return (
+
+ {value === undefined ? 'Not loaded' : JSON.stringify(value)}
+
+ );
+}
+
+export const buttonClass = 'rounded border px-3 py-2 hover:bg-muted disabled:opacity-50';
diff --git a/host/e2e_inertia/__init__.py b/host/e2e_inertia/__init__.py
new file mode 100644
index 000000000..012ff3c5f
--- /dev/null
+++ b/host/e2e_inertia/__init__.py
@@ -0,0 +1,42 @@
+"""Development-only Inertia v3 fixture routes for the browser e2e suite.
+
+The framework's own pages never use partial reloads, deferred props, merge
+props, once props, infinite scroll, error bags, history encryption,
+``WhenVisible``, polling or prefetch. These routes exist so
+``tests/e2e/inertia/`` can drive those client features in a real
+browser. The host mounts them only when ``settings.is_development`` is true.
+"""
+
+from typing import Protocol
+
+from fastapi import APIRouter, Depends, FastAPI
+from simple_module_hosting.token_guard import Authenticated
+
+from host.e2e_inertia import features, forms, navigation
+from host.e2e_inertia.constants import ROUTE_PREFIX
+
+
+def build_router() -> APIRouter:
+ """Return the fixture router; every route needs a signed-in user."""
+ router = APIRouter(
+ prefix=ROUTE_PREFIX,
+ include_in_schema=False,
+ dependencies=[Depends(Authenticated())],
+ )
+ router.include_router(features.router)
+ router.include_router(forms.router)
+ router.include_router(navigation.router)
+ return router
+
+
+class _HasIsDevelopment(Protocol):
+ @property
+ def is_development(self) -> bool: ...
+
+
+def mount_if_development(app: FastAPI, settings: _HasIsDevelopment) -> bool:
+ """Mount the fixture routes on ``app`` only in development; report whether it did."""
+ if not settings.is_development:
+ return False
+ app.include_router(build_router())
+ return True
diff --git a/host/e2e_inertia/constants.py b/host/e2e_inertia/constants.py
new file mode 100644
index 000000000..06a0ee516
--- /dev/null
+++ b/host/e2e_inertia/constants.py
@@ -0,0 +1,17 @@
+"""Stable identifiers for the development-only Inertia e2e fixture routes."""
+
+ROUTE_PREFIX = "/__e2e/inertia"
+
+PAGE_INDEX = "E2eInertia/Index"
+PAGE_PARTIAL = "E2eInertia/Partial"
+PAGE_DEFERRED = "E2eInertia/Deferred"
+PAGE_MERGE = "E2eInertia/Merge"
+PAGE_ONCE = "E2eInertia/Once"
+PAGE_SCROLL = "E2eInertia/Scroll"
+PAGE_FORMS = "E2eInertia/Forms"
+PAGE_REDIRECTS = "E2eInertia/Redirects"
+PAGE_HISTORY = "E2eInertia/History"
+PAGE_VISIBLE = "E2eInertia/Visible"
+
+ONCE_EXPIRING_KEY = "e2e-expiring"
+ONCE_EXPIRY_MS = 5000
diff --git a/host/e2e_inertia/features.py b/host/e2e_inertia/features.py
new file mode 100644
index 000000000..69d87c141
--- /dev/null
+++ b/host/e2e_inertia/features.py
@@ -0,0 +1,171 @@
+"""In-memory Inertia protocol demonstrations (props, once, scroll, history)."""
+
+import asyncio
+from time import time
+from typing import Annotated, Literal
+
+from fastapi import APIRouter, Query
+from simple_module_hosting.inertia_deps import InertiaDep
+from simple_module_inertia import always, deep_merge, defer, merge, once, optional, prepend, scroll
+from starlette.responses import RedirectResponse
+
+from host.e2e_inertia import constants as c
+from host.e2e_inertia.probes import count, counters, loader
+
+router = APIRouter()
+
+
+def once_props():
+ return {
+ "cached": once(loader("cached")),
+ "expiring": once(
+ loader("expiring"),
+ key=c.ONCE_EXPIRING_KEY,
+ expires_at=int(time() * 1000) + c.ONCE_EXPIRY_MS,
+ ),
+ }
+
+
+@router.get("/")
+async def index(inertia: InertiaDep):
+ # The client carries once values across components that declare the same cache keys.
+ return await inertia.render(
+ c.PAGE_INDEX, {name: optional(prop) for name, prop in once_props().items()}
+ )
+
+
+@router.get("/_probes")
+async def probes():
+ return counters.copy()
+
+
+@router.post("/_probes/reset")
+async def reset_probes():
+ counters.clear()
+ return {}
+
+
+@router.get("/partial")
+async def partial(inertia: InertiaDep):
+ return await inertia.render(
+ c.PAGE_PARTIAL,
+ {
+ "stamp": always(loader("stamp")),
+ "a": optional(loader("a")),
+ "b": loader("b"),
+ "nested": {"x": loader("nested.x"), "y": loader("nested.y")},
+ },
+ )
+
+
+async def load_slow():
+ call = count("slow")
+ await asyncio.sleep(0.3)
+ return call
+
+
+def load_fragile():
+ count("fragile")
+ raise RuntimeError("Intentional rescued e2e loader failure")
+
+
+@router.get("/deferred")
+async def deferred(inertia: InertiaDep):
+ return await inertia.render(
+ c.PAGE_DEFERRED,
+ {
+ "fast": defer(loader("fast")),
+ "slow": defer(load_slow, group="slow"),
+ "fragile": defer(load_fragile, rescue=True),
+ },
+ )
+
+
+@router.get("/merge")
+async def merging(inertia: InertiaDep, page: Annotated[int, Query(ge=1, le=100)] = 1):
+ start = (page - 1) * 3 + 1
+ return await inertia.render(
+ c.PAGE_MERGE,
+ {
+ "page": page,
+ "items": merge(loader("items", list(range(start, start + 3)))),
+ "prepended": prepend(loader("prepended", list(range(-start - 2, -start + 1)))),
+ "tree": deep_merge(loader("tree", {"pages": {str(page): page}, "latest": page})),
+ "matched": merge(
+ loader(
+ "matched",
+ [
+ {"id": 1, "revision": page},
+ {"id": page + 1, "revision": page},
+ ],
+ ),
+ match_on="id",
+ ),
+ },
+ )
+
+
+@router.get("/once")
+async def cached(inertia: InertiaDep):
+ return await inertia.render(c.PAGE_ONCE, once_props())
+
+
+@router.get("/scroll")
+async def scrolling(
+ inertia: InertiaDep,
+ page: Annotated[int, Query(ge=1, le=10)] = 1,
+ filter: Literal["all", "even"] = "all",
+):
+ ids = [number for number in range(1, 101) if filter == "all" or number % 2 == 0]
+ last_page = (len(ids) + 9) // 10
+ # Query validation bounds the public page number; a narrower filter can end earlier.
+ posts = [{"id": number} for number in ids[(page - 1) * 10 : page * 10]]
+ return await inertia.render(
+ c.PAGE_SCROLL,
+ {
+ "posts": scroll(
+ loader("posts", {"data": posts}),
+ page_name="page",
+ current_page=page,
+ previous_page=page - 1 if page > 1 else None,
+ next_page=page + 1 if page < last_page else None,
+ ),
+ "filter": filter,
+ },
+ )
+
+
+@router.get("/visible")
+async def visible(inertia: InertiaDep):
+ return await inertia.render(
+ c.PAGE_VISIBLE,
+ {
+ "below": optional(loader("below")),
+ "tick": always(loader("tick")),
+ },
+ )
+
+
+@router.get("/history/moved")
+async def history_moved():
+ # The visit's URL changes on the redirect, so only preserveFragment keeps its #hash.
+ return RedirectResponse(f"{c.ROUTE_PREFIX}/history/fragment", status_code=302)
+
+
+@router.get("/history/moved-plain")
+async def history_moved_plain():
+ return RedirectResponse(f"{c.ROUTE_PREFIX}/history/plain", status_code=302)
+
+
+@router.get("/history")
+@router.get("/history/{mode}")
+async def history(
+ inertia: InertiaDep, mode: Literal["encrypt", "clear", "fragment", "plain"] = "encrypt"
+):
+ if mode == "encrypt":
+ inertia.encrypt_history()
+ elif mode == "clear":
+ inertia.clear_history()
+ elif mode == "fragment":
+ inertia.preserve_fragment()
+ return await inertia.render(c.PAGE_HISTORY, {"mode": mode})
diff --git a/host/e2e_inertia/forms.py b/host/e2e_inertia/forms.py
new file mode 100644
index 000000000..f5a2bd4e0
--- /dev/null
+++ b/host/e2e_inertia/forms.py
@@ -0,0 +1,55 @@
+"""Two isolated form bags and FastAPI's automatic request-validation path."""
+
+from typing import Literal
+
+from fastapi import APIRouter, Request
+from pydantic import ValidationError, field_validator
+from simple_module_hosting.inertia_deps import InertiaDep
+from simple_module_hosting.inertia_utils import redirect_back_with_errors, validation_errors_to_dict
+from sqlmodel import Field, SQLModel
+from starlette.responses import RedirectResponse
+
+from host.e2e_inertia.constants import PAGE_FORMS, ROUTE_PREFIX
+
+router = APIRouter()
+
+_FORMS_URL = f"{ROUTE_PREFIX}/forms"
+
+
+class FormInput(SQLModel):
+ name: str = Field(min_length=1, max_length=200)
+ email: str = Field(min_length=1, max_length=254)
+
+ @field_validator("name")
+ @classmethod
+ def nonblank_name(cls, value: str):
+ if not value.strip():
+ raise ValueError("name_required")
+ return value
+
+ @field_validator("email")
+ @classmethod
+ def email_contains_at(cls, value: str):
+ if "@" not in value:
+ raise ValueError("email_invalid")
+ return value
+
+
+@router.get("/forms")
+async def forms(inertia: InertiaDep, success: Literal["", "default", "second", "validated"] = ""):
+ return await inertia.render(PAGE_FORMS, {"success": success})
+
+
+@router.post("/forms/validated")
+async def validated(data: FormInput):
+ return RedirectResponse(f"{_FORMS_URL}?success=validated", status_code=303)
+
+
+@router.post("/forms/{bag}")
+async def submit(bag: Literal["default", "second"], request: Request, data: dict[str, object]):
+ try:
+ FormInput.model_validate(data)
+ except ValidationError as exc:
+ errors = {field: f"Invalid {field}" for field in validation_errors_to_dict(exc)}
+ return redirect_back_with_errors(request, errors)
+ return RedirectResponse(f"{_FORMS_URL}?success={bag}", status_code=303)
diff --git a/host/e2e_inertia/navigation.py b/host/e2e_inertia/navigation.py
new file mode 100644
index 000000000..be7c72109
--- /dev/null
+++ b/host/e2e_inertia/navigation.py
@@ -0,0 +1,53 @@
+"""Redirect normalization, history targets and hosting error demonstrations."""
+
+from html import escape
+
+from fastapi import APIRouter, Request
+from simple_module_hosting.inertia_deps import InertiaDep
+from starlette.responses import HTMLResponse, RedirectResponse, Response
+
+from host.e2e_inertia.constants import PAGE_REDIRECTS, ROUTE_PREFIX
+
+router = APIRouter()
+
+_REDIRECTS_URL = f"{ROUTE_PREFIX}/redirects"
+_EXTERNAL_LANDED = "External HTML destination"
+
+
+@router.get("/redirects")
+async def redirects(inertia: InertiaDep, done: str = ""):
+ return await inertia.render(PAGE_REDIRECTS, {"done": done})
+
+
+@router.api_route("/redirects/mutate", methods=["PUT", "PATCH", "DELETE"])
+async def mutate(request: Request):
+ return RedirectResponse(f"{_REDIRECTS_URL}?done={request.method.lower()}", status_code=302)
+
+
+@router.post("/redirects/empty")
+async def empty():
+ return Response(status_code=200)
+
+
+@router.post("/redirects/back")
+async def back(inertia: InertiaDep):
+ return inertia.back()
+
+
+@router.get("/redirects/fragment")
+async def fragment():
+ return RedirectResponse(f"{_REDIRECTS_URL}#section-2", status_code=302)
+
+
+@router.get("/redirects/external")
+async def external(inertia: InertiaDep):
+ return inertia.location(f"{ROUTE_PREFIX}/_external")
+
+
+@router.get("/_external")
+async def external_target():
+ return HTMLResponse(
+ ''
+ + escape(_EXTERNAL_LANDED)
+ + " "
+ )
diff --git a/host/e2e_inertia/probes.py b/host/e2e_inertia/probes.py
new file mode 100644
index 000000000..86e05380b
--- /dev/null
+++ b/host/e2e_inertia/probes.py
@@ -0,0 +1,19 @@
+"""Per-process loader counters, deliberately observable by the e2e tests."""
+
+from collections.abc import Callable
+from typing import Any
+
+counters: dict[str, int] = {}
+
+
+def count(name: str) -> int:
+ counters[name] = counters.get(name, 0) + 1
+ return counters[name]
+
+
+def loader(name: str, value: Any = None) -> Callable[[], Any]:
+ def load():
+ call = count(name)
+ return call if value is None else value
+
+ return load
diff --git a/host/main.py b/host/main.py
index 4290ec95e..d5b869a07 100644
--- a/host/main.py
+++ b/host/main.py
@@ -16,6 +16,7 @@
from simple_module_hosting import create_app, merge_host_settings # noqa: E402
from simple_module_hosting.logging import setup_logging # noqa: E402
+from host.e2e_inertia import mount_if_development as mount_e2e_inertia_if_development # noqa: E402
from host.routes import router as host_router # noqa: E402
from host.routes_legacy import router as legacy_router # noqa: E402
@@ -32,6 +33,8 @@
app = create_app(settings)
app.include_router(host_router)
+# Fixture pages for tests/e2e/inertia/; development only.
+mount_e2e_inertia_if_development(app, settings)
# Mounted last: its catch-all {path:path} routes must not shadow a real
# route that happens to share a legacy prefix.
app.include_router(legacy_router)
diff --git a/host/tests/test_e2e_inertia_gating.py b/host/tests/test_e2e_inertia_gating.py
new file mode 100644
index 000000000..ee386cde2
--- /dev/null
+++ b/host/tests/test_e2e_inertia_gating.py
@@ -0,0 +1,35 @@
+"""The Inertia e2e fixture routes exist in development only and need a signed-in user."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from host.e2e_inertia import mount_if_development
+from host.e2e_inertia.constants import ROUTE_PREFIX
+
+_PATHS = ("/_probes", "/", "/partial", "/forms", "/redirects", "/history")
+
+
+def _client(*, development: bool) -> TestClient:
+ app = FastAPI()
+ mounted = mount_if_development(app, SimpleNamespace(is_development=development))
+ assert mounted is development
+ return TestClient(app, raise_server_exceptions=False)
+
+
+@pytest.mark.parametrize("path", _PATHS)
+def test_absent_outside_development(path: str) -> None:
+ assert _client(development=False).get(f"{ROUTE_PREFIX}{path}").status_code == 404
+
+
+@pytest.mark.parametrize("path", _PATHS)
+def test_anonymous_request_is_refused_in_development(path: str) -> None:
+ assert _client(development=True).get(f"{ROUTE_PREFIX}{path}").status_code == 401
+
+
+def test_probe_reset_is_guarded_too() -> None:
+ assert _client(development=True).post(f"{ROUTE_PREFIX}/_probes/reset").status_code == 401
diff --git a/tests/e2e/inertia/__init__.py b/tests/e2e/inertia/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/e2e/inertia/conftest.py b/tests/e2e/inertia/conftest.py
new file mode 100644
index 000000000..886d79b85
--- /dev/null
+++ b/tests/e2e/inertia/conftest.py
@@ -0,0 +1,101 @@
+"""Fixtures for the Inertia fixture-page browser tests in this directory.
+
+They drive the development-only pages under ``/__e2e/inertia``
+(``host/e2e_inertia``). ``page`` is overridden here, so only tests in this
+directory get it: signed in through one API login per session, failing the
+test on any console error or uncaught page error. The rest of the e2e suite
+keeps pytest-playwright's plain ``page``.
+"""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Callable, Iterator
+from pathlib import Path
+
+import pytest
+from playwright.sync_api import Browser, BrowserContext, Page, Playwright
+
+PREFIX = "/__e2e/inertia"
+
+
+@pytest.fixture(scope="session")
+def inertia_storage_state(
+ playwright: Playwright,
+ base_url: str,
+ e2e_username: str,
+ e2e_password: str,
+ tmp_path_factory: pytest.TempPathFactory,
+) -> str:
+ path = tmp_path_factory.mktemp("inertia-e2e") / "state.json"
+ api = playwright.request.new_context(base_url=base_url)
+ resp = api.post(
+ "/api/users/auth/login", form={"username": e2e_username, "password": e2e_password}
+ )
+ assert resp.status == 204, f"login failed: {resp.status} {resp.text()}"
+ api.storage_state(path=str(path))
+ api.dispose()
+ return str(path)
+
+
+def _noise(text: str, allow_resource: bool) -> bool:
+ # Browser-generated line for any 4xx/5xx subresource or navigation; only
+ # tests that deliberately provoke those opt in.
+ if allow_resource and "Failed to load resource" in text:
+ return True
+ # React DevTools hint printed by the dev build; not an application error.
+ return "Download the React DevTools" in text
+
+
+@pytest.fixture
+def inertia_context(
+ browser: Browser, browser_context_args: dict, inertia_storage_state: str
+) -> Iterator[BrowserContext]:
+ ctx = browser.new_context(
+ **browser_context_args,
+ storage_state=Path(inertia_storage_state),
+ viewport={"width": 1280, "height": 720},
+ )
+ yield ctx
+ ctx.close()
+
+
+@pytest.fixture
+def page(inertia_context: BrowserContext, request: pytest.FixtureRequest) -> Iterator[Page]:
+ """Signed-in page that fails the test on any console error or page error."""
+ pg = inertia_context.new_page()
+ allow_resource = request.node.get_closest_marker("allow_resource_errors") is not None
+ problems: list[str] = []
+ pg.on("pageerror", lambda e: problems.append(f"pageerror: {e}"))
+ pg.on(
+ "console",
+ lambda m: (
+ m.type == "error"
+ and not _noise(m.text, allow_resource)
+ and problems.append(f"console.error: {m.text}")
+ ),
+ )
+ yield pg
+ pg.close()
+ assert not problems, "browser errors:\n" + "\n".join(problems)
+
+
+@pytest.fixture(autouse=True)
+def reset_probes(inertia_context: BrowserContext) -> None:
+ assert inertia_context.request.post(f"{PREFIX}/_probes/reset").ok
+
+
+@pytest.fixture
+def probes(inertia_context: BrowserContext) -> Callable[[], dict[str, int]]:
+ def read() -> dict[str, int]:
+ return json.loads(inertia_context.request.get(f"{PREFIX}/_probes").text())
+
+ return read
+
+
+def pytest_configure(config):
+ config.addinivalue_line(
+ "markers",
+ "allow_resource_errors: tolerate 'Failed to load resource' console errors "
+ "(tests/e2e/inertia only)",
+ )
diff --git a/tests/e2e/inertia/test_inertia_deferred.py b/tests/e2e/inertia/test_inertia_deferred.py
new file mode 100644
index 000000000..5293ca7f3
--- /dev/null
+++ b/tests/e2e/inertia/test_inertia_deferred.py
@@ -0,0 +1,43 @@
+import pytest
+from playwright.sync_api import expect
+
+pytestmark = pytest.mark.e2e
+
+
+def test_fallback_then_resolve_and_groups(page, probes):
+ page.goto("/__e2e/inertia/deferred")
+ # slow group takes 300ms server side, so its fallback is observable
+ expect(page.get_by_test_id("e2e-slow-loading")).to_be_visible()
+ expect(page.get_by_test_id("e2e-value-fast")).to_have_text("1")
+ expect(page.get_by_test_id("e2e-value-slow")).to_have_text("1")
+ expect(page.get_by_test_id("e2e-slow-loading")).to_have_count(0)
+ expect(page.get_by_test_id("e2e-fast-loading")).to_have_count(0)
+ p = probes()
+ assert p["fast"] == 1 and p["slow"] == 1
+
+
+def test_groups_load_in_separate_requests(page):
+ reqs = []
+ page.on(
+ "request",
+ lambda r: (
+ reqs.append(r.headers.get("x-inertia-partial-data"))
+ if r.headers.get("x-inertia-partial-data")
+ else None
+ ),
+ )
+ page.goto("/__e2e/inertia/deferred")
+ expect(page.get_by_test_id("e2e-value-slow")).to_have_text("1")
+ expect(page.get_by_test_id("e2e-fragile-failed")).to_be_visible()
+ flat = ",".join(r for r in reqs)
+ assert "slow" in flat and "fast" in flat
+ assert not any("slow" in r and "fast" in r for r in reqs), f"groups merged: {reqs}"
+
+
+def test_rescued_prop_shows_failure(page, probes):
+ page.goto("/__e2e/inertia/deferred")
+ expect(page.get_by_test_id("e2e-fragile-failed")).to_be_visible()
+ expect(page.get_by_test_id("e2e-fragile-ready")).to_have_count(0)
+ # the rest of the page still works
+ expect(page.get_by_test_id("e2e-value-fast")).to_have_text("1")
+ assert probes()["fragile"] == 1
diff --git a/tests/e2e/inertia/test_inertia_forms.py b/tests/e2e/inertia/test_inertia_forms.py
new file mode 100644
index 000000000..ac8004458
--- /dev/null
+++ b/tests/e2e/inertia/test_inertia_forms.py
@@ -0,0 +1,71 @@
+import pytest
+from playwright.sync_api import expect
+
+pytestmark = pytest.mark.e2e
+
+
+def err(page, bag, field):
+ return page.get_by_test_id(f"e2e-{bag}-{field}-error")
+
+
+def test_default_bag_errors_only_in_default_form(page):
+ page.goto("/__e2e/inertia/forms")
+ page.get_by_test_id("e2e-default-submit").click()
+ expect(err(page, "default", "name")).not_to_be_empty()
+ expect(err(page, "default", "email")).not_to_be_empty()
+ expect(err(page, "second", "name")).to_be_empty()
+ expect(err(page, "second", "email")).to_be_empty()
+
+
+def test_second_bag_errors_only_in_second_form(page):
+ page.goto("/__e2e/inertia/forms")
+ page.get_by_test_id("e2e-second-submit").click()
+ expect(err(page, "second", "name")).not_to_be_empty()
+ expect(err(page, "second", "email")).not_to_be_empty()
+ expect(err(page, "default", "name")).to_be_empty()
+ expect(err(page, "default", "email")).to_be_empty()
+
+
+def test_bags_independent_then_success_clears(page):
+ page.goto("/__e2e/inertia/forms")
+ page.get_by_test_id("e2e-default-submit").click()
+ expect(err(page, "default", "name")).not_to_be_empty()
+ page.get_by_test_id("e2e-second-submit").click()
+ expect(err(page, "second", "name")).not_to_be_empty()
+ # the second submission must not have wiped/leaked the default bag's errors
+ expect(err(page, "default", "name")).not_to_be_empty()
+ page.get_by_test_id("e2e-default-name").fill("Ada")
+ page.get_by_test_id("e2e-default-email").fill("ada@example.com")
+ page.get_by_test_id("e2e-default-submit").click()
+ expect(page.get_by_test_id("e2e-form-success")).to_contain_text("default")
+ for field in ("name", "email"):
+ expect(err(page, "default", field)).to_be_empty()
+ expect(page).to_have_url("/__e2e/inertia/forms?success=default")
+
+
+def test_partial_invalid_shows_only_bad_field(page):
+ page.goto("/__e2e/inertia/forms")
+ page.get_by_test_id("e2e-second-name").fill("Ada")
+ page.get_by_test_id("e2e-second-email").fill("nope")
+ page.get_by_test_id("e2e-second-submit").click()
+ expect(err(page, "second", "email")).not_to_be_empty()
+ expect(err(page, "second", "name")).to_be_empty()
+ expect(err(page, "default", "email")).to_be_empty()
+
+
+@pytest.mark.allow_resource_errors
+@pytest.mark.xfail(strict=False, reason="fixed by #818")
+def test_validated_route_returns_field_errors(page):
+ page.goto("/__e2e/inertia/forms")
+ seen = []
+ page.on(
+ "response",
+ lambda r: (
+ seen.append((r.status, r.url, r.headers.get("content-type")))
+ if "/__e2e/inertia/forms/validated" in r.url
+ else None
+ ),
+ )
+ page.get_by_test_id("e2e-validated-submit").click()
+ expect(err(page, "default", "name")).not_to_be_empty(timeout=3000)
+ expect(page).to_have_url("/__e2e/inertia/forms")
diff --git a/tests/e2e/inertia/test_inertia_history.py b/tests/e2e/inertia/test_inertia_history.py
new file mode 100644
index 000000000..6afae89d2
--- /dev/null
+++ b/tests/e2e/inertia/test_inertia_history.py
@@ -0,0 +1,94 @@
+import json
+import re
+
+import pytest
+from playwright.sync_api import expect
+
+pytestmark = pytest.mark.e2e
+
+STATE = "JSON.stringify(window.history.state)"
+
+
+def state(page):
+ raw = page.evaluate(STATE)
+ return json.loads(raw) if raw else None
+
+
+def test_plain_page_history_state_is_readable(page):
+ """Control: an unencrypted page exposes its component and props in history.state."""
+ page.goto("/__e2e/inertia/partial")
+ expect(page.get_by_test_id("e2e-value-stamp")).to_be_visible()
+ page.wait_for_function("window.history.state && window.history.state.page")
+ st = state(page)
+ assert st["page"]["component"] == "E2eInertia/Partial", st
+
+
+def test_encrypted_history_state(page):
+ page.goto("/__e2e/inertia/")
+ page.get_by_test_id("e2e-link-history").click()
+ expect(page.get_by_test_id("e2e-value-history-mode")).to_have_text('"encrypt"')
+ raw = page.evaluate(STATE)
+ assert "E2eInertia/History" not in raw and "component" not in raw, raw[:300]
+ st = json.loads(raw)
+ assert isinstance(st["page"], (str, dict)) and not (
+ isinstance(st["page"], dict) and "component" in st["page"]
+ ), st
+ assert page.evaluate("window.history.state.page === undefined") is False
+
+
+def test_encrypted_page_survives_back_forward(page):
+ page.goto("/__e2e/inertia/")
+ page.evaluate("window.__marker = 1")
+ page.get_by_test_id("e2e-link-history").click()
+ expect(page.get_by_test_id("e2e-value-history-mode")).to_have_text('"encrypt"')
+ page.go_back()
+ expect(page.get_by_test_id("e2e-user-email")).to_be_visible()
+ seen = []
+ page.on("request", lambda r: seen.append(r.url))
+ page.go_forward()
+ expect(page.get_by_test_id("e2e-value-history-mode")).to_have_text('"encrypt"')
+ assert page.evaluate("window.__marker") == 1, "decrypting should not need a reload"
+ assert not [u for u in seen if "/__e2e/inertia/history" in u], (
+ f"should restore from history: {seen}"
+ )
+
+
+def test_clear_history_forces_refetch_of_earlier_encrypted_entries(page, base_url):
+ page.goto("/__e2e/inertia/history")
+ expect(page.get_by_test_id("e2e-value-history-mode")).to_have_text('"encrypt"')
+ page.evaluate("window.__marker = 1")
+ page.get_by_test_id("e2e-history-clear").click()
+ expect(page.get_by_test_id("e2e-value-history-mode")).to_have_text('"clear"')
+ assert page.evaluate("window.__marker") == 1
+ seen = []
+ page.on(
+ "request",
+ lambda r: (
+ seen.append((r.url, r.headers.get("x-inertia")))
+ if "/__e2e/inertia/history" in r.url
+ else None
+ ),
+ )
+ page.go_back()
+ # Key rotated: the previous encrypted entry cannot be decrypted, so the client
+ # falls back to an Inertia XHR refetch (not a full document reload).
+ expect(page.get_by_test_id("e2e-value-history-mode")).to_have_text('"encrypt"')
+ expect(page).to_have_url("/__e2e/inertia/history")
+ assert seen == [(f"{base_url}/__e2e/inertia/history", "true")], seen
+ assert page.evaluate("window.__marker") == 1
+
+
+def test_preserve_fragment_keeps_hash_across_a_redirect(page):
+ page.goto("/__e2e/inertia/history")
+ page.get_by_test_id("e2e-history-fragment").click()
+ expect(page.get_by_test_id("e2e-value-history-mode")).to_have_text('"fragment"')
+ expect(page).to_have_url(re.compile(r"/__e2e/inertia/history/fragment#history-section$"))
+ expect(page.get_by_test_id("e2e-history-section")).to_be_in_viewport()
+
+
+def test_redirect_without_preserve_fragment_drops_hash(page):
+ """Control: proves the test above depends on the flag, not default client behaviour."""
+ page.goto("/__e2e/inertia/history")
+ page.get_by_test_id("e2e-history-fragment-plain").click()
+ expect(page.get_by_test_id("e2e-value-history-mode")).to_have_text('"plain"')
+ expect(page).to_have_url(re.compile(r"/__e2e/inertia/history/plain$"))
diff --git a/tests/e2e/inertia/test_inertia_merge.py b/tests/e2e/inertia/test_inertia_merge.py
new file mode 100644
index 000000000..3a319e005
--- /dev/null
+++ b/tests/e2e/inertia/test_inertia_merge.py
@@ -0,0 +1,71 @@
+import json
+
+import pytest
+from playwright.sync_api import expect
+
+pytestmark = pytest.mark.e2e
+
+
+def val(page, name):
+ return page.get_by_test_id(f"e2e-value-{name}")
+
+
+def more(page, name, times=1):
+ for _ in range(times):
+ btn = page.get_by_test_id(f"e2e-more-{name}")
+ expect(btn).to_be_enabled()
+ btn.click()
+ expect(btn).to_be_enabled()
+
+
+def test_append_items(page):
+ page.goto("/__e2e/inertia/merge")
+ expect(val(page, "items")).to_have_text("[1,2,3]")
+ more(page, "items")
+ expect(val(page, "items")).to_have_text("[1,2,3,4,5,6]")
+ more(page, "items")
+ expect(val(page, "items")).to_have_text("[1,2,3,4,5,6,7,8,9]")
+
+
+def test_prepend(page):
+ page.goto("/__e2e/inertia/merge")
+ expect(val(page, "prepended")).to_have_text("[-3,-2,-1]")
+ more(page, "prepended")
+ expect(val(page, "prepended")).to_have_text("[-6,-5,-4,-3,-2,-1]")
+
+
+def test_deep_merge(page):
+ page.goto("/__e2e/inertia/merge")
+ more(page, "tree")
+ expect(page.get_by_test_id("e2e-requests")).to_have_text("1")
+ got = json.loads(val(page, "tree").inner_text())
+ assert got == {"pages": {"1": 1, "2": 2}, "latest": 2}
+
+
+def test_match_on_replaces_in_place(page):
+ page.goto("/__e2e/inertia/merge")
+ expect(val(page, "matched")).to_have_text('[{"id":1,"revision":1},{"id":2,"revision":1}]')
+ more(page, "matched")
+ got = json.loads(val(page, "matched").inner_text())
+ assert got == [
+ {"id": 1, "revision": 2},
+ {"id": 2, "revision": 1},
+ {"id": 3, "revision": 2},
+ ]
+
+
+def test_reset_replaces(page):
+ page.goto("/__e2e/inertia/merge")
+ more(page, "items", 2)
+ expect(val(page, "items")).to_have_text("[1,2,3,4,5,6,7,8,9]")
+ page.get_by_test_id("e2e-reset-items").click()
+ expect(val(page, "items")).to_have_text("[1,2,3]")
+ more(page, "items")
+ expect(val(page, "items")).to_have_text("[1,2,3,4,5,6]")
+
+
+def test_no_duplicates_after_many_loads(page):
+ page.goto("/__e2e/inertia/merge")
+ more(page, "items", 4)
+ got = json.loads(val(page, "items").inner_text())
+ assert got == list(range(1, 16))
diff --git a/tests/e2e/inertia/test_inertia_navigation.py b/tests/e2e/inertia/test_inertia_navigation.py
new file mode 100644
index 000000000..750faf78e
--- /dev/null
+++ b/tests/e2e/inertia/test_inertia_navigation.py
@@ -0,0 +1,58 @@
+import pytest
+from playwright.sync_api import expect
+
+pytestmark = pytest.mark.e2e
+
+
+def test_index_renders(page, e2e_username):
+ page.goto("/__e2e/inertia/")
+ expect(page.get_by_test_id("e2e-user-email")).to_have_text(e2e_username)
+ expect(page.get_by_test_id("e2e-link-partial")).to_be_visible()
+
+
+def test_link_navigation_is_client_side(page):
+ page.goto("/__e2e/inertia/")
+ page.evaluate("window.__marker = 'alive'")
+ page.get_by_test_id("e2e-link-partial").click()
+ expect(page).to_have_url("/__e2e/inertia/partial")
+ expect(page.get_by_test_id("e2e-value-stamp")).to_be_visible()
+ assert page.evaluate("window.__marker") == "alive"
+
+
+def test_back_forward(page):
+ page.goto("/__e2e/inertia/")
+ page.evaluate("window.__marker = 'alive'")
+ page.get_by_test_id("e2e-link-partial").click()
+ expect(page.get_by_test_id("e2e-value-stamp")).to_be_visible()
+ page.go_back()
+ expect(page.get_by_test_id("e2e-user-email")).to_be_visible()
+ expect(page).to_have_url("/__e2e/inertia/")
+ page.go_forward()
+ expect(page.get_by_test_id("e2e-value-stamp")).to_be_visible()
+ assert page.evaluate("window.__marker") == "alive"
+
+
+@pytest.mark.allow_resource_errors # the 409 is the point of this test
+def test_stale_asset_version_forces_full_reload(page):
+ """A 409 from a mismatched X-Inertia-Version must make the client hard-reload."""
+ page.goto("/__e2e/inertia/")
+ page.evaluate("window.__marker = 'alive'")
+
+ def stale(route):
+ headers = dict(route.request.headers)
+ if headers.get("x-inertia"):
+ headers["x-inertia-version"] = "stale-version"
+ route.continue_(headers=headers)
+
+ page.route("**/__e2e/inertia/partial", stale)
+ seen = []
+ page.on(
+ "response",
+ lambda r: seen.append((r.status, r.url)) if "/__e2e/inertia/partial" in r.url else None,
+ )
+ with page.expect_navigation():
+ page.get_by_test_id("e2e-link-partial").click()
+ page.unroute("**/__e2e/inertia/partial")
+ expect(page.get_by_test_id("e2e-value-stamp")).to_be_visible()
+ assert page.evaluate("window.__marker") is None, "expected a full page reload"
+ assert seen[0][0] == 409, seen
diff --git a/tests/e2e/inertia/test_inertia_once.py b/tests/e2e/inertia/test_inertia_once.py
new file mode 100644
index 000000000..15147cf53
--- /dev/null
+++ b/tests/e2e/inertia/test_inertia_once.py
@@ -0,0 +1,49 @@
+import pytest
+from playwright.sync_api import expect
+
+pytestmark = pytest.mark.e2e
+
+
+def val(page, name):
+ return page.get_by_test_id(f"e2e-value-{name}")
+
+
+def test_once_reused_after_navigating_away_and_back(page, probes):
+ page.goto("/__e2e/inertia/")
+ page.evaluate("window.__marker = 1")
+ page.get_by_test_id("e2e-link-once").click()
+ expect(val(page, "cached")).to_have_text("1")
+ assert probes()["cached"] == 1
+ page.get_by_test_id("e2e-once-away").click()
+ expect(page.get_by_test_id("e2e-user-email")).to_be_visible()
+ page.get_by_test_id("e2e-link-once").click()
+ expect(val(page, "cached")).to_have_text("1")
+ assert probes()["cached"] == 1, "cached once value must not be reloaded"
+ assert page.evaluate("window.__marker") == 1
+
+
+def test_once_expiry_refetches(page, probes):
+ page.goto("/__e2e/inertia/once")
+ expect(val(page, "expiring")).to_have_text("1")
+ page.get_by_test_id("e2e-once-away").click()
+ expect(page.get_by_test_id("e2e-user-email")).to_be_visible()
+ page.get_by_test_id("e2e-link-once").click()
+ expect(val(page, "expiring")).to_have_text("1") # still fresh
+ assert probes()["expiring"] == 1
+ page.get_by_test_id("e2e-once-away").click()
+ expect(page.get_by_test_id("e2e-user-email")).to_be_visible()
+ page.wait_for_timeout(5500) # real 5s server-side expiry; no event to wait on
+ page.get_by_test_id("e2e-link-once").click()
+ expect(val(page, "expiring")).to_have_text("2")
+ assert probes()["expiring"] == 2
+ assert probes()["cached"] == 1
+
+
+def test_explicit_refresh_updates(page, probes):
+ page.goto("/__e2e/inertia/once")
+ expect(val(page, "cached")).to_have_text("1")
+ page.get_by_test_id("e2e-refresh-once").click()
+ expect(val(page, "cached")).to_have_text("2")
+ assert probes()["cached"] == 2
+ page.get_by_test_id("e2e-refresh-expiring").click()
+ expect(val(page, "expiring")).to_have_text("2")
diff --git a/tests/e2e/inertia/test_inertia_partial.py b/tests/e2e/inertia/test_inertia_partial.py
new file mode 100644
index 000000000..1a014ed76
--- /dev/null
+++ b/tests/e2e/inertia/test_inertia_partial.py
@@ -0,0 +1,63 @@
+import pytest
+from playwright.sync_api import expect
+
+pytestmark = pytest.mark.e2e
+
+
+def v(page, name):
+ return page.get_by_test_id(f"e2e-value-{name}")
+
+
+def test_initial_state_optional_missing(page, probes):
+ page.goto("/__e2e/inertia/partial")
+ expect(v(page, "stamp")).to_have_text("1")
+ expect(v(page, "b")).to_have_text("1")
+ expect(v(page, "nested-x")).to_have_text("1")
+ expect(v(page, "nested-y")).to_have_text("1")
+ p = probes()
+ assert "a" not in p
+ assert p["stamp"] == 1 and p["b"] == 1
+
+
+def test_only_a(page, probes):
+ page.goto("/__e2e/inertia/partial")
+ expect(v(page, "stamp")).to_have_text("1")
+ page.get_by_test_id("e2e-only-a").click()
+ expect(v(page, "a")).to_have_text("1")
+ expect(v(page, "stamp")).to_have_text("2") # always props ride along
+ expect(v(page, "b")).to_have_text("1") # untouched
+ expect(v(page, "nested-x")).to_have_text("1")
+ p = probes()
+ assert p["a"] == 1 and p["b"] == 1 and p["stamp"] == 2
+ assert p["nested.x"] == 1 and p["nested.y"] == 1
+
+
+def test_except_b(page, probes):
+ page.goto("/__e2e/inertia/partial")
+ expect(v(page, "stamp")).to_have_text("1")
+ page.get_by_test_id("e2e-except-b").click()
+ expect(v(page, "stamp")).to_have_text("2")
+ expect(v(page, "nested-x")).to_have_text("2")
+ expect(v(page, "nested-y")).to_have_text("2")
+ expect(v(page, "b")).to_have_text("1")
+ p = probes()
+ assert p["b"] == 1, "excluded loader must not run"
+ # Observed: with `except` only (no `only`), the server evaluates optional prop `a`
+ # (Inertia's reference adapters do the same: optional is only skipped on non-partial
+ # requests). Pinned so a change in behaviour is noticed.
+ assert p.get("a") == 1
+ assert p["nested.x"] == 2
+
+
+def test_only_dotted_nested_x(page, probes):
+ page.goto("/__e2e/inertia/partial")
+ expect(v(page, "stamp")).to_have_text("1")
+ page.get_by_test_id("e2e-only-nested-x").click()
+ expect(v(page, "nested-x")).to_have_text("2")
+ expect(v(page, "stamp")).to_have_text("2")
+ p = probes()
+ assert p["nested.x"] == 2
+ assert p["nested.y"] == 1, "dotted only must not run sibling loader"
+ assert p["b"] == 1
+ # Sibling value must survive in the client (nested object merged, not replaced).
+ expect(v(page, "nested-y")).to_have_text("1")
diff --git a/tests/e2e/inertia/test_inertia_redirects.py b/tests/e2e/inertia/test_inertia_redirects.py
new file mode 100644
index 000000000..2eba376c3
--- /dev/null
+++ b/tests/e2e/inertia/test_inertia_redirects.py
@@ -0,0 +1,66 @@
+import re
+
+import pytest
+from playwright.sync_api import expect
+
+pytestmark = pytest.mark.e2e
+
+
+@pytest.mark.parametrize("method", ["put", "patch", "delete"])
+def test_mutation_redirect_lands_with_done(page, method):
+ page.goto("/__e2e/inertia/redirects")
+ page.evaluate("window.__marker = 1")
+ reqs = []
+ page.on(
+ "response",
+ lambda r: (
+ reqs.append((r.request.method, r.status, r.url))
+ if "/__e2e/inertia/redirects" in r.url
+ else None
+ ),
+ )
+ page.get_by_test_id(f"e2e-redirect-{method}").click()
+ expect(page).to_have_url(f"/__e2e/inertia/redirects?done={method}")
+ expect(page.get_by_test_id("e2e-value-done")).to_have_text(f'"{method}"')
+ assert page.evaluate("window.__marker") == 1, f"unexpected full reload; {reqs}"
+ # a PUT/PATCH/DELETE must be followed by a GET, never replayed with the same verb
+ assert reqs[0][0] == method.upper() and reqs[0][1] in (302, 303), reqs
+ assert reqs[-1][0] == "GET", reqs
+
+
+def test_empty_200_stays_on_page(page):
+ page.goto("/__e2e/inertia/redirects")
+ page.evaluate("window.__marker = 1")
+ page.get_by_test_id("e2e-redirect-empty").click()
+ expect(page.get_by_test_id("e2e-requests")).to_have_text("1")
+ expect(page).to_have_url("/__e2e/inertia/redirects")
+ expect(page.get_by_test_id("e2e-page")).to_be_visible()
+ assert page.evaluate("window.__marker") == 1
+
+
+def test_back_redirects_to_referer(page):
+ page.goto("/__e2e/inertia/redirects?done=seed")
+ page.get_by_test_id("e2e-redirect-back").click()
+ expect(page.get_by_test_id("e2e-requests")).to_have_text("1")
+ expect(page).to_have_url("/__e2e/inertia/redirects?done=seed")
+ expect(page.get_by_test_id("e2e-value-done")).to_have_text('"seed"')
+
+
+@pytest.mark.allow_resource_errors # 409 + X-Inertia-Location is the protocol under test
+def test_fragment_redirect_keeps_hash_and_scrolls(page):
+ page.goto("/__e2e/inertia/redirects")
+ assert page.evaluate("window.scrollY") == 0
+ page.get_by_test_id("e2e-redirect-fragment").click()
+ expect(page).to_have_url(re.compile(r"/__e2e/inertia/redirects#section-2$"))
+ expect(page.get_by_test_id("e2e-section-2")).to_be_in_viewport()
+ assert page.evaluate("window.scrollY") > 0
+
+
+@pytest.mark.allow_resource_errors # 409 + X-Inertia-Location is the protocol under test
+def test_external_location_is_full_navigation(page):
+ page.goto("/__e2e/inertia/redirects")
+ page.evaluate("window.__marker = 1")
+ page.get_by_test_id("e2e-redirect-external").click()
+ expect(page.get_by_test_id("e2e-external")).to_be_visible()
+ expect(page).to_have_url("/__e2e/inertia/_external")
+ assert page.evaluate("window.__marker") is None
diff --git a/tests/e2e/inertia/test_inertia_scroll.py b/tests/e2e/inertia/test_inertia_scroll.py
new file mode 100644
index 000000000..2368fc736
--- /dev/null
+++ b/tests/e2e/inertia/test_inertia_scroll.py
@@ -0,0 +1,65 @@
+import re
+
+import pytest
+from playwright.sync_api import expect
+
+pytestmark = pytest.mark.e2e
+
+
+def ids(page):
+ return (
+ page.get_by_test_id("e2e-posts")
+ .locator("li")
+ .evaluate_all("els => els.map(e => Number(e.dataset.testid.split('-').pop()))")
+ )
+
+
+def scroll_until(page, last_id):
+ for _ in range(60):
+ if page.get_by_test_id(f"e2e-post-{last_id}").count():
+ return
+ page.mouse.wheel(0, 4000)
+ page.wait_for_timeout(100) # let the intersection observer fire; bounded loop
+ raise AssertionError(f"post {last_id} never loaded; have {ids(page)}")
+
+
+def test_scroll_loads_next_pages_without_duplicates(page):
+ page.goto("/__e2e/inertia/scroll")
+ expect(page.get_by_test_id("e2e-post-1")).to_be_visible()
+ assert ids(page) == list(range(1, 11))
+ scroll_until(page, 30)
+ got = ids(page)
+ assert len(got) == len(set(got)), f"duplicates: {got}"
+ assert got[:30] == list(range(1, 31))
+ assert got == sorted(got)
+
+
+def test_url_page_param_updates(page):
+ page.goto("/__e2e/inertia/scroll")
+ scroll_until(page, 30)
+ # Inertia's InfiniteScroll replaces the URL with the page of the most visible item.
+ # The URL follows the most visible item, so keep scrolling (bounded) until it moves.
+ for _ in range(40):
+ if re.search(r"[?&]page=\d+", page.url):
+ break
+ page.mouse.wheel(0, 1500)
+ page.wait_for_timeout(100)
+ expect(page).to_have_url(re.compile(r"[?&]page=\d+"))
+
+
+def test_reset_filter(page):
+ page.goto("/__e2e/inertia/scroll")
+ scroll_until(page, 20)
+ page.get_by_test_id("e2e-reset-filter").click()
+ expect(page.get_by_test_id("e2e-post-2")).to_be_visible()
+ expect(page.get_by_test_id("e2e-post-1")).to_have_count(0)
+ got = ids(page)
+ assert got[:10] == list(range(2, 21, 2)), got
+ assert len(got) == len(set(got))
+ assert all(i % 2 == 0 for i in got)
+
+
+def test_direct_page_param_load(page):
+ page.goto("/__e2e/inertia/scroll?page=3")
+ expect(page.get_by_test_id("e2e-post-21")).to_be_visible()
+ assert ids(page)[0] == 21
diff --git a/tests/e2e/inertia/test_inertia_visible.py b/tests/e2e/inertia/test_inertia_visible.py
new file mode 100644
index 000000000..c32b67660
--- /dev/null
+++ b/tests/e2e/inertia/test_inertia_visible.py
@@ -0,0 +1,54 @@
+import pytest
+from playwright.sync_api import expect
+
+pytestmark = pytest.mark.e2e
+
+
+def tick(page):
+ return int(page.get_by_test_id("e2e-value-tick").inner_text())
+
+
+def test_when_visible_loads_only_after_scroll(page, probes):
+ page.goto("/__e2e/inertia/visible")
+ expect(page.get_by_test_id("e2e-value-tick")).to_be_visible()
+ first = tick(page)
+ # let the 1s poll run at least once so "not loaded yet" is not just a race
+ expect(page.get_by_test_id("e2e-value-tick")).not_to_have_text(str(first), timeout=4000)
+ assert "below" not in probes()
+ page.get_by_test_id("e2e-visible-spacer").scroll_into_view_if_needed()
+ page.mouse.wheel(0, 10000)
+ expect(page.get_by_test_id("e2e-value-below")).to_have_text("1")
+ assert probes()["below"] == 1
+
+
+def test_poll_updates_tick_repeatedly_and_stops(page):
+ page.goto("/__e2e/inertia/visible")
+ expect(page.get_by_test_id("e2e-value-tick")).to_have_text("1")
+ expect(page.get_by_test_id("e2e-value-tick")).to_have_text("2", timeout=4000)
+ expect(page.get_by_test_id("e2e-value-tick")).to_have_text("3", timeout=4000)
+ page.get_by_test_id("e2e-poll-toggle").click()
+ stopped = tick(page)
+ page.wait_for_timeout(2500) # must stay frozen for >2 poll intervals
+ assert tick(page) == stopped
+ page.get_by_test_id("e2e-poll-toggle").click()
+ expect(page.get_by_test_id("e2e-value-tick")).not_to_have_text(str(stopped), timeout=4000)
+
+
+def test_prefetch_on_hover_sends_purpose_header(page):
+ page.goto("/__e2e/inertia/visible")
+ link = page.get_by_test_id("e2e-prefetch-partial")
+ with page.expect_request(lambda r: r.url.endswith("/__e2e/inertia/partial")) as info:
+ link.hover()
+ req = info.value
+ assert req.headers.get("purpose") == "prefetch", req.headers
+ assert req.headers.get("x-inertia") == "true"
+ resp = req.response()
+ assert resp is not None and resp.status == 200
+ # clicking uses the prefetched response: no second request for /__e2e/inertia/partial
+ more = []
+ page.on(
+ "request", lambda r: more.append(r) if r.url.endswith("/__e2e/inertia/partial") else None
+ )
+ link.click()
+ expect(page.get_by_test_id("e2e-value-stamp")).to_be_visible()
+ assert not more, [m.headers for m in more]