diff --git a/src/routes/v2/pages/Editor/hooks/useLoadSpec.ts b/src/routes/v2/pages/Editor/hooks/useLoadSpec.ts index 890fedf74..cfe39c5b5 100644 --- a/src/routes/v2/pages/Editor/hooks/useLoadSpec.ts +++ b/src/routes/v2/pages/Editor/hooks/useLoadSpec.ts @@ -13,6 +13,7 @@ import { ReplayIdGenerator, YamlDeserializer, } from "@/models/componentSpec"; +import { hydrateLoadedSpecRefs } from "@/routes/v2/pages/Editor/utils/hydrateSpecRefs"; import { createUndoStoreWithEvents, loadUndoHistory, @@ -119,22 +120,32 @@ export function useLoadSpec(ref: PipelineRef) { loadUndoHistory(ref.name).catch(() => null), ]); - if (undoHistory) { - try { - const replayIdGen = new ReplayIdGenerator(undoHistory.idStack); - const spec = deserializeSpec(specData, replayIdGen); - const restoredUndoStore = createUndoStoreWithEvents( - undoHistory.undoEvents, - ); - return { spec, restoredUndoStore }; - } catch (error) { - console.warn("Failed to restore undo history, loading fresh:", error); - } - } + const loadedSpec = deserializeSpecData(specData, undoHistory); + await hydrateLoadedSpecRefs(loadedSpec.spec); - return { spec: deserializeSpec(specData) }; + return loadedSpec; }, staleTime: Infinity, retry: false, }); } + +function deserializeSpecData( + specData: unknown, + undoHistory: Awaited> | null, +): LoadedSpec { + if (undoHistory) { + try { + const replayIdGen = new ReplayIdGenerator(undoHistory.idStack); + const spec = deserializeSpec(specData, replayIdGen); + const restoredUndoStore = createUndoStoreWithEvents( + undoHistory.undoEvents, + ); + return { spec, restoredUndoStore }; + } catch (error) { + console.warn("Failed to restore undo history, loading fresh:", error); + } + } + + return { spec: deserializeSpec(specData) }; +} diff --git a/src/routes/v2/pages/Editor/utils/__tests__/hydrateSpecRefs.test.ts b/src/routes/v2/pages/Editor/utils/__tests__/hydrateSpecRefs.test.ts new file mode 100644 index 000000000..e62093d11 --- /dev/null +++ b/src/routes/v2/pages/Editor/utils/__tests__/hydrateSpecRefs.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ComponentSpec } from "@/models/componentSpec/entities/componentSpec"; +import { Task } from "@/models/componentSpec/entities/task"; +import { IncrementingIdGenerator } from "@/models/componentSpec/factories/idGenerator"; +import { hydrateLoadedSpecRefs } from "@/routes/v2/pages/Editor/utils/hydrateSpecRefs"; +import { hydrateComponentReference } from "@/services/componentService"; +import type { + ComponentSpec as ComponentSpecJson, + HydratedComponentReference, +} from "@/utils/componentSpec"; + +vi.mock("@/services/componentService", () => ({ + hydrateComponentReference: vi.fn(), +})); + +const mockHydrate = vi.mocked(hydrateComponentReference); + +const idGen = new IncrementingIdGenerator(); + +function makeContainerSpec(name: string): ComponentSpecJson { + return { + name, + implementation: { container: { image: "alpine" } }, + }; +} + +function makeHydratedRef(name: string): HydratedComponentReference { + return { + name, + digest: `digest-${name}`, + text: `name: ${name}`, + spec: makeContainerSpec(name), + }; +} + +function specWithTask(task: Task): ComponentSpec { + return new ComponentSpec({ + $id: idGen.next("spec"), + name: "Pipeline", + tasks: [task], + }); +} + +describe("hydrateLoadedSpecRefs", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("populates resolvedComponentSpec for a text-only ref", async () => { + const task = new Task({ + $id: idGen.next("task"), + name: "Process", + componentRef: { text: "name: Foo" }, + }); + const spec = specWithTask(task); + + mockHydrate.mockResolvedValue(makeHydratedRef("Foo")); + + expect(task.resolvedComponentSpec).toBeUndefined(); + + await hydrateLoadedSpecRefs(spec); + + expect(mockHydrate).toHaveBeenCalledTimes(1); + expect(task.resolvedComponentSpec).toBeDefined(); + expect(task.resolvedComponentSpec?.name).toBe("Foo"); + }); + + it("recurses into subgraph tasks without re-hydrating the subgraph ref", async () => { + const innerTask = new Task({ + $id: idGen.next("task"), + name: "Inner", + componentRef: { text: "name: Inner" }, + }); + const innerSpec = new ComponentSpec({ + $id: idGen.next("spec"), + name: "Subgraph", + tasks: [innerTask], + }); + const subgraphTask = new Task({ + $id: idGen.next("task"), + name: "Outer", + componentRef: { name: "Outer" }, + subgraphSpec: innerSpec, + }); + const spec = specWithTask(subgraphTask); + + mockHydrate.mockResolvedValue(makeHydratedRef("Inner")); + + await hydrateLoadedSpecRefs(spec); + + expect(mockHydrate).toHaveBeenCalledTimes(1); + expect(innerTask.resolvedComponentSpec).toBeDefined(); + expect(innerTask.resolvedComponentSpec?.name).toBe("Inner"); + }); +}); diff --git a/src/routes/v2/pages/Editor/utils/hydrateSpecRefs.ts b/src/routes/v2/pages/Editor/utils/hydrateSpecRefs.ts new file mode 100644 index 000000000..14088c918 --- /dev/null +++ b/src/routes/v2/pages/Editor/utils/hydrateSpecRefs.ts @@ -0,0 +1,29 @@ +import type { ComponentSpec } from "@/models/componentSpec"; +import type { Task } from "@/models/componentSpec/entities/task"; +import { hydrateComponentReference } from "@/services/componentService"; + +/** + * Hydrate every task's component reference in a freshly loaded spec so the + * live CSOM always carries a resolvable `componentRef.spec`. + */ +export async function hydrateLoadedSpecRefs( + spec: ComponentSpec, +): Promise { + await Promise.all(spec.tasks.map((task) => hydrateTaskRef(task))); +} + +async function hydrateTaskRef(task: Task): Promise { + if (task.subgraphSpec) { + await hydrateLoadedSpecRefs(task.subgraphSpec); + return; + } + + const hydrated = await hydrateComponentReference(task.componentRef); + if (!hydrated) return; + + task.setComponentRef(hydrated); + + if (task.subgraphSpec) { + await hydrateLoadedSpecRefs(task.subgraphSpec); + } +}