pytest-django-admin-kit 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,169 @@
1
+ """The pytest plugin: the fixtures the package adds.
2
+
3
+ Registered through a ``pytest11`` entry point, so a project that installs the package
4
+ gets them without listing anything in its own configuration. Every fixture is lazy and
5
+ none is autouse, so a project that installs the package and never uses ``admin_ui``
6
+ behaves exactly as it did before.
7
+
8
+ The browser comes from pytest-playwright, so its flags govern admin tests too:
9
+ ``--browser``, ``--headed``, ``--slowmo`` and the rest mean here what they mean
10
+ everywhere else in the project. That also makes each admin test carry the browser it
11
+ ran on in its id.
12
+
13
+ The fixtures are layered so one can be overridden without rewriting the rest. Point
14
+ ``admin_ui_urls`` at another ``AdminSite``, ``admin_ui_normalizers`` at other rules,
15
+ ``admin_ui_widgets`` at other widget classes, or ``admin_ui_config`` at different settings,
16
+ and ``admin_ui`` follows.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from contextlib import contextmanager
23
+ from typing import TYPE_CHECKING, Any, Iterator, TypedDict
24
+
25
+ import pytest
26
+
27
+ from .config import Config, from_django_settings
28
+
29
+ # The real imports happen inside the fixtures. Doing them here would add roughly 400
30
+ # modules and 150ms to pytest startup in every project that installs the package,
31
+ # most of it django.contrib.admin. mypy reads this block; Python never runs it.
32
+ if TYPE_CHECKING:
33
+ from pytest_django.live_server_helper import LiveServer
34
+ from pytest_playwright.pytest_playwright import CreateContextCallback
35
+
36
+ from .normalize import Normalizers
37
+ from .session import AdminSession
38
+ from .urls import AdminUrls
39
+ from .widgets import Widgets
40
+
41
+
42
+ _ASYNC_UNSAFE = "DJANGO_ALLOW_ASYNC_UNSAFE"
43
+
44
+
45
+ class _MachineIndependentContextArgs(TypedDict, total=False):
46
+ """The context arguments the package sets so results do not depend on the machine."""
47
+
48
+ timezone_id: str
49
+ locale: str
50
+
51
+
52
+ @contextmanager
53
+ def _orm_allowed_while_driving() -> Iterator[None]:
54
+ """Let the ORM run while a browser is live, and put the guard back afterwards.
55
+
56
+ The driver's synchronous API leaves an asyncio event loop on the test thread, and
57
+ Django then refuses every ORM call with ``SynchronousOnlyOperation``. Logging a
58
+ user in needs the ORM, so the guard has to come off.
59
+ """
60
+ previous = os.environ.get(_ASYNC_UNSAFE)
61
+ os.environ[_ASYNC_UNSAFE] = "true"
62
+ try:
63
+ yield
64
+ finally:
65
+ if previous is None:
66
+ os.environ.pop(_ASYNC_UNSAFE, None)
67
+ else:
68
+ os.environ[_ASYNC_UNSAFE] = previous
69
+
70
+
71
+ @pytest.fixture(scope="session")
72
+ def admin_ui_config() -> Config:
73
+ """Settings resolved once for the whole session."""
74
+ return from_django_settings()
75
+
76
+
77
+ @pytest.fixture(scope="session")
78
+ def admin_ui_urls(admin_ui_config: Config) -> AdminUrls:
79
+ """URLs for the configured admin site, resolved once."""
80
+ # Imported here, not at module scope: this module loads at pytest startup in
81
+ # every project that installs the package, and `urls` pulls in the whole of
82
+ # django.contrib.admin.
83
+ from .urls import AdminUrls, resolve_site
84
+
85
+ return AdminUrls(resolve_site(admin_ui_config.site))
86
+
87
+
88
+ @pytest.fixture(scope="session")
89
+ def admin_ui_normalizers(admin_ui_config: Config) -> Normalizers:
90
+ """The rules that read rendered values: the defaults, with the ones the settings
91
+ replace, built once."""
92
+ from .normalize import Normalizers
93
+
94
+ return Normalizers(admin_ui_config.normalizers)
95
+
96
+
97
+ @pytest.fixture(scope="session")
98
+ def admin_ui_widgets(admin_ui_config: Config) -> Widgets:
99
+ """The widget classes that read and fill controls: the project's, then the package's
100
+ own, built once."""
101
+ from .widgets import Widgets
102
+
103
+ return Widgets(admin_ui_config.widgets)
104
+
105
+
106
+ @pytest.fixture(scope="session")
107
+ def admin_ui_driving() -> Iterator[None]:
108
+ """Hold Django's async guard open around everything the admin session touches.
109
+
110
+ It depends on nothing, and in particular not on ``browser``. A guard that depends
111
+ on the browser is torn down when the browser is, while the driver behind it is
112
+ still running, and dropping the test database then fails with
113
+ ``SynchronousOnlyOperation``. That surfaces only when the browser is torn down
114
+ before the end of the session, which is what a second ``--browser`` does.
115
+
116
+ Depending on nothing also lets ``admin_ui`` request it first, so it is set up
117
+ before the database is built and torn down after the database is destroyed.
118
+
119
+ Being a fixture rather than a session hook is what keeps it honest. A project that
120
+ installs the package and never asks for ``admin_ui`` never runs this, and Django's
121
+ guard stays where it was.
122
+ """
123
+ with _orm_allowed_while_driving():
124
+ yield
125
+
126
+
127
+ @pytest.fixture
128
+ def admin_ui(
129
+ # The guard comes first so it outlives the database at both ends. `live_server`
130
+ # before `new_context` follows Django's own practice of closing the browser before
131
+ # joining the live-server thread, since a held-open connection can deadlock it.
132
+ # Inverting it here did not reproduce a hang, so treat that half as precaution
133
+ # rather than a demonstrated fix.
134
+ admin_ui_driving: None,
135
+ live_server: LiveServer,
136
+ new_context: CreateContextCallback,
137
+ admin_ui_config: Config,
138
+ admin_ui_urls: AdminUrls,
139
+ admin_ui_normalizers: Normalizers,
140
+ admin_ui_widgets: Widgets,
141
+ browser_context_args: dict[str, Any],
142
+ ) -> Iterator[AdminSession]:
143
+ """A fresh, unauthenticated admin session per test.
144
+
145
+ Each test gets its own browsing context, which is the isolation unit for cookies
146
+ and storage. Sharing one would let a test that never calls ``login`` inherit the
147
+ previous test's user, and whether it passed would depend on file order.
148
+
149
+ The context comes from pytest-playwright's factory, so admin tests record video
150
+ and traces on the same flags as every other browser test in the project.
151
+ """
152
+ from .session import AdminSession
153
+ from .tools import PageTools
154
+
155
+ # Only pass what the project has not set itself: the factory merges its own
156
+ # arguments with these, and a duplicate key raises TypeError. `base_url` is
157
+ # deliberately absent, since AdminSession navigates absolutely.
158
+ context_args: _MachineIndependentContextArgs = {}
159
+ if admin_ui_config.timezone is not None and "timezone_id" not in browser_context_args:
160
+ context_args["timezone_id"] = admin_ui_config.timezone
161
+ if admin_ui_config.locale is not None and "locale" not in browser_context_args:
162
+ context_args["locale"] = admin_ui_config.locale
163
+
164
+ context = new_context(**context_args)
165
+ context.set_default_timeout(admin_ui_config.timeout)
166
+
167
+ # No close here: the factory closes the contexts it made when the test ends.
168
+ tools = PageTools(admin_ui_urls, admin_ui_normalizers, admin_ui_widgets)
169
+ yield AdminSession(context, tools, live_server.url)
File without changes
@@ -0,0 +1,143 @@
1
+ """A value as the admin rendered it, wherever it stands: in a changelist cell or in a
2
+ form field the user may only read.
3
+
4
+ What such a value holds is read from the document's own text, so the admin's styling
5
+ never changes it, and the links it renders are kept next to it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any, Callable
11
+ from urllib.parse import SplitResult, urlsplit
12
+
13
+ from django.contrib.admin import ModelAdmin
14
+ from django.contrib.admin.utils import NotRelationField, get_fields_from_path
15
+ from django.core.exceptions import FieldDoesNotExist
16
+ from django.db.models import Field
17
+ from django.utils.html import strip_tags
18
+ from playwright.sync_api import Locator
19
+
20
+ if TYPE_CHECKING:
21
+ from .normalize import Normalizers
22
+
23
+
24
+ class Link:
25
+ """One link in a rendered value: its text, and its ``href`` exactly as rendered, split.
26
+
27
+ ``href`` is a ``SplitResult``, so its parts are there by name: ``path`` to compare
28
+ against ``admin_ui.url``, ``query`` for what the admin added to keep a filter. A
29
+ link compares equal to another link and to a ``(text, href)`` pair, as a tuple or a
30
+ list, with ``href`` given either as a string or already split.
31
+ """
32
+
33
+ def __init__(self, text: str, href: str | SplitResult) -> None:
34
+ self._text = text
35
+ self._href = urlsplit(href) if isinstance(href, str) else href
36
+
37
+ @property
38
+ def text(self) -> str:
39
+ return self._text
40
+
41
+ @property
42
+ def href(self) -> SplitResult:
43
+ return self._href
44
+
45
+ def __eq__(self, other: object) -> bool:
46
+ if isinstance(other, Link):
47
+ return (self._text, self._href) == (other._text, other._href)
48
+ if isinstance(other, (tuple, list)) and len(other) == 2:
49
+ text, href = other
50
+ if isinstance(text, str) and isinstance(href, (str, SplitResult)):
51
+ return self == Link(text, href)
52
+ return NotImplemented
53
+
54
+ def __hash__(self) -> int:
55
+ return hash((self._text, self._href))
56
+
57
+ def __repr__(self) -> str:
58
+ return f"Link({self._text!r}, {self._href.geturl()!r})"
59
+
60
+
61
+ class RenderedValue:
62
+ """One value the admin rendered, read from the element that holds it.
63
+
64
+ ``check`` is run before every read, and fails when the browser no longer shows the
65
+ page the value is on. ``normalizers`` read its value, by what stands behind it: the
66
+ ``name`` the admin renders it under, on a page ``model_admin`` draws.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ element: Locator,
72
+ name: str,
73
+ *,
74
+ check: Callable[[], None],
75
+ normalizers: Normalizers,
76
+ model_admin: ModelAdmin,
77
+ ) -> None:
78
+ self._element = element
79
+ self._name = name
80
+ self._check = check
81
+ self._normalizers = normalizers
82
+ self._model_admin = model_admin
83
+
84
+ @property
85
+ def native(self) -> Locator:
86
+ """The element, unwrapped, for anything the package does not model."""
87
+ return self._element
88
+
89
+ @property
90
+ def field(self) -> Field | None:
91
+ """The model field the admin rendered the value from, or ``None``, as for a method."""
92
+ return _model_field(self._model_admin.model, self._name)
93
+
94
+ @property
95
+ def empty_display(self) -> str:
96
+ """The text the admin shows here for a value it has none of: the model admin's, which
97
+ falls back to the site's."""
98
+ return display_text(self._model_admin.get_empty_value_display())
99
+
100
+ @property
101
+ def text(self) -> str:
102
+ """What the document shows, with its whitespace collapsed."""
103
+ self._check()
104
+ return text_of(self._element)
105
+
106
+ @property
107
+ def value(self) -> Any:
108
+ """What is shown, read by the rule for its kind: by default the text, or a boolean
109
+ where the admin drew one as an icon."""
110
+ self._check()
111
+ return self._normalizers.read(self)
112
+
113
+ @property
114
+ def links(self) -> list[Link]:
115
+ """The links rendered, in order; ``[]`` when there is none."""
116
+ self._check()
117
+ return [
118
+ Link(text_of(a), a.get_attribute("href") or "")
119
+ for a in self._element.locator("a[href]").all()
120
+ ]
121
+
122
+
123
+ def text_of(element: Locator) -> str:
124
+ """The document's text inside ``element``, with its whitespace collapsed."""
125
+ return " ".join((element.text_content() or "").split())
126
+
127
+
128
+ def display_text(display: str) -> str:
129
+ """``display`` as the page shows it: without markup, its whitespace collapsed."""
130
+ return " ".join(strip_tags(str(display)).split())
131
+
132
+
133
+ def _model_field(model: type[Any], name: str) -> Field | None:
134
+ """The model field the admin renders under ``name``, or ``None`` when it renders something
135
+ else there, such as a method.
136
+
137
+ The admin looks a name up as a field first, following a path such as ``category__name``,
138
+ and only then as a callable or a method, so a name that is a field is always that field.
139
+ """
140
+ try:
141
+ return get_fields_from_path(model, name)[-1]
142
+ except (FieldDoesNotExist, NotRelationField):
143
+ return None
@@ -0,0 +1,62 @@
1
+ """The outcome of submitting an admin form.
2
+
3
+ A result says whether the admin accepted the submission and where it sent the browser.
4
+ Anything shown on the page, such as fields, errors and messages, is read from
5
+ ``result.page``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from .pages import AdminPage
14
+ from .urls import AdminUrls
15
+
16
+
17
+ class SubmissionResult:
18
+ """The outcome of one form submission."""
19
+
20
+ def __init__(self, page: AdminPage, redirected: bool, urls: AdminUrls) -> None:
21
+ self._page = page
22
+ self._redirected = redirected
23
+ self._urls = urls
24
+
25
+ @property
26
+ def page(self) -> AdminPage:
27
+ """The page the browser shows after the submission.
28
+
29
+ It is the submitted page object itself if the browser stayed on that page, and a
30
+ new page object otherwise.
31
+ """
32
+ return self._page
33
+
34
+ @property
35
+ def success(self) -> bool:
36
+ """Whether the admin accepted the submission.
37
+
38
+ Django redirects after a successful save and shows the form again when it rejects
39
+ one, so this is whether a redirect happened.
40
+ """
41
+ return self._redirected
42
+
43
+ def redirected_to(self, url: str) -> bool:
44
+ """Whether the admin redirected the browser to ``url``, a path such as
45
+ ``admin_ui.url.list(Product)``.
46
+
47
+ A rejected form is shown again at its own URL without a redirect, so it is never
48
+ redirected to that URL.
49
+ """
50
+ return self._redirected and self._page.destination == url
51
+
52
+ def redirected_to_index(self) -> bool:
53
+ return self.redirected_to(self._urls.index())
54
+
55
+ def redirected_to_list(self, model: type[Any]) -> bool:
56
+ return self.redirected_to(self._urls.list(model))
57
+
58
+ def redirected_to_create(self, model: type[Any]) -> bool:
59
+ return self.redirected_to(self._urls.create(model))
60
+
61
+ def redirected_to_edit(self, instance: Any) -> bool:
62
+ return self.redirected_to(self._urls.edit(instance))
@@ -0,0 +1,165 @@
1
+ """The rows of a changelist and the cells in them.
2
+
3
+ A cell is addressed by the name its column is configured with, never by the label
4
+ shown over it, and a row by its position. A cell is a rendered value, as the
5
+ ``rendered`` module reads one, that also knows its column.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Callable, Iterator
11
+
12
+ from django.contrib.admin import ModelAdmin
13
+ from django.contrib.admin.utils import unquote
14
+ from django.db.models import Model
15
+ from django.urls import Resolver404, resolve
16
+ from playwright.sync_api import Locator
17
+
18
+ from .normalize import Normalizers
19
+ from .rendered import RenderedValue, display_text
20
+ from .urls import AdminUrls, model_admin
21
+
22
+
23
+ class Cell(RenderedValue):
24
+ """One cell of a changelist row."""
25
+
26
+ def __init__(
27
+ self,
28
+ element: Locator,
29
+ column: str,
30
+ *,
31
+ check: Callable[[], None],
32
+ normalizers: Normalizers,
33
+ model_admin: ModelAdmin,
34
+ ) -> None:
35
+ super().__init__(
36
+ element, column, check=check, normalizers=normalizers, model_admin=model_admin
37
+ )
38
+ self._column = column
39
+
40
+ @property
41
+ def column(self) -> str:
42
+ """The configured name of the column the cell is in."""
43
+ return self._column
44
+
45
+ @property
46
+ def empty_display(self) -> str:
47
+ """The text the admin shows here for a value it has none of: the column's own, when
48
+ it is a method or a callable that sets one, else the model admin's or the site's."""
49
+ function = None if self.field is not None else self._display_function()
50
+ if function is not None and hasattr(function, "empty_value_display"):
51
+ return display_text(function.empty_value_display)
52
+ return super().empty_display
53
+
54
+ def _display_function(self) -> Any:
55
+ """What the admin calls to render the column, looked up as the admin looks it up: a
56
+ callable in ``list_display``, then the model admin's attribute, then the model's."""
57
+ for entry in self._model_admin.list_display:
58
+ if callable(entry) and getattr(entry, "__name__", None) == self._column:
59
+ return entry
60
+ if hasattr(self._model_admin, self._column):
61
+ return getattr(self._model_admin, self._column)
62
+ return getattr(self._model_admin.model, self._column, None)
63
+
64
+
65
+ class Row:
66
+ """One row of a changelist, indexable by column name or position.
67
+
68
+ The columns are the ones the changelist had when the row was read. A project whose
69
+ ``list_display`` changes with the query string reads its rows again after filtering.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ element: Locator,
75
+ index: int,
76
+ *,
77
+ columns: list[str],
78
+ model: type[Model],
79
+ urls: AdminUrls,
80
+ check: Callable[[], None],
81
+ normalizers: Normalizers,
82
+ ) -> None:
83
+ self._element = element
84
+ self._index = index
85
+ self._columns = columns
86
+ self._model = model
87
+ self._urls = urls
88
+ self._check = check
89
+ self._normalizers = normalizers
90
+
91
+ @property
92
+ def index(self) -> int:
93
+ """The row's 0-based position in the changelist.
94
+
95
+ The row is that position, not a record: every read goes to whichever row the page
96
+ shows there now, so once the changelist is filtered or sorted it may be another one.
97
+ """
98
+ return self._index
99
+
100
+ @property
101
+ def native(self) -> Locator:
102
+ """The row element, unwrapped, for anything the package does not model."""
103
+ return self._element
104
+
105
+ @property
106
+ def object(self) -> Any:
107
+ """The instance behind the row, where the changelist links to its change page.
108
+
109
+ ``None`` for a row without such a link, as under ``list_display_links = None``.
110
+ The database is asked each time, so the instance is as it is stored now.
111
+ """
112
+ opts = self._model._meta
113
+ for cell in self._cells:
114
+ for link in cell.links:
115
+ try:
116
+ match = resolve(link.href.path)
117
+ except Resolver404:
118
+ continue
119
+ if (
120
+ match.namespace == self._urls.site.name
121
+ and match.url_name == f"{opts.app_label}_{opts.model_name}_change"
122
+ ):
123
+ return self._model._default_manager.get(pk=unquote(match.kwargs["object_id"]))
124
+ return None
125
+
126
+ def __len__(self) -> int:
127
+ return len(self._cells)
128
+
129
+ def __iter__(self) -> Iterator[Cell]:
130
+ return iter(self._cells)
131
+
132
+ def __getitem__(self, key: int | str) -> Cell:
133
+ if isinstance(key, int):
134
+ return self._cells[key]
135
+ try:
136
+ return self._cells[self._columns.index(key)]
137
+ except ValueError:
138
+ raise KeyError(
139
+ f"The row has no column named {key!r}. Columns: "
140
+ f"{', '.join(repr(name) for name in self._columns)}."
141
+ ) from None
142
+
143
+ @property
144
+ def _cells(self) -> list[Cell]:
145
+ self._check()
146
+ # The row's own cells line up with the columns by position once the
147
+ # checkbox Django adds for actions is skipped, as the headers skip its
148
+ # header cell.
149
+ cells = self._element.locator(":scope > th, :scope > td").all()
150
+ cells = [cell for cell in cells if "action-checkbox" not in _classes(cell)]
151
+ admin = model_admin(self._urls.site, self._model)
152
+ return [
153
+ Cell(
154
+ cell,
155
+ column,
156
+ check=self._check,
157
+ normalizers=self._normalizers,
158
+ model_admin=admin,
159
+ )
160
+ for cell, column in zip(cells, self._columns)
161
+ ]
162
+
163
+
164
+ def _classes(element: Locator) -> list[str]:
165
+ return (element.get_attribute("class") or "").split()
@@ -0,0 +1,132 @@
1
+ """The object the ``admin_ui`` fixture yields.
2
+
3
+ Login builds the session in Django and hands the browser the resulting cookie. It
4
+ never types a password, because passwords are stored irreversibly and a test handed an
5
+ arbitrary user has no way to know one. A user created with ``set_unusable_password()``
6
+ has no password at all and must still be able to log in.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, TypeVar
12
+ from urllib.parse import urljoin
13
+
14
+ from django.conf import settings
15
+ from django.test import Client
16
+ from playwright.sync_api import BrowserContext
17
+
18
+ from .pages import AdminPage, ChangelistPage, CreatePage, DeletePage, EditPage, IndexPage
19
+ from .tools import PageTools
20
+ from .urls import AdminUrls
21
+
22
+ _P = TypeVar("_P", bound=AdminPage)
23
+
24
+
25
+ class AdminSession:
26
+ """One test's view of the admin: who is logged in, and where things live."""
27
+
28
+ def __init__(
29
+ self,
30
+ context: BrowserContext,
31
+ tools: PageTools,
32
+ base_url: str,
33
+ ) -> None:
34
+ self._context = context
35
+ self._tools = tools
36
+ self._base_url = base_url
37
+
38
+ @property
39
+ def native(self) -> BrowserContext:
40
+ """The browsing context, unwrapped, for anything the package does not model."""
41
+ return self._context
42
+
43
+ @property
44
+ def url(self) -> AdminUrls:
45
+ """Admin URLs resolved through the site under test, as paths."""
46
+ return self._tools.urls
47
+
48
+ def absolute(self, path: str) -> str:
49
+ """Turn an admin path into a full URL on the live server.
50
+
51
+ The package navigates absolutely rather than setting the context's
52
+ ``base_url``, which belongs to the project: a test suite already using
53
+ ``--base-url`` for its own pages keeps it, and neither of us can break the
54
+ other.
55
+ """
56
+ return urljoin(self._base_url, path)
57
+
58
+ def login(self, user: Any, password: str | None = None) -> None:
59
+ """Log in as ``user`` without knowing or needing a password.
60
+
61
+ Calling it again replaces the session rather than adding a second one, so
62
+ switching users mid-test works.
63
+
64
+ With ``password``, the rendered login form is driven instead: the browser
65
+ opens the login page, types what the user model identifies users by and the
66
+ password, and submits. That is for tests whose subject is the login page. A
67
+ wrong password leaves the browser on the form, with its error shown, and the
68
+ session anonymous.
69
+ """
70
+ self.logout()
71
+
72
+ if password is None:
73
+ self._login_with_cookie(user)
74
+ else:
75
+ self._login_with_form(user, password)
76
+
77
+ def _login_with_cookie(self, user: Any) -> None:
78
+ client = Client()
79
+ client.force_login(user)
80
+
81
+ name = settings.SESSION_COOKIE_NAME
82
+ self._context.add_cookies(
83
+ [{"name": name, "value": client.cookies[name].value, "url": self._base_url}]
84
+ )
85
+
86
+ def _login_with_form(self, user: Any, password: str) -> None:
87
+ page = self._context.new_page()
88
+ page.goto(self.absolute(self._tools.urls.login()))
89
+
90
+ # The admin's form names the identity input `username` whatever the user
91
+ # model calls the field; only the value it expects follows the model.
92
+ page.locator('input[name="username"]').fill(user.get_username())
93
+ page.locator('input[name="password"]').fill(password)
94
+ with page.expect_response(lambda response: response.request.method == "POST"):
95
+ page.locator('input[type="submit"]').click()
96
+ page.wait_for_load_state()
97
+
98
+ def logout(self) -> None:
99
+ """Return the browser to anonymous by dropping its cookies."""
100
+ self._context.clear_cookies()
101
+
102
+ def open(self, path: str) -> AdminPage:
103
+ """Open any admin path in a new page, including views the package does not
104
+ model, and report how it went."""
105
+ return self._open(path, AdminPage)
106
+
107
+ def index(self) -> IndexPage:
108
+ """Open the admin index."""
109
+ return self._open(self._tools.urls.index(), IndexPage)
110
+
111
+ def list(self, model: type[Any]) -> ChangelistPage:
112
+ """Open a model's changelist."""
113
+ return self._open(self._tools.urls.list(model), ChangelistPage, model)
114
+
115
+ def create(self, model: type[Any]) -> CreatePage:
116
+ """Open the page that adds a new instance of ``model``."""
117
+ return self._open(self._tools.urls.create(model), CreatePage, model)
118
+
119
+ def edit(self, instance: Any) -> EditPage:
120
+ """Open the change page of ``instance``."""
121
+ return self._open(self._tools.urls.edit(instance), EditPage, type(instance))
122
+
123
+ def delete(self, instance: Any) -> DeletePage:
124
+ """Open the page that asks whether to delete ``instance``."""
125
+ return self._open(self._tools.urls.delete(instance), DeletePage, type(instance))
126
+
127
+ def _open(self, path: str, page_class: type[_P], *args: Any) -> _P:
128
+ page = self._context.new_page()
129
+ response = page.goto(self.absolute(path))
130
+ # `goto` returns None only for same-document navigations, never for a URL.
131
+ assert response is not None
132
+ return page_class(page, response.status, path, self._tools, *args)