import { expect, test } from "bun:test"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildLocalPipelineGraph, hideDbtRunnables, parseMuteAnnotations, } from "../src/commands/pipeline/localGraph.ts"; // Build a throwaway workspace tree with `f//` scripts and a // wmill.yaml at the root, then assert the graph the wasm-backed builder derives. function withFolder( files: Record, fn: (root: string, folder: string) => Promise | void, ) { const root = mkdtempSync(join(tmpdir(), "wm-pl-")); writeFileSync(join(root, "wmill.yaml"), "defaultTs: bun\n"); const folder = "mypipe"; mkdirSync(join(root, "f", folder), { recursive: true }); for (const [name, content] of Object.entries(files)) { writeFileSync(join(root, "f", folder, name), content); } return Promise.resolve(fn(root, folder)).finally(() => rmSync(root, { recursive: true, force: true }), ); } test("only `// pipeline` scripts become nodes; `// on` asset triggers wire edges", async () => { await withFolder( { // a source: pipeline member, subscribes to nothing, but is annotated. "raw.bun.ts": `// pipeline\nimport * as wmill from "windmill-client"\nexport async function main() {}\n`, // a transform: pipeline member, subscribes to raw. "staged.duckdb.sql": `-- pipeline\n-- on datatable://main/raw\nINSERT INTO main.staged SELECT 1;\n`, // not a pipeline member — must be excluded. "helper.bun.ts": `export async function main() {}\n`, }, async (root, folder) => { const { graph, scripts } = await buildLocalPipelineGraph({ root, folder, defaultTs: "bun" }); const paths = graph.runnables.map((r) => r.path).sort(); expect(paths).toEqual(["f/mypipe/raw", "f/mypipe/staged"]); // helper is excluded expect(paths).not.toContain("f/mypipe/helper"); expect(scripts.map((s) => s.path).sort()).toEqual(paths); // staged subscribes to datatable://main/raw via an asset trigger const assetTriggers = graph.triggers.filter((t) => t.trigger_kind === "asset"); expect(assetTriggers).toHaveLength(1); const at = assetTriggers[0] as Extract< (typeof graph.triggers)[number], { trigger_kind: "asset" } >; expect(at.asset_kind).toBe("datatable"); expect(at.asset_path).toBe("main/raw"); expect(at.runnable_path).toBe("f/mypipe/staged"); // the referenced asset exists in the asset set expect(graph.assets).toContainEqual({ kind: "datatable", path: "main/raw" }); }, ); }); test("native triggers surface as trigger rows (no deploy needed)", async () => { await withFolder( { "ingest.bun.ts": `// pipeline\n// on data_upload\nimport * as wmill from "windmill-client"\nexport async function main() {}\n`, "upload_trigger.duckdb.sql": `-- pipeline\n-- on data_upload\nSELECT 1;\n`, }, async (root, folder) => { const { graph } = await buildLocalPipelineGraph({ root, folder, defaultTs: "bun" }); const native = graph.triggers.filter((t) => t.trigger_kind !== "asset"); expect( native .filter((t) => t.trigger_kind === "data_upload") .map((t) => t.runnable_path) .sort(), ).toEqual(["f/mypipe/ingest", "f/mypipe/upload_trigger"]); }, ); }); test("retry delay metadata strips the optional `delay=` prefix", async () => { await withFolder( { "retry.duckdb.sql": `-- pipeline\n-- retry 2 delay=10s\nSELECT 1;\n`, }, async (root, folder) => { const { graph } = await buildLocalPipelineGraph({ root, folder, defaultTs: "bun" }); expect(graph.runnables.find((r) => r.path === "f/mypipe/retry")?.retry).toEqual({ count: 2, delay: "10s", }); }, ); }); test("empty / no-pipeline folder yields an empty graph", async () => { await withFolder( { "plain.bun.ts": `export async function main() {}\n` }, async (root, folder) => { const { graph } = await buildLocalPipelineGraph({ root, folder, defaultTs: "bun" }); expect(graph.runnables).toHaveLength(0); expect(graph.assets).toHaveLength(0); }, ); }); test("`// materialize ` producer connects to its `// on` consumer", async () => { // The wasm asset parser doesn't surface `// materialize`; the CLI-side scan // must still emit the producer's write edge so it links to the consumer. await withFolder( { "load.duckdb.sql": `-- pipeline\n-- materialize ducklake://main/users\nSELECT 1 AS id;\n`, "consume.duckdb.sql": `-- pipeline\n-- on ducklake://main/users\nSELECT count(*) FROM users;\n`, }, async (root, folder) => { const { graph } = await buildLocalPipelineGraph({ root, folder, defaultTs: "bun" }); // the materialize target is a shared asset expect(graph.assets).toContainEqual({ kind: "ducklake", path: "main/users" }); // the producer carries its materialize_target and a write edge const load = graph.runnables.find((r) => r.path === "f/mypipe/load"); expect(load?.materialize_target).toEqual({ kind: "ducklake", path: "main/users" }); expect(graph.edges).toContainEqual({ runnable_kind: "script", runnable_path: "f/mypipe/load", asset_kind: "ducklake", asset_path: "main/users", access_type: "w", }); // the consumer subscribes to the same asset (the connecting trigger) const at = graph.triggers.find( (t) => t.trigger_kind === "asset" && t.runnable_path === "f/mypipe/consume", ) as Extract<(typeof graph.triggers)[number], { trigger_kind: "asset" }> | undefined; expect(at?.asset_path).toBe("main/users"); }, ); }); test("HD-1: `// data_test relationships` adds a producer → tested-script ordering edge", async () => { // Mirror of the deployed graph's `test_edges` (backend `asset_graph`): the // referenced dimension's in-pipeline producer must materialize before the // tested script runs, so a cold cascade orders it first. The wasm emits the // parsed `relationships` test; the local builder resolves its producer. await withFolder( { "dim_customers.duckdb.sql": `-- pipeline\n-- materialize ducklake://main/dim_customers\nSELECT 1 AS id;\n`, "fct_orders_daily.duckdb.sql": `-- pipeline\n-- on ducklake://main/orders\n-- data_test relationships customer_id -> ducklake://main/dim_customers.id\nSELECT customer_id FROM main.orders;\n`, }, async (root, folder) => { const { graph } = await buildLocalPipelineGraph({ root, folder, defaultTs: "bun" }); expect(graph.test_edges).toEqual([ { producer_kind: "script", producer_path: "f/mypipe/dim_customers", runnable_kind: "script", runnable_path: "f/mypipe/fct_orders_daily", asset_kind: "ducklake", asset_path: "main/dim_customers", }, ]); }, ); }); test("HD-1: a relationships ref to an asset with no in-pipeline producer adds no edge", async () => { // No producer (external / not-yet-in-pipeline table) ⇒ no ordering edge — the // runtime error stands, exactly like the backend (`test_edges` stays empty and // is omitted from the graph). await withFolder( { "fct.duckdb.sql": `-- pipeline\n-- on ducklake://main/orders\n-- data_test relationships customer_id -> ducklake://main/external_dim.id\nSELECT customer_id FROM main.orders;\n`, }, async (root, folder) => { const { graph } = await buildLocalPipelineGraph({ root, folder, defaultTs: "bun" }); expect(graph.test_edges).toBeUndefined(); }, ); }); test("HD-1: a self-test (relationships on the script's own materialize output) is dropped", async () => { // The tested script produces the very asset it references — the backend's // self-edge guard drops it, and so must the local builder (no self-ordering). await withFolder( { "dim.duckdb.sql": `-- pipeline\n-- materialize ducklake://main/dim\n-- data_test relationships id -> ducklake://main/dim.id\nSELECT 1 AS id;\n`, }, async (root, folder) => { const { graph } = await buildLocalPipelineGraph({ root, folder, defaultTs: "bun" }); expect(graph.test_edges).toBeUndefined(); }, ); }); test("HD-1: a custom `// data_test