dhis2w-browser 0.5.3__tar.gz

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,84 @@
1
+ Metadata-Version: 2.3
2
+ Name: dhis2w-browser
3
+ Version: 0.5.3
4
+ Summary: Playwright-based helpers for DHIS2 UI automation.
5
+ Author: Morten Hansen
6
+ Author-email: Morten Hansen <morten@winterop.com>
7
+ Requires-Dist: dhis2w-client>=0.5.0,<0.6
8
+ Requires-Dist: pillow>=12.2.0
9
+ Requires-Dist: playwright>=1.58
10
+ Requires-Dist: pydantic>=2.13
11
+ Requires-Python: >=3.13
12
+ Description-Content-Type: text/markdown
13
+
14
+ # dhis2-browser
15
+
16
+ Playwright-based helpers for DHIS2 UI automation. Separate from `dhis2-client` so API-only callers never pull in Chromium.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ uv add 'dhis2-cli[browser]' # pulls dhis2-browser alongside the main CLI
22
+ playwright install chromium # one-off; pulls the actual browser driver
23
+ ```
24
+
25
+ Library-only consumers (no CLI) can install `dhis2-browser` on its own.
26
+
27
+ ## Surface
28
+
29
+ The CLI lives on the main `dhis2` entry point as a plugin; there's no separate `dhis2-browser` binary. Workflows mount under `dhis2 browser <subcommand>`:
30
+
31
+ ```bash
32
+ dhis2 browser pat --url http://localhost:8080 --username admin --password district
33
+ dhis2 browser dashboard screenshot --output-dir /tmp/out --only <uid>
34
+ dhis2 browser viz screenshot --output-dir /tmp/out --only <uid>
35
+ dhis2 browser map screenshot --output-dir /tmp/out --only <uid>
36
+ ```
37
+
38
+ Library callers import from `dhis2_browser` directly:
39
+
40
+ | Entry point | Purpose |
41
+ | --- | --- |
42
+ | `dhis2_browser.logged_in_page(url, username, password)` | Async context manager yielding a `(BrowserContext, Page)` tuple logged into DHIS2 via the React login form. |
43
+ | `dhis2_browser.session_from_cookie(url, jsessionid)` | Fast-path variant — inject a pre-minted `JSESSIONID` instead of driving the login form. |
44
+ | `dhis2_browser.create_pat(url, username, password, options=...)` | Mint a Personal Access Token V2 (`POST /api/apiToken`) through an authenticated browser session. Returns the `d2p_...` token string. DHIS2 only returns the token value once — store it immediately. |
45
+ | `dhis2_browser.drive_oauth2_login(profile_name, *, username, password)` | Run `dhis2 profile login <name> --no-browser` end-to-end — spawns the CLI, reads the authorize URL from its stderr, and drives Chromium through the DHIS2 React login + Spring AS consent screen + loopback redirect. Returns an `OAuth2LoginResult` model. |
46
+ | `dhis2_browser.drive_login_form(auth_url, *, username, password)` | Lower-level companion to `drive_oauth2_login` — navigates Chromium to an already-built authorize URL, fills the login form + consent screen, waits for the loopback redirect. For wiring Playwright into an in-process `OAuth2Auth.redirect_capturer`. |
47
+ | `dhis2_browser.capture_dashboard(...)` / `capture_visualization(...)` / `capture_map(...)` | Render a DHIS2 dashboard / chart / map as a PNG via the respective web app. Banner + background-trim helpers available for report-friendly output. |
48
+
49
+ ## Headless vs headful
50
+
51
+ Headless by default. Two ways to flip to visible:
52
+
53
+ 1. **Env var:** `DHIS2_HEADFUL=1` (or `true`/`yes`/`on`) — applies to every Playwright entry point in this package.
54
+ 2. **Explicit kwarg:** `logged_in_page(..., headless=False)` — overrides env.
55
+
56
+ The `dhis2 browser pat` CLI command defaults to **headful** (`--headful`) so first-time users can watch the login; pass `--headless` to flip.
57
+
58
+ Why: automation wants headless for speed; humans debugging a flow want to see it. One env var covers every caller (CLI, library, tests) uniformly. See `docs/decisions.md` 2026-04-17.
59
+
60
+ ## Example
61
+
62
+ ```python
63
+ import asyncio
64
+ from dhis2_browser import PatOptions, create_pat
65
+
66
+ async def main() -> None:
67
+ token = await create_pat(
68
+ "http://localhost:8080",
69
+ "admin",
70
+ "district",
71
+ options=PatOptions(
72
+ name="automation-bot",
73
+ expires_in_days=90,
74
+ allowed_methods=["GET", "POST"],
75
+ ),
76
+ )
77
+ print(token) # d2p_...
78
+
79
+ asyncio.run(main())
80
+ ```
81
+
82
+ ## Architecture
83
+
84
+ See `docs/architecture/browser.md` for the longer write-up: why PAT creation has to go through a browser (DHIS2 gates `/api/apiToken` behind a session cookie), how `logged_in_page` drives the React login form, and what's on the roadmap.
@@ -0,0 +1,71 @@
1
+ # dhis2-browser
2
+
3
+ Playwright-based helpers for DHIS2 UI automation. Separate from `dhis2-client` so API-only callers never pull in Chromium.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ uv add 'dhis2-cli[browser]' # pulls dhis2-browser alongside the main CLI
9
+ playwright install chromium # one-off; pulls the actual browser driver
10
+ ```
11
+
12
+ Library-only consumers (no CLI) can install `dhis2-browser` on its own.
13
+
14
+ ## Surface
15
+
16
+ The CLI lives on the main `dhis2` entry point as a plugin; there's no separate `dhis2-browser` binary. Workflows mount under `dhis2 browser <subcommand>`:
17
+
18
+ ```bash
19
+ dhis2 browser pat --url http://localhost:8080 --username admin --password district
20
+ dhis2 browser dashboard screenshot --output-dir /tmp/out --only <uid>
21
+ dhis2 browser viz screenshot --output-dir /tmp/out --only <uid>
22
+ dhis2 browser map screenshot --output-dir /tmp/out --only <uid>
23
+ ```
24
+
25
+ Library callers import from `dhis2_browser` directly:
26
+
27
+ | Entry point | Purpose |
28
+ | --- | --- |
29
+ | `dhis2_browser.logged_in_page(url, username, password)` | Async context manager yielding a `(BrowserContext, Page)` tuple logged into DHIS2 via the React login form. |
30
+ | `dhis2_browser.session_from_cookie(url, jsessionid)` | Fast-path variant — inject a pre-minted `JSESSIONID` instead of driving the login form. |
31
+ | `dhis2_browser.create_pat(url, username, password, options=...)` | Mint a Personal Access Token V2 (`POST /api/apiToken`) through an authenticated browser session. Returns the `d2p_...` token string. DHIS2 only returns the token value once — store it immediately. |
32
+ | `dhis2_browser.drive_oauth2_login(profile_name, *, username, password)` | Run `dhis2 profile login <name> --no-browser` end-to-end — spawns the CLI, reads the authorize URL from its stderr, and drives Chromium through the DHIS2 React login + Spring AS consent screen + loopback redirect. Returns an `OAuth2LoginResult` model. |
33
+ | `dhis2_browser.drive_login_form(auth_url, *, username, password)` | Lower-level companion to `drive_oauth2_login` — navigates Chromium to an already-built authorize URL, fills the login form + consent screen, waits for the loopback redirect. For wiring Playwright into an in-process `OAuth2Auth.redirect_capturer`. |
34
+ | `dhis2_browser.capture_dashboard(...)` / `capture_visualization(...)` / `capture_map(...)` | Render a DHIS2 dashboard / chart / map as a PNG via the respective web app. Banner + background-trim helpers available for report-friendly output. |
35
+
36
+ ## Headless vs headful
37
+
38
+ Headless by default. Two ways to flip to visible:
39
+
40
+ 1. **Env var:** `DHIS2_HEADFUL=1` (or `true`/`yes`/`on`) — applies to every Playwright entry point in this package.
41
+ 2. **Explicit kwarg:** `logged_in_page(..., headless=False)` — overrides env.
42
+
43
+ The `dhis2 browser pat` CLI command defaults to **headful** (`--headful`) so first-time users can watch the login; pass `--headless` to flip.
44
+
45
+ Why: automation wants headless for speed; humans debugging a flow want to see it. One env var covers every caller (CLI, library, tests) uniformly. See `docs/decisions.md` 2026-04-17.
46
+
47
+ ## Example
48
+
49
+ ```python
50
+ import asyncio
51
+ from dhis2_browser import PatOptions, create_pat
52
+
53
+ async def main() -> None:
54
+ token = await create_pat(
55
+ "http://localhost:8080",
56
+ "admin",
57
+ "district",
58
+ options=PatOptions(
59
+ name="automation-bot",
60
+ expires_in_days=90,
61
+ allowed_methods=["GET", "POST"],
62
+ ),
63
+ )
64
+ print(token) # d2p_...
65
+
66
+ asyncio.run(main())
67
+ ```
68
+
69
+ ## Architecture
70
+
71
+ See `docs/architecture/browser.md` for the longer write-up: why PAT creation has to go through a browser (DHIS2 gates `/api/apiToken` behind a session cookie), how `logged_in_page` drives the React login form, and what's on the roadmap.
@@ -0,0 +1,17 @@
1
+ [project]
2
+ name = "dhis2w-browser"
3
+ version = "0.5.3"
4
+ description = "Playwright-based helpers for DHIS2 UI automation."
5
+ readme = "README.md"
6
+ authors = [{ name = "Morten Hansen", email = "morten@winterop.com" }]
7
+ requires-python = ">=3.13"
8
+ dependencies = [
9
+ "dhis2w-client>=0.5.0,<0.6",
10
+ "pillow>=12.2.0",
11
+ "playwright>=1.58",
12
+ "pydantic>=2.13",
13
+ ]
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.11.7,<0.12.0"]
17
+ build-backend = "uv_build"
@@ -0,0 +1,58 @@
1
+ """Playwright-based helpers for DHIS2 UI automation."""
2
+
3
+ from dhis2w_browser.dashboard import (
4
+ CaptureResult,
5
+ DashboardTarget,
6
+ add_banner,
7
+ capture_dashboard,
8
+ slugify,
9
+ switch_dashboard,
10
+ trim_background,
11
+ )
12
+ from dhis2w_browser.maps import (
13
+ MapCaptureResult,
14
+ MapTarget,
15
+ add_map_banner,
16
+ capture_map,
17
+ slugify_map,
18
+ )
19
+ from dhis2w_browser.oauth2 import OAuth2LoginResult, drive_login_form, drive_oauth2_login
20
+ from dhis2w_browser.pat import PatAttribute, PatOptions, PatPayload, create_pat
21
+ from dhis2w_browser.session import logged_in_page, resolve_headless, session_from_cookie
22
+ from dhis2w_browser.visualization import (
23
+ VisualizationCaptureResult,
24
+ VisualizationTarget,
25
+ add_viz_banner,
26
+ capture_visualization,
27
+ slugify_viz,
28
+ )
29
+
30
+ __all__ = [
31
+ "CaptureResult",
32
+ "DashboardTarget",
33
+ "MapCaptureResult",
34
+ "MapTarget",
35
+ "OAuth2LoginResult",
36
+ "PatAttribute",
37
+ "PatOptions",
38
+ "PatPayload",
39
+ "VisualizationCaptureResult",
40
+ "VisualizationTarget",
41
+ "add_banner",
42
+ "add_map_banner",
43
+ "add_viz_banner",
44
+ "capture_dashboard",
45
+ "capture_map",
46
+ "capture_visualization",
47
+ "create_pat",
48
+ "drive_login_form",
49
+ "drive_oauth2_login",
50
+ "logged_in_page",
51
+ "resolve_headless",
52
+ "session_from_cookie",
53
+ "slugify",
54
+ "slugify_map",
55
+ "slugify_viz",
56
+ "switch_dashboard",
57
+ "trim_background",
58
+ ]
@@ -0,0 +1,358 @@
1
+ """Full-page screenshot helpers for DHIS2 dashboards.
2
+
3
+ The DHIS2 dashboard app is a React SPA where each dashboard item loads its
4
+ own plugin iframe. Three quirks the helpers in this module paper over:
5
+
6
+ 1. **Lazy-load via viewport intersection.** The app only materialises a
7
+ plugin iframe when its slot scrolls into view. Programmatic `scrollTop`
8
+ doesn't fire the intersection observers — real `mouse.wheel` events are
9
+ required.
10
+
11
+ 2. **Nested render detection.** There's no single "dashboard ready" event.
12
+ The only reliable way to know an item has rendered is to poke the inner
13
+ plugin iframe for substantial content (canvas / svg / leaflet /
14
+ highcharts / img / long text). A plateau detector covers items that
15
+ never fully render (data issues, bad expressions) so one stuck chart
16
+ doesn't block the whole capture.
17
+
18
+ 3. **Session-spanning hash navigation.** Switching dashboards via a fresh
19
+ URL forces a full reload (re-auth, re-init). Setting
20
+ `iframe.contentWindow.location.hash = '/{uid}'` swaps dashboards in
21
+ place — saves 3+ seconds per capture on big batches.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import re
28
+ from datetime import UTC, datetime
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING
31
+
32
+ from PIL import Image, ImageDraw, ImageFont
33
+ from pydantic import BaseModel, ConfigDict
34
+
35
+ if TYPE_CHECKING:
36
+ from playwright.async_api import Page
37
+
38
+
39
+ class DashboardTarget(BaseModel):
40
+ """One dashboard the caller wants captured — uid + display name + item count."""
41
+
42
+ model_config = ConfigDict(frozen=True)
43
+
44
+ uid: str
45
+ display_name: str
46
+ item_count: int
47
+
48
+
49
+ class CaptureResult(BaseModel):
50
+ """Outcome of one dashboard capture — output file + render-completion stats."""
51
+
52
+ model_config = ConfigDict(frozen=True)
53
+
54
+ uid: str
55
+ display_name: str
56
+ output_path: Path
57
+ items_expected: int
58
+ items_rendered: int
59
+
60
+
61
+ def slugify(name: str) -> str:
62
+ """Produce a filename-safe slug from a dashboard display name."""
63
+ stripped = name.lower().strip()
64
+ stripped = re.sub(r"[^\w\s-]", "", stripped)
65
+ stripped = re.sub(r"[\s_-]+", "-", stripped)
66
+ return stripped.strip("-") or "dashboard"
67
+
68
+
69
+ async def hide_chrome(page: Page) -> None:
70
+ """Hide DHIS2's outer header + the inner dashboard-app toolbar via injected CSS.
71
+
72
+ The header is on the outer page; the dashboard toolbar is inside the
73
+ plugin-host iframe (DHIS2's apps are iframed off the apps shell).
74
+ """
75
+ await page.evaluate(
76
+ """() => {
77
+ const header = document.querySelector('header');
78
+ if (header) header.style.display = 'none';
79
+ const iframe = document.querySelector('iframe');
80
+ if (iframe) {
81
+ iframe.style.top = '0';
82
+ iframe.style.height = '100vh';
83
+ }
84
+ }"""
85
+ )
86
+ await page.evaluate(
87
+ """() => {
88
+ const iframe = document.querySelector('iframe');
89
+ if (!iframe || !iframe.contentDocument) return;
90
+ const doc = iframe.contentDocument;
91
+ const toolbar = doc.querySelector('[data-test="dashboard-bar"]')
92
+ || doc.querySelector('div[class*="toolbar"]');
93
+ if (toolbar) toolbar.style.display = 'none';
94
+ }"""
95
+ )
96
+
97
+
98
+ async def count_rendered_items(page: Page) -> int:
99
+ """Count `.react-grid-item` slots whose inner plugin iframe has rendered content.
100
+
101
+ "Rendered" = the inner iframe's body contains a canvas, an SVG with
102
+ substantive paths/rects, a leaflet/highcharts container, or an image, or
103
+ the body's innerText is non-trivial (long text items). Cross-origin
104
+ inner iframes throw on `contentDocument` access — swallow those; they're
105
+ either app-iframes we can't inspect or plugin fallbacks that don't
106
+ matter for render detection.
107
+ """
108
+ raw = await page.evaluate(
109
+ """() => {
110
+ const outer = document.querySelector('iframe');
111
+ if (!outer || !outer.contentDocument) return 0;
112
+ const items = outer.contentDocument.querySelectorAll('.react-grid-item');
113
+ let count = 0;
114
+ items.forEach(el => {
115
+ const inner = el.querySelector('iframe');
116
+ if (!inner) {
117
+ // Items without an inner iframe (text blocks, etc.) — check the
118
+ // slot itself.
119
+ if (el.innerText.trim().length > 30) count++;
120
+ return;
121
+ }
122
+ try {
123
+ const doc = inner.contentDocument;
124
+ if (!doc || !doc.body) return;
125
+ const has = doc.querySelector('canvas')
126
+ || doc.querySelector('svg path')
127
+ || doc.querySelector('svg rect')
128
+ || doc.querySelector('table td')
129
+ || doc.querySelector('[class*="leaflet"]')
130
+ || doc.querySelector('[class*="highcharts"]')
131
+ || doc.querySelector('video')
132
+ || doc.querySelector('img[src]')
133
+ || doc.body.innerText.trim().length > 100;
134
+ if (has) count++;
135
+ } catch {
136
+ // Cross-origin iframe — skip.
137
+ }
138
+ });
139
+ return count;
140
+ }"""
141
+ )
142
+ return int(raw) if isinstance(raw, (int, float)) else 0
143
+
144
+
145
+ async def wait_for_render(
146
+ page: Page,
147
+ expected: int,
148
+ *,
149
+ timeout_seconds: float = 90.0,
150
+ poll_seconds: float = 2.0,
151
+ plateau_polls: int = 3,
152
+ ) -> int:
153
+ """Block until `expected` items have rendered, or the count plateaus.
154
+
155
+ Returns the highest `count_rendered_items(page)` result observed. The
156
+ plateau detector gives up gracefully after `plateau_polls` consecutive
157
+ unchanged polls — one stuck chart doesn't stall the rest of the pipeline.
158
+ """
159
+ deadline = asyncio.get_event_loop().time() + timeout_seconds
160
+ rendered = 0
161
+ stable_count = 0
162
+ last_rendered = -1
163
+ while asyncio.get_event_loop().time() < deadline:
164
+ rendered = await count_rendered_items(page)
165
+ if rendered >= expected:
166
+ return rendered
167
+ if rendered == last_rendered:
168
+ stable_count += 1
169
+ if stable_count >= plateau_polls:
170
+ return rendered
171
+ else:
172
+ stable_count = 0
173
+ last_rendered = rendered
174
+ await asyncio.sleep(poll_seconds)
175
+ return rendered
176
+
177
+
178
+ async def get_content_height(page: Page) -> int:
179
+ """Measure the furthest `.react-grid-item.bottom` so the viewport sizes right."""
180
+ height = await page.evaluate(
181
+ """() => {
182
+ const iframe = document.querySelector('iframe');
183
+ if (!iframe || !iframe.contentDocument) return 2000;
184
+ const items = iframe.contentDocument.querySelectorAll('.react-grid-item');
185
+ let maxBottom = 0;
186
+ items.forEach(el => {
187
+ const rect = el.getBoundingClientRect();
188
+ if (rect.bottom > maxBottom) maxBottom = rect.bottom;
189
+ });
190
+ return maxBottom || 2000;
191
+ }"""
192
+ )
193
+ return int(height)
194
+
195
+
196
+ async def scroll_to_load_all_items(page: Page, *, steps: int = 20, step_pixels: int = 800) -> None:
197
+ """Scroll through the dashboard with real `mouse.wheel` events to trigger lazy loading.
198
+
199
+ Programmatic `scrollTop` doesn't fire DHIS2's intersection-observer hooks;
200
+ only real wheel events prompt the app to materialise plugin iframes that
201
+ are still offscreen. Clicks into the dashboard area first so the inner
202
+ iframe has focus, scrolls down to the bottom, then back up so the first
203
+ items are re-visible when the screenshot fires.
204
+ """
205
+ for _ in range(15):
206
+ count = await page.evaluate(
207
+ """() => {
208
+ const f = document.querySelector('iframe');
209
+ if (!f || !f.contentDocument) return 0;
210
+ return f.contentDocument.querySelectorAll('.react-grid-item').length;
211
+ }"""
212
+ )
213
+ if count > 0:
214
+ break
215
+ await asyncio.sleep(1)
216
+ await page.mouse.click(700, 400)
217
+ await asyncio.sleep(0.5)
218
+ for _ in range(steps):
219
+ await page.mouse.wheel(0, step_pixels)
220
+ await asyncio.sleep(0.4)
221
+ for _ in range(steps):
222
+ await page.mouse.wheel(0, -step_pixels)
223
+ await asyncio.sleep(0.15)
224
+ await asyncio.sleep(2)
225
+
226
+
227
+ async def switch_dashboard(page: Page, dashboard_uid: str, *, settle_seconds: float = 5.0) -> None:
228
+ """Swap the inner iframe's hash to the given dashboard UID — no full reload.
229
+
230
+ The dashboard app reads the hash off its own location and re-renders in
231
+ place, dropping the prior grid items and building new ones. Settling
232
+ time gives the grid time to materialise before the caller starts
233
+ checking render completion.
234
+ """
235
+ await page.evaluate(
236
+ f"""() => {{
237
+ const iframe = document.querySelector('iframe');
238
+ if (iframe && iframe.contentWindow) {{
239
+ iframe.contentWindow.location.hash = '/{dashboard_uid}';
240
+ }}
241
+ }}"""
242
+ )
243
+ await asyncio.sleep(settle_seconds)
244
+
245
+
246
+ def trim_background(path: Path) -> None:
247
+ """Crop uniform-colour edges off the bottom + right of a screenshot (in-place)."""
248
+ img = Image.open(path)
249
+ pixels = img.load()
250
+ if pixels is None: # pragma: no cover — Pillow always returns PixelAccess for PNGs
251
+ return
252
+ width, height = img.size
253
+ background = pixels[width - 1, height - 1]
254
+
255
+ crop_y = height
256
+ for y in range(height - 1, -1, -1):
257
+ if not all(pixels[x, y] == background for x in range(0, width, 10)):
258
+ crop_y = min(y + 20, height)
259
+ break
260
+
261
+ crop_x = width
262
+ for x in range(width - 1, -1, -1):
263
+ if not all(pixels[x, y] == background for y in range(0, crop_y, 10)):
264
+ crop_x = min(x + 20, width)
265
+ break
266
+
267
+ if crop_x < width or crop_y < height:
268
+ img.crop((0, 0, crop_x, crop_y)).save(path)
269
+
270
+
271
+ def add_banner(
272
+ path: Path,
273
+ dashboard_name: str,
274
+ *,
275
+ instance_url: str,
276
+ username: str,
277
+ item_count: int,
278
+ timestamp: datetime | None = None,
279
+ ) -> None:
280
+ """Prepend a dark info banner above the screenshot (in-place)."""
281
+ when = timestamp or datetime.now(tz=UTC)
282
+ img = Image.open(path)
283
+ width, height = img.size
284
+
285
+ banner_height = 36
286
+ banner = Image.new("RGB", (width, banner_height), (55, 63, 81))
287
+ draw = ImageDraw.Draw(banner)
288
+
289
+ font: ImageFont.ImageFont | ImageFont.FreeTypeFont
290
+ try:
291
+ font = ImageFont.truetype("/System/Library/Fonts/SFNSMono.ttf", 14)
292
+ except OSError:
293
+ try:
294
+ font = ImageFont.truetype("/System/Library/Fonts/Menlo.ttc", 14)
295
+ except OSError:
296
+ font = ImageFont.load_default()
297
+
298
+ instance = re.sub(r"https?://", "", instance_url)
299
+ text = (
300
+ f" {dashboard_name} | {instance} | {item_count} items | {when.strftime('%Y-%m-%d %H:%M')} | {username}"
301
+ )
302
+ draw.text((10, 10), text, fill=(220, 225, 234), font=font)
303
+
304
+ result = Image.new("RGB", (width, banner_height + height))
305
+ result.paste(banner, (0, 0))
306
+ result.paste(img.convert("RGB"), (0, banner_height))
307
+ result.save(path)
308
+
309
+
310
+ async def capture_dashboard(
311
+ page: Page,
312
+ target: DashboardTarget,
313
+ output_path: Path,
314
+ *,
315
+ viewport_width: int = 1400,
316
+ viewport_height_expanded: int = 8000,
317
+ render_timeout_seconds: float = 90.0,
318
+ ) -> CaptureResult:
319
+ """Screenshot one dashboard, assuming `page` is already on the dashboard app.
320
+
321
+ Caller handles switching between dashboards (via `switch_dashboard`) or
322
+ initial navigation. This function handles: reset viewport → scroll-trigger
323
+ lazy loads → wait for renders → resize viewport to fit content → hide
324
+ chrome → capture full page.
325
+ """
326
+ await page.set_viewport_size({"width": viewport_width, "height": 900})
327
+
328
+ if target.item_count == 0:
329
+ # Empty dashboard — skip the scroll+wait dance; just hide chrome + snap.
330
+ await hide_chrome(page)
331
+ await page.screenshot(path=str(output_path), full_page=True)
332
+ return CaptureResult(
333
+ uid=target.uid,
334
+ display_name=target.display_name,
335
+ output_path=output_path,
336
+ items_expected=0,
337
+ items_rendered=0,
338
+ )
339
+
340
+ await scroll_to_load_all_items(page)
341
+ await page.set_viewport_size({"width": viewport_width, "height": viewport_height_expanded})
342
+ await asyncio.sleep(3)
343
+
344
+ rendered = await wait_for_render(page, target.item_count, timeout_seconds=render_timeout_seconds)
345
+
346
+ content_height = await get_content_height(page) + 50
347
+ await page.set_viewport_size({"width": viewport_width, "height": content_height})
348
+ await asyncio.sleep(2)
349
+
350
+ await hide_chrome(page)
351
+ await page.screenshot(path=str(output_path), full_page=True)
352
+ return CaptureResult(
353
+ uid=target.uid,
354
+ display_name=target.display_name,
355
+ output_path=output_path,
356
+ items_expected=target.item_count,
357
+ items_rendered=rendered,
358
+ )