Files
buildx/tests/dap_build.go
Jonathan A. Sternberg a8d359a9ca dap: make dap generally available
Removes the experimental flags and bits for dap and deletes some dead
code that somehow made its way this far without anyone noticing.

Signed-off-by: Jonathan A. Sternberg <jonathan.sternberg@docker.com>
2026-03-23 10:43:27 -05:00

1271 lines
31 KiB
Go

package tests
import (
"encoding/json"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"slices"
"strings"
"syscall"
"testing"
"time"
"github.com/containerd/continuity/fs/fstest"
"github.com/docker/buildx/commands"
debug "github.com/docker/buildx/dap"
"github.com/docker/buildx/dap/common"
"github.com/docker/buildx/util/daptest"
"github.com/google/go-dap"
"github.com/moby/buildkit/util/testutil/integration"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func dapBuildCmd(t *testing.T, sb integration.Sandbox, opts ...cmdOpt) (*daptest.Client, func(interrupt bool) error, error) {
opts = append([]cmdOpt{withArgs("dap", "build")}, opts...)
cmd := buildxCmd(sb, opts...)
pr, err := cmd.StdinPipe()
require.NoError(t, err)
pw, err := cmd.StdoutPipe()
require.NoError(t, err)
if err := cmd.Start(); err != nil {
return nil, nil, err
}
conn := daptest.LogConn(t, "client", debug.NewConn(pw, pr))
client := daptest.NewClient(conn)
done := func(interrupt bool) error {
defer client.Close()
if interrupt {
t.Log("sending interrupt")
signal := os.Interrupt
if runtime.GOOS == "windows" {
// Interrupt on windows is not implemented.
signal = syscall.SIGTERM
}
cmd.Process.Signal(signal)
}
// Attempt to wait for the process first. In general, we want
// the process to exit normally.
//
// If too much time passes when waiting, kill the command.
timer := time.AfterFunc(10*time.Second, func() {
t.Logf("killing process %v", cmd.Process.Pid)
cmd.Process.Kill()
})
defer timer.Stop()
t.Log("waiting for process to finish")
defer t.Log("process exited")
return cmd.Wait()
}
return client, done, nil
}
var dapBuildTests = []func(t *testing.T, sb integration.Sandbox){
testDapBuild,
testDapBuildStopOnEntry,
testDapBuildSetBreakpoints,
testDapBuildEntryBreakpoint,
testDapBuildVerifiedBreakpoints,
testDapBuildLoadedSource,
testDapBuildStepIn,
testDapBuildStepNext,
testDapBuildStepOut,
testDapBuildVariables,
testDapBuildDeferredEval,
testDapBuildExitedEvent,
}
func testDapBuild(t *testing.T, sb integration.Sandbox) {
dir := createTestProject(t)
client, done, err := dapBuildCmd(t, sb)
require.NoError(t, err)
interruptCh := pollInterruptEvents(client)
doLaunch(t, client, commands.LaunchConfig{
Dockerfile: path.Join(dir, "Dockerfile"),
ContextPath: dir,
})
select {
case <-time.After(10 * time.Second):
require.Fail(t, "timeout reached")
case em := <-interruptCh:
require.Equal(t, "terminated", em.GetEvent().Event)
}
require.NoError(t, done(false))
}
func testDapBuildStopOnEntry(t *testing.T, sb integration.Sandbox) {
dir := createTestProject(t)
client, done, err := dapBuildCmd(t, sb, withArgs(dir))
require.NoError(t, err)
interruptCh := pollInterruptEvents(client)
doLaunch(t, client, commands.LaunchConfig{
Dockerfile: path.Join(dir, "Dockerfile"),
ContextPath: dir,
Config: common.Config{
StopOnEntry: true,
},
})
stopped := waitForInterrupt[*dap.StoppedEvent](t, interruptCh)
require.Equal(t, "step", stopped.Body.Reason)
threads := doThreads(t, client)
require.ElementsMatch(t, []int{stopped.Body.ThreadId}, threads)
stackFrames := doStackTrace(t, client, stopped.Body.ThreadId)
assertStackTrace(t, stackFrames, []stackFrameMatcher{
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY`,
},
})
var exitErr *exec.ExitError
require.ErrorAs(t, done(true), &exitErr)
}
func testDapBuildSetBreakpoints(t *testing.T, sb integration.Sandbox) {
dir := createTestProject(t)
client, done, err := dapBuildCmd(t, sb, withArgs(dir))
require.NoError(t, err)
interruptCh := pollInterruptEvents(client)
doLaunch(t, client, commands.LaunchConfig{
Dockerfile: path.Join(dir, "Dockerfile"),
ContextPath: dir,
},
dap.SourceBreakpoint{Line: 2},
dap.SourceBreakpoint{Line: 4},
)
stopped := waitForInterrupt[*dap.StoppedEvent](t, interruptCh)
require.NotNil(t, stopped)
threads := doThreads(t, client)
require.ElementsMatch(t, []int{stopped.Body.ThreadId}, threads)
// Expect 2 stack frames. We should be stopped at line 2 which is reached
// from within the copy.
stackFrames := doStackTrace(t, client, stopped.Body.ThreadId)
assertStackTrace(t, stackFrames, []stackFrameMatcher{
{
SourceName: "Dockerfile",
Line: 2,
Name: `^\[base .*\] FROM`,
},
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY`,
},
})
// Continue should stop at the next breakpoint.
doContinue(t, client, stopped.Body.ThreadId)
stopped = waitForInterrupt[*dap.StoppedEvent](t, interruptCh)
require.NotNil(t, stopped)
threads = doThreads(t, client)
require.ElementsMatch(t, []int{stopped.Body.ThreadId}, threads)
stackFrames = doStackTrace(t, client, stopped.Body.ThreadId)
assertStackTrace(t, stackFrames, []stackFrameMatcher{
{
SourceName: "Dockerfile",
Line: 4,
Name: `^\[base .*\] RUN cp`,
},
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY`,
},
})
// Continue should go until the program ends.
doContinue(t, client, stopped.Body.ThreadId)
require.NoError(t, done(false))
}
// testDapBuildEntryBreakpoint checks that the entrypoint is a valid breakpoint.
func testDapBuildEntryBreakpoint(t *testing.T, sb integration.Sandbox) {
dir := createTestProject(t)
client, done, err := dapBuildCmd(t, sb, withArgs(dir))
require.NoError(t, err)
interruptCh := pollInterruptEvents(client)
doLaunch(t, client, commands.LaunchConfig{
Dockerfile: path.Join(dir, "Dockerfile"),
ContextPath: dir,
},
dap.SourceBreakpoint{Line: 7},
)
stopped := waitForInterrupt[*dap.StoppedEvent](t, interruptCh)
require.Equal(t, "breakpoint", stopped.Body.Reason)
threads := doThreads(t, client)
require.ElementsMatch(t, []int{stopped.Body.ThreadId}, threads)
stackFrames := doStackTrace(t, client, stopped.Body.ThreadId)
assertStackTrace(t, stackFrames, []stackFrameMatcher{
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY`,
},
})
var exitErr *exec.ExitError
require.ErrorAs(t, done(true), &exitErr)
}
func testDapBuildVerifiedBreakpoints(t *testing.T, sb integration.Sandbox) {
dir := createTestProject(t)
client, done, err := dapBuildCmd(t, sb, withArgs(dir))
require.NoError(t, err)
interruptCh := pollInterruptEvents(client)
var actual []dap.BreakpointEventBody
client.RegisterEvent("breakpoint", func(em dap.EventMessage) {
e := em.(*dap.BreakpointEvent)
actual = append(actual, e.Body)
})
doLaunch(t, client, commands.LaunchConfig{
Dockerfile: path.Join(dir, "Dockerfile"),
ContextPath: dir,
},
dap.SourceBreakpoint{Line: 2},
dap.SourceBreakpoint{Line: 10},
)
stopped := waitForInterrupt[*dap.StoppedEvent](t, interruptCh)
require.NotNil(t, stopped)
assert.Equal(t, []dap.BreakpointEventBody{
{
Reason: "changed",
Breakpoint: dap.Breakpoint{
Id: 1,
Line: 2,
EndLine: 2,
Source: &dap.Source{
Name: "Dockerfile",
Path: path.Join(dir, "Dockerfile"),
},
Verified: true,
},
},
{
Reason: "changed",
Breakpoint: dap.Breakpoint{
Id: 2,
Line: 10,
EndLine: 10,
Source: &dap.Source{
Name: "Dockerfile",
Path: path.Join(dir, "Dockerfile"),
},
Verified: false,
Reason: "failed",
},
},
}, actual)
var exitErr *exec.ExitError
require.ErrorAs(t, done(true), &exitErr)
}
func testDapBuildLoadedSource(t *testing.T, sb integration.Sandbox) {
type test struct {
Name string
ContextPath string
DockerfilePath string
}
runTest := func(t *testing.T, tt test, abspath bool) string {
dockerfile := []byte(`
FROM busybox:latest AS base
COPY foo /etc/foo
RUN cp /etc/foo /etc/bar
FROM scratch
COPY --from=base /etc/bar /bar
`)
appliers := []fstest.Applier{}
if tt.ContextPath != "" {
appliers = append(appliers, fstest.CreateDir(tt.ContextPath, 0o700))
}
if tt.DockerfilePath != "" {
appliers = append(appliers, fstest.CreateDir(path.Join(tt.ContextPath, tt.DockerfilePath), 0o700))
}
appliers = append(appliers,
fstest.CreateFile(path.Join(tt.ContextPath, tt.DockerfilePath, "Dockerfile"), dockerfile, 0o600),
fstest.CreateFile(path.Join(tt.ContextPath, "foo"), []byte("foo"), 0o600),
)
dir := tmpdir(t, appliers...)
client, done, err := dapBuildCmd(t, sb, withDir(dir))
require.NoError(t, err)
var source *dap.Source
client.RegisterEvent("loadedSource", func(em dap.EventMessage) {
e := em.(*dap.LoadedSourceEvent)
source = &e.Body.Source
})
launchCfg := commands.LaunchConfig{
Config: common.Config{
StopOnEntry: true,
},
}
if abspath {
launchCfg.ContextPath = path.Join(dir, tt.ContextPath)
} else {
launchCfg.ContextPath = tt.ContextPath
}
launchCfg.Dockerfile = path.Join(launchCfg.ContextPath, tt.DockerfilePath, "Dockerfile")
doLaunch(t, client, launchCfg)
interruptCh := pollInterruptEvents(client)
stopped := waitForInterrupt[*dap.StoppedEvent](t, interruptCh)
require.NotNil(t, stopped)
expected := path.Join(dir, tt.ContextPath, tt.DockerfilePath, "Dockerfile")
require.NotNil(t, source)
require.Equal(t, expected, source.Path)
require.Equal(t, "Dockerfile", source.Name)
var exitErr *exec.ExitError
require.ErrorAs(t, done(true), &exitErr)
return dir
}
for _, tt := range []test{
{
Name: "base path",
ContextPath: ".",
},
{
Name: "nested context",
ContextPath: "nested",
},
{
Name: "nested dockerfile",
ContextPath: ".",
DockerfilePath: "nested",
},
{
Name: "both nested",
ContextPath: "nested",
DockerfilePath: "subdir",
},
} {
t.Run(tt.Name, func(t *testing.T) {
t.Run("absolute paths", func(t *testing.T) {
runTest(t, tt, true)
})
t.Run("relative paths", func(t *testing.T) {
runTest(t, tt, false)
})
})
}
}
func testDapBuildStepIn(t *testing.T, sb integration.Sandbox) {
dir := createTestProject(t)
client, done, err := dapBuildCmd(t, sb, withArgs(dir))
require.NoError(t, err)
interruptCh := pollInterruptEvents(client)
doLaunch(t, client, commands.LaunchConfig{
Dockerfile: path.Join(dir, "Dockerfile"),
ContextPath: dir,
Config: common.Config{
StopOnEntry: true,
},
})
expected := [][]stackFrameMatcher{
// stop point 1
{
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
// stop point 2
{
{
SourceName: "Dockerfile",
Line: 2,
Name: `^\[base .*\] FROM .*/busybox`,
},
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
// stop point 3
{
{
SourceName: "Dockerfile",
Line: 3,
Name: `^\[base .*\] COPY foo`,
},
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
// stop point 4
{
{
SourceName: "Dockerfile",
Line: 4,
Name: `^\[base .*\] RUN cp /etc/foo`,
},
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
// stop point 5
// since we're at the end of a stage, the last stop point
// repeats to allow inspecting the return state.
{
{
SourceName: "Dockerfile",
Line: 4,
Name: `^\[base .*\] RUN cp /etc/foo`,
},
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
// stop point 6
{
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
// stop point 7
// repeat of stop point 5 but after the invocation.
{
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
}
for _, exp := range expected {
stopped := waitForInterrupt[*dap.StoppedEvent](t, interruptCh)
require.NotNil(t, stopped)
threads := doThreads(t, client)
require.ElementsMatch(t, []int{stopped.Body.ThreadId}, threads)
stackFrames := doStackTrace(t, client, stopped.Body.ThreadId)
assertStackTrace(t, stackFrames, exp)
doStepIn(t, client, stopped.Body.ThreadId)
}
require.NoError(t, done(false))
}
func testDapBuildStepNext(t *testing.T, sb integration.Sandbox) {
dir := createTestProject(t)
client, done, err := dapBuildCmd(t, sb, withArgs(dir))
require.NoError(t, err)
interruptCh := pollInterruptEvents(client)
doLaunch(t, client, commands.LaunchConfig{
Dockerfile: path.Join(dir, "Dockerfile"),
ContextPath: dir,
Config: common.Config{
StopOnEntry: true,
},
},
dap.SourceBreakpoint{Line: 3},
)
expected := [][]stackFrameMatcher{
// stop point 1
{
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
// stop point 2
// next would normally skip over base but we have a breakpoint
// on this line and it should not be skipped over.
{
{
SourceName: "Dockerfile",
Line: 3,
Name: `^\[base .*\] COPY foo`,
},
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
// stop point 3
{
{
SourceName: "Dockerfile",
Line: 4,
Name: `^\[base .*\] RUN cp /etc/foo`,
},
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
// stop point 4
// since we're at the end of a stage, the last stop point
// repeats to allow inspecting the return state.
{
{
SourceName: "Dockerfile",
Line: 4,
Name: `^\[base .*\] RUN cp /etc/foo`,
},
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
// stop point 5
{
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
// stop point 6
// repeat of stop point 5 but after the invocation.
{
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
}
for _, exp := range expected {
stopped := waitForInterrupt[*dap.StoppedEvent](t, interruptCh)
require.NotNil(t, stopped)
threads := doThreads(t, client)
require.ElementsMatch(t, []int{stopped.Body.ThreadId}, threads)
stackFrames := doStackTrace(t, client, stopped.Body.ThreadId)
assertStackTrace(t, stackFrames, exp)
doNext(t, client, stopped.Body.ThreadId)
}
require.NoError(t, done(false))
}
func testDapBuildStepOut(t *testing.T, sb integration.Sandbox) {
dir := createTestProject(t)
client, done, err := dapBuildCmd(t, sb, withArgs(dir))
require.NoError(t, err)
interruptCh := pollInterruptEvents(client)
doLaunch(t, client, commands.LaunchConfig{
Dockerfile: path.Join(dir, "Dockerfile"),
ContextPath: dir,
Config: common.Config{
StopOnEntry: true,
},
},
dap.SourceBreakpoint{Line: 3},
)
expected := [][]stackFrameMatcher{
// stop point 1
{
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
// stop point 2
// out would normally skip over base but we have a breakpoint
// on this line and it should not be skipped over.
{
{
SourceName: "Dockerfile",
Line: 3,
Name: `^\[base .*\] COPY foo`,
},
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
// stop point 3
{
{
SourceName: "Dockerfile",
Line: 7,
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
},
},
// stop point 3 should not be repeated unlike the
// previous methods because step out will skip
// the duplicate last step.
}
for _, exp := range expected {
stopped := waitForInterrupt[*dap.StoppedEvent](t, interruptCh)
require.NotNil(t, stopped)
threads := doThreads(t, client)
require.ElementsMatch(t, []int{stopped.Body.ThreadId}, threads)
stackFrames := doStackTrace(t, client, stopped.Body.ThreadId)
assertStackTrace(t, stackFrames, exp)
doStepOut(t, client, stopped.Body.ThreadId)
}
require.NoError(t, done(false))
}
func testDapBuildVariables(t *testing.T, sb integration.Sandbox) {
tests := []struct {
Name string
Breakpoint dap.SourceBreakpoint
Expected []variableScopeMatcher
}{
{
Name: "FROM",
Breakpoint: dap.SourceBreakpoint{Line: 2},
Expected: []variableScopeMatcher{
{
Name: "Arguments",
PresentationHint: "arguments",
Expensive: false,
Variables: variableSetMatcher{
Variables: []variableMatcher{
{
Name: "platform",
Value: `^(.*)/(.*)$`,
Nested: &variableSetMatcher{
Variables: []variableMatcher{
{
Name: "architecture",
Value: "^[^/]*$",
},
{
Name: "os",
Value: "^[^/]*$",
},
},
NonExhaustive: true,
},
},
},
},
},
},
},
{
Name: "COPY",
Breakpoint: dap.SourceBreakpoint{Line: 3},
Expected: []variableScopeMatcher{
{
Name: "Arguments",
PresentationHint: "arguments",
Expensive: false,
Variables: variableSetMatcher{
Variables: []variableMatcher{},
},
},
{
Name: "File Explorer",
PresentationHint: "locals",
Expensive: true,
Variables: variableSetMatcher{
// Do not check the variables in the file explorer since
// the underlying image may change.
NonExhaustive: true,
},
},
},
},
{
Name: "RUN",
Breakpoint: dap.SourceBreakpoint{Line: 4},
Expected: []variableScopeMatcher{
{
Name: "Arguments",
PresentationHint: "arguments",
Expensive: false,
Variables: variableSetMatcher{
Variables: []variableMatcher{
{
Name: "platform",
Value: `^(.*)/(.*)$`,
Nested: &variableSetMatcher{
Variables: []variableMatcher{
{
Name: "architecture",
Value: "^[^/]*$",
},
{
Name: "os",
Value: "^[^/]*$",
},
},
NonExhaustive: true,
},
},
{
Name: "exec",
Value: `/bin/sh -c cp /etc/foo /etc/bar`,
Nested: &variableSetMatcher{
Variables: []variableMatcher{
{
Name: "args",
Value: `/bin/sh -c cp /etc/foo /etc/bar`,
Nested: &variableSetMatcher{
Variables: []variableMatcher{
{
Name: "0",
Value: "/bin/sh",
},
{
Name: "1",
Value: "-c",
},
{
Name: "2",
Value: "cp /etc/foo /etc/bar",
},
},
},
},
{
Name: "env",
Value: `.*`,
Nested: &variableSetMatcher{
Variables: []variableMatcher{
{
Name: "PATH",
Value: `.*`,
},
},
NonExhaustive: true,
},
},
{
Name: "workdir",
Value: "/",
},
},
},
},
},
},
},
{
Name: "File Explorer",
PresentationHint: "locals",
Expensive: true,
Variables: variableSetMatcher{
// Do not check the variables in the file explorer since
// the underlying image may change.
NonExhaustive: true,
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
dir := createTestProject(t)
client, done, err := dapBuildCmd(t, sb)
require.NoError(t, err)
interruptCh := pollInterruptEvents(client)
doLaunch(t, client, commands.LaunchConfig{
Dockerfile: path.Join(dir, "Dockerfile"),
ContextPath: dir,
}, tt.Breakpoint)
stopped := waitForInterrupt[*dap.StoppedEvent](t, interruptCh)
threads := doThreads(t, client)
require.ElementsMatch(t, []int{stopped.Body.ThreadId}, threads)
// Only check the first stack frame.
stackFrames := doStackTrace(t, client, stopped.Body.ThreadId)
require.GreaterOrEqual(t, len(stackFrames), 1)
scopes := doScopes(t, client, stackFrames[0].Id)
assertVariableScopes(t, client, scopes, tt.Expected)
var exitErr *exec.ExitError
require.ErrorAs(t, done(true), &exitErr)
})
}
}
func testDapBuildDeferredEval(t *testing.T, sb integration.Sandbox) {
dir := createTestProject(t)
client, done, err := dapBuildCmd(t, sb)
require.NoError(t, err)
// Track when we see this message.
seen := make(chan struct{}, 1)
client.RegisterEvent("output", func(em dap.EventMessage) {
e := em.(*dap.OutputEvent)
if strings.Contains(e.Body.Output, "RUN cp /etc/foo /etc/bar") {
select {
case seen <- struct{}{}:
default:
}
}
})
interruptCh := pollInterruptEvents(client)
doLaunch(t, client, commands.LaunchConfig{
Dockerfile: path.Join(dir, "Dockerfile"),
ContextPath: dir,
},
dap.SourceBreakpoint{Line: 7},
)
stopped := waitForInterrupt[*dap.StoppedEvent](t, interruptCh)
require.NotNil(t, stopped)
// The output event is usually immediate but it can sometimes be delayed due to
// the multithreading in the printer. Just wait for a little bit.
select {
case <-seen:
// We should not have seen this message since the branch this
// message comes from should be deferred because we have
// not passed the breakpoint.
t.Fatal("step has been invoked before intended")
case <-time.After(100 * time.Millisecond):
}
doNext(t, client, stopped.Body.ThreadId)
stopped = waitForInterrupt[*dap.StoppedEvent](t, interruptCh)
require.NotNil(t, stopped)
select {
case <-seen:
// Wait up to a second for the input to be seen.
case <-time.After(time.Second):
t.Fatal("step should have been seen")
}
var exitErr *exec.ExitError
require.ErrorAs(t, done(true), &exitErr)
}
func testDapBuildExitedEvent(t *testing.T, sb integration.Sandbox) {
t.Run("success", func(t *testing.T) {
dir := createTestProject(t)
client, done, err := dapBuildCmd(t, sb)
require.NoError(t, err)
ch := make(chan *dap.ExitedEvent, 1)
client.RegisterEvent("exited", func(em dap.EventMessage) {
ch <- em.(*dap.ExitedEvent)
close(ch)
})
// Project should just build normally.
doLaunch(t, client, commands.LaunchConfig{
Dockerfile: path.Join(dir, "Dockerfile"),
ContextPath: dir,
})
select {
case exited := <-ch:
require.Equal(t, 0, exited.Body.ExitCode)
case <-time.After(5 * time.Second):
require.Fail(t, "timeout reached")
}
require.NoError(t, done(true))
})
t.Run("failure", func(t *testing.T) {
dir := createTestProject(t)
client, done, err := dapBuildCmd(t, sb)
require.NoError(t, err)
ch := make(chan *dap.ExitedEvent, 1)
client.RegisterEvent("exited", func(em dap.EventMessage) {
ch <- em.(*dap.ExitedEvent)
close(ch)
})
// Delete foo from the test project so this will fail.
err = os.Remove(filepath.Join(dir, "foo"))
require.NoError(t, err)
interruptCh := pollInterruptEvents(client)
doLaunch(t, client, commands.LaunchConfig{
Dockerfile: path.Join(dir, "Dockerfile"),
ContextPath: dir,
})
// We will hit an interrupt because of the failure.
stopped := waitForInterrupt[*dap.StoppedEvent](t, interruptCh)
require.Equal(t, "exception", stopped.Body.Reason)
// Continue execution which should trigger the exited event.
doNext(t, client, stopped.Body.ThreadId)
select {
case exited := <-ch:
require.NotEqual(t, 0, exited.Body.ExitCode)
case <-time.After(time.Second):
require.Fail(t, "timeout reached")
}
var exitErr *exec.ExitError
require.ErrorAs(t, done(false), &exitErr)
})
}
func doLaunch(t *testing.T, client *daptest.Client, config commands.LaunchConfig, bps ...dap.SourceBreakpoint) {
t.Helper()
configurationDoneCh := make(chan (<-chan *dap.ConfigurationDoneResponse))
client.RegisterEvent("initialized", func(em dap.EventMessage) {
go func() {
if len(bps) > 0 {
setBreakpointsResp := <-daptest.DoRequest[*dap.SetBreakpointsResponse](t, client, &dap.SetBreakpointsRequest{
Request: dap.Request{Command: "setBreakpoints"},
Arguments: dap.SetBreakpointsArguments{
Source: dap.Source{
Name: path.Base(config.Dockerfile),
Path: config.Dockerfile,
},
Breakpoints: bps,
},
})
assert.True(t, setBreakpointsResp.Success)
}
// Send configuration done since we don't do any configuration.
configurationDoneCh <- daptest.DoRequest[*dap.ConfigurationDoneResponse](t, client, &dap.ConfigurationDoneRequest{
Request: dap.Request{Command: "configurationDone"},
})
}()
})
initializeResp := <-daptest.DoRequest[*dap.InitializeResponse](t, client, &dap.InitializeRequest{
Request: dap.Request{Command: "initialize"},
})
require.True(t, initializeResp.Success)
require.True(t, initializeResp.Body.SupportsConfigurationDoneRequest)
args, err := json.Marshal(config)
require.NoError(t, err)
launchResp := <-daptest.DoRequest[*dap.LaunchResponse](t, client, &dap.LaunchRequest{
Request: dap.Request{Command: "launch"},
Arguments: json.RawMessage(args),
})
require.True(t, launchResp.Success)
var configurationDone <-chan *dap.ConfigurationDoneResponse
select {
case configurationDone = <-configurationDoneCh:
case <-time.After(10 * time.Second):
require.Fail(t, "timeout reached")
}
configurationDoneResp := <-configurationDone
require.True(t, configurationDoneResp.Success)
}
func doStepIn(t *testing.T, client *daptest.Client, threadID int) {
t.Helper()
stepResp := <-daptest.DoRequest[*dap.StepInResponse](t, client, &dap.StepInRequest{
Request: dap.Request{Command: "stepIn"},
Arguments: dap.StepInArguments{
ThreadId: threadID,
},
})
assert.True(t, stepResp.Success)
}
func doNext(t *testing.T, client *daptest.Client, threadID int) {
t.Helper()
stepResp := <-daptest.DoRequest[*dap.NextResponse](t, client, &dap.NextRequest{
Request: dap.Request{Command: "next"},
Arguments: dap.NextArguments{
ThreadId: threadID,
},
})
assert.True(t, stepResp.Success)
}
func doStepOut(t *testing.T, client *daptest.Client, threadID int) {
t.Helper()
stepResp := <-daptest.DoRequest[*dap.StepOutResponse](t, client, &dap.StepOutRequest{
Request: dap.Request{Command: "stepOut"},
Arguments: dap.StepOutArguments{
ThreadId: threadID,
},
})
assert.True(t, stepResp.Success)
}
func doContinue(t *testing.T, client *daptest.Client, threadID int) {
t.Helper()
continueResp := <-daptest.DoRequest[*dap.ContinueResponse](t, client, &dap.ContinueRequest{
Request: dap.Request{Command: "continue"},
Arguments: dap.ContinueArguments{
ThreadId: threadID,
},
})
assert.True(t, continueResp.Success)
}
func doThreads(t *testing.T, client *daptest.Client) []int {
t.Helper()
threadsResp := <-daptest.DoRequest[*dap.ThreadsResponse](t, client, &dap.ThreadsRequest{
Request: dap.Request{Command: "threads"},
})
require.True(t, threadsResp.Success)
ids := make([]int, 0, len(threadsResp.Body.Threads))
for _, thread := range threadsResp.Body.Threads {
ids = append(ids, thread.Id)
}
return ids
}
func doStackTrace(t *testing.T, client *daptest.Client, threadID int) []dap.StackFrame {
t.Helper()
stackTraceResp := <-daptest.DoRequest[*dap.StackTraceResponse](t, client, &dap.StackTraceRequest{
Request: dap.Request{Command: "stackTrace"},
Arguments: dap.StackTraceArguments{
ThreadId: threadID,
},
})
require.True(t, stackTraceResp.Success)
return stackTraceResp.Body.StackFrames
}
func doScopes(t *testing.T, client *daptest.Client, frameID int) []dap.Scope {
t.Helper()
scopesResp := <-daptest.DoRequest[*dap.ScopesResponse](t, client, &dap.ScopesRequest{
Request: dap.Request{Command: "scopes"},
Arguments: dap.ScopesArguments{
FrameId: frameID,
},
})
require.True(t, scopesResp.Success)
return scopesResp.Body.Scopes
}
func doVariables(t *testing.T, client *daptest.Client, referenceID int) []dap.Variable {
t.Helper()
variablesResp := <-daptest.DoRequest[*dap.VariablesResponse](t, client, &dap.VariablesRequest{
Request: dap.Request{Command: "variables"},
Arguments: dap.VariablesArguments{
VariablesReference: referenceID,
},
})
require.True(t, variablesResp.Success)
return variablesResp.Body.Variables
}
func pollInterruptEvents(client *daptest.Client) <-chan dap.EventMessage {
// Extra space in the message queue so unread events don't
// cause the client to hang.
ch := make(chan dap.EventMessage, 10)
client.RegisterEvent("stopped", func(em dap.EventMessage) {
ch <- em
})
client.RegisterEvent("terminated", func(em dap.EventMessage) {
ch <- em
})
return ch
}
func waitForInterrupt[E dap.EventMessage](t *testing.T, interruptCh <-chan dap.EventMessage) (e E) {
t.Helper()
select {
case <-time.After(10 * time.Second):
require.Fail(t, "timeout reached")
case em := <-interruptCh:
require.IsType(t, e, em)
e, _ = em.(E)
}
return e
}
type stackFrameMatcher struct {
SourceName string
Line int
Name any
}
func (m *stackFrameMatcher) AssertMatches(t *testing.T, actual *dap.StackFrame) {
t.Helper()
var actualName string
if actual.Source != nil {
actualName = actual.Source.Name
}
assert.Equal(t, m.Line, actual.Line)
assert.Equal(t, m.SourceName, actualName)
assert.Regexp(t, m.Name, actual.Name)
}
func assertStackTrace(t *testing.T, actual []dap.StackFrame, expected []stackFrameMatcher) {
t.Helper()
if assert.Len(t, actual, len(expected)) {
for i, exp := range expected {
exp.AssertMatches(t, &actual[i])
}
}
}
type variableScopeMatcher struct {
Name string
PresentationHint string
Expensive bool
Variables variableSetMatcher
}
func assertVariableScopes(t *testing.T, client *daptest.Client, actual []dap.Scope, expected []variableScopeMatcher) {
t.Helper()
assert.Len(t, actual, len(expected))
for _, m := range expected {
index := slices.IndexFunc(actual, func(o dap.Scope) bool {
return m.Name == o.Name
})
if assert.GreaterOrEqualf(t, index, 0, "no scope with name %q", m.Name) {
act := &actual[index]
assert.Equal(t, m.PresentationHint, act.PresentationHint)
assert.Equal(t, m.Expensive, act.Expensive)
assertVariableSet(t, client, act.VariablesReference, &m.Variables)
}
}
}
type variableSetMatcher struct {
// Variables covers variables inside this variable set.
// Variables can be in any order.
Variables []variableMatcher
// NonExhaustive defines if this matcher is non-exhaustive.
// A non-exhaustive matcher will just check for the existence
// of the variables listed and won't check if there are extra
// variables.
NonExhaustive bool
}
type variableMatcher struct {
Name string
Value any
Nested *variableSetMatcher
}
func (m *variableSetMatcher) AssertMatches(t *testing.T, client *daptest.Client, actual []dap.Variable) {
t.Helper()
if !m.NonExhaustive {
assert.Len(t, actual, len(m.Variables))
}
for _, v := range m.Variables {
index := slices.IndexFunc(actual, func(o dap.Variable) bool {
return v.Name == o.Name
})
if assert.GreaterOrEqualf(t, index, 0, "no variable with name %q", v.Name) {
act := &actual[index]
assert.Regexp(t, v.Value, act.Value)
assertVariableSet(t, client, act.VariablesReference, v.Nested)
}
}
}
func assertVariableSet(t *testing.T, client *daptest.Client, referenceID int, expected *variableSetMatcher) {
t.Helper()
if expected == nil {
assert.LessOrEqual(t, referenceID, 0)
return
}
if assert.Greater(t, referenceID, 0) {
variables := doVariables(t, client, referenceID)
expected.AssertMatches(t, client, variables)
}
}