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.