Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions .dagger/modules/e2e/client-test.dang
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""
Checks for typed-client generation (generateClient / generateAllClient) and the
Go codegen helpers behind it.
"""
type ClientTest {
let u: Utils! = Utils()

let fixtureRoot: String! = ".dagger/modules/e2e/fixtures"

# A client binds to the client/app module (which depends on client/dep) and is
# generated into its own dir.
let clientModulePath: String! = fixtureRoot + "/client/app"
let clientOutputPath: String! = fixtureRoot + "/client/out"
# A client dir that already has a package.json pointing @dagger.io/dagger at a
# local bundle; regeneration must preserve it.
let clientExistingPath: String! = fixtureRoot + "/client/existing"

# dagger/dagger commit whose sdk/typescript builds the @dagger.io/dagger the
# compile check type-checks against. Pinned to a commit (not a branch) so
# Dagger content-addresses the whole SDK build and reuses it across runs — a
# branch re-resolves HEAD every run and rebuilds. Bump to track main.
let daggerSourceRef: String! = "944ff62a6a768e91fc72519f343e50da2c77d889"

"""
Generating a client for a module should produce a scoped-package client:
dagger.gen.ts (core types), one <module>.gen.ts per module (the bound module
plus its deps), and package.json — rooted at the client output path.
"""
pub generateCheck(ws: Workspace!): Void @check {
let changes = typescriptSdk.generateClient(ws, module: clientModulePath, path: clientOutputPath)

u.assertGenerated(changes, clientOutputPath + "/dagger.gen.ts")
u.assertGenerated(changes, clientOutputPath + "/client-app.gen.ts")
u.assertGenerated(changes, clientOutputPath + "/package.json")
u.assertGenerated(changes, clientOutputPath + "/tsconfig.json")

# The binding must carry the bound module's actual API, not just exist: proves
# the client-facing schema was resolved and codegen'd, not left empty.
u.assertContains(changes.layer.file(clientOutputPath + "/client-app.gen.ts").contents, "hello", "the bound module's hello function must be in the generated client")

null
}

"""
A generated client must type-check. The bindings extend `BaseClient`, which no
published @dagger.io/dagger exports yet, so build the library from dagger `main`
and `tsc --noEmit` the generated client against it — catching binding/tsconfig
bugs that file-presence checks miss.
"""
pub compilesCheck(ws: Workspace!): Void @check {
let changes = typescriptSdk.generateClient(ws, module: clientModulePath, path: clientOutputPath)
let clientDir = changes.layer.directory(clientOutputPath)

let node = container
.from("node:22-alpine")
.withMountedCache("/root/.npm", cacheVolume("npm-cache"))

# Build @dagger.io/dagger from source (pinned dagger commit). npm needs
# --legacy-peer-deps for the SDK's graphql peer range. The pinned tree makes
# this whole chain cache: after the first build, later runs reuse the layer
# and the npm cache volume backs the first build's downloads.
let daggerLib = node
.withDirectory("/build", git("https://github.com/dagger/dagger").commit(daggerSourceRef).tree.directory("sdk/typescript"))
.withWorkdir("/build")
.withExec(["npm", "install", "--no-audit", "--no-fund", "--legacy-peer-deps"])
.withExec(["npm", "run", "build"])
.directory("/build")

# Point the client's @dagger.io/dagger at the built lib (npm install of a
# local path overrides the version pin) and type-check.
let out = node
.withDirectory("/dagger-lib", daggerLib)
.withDirectory("/client", clientDir)
.withWorkdir("/client")
.withExec(["npm", "install", "--no-audit", "--no-fund", "--legacy-peer-deps", "/dagger-lib", "typescript@5.9.3"])
.withExec(["npx", "--no-install", "tsc", "--noEmit"])
.stdout

null
}

"""
Regenerating a client whose package.json already points @dagger.io/dagger at a
local bundle must preserve that dependency (not overwrite it with the engine
version pin), and the emitted package.json must be pretty-printed. A
user-authored file in the client dir (main.ts) must also survive: generated
files overlay the existing dir rather than replacing it.
"""
pub respectsExistingCheck(ws: Workspace!): Void @check {
let changes = typescriptSdk.generateClient(ws, module: clientModulePath, path: clientExistingPath)

# The "./sdk" value proves the local ref is preserved; the ": " (space after
# colon) proves the output is pretty-printed, not a single minified line.
let pkg = changes.layer.file(clientExistingPath + "/package.json").contents
u.assertContains(pkg, "\"@dagger.io/dagger\": \"./sdk\"", "existing local @dagger.io/dagger ref must be preserved and pretty-printed")
u.assertContains(pkg, "\"name\": \"my-existing-client\"", "existing package name must be preserved")

# A user file in the client dir must not be staged for removal, and must
# still be present in the regenerated output.
u.assert(u.contains(changes.removedPaths, clientExistingPath + "/main.ts") == false, "regenerating a client must not remove a user-authored file")
u.assertContains(changes.after.file(clientExistingPath + "/main.ts").contents, "User-authored entrypoint", "the user's main.ts must survive regeneration")

null
}

"""
Regenerating a client from within the client dir itself (cwd == the client
path) must still resolve the bound module — a workspace-root-relative ref, so
resolution must be cwd-independent — and preserve user files there. This is the
exact cwd where regeneration used to mis-resolve the module and stage main.ts
for removal; changeset paths are relative to that cwd, hence the bare names.
"""
pub fromCwdCheck(ws: Workspace!): Void @check {
let atClient = ws
.directory("/", include: [fixtureRoot + "/client/**"])
.asWorkspace(cwd: clientExistingPath)
let changes = typescriptSdk.generateClient(atClient, module: clientModulePath, path: clientExistingPath)

# Bindings generated => the module resolved from the subdir cwd (not an empty
# or mis-resolved schema).
u.assertGenerated(changes, "client-app.gen.ts")
u.assertGenerated(changes, "dagger.gen.ts")

# The user file in the client dir survives regeneration from this cwd.
u.assert(u.contains(changes.removedPaths, "main.ts") == false, "regenerating from the client dir must not remove a user file")
u.assertContains(changes.after.file("main.ts").contents, "User-authored entrypoint", "the user's main.ts must survive regeneration from the client dir")

null
}

"""
Regenerating all workspace-registered clients should generate each one at its
configured path.
"""
pub generateAllCheck(ws: Workspace!): Void @check {
let changes = typescriptSdk.generateAllClient(ws)

u.assertGenerated(changes, clientOutputPath + "/dagger.gen.ts")
u.assertGenerated(changes, clientOutputPath + "/client-app.gen.ts")
u.assertGenerated(changes, clientOutputPath + "/package.json")
u.assertGenerated(changes, clientOutputPath + "/tsconfig.json")

null
}

"""
The Go helpers (codegen, config-updator) must pass their own unit + golden
tests. Runs `go test` in a container so `dagger check` covers the codegen
logic, not just its end-to-end output.
"""
pub helperTestsCheck(ws: Workspace!): Void @check {
let out = container
.from("golang:1.25-alpine")
.withoutEntrypoint
.withMountedCache("/go/pkg/mod", cacheVolume("go-mod"))
.withMountedCache("/root/.cache/go-build", cacheVolume("go-build"))
.withDirectory("/helpers", ws.directory("helpers"))
.withWorkdir("/helpers/codegen")
.withExec(["go", "test", "./..."])
.withWorkdir("/helpers/config-updator")
.withExec(["go", "test", "./..."])
.stdout

null
}
}
101 changes: 101 additions & 0 deletions .dagger/modules/e2e/config-test.dang
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""
Checks for a module's build-config (ModConfig): reading packageManager / baseImage
from package.json or deno.json, and the set/unset mutators.
"""
type ConfigTest {
let u: Utils! = Utils()

let fixtureRoot: String! = ".dagger/modules/e2e/fixtures"

let configModulePath: String! = fixtureRoot + "/config/app"
let configuredModulePath: String! = fixtureRoot + "/config/configured"
let configuredDenoModulePath: String! = fixtureRoot + "/config/configured-deno"
let configBareModulePath: String! = fixtureRoot + "/config/bare"

"""
Config readers should reflect package.json (or deno.json), and mutators
should edit only the targeted config file while preserving unrelated keys.
"""
pub check(ws: Workspace!): Void @check {
let app = typescriptSdk.mod(ws, path: configModulePath).config
let configured = typescriptSdk.mod(ws, path: configuredModulePath).config
let configuredDeno = typescriptSdk.mod(ws, path: configuredDenoModulePath).config

# Defaults: unconfigured node module reads empty.
u.assert(app.packageManager == "", "default packageManager should be empty")
u.assert(app.baseImage == "", "default baseImage should be empty")

# Configured node module reads the override values.
u.assert(configured.packageManager == "pnpm@8.15.4", "configured packageManager should read pnpm@8.15.4")
u.assert(configured.baseImage == "node:23.2.0-alpine", "configured baseImage should read the override")

# Deno module: no package.json, baseImage comes from deno.json.
u.assert(configuredDeno.packageManager == "", "deno-only module should expose no packageManager")
u.assert(configuredDeno.baseImage == "denoland/deno:alpine-2.0.0", "deno baseImage should read from deno.json")

let appPkg = configModulePath + "/package.json"

# Single-flag set: package manager only.
let pm = app.set(packageManager: "yarn@1.22.22")
u.assertOnlyFileChanged(pm, appPkg, "set(packageManager)")
u.assertContains(pm.after.file(appPkg).contents, "yarn@1.22.22", "set(packageManager) did not write the value")
u.assertContains(pm.after.file(appPkg).contents, "\"type\": \"module\"", "set(packageManager) dropped unrelated keys")

# Single-flag set: base image only.
let img = app.set(baseImage: "node:23.2.0-alpine")
u.assertOnlyFileChanged(img, appPkg, "set(baseImage)")
u.assertContains(img.after.file(appPkg).contents, "node:23.2.0-alpine", "set(baseImage) did not write the image")

# Both flags in one call: a single file change carrying both edits.
let both = app.set(packageManager: "npm@10.7.0", baseImage: "node:23.2.0-alpine")
u.assertOnlyFileChanged(both, appPkg, "set(packageManager, baseImage)")
u.assertContains(both.after.file(appPkg).contents, "npm@10.7.0", "set(both) did not write packageManager")
u.assertContains(both.after.file(appPkg).contents, "node:23.2.0-alpine", "set(both) did not write baseImage")

let configuredPkg = configuredModulePath + "/package.json"

let unsetPm = configured.unsetPackageManager
u.assertOnlyFileChanged(unsetPm, configuredPkg, "unsetPackageManager")
u.assertNotContains(unsetPm.after.file(configuredPkg).contents, "packageManager", "unsetPackageManager left the field")
u.assertContains(unsetPm.after.file(configuredPkg).contents, "node:23.2.0-alpine", "unsetPackageManager clobbered dagger.baseImage")

let unsetImg = configured.unsetBaseImage
u.assertOnlyFileChanged(unsetImg, configuredPkg, "unsetBaseImage")
u.assertNotContains(unsetImg.after.file(configuredPkg).contents, "baseImage", "unsetBaseImage left the override")
u.assertContains(unsetImg.after.file(configuredPkg).contents, "pnpm@8.15.4", "unsetBaseImage clobbered packageManager")

# Deno modules: baseImage edits route to deno.json, not package.json.
let configuredDenoFile = configuredDenoModulePath + "/deno.json"

let denoImg = configuredDeno.set(baseImage: "denoland/deno:latest")
u.assertOnlyFileChanged(denoImg, configuredDenoFile, "set(baseImage) on deno module")
u.assertContains(denoImg.after.file(configuredDenoFile).contents, "denoland/deno:latest", "set(baseImage) did not write to deno.json")

let unsetDenoImg = configuredDeno.unsetBaseImage
u.assertOnlyFileChanged(unsetDenoImg, configuredDenoFile, "unsetBaseImage on deno module")
u.assertNotContains(unsetDenoImg.after.file(configuredDenoFile).contents, "baseImage", "unsetBaseImage left the override in deno.json")
u.assertContains(unsetDenoImg.after.file(configuredDenoFile).contents, "nodeModulesDir", "unsetBaseImage clobbered unrelated deno keys")

# unset-package-manager on a Deno module is a no-op: there is no
# package.json, and the unset must not fabricate an empty one.
let denoUnsetPm = configuredDeno.unsetPackageManager
u.assert(denoUnsetPm.isEmpty, "unsetPackageManager on a Deno module should be a no-op")
u.assert(u.contains(denoUnsetPm.addedPaths, configuredDenoModulePath + "/package.json") == false, "unsetPackageManager must not create a package.json in a Deno module")

# Bare module (no package.json or deno.json): readers are empty and the
# unsetters are no-ops that never write a stray config file.
let bare = typescriptSdk.mod(ws, path: configBareModulePath).config
u.assert(bare.packageManager == "", "bare module should expose no packageManager")
u.assert(bare.baseImage == "", "bare module should expose no baseImage")

let bareUnsetPm = bare.unsetPackageManager
u.assert(bareUnsetPm.isEmpty, "unsetPackageManager on a module without package.json should be a no-op")
u.assert(u.contains(bareUnsetPm.addedPaths, configBareModulePath + "/package.json") == false, "unsetPackageManager must not fabricate a package.json")

let bareUnsetImg = bare.unsetBaseImage
u.assert(bareUnsetImg.isEmpty, "unsetBaseImage on a module without a config file should be a no-op")
u.assert(u.contains(bareUnsetImg.addedPaths, configBareModulePath + "/package.json") == false, "unsetBaseImage must not fabricate a package.json")

null
}
}
10 changes: 10 additions & 0 deletions .dagger/modules/e2e/fixtures/client/app/dagger.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "client-app",
"engineVersion": "latest",
"sdk": {
"source": "typescript"
},
"dependencies": [
"../dep"
]
}
6 changes: 6 additions & 0 deletions .dagger/modules/e2e/fixtures/client/app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"type": "module",
"dependencies": {
"typescript": "5.9.3"
}
}
16 changes: 16 additions & 0 deletions .dagger/modules/e2e/fixtures/client/app/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { dag, object, func } from "@dagger.io/dagger"

/**
* A module that binds a generated client and depends on client-dep (used
* internally, so a client for this module is served with the dependency).
*/
@object()
export class ClientApp {
/**
* Greet through the dependency module.
*/
@func()
async hello(name: string): Promise<string> {
return await dag.clientDep().greet(name)
}
}
13 changes: 13 additions & 0 deletions .dagger/modules/e2e/fixtures/client/app/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"moduleResolution": "Node",
"experimentalDecorators": true,
"strict": true,
"skipLibCheck": true,
"paths": {
"@dagger.io/dagger": ["./sdk/index.ts"],
"@dagger.io/dagger/telemetry": ["./sdk/telemetry.ts"]
}
}
}
7 changes: 7 additions & 0 deletions .dagger/modules/e2e/fixtures/client/dep/dagger.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "client-dep",
"engineVersion": "latest",
"sdk": {
"source": "typescript"
}
}
6 changes: 6 additions & 0 deletions .dagger/modules/e2e/fixtures/client/dep/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"type": "module",
"dependencies": {
"typescript": "5.9.3"
}
}
15 changes: 15 additions & 0 deletions .dagger/modules/e2e/fixtures/client/dep/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { object, func } from "@dagger.io/dagger"

/**
* A dependency module bound clients can pull in.
*/
@object()
export class ClientDep {
/**
* Return a friendly greeting from the dependency.
*/
@func()
greet(name: string): string {
return `hello ${name} from client-dep`
}
}
13 changes: 13 additions & 0 deletions .dagger/modules/e2e/fixtures/client/dep/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"moduleResolution": "Node",
"experimentalDecorators": true,
"strict": true,
"skipLibCheck": true,
"paths": {
"@dagger.io/dagger": ["./sdk/index.ts"],
"@dagger.io/dagger/telemetry": ["./sdk/telemetry.ts"]
}
}
}
6 changes: 6 additions & 0 deletions .dagger/modules/e2e/fixtures/client/existing/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// User-authored entrypoint; regeneration must not delete this.
import { connection, dag } from "./dagger.gen"

await connection(async () => {
console.log(await dag.clientApp())
})
8 changes: 8 additions & 0 deletions .dagger/modules/e2e/fixtures/client/existing/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "my-existing-client",
"type": "module",
"dependencies": {
"@dagger.io/dagger": "./sdk",
"typescript": "5.9.3"
}
}
Loading