img2threejs / tests /test_viewer_runtime.py
Mike0021's picture
Fix deep-link gallery card race
a359c5a verified
Raw
History Blame Contribute Delete
6.77 kB
"""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