Spaces:
Running
Running
File size: 13,528 Bytes
39ff632 bf1fb5f 39ff632 bf1fb5f 37e3d5a 39ff632 37e3d5a bf1fb5f 39ff632 37e3d5a 39ff632 37e3d5a 39ff632 bf1fb5f 39ff632 37e3d5a 39ff632 37e3d5a 39ff632 | 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 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 | """End-to-end pipeline tests (app/pipeline.py) with a mocked LLM.
The forge scripts, validator, generator and esbuild bundling all run for
real; only the vision-LLM call is substituted.
"""
from __future__ import annotations
import asyncio
import dataclasses
import json
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from app import pipeline as pipeline_module
from app.gallery import GalleryError, GalleryStore
from app.pipeline import JobRegistry, run_job
from tests.conftest import MockLLM
REPO_ROOT = Path(__file__).resolve().parents[1]
FIXTURES = Path(__file__).resolve().parent / "fixtures"
ESBUILD = REPO_ROOT / "node_modules" / ".bin" / "esbuild"
needs_bundler = pytest.mark.skipif(
not ESBUILD.exists(),
reason="esbuild not installed (run npm ci)")
def shallow_spec_text(tmp_path: Path) -> str:
"""A structurally valid but strict-quality-failing starter spec."""
assessment = tmp_path / "a.json"
spec = tmp_path / "s.json"
forge = REPO_ROOT / "forge"
subprocess.run(
[sys.executable, str(forge / "stage2_spec" / "new_pre_spec_assessment.py"),
"Widget", "--complexity", "simple", "--out", str(assessment)],
check=True, capture_output=True)
subprocess.run(
[sys.executable, str(forge / "stage2_spec" / "new_sculpt_spec.py"),
"Widget", "--assessment", str(assessment), "--out", str(spec)],
check=True, capture_output=True)
return spec.read_text(encoding="utf-8")
async def run(tmp_path, make_settings, replies, raw_upload, **env):
from app.pipeline import JobRegistry as JR
registry = JR(tmp_path / "runs")
job = registry.create()
settings = make_settings(runs_dir=str(tmp_path / "runs"), **env)
await run_job(job, raw_upload=raw_upload, object_hint=None,
settings=settings, llm=MockLLM(replies))
return job
@pytest.mark.asyncio
@needs_bundler
async def test_happy_path_full_artifacts(tmp_path, make_settings,
ref_png_bytes, canned_spec_text):
job = await run(tmp_path, make_settings, [canned_spec_text], ref_png_bytes)
assert job.status == "done", job.error
result = job.result
assert result["targetName"] == "Mug"
assert result["generatedPass"] == "hosted-preview"
assert result["generationMode"] == "hosted-unreviewed-preview"
assert result["reviewStatus"] == "unreviewed"
assert result["completedPasses"] == []
assert result["components"] >= 2
assert result["honesty"] # honesty notes always present
assert result["shareRequested"] is True
assert result["shared"] is True
assert result["galleryItem"]["id"] == job.id
assert (tmp_path / "gallery" / job.id / "item.json").is_file()
assert not list(job.dir.glob(".gallery-publish-*"))
assert not list((tmp_path / "gallery").glob(".publishing-*"))
factory = (job.dir / "factory.ts").read_text(encoding="utf-8")
assert "export function createMugModel" in factory
assert "sculptRuntime" in factory
bundle = job.dir / "model.bundle.js"
assert bundle.exists() and bundle.stat().st_size > 100_000 # three included
standalone = (job.dir / "standalone.html").read_text(encoding="utf-8")
assert "await import(url)" in standalone
assert "mod.mountViewer" in standalone
assert "img2threejs" in standalone # attribution header
spec = json.loads((job.dir / "spec.json").read_text(encoding="utf-8"))
assert spec["reviewHistory"] == []
assert spec["sculptPipeline"]["passGateMode"] == "locked-sequential"
compile_spec = json.loads(
(job.dir / "compile-spec.json").read_text(encoding="utf-8"))
assert compile_spec["reviewHistory"] == []
assert compile_spec["sculptPipeline"]["passGateMode"] == "hosted-unreviewed-preview"
@pytest.mark.asyncio
@needs_bundler
async def test_gallery_outage_preserves_completed_result(
tmp_path, make_settings, ref_png_bytes, canned_spec_text
):
class UnavailableGallery(GalleryStore):
def publish(self, **kwargs):
raise GalleryError("storage temporarily unavailable")
registry = JobRegistry(tmp_path / "runs")
job = registry.create()
settings = make_settings(runs_dir=str(tmp_path / "runs"))
await run_job(
job,
raw_upload=ref_png_bytes,
object_hint=None,
settings=settings,
llm=MockLLM([canned_spec_text]),
gallery_store=UnavailableGallery(tmp_path / "gallery"),
)
assert job.status == "done", job.error
assert job.result["shareRequested"] is True
assert job.result["shared"] is False
assert job.result["galleryItem"] is None
assert "model is ready" in job.result["publicationWarning"]
assert (job.dir / "model.bundle.js").is_file()
publishing = [
event for event in job.events if event["stage"] == "publishing"
]
assert publishing[-1]["status"] == "done"
assert publishing[-1]["data"]["warning"] is True
stages = [e["stage"] for e in job.events]
assert stages[0] == "intake"
assert "spec-authoring" in stages and "generation" in stages
assert "publishing" in stages
assert job.events[-1]["stage"] == "done" and job.events[-1]["status"] == "done"
@pytest.mark.asyncio
async def test_opaque_stage_wait_emits_elapsed_feedback(tmp_path, monkeypatch):
registry = JobRegistry(tmp_path / "runs")
job = registry.create()
monkeypatch.setattr(pipeline_module, "STAGE_PROGRESS_INTERVAL_S", 0.01)
result = await pipeline_module._await_with_feedback(
job,
stage="generation",
awaitable=asyncio.sleep(0.035, result="ready"),
message="Still generating ({elapsed}s in this stage, {total}s total).",
)
assert result == "ready"
progress = [
event for event in job.events
if event["stage"] == "generation" and event["status"] == "progress"
]
assert len(progress) >= 2
assert all("elapsedSeconds" in event["data"] for event in progress)
assert all("%" not in event["message"] for event in progress)
@pytest.mark.asyncio
@needs_bundler
async def test_gallery_publication_timeout_preserves_result_and_kills_worker(
tmp_path,
make_settings,
ref_png_bytes,
canned_spec_text,
monkeypatch,
):
def hanging_worker(*_args):
return [sys.executable, "-c", "import time; time.sleep(60)"]
monkeypatch.setattr(
pipeline_module, "_gallery_worker_command", hanging_worker
)
settings = dataclasses.replace(
make_settings(runs_dir=str(tmp_path / "runs")),
gallery_publish_timeout_s=0.05,
)
registry = JobRegistry(tmp_path / "runs")
job = registry.create()
await run_job(
job,
raw_upload=ref_png_bytes,
object_hint=None,
settings=settings,
llm=MockLLM([canned_spec_text]),
)
assert job.status == "done", job.error
assert job.result["shared"] is False
assert "publicationWarning" in job.result
assert not (tmp_path / "gallery" / job.id).exists()
assert not list((tmp_path / "gallery").glob(".publishing-*"))
assert not list(job.dir.glob(".gallery-publish-*"))
@pytest.mark.asyncio
async def test_esbuild_is_killed_and_reaped_on_outer_cancellation(
tmp_path, make_settings, monkeypatch
):
executable = tmp_path / "fake-esbuild"
executable.write_text("# test executable\n", encoding="utf-8")
executable.chmod(0o755)
monkeypatch.setattr(pipeline_module, "REPO_ROOT", tmp_path)
settings = dataclasses.replace(
make_settings(runs_dir=str(tmp_path / "runs")),
esbuild_entry="fake-esbuild",
)
job = JobRegistry(tmp_path / "runs").create()
entry = job.dir / "entry.js"
entry.write_text("export {};\n", encoding="utf-8")
started = asyncio.Event()
class FakeProcess:
returncode = None
killed = False
waited = False
async def communicate(self):
started.set()
await asyncio.Future()
def kill(self):
self.killed = True
self.returncode = -9
async def wait(self):
self.waited = True
return self.returncode
process = FakeProcess()
async def fake_subprocess(*_args, **_kwargs):
return process
monkeypatch.setattr(
pipeline_module.asyncio,
"create_subprocess_exec",
fake_subprocess,
)
task = asyncio.create_task(
pipeline_module._run_esbuild(
job, entry, job.dir / "model.bundle.js", settings
)
)
await started.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert process.killed is True
assert process.waited is True
@pytest.mark.asyncio
@needs_bundler
async def test_validator_feedback_repair_round(tmp_path, make_settings,
ref_png_bytes, canned_spec_text):
shallow = shallow_spec_text(tmp_path)
job = await run(tmp_path, make_settings, [shallow, canned_spec_text], ref_png_bytes)
assert job.status == "done", job.error
mock_calls = 2 # first spec rejected, repair round accepted
assert len(job.events) > 0
# The repair prompt must carry the validator's actual error strings.
# (mock recorded every message list)
@pytest.mark.asyncio
async def test_persistent_validation_failure_is_honest(tmp_path, make_settings,
ref_png_bytes):
shallow = shallow_spec_text(tmp_path)
job = await run(tmp_path, make_settings, [shallow], ref_png_bytes,
SPEC_REPAIR_ROUNDS=2)
assert job.status == "error"
assert job.error["code"] == "spec_validation_failed"
assert job.error["detail"]["errors"] # validator output surfaced
assert not (job.dir / "factory.ts").exists() # nothing fabricated
assert not (job.dir / "model.bundle.js").exists()
@pytest.mark.asyncio
async def test_llm_garbage_json_is_honest(tmp_path, make_settings, ref_png_bytes):
job = await run(tmp_path, make_settings, ["definitely not json"], ref_png_bytes)
assert job.status == "error"
assert job.error["code"] == "spec_validation_failed"
assert "parseable JSON" in job.error["detail"]["errors"][0]
assert not (job.dir / "factory.ts").exists()
@pytest.mark.asyncio
@needs_bundler
async def test_bad_json_enters_repair_round(tmp_path, make_settings,
ref_png_bytes, canned_spec_text):
job = await run(
tmp_path, make_settings,
["definitely not json", canned_spec_text], ref_png_bytes,
SPEC_REPAIR_ROUNDS=1,
)
assert job.status == "done", job.error
assert any(
event["stage"] == "spec-authoring" and event["status"] == "progress"
for event in job.events
)
@pytest.mark.asyncio
@needs_bundler
async def test_hosted_primitive_error_enters_repair_round(
tmp_path, make_settings, ref_png_bytes, canned_spec, canned_spec_text,
):
unsupported = json.loads(json.dumps(canned_spec))
unsupported["componentTree"][0]["primitive"] = "metaball"
job = await run(
tmp_path, make_settings,
[json.dumps(unsupported), canned_spec_text], ref_png_bytes,
SPEC_REPAIR_ROUNDS=1,
)
assert job.status == "done", job.error
progress = [
event for event in job.events
if event["stage"] == "spec-authoring" and event["status"] == "progress"
]
assert progress
assert "unsupported primitive" in json.dumps(progress[0])
@pytest.mark.asyncio
async def test_unsuitable_image_verdict_is_honest(tmp_path, make_settings,
ref_png_bytes, canned_spec):
rejected = dict(canned_spec)
rejected["suitability"] = "reject"
job = await run(tmp_path, make_settings, [json.dumps(rejected)], ref_png_bytes)
assert job.status == "error"
assert job.error["code"] == "unsuitable_image"
assert not (job.dir / "factory.ts").exists()
@pytest.mark.asyncio
async def test_bad_image_bytes_rejected_at_intake(tmp_path, make_settings):
job = await run(tmp_path, make_settings, ["unused"], b"<html>nope</html>")
assert job.status == "error"
assert job.error["stage"] == "intake"
assert job.error["code"] in {"not_an_image", "svg_rejected"}
gallery = tmp_path / "gallery"
assert not (gallery / job.id).exists()
@pytest.mark.asyncio
@needs_bundler
async def test_fenced_llm_reply_is_repaired(tmp_path, make_settings,
ref_png_bytes, canned_spec_text):
fenced = f"Here is the spec:\n```json\n{canned_spec_text}\n```"
job = await run(tmp_path, make_settings, [fenced], ref_png_bytes)
assert job.status == "done", job.error
@pytest.mark.asyncio
@needs_bundler
async def test_concurrent_jobs_isolated(tmp_path, make_settings,
ref_png_bytes, canned_spec_text):
import asyncio
registry = JobRegistry(tmp_path / "runs")
settings = make_settings(runs_dir=str(tmp_path / "runs"))
async def one():
job = registry.create()
await run_job(job, raw_upload=ref_png_bytes, object_hint=None,
settings=settings, llm=MockLLM([canned_spec_text]))
return job
jobs = await asyncio.gather(one(), one(), one())
assert {j.status for j in jobs} == {"done"}
assert len({j.id for j in jobs}) == 3
for job in jobs:
assert (job.dir / "factory.ts").exists()
|