Spaces:
Running
Running
| """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 | |
| 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" | |
| 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" | |
| 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) | |
| 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-*")) | |
| 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 | |
| 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) | |
| 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() | |
| 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() | |
| 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 | |
| ) | |
| 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]) | |
| 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() | |
| 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() | |
| 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 | |
| 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() | |