Spaces:
Sleeping
Sleeping
File size: 6,768 Bytes
37e3d5a a359c5a 37e3d5a 8d7dfcd 37e3d5a a359c5a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 | """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
|