Files
2026-07-13 13:12:00 +08:00

205 lines
8.0 KiB
Python

"""E2E: read-only .ipynb notebook preview and source toggle in the FileViewer.
Counterpart to ``test_markdown_rich_rendering.py`` for notebooks: a seeded
``.ipynb`` must open as a rendered notebook (markdown cells as HTML, code
cells with execution counts, mime-bundle outputs) rather than raw JSON, with
the security posture pinned — ``text/html`` outputs are never injected into
the DOM (a hostile ``<script>`` output must not execute or mount) and fall
back to ``text/plain`` with a note; only raster images render, as inert
data-URIs. The source toggle must flip to the raw-JSON view and back.
Seeded via the filesystem PUT endpoint (no agent run).
"""
from __future__ import annotations
import json
import re
from collections.abc import Iterator
import httpx
import pytest
from playwright.sync_api import Page, expect
# ---------------------------------------------------------------------------
# Test constants
# ---------------------------------------------------------------------------
_NOTEBOOK_FILE_PATH = "analysis.ipynb"
# 1x1 red PNG, base64 — a real decodable image for the data-URI output.
_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
"AAAADUlEQVR4nGP4z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
)
# nbformat-4 fixture covering the render matrix: a markdown cell, a code cell
# with a stream output, a code cell whose execute_result carries BOTH a
# hostile text/html payload and a text/plain fallback, a display_data image,
# and an error output with ANSI escape codes in the traceback.
_NOTEBOOK_CONTENT = json.dumps(
{
"nbformat": 4,
"nbformat_minor": 5,
"metadata": {"language_info": {"name": "python"}},
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": ["# Quarterly Analysis\n", "\n", "Exploring **Q3 data**.\n"],
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"source": ["print('rows: 1523')\n"],
"outputs": [{"output_type": "stream", "name": "stdout", "text": ["rows: 1523\n"]}],
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"source": ["df.describe()\n"],
"outputs": [
{
"output_type": "execute_result",
"execution_count": 2,
"metadata": {},
"data": {
"text/html": [
'<script id="nb-xss">window.__nb_xss = 1</script>'
"<table><tr><td>injected</td></tr></table>"
],
"text/plain": [" amount\ncount 1523.0"],
},
}
],
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {},
"source": ["df.plot()\n"],
"outputs": [
{
"output_type": "display_data",
"metadata": {},
"data": {
"image/png": _PNG_B64,
"text/plain": ["<Figure size 640x480 with 1 Axes>"],
},
}
],
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"source": ["1/0\n"],
"outputs": [
{
"output_type": "error",
"ename": "ZeroDivisionError",
"evalue": "division by zero",
"traceback": ["\x1b[0;31mZeroDivisionError\x1b[0m: division by zero"],
}
],
},
],
}
)
# ---------------------------------------------------------------------------
# Fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def seeded_notebook_session(
seeded_session: tuple[str, str],
) -> Iterator[tuple[str, str, str]]:
"""Seed the notebook file and yield (base_url, session_id, path).
:param seeded_session: Runner-bound (base_url, session_id) pair.
:returns: ``(base_url, session_id, file_path)`` for the test body.
"""
base_url, session_id = seeded_session
file_url = (
f"{base_url}/v1/sessions/{session_id}"
f"/resources/environments/default/filesystem/{_NOTEBOOK_FILE_PATH}"
)
resp = httpx.put(
file_url,
json={"content": _NOTEBOOK_CONTENT, "encoding": "utf-8"},
timeout=10.0,
)
resp.raise_for_status()
yield (base_url, session_id, _NOTEBOOK_FILE_PATH)
# ---------------------------------------------------------------------------
# Test
# ---------------------------------------------------------------------------
def test_notebook_renders_preview_and_source_toggle(
page: Page,
seeded_notebook_session: tuple[str, str, str],
) -> None:
"""Notebook cells render as a preview; source toggle shows the raw JSON."""
base_url, session_id, _file_path = seeded_notebook_session
page.goto(f"{base_url}/c/{session_id}?view=explore")
file_button = page.get_by_role(
"button", name=re.compile(rf"^{re.escape(_NOTEBOOK_FILE_PATH)}\b")
)
expect(file_button).to_be_visible(timeout=30_000)
file_button.click()
# Match the visible FileViewer instance directly (mobile + desktop both
# mount with the same test id; order is not guaranteed).
file_viewer = page.locator('[data-testid="file-viewer"]:visible')
expect(file_viewer).to_be_visible()
expect(
page.get_by_role("button", name=f"Close {_NOTEBOOK_FILE_PATH}", exact=True).first
).to_be_visible()
# Preview is the default for notebooks: the markdown cell renders as
# semantic HTML (a heading, not "# Quarterly Analysis" verbatim).
heading = file_viewer.locator("h1").filter(has_text="Quarterly Analysis")
expect(heading).to_be_visible(timeout=10_000)
expect(heading).not_to_contain_text("#")
expect(file_viewer.locator("strong").filter(has_text="Q3 data")).to_be_visible()
# Code cells carry their source and execution counts; stream output shows.
expect(file_viewer.get_by_text("In [1]:", exact=False)).to_be_visible()
expect(file_viewer.get_by_text("rows: 1523").last).to_be_visible()
# Security: the hostile text/html output is never injected — no script or
# table mounts, no side effect runs, and the text/plain fallback shows
# with the suppression note.
expect(file_viewer.locator("#nb-xss")).to_have_count(0)
expect(file_viewer.locator("table")).to_have_count(0)
assert page.evaluate("() => window.__nb_xss") is None
expect(file_viewer.get_by_text("Rich HTML output hidden", exact=False)).to_be_visible()
expect(file_viewer.get_by_text("count 1523.0", exact=False)).to_be_visible()
# The image/png output renders as an inert data-URI image.
image = file_viewer.locator('img[src^="data:image/png;base64,"]')
expect(image).to_be_visible()
# The error traceback renders with its ANSI escapes consumed, not verbatim.
expect(file_viewer.get_by_text("division by zero", exact=False).first).to_be_visible()
expect(file_viewer.get_by_text("[0;31m", exact=False)).to_have_count(0)
# Source toggle: the raw notebook JSON becomes visible.
file_viewer.get_by_role("button", name="View source").click()
expect(file_viewer.get_by_text('"nbformat"', exact=False).first).to_be_visible(timeout=10_000)
expect(file_viewer.get_by_text('"cell_type"', exact=False).first).to_be_visible()
# Toggle back to the rendered preview.
file_viewer.get_by_role("button", name="View preview").click()
expect(file_viewer.locator("h1").filter(has_text="Quarterly Analysis")).to_be_visible(
timeout=10_000
)