import asyncio import hashlib import inspect import json import subprocess from pathlib import Path import pytest from apps.web import pipeline_backend as backend from apps.web.vue3 import main as api from gfa_core.mas.stage_capabilities import get_stage_capability from gfa_core.mas.tools.stage_identity import StageIdentity, get_stage_status_for_identity from gfa_core.project_paths import get_conda_env_bin, get_conda_env_rscript, get_configured_path GENOTYPE = "genotype:" + "a" * 64 PHENOTYPE = "phenotype:" + "b" * 64 MODEL = "gwas-model:" + "c" * 64 ANALYSIS = "gwas-analysis:" + "d" * 64 PLINK = str(get_conda_env_bin("ASAW") / "plink") EMMAX = "/home/xzq/Auto_GWAS/gwas_work/emmax-intel64" OFFICIAL_EMMAX = get_configured_path("GWAS_EMMAX_BIN", "emmax") RSCRIPT = str(get_conda_env_rscript("ASAW")) def _sha(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def _artifact(workspace: Path, path: Path, role: str) -> dict: return { "role": role, "artifact": role, "path": str(path.relative_to(workspace)), "full_path": str(path.resolve()), "size": path.stat().st_size, "sha256": _sha(path), } def _producer_manifests(workspace: Path, prefix: str = "cohort") -> tuple[dict, dict]: final = workspace / "final" final.mkdir(parents=True, exist_ok=True) vcf = final / f"{prefix}.gwas_fmt.sorted.vcf.gz" trait = final / f"{prefix}.gwas_trait.trait.txt" vcf.write_bytes(b"vcf") trait.write_text("\theading_days\nS1\t1\nS2\t2\n", encoding="utf-8") fmt = { "manifest_version": "1.0", "run_id": "fmt-run", "stage": "gwas_fmt", "prefix": prefix, "status": "success", "genotype_scope_id": GENOTYPE, "artifacts": {"sorted_vcf": _artifact(workspace, vcf, "sorted_vcf")}, } trait_manifest = { "manifest_version": "1.0", "run_id": "trait-run", "stage": "gwas_trait", "prefix": prefix, "status": "success", "phenotype_scope_id": PHENOTYPE, "analysis_scope_id": "gwas-analysis:" + "e" * 64, "artifacts": {"trait_table": _artifact(workspace, trait, "trait_table")}, } fmt_path = final / f"{prefix}.gwas_fmt.manifest.json" trait_path = final / f"{prefix}.gwas_trait.identity.manifest.json" fmt_path.write_text(json.dumps(fmt), encoding="utf-8") trait_path.write_text(json.dumps(trait_manifest), encoding="utf-8") return { "path": fmt_path, "artifact": vcf, "manifest": fmt, }, {"path": trait_path, "artifact": trait, "manifest": trait_manifest} def _emmax_manifest(workspace: Path, prefix: str = "cohort", roles=None) -> Path: final = workspace / "final" final.mkdir(parents=True, exist_ok=True) roles = roles or ("emmax_full", "emmax_pvalues", "report", "environment") artifacts = {} for role in roles: suffix = ".md" if role == "report" else ".txt" if role == "environment" else ".tsv" path = final / f"{prefix}.gwas_emmax.{role}{suffix}" path.write_text(f"{role}\n", encoding="utf-8") artifacts[role] = _artifact(workspace, path, role) manifest = { "manifest_version": "1.0", "stage": "gwas_emmax", "prefix": prefix, "status": "success", "genotype_scope_id": GENOTYPE, "phenotype_scope_id": PHENOTYPE, "model_scope_id": MODEL, "analysis_scope_id": ANALYSIS, "elapsed_sec": 12.34, "artifacts": artifacts, } path = final / f"{prefix}.gwas_emmax.identity.manifest.json" path.write_text(json.dumps(manifest), encoding="utf-8") return path def _scopes() -> dict: return {"genotypeScopeId": GENOTYPE, "phenotypeScopeId": PHENOTYPE} def test_emmax_unset_default_is_resolved_server_side_across_entry_points(monkeypatch, tmp_path): workspace = tmp_path / "workspace" / "job" _producer_manifests(workspace) monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace") request = api.ExecuteRequest(stage="gwas_emmax") preflight = api._api_semantic_preflight("job", "gwas_emmax", "cohort", _scopes()) assert request.gwasEmmax == "" assert preflight["materialized_params"]["gwasEmmax"] == OFFICIAL_EMMAX assert inspect.signature(backend.run_stage).parameters["gwas_emmax"].default == OFFICIAL_EMMAX assert "default=GWAS_EMMAX_BIN" in inspect.getsource( __import__("gfa_core.mas.tools.stage_tools", fromlist=["StageToolExecutor"]).StageToolExecutor.execute ) def test_emmax_semantic_preflight_materializes_two_exact_producer_manifests(monkeypatch, tmp_path): workspace = tmp_path / "workspace" / "job" fmt, trait = _producer_manifests(workspace) monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace") result = api._api_semantic_preflight("job", "gwas_emmax", "cohort", _scopes()) assert result["valid"] is True assert result["materialized_params"] == { "gwasSortedVcf": str(fmt["artifact"]), "gwasTraitFile": str(trait["artifact"]), "genotypeScopeId": GENOTYPE, "phenotypeScopeId": PHENOTYPE, "gwasPlink": PLINK, "gwasEmmax": OFFICIAL_EMMAX, "gwasRscript": RSCRIPT, "gwasRequestedPcs": 3, } assert result["decisions"]["sorted_vcf"]["source_manifest"] == str(fmt["path"]) assert result["decisions"]["trait_table"]["source_manifest"] == str(trait["path"]) def test_emmax_semantic_preflight_materializes_scopes_from_unique_producer_manifests( monkeypatch, tmp_path ): workspace = tmp_path / "workspace" / "job" fmt, trait = _producer_manifests(workspace) monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace") result = api._api_semantic_preflight("job", "gwas_emmax", "cohort", {}) assert result["valid"] is True assert result["materialized_params"] == { "gwasSortedVcf": str(fmt["artifact"]), "gwasTraitFile": str(trait["artifact"]), "genotypeScopeId": GENOTYPE, "phenotypeScopeId": PHENOTYPE, "gwasPlink": PLINK, "gwasEmmax": OFFICIAL_EMMAX, "gwasRscript": RSCRIPT, "gwasRequestedPcs": 3, } @pytest.mark.parametrize("requested,valid", [(0, True), (-1, False), (True, False)]) def test_emmax_preflight_requested_pcs_matches_cli_nonnegative_contract( monkeypatch, tmp_path, requested, valid, ): _producer_manifests(tmp_path / "workspace" / "job") monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace") result = api._api_semantic_preflight( "job", "gwas_emmax", "cohort", {**_scopes(), "gwasRequestedPcs": requested}, ) assert result["valid"] is valid if valid: assert result["materialized_params"]["gwasRequestedPcs"] == requested @pytest.mark.parametrize("case", [ "missing_scope", "wrong_scope", "failed", "bad_hash", "bad_size", "missing_manifest", "caller_vcf", "caller_trait", "bad_version", "missing_run_id", "bad_trait_analysis", ]) def test_emmax_semantic_preflight_fails_closed(monkeypatch, tmp_path, case): workspace = tmp_path / "workspace" / "job" fmt, trait = _producer_manifests(workspace) params = _scopes() target = fmt if case in {"failed", "bad_hash", "bad_size", "bad_version", "missing_run_id"} else trait manifest = target["manifest"] if case == "missing_scope": params["phenotypeScopeId"] = "" elif case == "wrong_scope": params["phenotypeScopeId"] = "phenotype:" + "9" * 64 elif case == "failed": manifest["status"] = "failed" elif case == "bad_hash": next(iter(manifest["artifacts"].values()))["sha256"] = "0" * 64 elif case == "bad_size": next(iter(manifest["artifacts"].values()))["size"] += 1 elif case == "missing_manifest": trait["path"].unlink() elif case == "caller_vcf": params["gwasSortedVcf"] = str(trait["artifact"]) elif case == "caller_trait": params["gwasTraitFile"] = str(fmt["artifact"]) elif case == "bad_version": manifest["manifest_version"] = "0.9" elif case == "missing_run_id": manifest.pop("run_id") elif case == "bad_trait_analysis": manifest["analysis_scope_id"] = "" if case in {"failed", "bad_hash", "bad_size", "bad_version", "missing_run_id", "bad_trait_analysis"}: target["path"].write_text(json.dumps(manifest), encoding="utf-8") monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace") result = api._api_semantic_preflight("job", "gwas_emmax", "cohort", params) assert result["valid"] is False assert result["issues"][0]["code"] == "invalid_gwas_emmax_upstream" def test_emmax_command_is_only_canonical_runner_and_preserves_explicit_values(tmp_path): command = backend._gwas_emmax_cli_command( workspace=tmp_path, job_id="job", prefix="cohort", sorted_vcf=tmp_path / "in.vcf.gz", trait=tmp_path / "trait.txt", vcf_manifest=tmp_path / "fmt.json", trait_manifest=tmp_path / "trait.json", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, plink=PLINK, emmax=EMMAX, rscript=RSCRIPT, requested_pcs=3, threads=6, log_file=tmp_path / "logs/emmax.log", ) assert command[:3] == [backend.sys.executable, "-m", "apps.cli.run_gwas_emmax"] expected = { "--vcf": str(tmp_path / "in.vcf.gz"), "--trait": str(tmp_path / "trait.txt"), "--vcf-manifest": str(tmp_path / "fmt.json"), "--trait-manifest": str(tmp_path / "trait.json"), "--genotype-scope-id": GENOTYPE, "--phenotype-scope-id": PHENOTYPE, "--plink": PLINK, "--emmax": EMMAX, "--rscript": RSCRIPT, "--requested-pcs": "3", "--threads": "6", "--log-file": str(tmp_path / "logs/emmax.log"), } for flag, value in expected.items(): assert command.count(flag) == 1 assert command[command.index(flag) + 1] == value assert not any(token.endswith(".sh") or token.endswith(".R") for token in command) def test_execute_request_and_frontend_dag_forward_emmax_contract(tmp_path): request = api.ExecuteRequest( stage="gwas_emmax", gwasPlink=PLINK, gwasEmmax=EMMAX, gwasRscript=RSCRIPT, gwasRequestedPcs=2, **_scopes(), ) assert (request.gwasPlink, request.gwasEmmax, request.gwasRequestedPcs) == (PLINK, EMMAX, 2) frontend = (Path(__file__).resolve().parents[2] / "apps/web/vue3/index.html").read_text(encoding="utf-8") start = frontend.index("const applyGwasExecutionParams =") execute = frontend.index("const executeSingleStageWithParams =", start) end = frontend.index("// 执行单个阶段(内部函数)", execute) stage_params = {**_scopes(), "gwasPlink": PLINK, "gwasEmmax": EMMAX, "gwasRscript": RSCRIPT, "gwasRequestedPcs": 2} script = tmp_path / "emmax-request.cjs" script.write_text( "let captured=null;const batchExecuteParams={value:{plan:{params:{}}}};" "const batchExecuteStages={value:[{id:'gwas_emmax',params:" + json.dumps(stage_params) + "}]};" "const confirmParams=new Proxy({cpu:6,comboPanels:[]},{get:(o,k)=>o[k]});" "const sessionId={value:'s'};const jobInfo={job_id:'job'};const getStageId=s=>s.id;" "const getStageAction=s=>s.action;const usesRnaseqSampleParallel=()=>false;" "const stageCpuForExecution=()=>6;const confirmCpuForStage=()=>6;" "const executionPrefix=()=> 'cohort';const applyGeneFamilyContextToRequest=()=>{};" "const applyRnaseqExecutionParams=()=>{};const applyWgsExecutionParams=()=>{};const submitExecuteRequestWithAutoAnnotation=async b=>{captured=b;return {success:true,async:false};};" "const createStructuredExecuteError=x=>new Error(JSON.stringify(x));const runInteractiveSubfamilyConfirm=async()=>({});" "const syncIdentifyExecutionSpecies=()=>{};const addAgentMessage=()=>{};const getStageLabel=x=>x;" "const activePipelineTaskIds={value:[]};const pollTaskUntilComplete=async()=>({});const console={log:()=>{}};" + frontend[start:execute] + frontend[execute:end] + "executeSingleStageWithParams('gwas_emmax').then(()=>process.stdout.write(JSON.stringify(captured))).catch(e=>{process.stderr.write(String(e));process.exit(1);});", encoding="utf-8", ) completed = subprocess.run(["node", str(script)], text=True, capture_output=True, check=False) assert completed.returncode == 0, completed.stderr body = json.loads(completed.stdout) for key, value in stage_params.items(): assert body[key] == value def _run_frontend_emmax_handoff(tmp_path: Path, producer_results: list[tuple[str, dict]]) -> dict: frontend = (Path(__file__).resolve().parents[2] / "apps/web/vue3/index.html").read_text(encoding="utf-8") handoff_start = frontend.index("const handoffGwasEmmaxProducerIdentity =") apply_start = frontend.index("const applyGwasExecutionParams =", handoff_start) execute_start = frontend.index("const executeSingleStageWithParams =", apply_start) stages = [ {"id": "gwas_fmt", "params": {}}, {"id": "gwas_trait", "params": {}}, {"id": "gwas_emmax", "params": {}}, ] script = tmp_path / "emmax-handoff.cjs" script.write_text( "const batchExecuteStages={value:" + json.dumps(stages) + "};" "const batchExecuteParams={value:{plan:{params:{}}}};" "const stageExecutionStatus={value:{gwas_fmt:'running',gwas_trait:'pending',gwas_emmax:'pending'}};" "const confirmParams={};const getStageId=s=>s.id;const executionPrefix=()=> 'cohort';" + frontend[handoff_start:apply_start] + frontend[apply_start:execute_start] + "const producerResults=" + json.dumps(producer_results) + ";" + "for(const [stage,result] of producerResults) handoffGwasEmmaxProducerIdentity(stage,result);" + "const body={};const emmax=batchExecuteStages.value.find(s=>s.id==='gwas_emmax');" + "applyGwasExecutionParams(body,'gwas_emmax',emmax.params);" + "process.stdout.write(JSON.stringify({body,params:emmax.params}));", encoding="utf-8", ) completed = subprocess.run(["node", str(script)], text=True, capture_output=True, check=False) assert completed.returncode == 0, completed.stderr return json.loads(completed.stdout) def test_frontend_runtime_handoff_feeds_exact_producer_scopes_to_emmax_preflight( monkeypatch, tmp_path, ): _producer_manifests(tmp_path / "workspace/job") monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace") producer_results = [ ["gwas_fmt", {"status": "success", "result": { "stage_identity": {"stage": "gwas_fmt", "prefix": "cohort", "genotype_scope_id": GENOTYPE}, "stage_status": {"stage": "gwas_fmt", "completed": True, "prefix": "cohort", "genotype_scope_id": GENOTYPE}, }}], ["gwas_trait", {"status": "success", "result": { "stage_identity": {"stage": "gwas_trait", "prefix": "cohort", "phenotype_scope_id": PHENOTYPE}, "stage_status": {"stage": "gwas_trait", "completed": True, "prefix": "cohort", "phenotype_scope_id": PHENOTYPE}, }}], ] captured = _run_frontend_emmax_handoff(tmp_path, producer_results) assert captured["params"] == { "genotypeScopeId": GENOTYPE, "phenotypeScopeId": PHENOTYPE, } preflight = api._api_semantic_preflight( "job", "gwas_emmax", "cohort", captured["body"], ) assert preflight["valid"] is True assert preflight["materialized_params"]["genotypeScopeId"] == GENOTYPE assert preflight["materialized_params"]["phenotypeScopeId"] == PHENOTYPE frontend = (Path(__file__).resolve().parents[2] / "apps/web/vue3/index.html").read_text(encoding="utf-8") verified = frontend.index("if (!manifestCompleted)") handoff_call = frontend.index("handoffGwasEmmaxProducerIdentity(stageAction, stageResult);", verified) completed_mark = frontend.index("stageExecutionStatus.value[stageId] = 'completed';", handoff_call) assert verified < handoff_call < completed_mark @pytest.mark.parametrize("bad_result", [ {"status": "success", "result": {"stage_identity": { "stage": "gwas_trait", "prefix": "cohort", "genotype_scope_id": GENOTYPE, }, "stage_status": {"stage": "gwas_trait", "prefix": "cohort", "completed": True}}}, {"status": "success", "result": {"stage_identity": { "stage": "gwas_fmt", "prefix": "cohort", "genotype_scope_id": "genotype:bad", }, "stage_status": {"stage": "gwas_fmt", "prefix": "cohort", "completed": True}}}, {"status": "success", "result": {"stage_status": { "stage": "gwas_fmt", "prefix": "cohort", "completed": False, "genotype_scope_id": GENOTYPE, }}}, {"status": "success", "result": { "stage_identity": {"stage": "gwas_fmt", "prefix": "other", "genotype_scope_id": GENOTYPE}, "stage_status": {"stage": "gwas_fmt", "prefix": "other", "completed": True}, }}, ]) def test_frontend_handoff_rejects_bad_identity_but_backend_uses_authoritative_manifests( monkeypatch, tmp_path, bad_result, ): _producer_manifests(tmp_path / "workspace/job") monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace") captured = _run_frontend_emmax_handoff(tmp_path, [["gwas_fmt", bad_result]]) assert "genotypeScopeId" not in captured["params"] result = api._api_semantic_preflight("job", "gwas_emmax", "cohort", captured["body"]) assert result["valid"] is True assert result["materialized_params"]["genotypeScopeId"] == GENOTYPE assert result["materialized_params"]["phenotypeScopeId"] == PHENOTYPE def test_emmax_declarations_and_exact_four_scope_status(tmp_path): from gfa_core.mas.tools.stage_tools import StageToolRegistry capability = get_stage_capability("gwas_emmax") assert [item.role for item in capability.consumes] == ["sorted_vcf", "trait_table"] assert [item.role for item in capability.provides] == [ "emmax_full", "emmax_pvalues", "manifest", "report", "environment", ] tool = StageToolRegistry().get("gwas_emmax") assert tool.outputs == ["emmax_full", "emmax_pvalues", "manifest", "report", "environment"] workspace = tmp_path / "workspace" / "job" _emmax_manifest(workspace) identity = StageIdentity( stage="gwas_emmax", prefix="cohort", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, model_scope_id=MODEL, analysis_scope_id=ANALYSIS, ) assert get_stage_status_for_identity( "job", identity, workspace_root=tmp_path / "workspace", strict_identity=True, ).completed is True identity.model_scope_id = "" partial = get_stage_status_for_identity( "job", identity, workspace_root=tmp_path / "workspace", strict_identity=True, ) assert partial.completed is False assert "all four" in partial.errors[0] def test_emmax_results_require_exact_scopes_and_elapsed(monkeypatch, tmp_path): workspace = tmp_path / "workspace" / "job" _emmax_manifest(workspace) monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace") exact = asyncio.run(api.get_results( job_id="job", stage="gwas_emmax", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, model_scope_id=MODEL, analysis_scope_id=ANALYSIS, )) assert exact["gwas_emmax"]["images"] == [] assert {item["role"] for item in exact["gwas_emmax"]["files"]} == { "emmax_full", "emmax_pvalues", "report", "environment", "manifest", } assert exact["gwas_emmax"]["elapsed_time"] == 12.3 wrong = asyncio.run(api.get_results( job_id="job", stage="gwas_emmax", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, model_scope_id="gwas-model:" + "9" * 64, analysis_scope_id=ANALYSIS, )) assert wrong["gwas_emmax"]["files"] == [] def test_backend_emmax_accepts_only_fresh_exact_success_manifest(monkeypatch, tmp_path): workspace = tmp_path / "workspace" fmt, trait = _producer_manifests(workspace) decisions = { "sorted_vcf": {"path": str(fmt["artifact"]), "source_manifest": str(fmt["path"])}, "trait_table": {"path": str(trait["artifact"]), "source_manifest": str(trait["path"])}, } monkeypatch.setattr(backend, "_resolve_gwas_emmax_inputs", lambda **_kwargs: decisions) def fake_run(command, cwd=None): assert command[command.index("--log-file") + 1].endswith("_gwas_emmax.log") _emmax_manifest(workspace) return 0 monkeypatch.setattr(backend, "_run_child_owned_log", fake_run) message, manifest = backend.run_stage( stage="gwas_emmax", job=backend.JobInfo("job", str(tmp_path / "input"), str(workspace), str(workspace / "logs"), "now"), prefix="cohort", cpu=6, genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, gwas_plink=PLINK, gwas_emmax=EMMAX, gwas_rscript=RSCRIPT, gwas_requested_pcs=3, ) assert message.startswith("✅") assert manifest == workspace / "final/cohort.gwas_emmax.identity.manifest.json" def test_backend_emmax_failure_never_replays_prior_success(monkeypatch, tmp_path): workspace = tmp_path / "workspace" fmt, trait = _producer_manifests(workspace) prior = _emmax_manifest(workspace) decisions = { "sorted_vcf": {"path": str(fmt["artifact"]), "source_manifest": str(fmt["path"])}, "trait_table": {"path": str(trait["artifact"]), "source_manifest": str(trait["path"])}, } monkeypatch.setattr(backend, "_resolve_gwas_emmax_inputs", lambda **_kwargs: decisions) monkeypatch.setattr(backend, "_run_child_owned_log", lambda *_args, **_kwargs: 7) message, manifest = backend.run_stage( stage="gwas_emmax", job=backend.JobInfo("job", str(tmp_path / "input"), str(workspace), str(workspace / "logs"), "now"), prefix="cohort", cpu=6, genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, ) assert "exit code: 7" in message assert manifest is None assert prior.is_file() def _configure_task_test(monkeypatch, tmp_path, fake_run_stage): import gfa_core.mas.resource_governor as resource_governor class NoopLease: def __init__(self, **_kwargs): pass def wait(self, update_state=None): return None def release(self, **_kwargs): return None monkeypatch.setattr(resource_governor, "ResourceLease", NoopLease) monkeypatch.setattr(resource_governor, "get_task", lambda _task_id: {}) monkeypatch.setattr(resource_governor, "snapshot", lambda **_kwargs: {"usage": {}}) monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace") monkeypatch.setattr(api, "INPUT_ROOT", tmp_path / "input") monkeypatch.setattr(api, "_attach_single_stage_status", lambda _task: None) monkeypatch.setattr(api, "_attach_annotation_progress", lambda _task: None) monkeypatch.setattr(api.time, "sleep", lambda _seconds: None) monkeypatch.setattr(backend, "run_stage", fake_run_stage) def test_api_task_forwards_emmax_fields_and_accepts_complete_postflight(monkeypatch, tmp_path): captured = {} def fake_run_stage(**kwargs): captured.update(kwargs) return "✅ done", _emmax_manifest(tmp_path / "workspace/job") _configure_task_test(monkeypatch, tmp_path, fake_run_stage) api.tasks["emmax-task"] = { "id": "emmax-task", "stage": "gwas_emmax", "job_id": "job", "status": "pending", "message": "", "created_at": "2026-07-21T00:00:00", } params = {"prefix": "cohort", **_scopes(), "gwasPlink": PLINK, "gwasEmmax": EMMAX, "gwasRscript": RSCRIPT, "gwasRequestedPcs": 2} api.run_stage_sync("emmax-task", "gwas_emmax", "job", params) assert {key: captured[key] for key in ( "gwas_plink", "gwas_emmax", "gwas_rscript", "gwas_requested_pcs", )} == { "gwas_plink": PLINK, "gwas_emmax": EMMAX, "gwas_rscript": RSCRIPT, "gwas_requested_pcs": 2, } task = api.tasks["emmax-task"] assert task["status"] == "success", task files = task["result"]["stage_files"]["gwas_emmax"]["files"] assert {item["role"] for item in files} == { "emmax_full", "emmax_pvalues", "report", "environment", "manifest", } assert task["result"]["stage_files"]["gwas_emmax"]["elapsed_time"] == 12.3 def test_api_task_rejects_partial_emmax_manifest_without_publishing_result(monkeypatch, tmp_path): def fake_run_stage(**_kwargs): path = _emmax_manifest( tmp_path / "workspace/job", roles=("emmax_full", "report", "environment"), ) return "✅ done", path _configure_task_test(monkeypatch, tmp_path, fake_run_stage) api.tasks["partial-emmax"] = { "id": "partial-emmax", "stage": "gwas_emmax", "job_id": "job", "status": "pending", "message": "", "created_at": "2026-07-21T00:00:00", } api.run_stage_sync("partial-emmax", "gwas_emmax", "job", {"prefix": "cohort", **_scopes()}) task = api.tasks["partial-emmax"] assert task["status"] == "failed" assert task["result"]["success"] is False assert "files" not in task["result"] and "images" not in task["result"]