"""Phase 26 E2E (Playwright): documents open in the almost-fullscreen
modal — not in a new page.
Story: ``.agents/user_stories/document-modal.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_document_viewer.py -v --no-cov
Seeding reuses the real importer against ``tests/fixtures/docs/`` with the
deterministic mock embeddings (same harness as the phase-10 suite — only
the assertions changed: chips/row links now open the SAME-PAGE modal,
no ``expect_popup``).
Test → story mapping (Playwright Mapping Rule):
1. ``test_source_chip_opens_modal`` — chat chip → modal opens in-page
(NO new tab, URL unchanged), title + ``.doc-md`` content + meta row.
2. ``test_sources_row_opens_modal`` — Sources path link → modal, yaml in
``
``, mono font, URL unchanged.
3. ``test_modal_closes_on_button_escape_and_backdrop`` — close via
``#doc-modal-close``, via backdrop click, via ``Escape``.
4. ``test_modal_focus_and_a11y`` — ``role="dialog"`` + ``aria-modal``,
focus inside the panel on open, close button has an ``aria-label``.
5. ``test_modal_xss_safe`` — hostile md document opened through the modal
renders as escaped text; no dialog fires.
6. ``test_standalone_page_still_works`` — the dedicated ``/document.html``
page keeps its phase-10 contract (title/content/badges, not-found,
dark theme, no-CDN, a11y frame, the 72rem-container md column —
phase 100: ≈1112px at the 1280px fixture viewport, the container's
inner content).
7. ``test_modal_theme_and_no_cdn`` — dark page background, the panel on
the Phase-08 surface colour, every asset same-origin or ``data:``.
8. ``test_raw_code_block_themed_scrollbar`` — phase 116: the raw code
block (``pre.doc-raw``) overflows horizontally, a scroll action moves
it, the themed scrollbar tokens are in effect via computed style, and
a screenshot records the themed (not native light-gray) bar.
"""
from __future__ import annotations
import asyncio
import re
from datetime import UTC, datetime
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.models import Document
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
SCREENSHOTS = REPO / ".agents" / "screenshots" # house convention for visual records
QUESTION = "How is my Kubernetes cluster set up?"
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
"""Truncate the KB (and query log), then optionally re-import fixtures."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
def _ask_for_chip(page: Page, app_url: str) -> Any:
"""Drive one chat turn and return the kubernetes.md source chip."""
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", QUESTION)
page.click("#send-btn")
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
expect(chip).to_have_count(1, timeout=30_000)
return chip
def _assert_closed(page: Page) -> None:
"""The modal is fully closed: the hidden attribute is back and the
overlay is gone from view."""
expect(page.locator("#doc-modal")).to_have_attribute("hidden", "")
expect(page.locator(".doc-modal")).not_to_be_visible()
def _drill(page: Page, *names: str) -> None:
"""Phase 97: the catalog is the drill-down tree the agent's `ls`
sees — click through the source/folder rows (exact name match,
one per name) to the level that holds the asserted file. The drill
is the only change from the flat-table era; the row itself is
unchanged."""
for name in names:
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
def _seed_long_line_doc() -> None:
"""Seed the synthetic phase-116 fixture (see the test docstring):
a non-markdown doc whose one long line is deliberately longer than
the modal width — the shape of the TODO's quest .pl ``quest::say``
line. Direct DB seed, the ``test_modal_xss_safe`` pattern (the
viewer is database-only; no chunks needed)."""
long_line = "quest::say(\"" + ("the quick brown fox jumps over the lazy dog " * 20) + "\")"
with SessionLocal() as db:
db.add(
Document(
source="docs",
path="notes/long-line.txt",
full_path="/tmp/long-line.txt",
title="Long Line Fixture",
content=(
"# long-line-fixture: the line below is deliberately\n"
"# longer than the document modal's width (phase 116).\n"
+ long_line
+ "\n"
),
content_hash="e" * 64,
indexed_at=datetime.now(UTC),
)
)
db.commit()
# ---------------------------------------------------------------------------
# 1. Chat source chip → SAME-PAGE modal (no new tab)
# ---------------------------------------------------------------------------
def test_source_chip_opens_modal(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
chip = _ask_for_chip(page, app_url)
# The encoded viewer URL stays as the no-JS / context-menu escape
# hatch — but phase 26 removed target=_blank: the left click is
# intercepted and opens the modal in place.
expect(chip.first).to_have_attribute(
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
)
expect(chip.first).not_to_have_attribute("target")
before = len(page.context.pages)
chip.first.click()
# No new tab: the click must not have spawned a page.
assert len(page.context.pages) == before, "clicking a chip must not open a new tab"
# The almost-fullscreen modal becomes visible (hidden attribute gone).
expect(page.locator("#doc-modal")).not_to_have_attribute("hidden")
expect(page.locator(".doc-modal")).to_be_visible()
# "Almost-fullscreen": the panel is min(1100px, 96vw) × 92vh, centered
# (at a 1280px viewport the 1100px cap wins over 96vw = 1228.8px).
box = page.locator("#doc-modal-panel").bounding_box()
assert box is not None, "modal panel not rendered"
expected_w = min(1100, 0.96 * 1280)
expected_h = 0.92 * 800
assert abs(box["width"] - expected_w) < 2, f"panel width {box['width']} (want ~{expected_w})"
assert abs(box["height"] - expected_h) < 2, f"panel height {box['height']} (want ~{expected_h})"
# Same content the /document.html page renders: title, markdown in
# the centered .doc-md column (the modal's content target is
# #doc-modal-content — the modal variant of the page's #doc-content).
expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster")
expect(page.locator("#doc-modal-content .doc-md")).to_have_count(1)
expect(page.locator("#doc-modal-content")).to_contain_text("Talos Linux on three nodes")
# Meta row mirrors the viewer: source · format · mono path · indexed · chunks.
expect(page.locator("#doc-modal-meta .doc-source-badge")).to_have_text("docs")
expect(page.locator("#doc-modal-meta .format-badge")).to_have_text("md")
expect(page.locator("#doc-modal-meta .doc-path")).to_have_text("homelab/kubernetes.md")
expect(page.locator("#doc-modal-meta .doc-indexed")).to_contain_text("Indexed")
assert re.fullmatch(
r"\d+ chunks?", page.locator("#doc-modal-meta .doc-chunks").inner_text()
)
# Still the chat page: no navigation happened.
assert page.url == app_url + "/", f"navigated away: {page.url}"
# ---------------------------------------------------------------------------
# 2. Sources table path link → same-page modal (yaml → raw pre)
# ---------------------------------------------------------------------------
def test_sources_row_opens_modal(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
login(page, app_url) # phase 16: the Sources catalog is admin-only
# Phase 97: the row lives at its folder level (docs → homelab →
# container_gitlab) — the drill is the only change.
_drill(page, "docs", "homelab", "container_gitlab")
row = page.locator("#docs-tbody tr", has_text="gitlab-compose.yaml")
expect(row).to_have_count(1)
link = row.locator("td:nth-child(2) a.doc-link")
expect(link).to_have_count(1)
# Encoded URL kept as the escape hatch (slashes come out as %2F);
# no target=_blank any more.
expect(link).to_have_attribute(
"href",
"/document.html?source=docs&path=homelab%2Fcontainer_gitlab%2Fgitlab-compose.yaml",
)
expect(link).not_to_have_attribute("target")
expect(link).to_have_attribute("title", "homelab/container_gitlab/gitlab-compose.yaml")
before = len(page.context.pages)
link.click()
assert len(page.context.pages) == before, "clicking a row link must not open a new tab"
expect(page.locator(".doc-modal")).to_be_visible()
expect(page.locator("#doc-modal-title")).to_have_text("gitlab-compose")
expect(page.locator("#doc-modal-meta .format-badge")).to_have_text("yaml")
# Non-markdown formats render as escaped monospace text in a pre.
pre = page.locator("#doc-modal-content pre.doc-raw")
expect(pre).to_have_count(1)
expect(pre).to_contain_text("gitlab/gitlab-ce:17.2.1-ce.0")
font = pre.evaluate("el => getComputedStyle(el).fontFamily")
assert "mono" in font
# Still on the Sources page: no navigation happened.
assert page.url == app_url + "/sources.html", f"navigated away: {page.url}"
# ---------------------------------------------------------------------------
# 3. Close on button, backdrop, and Escape
# ---------------------------------------------------------------------------
def test_modal_closes_on_button_escape_and_backdrop(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
chip = _ask_for_chip(page, app_url)
def open_and_loaded() -> None:
chip.first.click()
expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster")
# 1) The close button.
open_and_loaded()
page.click("#doc-modal-close")
_assert_closed(page)
# 2) The backdrop — a point outside the centered 96vw × 92vh panel
# (panel starts at 4vh from the top / 2vw from the edge).
open_and_loaded()
page.locator("#doc-modal-backdrop").click(position={"x": 5, "y": 5})
_assert_closed(page)
# 3) Escape — the capture is document-level, so it works from any
# focus position inside (or outside) the panel.
open_and_loaded()
page.keyboard.press("Escape")
_assert_closed(page)
# After closing, the page behind is untouched: chat is still there.
assert page.url == app_url + "/"
expect(page.locator("#composer")).to_be_visible()
# ---------------------------------------------------------------------------
# 4. Focus management + dialog a11y frame
# ---------------------------------------------------------------------------
def test_modal_focus_and_a11y(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
chip = _ask_for_chip(page, app_url)
chip.first.click()
expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster")
# The panel is a proper modal dialog, labelled by its title.
panel = page.locator("#doc-modal-panel")
expect(panel).to_have_attribute("role", "dialog")
expect(panel).to_have_attribute("aria-modal", "true")
expect(panel).to_have_attribute("aria-labelledby", "doc-modal-title")
# On open, focus moves into the dialog's content target.
focus_id = page.evaluate("() => document.activeElement && document.activeElement.id")
assert focus_id == "doc-modal-content", f"focus {focus_id!r} did not move into the modal"
# The close control carries an accessible name (icon-only button).
expect(page.locator("#doc-modal-close")).to_have_attribute("aria-label", "Close document")
# The "Full page" escape hatch is rebuilt to the same encoded viewer
# URL (no back param — the dedicated page's own default applies).
expect(page.locator("#doc-modal-open")).to_be_visible()
expect(page.locator("#doc-modal-open")).to_have_attribute(
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md"
)
# Closing returns focus to the triggering control.
page.keyboard.press("Escape")
_assert_closed(page)
focus_id = page.evaluate("() => document.activeElement && document.activeElement.className")
assert "source-chip" in (focus_id or ""), f"focus {focus_id!r} did not return to the chip"
# ---------------------------------------------------------------------------
# 5. Modal rendering stays XSS-safe (hostile md, opened via the modal)
# ---------------------------------------------------------------------------
def test_modal_xss_safe(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
# A document whose content carries a hostile \n\nXSS-FIXTURE-MARKER",
content_hash="c" * 64,
indexed_at=datetime.now(UTC),
)
)
db.commit()
dialogs: list[str] = []
def _catch_dialog(d) -> None: # a fired dialog == executed script
dialogs.append(d.message)
d.dismiss()
page.on("dialog", _catch_dialog)
# The Sources table lists every indexed document — the admin entry
# point into the modal for a doc the chat never cited.
login(page, app_url)
# Phase 97: the seeded doc's row lives at the notes/ level — the
# drill is the only change.
_drill(page, "docs", "notes")
row = page.locator("#docs-tbody tr", has_text="xss-fixture.md")
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
expect(page.locator(".doc-modal")).to_be_visible()
expect(page.locator("#doc-modal-title")).to_have_text("Xss Fixture")
# The tag shows up as VISIBLE, ESCAPED text — rendered, never executed.
expect(page.locator("#doc-modal-content")).to_contain_text("")
expect(page.locator("#doc-modal-content")).to_contain_text("XSS-FIXTURE-MARKER")
assert page.locator("#doc-modal-content script").count() == 0, "hostile script became live HTML"
assert dialogs == [], f"dialog fired — script executed: {dialogs}"
# ---------------------------------------------------------------------------
# 6. The dedicated /document.html page keeps its phase-10 contract
# ---------------------------------------------------------------------------
def test_standalone_page_still_works(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
# Phase 79: the viewer content is gated — the signed-in session sees
# the phase-10 contract unchanged (the not-found cards below come
# from the 404 / missing-params paths, not the auth gate).
login(page, app_url, next="/")
errors: list[str] = []
page.on("pageerror", lambda e: errors.append(str(e)))
# Direct link renders exactly as before (phase 10): title, meta row,
# markdown in the centered column.
page.goto(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md")
expect(page.locator("#doc-title")).to_have_text("Kubernetes Homelab Cluster")
expect(page.locator("#doc-meta .doc-source-badge")).to_have_text("docs")
expect(page.locator("#doc-meta .format-badge")).to_have_text("md")
expect(page.locator("#doc-content .doc-md")).to_have_count(1)
expect(page.locator("#doc-content")).to_contain_text("Talos Linux on three nodes")
# Not-found state: an unknown pair AND missing params — no console
# crash, the designed card with the Sources link.
page.goto(f"{app_url}/document.html?source=docs&path=definitely/not/here.md")
expect(page.locator("#doc-title")).to_have_text("Document not found")
card = page.locator("#doc-not-found")
expect(card).to_be_visible()
expect(card).to_contain_text("Document not found")
expect(card.locator("a.doc-open-sources")).to_have_attribute("href", "/sources.html")
expect(page.locator("#doc-content")).to_be_empty()
page.goto(f"{app_url}/document.html")
expect(page.locator("#doc-not-found")).to_be_visible()
# Dark theme + all assets local + a11y frame + capped md column.
page.goto(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md")
expect(page.locator("#doc-content .doc-md")).not_to_be_empty()
bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor")
assert bg == "rgb(15, 10, 10)", "the dark-red rebrand canvas (#0f0a0a)"
refs = page.evaluate(
"""() => [...document.querySelectorAll("script[src], link[href]")]
.map((el) => el.src || el.href)"""
)
assert refs, "expected local asset references on /document.html"
for ref in refs:
assert ref.startswith(app_url) or ref.startswith("data:"), f"non-local: {ref}"
expect(page.locator("header.doc-header")).to_have_count(1)
expect(page.locator("main#main")).to_have_count(1)
expect(page.locator("footer.app-footer")).to_have_count(1)
expect(page.locator(".skip-link")).to_have_count(1)
expect(page.locator(".doc-shell")).to_have_attribute("aria-live", "polite")
assert page.evaluate("() => document.activeElement && document.activeElement.id") == "main"
# Markdown column: the 72rem container's inner content (phase 100 —
# the 46rem cap and its wide-desktop doubling are retired): at the
# 1280px fixture viewport the container is 1152px border-box, so
# .doc-md (width:100% inside it) measures 1152 − 2×1.25rem = 1112px.
box = page.locator("#doc-content .doc-md").bounding_box()
assert box is not None, "the standalone .doc-md column is not rendered"
assert abs(box["width"] - 1112) <= 4, (
f"the standalone .doc-md column is {box['width']:.0f}px, "
f"want 1112px (the 72rem container's inner content) ±4px"
)
assert errors == [], f"console crashes: {errors}"
# ---------------------------------------------------------------------------
# 7. Modal theme + no CDN on the touched page
# ---------------------------------------------------------------------------
def test_modal_theme_and_no_cdn(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
chip = _ask_for_chip(page, app_url)
chip.first.click()
expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster")
expect(page.locator("#doc-modal-content .doc-md")).not_to_be_empty()
# Dark theme (phase 08, dark-red rebrand 2026-08-28): the page
# background is untouched, and the modal panel sits on the --surface
# colour (#1a0f0f).
bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor")
assert bg == "rgb(15, 10, 10)"
surface = page.evaluate(
"() => getComputedStyle(document.querySelector('.doc-modal-panel')).backgroundColor"
)
assert surface == "rgb(26, 15, 15)", f"panel not on the --surface colour: {surface}"
# No-CDN: every script/link reference on the chat page (the touched
# page) is same-origin or a data: URI — the modal adds no assets.
refs = page.evaluate(
"""() => [...document.querySelectorAll("script[src], link[href]")]
.map((el) => el.src || el.href)"""
)
assert refs, "expected local asset references on the chat page"
for ref in refs:
assert ref.startswith(app_url) or ref.startswith("data:"), f"non-local: {ref}"
# ---------------------------------------------------------------------------
# 8. Phase 116: the raw code block scrolls horizontally with a themed
# scrollbar (task 01's scoped rules) — never the native light-gray bar
# ---------------------------------------------------------------------------
def test_raw_code_block_themed_scrollbar(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""The modal's ``pre.doc-raw`` overflows horizontally, a horizontal
scroll action actually moves it, and task 01's themed scrollbar rules
are in effect (the screenshot is the visual record of the themed bar).
Document choice (the task says to check the fixture KB first): no
fixture doc qualifies — the longest RAW (non-markdown) fixture line is
91 chars (``homelab/networking/static-dns.json``) ≈ 742px at the
0.85rem mono font, well inside the ≈1058px pre client width at the
1280px fixture viewport (panel 1100px − content padding − pre border).
So this test seeds a synthetic doc instead (the
``test_modal_xss_safe`` direct-seed pattern): one ≈935-char
``quest::say(...)`` line — the shape of the TODO's observed quest
.pl line — which guarantees real horizontal overflow.
Screenshot note: headless Chromium paints NO scrollbars at all
(frame UI is omitted — verified on Chromium 151), so the suite's
screenshot (``doc_modal_scrollbar_1280.png``) is the record of the
scrolled modal state (long line mid-content = overflow + working
scroll, no native bar); the themed bar itself is asserted via
computed style here and recorded in the headed capture
``doc_modal_scrollbar_themed_1280.png`` (phase 116, task 02).
"""
_reset_db(mock_llm, seed=True)
_seed_long_line_doc()
login(page, app_url)
_drill(page, "docs", "notes")
row = page.locator("#docs-tbody tr", has_text="long-line.txt")
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
expect(page.locator(".doc-modal")).to_be_visible()
expect(page.locator("#doc-modal-title")).to_have_text("Long Line Fixture")
pre = page.locator("#doc-modal-content pre.doc-raw")
expect(pre).to_have_count(1)
# The overflow is real: the long line makes the content wider than the
# box (no line-wrap — locked A1, phase 116).
dims = pre.evaluate("el => ({sw: el.scrollWidth, cw: el.clientWidth})")
assert dims["sw"] > dims["cw"], f"no horizontal overflow: {dims}"
# The overflow is SCROLLED, not clipped: the element owns its
# overflow-x (task 01's comment in styles.css) and the themed rules
# are in effect — scrollbar-width: thin + scrollbar-color as a
# two-colour pair (the ::-webkit-scrollbar pseudos are not exposed via
# computed style; that half is the screenshot's job).
themed = page.evaluate(
"""() => {
const el = document.querySelector('#doc-modal-content pre.doc-raw');
const cs = getComputedStyle(el);
const probe = (name) => {
const s = document.createElement('span');
const hidden = 'position:absolute;visibility:hidden;';
s.style.cssText = hidden + 'background:var(' + name + ')';
document.body.appendChild(s);
const bg = getComputedStyle(s).backgroundColor;
s.remove();
return bg;
};
return {
overflowX: cs.overflowX,
width: cs.scrollbarWidth,
color: cs.scrollbarColor,
thumb: probe('--scrollbar-thumb'),
track: probe('--scrollbar-track'),
};
}"""
)
assert themed["overflowX"] == "auto", f"overflow-x is {themed['overflowX']}, not auto"
assert themed["width"] == "thin", f"scrollbar-width is {themed['width']}, not thin"
# The tokens are color-mix() derivations, so Chromium serializes the
# used values as color(srgb …) (plain rgb(…) or #hex also accepted).
colors = re.findall(
r"#[0-9a-fA-F]{3,8}\b|rgb\([^)]*\)|color\([^)]*\)", themed["color"]
)
assert len(colors) == 2, f"scrollbar-color {themed['color']!r} is not a two-colour pair"
assert colors[0] == themed["thumb"], (
f"scrollbar thumb {colors[0]} is not the theme's --scrollbar-thumb {themed['thumb']}"
)
assert colors[1] == themed["track"], (
f"scrollbar track {colors[1]} is not the theme's --scrollbar-track {themed['track']}"
)
# A horizontal scroll action actually moves it (no clipping — the
# rest of the line is reachable). The pre is freshly rendered, so
# scrollLeft starts at 0.
before = pre.evaluate("el => el.scrollLeft")
assert before == 0, f"expected scrollLeft 0 before the action, got {before}"
pre.evaluate("el => el.scrollBy({left: 300, behavior: 'instant'})")
after = pre.evaluate("el => el.scrollLeft")
assert after > 250, f"scrollBy(300) only moved to {after}"
# Visual record (house convention): the bar at the pre's bottom is the
# themed one — scroll to mid-content so the thumb sits mid-track.
pre.evaluate("el => el.scrollTo({left: el.scrollWidth / 2, behavior: 'instant'})")
shot = SCREENSHOTS / "doc_modal_scrollbar_1280.png"
page.screenshot(path=str(shot))
assert shot.exists() and shot.stat().st_size > 0