From cd44a6c20c9e9692709d3c7649430a3fe32627ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Sun, 30 Aug 2026 10:38:30 +0200 Subject: [PATCH 01/15] feat(export): preserve layouts in HTML exports Resolve configured layouts once and serialize them through static and WebAssembly mount configs so file, HTTP, and Pyodide exports share the same fallback behavior. --- marimo/_ast/app.py | 15 --- marimo/_cli/development/commands.py | 4 +- marimo/_export/exporter.py | 2 + marimo/_export/file.py | 36 +++++- marimo/_export/requests.py | 3 + marimo/_pyodide/pyodide_session.py | 7 ++ marimo/_runtime/layout/layout.py | 47 ++++++-- marimo/_schemas/export.py | 24 +++- marimo/_server/api/endpoints/export.py | 6 + marimo/_server/templates/api.py | 5 + marimo/_templates.py | 14 ++- packages/openapi/api.yaml | 32 ++++-- packages/openapi/src/api.ts | 28 +++-- tests/_cli/test_cli_export.py | 30 ++++- tests/_export/test_exporter.py | 106 +++++++++++++++--- tests/_pyodide/test_pyodide_session.py | 11 +- tests/_runtime/layout/test_layout.py | 20 +++- tests/_server/api/endpoints/test_export.py | 42 +++++-- tests/_server/templates/snapshots/export1.txt | 3 +- tests/_server/templates/snapshots/export2.txt | 3 +- tests/_server/templates/snapshots/export3.txt | 3 +- tests/_server/templates/snapshots/export4.txt | 3 +- tests/_server/templates/snapshots/export5.txt | 3 +- tests/_server/templates/snapshots/export6.txt | 3 +- tests/_server/templates/test_templates_api.py | 9 +- tests/_server/templates/utils.py | 13 +++ tests/_server/test_templates_filename.py | 12 -- 27 files changed, 372 insertions(+), 112 deletions(-) diff --git a/marimo/_ast/app.py b/marimo/_ast/app.py index 5dacdfdb6e6..fbc6d3f74a0 100644 --- a/marimo/_ast/app.py +++ b/marimo/_ast/app.py @@ -1,7 +1,6 @@ # Copyright 2026 Marimo. All rights reserved. from __future__ import annotations -import base64 import inspect import os import sys @@ -14,7 +13,6 @@ Sequence, ) from dataclasses import dataclass -from pathlib import Path from textwrap import dedent from typing import ( TYPE_CHECKING, @@ -1008,19 +1006,6 @@ def set_execution_context( def update_config(self, updates: dict[str, Any]) -> _AppConfig: return self.config.update(updates) - def inline_layout_file(self) -> InternalApp: - if self.config.layout_file: - layout_path = Path(self.config.layout_file) - if self._app._filename: - # Resolve relative to the current working directory - layout_path = Path(self._app._filename).parent / layout_path - layout_file = layout_path.read_bytes() - data_uri = base64.b64encode(layout_file).decode() - self.update_config( - {"layout_file": f"data:application/json;base64,{data_uri}"} - ) - return self - def with_data( self, *, diff --git a/marimo/_cli/development/commands.py b/marimo/_cli/development/commands.py index 325e341aef7..eab1b62d07a 100644 --- a/marimo/_cli/development/commands.py +++ b/marimo/_cli/development/commands.py @@ -757,7 +757,6 @@ def preview(file_path: Path, port: int, host: str, headless: bool) -> None: from starlette.routing import Route from starlette.staticfiles import StaticFiles - from marimo._ast.app_config import _AppConfig from marimo._config.config import DEFAULT_CONFIG from marimo._server.tokens import SkewProtectionToken from marimo._templates import static_notebook_template @@ -834,13 +833,14 @@ def preview(file_path: Path, port: int, host: str, headless: bool) -> None: user_config=DEFAULT_CONFIG, config_overrides={}, server_token=SkewProtectionToken("preview"), - app_config=_AppConfig(), + app_config=file_manager.app.config, filepath=str(file_path), code=code, session_snapshot=session_snapshot, code_hash=hash_code(code), notebook_snapshot=notebook_snapshot, files={}, + layout=file_manager.read_layout_config(), model_notifications=session_view.get_model_notifications(), asset_url=asset_url, ) diff --git a/marimo/_export/exporter.py b/marimo/_export/exporter.py index b2c191b877a..cce708a0eeb 100644 --- a/marimo/_export/exporter.py +++ b/marimo/_export/exporter.py @@ -271,6 +271,7 @@ def export_as_html( session_snapshot=session_snapshot, notebook_snapshot=notebook_snapshot, files=virtual_files, + layout=request.layout, model_notifications=model_notifications, asset_url=request.options.asset_url, ) @@ -537,6 +538,7 @@ def export_as_wasm( code=request.code, asset_url=request.options.asset_url, show_code=request.options.show_code, + layout=request.layout, session_snapshot=request.session_snapshot, notebook_snapshot=request.notebook_snapshot, ) diff --git a/marimo/_export/file.py b/marimo/_export/file.py index 8cfe9e8293b..d2c03a43c9e 100644 --- a/marimo/_export/file.py +++ b/marimo/_export/file.py @@ -55,6 +55,11 @@ from marimo._messaging.types import KernelMessage from marimo._output.hypertext import patch_html_for_non_interactive_output from marimo._runtime.commands import AppMetadata +from marimo._runtime.layout.layout import ( + LayoutConfig, + layout_config_to_data_uri, + read_layout_config, +) from marimo._runtime.patches import extract_docstring_from_header from marimo._schemas.export_options import ( IPYNBExportOptions, @@ -79,6 +84,25 @@ from marimo._types.ids import CellId_t +def _resolve_and_inline_layout(app: InternalApp) -> LayoutConfig | None: + layout_file = app.config.layout_file + if layout_file is None: + return None + + directory = Path(app.filename).parent if app.filename else Path.cwd() + layout = read_layout_config(directory, layout_file) + app.update_config( + { + "layout_file": ( + layout_config_to_data_uri(layout) + if layout is not None + else None + ) + } + ) + return layout + + def _as_ir(path: MarimoPath) -> NotebookSerialization: if path.is_python(): py_contents = path.read_text(encoding="utf-8") @@ -192,8 +216,7 @@ async def export_wasm( did_error=True, ) app = InternalApp(_app) - # Inline the layout file, if it exists - app.inline_layout_file() + layout = _resolve_and_inline_layout(app) config = get_default_config_manager( current_path=request.path.absolute_name ) @@ -210,6 +233,7 @@ async def export_wasm( display_config=resolved["display"], code=code, options=request.options, + layout=layout, sharing_config=resolved.get("sharing"), ) ) @@ -322,9 +346,7 @@ async def export_html( request: HTMLFileExportRequest, ) -> ExportResult: file_manager = load_notebook(request.path.absolute_name) - - # Inline the layout file, if it exists - file_manager.app.inline_layout_file() + layout = _resolve_and_inline_layout(file_manager.app) if request.execution is None: from marimo._session.state.session_view import SessionView @@ -358,6 +380,7 @@ async def export_html( ), display_config=display_config, options=request.options, + layout=layout, sharing_config=( resolved.get("sharing") if request.execution is not None @@ -447,7 +470,7 @@ async def _export_wasm_with_execution( raise ValueError("Execution options are required.") file_manager = load_notebook(request.path.absolute_name) - file_manager.app.inline_layout_file() + layout = _resolve_and_inline_layout(file_manager.app) config = get_default_config_manager(current_path=file_manager.path) resolved = config.get_config() @@ -508,6 +531,7 @@ async def _export_wasm_with_execution( display_config=display_config, code=code, options=request.options, + layout=layout, session_snapshot=snapshot.session, notebook_snapshot=snapshot.notebook, sharing_config=resolved.get("sharing"), diff --git a/marimo/_export/requests.py b/marimo/_export/requests.py index 986e2c88a8b..f71a040e59a 100644 --- a/marimo/_export/requests.py +++ b/marimo/_export/requests.py @@ -13,6 +13,7 @@ from marimo._config.config import DisplayConfig, SharingConfig from marimo._export._status import PDFExportStatusCallback from marimo._runtime.commands import SerializedCLIArgs +from marimo._runtime.layout.layout import LayoutConfig from marimo._schemas.export_options import ( HTMLExportOptions, IPYNBExportOptions, @@ -77,6 +78,7 @@ class HTMLExportRequest: snapshot: NotebookExportSnapshot display_config: DisplayConfig options: HTMLExportOptions + layout: LayoutConfig | None = None sharing_config: SharingConfig | None = None @@ -94,6 +96,7 @@ class WASMExportRequest: display_config: DisplayConfig code: str options: WASMExportOptions + layout: LayoutConfig | None = None session_snapshot: NotebookSessionV1 | None = None notebook_snapshot: NotebookV1 | None = None sharing_config: SharingConfig | None = None diff --git a/marimo/_pyodide/pyodide_session.py b/marimo/_pyodide/pyodide_session.py index dec66984b94..982016e3797 100644 --- a/marimo/_pyodide/pyodide_session.py +++ b/marimo/_pyodide/pyodide_session.py @@ -44,6 +44,7 @@ ExportAsMarkdownRequest, ExportAsScriptRequest, ExportedFile, + to_html_export_layout, to_html_export_options, to_markdown_export_options, ) @@ -417,6 +418,12 @@ def export_html(self, request: str) -> str: ), display_config=self.session._initial_user_config["display"], options=to_html_export_options(parsed), + layout=( + to_html_export_layout( + parsed, + fallback=self.session.app_manager.read_layout_config, + ) + ), ) ) return self._dump( diff --git a/marimo/_runtime/layout/layout.py b/marimo/_runtime/layout/layout.py index 75714dba073..e61cca3c997 100644 --- a/marimo/_runtime/layout/layout.py +++ b/marimo/_runtime/layout/layout.py @@ -1,6 +1,7 @@ # Copyright 2026 Marimo. All rights reserved. from __future__ import annotations +import base64 import json import os from dataclasses import dataclass @@ -23,6 +24,37 @@ class LayoutConfig: data: dict[str, Any] +def layout_config_to_data_uri(config: LayoutConfig) -> str: + contents = json.dumps({"type": config.type, "data": config.data}) + encoded = base64.b64encode(contents.encode("utf-8")).decode("ascii") + return f"data:application/json;base64,{encoded}" + + +def _parse_layout_config( + contents: str | bytes, *, source: str +) -> LayoutConfig | None: + try: + value = json.loads(contents) + except (json.JSONDecodeError, UnicodeDecodeError, TypeError) as error: + LOGGER.warning("Failed to parse layout config %s: %s", source, error) + return None + + if not isinstance(value, dict): + LOGGER.warning("Layout config %s must be an object", source) + return None + + layout_type = value.get("type") + layout_data = value.get("data") + if not isinstance(layout_type, str) or not isinstance(layout_data, dict): + LOGGER.warning( + "Layout config %s must contain string `type` and object `data` fields", + source, + ) + return None + + return LayoutConfig(type=layout_type, data=layout_data) + + def save_layout_config( directory: str | Path, app_name: str, config: LayoutConfig ) -> str: @@ -63,14 +95,11 @@ def read_layout_config( # Handle data URI if filename.startswith("data:"): try: - # Decode base64 _mime, data = from_data_uri(filename) - # Parse as JSON - data_json = json.loads(data) - return LayoutConfig(type=data_json["type"], data=data_json["data"]) except Exception as e: LOGGER.warning("Failed to decode data URI: %s", e) return None + return _parse_layout_config(data, source="data URI") filepath = os.path.join(directory, filename) if not os.path.exists(filepath): @@ -79,6 +108,10 @@ def read_layout_config( if not filepath.endswith(".json"): LOGGER.warning("Layout file %s is not a JSON file", filepath) return None - with open(filepath, encoding="utf-8") as f: - data = json.load(f) - return LayoutConfig(type=data["type"], data=data["data"]) # type: ignore[call-overload] + try: + with open(filepath, encoding="utf-8") as f: + contents = f.read() + except (OSError, UnicodeError) as error: + LOGGER.warning("Failed to read layout config %s: %s", filepath, error) + return None + return _parse_layout_config(contents, source=filepath) diff --git a/marimo/_schemas/export.py b/marimo/_schemas/export.py index faa36e07741..1917a5793fa 100644 --- a/marimo/_schemas/export.py +++ b/marimo/_schemas/export.py @@ -1,12 +1,13 @@ # Copyright 2026 Marimo. All rights reserved. from __future__ import annotations -from typing import Literal +from typing import TYPE_CHECKING, Literal import msgspec from marimo._convert.markdown.flavor.base import MarkdownFlavorName from marimo._messaging.mimetypes import MimeBundleTuple +from marimo._runtime.layout.layout import LayoutConfig from marimo._schemas.export_options import ( ExportPDFPreset, ExportSetupRequirementName, @@ -19,12 +20,23 @@ ) from marimo._types.ids import CellId_t +if TYPE_CHECKING: + from collections.abc import Callable + class ExportAsHTMLRequest(msgspec.Struct, rename="camel"): + """Request a static HTML export. + + `layout` carries the current client layout. An omitted field reads the + saved layout file, `null` selects the vertical layout, and an object uses + that serialized layout for this export. + """ + download: bool files: list[str] include_code: bool asset_url: str | None = None + layout: LayoutConfig | None | msgspec.UnsetType = msgspec.UNSET def to_html_export_options( @@ -37,6 +49,16 @@ def to_html_export_options( ) +def to_html_export_layout( + request: ExportAsHTMLRequest, + *, + fallback: Callable[[], LayoutConfig | None], +) -> LayoutConfig | None: + if request.layout is msgspec.UNSET: + return fallback() + return request.layout + + class ExportAsScriptRequest(msgspec.Struct, rename="camel"): download: bool diff --git a/marimo/_server/api/endpoints/export.py b/marimo/_server/api/endpoints/export.py index 2f7917bb8a0..32bb1b2aee2 100644 --- a/marimo/_server/api/endpoints/export.py +++ b/marimo/_server/api/endpoints/export.py @@ -53,6 +53,7 @@ ExportFormatAvailability, InstallExportRequirementsRequest, UpdateCellOutputsRequest, + to_html_export_layout, to_html_export_options, to_ipynb_export_options, to_markdown_export_options, @@ -239,6 +240,10 @@ async def export_as_html( resolved_config = session.config_manager.get_config() app = session.app_file_manager.app + layout = to_html_export_layout( + body, + fallback=session.app_file_manager.read_layout_config, + ) html, filename = Exporter().export_as_html( HTMLExportRequest( filename=session.app_file_manager.filename, @@ -252,6 +257,7 @@ async def export_as_html( ), display_config=resolved_config["display"], options=to_html_export_options(body), + layout=layout, sharing_config=resolved_config.get("sharing"), ) ) diff --git a/marimo/_server/templates/api.py b/marimo/_server/templates/api.py index f39b462b7cc..b1a4bca66b5 100644 --- a/marimo/_server/templates/api.py +++ b/marimo/_server/templates/api.py @@ -14,6 +14,7 @@ from marimo._ast.app_config import _AppConfig from marimo._config.config import MarimoConfig, PartialMarimoConfig from marimo._convert.converters import MarimoConvert +from marimo._runtime.layout.layout import LayoutConfig from marimo._schemas.notebook import NotebookV1 from marimo._schemas.session import NotebookSessionV1 from marimo._server.tokens import SkewProtectionToken @@ -162,6 +163,7 @@ def render_static_notebook( session_snapshot: NotebookSessionV1, notebook_snapshot: NotebookV1 | None = None, files: dict[str, str] | None = None, + layout: dict[str, Any] | None = None, config: dict[str, Any] | None = None, app_config: dict[str, Any] | None = None, asset_url: str | None = None, @@ -178,6 +180,7 @@ def render_static_notebook( session_snapshot: Pre-computed outputs for all cells (required). notebook_snapshot: Notebook structure/metadata. files: Files to embed (key=path, value=base64 content). + layout: Serialized notebook layout with `type` and `data` fields. config: User configuration overrides. app_config: Notebook-specific configuration. asset_url: CDN URL for assets (default: jsDelivr). @@ -207,6 +210,7 @@ def render_static_notebook( user_config = _parse_config(config) config_overrides_obj = _parse_partial_config(config or {}) app_config_obj = _AppConfig.from_untrusted_dict(app_config) + layout_config = LayoutConfig(**layout) if layout is not None else None # Get HTML template html = _get_html_template() @@ -223,6 +227,7 @@ def render_static_notebook( session_snapshot=session_snapshot, notebook_snapshot=notebook_snapshot, files=files or {}, + layout=layout_config, asset_url=asset_url, ) diff --git a/marimo/_templates.py b/marimo/_templates.py index 1fbdce6d73e..39cbc2b5586 100644 --- a/marimo/_templates.py +++ b/marimo/_templates.py @@ -25,6 +25,7 @@ from marimo._version import __version__ if TYPE_CHECKING: + from marimo._runtime.layout.layout import LayoutConfig from marimo._server.api.endpoints.assets import LspWorkspace @@ -104,6 +105,7 @@ def _get_mount_config( session_snapshot: NotebookSessionV1 | None = None, notebook_snapshot: NotebookV1 | None = None, runtime_config: list[dict[str, Any]] | None = None, + layout: LayoutConfig | None = None, ) -> str: """ Return a JSON string with custom indentation and sorting. @@ -121,6 +123,11 @@ def _get_mount_config( "app_config": _del_none_or_empty(app_config.asdict()) if app_config else {}, + "layout": ( + {"type": layout.type, "data": layout.data} + if layout is not None + else None + ), "view": { "showAppCode": show_app_code, }, @@ -139,10 +146,11 @@ def _get_mount_config( "config": {user_config}, "configOverrides": {config_overrides}, "appConfig": {app_config}, + "layout": {layout}, "view": {view}, "notebook": {notebook}, "session": {session}, - "runtimeConfig": {runtime_config}, + "runtimeConfig": {runtime_config} }} """.format(**{k: json_script(v) for k, v in options.items()}).strip() @@ -397,6 +405,7 @@ def static_notebook_template( files: dict[str, str], model_notifications: list[ModelLifecycleNotification] | None = None, asset_url: str | None = None, + layout: LayoutConfig | None = None, ) -> str: if asset_url is None: version = str(__version__).replace(".dev", "-dev") @@ -423,6 +432,7 @@ def static_notebook_template( session_snapshot=session_snapshot, notebook_snapshot=notebook_snapshot, runtime_config=None, + layout=layout, ), ) @@ -508,6 +518,7 @@ def wasm_notebook_template( mode: Literal["edit", "run"], code: str, show_code: bool, + layout: LayoutConfig | None = None, asset_url: str | None = None, session_snapshot: NotebookSessionV1 | None = None, notebook_snapshot: NotebookV1 | None = None, @@ -546,6 +557,7 @@ def wasm_notebook_template( runtime_config=None, session_snapshot=session_snapshot, notebook_snapshot=notebook_snapshot, + layout=layout, ), ) diff --git a/packages/openapi/api.yaml b/packages/openapi/api.yaml index 8bb7beaa661..d080976e0fd 100644 --- a/packages/openapi/api.yaml +++ b/packages/openapi/api.yaml @@ -1594,6 +1594,18 @@ components: - label title: DetectedDataSourceOrigin type: object + DiagnosticsConfig: + description: "Configuration options for diagnostics.\n\n **Keys.**\n\n \ + \ - `enabled`: if `True`, diagnostics will be shown in the editor\n -\ + \ `sql_linter`: if `True`, SQL cells will have linting enabled" + properties: + enabled: + type: boolean + sql_linter: + type: boolean + required: [] + title: DiagnosticsConfig + type: object DialectHidesWhen: description: Hide this suggestion when a live SQL engine dialect contains a substring. @@ -1610,18 +1622,6 @@ components: - substrings title: DialectHidesWhen type: object - DiagnosticsConfig: - description: "Configuration options for diagnostics.\n\n **Keys.**\n\n \ - \ - `enabled`: if `True`, diagnostics will be shown in the editor\n -\ - \ `sql_linter`: if `True`, SQL cells will have linting enabled" - properties: - enabled: - type: boolean - sql_linter: - type: boolean - required: [] - title: DiagnosticsConfig - type: object DiscoverDataSourcesCommand: description: "Discover datasource connections from the live kernel environment\ \ and configuration.\n\n Attributes:\n request_id: Unique identifier\ @@ -1917,6 +1917,10 @@ components: title: ExecuteStaleCellsCommand type: object ExportAsHTMLRequest: + description: "Request a static HTML export.\n\n `layout` carries the current\ + \ client layout. An omitted field reads the\n saved layout file, `null`\ + \ selects the vertical layout, and an object uses\n that serialized layout\ + \ for this export." properties: assetUrl: anyOf: @@ -1931,6 +1935,10 @@ components: type: array includeCode: type: boolean + layout: + anyOf: + - $ref: '#/components/schemas/LayoutConfig' + - type: 'null' required: - download - files diff --git a/packages/openapi/src/api.ts b/packages/openapi/src/api.ts index 1d0b69177e6..41f4137cc84 100644 --- a/packages/openapi/src/api.ts +++ b/packages/openapi/src/api.ts @@ -4633,15 +4633,6 @@ export interface components { /** @enum {unknown} */ type: "configuration" | "environment"; }; - /** - * DialectHidesWhen - * @description Hide this suggestion when a live SQL engine dialect contains a substring. - */ - DialectHidesWhen: { - /** @enum {unknown} */ - kind: "dialect"; - substrings: string[]; - }; /** * DiagnosticsConfig * @description Configuration options for diagnostics. @@ -4655,6 +4646,15 @@ export interface components { enabled?: boolean; sql_linter?: boolean; }; + /** + * DialectHidesWhen + * @description Hide this suggestion when a live SQL engine dialect contains a substring. + */ + DialectHidesWhen: { + /** @enum {unknown} */ + kind: "dialect"; + substrings: string[]; + }; /** * DiscoverDataSourcesCommand * @description Discover datasource connections from the live kernel environment and configuration. @@ -4851,13 +4851,21 @@ export interface components { /** @enum {unknown} */ type: "execute-stale-cells"; }; - /** ExportAsHTMLRequest */ + /** + * ExportAsHTMLRequest + * @description Request a static HTML export. + * + * `layout` carries the current client layout. An omitted field reads the + * saved layout file, `null` selects the vertical layout, and an object uses + * that serialized layout for this export. + */ ExportAsHTMLRequest: { /** @default null */ assetUrl?: string | null; download: boolean; files: string[]; includeCode: boolean; + layout?: components["schemas"]["LayoutConfig"] | null; }; /** ExportAsIPYNBRequest */ ExportAsIPYNBRequest: { diff --git a/tests/_cli/test_cli_export.py b/tests/_cli/test_cli_export.py index 9dd70db979d..356e67aace6 100644 --- a/tests/_cli/test_cli_export.py +++ b/tests/_cli/test_cli_export.py @@ -28,7 +28,10 @@ from marimo._utils.paths import marimo_package_path from marimo._utils.platform import is_windows from marimo._utils.scripts import read_pyproject_from_script -from tests._server.templates.utils import normalize_index_html +from tests._server.templates.utils import ( + normalize_index_html, + parse_mount_config, +) from tests.mocks import ( _sanitize_version, delete_lines_with_files, @@ -153,11 +156,14 @@ async def _wait_for_file(file: str, timeout: float = 10.0) -> None: def _write_minimal_wasm_notebook( - file: Path, cell: str, metadata: str = "" + file: Path, + cell: str, + metadata: str = "", + app_args: str = "", ) -> None: file.write_text( f"{metadata}import marimo\n\n" - "app = marimo.App()\n\n" + f"app = marimo.App({app_args})\n\n" "@app.cell\n" "def __():\n" f"{cell}\n\n" @@ -221,11 +227,20 @@ def test_cli_export_html_no_code(temp_marimo_file: str) -> None: assert '' in html @staticmethod - def test_cli_export_html_wasm(temp_marimo_file: str) -> None: - out_dir = Path(temp_marimo_file).parent / "out" + def test_cli_export_html_wasm(tmp_path: Path) -> None: + notebook = tmp_path / "notebook.py" + _write_minimal_wasm_notebook( + notebook, + ' "hello"\n return\n', + app_args='layout_file="layouts/notebook.slides.json"', + ) + layout_file = tmp_path / "layouts" / "notebook.slides.json" + layout_file.parent.mkdir() + layout_file.write_text('{"type": "slides", "data": {}}') + out_dir = tmp_path / "out" p = _run_export( "html-wasm", - temp_marimo_file, + str(notebook), "--mode", "edit", "--output", @@ -239,6 +254,9 @@ def test_cli_export_html_wasm(temp_marimo_file: str) -> None: assert " Path: + notebook = tmp_path / "test.py" + source = ( + (FIXTURES_DIR / "with_layout.py") + .read_text() + .replace("layouts/layout.json", layout_file) + ) + notebook.write_text(source) + if contents is not None: + path = tmp_path / layout_file + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents) + return notebook + + def _pdf_export_request( *, app: InternalApp, @@ -421,25 +442,78 @@ async def test_export_wasm(mode: str, expected_mode_in_content: str) -> None: assert expected_mode_in_content in content -async def test_export_html_with_layout(tmp_path: Path) -> None: - """Test HTML export with layout file.""" - test_file = tmp_path / "test.py" - test_file.write_text((FIXTURES_DIR / "with_layout.py").read_text()) - - # Create the layout file - layout_file = tmp_path / "layouts" / "layout.json" - layout_file.parent.mkdir(parents=True, exist_ok=True) - layout_file.write_text('{"type": "slides", "data": {}}') +@pytest.mark.parametrize( + "export_kind", + ["static", "wasm", "wasm-executed"], +) +@pytest.mark.parametrize( + ("layout_file", "contents", "expected"), + [ + ( + "layouts/layout.json", + '{"type": "slides", "data": {}}', + {"type": "slides", "data": {}}, + ), + ("layouts/layout.json", "{", None), + ("layouts/missing.json", None, None), + ( + "data:application/json;base64," + + base64.b64encode( + b'{"type":"slides","data":{"deck":{"transition":"fade"}}}' + ).decode("ascii"), + None, + { + "type": "slides", + "data": {"deck": {"transition": "fade"}}, + }, + ), + ( + "layouts/layout.txt", + '{"type": "slides", "data": {}}', + None, + ), + ], + ids=["valid", "malformed", "missing", "data-uri", "non-json"], +) +async def test_file_export_resolves_layout_once( + tmp_path: Path, + export_kind: str, + layout_file: str, + contents: str | None, + expected: dict[str, Any] | None, +) -> None: + test_file = _write_layout_notebook( + tmp_path, + layout_file=layout_file, + contents=contents, + ) - result = await export_wasm( - WASMFileExportRequest( - path=MarimoPath(test_file), - options=WASMExportOptions(mode="edit", show_code=True), + if export_kind == "static": + result = await export_html( + HTMLFileExportRequest( + path=MarimoPath(test_file), + options=HTMLExportOptions(files=(), include_code=True), + ) ) - ) + else: + result = await export_wasm( + WASMFileExportRequest( + path=MarimoPath(test_file), + options=WASMExportOptions(mode="run", show_code=True), + execution=( + NotebookExecutionOptions(cli_args={}, argv=[]) + if export_kind == "wasm-executed" + else None + ), + ) + ) + assert result.did_error is False - assert "layout.json" not in result.text - assert "data:application/json" in result.text + assert parse_mount_config(result.text)["layout"] == expected + if not layout_file.startswith("data:"): + assert layout_file not in result.text + if expected is not None: + assert "data:application/json;base64," in result.text # HTML export diff --git a/tests/_pyodide/test_pyodide_session.py b/tests/_pyodide/test_pyodide_session.py index f11a50b427a..3acf9409b44 100644 --- a/tests/_pyodide/test_pyodide_session.py +++ b/tests/_pyodide/test_pyodide_session.py @@ -54,6 +54,7 @@ from marimo._session.model import SessionMode from marimo._session.notebook import AppFileManager from marimo._types.ids import CellId_t, UIElementId +from tests._server.templates.utils import parse_mount_config if TYPE_CHECKING: from collections.abc import AsyncGenerator, Generator @@ -1074,6 +1075,10 @@ def test_pyodide_bridge_export_html( "download": False, "files": [], "includeCode": True, + "layout": { + "type": "slides", + "data": {"deck": {"transition": "fade"}}, + }, } ) @@ -1082,8 +1087,10 @@ def test_pyodide_bridge_export_html( assert exported_file["filename"] == "test.html" assert exported_file["mediaType"] == "text/html; charset=utf-8" - # HTML should contain marimo-related content - assert len(exported_file["contents"]) > 0 + assert parse_mount_config(exported_file["contents"])["layout"] == { + "type": "slides", + "data": {"deck": {"transition": "fade"}}, + } @pytest.mark.parametrize( diff --git a/tests/_runtime/layout/test_layout.py b/tests/_runtime/layout/test_layout.py index e55ad41a896..17c7d3f538d 100644 --- a/tests/_runtime/layout/test_layout.py +++ b/tests/_runtime/layout/test_layout.py @@ -79,11 +79,19 @@ def test_read_layout_config_invalid_data_uri(): assert config is None -def test_read_layout_config_invalid_json(temp_dir: str): - # Create an invalid JSON file - path = Path(temp_dir) / "layouts" / "invalid.grid.json" +@pytest.mark.parametrize( + "contents", + ["invalid json", b"\xff\xfe", "[]", '{"type": 1, "data": []}'], + ids=["json", "encoding", "root", "fields"], +) +def test_read_layout_config_invalid_contents( + temp_dir: str, contents: str | bytes +) -> None: + path = Path(temp_dir) / "layouts" / "invalid.slides.json" path.parent.mkdir(exist_ok=True) - path.write_text("invalid json") + if isinstance(contents, bytes): + path.write_bytes(contents) + else: + path.write_text(contents) - with pytest.raises(json.JSONDecodeError): - read_layout_config(temp_dir, "layouts/invalid.grid.json") + assert read_layout_config(temp_dir, "layouts/invalid.slides.json") is None diff --git a/tests/_server/api/endpoints/test_export.py b/tests/_server/api/endpoints/test_export.py index 4c3bc83a0c3..69cfce64815 100644 --- a/tests/_server/api/endpoints/test_export.py +++ b/tests/_server/api/endpoints/test_export.py @@ -22,6 +22,7 @@ from marimo._messaging.cell_output import CellChannel, CellOutput from marimo._messaging.notification import CellNotification from marimo._output.utils import uri_encode_component +from marimo._runtime.layout.layout import LayoutConfig from marimo._schemas.export import ( ExportAvailabilityResponse, ExportFormatAvailability, @@ -42,6 +43,7 @@ with_read_session, with_session, ) +from tests._server.templates.utils import parse_mount_config from tests.mocks import EDGE_CASE_FILENAMES, snapshotter if TYPE_CHECKING: @@ -442,15 +444,31 @@ def test_export_html(client: TestClient) -> None: session = get_session_manager(client).get_session(SESSION_ID) assert session session.app_file_manager.filename = "test.py" - response = client.post( - "/api/export/html", - headers={**HEADERS, "Origin": "localhost"}, - json={ - "download": False, - "files": [], - "includeCode": True, - }, - ) + with patch.object( + session.app_file_manager, + "read_layout_config", + return_value=LayoutConfig(type="slides", data={"deck": {}}), + ): + response = client.post( + "/api/export/html", + headers={**HEADERS, "Origin": "localhost"}, + json={ + "download": False, + "files": [], + "includeCode": True, + }, + ) + current_layout_response = client.post( + "/api/export/html", + headers=HEADERS, + json={ + "download": False, + "files": [], + "includeCode": True, + "layout": None, + }, + ) + body = response.text assert '' not in body assert CODE in body @@ -461,6 +479,12 @@ def test_export_html(client: TestClient) -> None: assert response.headers["content-type"] == "text/html; charset=utf-8" exposed_headers = response.headers["access-control-expose-headers"].lower() assert "content-disposition" in exposed_headers + assert parse_mount_config(response.text)["layout"] == { + "type": "slides", + "data": {"deck": {}}, + } + assert current_layout_response.status_code == 200 + assert parse_mount_config(current_layout_response.text)["layout"] is None @with_session(SESSION_ID) diff --git a/tests/_server/templates/snapshots/export1.txt b/tests/_server/templates/snapshots/export1.txt index a3aafbfe7c2..4ce31a29949 100644 --- a/tests/_server/templates/snapshots/export1.txt +++ b/tests/_server/templates/snapshots/export1.txt @@ -103,10 +103,11 @@ "config": {"ai": {"allow_provider_config": true, "custom_providers": {}, "enabled": true, "models": {"custom_models": [], "displayed_models": []}}, "completion": {"activate_on_typing": true, "auto_close_pairs": true, "copilot": false, "signature_hint_on_typing": false}, "diagnostics": {"sql_linter": true}, "display": {"cell_output": "below", "code_editor_font_size": 14, "code_lens": true, "dataframes": "rich", "default_table_max_columns": 50, "default_table_page_size": 10, "default_width": "medium", "reference_highlighting": true, "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "language_servers": {"pylsp": {"enable_flake8": false, "enable_mypy": true, "enable_pydocstyle": false, "enable_pyflakes": false, "enable_pylint": false, "enable_ruff": true, "enabled": false}}, "mcp": {"mcpServers": {}, "presets": []}, "package_management": {"manager": "uv"}, "runtime": {"auto_instantiate": false, "auto_reload": "off", "default_csv_encoding": "utf-8", "default_sql_output": "auto", "on_cell_change": "autorun", "output_max_bytes": 8000000, "reactive_tests": true, "show_tracebacks": false, "std_stream_max_bytes": 1000000, "watcher_on_save": "lazy"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}, "snippets": {"custom_paths": [], "include_default_snippets": true}}, "configOverrides": {"formatting": {"line_length": 100}}, "appConfig": {"sql_output": "auto", "width": "compact"}, + "layout": null, "view": {"showAppCode": true}, "notebook": {"cells": [{"code": "print('Hello, Cell 1')", "code_hash": "6eea77c855e5bfd73f3d851f474ed789", "config": {}, "id": "cell1", "name": "Cell 1"}, {"code": "print('Hello, Cell 2')", "code_hash": "0dce0226aecd5db222f6a3aa3223214b", "config": {}, "id": "cell2", "name": "Cell 2"}], "metadata": {"marimo_version": "0.0.0"}, "version": "1"}, "session": {"cells": [{"code_hash": "abc123", "console": [{"name": "stdout", "text": "Hello, Cell 1", "type": "stream"}, {"name": "stderr", "text": "Error in Cell 1", "type": "stream"}], "id": "cell1", "outputs": [{"data": {"text/plain": "Hello, Cell 1"}, "type": "data"}]}, {"code_hash": "def456", "console": [], "id": "cell2", "outputs": []}], "metadata": {"marimo_version": "0.0.0"}, "version": "1"}, - "runtimeConfig": null, + "runtimeConfig": null }), writable: false, configurable: false, diff --git a/tests/_server/templates/snapshots/export2.txt b/tests/_server/templates/snapshots/export2.txt index a4c3041430c..250a51c0a37 100644 --- a/tests/_server/templates/snapshots/export2.txt +++ b/tests/_server/templates/snapshots/export2.txt @@ -103,10 +103,11 @@ "config": {"ai": {"allow_provider_config": true, "custom_providers": {}, "enabled": true, "models": {"custom_models": [], "displayed_models": []}}, "completion": {"activate_on_typing": true, "auto_close_pairs": true, "copilot": false, "signature_hint_on_typing": false}, "diagnostics": {"sql_linter": true}, "display": {"cell_output": "below", "code_editor_font_size": 14, "code_lens": true, "dataframes": "rich", "default_table_max_columns": 50, "default_table_page_size": 10, "default_width": "medium", "reference_highlighting": true, "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "language_servers": {"pylsp": {"enable_flake8": false, "enable_mypy": true, "enable_pydocstyle": false, "enable_pyflakes": false, "enable_pylint": false, "enable_ruff": true, "enabled": false}}, "mcp": {"mcpServers": {}, "presets": []}, "package_management": {"manager": "uv"}, "runtime": {"auto_instantiate": false, "auto_reload": "off", "default_csv_encoding": "utf-8", "default_sql_output": "auto", "on_cell_change": "autorun", "output_max_bytes": 8000000, "reactive_tests": true, "show_tracebacks": false, "std_stream_max_bytes": 1000000, "watcher_on_save": "lazy"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}, "snippets": {"custom_paths": [], "include_default_snippets": true}}, "configOverrides": {"formatting": {"line_length": 100}}, "appConfig": {"sql_output": "auto", "width": "compact"}, + "layout": null, "view": {"showAppCode": true}, "notebook": {"cells": [{"code": "print('Hello, Cell 1')", "code_hash": "6eea77c855e5bfd73f3d851f474ed789", "config": {}, "id": "cell1", "name": "Cell 1"}, {"code": "print('Hello, Cell 2')", "code_hash": "0dce0226aecd5db222f6a3aa3223214b", "config": {}, "id": "cell2", "name": "Cell 2"}], "metadata": {"marimo_version": "0.0.0"}, "version": "1"}, "session": {"cells": [{"code_hash": "abc123", "console": [{"name": "stdout", "text": "Hello, Cell 1", "type": "stream"}, {"name": "stderr", "text": "Error in Cell 1", "type": "stream"}], "id": "cell1", "outputs": [{"data": {"text/plain": "Hello, Cell 1"}, "type": "data"}]}, {"code_hash": "def456", "console": [], "id": "cell2", "outputs": []}], "metadata": {"marimo_version": "0.0.0"}, "version": "1"}, - "runtimeConfig": null, + "runtimeConfig": null }), writable: false, configurable: false, diff --git a/tests/_server/templates/snapshots/export3.txt b/tests/_server/templates/snapshots/export3.txt index 91f2a5f5379..85c60fc7387 100644 --- a/tests/_server/templates/snapshots/export3.txt +++ b/tests/_server/templates/snapshots/export3.txt @@ -103,10 +103,11 @@ "config": {"ai": {"allow_provider_config": true, "custom_providers": {}, "enabled": true, "models": {"custom_models": [], "displayed_models": []}}, "completion": {"activate_on_typing": true, "auto_close_pairs": true, "copilot": false, "signature_hint_on_typing": false}, "diagnostics": {"sql_linter": true}, "display": {"cell_output": "below", "code_editor_font_size": 14, "code_lens": true, "dataframes": "rich", "default_table_max_columns": 50, "default_table_page_size": 10, "default_width": "medium", "reference_highlighting": true, "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "language_servers": {"pylsp": {"enable_flake8": false, "enable_mypy": true, "enable_pydocstyle": false, "enable_pyflakes": false, "enable_pylint": false, "enable_ruff": true, "enabled": false}}, "mcp": {"mcpServers": {}, "presets": []}, "package_management": {"manager": "uv"}, "runtime": {"auto_instantiate": false, "auto_reload": "off", "default_csv_encoding": "utf-8", "default_sql_output": "auto", "on_cell_change": "autorun", "output_max_bytes": 8000000, "reactive_tests": true, "show_tracebacks": false, "std_stream_max_bytes": 1000000, "watcher_on_save": "lazy"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}, "snippets": {"custom_paths": [], "include_default_snippets": true}}, "configOverrides": {"formatting": {"line_length": 100}}, "appConfig": {"sql_output": "auto", "width": "compact"}, + "layout": null, "view": {"showAppCode": true}, "notebook": {"cells": [], "metadata": {"marimo_version": "0.0.0"}, "version": "1"}, "session": {"cells": [], "metadata": {"marimo_version": "0.0.0"}, "version": "1"}, - "runtimeConfig": null, + "runtimeConfig": null }), writable: false, configurable: false, diff --git a/tests/_server/templates/snapshots/export4.txt b/tests/_server/templates/snapshots/export4.txt index d0e36a64b90..57ea4bd3205 100644 --- a/tests/_server/templates/snapshots/export4.txt +++ b/tests/_server/templates/snapshots/export4.txt @@ -103,10 +103,11 @@ "config": {"ai": {"allow_provider_config": true, "custom_providers": {}, "enabled": true, "models": {"custom_models": [], "displayed_models": []}}, "completion": {"activate_on_typing": true, "auto_close_pairs": true, "copilot": false, "signature_hint_on_typing": false}, "diagnostics": {"sql_linter": true}, "display": {"cell_output": "below", "code_editor_font_size": 14, "code_lens": true, "dataframes": "rich", "default_table_max_columns": 50, "default_table_page_size": 10, "default_width": "medium", "reference_highlighting": true, "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "language_servers": {"pylsp": {"enable_flake8": false, "enable_mypy": true, "enable_pydocstyle": false, "enable_pyflakes": false, "enable_pylint": false, "enable_ruff": true, "enabled": false}}, "mcp": {"mcpServers": {}, "presets": []}, "package_management": {"manager": "uv"}, "runtime": {"auto_instantiate": false, "auto_reload": "off", "default_csv_encoding": "utf-8", "default_sql_output": "auto", "on_cell_change": "autorun", "output_max_bytes": 8000000, "reactive_tests": true, "show_tracebacks": false, "std_stream_max_bytes": 1000000, "watcher_on_save": "lazy"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}, "snippets": {"custom_paths": [], "include_default_snippets": true}}, "configOverrides": {"formatting": {"line_length": 100}}, "appConfig": {"css_file": "custom.css", "sql_output": "auto", "width": "compact"}, + "layout": null, "view": {"showAppCode": true}, "notebook": {"cells": [], "metadata": {"marimo_version": "0.0.0"}, "version": "1"}, "session": {"cells": [], "metadata": {"marimo_version": "0.0.0"}, "version": "1"}, - "runtimeConfig": null, + "runtimeConfig": null }), writable: false, configurable: false, diff --git a/tests/_server/templates/snapshots/export5.txt b/tests/_server/templates/snapshots/export5.txt index af55fed9684..8639bed716d 100644 --- a/tests/_server/templates/snapshots/export5.txt +++ b/tests/_server/templates/snapshots/export5.txt @@ -115,10 +115,11 @@ "config": {"ai": {"allow_provider_config": true, "custom_providers": {}, "enabled": true, "models": {"custom_models": [], "displayed_models": []}}, "completion": {"activate_on_typing": true, "auto_close_pairs": true, "copilot": false, "signature_hint_on_typing": false}, "diagnostics": {"sql_linter": true}, "display": {"cell_output": "below", "code_editor_font_size": 14, "code_lens": true, "dataframes": "rich", "default_table_max_columns": 50, "default_table_page_size": 10, "default_width": "medium", "reference_highlighting": true, "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "language_servers": {"pylsp": {"enable_flake8": false, "enable_mypy": true, "enable_pydocstyle": false, "enable_pyflakes": false, "enable_pylint": false, "enable_ruff": true, "enabled": false}}, "mcp": {"mcpServers": {}, "presets": []}, "package_management": {"manager": "uv"}, "runtime": {"auto_instantiate": false, "auto_reload": "off", "default_csv_encoding": "utf-8", "default_sql_output": "auto", "on_cell_change": "autorun", "output_max_bytes": 8000000, "reactive_tests": true, "show_tracebacks": false, "std_stream_max_bytes": 1000000, "watcher_on_save": "lazy"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}, "snippets": {"custom_paths": [], "include_default_snippets": true}}, "configOverrides": {"formatting": {"line_length": 100}}, "appConfig": {"app_title": "My App", "html_head_file": "head.html", "sql_output": "auto", "width": "compact"}, + "layout": null, "view": {"showAppCode": true}, "notebook": {"cells": [], "metadata": {"marimo_version": "0.0.0"}, "version": "1"}, "session": {"cells": [], "metadata": {"marimo_version": "0.0.0"}, "version": "1"}, - "runtimeConfig": null, + "runtimeConfig": null }), writable: false, configurable: false, diff --git a/tests/_server/templates/snapshots/export6.txt b/tests/_server/templates/snapshots/export6.txt index d95c2a75731..80113423e19 100644 --- a/tests/_server/templates/snapshots/export6.txt +++ b/tests/_server/templates/snapshots/export6.txt @@ -104,10 +104,11 @@ "config": {"ai": {"allow_provider_config": true, "custom_providers": {}, "enabled": true, "models": {"custom_models": [], "displayed_models": []}}, "completion": {"activate_on_typing": true, "auto_close_pairs": true, "copilot": false, "signature_hint_on_typing": false}, "diagnostics": {"sql_linter": true}, "display": {"cell_output": "below", "code_editor_font_size": 14, "code_lens": true, "custom_css": ["custom1.css", "custom2.css"], "dataframes": "rich", "default_table_max_columns": 50, "default_table_page_size": 10, "default_width": "medium", "reference_highlighting": true, "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "language_servers": {"pylsp": {"enable_flake8": false, "enable_mypy": true, "enable_pydocstyle": false, "enable_pyflakes": false, "enable_pylint": false, "enable_ruff": true, "enabled": false}}, "mcp": {"mcpServers": {}, "presets": []}, "package_management": {"manager": "uv"}, "runtime": {"auto_instantiate": false, "auto_reload": "off", "default_csv_encoding": "utf-8", "default_sql_output": "auto", "on_cell_change": "autorun", "output_max_bytes": 8000000, "reactive_tests": true, "show_tracebacks": false, "std_stream_max_bytes": 1000000, "watcher_on_save": "lazy"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}, "snippets": {"custom_paths": [], "include_default_snippets": true}}, "configOverrides": {"formatting": {"line_length": 100}}, "appConfig": {"sql_output": "auto", "width": "compact"}, + "layout": null, "view": {"showAppCode": true}, "notebook": {"cells": [], "metadata": {"marimo_version": "0.0.0"}, "version": "1"}, "session": {"cells": [], "metadata": {"marimo_version": "0.0.0"}, "version": "1"}, - "runtimeConfig": null, + "runtimeConfig": null }), writable: false, configurable: false, diff --git a/tests/_server/templates/test_templates_api.py b/tests/_server/templates/test_templates_api.py index 47d1401fa4b..165f1a2181c 100644 --- a/tests/_server/templates/test_templates_api.py +++ b/tests/_server/templates/test_templates_api.py @@ -14,6 +14,7 @@ render_notebook, render_static_notebook, ) +from tests._server.templates.utils import parse_mount_config class TestRenderNotebook(unittest.TestCase): @@ -116,9 +117,15 @@ def __(mo): def test_render_static_notebook(self) -> None: html = render_static_notebook( - code=self.code, session_snapshot=self.session_snapshot + code=self.code, + session_snapshot=self.session_snapshot, + layout={"type": "slides", "data": {"deck": {}}}, ) assert " None: html = render_static_notebook( diff --git a/tests/_server/templates/utils.py b/tests/_server/templates/utils.py index 4592d78774a..c1c72df978c 100644 --- a/tests/_server/templates/utils.py +++ b/tests/_server/templates/utils.py @@ -1,7 +1,20 @@ from __future__ import annotations +import json import os import re +from typing import Any, cast + + +def parse_mount_config(html: str) -> dict[str, Any]: + property_start = html.index( + 'Object.defineProperty(window, "__MARIMO_MOUNT_CONFIG__"' + ) + start = html.index("value: Object.freeze({", property_start) + len( + "value: Object.freeze(" + ) + config, _end = json.JSONDecoder().raw_decode(html[start:]) + return cast(dict[str, Any], config) def remove_hash_from_href(url: str) -> str: diff --git a/tests/_server/test_templates_filename.py b/tests/_server/test_templates_filename.py index 10bf51f8ab8..23f400b8739 100644 --- a/tests/_server/test_templates_filename.py +++ b/tests/_server/test_templates_filename.py @@ -76,10 +76,6 @@ def test_get_mount_config_filename_handling( config_overrides=config_overrides, app_config=app_config, ) - # Remove the last ',' - last_comma_index = result.rfind(",") - result = result[:last_comma_index] + result[last_comma_index + 1 :] - # Should be valid JSON config_data = json.loads(result) @@ -291,10 +287,6 @@ def test_filename_injection_prevention( app_config=app_config, ) - # Remove trailing comma for JSON parsing - last_comma_index = result.rfind(",") - result = result[:last_comma_index] + result[last_comma_index + 1 :] - # Must not contain unescaped script tags (< and > should be escaped) assert "