Files
buildx/tests/dap_build.go
T
Jonathan A. Sternberg 3e4bd229ab dap: skip the load build context step when it doesn't have an associated source line
Skip the load build context step when it doesn't have an associated
source line. This caused an extra branch to be created in an otherwise
pretty straightforward dockerfile where stepping in on a copy
instruction that used the context would stay on the same line because it
"stepped into" the context loading rather than being treated the same as
step next.

This resulted in some bad and confusing ergonomics with the cursor
position that were a bit confusing and unexpected.

There might be more areas to try and prune but the most common one, a
single branch instruction that doesn't have a location, now gets skipped
which is the exact thing that was generated for loading the context.

Signed-off-by: Jonathan A. Sternberg <jonathan.sternberg@docker.com>
2026-03-10 12:35:13 -05:00

1006 lines
24 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`,
},
},
// 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)
}
}