From 7b29dbff7e43e87741476f47df17ad1582b16546 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 00:58:15 +0800 Subject: feat(pipeline): add runnable artifact pipeline increment [Change Nature] - This commit adds the first runnable increment of the F# artifact pipeline: CLI, server, frontend bundle, stage fixture, and tests. It is feature work, not a bug fix. [New Capability] - CLI `run` executes the three-step Blender fixture through the process bridge and writes GLBs, step logs, events, and a manifest; `verify` validates a manifest without Blender. - Server exposes run start, run-scoped SSE, artifact manifest/file routes, and serves the Fable frontend from its build output. - Frontend starts runs, follows run-scoped SSE progress, and links the artifact manifest on completion. [Implementation] - Shared layer holds domain, run state, replay, and artifact contracts; Modeling holds the pipeline, 120s bridge timeout, and result-file protocol; stage/blender/fixture.py owns geometry in Python (bpy owns geometry, F# owns orchestration). - Fable bundle is regenerated into public/ and copied by the server project; solution includes all projects with warnings-as-errors. - README documents build, Fable, server, interpreter/env, fixture limitations, and current verification status. [Impact] - Verified: 40/40 solution tests pass; Release build 0 warnings/errors; real CLI run with bpy 5.0.1 produced 3 GLBs, 3 logs, and a verify-clean manifest; isolated-port server smoke flow succeeded. - Not yet verified: browser end-to-end behavior and production fidelity. Artifacts and caches are gitignored. --- .../SomhairlesDream.Modeling.Tests/BridgeTests.fs | 72 ++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/SomhairlesDream.Modeling.Tests/BridgeTests.fs (limited to 'tests/SomhairlesDream.Modeling.Tests/BridgeTests.fs') diff --git a/tests/SomhairlesDream.Modeling.Tests/BridgeTests.fs b/tests/SomhairlesDream.Modeling.Tests/BridgeTests.fs new file mode 100644 index 0000000..d9c7347 --- /dev/null +++ b/tests/SomhairlesDream.Modeling.Tests/BridgeTests.fs @@ -0,0 +1,72 @@ +module SomhairlesDream.Modeling.Tests.BridgeTests + +open System +open System.IO +open Xunit +open SomhairlesDream.Modeling + +let private withTempDirectory action = + let path = Path.Combine(Path.GetTempPath(), "somhairles-dream-bridge-" + Guid.NewGuid().ToString("N")) + Directory.CreateDirectory(path) |> ignore + + try + action path + finally + if Directory.Exists(path) then + Directory.Delete(path, true) + +let private fixtureScript = + [| "import argparse" + "import json" + "import os" + "" + "parser = argparse.ArgumentParser()" + "parser.add_argument('--step-id', required=True)" + "parser.add_argument('--output', required=True)" + "parser.add_argument('--render-output')" + "parser.add_argument('--result-output')" + "args = parser.parse_args()" + "os.makedirs(os.path.dirname(args.output), exist_ok=True)" + "with open(args.output, 'wb') as stream:" + " stream.write(b'glTF')" + "result_path = args.result_output or args.output + '.result.json'" + "with open(result_path, 'w', encoding='utf-8') as stream:" + " json.dump({" + " 'ok': True," + " 'blenderVersion': 'test'," + " 'exportedAt': '2026-09-20T12:00:05Z'," + " 'objectIds': ['heritage.foundation', 'heritage.deck']," + " 'verifiedObjectIds': ['heritage.foundation', 'heritage.deck']," + " 'glbBytes': 4" + " }, stream)" + "with open(args.output + '.log', 'w', encoding='utf-8') as stream:" + " stream.write('fixture log retained separately\\n')" + "print('Blender informational output')" |] + |> String.concat Environment.NewLine + +[] +let ``bridge reads result file instead of polluted stdout and retains process log`` () = + withTempDirectory (fun root -> + let blenderRoot = Path.Combine(root, "stage", "blender") + Directory.CreateDirectory(blenderRoot) |> ignore + File.WriteAllText(Path.Combine(blenderRoot, "fixture.py"), fixtureScript) + + let output = Path.Combine(root, "run", "step.glb") + let request = + { Step = Design.steps[0] + GlbPath = output + RenderPath = None + ResultPath = output + ".result.json" + LogPath = output + ".log" } + + let result = + (BlenderProcessBridge("python3", Path.Combine(root, "stage")) :> IArtifactBridge) + .Export(request, ignore) + + match result with + | Error message -> failwith message + | Ok value -> + Assert.Equal("test", value.BlenderVersion) + Assert.Equal(4L, value.GlbBytes) + Assert.True(File.Exists(output + ".log")) + Assert.Contains("Blender informational output", File.ReadAllText(output + ".log"))) -- cgit v1.2.3