Spaces:
Sleeping
Sleeping
| """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") | |
| 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 | |