286 lines
11 KiB
Python
286 lines
11 KiB
Python
"""Render smoke tests for app/pdf.py — the 'resume won't render' incident class.
|
|
|
|
A live demo of Resume-Matcher once broke a YouTuber's stream because PDF
|
|
rendering failed. These tests give real coverage to the render path:
|
|
|
|
* Pure helpers (format/margins) always run — no browser required.
|
|
* A real headless-Chromium render proves the happy path actually emits PDF
|
|
bytes (magic header + non-trivial size), not just "no exception".
|
|
|
|
The real frontend is not available under test, so the render targets a
|
|
self-contained ``data:`` URL that already contains the ``.resume-print``
|
|
selector. ``networkidle`` and ``wait_for_selector`` both resolve on a static
|
|
data: URL, exercising the same goto → wait → page.pdf flow as production.
|
|
|
|
Real-render tests skip cleanly (never hard-fail) when no Chromium binary can
|
|
be launched.
|
|
"""
|
|
|
|
import socket
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from playwright.async_api import Error as PlaywrightError
|
|
|
|
from app.pdf import (
|
|
PDFRenderError,
|
|
_raise_playwright_error,
|
|
_render_page_to_pdf,
|
|
_resolve_pdf_format,
|
|
_resolve_pdf_margins,
|
|
close_pdf_renderer,
|
|
render_resume_pdf,
|
|
)
|
|
|
|
|
|
# A self-contained page that satisfies wait_for_selector(".resume-print")
|
|
# without needing the real frontend running.
|
|
RESUME_PRINT_DATA_URL = (
|
|
"data:text/html,"
|
|
"<html><body><div class='resume-print'>Hello PDF</div></body></html>"
|
|
)
|
|
|
|
|
|
def _refused_url():
|
|
"""Return a URL on a closed, connection-refusing localhost port.
|
|
|
|
Bind an ephemeral port to claim a free number, read it, then CLOSE the
|
|
socket: with nothing bound, the kernel answers a connect with RST, so
|
|
Chromium navigation fails fast with net::ERR_CONNECTION_REFUSED — the signal
|
|
this test needs.
|
|
|
|
We deliberately close rather than hold the socket bound-but-unlistening:
|
|
that was tried, and on macOS a bound, non-listening socket does NOT refuse —
|
|
the SYN is dropped and Chromium hangs until its 30s navigation timeout (a
|
|
different, slower failure path), so the test would stop exercising
|
|
connection-refused. The residual window between close() and connect() is
|
|
sub-millisecond on a loopback ephemeral port, and a collision would only
|
|
*delay* the same failure, never mask it. (Port 9/discard is on Chromium's
|
|
unsafe-ports blocklist and yields ERR_UNSAFE_PORT, which also doesn't
|
|
exercise this mapping.)
|
|
"""
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.bind(("127.0.0.1", 0))
|
|
port = sock.getsockname()[1]
|
|
sock.close()
|
|
return f"http://127.0.0.1:{port}/"
|
|
|
|
|
|
async def _render_or_skip(url, **kwargs):
|
|
"""Render ``url`` to PDF, or skip the test if Chromium is unavailable.
|
|
|
|
Distinguishes "Chromium can't launch" (skip — environment limitation) from
|
|
a genuine render/navigation failure (re-raise — that's the bug we test for).
|
|
A missing-browser failure surfaces as a PDFRenderError mentioning the
|
|
executable, or as a raw PlaywrightError about a missing executable.
|
|
"""
|
|
try:
|
|
return await render_resume_pdf(url, **kwargs)
|
|
except PDFRenderError as exc:
|
|
if "executable" in str(exc).lower():
|
|
pytest.skip(f"chromium unavailable: {exc}")
|
|
raise
|
|
except PlaywrightError as exc:
|
|
if "Executable doesn't exist" in str(exc):
|
|
pytest.skip(f"chromium unavailable: {exc}")
|
|
raise
|
|
except NotImplementedError as exc:
|
|
# Subprocess launch unsupported on this event loop policy.
|
|
pytest.skip(f"chromium subprocess launch unsupported: {exc}")
|
|
|
|
|
|
class TestResolvePdfFormat:
|
|
"""_resolve_pdf_format — page-size string → Playwright PDF format."""
|
|
|
|
def test_a4_maps_to_a4(self):
|
|
assert _resolve_pdf_format("A4") == "A4"
|
|
|
|
def test_letter_maps_to_letter(self):
|
|
# Note the case change: input "LETTER" → Playwright's "Letter".
|
|
assert _resolve_pdf_format("LETTER") == "Letter"
|
|
|
|
def test_unknown_defaults_to_a4(self):
|
|
assert _resolve_pdf_format("TABLOID") == "A4"
|
|
|
|
def test_empty_string_defaults_to_a4(self):
|
|
assert _resolve_pdf_format("") == "A4"
|
|
|
|
|
|
class TestResolvePdfMargins:
|
|
"""_resolve_pdf_margins — margin dict → mm-suffixed Playwright margins."""
|
|
|
|
def test_none_returns_ten_mm_on_all_sides(self):
|
|
assert _resolve_pdf_margins(None) == {
|
|
"top": "10mm",
|
|
"right": "10mm",
|
|
"bottom": "10mm",
|
|
"left": "10mm",
|
|
}
|
|
|
|
def test_empty_dict_falls_back_to_defaults(self):
|
|
# Empty dict is falsy, so it takes the same default path as None.
|
|
assert _resolve_pdf_margins({}) == {
|
|
"top": "10mm",
|
|
"right": "10mm",
|
|
"bottom": "10mm",
|
|
"left": "10mm",
|
|
}
|
|
|
|
def test_custom_values_are_formatted_as_mm(self):
|
|
result = _resolve_pdf_margins(
|
|
{"top": 20, "right": 15, "bottom": 25, "left": 5}
|
|
)
|
|
assert result == {
|
|
"top": "20mm",
|
|
"right": "15mm",
|
|
"bottom": "25mm",
|
|
"left": "5mm",
|
|
}
|
|
|
|
def test_partial_dict_fills_missing_sides_with_ten(self):
|
|
# Provided keys win; absent keys default to 10mm.
|
|
result = _resolve_pdf_margins({"top": 30})
|
|
assert result == {
|
|
"top": "30mm",
|
|
"right": "10mm",
|
|
"bottom": "10mm",
|
|
"left": "10mm",
|
|
}
|
|
|
|
|
|
class TestRenderPageWaitStrategy:
|
|
"""#799/#808: rendering must wait on a deterministic readiness condition
|
|
(document 'load' + the resume content selector + fonts), NOT the
|
|
environment-fragile 'networkidle' that hangs against the Next.js dev server
|
|
(HMR/Turbopack/RSC streaming keep the network busy, so idle never arrives →
|
|
30s timeout → 503). These are browser-free: they mock the Playwright Page.
|
|
"""
|
|
|
|
async def test_goto_uses_load_with_bounded_timeout(self):
|
|
page = AsyncMock()
|
|
page.pdf.return_value = b"%PDF-1.4 fake"
|
|
await _render_page_to_pdf(page, "http://f/print/r", ".resume-print", "A4", {"top": "10mm"})
|
|
_, goto_kwargs = page.goto.call_args
|
|
assert goto_kwargs.get("wait_until") == "load"
|
|
# An explicit, positive, bounded navigation timeout (not the fragile default).
|
|
timeout = goto_kwargs.get("timeout")
|
|
assert isinstance(timeout, (int, float)) and timeout > 0
|
|
|
|
async def test_still_gates_on_content_selector(self):
|
|
"""The real readiness signal — the resume content must be present."""
|
|
page = AsyncMock()
|
|
page.pdf.return_value = b"%PDF-1.4 fake"
|
|
await _render_page_to_pdf(page, "http://f/print/r", ".resume-print", "A4", {"top": "10mm"})
|
|
page.wait_for_selector.assert_awaited()
|
|
selector_arg = page.wait_for_selector.call_args.args[0]
|
|
assert selector_arg == ".resume-print"
|
|
|
|
async def test_still_waits_for_fonts_bounded(self):
|
|
"""Fonts must be loaded before snapshot (else text renders unstyled), and
|
|
the wait must be bounded by the nav timeout — not Playwright's default."""
|
|
page = AsyncMock()
|
|
page.pdf.return_value = b"%PDF-1.4 fake"
|
|
await _render_page_to_pdf(page, "http://f/print/r", ".resume-print", "A4", {"top": "10mm"})
|
|
page.wait_for_function.assert_awaited()
|
|
assert "fonts" in page.wait_for_function.call_args.args[0]
|
|
assert page.wait_for_function.call_args.kwargs.get("timeout")
|
|
|
|
|
|
class TestPlaywrightErrorMapping:
|
|
"""#811 + info-disclosure (CLAUDE.md rule 5): the catch-all must NOT leak raw
|
|
Playwright internals (call log, internal navigation URLs) to the client;
|
|
curated, safe messages must be preserved.
|
|
"""
|
|
|
|
def test_catch_all_is_generic_and_hides_internals(self):
|
|
raw = (
|
|
"Page.goto: Timeout 30000ms exceeded.\n"
|
|
"Call log:\n"
|
|
' - navigating to "http://localhost:3000/print/resumes/SECRET-RESUME-ID"'
|
|
)
|
|
with pytest.raises(PDFRenderError) as exc_info:
|
|
_raise_playwright_error(PlaywrightError(raw), "http://localhost:3000/print/resumes/SECRET-RESUME-ID")
|
|
msg = str(exc_info.value)
|
|
assert "Call log" not in msg
|
|
assert "SECRET-RESUME-ID" not in msg
|
|
assert "30000ms" not in msg
|
|
|
|
def test_connection_refused_message_preserved(self):
|
|
with pytest.raises(PDFRenderError) as exc_info:
|
|
_raise_playwright_error(
|
|
PlaywrightError("net::ERR_CONNECTION_REFUSED at http://localhost:3000"),
|
|
"http://localhost:3000/print/x",
|
|
)
|
|
assert "cannot connect to frontend" in str(exc_info.value).lower()
|
|
|
|
def test_missing_executable_message_preserved(self):
|
|
with pytest.raises(PDFRenderError) as exc_info:
|
|
_raise_playwright_error(
|
|
PlaywrightError("Executable doesn't exist at /ms-playwright/chromium"),
|
|
"http://x/",
|
|
)
|
|
assert "playwright install" in str(exc_info.value).lower()
|
|
|
|
|
|
class TestRenderResumePdf:
|
|
"""render_resume_pdf — real headless-Chromium render of a self-contained page.
|
|
|
|
These require a launchable Chromium and skip cleanly when one is absent.
|
|
Teardown tears down the module-global browser so a leaked process can't
|
|
bleed into other tests.
|
|
"""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def _teardown_renderer(self):
|
|
# Yield first; close the shared browser after every test in this class.
|
|
yield
|
|
await close_pdf_renderer()
|
|
|
|
async def test_renders_valid_pdf_bytes(self):
|
|
"""THE proof rendering works: real PDF magic header + non-trivial size."""
|
|
pdf = await _render_or_skip(RESUME_PRINT_DATA_URL)
|
|
assert isinstance(pdf, (bytes, bytearray))
|
|
assert pdf[:4] == b"%PDF"
|
|
assert len(pdf) > 1000
|
|
|
|
async def test_renders_letter_size_with_custom_margins(self):
|
|
"""Format + margins flow through into a valid render (LETTER, custom mm)."""
|
|
pdf = await _render_or_skip(
|
|
RESUME_PRINT_DATA_URL,
|
|
page_size="LETTER",
|
|
margins={"top": 20, "right": 15, "bottom": 20, "left": 15},
|
|
)
|
|
assert pdf[:4] == b"%PDF"
|
|
assert len(pdf) > 1000
|
|
|
|
|
|
class TestRenderResumePdfErrors:
|
|
"""render_resume_pdf error mapping — connection failures become PDFRenderError."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def _teardown_renderer(self):
|
|
yield
|
|
await close_pdf_renderer()
|
|
|
|
async def test_connection_refused_raises_pdf_render_error(self):
|
|
"""A refused target must surface as PDFRenderError, not a raw Playwright
|
|
error — this is the 'cannot connect to frontend' incident path.
|
|
|
|
Skips only when Chromium itself can't launch (it still needs a browser
|
|
to *attempt* the connection); a genuine connection failure must raise.
|
|
"""
|
|
try:
|
|
with pytest.raises(PDFRenderError) as exc_info:
|
|
await render_resume_pdf(_refused_url())
|
|
except PlaywrightError as exc:
|
|
if "Executable doesn't exist" in str(exc):
|
|
pytest.skip(f"chromium unavailable: {exc}")
|
|
raise
|
|
# The browser launched but couldn't reach the frontend — but if the only
|
|
# failure was a missing executable surfaced as PDFRenderError, treat that
|
|
# as a skip rather than a false-positive pass.
|
|
message = str(exc_info.value).lower()
|
|
if "executable" in message:
|
|
pytest.skip(f"chromium unavailable: {exc_info.value}")
|
|
assert "cannot connect to frontend" in message
|