"""Focused checks for the sandboxed generated-model viewer runtime. The three.js option helpers and screenshot error paths are exercised in Node; the small iframe protocol is checked statically so this suite does not need a browser server or weaken the opaque-origin sandbox. """ from __future__ import annotations import shutil import subprocess from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[1] CORE = ROOT / "app" / "static" / "viewer-core.js" SHELL = ROOT / "app" / "static" / "viewer.html" INDEX = ROOT / "app" / "static" / "index.html" APP = ROOT / "app" / "static" / "app.js" NODE = shutil.which("node") @pytest.mark.skipif(not NODE, reason="node is not installed") def test_viewer_options_and_capture_errors_are_node_testable(): script = f""" import assert from 'node:assert/strict'; import {{ ViewerCaptureError, applyShadowsOption, applyWireframeOption, captureRendererPng, }} from {CORE.as_uri()!r}; const meshA = {{ material: {{ wireframe: false, needsUpdate: false }}, }}; const shared = {{ wireframe: false, needsUpdate: false }}; const meshB = {{ material: [shared, shared, {{ opacity: 1 }}] }}; const root = {{ traverse(callback) {{ for (const node of [this, meshA, meshB]) callback(node); }}, }}; let applied = applyWireframeOption(root, true); assert.deepEqual(applied, {{ enabled: true, materials: 2 }}); assert.equal(meshA.material.wireframe, true); assert.equal(meshA.material.needsUpdate, true); assert.equal(shared.wireframe, true); assert.throws(() => applyWireframeOption(root, 'yes'), /must be a boolean/); applied = applyWireframeOption(root, false); assert.equal(applied.enabled, false); assert.equal(meshA.material.wireframe, false); assert.equal(shared.wireframe, false); const renderer = {{ shadowMap: {{ enabled: true, needsUpdate: false }}, render() {{}}, domElement: {{ toBlob(callback, type) {{ assert.equal(type, 'image/png'); callback({{ bytes: 1 }}); }}, }}, }}; const ground = {{ visible: true }}; assert.equal(applyShadowsOption(renderer, ground, false), false); assert.equal(renderer.shadowMap.enabled, false); assert.equal(renderer.shadowMap.needsUpdate, true); assert.equal(ground.visible, false); assert.equal(applyShadowsOption(renderer, ground, true), true); assert.equal(ground.visible, true); globalThis.FileReader = class {{ readAsDataURL() {{ this.result = 'data:image/png;base64,AA=='; queueMicrotask(() => this.onload()); }} }}; assert.equal( await captureRendererPng(renderer, {{}}, {{}}), 'data:image/png;base64,AA==', ); async function expectCaptureCode(promise, expected) {{ try {{ await promise; assert.fail(`expected ${{expected}}`); }} catch (error) {{ assert.ok(error instanceof ViewerCaptureError); assert.equal(error.code, expected); }} }} await expectCaptureCode(captureRendererPng({{ render() {{ throw new Error('GPU reset'); }}, domElement: renderer.domElement, }}, {{}}, {{}}), 'capture-render-failed'); await expectCaptureCode(captureRendererPng({{ render() {{}}, domElement: {{ toBlob(callback) {{ callback(null); }} }}, }}, {{}}, {{}}), 'capture-encode-failed'); globalThis.FileReader = class {{ readAsDataURL() {{ this.error = new Error('read failed'); queueMicrotask(() => this.onerror()); }} }}; await expectCaptureCode(captureRendererPng(renderer, {{}}, {{}}), 'capture-read-failed'); """ result = subprocess.run( [NODE, "--input-type=module", "--eval", script], cwd=ROOT, capture_output=True, text=True, timeout=30, ) assert result.returncode == 0, f"{result.stdout}\n{result.stderr}" def test_viewer_shell_protocol_and_opaque_origin_security(): shell = SHELL.read_text(encoding="utf-8") index = INDEX.read_text(encoding="utf-8") # The parent keeps the generated bundle in an opaque-origin frame and the # shell accepts commands from that one WindowProxy only. assert 'sandbox="allow-scripts"' in index assert 'sandbox="allow-scripts allow-same-origin"' not in index assert "if (event.source !== parent) return;" in shell assert "parent.postMessage(message, '*')" in shell assert "default-src 'none'" in shell # Author-level display rules can override the browser's default handling # of the hidden attribute. The ready viewer must never retain its internal # loading label over the rendered model. assert "#status[hidden] { display: none !important; }" in shell for command in ( "set-option", "reset-camera", "capture", "dispose", ): assert f"data.type === '{command}'" in shell for acknowledgement in ( "option-applied", "option-error", "camera-reset", "capture-error", "disposed", ): assert f"type: '{acknowledgement}'" in shell # Every init starts by releasing the prior session before evaluating the # replacement bundle. Explicit dispose also invalidates an in-flight init. boot = shell[shell.index("async function boot"):shell.index("function setOption")] assert boot.index("disposeSession();") < boot.index("await import(url)") dispose_branch = shell[shell.index("data.type === 'dispose'"):] assert "++bootVersion;" in dispose_branch assert "disposeSession();" in dispose_branch # Capture failures are request-correlated and separate from fatal init # errors, allowing the parent to keep a healthy preview interactive. assert "code: error && error.code ? error.code : 'capture-failed'" in shell assert "code: 'viewer-not-ready'" in shell assert "operation: 'init'" in shell def test_viewer_session_exposes_all_preview_controls(): core = CORE.read_text(encoding="utf-8") returned_session = core[core.index(" return {\n capture,"):] for method in ( "capture,", "dispose,", "resetCamera,", "setShadows,", "setWireframe,", ): assert method in returned_session assert "controls.saveState();" in core assert "controls.reset();" in core assert "if (disposed) return;" in core def test_gallery_detail_and_index_race_still_renders_the_card(): app = APP.read_text(encoding="utf-8") # A deep-link detail fetch and the gallery listing fetch run concurrently. # The detail may populate galleryState.items first, so DOM presence—not Map # presence—must decide whether the listing still needs to render its card. assert "article.dataset.galleryId = item.id;" in app assert "card.dataset.galleryId === item.id" in app listing = app[app.index("async function loadGallery"):app.index("function openDialog")] assert "galleryState.items.has(item.id)" not in listing