Repository: openllb/hlb
Pull request #85 (2020-04-14 08:03:50)

Add codegen test framework
- Simplified how `llb.Local` id is calculated. The terminal op of the graph `def.Def[len(def.Def)-1]` is an empty vertex with an input to the last vertex's digest. Since that vertex also has its digests of its inputs and so on, the digest of the terminal op is a merkle hash for the graph.
- Change `solver/request` to be more testable
- Add `(solver.Request).Tree` to debug solve trees and compare results with expected value

```sh
❯ go test -v ./codegen
=== RUN   TestCodeGen
=== RUN   TestCodeGen/image
=== PAUSE TestCodeGen/image
=== RUN   TestCodeGen/call_function
=== PAUSE TestCodeGen/call_function
=== RUN   TestCodeGen/local
=== PAUSE TestCodeGen/local
=== CONT  TestCodeGen/image
=== CONT  TestCodeGen/local
=== CONT  TestCodeGen/call_function
--- PASS: TestCodeGen (0.00s)
    --- PASS: TestCodeGen/image (0.00s)
        codegen_test.go:153: .
            └── parallel
                └── single
                    └── [source]  identifier:"docker-image://docker.io/library/alpine:latest"

    --- PASS: TestCodeGen/local (0.00s)
        codegen_test.go:153: .
            └── parallel
                └── single
                    └── [source]  identifier:"local://sha256:9c47e3158363ec87074ac17b48adf8db980ffdc680a347d3106c9d8dc1ddebc5" attrs:<key:"local.session" value:"661ubccj9uggraywqw5imxova" >

    --- PASS: TestCodeGen/call_function (0.00s)
        codegen_test.go:153: .
            └── parallel
                └── single
                    └── [source]  identifier:"docker-image://docker.io/library/busybox:latest"

PASS
ok  	github.com/openllb/hlb/codegen	(cached)
```


Patch:
diff --git a/codegen/codegen.go b/codegen/codegen.go
index 201b95f..76ba315 100644
--- a/codegen/codegen.go
+++ b/codegen/codegen.go
@@ -44,11 +44,11 @@ var (
 type StateOption func(llb.State) (llb.State, error)
 
 type CodeGen struct {
-	Debug   Debugger
-	request solver.Request
-	cln     *client.Client
+	Debug     Debugger
+	cln       *client.Client
+	sessionID string
 
-	localID         string
+	requests        []solver.Request
 	syncedDirByID   map[string]filesync.SyncedDir
 	fileSourceByID  map[string]secretsprovider.FileSource
 	agentConfigByID map[string]sockprovider.AgentConfig
@@ -84,7 +84,7 @@ func WithClient(cln *client.Client) CodeGenOption {
 func New(opts ...CodeGenOption) (*CodeGen, error) {
 	cg := &CodeGen{
 		Debug:           NewNoopDebugger(),
-		localID:         identity.NewID(),
+		sessionID:       identity.NewID(),
 		syncedDirByID:   make(map[string]filesync.SyncedDir),
 		fileSourceByID:  make(map[string]secretsprovider.FileSource),
 		agentConfigByID: make(map[string]sockprovider.AgentConfig),
@@ -99,6 +99,10 @@ func New(opts ...CodeGenOption) (*CodeGen, error) {
 	return cg, nil
 }
 
+func (cg *CodeGen) SessionID() string {
+	return cg.sessionID
+}
+
 func (cg *CodeGen) SolveOptions(ctx context.Context, st llb.State) (opts []solver.SolveOption, err error) {
 	env, err := st.Env(ctx)
 	if err != nil {
@@ -128,7 +132,7 @@ func (cg *CodeGen) SolveOptions(ctx context.Context, st llb.State) (opts []solve
 }
 
 func (cg *CodeGen) Generate(ctx context.Context, mod *parser.Module, targets []Target) (solver.Request, error) {
-	cg.request = solver.NewEmptyRequest()
+	var requests []solver.Request
 
 	for _, target := range targets {
 		// Reset codegen state for next target.
@@ -136,13 +140,13 @@ func (cg *CodeGen) Generate(ctx context.Context, mod *parser.Module, targets []T
 
 		obj := mod.Scope.Lookup(target.Name)
 		if obj == nil {
-			return cg.request, fmt.Errorf("unknown target %q", target)
+			return nil, fmt.Errorf("unknown target %q", target)
 		}
 
 		// Yield to the debugger before compiling anything.
 		err := cg.Debug(ctx, mod.Scope, mod, nil)
 		if err != nil {
-			return cg.request, err
+			return nil, err
 		}
 
 		call := parser.NewCallStmt(target.Name, nil, nil, nil).Call
@@ -153,55 +157,66 @@ func (cg *CodeGen) Generate(ctx context.Context, mod *parser.Module, targets []T
 			switch n := obj.Node.(type) {
 			case *parser.FuncDecl:
 				if n.Type.ObjType != parser.Filesystem {
-					return cg.request, checker.ErrInvalidTarget{Node: n, Target: target.Name}
+					return nil, checker.ErrInvalidTarget{Node: n, Target: target.Name}
 				}
 
 				st, err = cg.EmitFilesystemFuncDecl(ctx, mod.Scope, n, call, noopAliasCallback, nil)
 				if err != nil {
-					return cg.request, err
+					return nil, err
 				}
 			case *parser.AliasDecl:
 				if n.Func.Type.ObjType != parser.Filesystem {
-					return cg.request, checker.ErrInvalidTarget{Node: n, Target: target.Name}
+					return nil, checker.ErrInvalidTarget{Node: n, Target: target.Name}
 				}
 
 				st, err = cg.EmitFilesystemAliasDecl(ctx, mod.Scope, n, call, nil)
 				if err != nil {
-					return cg.request, err
+					return nil, err
 				}
 			}
 		default:
-			return cg.request, checker.ErrInvalidTarget{Node: obj.Node, Target: target.Name}
+			return nil, checker.ErrInvalidTarget{Node: obj.Node, Target: target.Name}
 		}
 
 		def, err := st.Marshal(ctx, llb.LinuxAmd64)
 		if err != nil {
-			return cg.request, err
+			return nil, err
 		}
 
 		opts, err := cg.SolveOptions(ctx, st)
 		if err != nil {
-			return cg.request, err
+			return nil, err
 		}
 
 		s, err := cg.newSession(ctx)
 		if err != nil {
-			return cg.request, err
+			return nil, err
 		}
 
-		cg.request = cg.request.Peer(solver.NewRequest(s, def, opts...))
+		request := solver.Single(&solver.Params{Def: def, SolveOpts: opts, Session: s})
 
-		for _, output := range target.Outputs {
-			cg.outputRequest(ctx, st, output)
+		if len(cg.requests) > 0 || len(target.Outputs) > 0 {
+			peerRequests := append([]solver.Request{request}, cg.requests...)
+			for _, output := range target.Outputs {
+				peerRequest, err := cg.outputRequest(ctx, st, output)
+				if err != nil {
+					return nil, err
+				}
+				peerRequests = append(peerRequests, peerRequest)
+			}
+			request = solver.Parallel(peerRequests...)
 		}
+
+		requests = append(requests, request)
 	}
 
-	return cg.request, nil
+	return solver.Parallel(requests...), nil
 }
 
 // Reset all the options and session attachables for the next target.
 // If we ever need to parallelize compilation we can revisit this.
 func (cg *CodeGen) reset() {
+	cg.requests = []solver.Request{}
 	cg.solveOpts = []solver.SolveOption{}
 	cg.syncedDirByID = map[string]filesync.SyncedDir{}
 	cg.fileSourceByID = map[string]secretsprovider.FileSource{}
@@ -550,16 +565,17 @@ func (cg *CodeGen) EmitFilesystemChainStmt(ctx context.Context, scope *parser.Sc
 			return so, err
 		}
 
-		opts := []llb.LocalOption{llb.SessionID(cg.localID)}
+		var opts []llb.LocalOption
 		for _, iopt := range iopts {
 			opt := iopt.(llb.LocalOption)
 			opts = append(opts, opt)
 		}
 
-		id, err := buildLocalID(ctx, path, opts...)
+		id, err := cg.LocalID(path, opts...)
 		if err != nil {
 			return so, err
 		}
+		opts = append(opts, llb.SessionID(cg.sessionID))
 
 		// Register paths as syncable directories for the session.
 		cg.syncedDirByID[id] = filesync.SyncedDir{
@@ -855,7 +871,8 @@ func (cg *CodeGen) EmitFilesystemChainStmt(ctx context.Context, scope *parser.Sc
 		}
 
 		so = func(st llb.State) (llb.State, error) {
-			err := cg.outputRequest(ctx, st, Output{Type: OutputDockerPush, Ref: ref})
+			request, err := cg.outputRequest(ctx, st, Output{Type: OutputDockerPush, Ref: ref})
+			cg.requests = append(cg.requests, request)
 			return st, err
 		}
 	case "dockerLoad":
@@ -864,7 +881,8 @@ func (cg *CodeGen) EmitFilesystemChainStmt(ctx context.Context, scope *parser.Sc
 			return so, err
 		}
 		so = func(st llb.State) (llb.State, error) {
-			err := cg.outputRequest(ctx, st, Output{Type: OutputDockerLoad, Ref: ref})
+			request, err := cg.outputRequest(ctx, st, Output{Type: OutputDockerLoad, Ref: ref})
+			cg.requests = append(cg.requests, request)
 			return st, err
 		}
 	case "download":
@@ -874,7 +892,8 @@ func (cg *CodeGen) EmitFilesystemChainStmt(ctx context.Context, scope *parser.Sc
 		}
 
 		so = func(st llb.State) (llb.State, error) {
-			err := cg.outputRequest(ctx, st, Output{Type: OutputDownload, LocalPath: localPath})
+			request, err := cg.outputRequest(ctx, st, Output{Type: OutputDownload, LocalPath: localPath})
+			cg.requests = append(cg.requests, request)
 			return st, err
 		}
 	case "downloadTarball":
@@ -884,7 +903,8 @@ func (cg *CodeGen) EmitFilesystemChainStmt(ctx context.Context, scope *parser.Sc
 		}
 
 		so = func(st llb.State) (llb.State, error) {
-			err := cg.outputRequest(ctx, st, Output{Type: OutputDownloadTarball, LocalPath: localPath})
+			request, err := cg.outputRequest(ctx, st, Output{Type: OutputDownloadTarball, LocalPath: localPath})
+			cg.requests = append(cg.requests, request)
 			return st, err
 		}
 	case "downloadOCITarball":
@@ -894,7 +914,8 @@ func (cg *CodeGen) EmitFilesystemChainStmt(ctx context.Context, scope *parser.Sc
 		}
 
 		so = func(st llb.State) (llb.State, error) {
-			err := cg.outputRequest(ctx, st, Output{Type: OutputDownloadOCITarball, LocalPath: localPath})
+			request, err := cg.outputRequest(ctx, st, Output{Type: OutputDownloadOCITarball, LocalPath: localPath})
+			cg.requests = append(cg.requests, request)
 			return st, err
 		}
 	case "downloadDockerTarball":
@@ -909,7 +930,8 @@ func (cg *CodeGen) EmitFilesystemChainStmt(ctx context.Context, scope *parser.Sc
 		}
 
 		so = func(st llb.State) (llb.State, error) {
-			err := cg.outputRequest(ctx, st, Output{Type: OutputDownloadDockerTarball, LocalPath: localPath, Ref: ref})
+			request, err := cg.outputRequest(ctx, st, Output{Type: OutputDownloadDockerTarball, LocalPath: localPath, Ref: ref})
+			cg.requests = append(cg.requests, request)
 			return st, err
 		}
 	default:
@@ -1559,7 +1581,7 @@ func (cg *CodeGen) EmitExecOptions(ctx context.Context, scope *parser.Scope, op
 					return opts, err
 				}
 
-				id := digest.FromString(localPath).String()
+				id := SecretID(localPath)
 
 				// Register path as an allowed file source for the session.
 				cg.fileSourceByID[id] = secretsprovider.FileSource{
@@ -1818,17 +1840,24 @@ func (cg *CodeGen) EmitMountOptions(ctx context.Context, scope *parser.Scope, op
 
 // Get a consistent hash for this local (path + options) so we don't transport
 // the same content multiple times when referenced repeatedly.
-func buildLocalID(ctx context.Context, path string, opts ...llb.LocalOption) (string, error) {
-	// First get serialized bytes for this llb.Local state.
-	st := llb.Local("", opts...)
-	_, hashInput, _, err := st.Output().Vertex(ctx).Marshal(ctx, &llb.Constraints{})
+func (cg *CodeGen) LocalID(path string, opts ...llb.LocalOption) (string, error) {
+	opts = append(opts, llb.SessionID(cg.SessionID()))
+	st := llb.Local(path, opts...)
+
+	def, err := st.Marshal(context.Background())
 	if err != nil {
 		return "", err
 	}
 
-	// Next append the path so we have the path + options serialized hash input.
-	hashInput = append(hashInput, []byte(path)...)
-	return string(digest.FromBytes(hashInput)), nil
+	// The terminal op of the graph def.Def[len(def.Def)-1] is an empty vertex with
+	// an input to the last vertex's digest. Since that vertex also has its digests
+	// of its inputs and so on, the digest of the terminal op is a merkle hash for
+	// the graph.
+	return digest.FromBytes(def.Def[len(def.Def)-1]).String(), nil
+}
+
+func SecretID(path string) string {
+	return digest.FromString(path).String()
 }
 
 func outputFromWriter(w io.WriteCloser) func(map[string]string) (io.WriteCloser, error) {
diff --git a/codegen/output.go b/codegen/output.go
index cdc2c25..dc0e824 100644
--- a/codegen/output.go
+++ b/codegen/output.go
@@ -37,15 +37,15 @@ const (
 	OutputDownloadDockerTarball
 )
 
-func (cg *CodeGen) outputRequest(ctx context.Context, st llb.State, output Output) error {
+func (cg *CodeGen) outputRequest(ctx context.Context, st llb.State, output Output) (solver.Request, error) {
 	opts, err := cg.SolveOptions(ctx, st)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	s, err := cg.newSession(ctx)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	switch output.Type {
@@ -53,18 +53,18 @@ func (cg *CodeGen) outputRequest(ctx context.Context, st llb.State, output Outpu
 		opts = append(opts, solver.WithPushImage(output.Ref))
 	case OutputDockerLoad:
 		if cg.mw == nil {
-			return errors.WithStack(errors.Errorf("progress.MultiWriter must be provided for dockerLoad"))
+			return nil, errors.WithStack(errors.Errorf("progress.MultiWriter must be provided for dockerLoad"))
 		}
 
 		if cg.dockerCli == nil {
 			cg.dockerCli, err = command.NewDockerCli()
 			if err != nil {
-				return err
+				return nil, err
 			}
 
 			err = cg.dockerCli.Initialize(flags.NewClientOptions())
 			if err != nil {
-				return err
+				return nil, err
 			}
 		}
 
@@ -99,7 +99,7 @@ func (cg *CodeGen) outputRequest(ctx context.Context, st llb.State, output Outpu
 	case OutputDownloadTarball, OutputDownloadOCITarball, OutputDownloadDockerTarball:
 		f, err := os.Open(output.LocalPath)
 		if err != nil {
-			return err
+			return nil, err
 		}
 
 		s.Allow(filesync.NewFSSyncTarget(outputFromWriter(f)))
@@ -116,9 +116,8 @@ func (cg *CodeGen) outputRequest(ctx context.Context, st llb.State, output Outpu
 
 	def, err := st.Marshal(ctx, llb.LinuxAmd64)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
-	cg.request = cg.request.Peer(solver.NewRequest(s, def, opts...))
-	return nil
+	return solver.Single(&solver.Params{Def: def, SolveOpts: opts, Session: s}), nil
 }
diff --git a/solver/request.go b/solver/request.go
index c5647ab..aa14790 100644
--- a/solver/request.go
+++ b/solver/request.go
@@ -7,6 +7,9 @@ import (
 	"github.com/moby/buildkit/client"
 	"github.com/moby/buildkit/client/llb"
 	"github.com/moby/buildkit/session"
+	"github.com/moby/buildkit/solver/pb"
+	digest "github.com/opencontainers/go-digest"
+	"github.com/xlab/treeprint"
 	"golang.org/x/sync/errgroup"
 )
 
@@ -20,48 +23,22 @@ type Request interface {
 	// for each independent solve.
 	Solve(ctx context.Context, cln *client.Client, mw *progress.MultiWriter) error
 
-	// Next adds a sequential solve to this request. The added request will only
-	// execute after this request has completed.
-	Next(n Request) Request
-
-	// Peer adds a parallel solve to this request. The added request will execute
-	// in parallel with this request.
-	Peer(p Request) Request
-}
-
-// NewEmptyRequest returns an empty request, which can be used as the root of
-// a solve request tree.
-func NewEmptyRequest() Request {
-	return &nullRequest{}
-}
-
-type nullRequest struct{}
-
-func (r *nullRequest) Solve(ctx context.Context, cln *client.Client, mw *progress.MultiWriter) error {
-	return nil
-}
-
-func (r *nullRequest) Next(n Request) Request {
-	return n
+	Tree(tree treeprint.Tree) error
 }
 
-func (r *nullRequest) Peer(p Request) Request {
-	return p
+type Params struct {
+	Def       *llb.Definition
+	SolveOpts []SolveOption
+	Session   *session.Session
 }
 
 type singleRequest struct {
-	s    *session.Session
-	def  *llb.Definition
-	opts []SolveOption
+	params *Params
 }
 
-// NewRequest returns a single solve request.
-func NewRequest(s *session.Session, def *llb.Definition, opts ...SolveOption) Request {
-	return &singleRequest{
-		s:    s,
-		def:  def,
-		opts: opts,
-	}
+// Single returns a single solve request.
+func Single(params *Params) Request {
+	return &singleRequest{params: params}
 }
 
 func (r *singleRequest) Solve(ctx context.Context, cln *client.Client, mw *progress.MultiWriter) error {
@@ -73,32 +50,83 @@ func (r *singleRequest) Solve(ctx context.Context, cln *client.Client, mw *progr
 	g, ctx := errgroup.WithContext(ctx)
 
 	g.Go(func() error {
-		return r.s.Run(ctx, cln.Dialer())
+		return r.params.Session.Run(ctx, cln.Dialer())
 	})
 
 	g.Go(func() error {
-		return Solve(ctx, cln, r.s, pw, r.def, r.opts...)
+		return Solve(ctx, cln, r.params.Session, pw, r.params.Def, r.params.SolveOpts...)
 	})
 
 	return g.Wait()
 }
 
-func (r *singleRequest) Next(n Request) Request {
-	return &sequentialRequest{
-		reqs: []Request{r, n},
+func (r *singleRequest) Tree(tree treeprint.Tree) error {
+	branch := tree.AddBranch("single")
+	return treeFromDefinition(branch, r.params.Def)
+}
+
+func treeFromDefinition(tree treeprint.Tree, def *llb.Definition) error {
+	ops := make(map[digest.Digest]*pb.Op)
+
+	var dgst digest.Digest
+	for _, dt := range def.Def {
+		var op pb.Op
+		if err := (&op).Unmarshal(dt); err != nil {
+			return err
+		}
+		dgst = digest.FromBytes(dt)
+		ops[dgst] = &op
 	}
+
+	if dgst == "" {
+		return nil
+	}
+
+	terminal := ops[dgst]
+	child := op{dgst: terminal.Inputs[0].Digest, ops: ops}
+	return child.Tree(tree)
 }
 
-func (r *singleRequest) Peer(p Request) Request {
-	return &parallelRequest{
-		reqs: []Request{r, p},
+type op struct {
+	dgst digest.Digest
+	ops  map[digest.Digest]*pb.Op
+}
+
+func (o op) Tree(tree treeprint.Tree) error {
+	pbOp := o.ops[o.dgst]
+
+	var branch treeprint.Tree
+
+	switch v := pbOp.Op.(type) {
+	case *pb.Op_Source:
+		branch = tree.AddMetaBranch("source", v.Source)
+	case *pb.Op_Exec:
+		branch = tree.AddMetaBranch("exec", v.Exec)
+	case *pb.Op_File:
+		branch = tree.AddMetaBranch("file", v.File)
+	case *pb.Op_Build:
+		branch = tree.AddMetaBranch("build", v.Build)
 	}
+
+	for _, input := range pbOp.Inputs {
+		child := op{dgst: input.Digest, ops: o.ops}
+		err := child.Tree(branch)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
 }
 
 type parallelRequest struct {
 	reqs []Request
 }
 
+func Parallel(reqs ...Request) Request {
+	return &parallelRequest{reqs: reqs}
+}
+
 func (r *parallelRequest) Solve(ctx context.Context, cln *client.Client, mw *progress.MultiWriter) error {
 	g, ctx := errgroup.WithContext(ctx)
 	for _, req := range r.reqs {
@@ -110,21 +138,25 @@ func (r *parallelRequest) Solve(ctx context.Context, cln *client.Client, mw *pro
 	return g.Wait()
 }
 
-func (r *parallelRequest) Next(n Request) Request {
-	return &sequentialRequest{
-		reqs: []Request{r, n},
+func (r *parallelRequest) Tree(tree treeprint.Tree) error {
+	branch := tree.AddBranch("parallel")
+	for _, req := range r.reqs {
+		err := req.Tree(branch)
+		if err != nil {
+			return err
+		}
 	}
-}
-
-func (r *parallelRequest) Peer(p Request) Request {
-	r.reqs = append(r.reqs, p)
-	return r
+	return nil
 }
 
 type sequentialRequest struct {
 	reqs []Request
 }
 
+func Sequential(reqs ...Request) Request {
+	return &sequentialRequest{reqs: reqs}
+}
+
 func (r *sequentialRequest) Solve(ctx context.Context, cln *client.Client, mw *progress.MultiWriter) error {
 	for _, req := range r.reqs {
 		err := req.Solve(ctx, cln, mw)
@@ -135,13 +167,13 @@ func (r *sequentialRequest) Solve(ctx context.Context, cln *client.Client, mw *p
 	return nil
 }
 
-func (r *sequentialRequest) Next(n Request) Request {
-	r.reqs = append(r.reqs, n)
-	return r
-}
-
-func (r *sequentialRequest) Peer(p Request) Request {
-	return &parallelRequest{
-		reqs: []Request{r, p},
+func (r *sequentialRequest) Tree(tree treeprint.Tree) error {
+	branch := tree.AddBranch("sequential")
+	for _, req := range r.reqs {
+		err := req.Tree(branch)
+		if err != nil {
+			return err
+		}
 	}
+	return nil
 }
diff --git a/solver/solve.go b/solver/solve.go
index dbc079f..000e776 100644
--- a/solver/solve.go
+++ b/solver/solve.go
@@ -22,7 +22,7 @@ type SolveInfo struct {
 	OutputLocal           string
 	OutputLocalTarball    bool
 	OutputLocalOCITarball bool
-	Callbacks             []func() error
+	Callbacks             []func() error `json:"-"`
 	ImageSpec             *specs.Image
 }
 


Test patch:
diff --git a/codegen/codegen_test.go b/codegen/codegen_test.go
new file mode 100644
index 0000000..e37aca2
--- /dev/null
+++ b/codegen/codegen_test.go
@@ -0,0 +1,154 @@
+package codegen
+
+import (
+	"context"
+	"strings"
+	"testing"
+
+	"github.com/moby/buildkit/client/llb"
+	"github.com/openllb/hlb/checker"
+	"github.com/openllb/hlb/parser"
+	"github.com/openllb/hlb/solver"
+	"github.com/stretchr/testify/require"
+	"github.com/xlab/treeprint"
+)
+
+func Expect(t *testing.T, st llb.State) solver.Request {
+	def, err := st.Marshal(context.Background(), llb.LinuxAmd64)
+	require.NoError(t, err)
+
+	return solver.Single(&solver.Params{
+		Def: def,
+	})
+}
+
+type testCase struct {
+	name    string
+	targets []string
+	input   string
+	fn      func(t *testing.T, cg *CodeGen) solver.Request
+}
+
+func cleanup(value string) string {
+	result := strings.TrimSpace(value)
+	result = strings.ReplaceAll(result, strings.Repeat("\t", 3), "")
+	result = strings.ReplaceAll(result, "|\n", "| \n")
+	return result
+}
+
+func TestCodeGen(t *testing.T) {
+	for _, tc := range []testCase{{
+		"image",
+		[]string{"default"},
+		`
+		fs default() {
+			image "alpine"
+		}
+		`,
+		func(t *testing.T, cg *CodeGen) solver.Request {
+			return solver.Parallel(
+				Expect(t, llb.Image("alpine")),
+			)
+		},
+	}, {
+		"call function",
+		[]string{"default"},
+		`
+		fs default() {
+			foo "busybox"
+		}
+
+		fs foo(string ref) {
+			image ref
+		}
+		`,
+		func(t *testing.T, cg *CodeGen) solver.Request {
+			return solver.Parallel(
+				Expect(t, llb.Image("busybox")),
+			)
+		},
+	}, {
+		"local",
+		[]string{"default"},
+		`
+		fs default() {
+			local "."
+		}
+		`,
+		func(t *testing.T, cg *CodeGen) solver.Request {
+			id, err := cg.LocalID(".")
+			require.NoError(t, err)
+
+			return solver.Parallel(
+				Expect(t, llb.Local(id, llb.SessionID(cg.SessionID()))),
+			)
+		},
+		// }, {
+		// 	"sequential group",
+		// 	[]string{"default"},
+		// 	`
+		// 	group default() {
+		// 		image "alpine"
+		// 		image "busybox"
+		// 	}
+		// 	`,
+		// 	func(t *testing.T, cg *CodeGen) solver.Request {
+		// 		return solver.Sequential(
+		// 			Expect(t, llb.Image("alpine")),
+		// 			Expect(t, llb.Image("busybox")),
+		// 		)
+		// 	},
+		// }, {
+		// 	"parallel group",
+		// 	[]string{"default"},
+		// 	`
+		// 	group default() {
+		// 		parallel group {
+		// 			image "alpine"
+		// 		} group {
+		// 			image "busybox"
+		// 		}
+		// 	}
+		// 	`,
+		// 	func(t *testing.T, cg *CodeGen) solver.Request {
+		// 		return solver.Parallel(
+		// 			Expect(t, llb.Image("alpine")),
+		// 			Expect(t, llb.Image("busybox")),
+		// 		)
+		// 	},
+	}} {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			cg, err := New()
+			require.NoError(t, err)
+
+			mod, err := parser.Parse(strings.NewReader(cleanup(tc.input)))
+			require.NoError(t, err)
+
+			err = checker.Check(mod)
+			require.NoError(t, err)
+
+			var targets []Target
+			for _, target := range tc.targets {
+				targets = append(targets, Target{Name: target})
+			}
+
+			request, err := cg.Generate(context.Background(), mod, targets)
+			require.NoError(t, err)
+
+			testRequest := tc.fn(t, cg)
+
+			expected := treeprint.New()
+			testRequest.Tree(expected)
+
+			actual := treeprint.New()
+			request.Tree(actual)
+			t.Log(actual.String())
+
+			// Compare trees.
+			require.Equal(t, expected.String(), actual.String())
+		})
+	}
+}