When a breakpoint fails to be verified, it will switch the reason to "failed". It starts off the reason as "pending". The `reason` field for a breakpoint was added some time after the last release of `go-dap` which has only been updated once in the last year so this uses the `main` branch version which contains the field. Signed-off-by: Jonathan A. Sternberg <jonathan.sternberg@docker.com>
1054 lines
26 KiB
Go
1054 lines
26 KiB
Go
package tests
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"runtime"
|
|
"slices"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"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) {
|
|
if !isExperimental() {
|
|
t.Skip("only testing when experimental is enabled")
|
|
}
|
|
|
|
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,
|
|
testDapBuildVerifiedBreakpoints,
|
|
testDapBuildStepIn,
|
|
testDapBuildStepNext,
|
|
testDapBuildStepOut,
|
|
testDapBuildVariables,
|
|
}
|
|
|
|
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)
|
|
threads := doThreads(t, client)
|
|
require.ElementsMatch(t, []int{stopped.Body.ThreadId}, threads)
|
|
|
|
stackTraceResp := <-daptest.DoRequest[*dap.StackTraceResponse](t, client, &dap.StackTraceRequest{
|
|
Request: dap.Request{Command: "stackTrace"},
|
|
Arguments: dap.StackTraceArguments{
|
|
ThreadId: stopped.Body.ThreadId,
|
|
},
|
|
})
|
|
require.True(t, stackTraceResp.Success)
|
|
require.Len(t, stackTraceResp.Body.StackFrames, 1)
|
|
|
|
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))
|
|
}
|
|
|
|
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,
|
|
Verified: true,
|
|
},
|
|
},
|
|
{
|
|
Reason: "changed",
|
|
Breakpoint: dap.Breakpoint{
|
|
Id: 2,
|
|
Line: 10,
|
|
EndLine: 10,
|
|
Verified: false,
|
|
Reason: "failed",
|
|
},
|
|
},
|
|
}, actual)
|
|
|
|
var exitErr *exec.ExitError
|
|
require.ErrorAs(t, done(true), &exitErr)
|
|
}
|
|
|
|
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`,
|
|
},
|
|
},
|
|
// the following three steps are unintended and are the result
|
|
// of a bug in the debug adapter.
|
|
// see issue https://github.com/docker/buildx/issues/3565
|
|
{
|
|
{
|
|
Name: `^\[internal\] load build context`,
|
|
},
|
|
{
|
|
SourceName: "Dockerfile",
|
|
Line: 3,
|
|
Name: `^\[base .*\] COPY foo`,
|
|
},
|
|
{
|
|
SourceName: "Dockerfile",
|
|
Line: 7,
|
|
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
|
|
},
|
|
},
|
|
// todo: this shouldn't be a stop point.
|
|
{
|
|
{
|
|
Name: `^\[internal\] load build context`,
|
|
},
|
|
{
|
|
SourceName: "Dockerfile",
|
|
Line: 3,
|
|
Name: `^\[base .*\] COPY foo`,
|
|
},
|
|
{
|
|
SourceName: "Dockerfile",
|
|
Line: 7,
|
|
Name: `^\[stage-1 .*\] COPY .* /etc/bar`,
|
|
},
|
|
},
|
|
// duplicate of stop point 3 because of unintended branch
|
|
// associated with the build context copy.
|
|
{
|
|
{
|
|
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 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)
|
|
}
|
|
}
|