Skip to content

Commit 6095425

Browse files
committed
test(remediation): add integration and smoke tests for remy provider [IDE-2052]
- codeaction/remediation_test.go: 7 integration tests for CodeActionsService with fakeRemediationProvider — covers RemediationAgentQuickFix offered for fixable Code issues, nil provider, empty FindingId, non-Code product, HasAIFix=false, resolve invokes provider, does not mutate issue.CodeActions - server/server_test.go: INTEG-005..007 — dynamic folder reachability, didChange onFileChange plumbing, full codeAction/resolve round-trip with mock issue provider and fake remediation provider - server/server_smoke_test.go: SMOKE-001 (SMOKE_SHARD_4) — downloads preview CLI, registers "fix" workflow stub, skips only when no LLM credentials - converter/converter_test.go: FindingId propagation through Code/OSS/Secrets/ IaC converters and RemediationAgentQuickFix kind derivation
1 parent 0e94ef1 commit 6095425

4 files changed

Lines changed: 527 additions & 0 deletions

File tree

application/codeaction/remediation_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,48 @@ func TestResolveCodeAction_RemediationAgent_InvokesProvider(t *testing.T) {
189189
assert.Len(t, resolved.Edit.Changes, 1, "resolved edit must carry the provider's changes")
190190
}
191191

192+
func TestGetCodeActions_RemediationAgent_NonCodeProduct_NoAction(t *testing.T) {
193+
fake := &fakeRemediationProvider{edit: &types.WorkspaceEdit{}}
194+
195+
// OSS issue with a FindingId and HasAIFix — should not get a remediation action.
196+
ossIssue := &snyk.Issue{
197+
FindingId: "finding-oss",
198+
Product: product.ProductOpenSource,
199+
AdditionalData: snyk.OssIssueData{
200+
IsUpgradable: true,
201+
},
202+
}
203+
204+
service, params := setupWithIssueAndProvider(t, ossIssue, fake)
205+
actions := service.GetCodeActions(params)
206+
207+
for _, a := range actions {
208+
assert.NotEqual(t, types.RemediationAgentQuickFix, a.Kind,
209+
"non-Code product must not produce RemediationAgentQuickFix actions")
210+
}
211+
}
212+
213+
func TestGetCodeActions_RemediationAgent_NotAIFixable_NoAction(t *testing.T) {
214+
fake := &fakeRemediationProvider{edit: &types.WorkspaceEdit{}}
215+
216+
// Code issue with HasAIFix=false — provider present but issue is not AI-fixable.
217+
issue := &snyk.Issue{
218+
FindingId: "finding-not-fixable",
219+
Product: product.ProductCode,
220+
AdditionalData: snyk.CodeIssueData{
221+
HasAIFix: false,
222+
},
223+
}
224+
225+
service, params := setupWithIssueAndProvider(t, issue, fake)
226+
actions := service.GetCodeActions(params)
227+
228+
for _, a := range actions {
229+
assert.NotEqual(t, types.RemediationAgentQuickFix, a.Kind,
230+
"non-AI-fixable Code issue must not produce RemediationAgentQuickFix actions")
231+
}
232+
}
233+
192234
func TestGetCodeActions_RemediationAgent_DoesNotMutateIssueCodeActions(t *testing.T) {
193235
fake := &fakeRemediationProvider{edit: &types.WorkspaceEdit{}}
194236
issue := buildFixableIssue("finding-mutate")

application/server/server_smoke_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"bufio"
2121
"encoding/json"
2222
"fmt"
23+
"net/http"
2324
"os"
2425
"os/exec"
2526
"path/filepath"
@@ -52,6 +53,7 @@ import (
5253
"github.com/snyk/snyk-ls/infrastructure/cli/install"
5354
"github.com/snyk/snyk-ls/infrastructure/featureflag"
5455
"github.com/snyk/snyk-ls/internal/folderconfig"
56+
"github.com/snyk/snyk-ls/internal/observability/error_reporting"
5557
"github.com/snyk/snyk-ls/internal/product"
5658
"github.com/snyk/snyk-ls/internal/testsupport"
5759
"github.com/snyk/snyk-ls/internal/testutil"
@@ -2474,3 +2476,138 @@ func gitCommandForMonorepoBenchmark(dir string, args ...string) *exec.Cmd {
24742476
cmd.Env = testsupport.GitEnvWithoutInheritedRepoConfig(os.Environ())
24752477
return cmd
24762478
}
2479+
2480+
// substituteRemyFlow registers a stub "fix" workflow on the engine that shells
2481+
// out to the preview CLI binary (`snyk fix --agentic --experimental
2482+
// --auto-approve`). This mirrors the substituteDepGraphFlow pattern: the
2483+
// preview CLI always bundles the remy extension, so the stub makes the
2484+
// workflow available without importing the private remy-cli-extension module.
2485+
//
2486+
// When the engine is not already on the preview channel, a preview CLI is
2487+
// downloaded into a test-scoped temp dir. The engine's SettingCliPath is
2488+
// restored to its original value after download so that scans continue to use
2489+
// whichever CLI was already installed.
2490+
func substituteRemyFlow(t *testing.T, engine workflow.Engine) {
2491+
t.Helper()
2492+
conf := engine.GetConfiguration()
2493+
2494+
// Always download the preview CLI into a test-scoped temp dir.
2495+
// getDistributionChannel reads engine.GetRuntimeInfo().GetVersion(), not config,
2496+
// so channel cannot be overridden via conf.Set — call GetLatestReleaseByChannel directly.
2497+
origCLIPath := types.GetGlobalString(conf, types.SettingCliPath)
2498+
previewDir := t.TempDir()
2499+
discovery := &install.Discovery{}
2500+
previewCLIPath := filepath.Join(previewDir, discovery.ExecutableName(false))
2501+
2502+
// Point SettingCliPath at the preview dir so the downloader writes there.
2503+
conf.Set(configresolver.UserGlobalKey(types.SettingCliPath), previewCLIPath)
2504+
er := error_reporting.NewTestErrorReporter(engine)
2505+
resolver := testutil.DefaultConfigResolver(engine)
2506+
cliRelease := install.NewCLIRelease(engine, func() *http.Client { return http.DefaultClient })
2507+
release, err := cliRelease.GetLatestReleaseByChannel("preview", false)
2508+
require.NoError(t, err, "failed to fetch preview CLI release metadata")
2509+
downloader := install.NewDownloader(engine, er, func() *http.Client { return http.DefaultClient }, resolver)
2510+
require.NoError(t, downloader.Download(release, false), "failed to download preview CLI binary")
2511+
2512+
// Restore the original CLI path so scans continue to use the already-installed stable CLI.
2513+
conf.Set(configresolver.UserGlobalKey(types.SettingCliPath), origCLIPath)
2514+
2515+
remyWorkflowID := workflow.NewWorkflowIdentifier("fix")
2516+
flagset := workflow.ConfigurationOptionsFromFlagset(pflag.NewFlagSet("", pflag.ContinueOnError))
2517+
callback := func(invocation workflow.InvocationContext, _ []workflow.Data) ([]workflow.Data, error) {
2518+
dirs := invocation.GetConfiguration().GetStringSlice(configuration.INPUT_DIRECTORY)
2519+
if len(dirs) == 0 {
2520+
return nil, fmt.Errorf("remy substitute: INPUT_DIRECTORY not set")
2521+
}
2522+
contentRoot := dirs[0]
2523+
cmd := exec.CommandContext(t.Context(), previewCLIPath,
2524+
"fix", contentRoot,
2525+
"--agentic", "--experimental", "--auto-approve",
2526+
)
2527+
cmd.Dir = contentRoot
2528+
cmd.Env = os.Environ()
2529+
if out, err := cmd.CombinedOutput(); err != nil {
2530+
return nil, fmt.Errorf("remy substitute: snyk fix --agentic failed: %w\n%s", err, out)
2531+
}
2532+
return nil, nil
2533+
}
2534+
_, regErr := engine.Register(remyWorkflowID, flagset, callback)
2535+
require.NoError(t, regErr)
2536+
}
2537+
2538+
// Test_Smoke_RemediationAgent_CodeAction verifies the full LSP code-action
2539+
// roundtrip for the quickfix.snyk.remediationAgent action (SMOKE-001).
2540+
//
2541+
// It clones a real repo, waits for Snyk Code scan, finds a fixable issue,
2542+
// lists code actions to confirm the Remy action has the correct protocol shape
2543+
// (deferred, kind set, UUID present), resolves it via codeAction/resolve, and
2544+
// asserts the resolved WorkspaceEdit is non-nil with at least one file change.
2545+
//
2546+
// Skips only when LLM credentials are absent — set ANTHROPIC_API_KEY or
2547+
// OPENAI_API_KEY. The preview CLI always bundles the remy extension, so
2548+
// credential absence is the only legitimate reason to skip.
2549+
func Test_Smoke_RemediationAgent_CodeAction(t *testing.T) {
2550+
if os.Getenv("ANTHROPIC_API_KEY") == "" && os.Getenv("OPENAI_API_KEY") == "" {
2551+
t.Skip("skipping Remy smoke test: LLM credentials not available — set ANTHROPIC_API_KEY or OPENAI_API_KEY")
2552+
}
2553+
2554+
engine, tokenService := testutil.SmokeTestWithEngine(t, "", "SMOKE_SHARD_4")
2555+
engine.GetConfiguration().Set("remediation_agent_enabled", true)
2556+
testutil.CreateDummyProgressListener(t)
2557+
2558+
repoTempDir := types.FilePath(testutil.TempDirWithRetry(t))
2559+
loc, jsonRPCRecorder, _ := setupServer(t, engine, tokenService, WithRealDI())
2560+
enableOnlyProducts(t, engine, product.ProductCode)
2561+
2562+
cloneTargetDir := setupRepoAndInitializeInDir(t, repoTempDir, testsupport.NodejsGoof, "0336589", loc, engine, tokenService)
2563+
substituteRemyFlow(t, engine)
2564+
2565+
waitForScan(t, string(cloneTargetDir), engine)
2566+
checkForScanParams(t, jsonRPCRecorder, string(cloneTargetDir), product.ProductCode)
2567+
2568+
issueList := getIssueListFromPublishDiagnosticsNotification(t, jsonRPCRecorder, product.ProductCode, cloneTargetDir)
2569+
require.NotEmpty(t, issueList, "expected at least one Snyk Code issue in scan results")
2570+
2571+
var fixableIssue *types.ScanIssue
2572+
for i := range issueList {
2573+
data, ok := issueList[i].AdditionalData.(map[string]interface{})
2574+
if ok && data["hasAIFix"] == true {
2575+
fixableIssue = &issueList[i]
2576+
break
2577+
}
2578+
}
2579+
if fixableIssue == nil {
2580+
t.Skip("no fixable (hasAIFix=true) Code issue in scan results")
2581+
}
2582+
2583+
// --- list phase: verify protocol shape ---
2584+
response, err := loc.Client.Call(t.Context(), "textDocument/codeAction", sglsp.CodeActionParams{
2585+
TextDocument: sglsp.TextDocumentIdentifier{URI: uri.PathToUri(fixableIssue.FilePath)},
2586+
Range: fixableIssue.Range,
2587+
})
2588+
require.NoError(t, err)
2589+
2590+
var actions []types.LSPCodeAction
2591+
require.NoError(t, response.UnmarshalResult(&actions))
2592+
2593+
var remyAction *types.LSPCodeAction
2594+
for i := range actions {
2595+
if actions[i].Kind == types.RemediationAgentQuickFix {
2596+
remyAction = &actions[i]
2597+
break
2598+
}
2599+
}
2600+
require.NotNil(t, remyAction, "expected code action with kind %q", types.RemediationAgentQuickFix)
2601+
assert.Nil(t, remyAction.Edit, "Remy action must carry no edit at list time (deferred)")
2602+
assert.NotNil(t, remyAction.Data, "Remy action must carry a resolve UUID in Data")
2603+
2604+
// --- resolve phase: assert real edit returned ---
2605+
resolveResponse, err := loc.Client.Call(t.Context(), "codeAction/resolve", remyAction)
2606+
require.NoError(t, err)
2607+
2608+
var resolved types.LSPCodeAction
2609+
require.NoError(t, resolveResponse.UnmarshalResult(&resolved))
2610+
assert.Equal(t, types.RemediationAgentQuickFix, resolved.Kind, "resolved action must preserve kind")
2611+
require.NotNil(t, resolved.Edit, "resolved edit must be non-nil when LLM credentials are present")
2612+
assert.NotEmpty(t, resolved.Edit.Changes, "resolved edit must contain at least one file change")
2613+
}

application/server/server_test.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,17 @@ import (
4242
"github.com/snyk/go-application-framework/pkg/runtimeinfo"
4343
"github.com/snyk/go-application-framework/pkg/workflow"
4444

45+
"github.com/snyk/snyk-ls/application/codeaction"
4546
"github.com/snyk/snyk-ls/application/config"
4647
"github.com/snyk/snyk-ls/application/di"
48+
"github.com/snyk/snyk-ls/application/watcher"
4749
mock_command "github.com/snyk/snyk-ls/domain/ide/command/mock"
4850
"github.com/snyk/snyk-ls/domain/ide/converter"
4951
"github.com/snyk/snyk-ls/domain/ide/hover"
5052
"github.com/snyk/snyk-ls/domain/ide/workspace"
5153
"github.com/snyk/snyk-ls/domain/snyk"
54+
"github.com/snyk/snyk-ls/domain/snyk/mock_snyk"
55+
"github.com/snyk/snyk-ls/domain/snyk/remediation"
5256
"github.com/snyk/snyk-ls/domain/snyk/scanner"
5357
"github.com/snyk/snyk-ls/infrastructure/authentication"
5458
"github.com/snyk/snyk-ls/infrastructure/cli"
@@ -1822,3 +1826,173 @@ func TestInitializeHandler_MissingDep_PropagatesLSPError(t *testing.T) {
18221826
})
18231827
}
18241828
}
1829+
1830+
// Test_textDocumentDidChange_WithRemediationEnabled_NoRPCError verifies that a
1831+
// textDocument/didChange notification reaches the onFileChange callback and
1832+
// returns no RPC error when the remediation agent is enabled (INTEG-006).
1833+
func Test_textDocumentDidChange_WithRemediationEnabled_NoRPCError(t *testing.T) {
1834+
engine, tokenService := testutil.UnitTestWithEngine(t)
1835+
engine.GetConfiguration().Set("remediation_agent_enabled", true)
1836+
loc, _, _ := setupServer(t, engine, tokenService)
1837+
testutil.CreateDummyProgressListener(t)
1838+
1839+
dir := t.TempDir()
1840+
file := testsupport.CreateTempFile(t, dir)
1841+
fileURI := uri.PathToUri(types.FilePath(file.Name()))
1842+
folderURI := uri.PathToUri(types.FilePath(dir))
1843+
1844+
// Add the folder so the file is inside a known workspace folder.
1845+
_, err := loc.Client.Call(t.Context(), "workspace/didChangeWorkspaceFolders", types.DidChangeWorkspaceFoldersParams{
1846+
Event: types.WorkspaceFoldersChangeEvent{
1847+
Added: []types.WorkspaceFolder{{Name: dir, Uri: folderURI}},
1848+
},
1849+
})
1850+
require.NoError(t, err)
1851+
1852+
// didChange must reach onFileChange and return no RPC error.
1853+
_, err = loc.Client.Call(t.Context(), "textDocument/didChange", sglsp.DidChangeTextDocumentParams{
1854+
TextDocument: sglsp.VersionedTextDocumentIdentifier{TextDocumentIdentifier: sglsp.TextDocumentIdentifier{URI: fileURI}, Version: 1},
1855+
ContentChanges: []sglsp.TextDocumentContentChangeEvent{{Text: "package main\n\nfunc main() {}\n"}},
1856+
})
1857+
require.NoError(t, err, "textDocument/didChange must not return an RPC error")
1858+
}
1859+
1860+
// Test_workspaceDidChangeWorkspaceFolders_RemediationAction_WorksInDynamicFolder verifies that a
1861+
// textDocument/codeAction request against a file in a folder added via workspace/didChangeWorkspaceFolders
1862+
// succeeds without error (INTEG-005: worktree-root code-action reachability).
1863+
func Test_workspaceDidChangeWorkspaceFolders_RemediationAction_WorksInDynamicFolder(t *testing.T) {
1864+
engine, tokenService := testutil.UnitTestWithEngine(t)
1865+
// Enable the remediation-agent flag so the provider is wired in by TestInit path.
1866+
engine.GetConfiguration().Set("remediation_agent_enabled", true)
1867+
loc, _, _ := setupServer(t, engine, tokenService)
1868+
testutil.CreateDummyProgressListener(t)
1869+
1870+
dir := t.TempDir()
1871+
file := testsupport.CreateTempFile(t, dir)
1872+
filePath := types.FilePath(file.Name())
1873+
folderUri := uri.PathToUri(types.FilePath(dir))
1874+
1875+
// Add the folder dynamically, mirroring what ambient-canary does for a worktree root.
1876+
_, err := loc.Client.Call(t.Context(), "workspace/didChangeWorkspaceFolders", types.DidChangeWorkspaceFoldersParams{
1877+
Event: types.WorkspaceFoldersChangeEvent{
1878+
Added: []types.WorkspaceFolder{{Name: dir, Uri: folderUri}},
1879+
},
1880+
})
1881+
require.NoError(t, err)
1882+
1883+
// textDocument/codeAction must return a valid (possibly empty) response — no RPC error.
1884+
params := types.CodeActionParams{
1885+
TextDocument: sglsp.TextDocumentIdentifier{URI: uri.PathToUri(filePath)},
1886+
Range: sglsp.Range{Start: sglsp.Position{Line: 0, Character: 0}, End: sglsp.Position{Line: 0, Character: 1}},
1887+
}
1888+
resp, err := loc.Client.Call(t.Context(), "textDocument/codeAction", params)
1889+
require.NoError(t, err, "codeAction must not return an RPC error for a dynamically-added folder")
1890+
1891+
var actions []types.LSPCodeAction
1892+
require.NoError(t, resp.UnmarshalResult(&actions))
1893+
// No assertion on count: the file is empty, so no issues and no actions.
1894+
// The test's value is that the call succeeds rather than returning a "folder not found" error.
1895+
}
1896+
1897+
// serverTestRemediationProvider is a test double for remediation.RemediationProvider
1898+
// used in server-level integration tests.
1899+
type serverTestRemediationProvider struct {
1900+
edit *types.WorkspaceEdit
1901+
}
1902+
1903+
func (f *serverTestRemediationProvider) Remediate(_ context.Context, _ remediation.RemediationRequest) (*types.WorkspaceEdit, error) {
1904+
return f.edit, nil
1905+
}
1906+
1907+
// Test_codeActionResolve_RemediationAgent_ReturnsEdit verifies the full LSP
1908+
// round-trip for a RemediationAgentQuickFix code action:
1909+
// 1. textDocument/codeAction returns an action with kind=RemediationAgentQuickFix,
1910+
// nil edit, and a UUID in Data.
1911+
// 2. codeAction/resolve calls through to the remediation provider and returns
1912+
// the WorkspaceEdit (INTEG-007).
1913+
func Test_codeActionResolve_RemediationAgent_ReturnsEdit(t *testing.T) {
1914+
engine, tokenService := testutil.UnitTestWithEngine(t)
1915+
testutil.CreateDummyProgressListener(t)
1916+
1917+
dir := t.TempDir()
1918+
file := testsupport.CreateTempFile(t, dir)
1919+
filePath := types.FilePath(file.Name())
1920+
fileURI := uri.PathToUri(filePath)
1921+
folderURI := uri.PathToUri(types.FilePath(dir))
1922+
1923+
// Fixable Code issue with FindingId — the one that triggers RemediationAgentQuickFix.
1924+
fixableIssue := &snyk.Issue{
1925+
FindingId: "finding-integ-resolve",
1926+
Product: product.ProductCode,
1927+
AdditionalData: snyk.CodeIssueData{HasAIFix: true},
1928+
}
1929+
1930+
// Mock issue provider returns the fixable issue for any range query.
1931+
ctrl := gomock.NewController(t)
1932+
issueProvider := mock_snyk.NewMockIssueProvider(ctrl)
1933+
issueProvider.EXPECT().IssuesForRange(gomock.Any(), gomock.Any()).Return([]types.Issue{fixableIssue}).AnyTimes()
1934+
1935+
// Fake remediation provider returns a pre-built WorkspaceEdit.
1936+
mockEdit := &types.WorkspaceEdit{
1937+
Changes: map[string][]types.TextEdit{
1938+
string(filePath): {{NewText: "// fixed by remy\n"}},
1939+
},
1940+
}
1941+
fakeProvider := &serverTestRemediationProvider{edit: mockEdit}
1942+
1943+
// Build a custom CodeActionsService wired with the mocks.
1944+
fw := watcher.NewFileWatcher()
1945+
customCAService := codeaction.NewService(
1946+
engine, issueProvider, fw,
1947+
notification.NewMockNotifier(),
1948+
featureflag.NewFakeService(),
1949+
types.NewConfigResolver(engine.GetLogger()),
1950+
fakeProvider,
1951+
)
1952+
1953+
// Start server with the custom CodeActionsService injected via deps override.
1954+
baseDeps := di.TestInit(t, engine, tokenService, nil)
1955+
baseDeps.CodeActionService = customCAService
1956+
jsonRPCRecorder := &testsupport.JsonRPCRecorder{}
1957+
loc := startServer(engine, tokenService, nil, jsonRPCRecorder, baseDeps)
1958+
t.Cleanup(func() { _ = loc.Close() })
1959+
1960+
// Register the workspace folder so GetFolderContaining finds the file.
1961+
_, err := loc.Client.Call(t.Context(), "workspace/didChangeWorkspaceFolders", types.DidChangeWorkspaceFoldersParams{
1962+
Event: types.WorkspaceFoldersChangeEvent{
1963+
Added: []types.WorkspaceFolder{{Name: dir, Uri: folderURI}},
1964+
},
1965+
})
1966+
require.NoError(t, err)
1967+
1968+
// List code actions for the file range.
1969+
caParams := types.CodeActionParams{
1970+
TextDocument: sglsp.TextDocumentIdentifier{URI: fileURI},
1971+
Range: sglsp.Range{Start: sglsp.Position{Line: 0, Character: 0}, End: sglsp.Position{Line: 0, Character: 1}},
1972+
}
1973+
caResp, err := loc.Client.Call(t.Context(), "textDocument/codeAction", caParams)
1974+
require.NoError(t, err)
1975+
1976+
var listedActions []types.LSPCodeAction
1977+
require.NoError(t, caResp.UnmarshalResult(&listedActions))
1978+
1979+
var remyAction *types.LSPCodeAction
1980+
for i := range listedActions {
1981+
if listedActions[i].Kind == types.RemediationAgentQuickFix {
1982+
remyAction = &listedActions[i]
1983+
break
1984+
}
1985+
}
1986+
require.NotNil(t, remyAction, "expected a RemediationAgentQuickFix action in the list response")
1987+
assert.Nil(t, remyAction.Edit, "edit must be nil at list time (deferred)")
1988+
require.NotNil(t, remyAction.Data, "Data must carry the deferred-resolve UUID")
1989+
1990+
// Resolve the code action through the full LSP stack.
1991+
resolveResp, err := loc.Client.Call(t.Context(), "codeAction/resolve", *remyAction)
1992+
require.NoError(t, err)
1993+
1994+
var resolved types.LSPCodeAction
1995+
require.NoError(t, resolveResp.UnmarshalResult(&resolved))
1996+
require.NotNil(t, resolved.Edit, "resolved action must carry the WorkspaceEdit from the provider")
1997+
assert.NotEmpty(t, resolved.Edit.Changes, "WorkspaceEdit must have at least one change")
1998+
}

0 commit comments

Comments
 (0)