Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/e2e-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<Page>`. 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):
Expand Down
16 changes: 11 additions & 5 deletions host/client_app/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,17 @@ type PageLoader = () => Promise<PageModule>;
// 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<PageModule>([
'./pages/**/*.tsx',
'!./pages/**/*.test.tsx',
'!./pages/**/*.spec.tsx',
]);
const hostPages = {
...import.meta.glob<PageModule>([
'./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<PageModule>(['./pages/E2eInertia/*.tsx']) : {}),
};

const pages: Record<string, PageLoader> = {};

Expand Down
23 changes: 23 additions & 0 deletions host/client_app/pages/E2eInertia/Deferred.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<E2ePage title="Deferred groups">
<InertiaDeferred data="fast" fallback={<p data-testid="e2e-fast-loading">Loading…</p>}>
<Value name="fast" value={fast} />
</InertiaDeferred>
<InertiaDeferred data="slow" fallback={<p data-testid="e2e-slow-loading">Loading…</p>}>
<Value name="slow" value={slow} />
</InertiaDeferred>
<InertiaDeferred
data="fragile"
fallback={<p data-testid="e2e-fragile-loading">Loading…</p>}
rescue={<p data-testid="e2e-fragile-failed">Loader failed safely</p>}
>
<p data-testid="e2e-fragile-ready">Loader ready</p>
</InertiaDeferred>
</E2ePage>
);
}
68 changes: 68 additions & 0 deletions host/client_app/pages/E2eInertia/Forms.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<form
noValidate
data-testid={`e2e-form-${bag}`}
className="space-y-2 rounded border p-4"
onSubmit={(event) => {
event.preventDefault();
form.post(`/__e2e/inertia/forms/${bag}`, {
errorBag: bag === 'second' ? 'second' : undefined,
});
}}
>
<h2>{bag === 'default' ? 'Default form' : 'Second form'}</h2>
{(['name', 'email'] as const).map((field) => (
<div key={field}>
<label htmlFor={`${bag}-${field}`}>{field === 'name' ? 'Name' : 'Email'}</label>
<input
id={`${bag}-${field}`}
data-testid={`e2e-${bag}-${field}`}
className="block rounded border p-2"
type={field === 'email' ? 'email' : 'text'}
value={form.data[field]}
aria-invalid={!!form.errors[field]}
aria-describedby={`${bag}-${field}-error`}
onChange={(event) => form.setData(field, event.target.value)}
/>
<p id={`${bag}-${field}-error`} role="alert" data-testid={`e2e-${bag}-${field}-error`}>
{form.errors[field]}
</p>
</div>
))}
<button
type="submit"
className={buttonClass}
disabled={form.processing}
data-testid={`e2e-${bag}-submit`}
>
Submit
</button>
{bag === 'default' && (
<button
type="button"
className={buttonClass}
data-testid="e2e-validated-submit"
onClick={() => form.post('/__e2e/inertia/forms/validated')}
>
Submit via FastAPI validation
</button>
)}
</form>
);
}

export default function Forms({ success }: { success: string }) {
return (
<E2ePage title="Forms and error bags">
<p data-testid="e2e-form-success">{success ? `Saved ${success} form` : ''}</p>
<DemoForm bag="default" />
<DemoForm bag="second" />
</E2ePage>
);
}
43 changes: 43 additions & 0 deletions host/client_app/pages/E2eInertia/History.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<E2ePage title="History and fragments">
<Value name="history-mode" value={mode} />
<nav className="flex gap-4">
<Link href="/__e2e/inertia/history/encrypt" data-testid="e2e-history-encrypt">
Encrypt history
</Link>
<Link href="/__e2e/inertia/history/clear" data-testid="e2e-history-clear">
Clear history
</Link>
<Link
href="/__e2e/inertia/history/moved#history-section"
data-testid="e2e-history-fragment"
>
Redirect keeping fragment
</Link>
<Link
href="/__e2e/inertia/history/moved-plain#history-section"
data-testid="e2e-history-fragment-plain"
>
Redirect without preserveFragment
</Link>
<button
type="button"
className={buttonClass}
onClick={() => router.reload()}
data-testid="e2e-history-preserve"
>
Reload preserving fragment
</button>
</nav>
<div style={{ height: '110vh' }} aria-hidden="true" />
<h2 id="history-section" data-testid="e2e-history-section">
History section
</h2>
</E2ePage>
);
}
38 changes: 38 additions & 0 deletions host/client_app/pages/E2eInertia/Index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<E2ePage title="Inertia feature lab">
<p>
Signed in: <span data-testid="e2e-user-email">{auth.user?.email}</span>
</p>
<ul className="grid gap-3 sm:grid-cols-2">
{features.map((slug) => (
<li key={slug}>
<Link
href={`/__e2e/inertia/${slug}`}
data-testid={`e2e-link-${slug}`}
className="block rounded border p-4"
>
{slug}
</Link>
</li>
))}
</ul>
</E2ePage>
);
}
68 changes: 68 additions & 0 deletions host/client_app/pages/E2eInertia/Merge.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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 (
<E2ePage title="Merging props">
{(['items', 'prepended', 'tree', 'matched'] as const).map((name) => (
<section key={name}>
<h2>{name}</h2>
<Value name={name} value={props[name]} />
<button
type="button"
disabled={loading}
className={buttonClass}
data-testid={`e2e-more-${name}`}
onClick={() => more(name)}
>
Load more
</button>
</section>
))}
<button
type="button"
disabled={loading}
className={buttonClass}
data-testid="e2e-reset-items"
onClick={() =>
router.reload({
onStart: () => setLoading(true),
onFinish: () => setLoading(false),
only: ['items'],
data: { page: 1 },
reset: ['items'],
onSuccess: () => setPages((previous) => ({ ...previous, items: 1 })),
})
}
>
Reset items
</button>
</E2ePage>
);
}
31 changes: 31 additions & 0 deletions host/client_app/pages/E2eInertia/Once.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<E2ePage title="Once props">
<Value name="cached" value={cached} />
<Value name="expiring" value={expiring} />
<Link href="/__e2e/inertia/" data-testid="e2e-once-away">
Visit index and return
</Link>
<button
type="button"
className={buttonClass}
data-testid="e2e-refresh-once"
onClick={() => router.reload({ only: ['cached'] })}
>
Refresh once
</button>
<button
type="button"
className={buttonClass}
data-testid="e2e-refresh-expiring"
onClick={() => router.reload({ only: ['expiring'] })}
>
Refresh expiring value
</button>
</E2ePage>
);
}
47 changes: 47 additions & 0 deletions host/client_app/pages/E2eInertia/Partial.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<E2ePage title="Partial reloads">
<div className="flex flex-wrap gap-3">
<button
type="button"
className={buttonClass}
data-testid="e2e-only-a"
onClick={() => router.reload({ only: ['a'] })}
>
Reload only a
</button>
<button
type="button"
className={buttonClass}
data-testid="e2e-except-b"
onClick={() => router.reload({ except: ['b'] })}
>
Reload except b
</button>
<button
type="button"
className={buttonClass}
data-testid="e2e-only-nested-x"
onClick={() => router.reload({ only: ['nested.x'] })}
>
Reload only nested.x
</button>
</div>
<Value name="stamp" value={stamp} />
<Value name="a" value={a} />
<Value name="b" value={b} />
<Value name="nested-x" value={nested?.x} />
<Value name="nested-y" value={nested?.y} />
</E2ePage>
);
}
Loading
Loading