From 7f9fc67f7970039c52890cb1f026095cd5ddcbb7 Mon Sep 17 00:00:00 2001 From: Vasek - Tom C Date: Wed, 12 Aug 2026 14:33:28 +0200 Subject: [PATCH] test: cover the codegen CLI, introspect and the untested render paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage was lopsided: the generator sat at 75% while the CLI this stack was built around was at 0% for every subcommand, and three entrypoint render paths had never executed. 56.3% overall, but not where a regression would hurt. The real hole was enums and interfaces. The entrypoint golden happened to use neither, so renderEnumDef, renderEnumMemberCall, renderInterfaceDef and sortedEnumValueKeys had never run — for constructs modules use routinely. Rather than hand-write typedef JSON, the fixture module grew an @enumType, an interface and a variadic and was rescanned with the real introspector, so the golden still pins the renderer against input it actually receives. The CLI tests cover dispatch and each required-flag error, and run each generator into a temp dir, which also exercises the overlay that writes results to disk. Introspect gets an httptest session: the token goes out as basic auth, only the payload under "data" is written, and a GraphQL errors array fails loudly rather than writing an empty schema — which would otherwise surface much later as bindings with no API in them. The routing check asserts what is otherwise invisible: both paths emit the same file names, so the bundle each module ends up with is the only evidence of which generator ran. Confirmed it fails when the condition is flipped. Coverage is now 70.6%, with the CLI at 80-90% per subcommand. Signed-off-by: Tom Chauveau --- .dagger/modules/e2e/generate.dang | 32 +++ .../testdata/entrypoint_smoke_want.ts | 63 +++++- .../typescript/testdata/typedef_smoke.json | 191 ++++++++++++++-- helpers/codegen/introspect_test.go | 102 +++++++++ helpers/codegen/main_cli_test.go | 211 ++++++++++++++++++ typescript-sdk.dang | 8 + 6 files changed, 583 insertions(+), 24 deletions(-) create mode 100644 helpers/codegen/introspect_test.go create mode 100644 helpers/codegen/main_cli_test.go diff --git a/.dagger/modules/e2e/generate.dang b/.dagger/modules/e2e/generate.dang index 3eb6b61..9a6c49b 100644 --- a/.dagger/modules/e2e/generate.dang +++ b/.dagger/modules/e2e/generate.dang @@ -79,6 +79,38 @@ type GenerateChecks { null } + """ + A CLI 1.0 module should be generated by this SDK and a legacy one by the + engine, and the bundle each ends up with is what distinguishes them: ours is + the one committed in this repo, the engine's is built from its own image. + + Routing is invisible in the generated tree otherwise — both paths produce the + same file names — so a flipped condition would leave every check passing while + workspace modules quietly went back to being generated by the engine, drifting + from the library this SDK ships. + """ + generateRoutingCheck(ws: Workspace!): Void @check { + let ours = typescriptSdk.mod(ws, path: fixtures.depAppModule).generate(ws) + .after.directory(fixtures.depAppModule).file("sdk/core.js").digest + + let shipped = typescriptSdk.libraryBundleFile("core.js").digest + + Asserts.assert( + ours == shipped, + "a dagger-module.toml module should be generated with the bundle this SDK ships", + ) + + let legacy = typescriptSdk.mod(ws, path: fixtures.generateModule).generate(ws) + .after.directory(fixtures.generateModule).file("sdk/core.js").digest + + Asserts.assert( + legacy != shipped, + "a dagger.json module should keep being generated by the engine's runtime", + ) + + null + } + """ A Deno module gets the same bindings, bundle and entrypoint as any other — only its config differs, because Deno resolves imports through deno.json diff --git a/helpers/codegen/generator/typescript/testdata/entrypoint_smoke_want.ts b/helpers/codegen/generator/typescript/testdata/entrypoint_smoke_want.ts index b12e3eb..6c0a669 100644 --- a/helpers/codegen/generator/typescript/testdata/entrypoint_smoke_want.ts +++ b/helpers/codegen/generator/typescript/testdata/entrypoint_smoke_want.ts @@ -42,6 +42,27 @@ function formatError(e: unknown): DaggerError { } } + +class __Iface_Greeter { + constructor(public _ctx: Context) {} + + static fromID(id: string): __Iface_Greeter { + return new __Iface_Greeter(new Context().selectNode(id, "SmokeGreeter")) + } + + async id(): Promise { + return await this._ctx.select("id").execute() + } + +async greet(name: any): Promise { + const __args: Record = {} + if (name !== undefined) __args["name"] = name + return await this._ctx.select("greet", __args).execute() + } +} + + + function rebuildSmoke(state: any): Smoke { const __obj = Object.assign(Object.create(Smoke.prototype), state ?? {}) if (state) { @@ -65,15 +86,24 @@ async function serializeSmoke(__obj: Smoke): Promise { async function register(): Promise { let mod = dag.module_() - let obj_Smoke = dag.typeDef().withObject("Smoke", { description: "A module covering the shapes the entrypoint has to dispatch.", sourceMap: dag.sourceMap("src/index.ts", 7, 14) }) - obj_Smoke = obj_Smoke.withFunction(dag.function_("ctr", dag.typeDef().withObject("Container")).withSourceMap(dag.sourceMap("src/index.ts", 31, 3))) - obj_Smoke = obj_Smoke.withFunction(dag.function_("greet", dag.typeDef().withKind(TypeDefKind.StringKind)).withDescription("Greet someone.").withSourceMap(dag.sourceMap("src/index.ts", 26, 3)).withArg("name", dag.typeDef().withKind(TypeDefKind.StringKind), { sourceMap: dag.sourceMap("src/index.ts", 26, 9) }).withArg("loud", dag.typeDef().withKind(TypeDefKind.BooleanKind).withOptional(true), { sourceMap: dag.sourceMap("src/index.ts", 26, 23) })) - obj_Smoke = obj_Smoke.withFunction(dag.function_("nothing", dag.typeDef().withKind(TypeDefKind.VoidKind).withOptional(true)).withSourceMap(dag.sourceMap("src/index.ts", 41, 3))) - obj_Smoke = obj_Smoke.withFunction(dag.function_("read", dag.typeDef().withKind(TypeDefKind.StringKind)).withSourceMap(dag.sourceMap("src/index.ts", 36, 9)).withArg("path", dag.typeDef().withKind(TypeDefKind.StringKind), { sourceMap: dag.sourceMap("src/index.ts", 36, 14) })) - obj_Smoke = obj_Smoke.withField("greeting", dag.typeDef().withKind(TypeDefKind.StringKind), { sourceMap: dag.sourceMap("src/index.ts", 15, 3) }) - obj_Smoke = obj_Smoke.withField("source", dag.typeDef().withObject("Directory"), { description: "Where the source lives.", sourceMap: dag.sourceMap("src/index.ts", 12, 3) }) - obj_Smoke = obj_Smoke.withConstructor(dag.function_("", obj_Smoke).withArg("source", dag.typeDef().withObject("Directory"), { sourceMap: dag.sourceMap("src/index.ts", 17, 15) }).withArg("greeting", dag.typeDef().withKind(TypeDefKind.StringKind), { defaultValue: JSON.stringify("hello") as string & { __JSON: never }, sourceMap: dag.sourceMap("src/index.ts", 17, 34) })) + let obj_Smoke = dag.typeDef().withObject("Smoke", { description: "A module covering the shapes the entrypoint has to dispatch.", sourceMap: dag.sourceMap("src/index.ts", 27, 14) }) + obj_Smoke = obj_Smoke.withFunction(dag.function_("ctr", dag.typeDef().withObject("Container")).withSourceMap(dag.sourceMap("src/index.ts", 64, 3))) + obj_Smoke = obj_Smoke.withFunction(dag.function_("greet", dag.typeDef().withKind(TypeDefKind.StringKind)).withDescription("Greet someone.").withSourceMap(dag.sourceMap("src/index.ts", 46, 3)).withArg("name", dag.typeDef().withKind(TypeDefKind.StringKind), { sourceMap: dag.sourceMap("src/index.ts", 46, 9) }).withArg("volume", dag.typeDef().withEnum("Volume"), { sourceMap: dag.sourceMap("src/index.ts", 46, 23) }).withArg("loud", dag.typeDef().withKind(TypeDefKind.BooleanKind).withOptional(true), { sourceMap: dag.sourceMap("src/index.ts", 46, 39) })) + obj_Smoke = obj_Smoke.withFunction(dag.function_("greetAll", dag.typeDef().withKind(TypeDefKind.StringKind)).withDescription("Greet everyone.").withSourceMap(dag.sourceMap("src/index.ts", 54, 3)).withArg("names", dag.typeDef().withListOf(dag.typeDef().withKind(TypeDefKind.StringKind)).withOptional(true), { sourceMap: dag.sourceMap("src/index.ts", 54, 15) })) + obj_Smoke = obj_Smoke.withFunction(dag.function_("nothing", dag.typeDef().withKind(TypeDefKind.VoidKind).withOptional(true)).withSourceMap(dag.sourceMap("src/index.ts", 74, 3))) + obj_Smoke = obj_Smoke.withFunction(dag.function_("read", dag.typeDef().withKind(TypeDefKind.StringKind)).withSourceMap(dag.sourceMap("src/index.ts", 69, 9)).withArg("path", dag.typeDef().withKind(TypeDefKind.StringKind), { sourceMap: dag.sourceMap("src/index.ts", 69, 14) })) + obj_Smoke = obj_Smoke.withFunction(dag.function_("withGreeter", dag.typeDef().withInterface("Greeter")).withSourceMap(dag.sourceMap("src/index.ts", 59, 3)).withArg("greeter", dag.typeDef().withInterface("Greeter"), { sourceMap: dag.sourceMap("src/index.ts", 59, 15) })) + obj_Smoke = obj_Smoke.withField("greeting", dag.typeDef().withKind(TypeDefKind.StringKind), { sourceMap: dag.sourceMap("src/index.ts", 35, 3) }) + obj_Smoke = obj_Smoke.withField("source", dag.typeDef().withObject("Directory"), { description: "Where the source lives.", sourceMap: dag.sourceMap("src/index.ts", 32, 3) }) + obj_Smoke = obj_Smoke.withConstructor(dag.function_("", obj_Smoke).withArg("source", dag.typeDef().withObject("Directory"), { sourceMap: dag.sourceMap("src/index.ts", 37, 15) }).withArg("greeting", dag.typeDef().withKind(TypeDefKind.StringKind), { defaultValue: JSON.stringify("hello") as string & { __JSON: never }, sourceMap: dag.sourceMap("src/index.ts", 37, 34) })) mod = mod.withObject(obj_Smoke) + let enum_Volume = dag.typeDef().withEnum("Volume", { description: "How loudly to greet.", sourceMap: dag.sourceMap("src/index.ts", 7, 14) }) + enum_Volume = enum_Volume.withEnumMember("Loud", { description: "Shout it.", sourceMap: dag.sourceMap("src/index.ts", 13, 19), value: "LOUD" }) + enum_Volume = enum_Volume.withEnumMember("Quiet", { sourceMap: dag.sourceMap("src/index.ts", 8, 19), value: "QUIET" }) + mod = mod.withEnum(enum_Volume) + let iface_Greeter = dag.typeDef().withInterface("Greeter", { description: "Anything that can greet.", sourceMap: dag.sourceMap("src/index.ts", 19, 18) }) + iface_Greeter = iface_Greeter.withFunction(dag.function_("greet", dag.typeDef().withKind(TypeDefKind.StringKind)).withSourceMap(dag.sourceMap("src/index.ts", 20, 3)).withArg("name", dag.typeDef().withKind(TypeDefKind.StringKind), { sourceMap: dag.sourceMap("src/index.ts", 20, 11) })) + mod = mod.withInterface(iface_Greeter) return await mod.id() } @@ -103,8 +133,16 @@ async function invoke( case "greet": { const __parent = rebuildSmoke(parentJson) const __arg_name = args["name"] === undefined || args["name"] === null ? args["name"] : args["name"] + const __arg_volume = args["volume"] === undefined || args["volume"] === null ? args["volume"] : ({ "Loud": "LOUD", "Quiet": "QUIET" } as Record)[args["volume"]] ?? args["volume"] const __arg_loud = args["loud"] === undefined || args["loud"] === null ? args["loud"] : args["loud"] - const __result = await __parent.greet(__arg_name, __arg_loud) + const __result = await __parent.greet(__arg_name, __arg_volume, __arg_loud) + return __result + } + + case "greetAll": { + const __parent = rebuildSmoke(parentJson) + const __arg_names = args["names"] === undefined || args["names"] === null ? [] : (args["names"] as any[]).map((__v) => __v) + const __result = await __parent.greetAll(...__arg_names) return __result } @@ -120,6 +158,13 @@ async function invoke( const __result = await __parent.read(__arg_path) return __result } + + case "withGreeter": { + const __parent = rebuildSmoke(parentJson) + const __arg_greeter = args["greeter"] === undefined || args["greeter"] === null ? args["greeter"] : __Iface_Greeter.fromID(args["greeter"]) + const __result = await __parent.withGreeter(__arg_greeter) + return await (__result).id() + } default: throw new Error(`unknown function ${fnName} on Smoke`) } diff --git a/helpers/codegen/generator/typescript/testdata/typedef_smoke.json b/helpers/codegen/generator/typescript/testdata/typedef_smoke.json index 7670d8c..4fbab4a 100644 --- a/helpers/codegen/generator/typescript/testdata/typedef_smoke.json +++ b/helpers/codegen/generator/typescript/testdata/typedef_smoke.json @@ -1,6 +1,82 @@ { - "enums": {}, - "interfaces": {}, + "enums": { + "Volume": { + "description": "How loudly to greet.", + "location": { + "column": 14, + "filepath": "src/index.ts", + "line": 7 + }, + "name": "Volume", + "values": { + "Loud": { + "description": "Shout it.", + "location": { + "column": 19, + "filepath": "src/index.ts", + "line": 13 + }, + "name": "Loud", + "value": "LOUD" + }, + "Quiet": { + "description": "", + "location": { + "column": 19, + "filepath": "src/index.ts", + "line": 8 + }, + "name": "Quiet", + "value": "QUIET" + } + } + } + }, + "interfaces": { + "Greeter": { + "description": "Anything that can greet.", + "functions": { + "greet": { + "arguments": [ + { + "description": "", + "isNullable": false, + "isOptional": false, + "isVariadic": false, + "location": { + "column": 11, + "filepath": "src/index.ts", + "line": 20 + }, + "name": "name", + "type": { + "kind": "STRING_KIND" + } + } + ], + "description": "", + "isCheck": false, + "isGenerator": false, + "isUp": false, + "location": { + "column": 3, + "filepath": "src/index.ts", + "line": 20 + }, + "name": "greet", + "returnType": { + "kind": "STRING_KIND" + } + } + }, + "location": { + "column": 18, + "filepath": "src/index.ts", + "line": 19 + }, + "name": "Greeter" + } + }, "name": "Smoke", "objects": { "Smoke": { @@ -14,7 +90,7 @@ "location": { "column": 15, "filepath": "src/index.ts", - "line": 17 + "line": 37 }, "name": "source", "type": { @@ -31,7 +107,7 @@ "location": { "column": 34, "filepath": "src/index.ts", - "line": 17 + "line": 37 }, "name": "greeting", "type": { @@ -48,7 +124,7 @@ "location": { "column": 14, "filepath": "src/index.ts", - "line": 7 + "line": 27 }, "methods": { "ctr": { @@ -60,7 +136,7 @@ "location": { "column": 3, "filepath": "src/index.ts", - "line": 31 + "line": 64 }, "name": "ctr", "returnType": { @@ -78,7 +154,7 @@ "location": { "column": 9, "filepath": "src/index.ts", - "line": 26 + "line": 46 }, "name": "name", "type": { @@ -88,12 +164,28 @@ { "description": "", "isNullable": false, - "isOptional": true, + "isOptional": false, "isVariadic": false, "location": { "column": 23, "filepath": "src/index.ts", - "line": 26 + "line": 46 + }, + "name": "volume", + "type": { + "kind": "ENUM_KIND", + "name": "Volume" + } + }, + { + "description": "", + "isNullable": false, + "isOptional": true, + "isVariadic": false, + "location": { + "column": 39, + "filepath": "src/index.ts", + "line": 46 }, "name": "loud", "type": { @@ -108,13 +200,48 @@ "location": { "column": 3, "filepath": "src/index.ts", - "line": 26 + "line": 46 }, "name": "greet", "returnType": { "kind": "STRING_KIND" } }, + "greetAll": { + "arguments": [ + { + "description": "", + "isNullable": false, + "isOptional": true, + "isVariadic": true, + "location": { + "column": 15, + "filepath": "src/index.ts", + "line": 54 + }, + "name": "names", + "type": { + "kind": "LIST_KIND", + "typeDef": { + "kind": "STRING_KIND" + } + } + } + ], + "description": "Greet everyone.", + "isCheck": false, + "isGenerator": false, + "isUp": false, + "location": { + "column": 3, + "filepath": "src/index.ts", + "line": 54 + }, + "name": "greetAll", + "returnType": { + "kind": "STRING_KIND" + } + }, "nothing": { "arguments": [], "description": "", @@ -124,7 +251,7 @@ "location": { "column": 3, "filepath": "src/index.ts", - "line": 41 + "line": 74 }, "name": "nothing", "returnType": { @@ -141,7 +268,7 @@ "location": { "column": 14, "filepath": "src/index.ts", - "line": 36 + "line": 69 }, "name": "path", "type": { @@ -156,12 +283,46 @@ "location": { "column": 9, "filepath": "src/index.ts", - "line": 36 + "line": 69 }, "name": "read", "returnType": { "kind": "STRING_KIND" } + }, + "withGreeter": { + "arguments": [ + { + "description": "", + "isNullable": false, + "isOptional": false, + "isVariadic": false, + "location": { + "column": 15, + "filepath": "src/index.ts", + "line": 59 + }, + "name": "greeter", + "type": { + "kind": "INTERFACE_KIND", + "name": "Greeter" + } + } + ], + "description": "", + "isCheck": false, + "isGenerator": false, + "isUp": false, + "location": { + "column": 3, + "filepath": "src/index.ts", + "line": 59 + }, + "name": "withGreeter", + "returnType": { + "kind": "INTERFACE_KIND", + "name": "Greeter" + } } }, "name": "Smoke", @@ -172,7 +333,7 @@ "location": { "column": 3, "filepath": "src/index.ts", - "line": 15 + "line": 35 }, "name": "greeting", "type": { @@ -185,7 +346,7 @@ "location": { "column": 3, "filepath": "src/index.ts", - "line": 12 + "line": 32 }, "name": "source", "type": { diff --git a/helpers/codegen/introspect_test.go b/helpers/codegen/introspect_test.go new file mode 100644 index 0000000..d3b977b --- /dev/null +++ b/helpers/codegen/introspect_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// sessionServer stands in for the nested session's GraphQL endpoint and points +// the env vars the engine injects at it, so these cases exercise the real +// request path rather than a seam. +func sessionServer(t *testing.T, handler http.HandlerFunc) { + t.Helper() + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + parsed, err := url.Parse(srv.URL) + require.NoError(t, err) + + t.Setenv(sessionPortEnv, parsed.Port()) + t.Setenv(sessionTokenEnv, "test-token") +} + +func TestRunIntrospect(t *testing.T) { + var gotQuery, gotUser string + sessionServer(t, func(w http.ResponseWriter, r *http.Request) { + user, _, _ := r.BasicAuth() + gotUser = user + + body, _ := io.ReadAll(r.Body) + var req struct { + Query string `json:"query"` + OpName string `json:"operationName"` + } + _ = json.Unmarshal(body, &req) + gotQuery = req.Query + + _, _ = w.Write([]byte(`{"data": {"__schema": {"queryType": {"name": "Query"}}}}`)) + }) + + out := filepath.Join(t.TempDir(), "schema.json") + require.NoError(t, runIntrospect([]string{"--output", out})) + + // The session authenticates with the token as the basic-auth username. + require.Equal(t, "test-token", gotUser) + require.Contains(t, gotQuery, "IntrospectionQuery") + + // Only the payload under "data" is written: that is the shape the + // generators parse, so unwrapping here keeps them free of the transport. + contents, err := os.ReadFile(out) + require.NoError(t, err) + require.JSONEq(t, `{"__schema": {"queryType": {"name": "Query"}}}`, string(contents)) +} + +// TestRunIntrospectGraphQLError covers a request that succeeds at the HTTP +// level but fails in the API. Ignoring the errors array would write an empty +// schema and turn a clear failure into bindings with no API in them. +func TestRunIntrospectGraphQLError(t *testing.T) { + sessionServer(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"errors": [{"message": "schema unavailable"}]}`)) + }) + + err := runIntrospect([]string{"--output", filepath.Join(t.TempDir(), "schema.json")}) + require.ErrorContains(t, err, "schema unavailable") +} + +func TestRunIntrospectEmptyData(t *testing.T) { + sessionServer(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{}`)) + }) + + err := runIntrospect([]string{"--output", filepath.Join(t.TempDir(), "schema.json")}) + require.ErrorContains(t, err, "returned no data") +} + +func TestRunIntrospectHTTPError(t *testing.T) { + sessionServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + err := runIntrospect([]string{"--output", filepath.Join(t.TempDir(), "schema.json")}) + require.ErrorContains(t, err, "unexpected status") +} + +// TestRunIntrospectOutsideSession covers running without the nested-exec env +// the engine provides. The message has to name the cause, since the fix is to +// change how the exec is declared rather than anything about the command. +func TestRunIntrospectOutsideSession(t *testing.T) { + t.Setenv(sessionPortEnv, "") + require.NoError(t, os.Unsetenv(sessionPortEnv)) + + err := runIntrospect([]string{"--output", filepath.Join(t.TempDir(), "schema.json")}) + require.ErrorContains(t, err, "experimentalPrivilegedNesting") +} diff --git a/helpers/codegen/main_cli_test.go b/helpers/codegen/main_cli_test.go new file mode 100644 index 0000000..3bd1430 --- /dev/null +++ b/helpers/codegen/main_cli_test.go @@ -0,0 +1,211 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// minimalSchema is the smallest introspection response the generators accept: +// a Query type and nothing else. The generators' own behaviour is covered by +// the golden tests; these cases are about the CLI around them. +const minimalSchema = `{ + "__schema": { + "queryType": {"name": "Query"}, + "types": [{"kind": "OBJECT", "name": "Query", "fields": []}] + }, + "__schemaVersion": "v0.21.0" +}` + +func writeFile(t *testing.T, dir, name, contents string) string { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +} + +func TestRunDispatch(t *testing.T) { + dir := t.TempDir() + schema := writeFile(t, dir, "schema.json", minimalSchema) + + for _, tc := range []struct { + name string + args []string + wantErr string + }{ + { + name: "no subcommand", + args: nil, + wantErr: "usage: codegen", + }, + { + name: "unknown subcommand", + args: []string{"generate-module"}, + wantErr: `unknown command "generate-module"`, + }, + { + // Each generator reads the schema from a file; without one there is + // nothing to generate from, and defaulting to an empty schema would + // silently emit bindings with no API in them. + name: "module without a schema", + args: []string{"module", "--module-name", "app"}, + wantErr: "--introspection-json-path is required", + }, + { + // The module name decides which types stay in client.gen.ts and + // which split into per-dependency files, so it cannot be inferred. + name: "module without a name", + args: []string{"module", "--introspection-json-path", schema}, + wantErr: "--module-name is required", + }, + { + name: "client without a schema", + args: []string{"client"}, + wantErr: "--introspection-json-path is required", + }, + { + name: "library without a schema", + args: []string{"library"}, + wantErr: "--introspection-json-path is required", + }, + { + // The entrypoint is rendered from the scan of the user's source, not + // from the schema — it is the one generator with a different input. + name: "entrypoint without a typedef", + args: []string{"entrypoint"}, + wantErr: "--typedef-json-path is required", + }, + { + name: "schema file that does not exist", + args: []string{"library", "--introspection-json-path", filepath.Join(dir, "absent.json")}, + wantErr: "read introspection json", + }, + { + name: "schema file that is not introspection output", + args: []string{"library", "--introspection-json-path", writeFile(t, dir, "empty.json", `{}`)}, + wantErr: "introspection json has no __schema", + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := run(tc.args) + require.ErrorContains(t, err, tc.wantErr) + }) + } +} + +// TestRunModule covers the module subcommand end to end, including the overlay +// writing the result to disk — the step every generator finishes with and which +// no other test exercises. +func TestRunModule(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "out") + + err := run([]string{ + "module", + "--introspection-json-path", writeFile(t, dir, "schema.json", minimalSchema), + "--module-name", "app", + "--output", out, + }) + require.NoError(t, err) + + // Flat in the output directory: the caller lays this down as the module's + // sdk/, so a nested path would land the bindings where nothing looks. + contents, err := os.ReadFile(filepath.Join(out, "client.gen.ts")) + require.NoError(t, err) + require.Contains(t, string(contents), `from "./core.js"`) +} + +func TestRunClient(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "out") + meta := writeFile(t, dir, "meta.json", `{ + "moduleName": "app", + "engineVersion": "v1.0.0-beta.9", + "module": {"kind": "DIR_SOURCE", "path": ".dagger/modules/app"} + }`) + + err := run([]string{ + "client", + "--introspection-json-path", writeFile(t, dir, "schema.json", minimalSchema), + "--client-meta-path", meta, + "--output", out, + }) + require.NoError(t, err) + + contents, err := os.ReadFile(filepath.Join(out, "dagger.gen.ts")) + require.NoError(t, err) + require.Contains(t, string(contents), `from "@dagger.io/dagger"`) +} + +// TestRunClientRejectsUnservableModule guards the fail-closed check: a client +// serves exactly the module it binds, and a kind with no serve path would +// produce a client that builds and then cannot reach its module at runtime. +func TestRunClientRejectsUnservableModule(t *testing.T) { + dir := t.TempDir() + meta := writeFile(t, dir, "meta.json", `{"moduleName": "app", "module": {"kind": "WAT"}}`) + + err := run([]string{ + "client", + "--introspection-json-path", writeFile(t, dir, "schema.json", minimalSchema), + "--client-meta-path", meta, + "--output", filepath.Join(dir, "out"), + }) + require.ErrorContains(t, err, `unsupported source kind "WAT"`) +} + +func TestRunLibrary(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "out") + + err := run([]string{ + "library", + "--introspection-json-path", writeFile(t, dir, "schema.json", minimalSchema), + "--output", out, + }) + require.NoError(t, err) + + // The library's bindings ship inside the library, so they reach the runtime + // by relative path rather than through the bundle or the package name. + contents, err := os.ReadFile(filepath.Join(out, "client.gen.ts")) + require.NoError(t, err) + require.Contains(t, string(contents), `from "../common/context.js"`) +} + +func TestRunEntrypoint(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "out") + + typedef, err := json.Marshal(map[string]any{ + "name": "App", + "objects": map[string]any{ + "App": map[string]any{ + "name": "App", + "kind": "class", + "isExported": true, + "location": map[string]any{"filepath": "src/index.ts", "line": 3, "column": 14}, + "methods": map[string]any{ + "hello": map[string]any{ + "name": "hello", + "returnType": map[string]any{"kind": "STRING_KIND"}, + }, + }, + }, + }, + }) + require.NoError(t, err) + + require.NoError(t, run([]string{ + "entrypoint", + "--typedef-json-path", writeFile(t, dir, "typedef.json", string(typedef)), + "--output", out, + "--module-root", "/work", + })) + + contents, err := os.ReadFile(filepath.Join(out, "__dagger.entrypoint.ts")) + require.NoError(t, err) + require.Contains(t, string(contents), `import { App } from "./src/index"`) + require.Contains(t, string(contents), `case "hello":`) +} diff --git a/typescript-sdk.dang b/typescript-sdk.dang index 32dfd82..026ffe3 100644 --- a/typescript-sdk.dang +++ b/typescript-sdk.dang @@ -461,6 +461,14 @@ type TypescriptSdk { currentModule.source.directory("library/bundle") } + """ + A file from the prebuilt library this SDK ships, so callers can tell what a + generated module was built against. + """ + libraryBundleFile(name: String!): File! { + tsBundle.file(name) + } + """ Generate a module's own bindings (client.gen.ts plus one .gen.ts per dependency) from its module-facing introspection schema.