mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
9fc12afdaa
PowerShell's echo aliases to Write-Output, which rejects -e as an ambiguous parameter and exits 1. Use plain echo with spaces instead. Remove the Windows t.Skip and TestMain exception. TestMain untested-tools check now only fails on full-suite runs. Filtered runs (e.g. -run TestTools) warn instead. Closes CODAGT-518
2715 lines
83 KiB
Go
2715 lines
83 KiB
Go
package toolsdk_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/goleak"
|
|
"golang.org/x/xerrors"
|
|
|
|
agentapi "github.com/coder/agentapi-sdk-go"
|
|
"github.com/coder/aisdk-go"
|
|
"github.com/coder/coder/v2/agent"
|
|
"github.com/coder/coder/v2/agent/agenttest"
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbfake"
|
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/agentsdk"
|
|
"github.com/coder/coder/v2/codersdk/toolsdk"
|
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
|
"github.com/coder/coder/v2/provisioner/echo"
|
|
"github.com/coder/coder/v2/provisionersdk/proto"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// setupWorkspaceForAgent creates a workspace setup exactly like main SSH tests
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
func setupWorkspaceForAgent(t *testing.T, opts *coderdtest.Options) (*codersdk.Client, database.WorkspaceTable, string) {
|
|
t.Helper()
|
|
return setupWorkspaceForAgentWithName(t, opts, "myworkspace")
|
|
}
|
|
|
|
// setupWorkspaceForAgentWithName creates a workspace setup exactly like main
|
|
// SSH tests, but with a caller-provided workspace name.
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
func setupWorkspaceForAgentWithName(t *testing.T, opts *coderdtest.Options, workspaceName string) (*codersdk.Client, database.WorkspaceTable, string) {
|
|
t.Helper()
|
|
|
|
client, store := coderdtest.NewWithDatabase(t, opts)
|
|
client.SetLogger(testutil.Logger(t).Named("client"))
|
|
first := coderdtest.CreateFirstUser(t, client)
|
|
userClient, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
|
|
r.Username = "myuser"
|
|
})
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
Name: workspaceName,
|
|
OrganizationID: first.OrganizationID,
|
|
OwnerID: user.ID,
|
|
}).WithAgent().Do()
|
|
|
|
return userClient, r.Workspace, r.AgentToken
|
|
}
|
|
|
|
type recordingAgentConnFunc struct {
|
|
conn workspacesdk.AgentConn
|
|
err error
|
|
agentID uuid.UUID
|
|
calls int
|
|
}
|
|
|
|
func (d *recordingAgentConnFunc) AgentConn(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) {
|
|
d.calls++
|
|
d.agentID = agentID
|
|
if d.err != nil {
|
|
return nil, nil, d.err
|
|
}
|
|
return d.conn, nil, nil
|
|
}
|
|
|
|
// These tests are dependent on the state of the coder server.
|
|
// Running them in parallel is prone to racy behavior.
|
|
// nolint:tparallel,paralleltest
|
|
func TestGenericToolMCPAnnotations(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
toolName string
|
|
readOnlyHint bool
|
|
destructiveHint bool
|
|
idempotentHint bool
|
|
openWorldHint bool
|
|
}{
|
|
{
|
|
name: "ReadOnlyTool",
|
|
toolName: toolsdk.ToolNameGetAuthenticatedUser,
|
|
readOnlyHint: true,
|
|
destructiveHint: false,
|
|
idempotentHint: true,
|
|
openWorldHint: false,
|
|
},
|
|
{
|
|
name: "DestructiveTool",
|
|
toolName: toolsdk.ToolNameWorkspaceWriteFile,
|
|
readOnlyHint: false,
|
|
destructiveHint: true,
|
|
idempotentHint: false,
|
|
openWorldHint: false,
|
|
},
|
|
{
|
|
name: "MutatingTool",
|
|
toolName: toolsdk.ToolNameCreateWorkspace,
|
|
readOnlyHint: false,
|
|
destructiveHint: false,
|
|
idempotentHint: false,
|
|
openWorldHint: false,
|
|
},
|
|
{
|
|
name: "PortForwardIsReadOnly",
|
|
toolName: toolsdk.ToolNameWorkspacePortForward,
|
|
readOnlyHint: true,
|
|
destructiveHint: false,
|
|
idempotentHint: true,
|
|
openWorldHint: false,
|
|
},
|
|
{
|
|
name: "GetTemplateIsReadOnly",
|
|
toolName: toolsdk.ToolNameGetTemplate,
|
|
readOnlyHint: true,
|
|
destructiveHint: false,
|
|
idempotentHint: true,
|
|
openWorldHint: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tc := tt
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var found *toolsdk.GenericTool
|
|
for i := range toolsdk.All {
|
|
if toolsdk.All[i].Name == tc.toolName {
|
|
found = &toolsdk.All[i]
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, found)
|
|
assert.Equal(t, tc.readOnlyHint, found.MCPAnnotations.ReadOnlyHint)
|
|
assert.Equal(t, tc.destructiveHint, found.MCPAnnotations.DestructiveHint)
|
|
assert.Equal(t, tc.idempotentHint, found.MCPAnnotations.IdempotentHint)
|
|
assert.Equal(t, tc.openWorldHint, found.MCPAnnotations.OpenWorldHint)
|
|
})
|
|
}
|
|
}
|
|
|
|
// These tests are dependent on the state of the coder server.
|
|
// Running them in parallel is prone to racy behavior.
|
|
// nolint:tparallel,paralleltest
|
|
func TestTools(t *testing.T) {
|
|
// Given: a running coderd instance using SSH test setup pattern
|
|
setupCtx := testutil.Context(t, testutil.WaitShort)
|
|
client, store := coderdtest.NewWithDatabase(t, nil)
|
|
owner := coderdtest.CreateFirstUser(t, client)
|
|
// Given: a member user with which to test the tools.
|
|
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
|
// Given: a workspace with an agent.
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
|
|
agents[0].Apps = []*proto.App{
|
|
{
|
|
Slug: "some-agent-app",
|
|
},
|
|
}
|
|
return agents
|
|
}).Do()
|
|
preset := dbgen.Preset(t, store, database.InsertPresetParams{
|
|
TemplateVersionID: r.TemplateVersion.ID,
|
|
Name: testutil.GetRandomNameHyphenated(t),
|
|
CreatedAt: r.TemplateVersion.CreatedAt,
|
|
Description: "Preset for agent tool tests.",
|
|
})
|
|
|
|
// Given: a client configured with the agent token.
|
|
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(r.AgentToken))
|
|
// Get the agent ID from the API. Overriding it in dbfake doesn't work.
|
|
ws, err := client.Workspace(setupCtx, r.Workspace.ID)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, ws.LatestBuild.Resources)
|
|
require.NotEmpty(t, ws.LatestBuild.Resources[0].Agents)
|
|
agentID := ws.LatestBuild.Resources[0].Agents[0].ID
|
|
|
|
// Given: the workspace agent has written logs.
|
|
agentClient.PatchLogs(setupCtx, agentsdk.PatchLogs{
|
|
Logs: []agentsdk.Log{
|
|
{
|
|
CreatedAt: time.Now(),
|
|
Level: codersdk.LogLevelInfo,
|
|
Output: "test log message",
|
|
},
|
|
},
|
|
})
|
|
|
|
t.Run("ReportTask", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
tb, err := toolsdk.NewDeps(memberClient, toolsdk.WithTaskReporter(func(args toolsdk.ReportTaskArgs) error {
|
|
return agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{
|
|
AppSlug: "some-agent-app",
|
|
Message: args.Summary,
|
|
URI: args.Link,
|
|
State: codersdk.WorkspaceAppStatusState(args.State),
|
|
})
|
|
}))
|
|
require.NoError(t, err)
|
|
_, err = testTool(t, toolsdk.ReportTask, tb, toolsdk.ReportTaskArgs{
|
|
Summary: "test summary",
|
|
State: "complete",
|
|
Link: "https://example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("GetWorkspace", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
workspace string
|
|
}{
|
|
{
|
|
name: "ByID",
|
|
workspace: r.Workspace.ID.String(),
|
|
},
|
|
{
|
|
name: "ByName",
|
|
workspace: r.Workspace.Name,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result, err := testTool(t, toolsdk.GetWorkspace, tb, toolsdk.GetWorkspaceArgs{
|
|
WorkspaceID: tt.workspace,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, r.Workspace.ID, result.ID, "expected the workspace ID to match")
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("GetWorkspace_ByUUIDLikeName", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Regression test: a workspace whose name is a valid dashless
|
|
// UUID should resolve correctly. Previously, the handler would
|
|
// parse the name as a UUID, get a 404 from the ID-based lookup,
|
|
// and never fall back to name-based lookup.
|
|
const uuidLikeName = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
uuidWorkspace := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
Name: uuidLikeName,
|
|
}).Do()
|
|
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
|
|
result, err := testTool(t, toolsdk.GetWorkspace, tb, toolsdk.GetWorkspaceArgs{
|
|
WorkspaceID: uuidLikeName,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, uuidWorkspace.Workspace.ID, result.ID)
|
|
})
|
|
|
|
t.Run("ListTemplates", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
// Get the templates directly for comparison
|
|
expected, err := memberClient.Templates(context.Background(), codersdk.TemplateFilter{})
|
|
require.NoError(t, err)
|
|
|
|
result, err := testTool(t, toolsdk.ListTemplates, tb, toolsdk.NoArgs{})
|
|
|
|
require.NoError(t, err)
|
|
require.Len(t, result, len(expected))
|
|
|
|
// Sort the results by name to ensure the order is consistent
|
|
sort.Slice(expected, func(a, b int) bool {
|
|
return expected[a].Name < expected[b].Name
|
|
})
|
|
sort.Slice(result, func(a, b int) bool {
|
|
return result[a].Name < result[b].Name
|
|
})
|
|
for i, template := range result {
|
|
require.Equal(t, expected[i].ID.String(), template.ID)
|
|
}
|
|
})
|
|
|
|
t.Run("Whoami", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
result, err := testTool(t, toolsdk.GetAuthenticatedUser, tb, toolsdk.NoArgs{})
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, member.ID, result.ID)
|
|
require.Equal(t, member.Username, result.Username)
|
|
})
|
|
|
|
t.Run("ListWorkspaces", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
result, err := testTool(t, toolsdk.ListWorkspaces, tb, toolsdk.ListWorkspacesArgs{})
|
|
|
|
require.NoError(t, err)
|
|
require.Len(t, result, 1, "expected 1 workspace")
|
|
workspace := result[0]
|
|
require.Equal(t, r.Workspace.ID.String(), workspace.ID, "expected the workspace to match the one we created")
|
|
})
|
|
|
|
t.Run("CreateWorkspaceBuild", func(t *testing.T) {
|
|
t.Run("Stop", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
result, err := testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
|
|
WorkspaceID: r.Workspace.ID.String(),
|
|
Transition: "stop",
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, codersdk.WorkspaceTransitionStop, result.Transition)
|
|
require.Equal(t, r.Workspace.ID, result.WorkspaceID)
|
|
require.Equal(t, r.TemplateVersion.ID, result.TemplateVersionID)
|
|
require.Equal(t, codersdk.WorkspaceTransitionStop, result.Transition)
|
|
|
|
// Important: cancel the build. We don't run any provisioners, so this
|
|
// will remain in the 'pending' state indefinitely.
|
|
require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID, codersdk.CancelWorkspaceBuildParams{}))
|
|
})
|
|
|
|
t.Run("Start_NoAutoBumpAcrossActiveVersionChange", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
// Isolated fixture: move the template's active version
|
|
// forward without changing the workspace's previously built
|
|
// version, so the start request must choose between them.
|
|
noBumpBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
}).Do()
|
|
previousVersionID := noBumpBuild.TemplateVersion.ID
|
|
|
|
newActiveVersion := dbfake.TemplateVersion(t, store).
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
Seed(database.TemplateVersion{
|
|
OrganizationID: owner.OrganizationID,
|
|
CreatedBy: owner.UserID,
|
|
TemplateID: uuid.NullUUID{UUID: noBumpBuild.Template.ID, Valid: true},
|
|
}).Do()
|
|
require.NotEqual(t, previousVersionID, newActiveVersion.TemplateVersion.ID)
|
|
|
|
// Confirm v2 is now the template's active version. Without this the test
|
|
// would silently degrade to a tautology if dbfake.TemplateVersion's
|
|
// promote-by-default behavior ever changed: the contract being locked in
|
|
// is "do not auto-bump to the *currently active* version", which requires
|
|
// v2 to actually be active here.
|
|
template, err := store.GetTemplateByID(dbauthz.AsSystemRestricted(ctx), noBumpBuild.Template.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, newActiveVersion.TemplateVersion.ID, template.ActiveVersionID)
|
|
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
result, err := testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
|
|
WorkspaceID: noBumpBuild.Workspace.ID.String(),
|
|
Transition: "start",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, previousVersionID, result.TemplateVersionID)
|
|
|
|
// Important: cancel the build. We don't run any provisioners, so this
|
|
// will remain in the 'pending' state indefinitely.
|
|
require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID, codersdk.CancelWorkspaceBuildParams{}))
|
|
})
|
|
|
|
t.Run("TemplateVersionChange", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
// Get the current template version ID before updating
|
|
workspace, err := memberClient.Workspace(ctx, r.Workspace.ID)
|
|
require.NoError(t, err)
|
|
originalVersionID := workspace.LatestBuild.TemplateVersionID
|
|
|
|
// Create a new template version to update to
|
|
newVersion := dbfake.TemplateVersion(t, store).
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
Seed(database.TemplateVersion{
|
|
OrganizationID: owner.OrganizationID,
|
|
CreatedBy: owner.UserID,
|
|
TemplateID: uuid.NullUUID{UUID: r.Template.ID, Valid: true},
|
|
}).Do()
|
|
|
|
// Update to new version
|
|
updateBuild, err := testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
|
|
WorkspaceID: r.Workspace.ID.String(),
|
|
Transition: "start",
|
|
TemplateVersionID: newVersion.TemplateVersion.ID.String(),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, codersdk.WorkspaceTransitionStart, updateBuild.Transition)
|
|
require.Equal(t, r.Workspace.ID.String(), updateBuild.WorkspaceID.String())
|
|
require.Equal(t, newVersion.TemplateVersion.ID.String(), updateBuild.TemplateVersionID.String())
|
|
// Cancel the build so it doesn't remain in the 'pending' state indefinitely.
|
|
require.NoError(t, client.CancelWorkspaceBuild(ctx, updateBuild.ID, codersdk.CancelWorkspaceBuildParams{}))
|
|
|
|
// Roll back to the original version
|
|
rollbackBuild, err := testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
|
|
WorkspaceID: r.Workspace.ID.String(),
|
|
Transition: "start",
|
|
TemplateVersionID: originalVersionID.String(),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, codersdk.WorkspaceTransitionStart, rollbackBuild.Transition)
|
|
require.Equal(t, r.Workspace.ID.String(), rollbackBuild.WorkspaceID.String())
|
|
require.Equal(t, originalVersionID.String(), rollbackBuild.TemplateVersionID.String())
|
|
// Cancel the build so it doesn't remain in the 'pending' state indefinitely.
|
|
require.NoError(t, client.CancelWorkspaceBuild(ctx, rollbackBuild.ID, codersdk.CancelWorkspaceBuildParams{}))
|
|
})
|
|
|
|
t.Run("Start_WithPreset", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
|
|
result, err := testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
|
|
WorkspaceID: r.Workspace.ID.String(),
|
|
Transition: "start",
|
|
TemplateVersionPresetID: preset.ID.String(),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, codersdk.WorkspaceTransitionStart, result.Transition)
|
|
require.Equal(t, r.Workspace.ID, result.WorkspaceID)
|
|
require.NotNil(t, result.TemplateVersionPresetID,
|
|
"build must record the preset ID supplied to create_workspace_build")
|
|
require.Equal(t, preset.ID, *result.TemplateVersionPresetID)
|
|
|
|
require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID, codersdk.CancelWorkspaceBuildParams{}))
|
|
})
|
|
|
|
t.Run("Start_WithRichParameters", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
// Isolated fixture: a template version with one rich
|
|
// parameter, so rich_parameters has something to bind
|
|
// to. The shared `r` fixture has no parameters.
|
|
rpBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
}).Do()
|
|
dbgen.TemplateVersionParameter(t, store, database.TemplateVersionParameter{
|
|
TemplateVersionID: rpBuild.TemplateVersion.ID,
|
|
Name: "region",
|
|
Description: "Region to deploy in.",
|
|
Type: "string",
|
|
DefaultValue: "us-east-1",
|
|
Required: false,
|
|
Mutable: true,
|
|
})
|
|
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
result, err := testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
|
|
WorkspaceID: rpBuild.Workspace.ID.String(),
|
|
Transition: "start",
|
|
RichParameters: map[string]string{"region": "us-west-2"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, codersdk.WorkspaceTransitionStart, result.Transition)
|
|
|
|
params, err := memberClient.WorkspaceBuildParameters(ctx, result.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, params, 1)
|
|
require.Equal(t, "region", params[0].Name)
|
|
require.Equal(t, "us-west-2", params[0].Value)
|
|
|
|
require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID, codersdk.CancelWorkspaceBuildParams{}))
|
|
})
|
|
|
|
t.Run("Start_WithPresetAndParams", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
// Isolated fixture: a template version with a parameter
|
|
// and a preset that sets it. Asserts the documented
|
|
// override direction: when preset and rich_parameters
|
|
// conflict, the preset value wins. Mirrors the
|
|
// CreateWorkspace/WithPresetAndParams contract.
|
|
ovBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
}).Do()
|
|
dbgen.TemplateVersionParameter(t, store, database.TemplateVersionParameter{
|
|
TemplateVersionID: ovBuild.TemplateVersion.ID,
|
|
Name: "region",
|
|
Description: "Region to deploy in.",
|
|
Type: "string",
|
|
DefaultValue: "us-east-1",
|
|
Required: false,
|
|
Mutable: true,
|
|
})
|
|
ovPreset := dbgen.Preset(t, store, database.InsertPresetParams{
|
|
TemplateVersionID: ovBuild.TemplateVersion.ID,
|
|
Name: testutil.GetRandomNameHyphenated(t),
|
|
CreatedAt: ovBuild.TemplateVersion.CreatedAt,
|
|
Description: "Preset for build override test.",
|
|
})
|
|
dbgen.PresetParameter(t, store, database.InsertPresetParametersParams{
|
|
TemplateVersionPresetID: ovPreset.ID,
|
|
Names: []string{"region"},
|
|
Values: []string{"us-west-2"},
|
|
})
|
|
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
result, err := testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
|
|
WorkspaceID: ovBuild.Workspace.ID.String(),
|
|
Transition: "start",
|
|
TemplateVersionPresetID: ovPreset.ID.String(),
|
|
RichParameters: map[string]string{"region": "us-east-1"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result.TemplateVersionPresetID)
|
|
require.Equal(t, ovPreset.ID, *result.TemplateVersionPresetID)
|
|
|
|
params, err := memberClient.WorkspaceBuildParameters(ctx, result.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, params, 1)
|
|
require.Equal(t, "region", params[0].Name)
|
|
require.Equal(t, "us-west-2", params[0].Value,
|
|
"preset parameter value must override conflicting rich_parameters entry")
|
|
|
|
require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID, codersdk.CancelWorkspaceBuildParams{}))
|
|
})
|
|
|
|
t.Run("RejectsPresetOnStop", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
_, err = testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
|
|
WorkspaceID: r.Workspace.ID.String(),
|
|
Transition: "stop",
|
|
TemplateVersionPresetID: preset.ID.String(),
|
|
})
|
|
require.ErrorContains(t, err, "template_version_preset_id is only valid for start")
|
|
})
|
|
|
|
t.Run("RejectsParamsOnDelete", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
_, err = testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
|
|
WorkspaceID: r.Workspace.ID.String(),
|
|
Transition: "delete",
|
|
RichParameters: map[string]string{"region": "us-west-2"},
|
|
})
|
|
require.ErrorContains(t, err, "rich_parameters is only valid for start")
|
|
})
|
|
|
|
t.Run("RejectsBothOnStop", func(t *testing.T) {
|
|
// Both fields set on a non-start transition. The
|
|
// handler must surface both violations via errors.Join
|
|
// so agents fix both in one round-trip rather than
|
|
// fix-one, retry, hit-the-next.
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
_, err = testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
|
|
WorkspaceID: r.Workspace.ID.String(),
|
|
Transition: "stop",
|
|
TemplateVersionPresetID: preset.ID.String(),
|
|
RichParameters: map[string]string{"region": "us-west-2"},
|
|
})
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "template_version_preset_id is only valid for start")
|
|
require.ErrorContains(t, err, "rich_parameters is only valid for start")
|
|
})
|
|
|
|
t.Run("InvalidPresetID", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
_, err = testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
|
|
WorkspaceID: r.Workspace.ID.String(),
|
|
Transition: "start",
|
|
TemplateVersionPresetID: "not-a-uuid",
|
|
})
|
|
require.ErrorContains(t, err, "template_version_preset_id must be a valid UUID")
|
|
})
|
|
})
|
|
|
|
t.Run("ListTemplateVersionParameters", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
params, err := testTool(t, toolsdk.ListTemplateVersionParameters, tb, toolsdk.ListTemplateVersionParametersArgs{
|
|
TemplateVersionID: r.TemplateVersion.ID.String(),
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.Empty(t, params)
|
|
})
|
|
|
|
t.Run("GetTemplate", func(t *testing.T) {
|
|
// Build an isolated fixture so the existing fixture's
|
|
// assertions (no parameters, single preset with no
|
|
// preset parameters) stay intact.
|
|
gtBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
}).Do()
|
|
// Add a rich parameter to the active version so
|
|
// `parameters` is non-empty in the response.
|
|
dbgen.TemplateVersionParameter(t, store, database.TemplateVersionParameter{
|
|
TemplateVersionID: gtBuild.TemplateVersion.ID,
|
|
Name: "region",
|
|
DisplayName: "Region",
|
|
Description: "Region to deploy in.",
|
|
Type: "string",
|
|
DefaultValue: "us-east-1",
|
|
Required: false,
|
|
Mutable: true,
|
|
})
|
|
// Attach a preset with one parameter so we can assert
|
|
// PresetParameters round-trip end-to-end.
|
|
const gtPresetDesiredPrebuildInstances = 3
|
|
gtPreset := dbgen.Preset(t, store, database.InsertPresetParams{
|
|
TemplateVersionID: gtBuild.TemplateVersion.ID,
|
|
Name: testutil.GetRandomNameHyphenated(t),
|
|
CreatedAt: gtBuild.TemplateVersion.CreatedAt,
|
|
Description: "Preset for GetTemplate tests.",
|
|
DesiredInstances: sql.NullInt32{
|
|
Int32: gtPresetDesiredPrebuildInstances,
|
|
Valid: true,
|
|
},
|
|
})
|
|
dbgen.PresetParameter(t, store, database.InsertPresetParametersParams{
|
|
TemplateVersionPresetID: gtPreset.ID,
|
|
Names: []string{"region"},
|
|
Values: []string{"us-west-2"},
|
|
})
|
|
|
|
// A second template with no presets, used to assert
|
|
// the omit-when-empty behavior of the `presets` field.
|
|
gtNoPresetBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
}).Do()
|
|
|
|
t.Run("WithPresets", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
|
|
result, err := testTool(t, toolsdk.GetTemplate, tb, toolsdk.GetTemplateArgs{
|
|
TemplateID: gtBuild.Template.ID.String(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// MinimalTemplate fields populated.
|
|
require.Equal(t, gtBuild.Template.ID.String(), result.ID)
|
|
require.Equal(t, gtBuild.Template.Name, result.Name)
|
|
require.Equal(t, gtBuild.Template.ActiveVersionID, result.ActiveVersionID)
|
|
|
|
// Parameters round-trip from the active version.
|
|
require.Len(t, result.Parameters, 1)
|
|
require.Equal(t, "region", result.Parameters[0].Name)
|
|
require.Equal(t, "us-east-1", result.Parameters[0].DefaultValue)
|
|
|
|
// Presets and their parameters round-trip.
|
|
require.Len(t, result.Presets, 1)
|
|
require.Equal(t, gtPreset.ID, result.Presets[0].ID)
|
|
require.Equal(t, gtPreset.Name, result.Presets[0].Name)
|
|
require.Equal(t, "Preset for GetTemplate tests.", result.Presets[0].Description)
|
|
require.Len(t, result.Presets[0].Parameters, 1)
|
|
require.Equal(t, "region", result.Presets[0].Parameters[0].Name)
|
|
require.Equal(t, "us-west-2", result.Presets[0].Parameters[0].Value)
|
|
|
|
// DesiredPrebuildInstances round-trips through toPresetView.
|
|
// The tool description tells the LLM to prefer presets with
|
|
// desired_prebuild_instances > 0; if this field stops
|
|
// flowing, that hint silently breaks.
|
|
require.NotNil(t, result.Presets[0].DesiredPrebuildInstances,
|
|
"desired_prebuild_instances should be populated when the preset has DesiredInstances")
|
|
require.EqualValues(t, gtPresetDesiredPrebuildInstances, *result.Presets[0].DesiredPrebuildInstances)
|
|
})
|
|
|
|
t.Run("WithoutPresets", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
|
|
result, err := testTool(t, toolsdk.GetTemplate, tb, toolsdk.GetTemplateArgs{
|
|
TemplateID: gtNoPresetBuild.Template.ID.String(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, gtNoPresetBuild.Template.ID.String(), result.ID)
|
|
require.Empty(t, result.Presets, "presets should be empty when the template has none")
|
|
|
|
// The `presets` field should be absent from the
|
|
// JSON entirely when the template has no presets.
|
|
b, err := json.Marshal(result)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, string(b), `"presets"`)
|
|
})
|
|
|
|
t.Run("InvalidID", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
|
|
_, err = testTool(t, toolsdk.GetTemplate, tb, toolsdk.GetTemplateArgs{
|
|
TemplateID: "not-a-uuid",
|
|
})
|
|
require.ErrorContains(t, err, "template_id must be a valid UUID")
|
|
})
|
|
|
|
t.Run("NotFound", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
|
|
_, err = testTool(t, toolsdk.GetTemplate, tb, toolsdk.GetTemplateArgs{
|
|
TemplateID: uuid.New().String(),
|
|
})
|
|
require.ErrorContains(t, err, "get template")
|
|
})
|
|
})
|
|
|
|
t.Run("GetWorkspaceAgentLogs", func(t *testing.T) {
|
|
_ = testutil.Context(t, testutil.WaitShort)
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
|
|
logs, err := testTool(t, toolsdk.GetWorkspaceAgentLogs, tb, toolsdk.GetWorkspaceAgentLogsArgs{
|
|
WorkspaceAgentID: agentID.String(),
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, logs)
|
|
})
|
|
|
|
t.Run("GetWorkspaceBuildLogs", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
logs, err := testTool(t, toolsdk.GetWorkspaceBuildLogs, tb, toolsdk.GetWorkspaceBuildLogsArgs{
|
|
WorkspaceBuildID: r.Build.ID.String(),
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
_ = logs // The build may not have any logs yet, so we just check that the function returns successfully
|
|
})
|
|
|
|
t.Run("GetTemplateVersionLogs", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
logs, err := testTool(t, toolsdk.GetTemplateVersionLogs, tb, toolsdk.GetTemplateVersionLogsArgs{
|
|
TemplateVersionID: r.TemplateVersion.ID.String(),
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
_ = logs // Just ensuring the call succeeds
|
|
})
|
|
|
|
t.Run("UpdateTemplateActiveVersion", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(client)
|
|
require.NoError(t, err)
|
|
result, err := testTool(t, toolsdk.UpdateTemplateActiveVersion, tb, toolsdk.UpdateTemplateActiveVersionArgs{
|
|
TemplateID: r.Template.ID.String(),
|
|
TemplateVersionID: r.TemplateVersion.ID.String(),
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.Contains(t, result, "Successfully updated")
|
|
})
|
|
|
|
t.Run("DeleteTemplate", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(client)
|
|
require.NoError(t, err)
|
|
_, err = testTool(t, toolsdk.DeleteTemplate, tb, toolsdk.DeleteTemplateArgs{
|
|
TemplateID: r.Template.ID.String(),
|
|
})
|
|
|
|
// This will fail with because there already exists a workspace.
|
|
require.ErrorContains(t, err, "All workspaces must be deleted before a template can be removed")
|
|
})
|
|
|
|
t.Run("UploadTarFile", func(t *testing.T) {
|
|
files := map[string]string{
|
|
"main.tf": `resource "null_resource" "example" {}`,
|
|
}
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
|
|
result, err := testTool(t, toolsdk.UploadTarFile, tb, toolsdk.UploadTarFileArgs{
|
|
Files: files,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, result.ID)
|
|
})
|
|
|
|
t.Run("CreateTemplateVersion", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(client)
|
|
require.NoError(t, err)
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
file := dbgen.File(t, store, database.File{})
|
|
t.Run("WithoutTemplateID", func(t *testing.T) {
|
|
tv, err := testTool(t, toolsdk.CreateTemplateVersion, tb, toolsdk.CreateTemplateVersionArgs{
|
|
FileID: file.ID.String(),
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, tv)
|
|
})
|
|
t.Run("WithTemplateID", func(t *testing.T) {
|
|
tv, err := testTool(t, toolsdk.CreateTemplateVersion, tb, toolsdk.CreateTemplateVersionArgs{
|
|
FileID: file.ID.String(),
|
|
TemplateID: r.Template.ID.String(),
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, tv)
|
|
})
|
|
})
|
|
|
|
t.Run("CreateTemplate", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(client)
|
|
require.NoError(t, err)
|
|
// Create a new template version for use here.
|
|
tv := dbfake.TemplateVersion(t, store).
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
Seed(database.TemplateVersion{OrganizationID: owner.OrganizationID, CreatedBy: owner.UserID}).
|
|
SkipCreateTemplate().Do()
|
|
|
|
// We're going to re-use the pre-existing template version
|
|
_, err = testTool(t, toolsdk.CreateTemplate, tb, toolsdk.CreateTemplateArgs{
|
|
Name: testutil.GetRandomNameHyphenated(t),
|
|
DisplayName: "Test Template",
|
|
Description: "This is a test template",
|
|
VersionID: tv.TemplateVersion.ID.String(),
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("CreateWorkspace", func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(client)
|
|
require.NoError(t, err)
|
|
t.Run("WithoutPreset", func(t *testing.T) {
|
|
res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
|
|
User: "me",
|
|
TemplateVersionID: r.TemplateVersion.ID.String(),
|
|
Name: testutil.GetRandomNameHyphenated(t),
|
|
RichParameters: map[string]string{},
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, res.ID, "expected a workspace ID")
|
|
})
|
|
|
|
t.Run("WithPreset", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
|
|
User: "me",
|
|
TemplateVersionID: r.TemplateVersion.ID.String(),
|
|
TemplateVersionPresetID: preset.ID.String(),
|
|
Name: testutil.GetRandomNameHyphenated(t),
|
|
RichParameters: map[string]string{},
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, res.ID, "expected a workspace ID")
|
|
|
|
build, err := client.WorkspaceBuild(ctx, res.LatestBuild.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, build.TemplateVersionPresetID)
|
|
require.Equal(t, preset.ID, *build.TemplateVersionPresetID)
|
|
})
|
|
|
|
t.Run("WithTemplateID", func(t *testing.T) {
|
|
// Exercises the template_id path on create_workspace,
|
|
// which lets the server resolve the active version
|
|
// atomically with the build. Mirrors how the chattool
|
|
// surface keys this tool.
|
|
res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
|
|
User: "me",
|
|
TemplateID: r.Template.ID.String(),
|
|
Name: testutil.GetRandomNameHyphenated(t),
|
|
RichParameters: map[string]string{},
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, res.ID, "expected a workspace ID")
|
|
})
|
|
|
|
t.Run("WithRichParameters", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
// Isolated fixture: a template version with a single
|
|
// rich parameter, no preset. Confirms that
|
|
// rich_parameters round-trip on their own without
|
|
// being shadowed or overridden by preset auto-binding
|
|
// when no preset matches.
|
|
rpBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
}).Do()
|
|
dbgen.TemplateVersionParameter(t, store, database.TemplateVersionParameter{
|
|
TemplateVersionID: rpBuild.TemplateVersion.ID,
|
|
Name: "region",
|
|
Description: "Region to deploy in.",
|
|
Type: "string",
|
|
DefaultValue: "us-east-1",
|
|
Required: false,
|
|
Mutable: true,
|
|
})
|
|
|
|
res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
|
|
User: "me",
|
|
TemplateVersionID: rpBuild.TemplateVersion.ID.String(),
|
|
Name: testutil.GetRandomNameHyphenated(t),
|
|
RichParameters: map[string]string{"region": "us-west-2"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, res.ID, "expected a workspace ID")
|
|
|
|
params, err := client.WorkspaceBuildParameters(ctx, res.LatestBuild.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, params, 1)
|
|
require.Equal(t, "region", params[0].Name)
|
|
require.Equal(t, "us-west-2", params[0].Value)
|
|
})
|
|
|
|
t.Run("RejectsBothIDs", func(t *testing.T) {
|
|
_, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
|
|
User: "me",
|
|
TemplateID: r.Template.ID.String(),
|
|
TemplateVersionID: r.TemplateVersion.ID.String(),
|
|
Name: testutil.GetRandomNameHyphenated(t),
|
|
RichParameters: map[string]string{},
|
|
})
|
|
require.ErrorContains(t, err, "exactly one of template_id or template_version_id")
|
|
})
|
|
|
|
t.Run("RejectsNeitherID", func(t *testing.T) {
|
|
_, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
|
|
User: "me",
|
|
Name: testutil.GetRandomNameHyphenated(t),
|
|
RichParameters: map[string]string{},
|
|
})
|
|
require.ErrorContains(t, err, "exactly one of template_id or template_version_id")
|
|
})
|
|
|
|
t.Run("WithPresetAndParams", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
// Build an isolated fixture: a template version with one
|
|
// rich parameter and a preset that sets it. The shared
|
|
// fixture's preset has no parameters and would not exercise
|
|
// the override path.
|
|
ovBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
}).Do()
|
|
dbgen.TemplateVersionParameter(t, store, database.TemplateVersionParameter{
|
|
TemplateVersionID: ovBuild.TemplateVersion.ID,
|
|
Name: "region",
|
|
Description: "Region to deploy in.",
|
|
Type: "string",
|
|
DefaultValue: "us-east-1",
|
|
Required: false,
|
|
Mutable: true,
|
|
})
|
|
ovPreset := dbgen.Preset(t, store, database.InsertPresetParams{
|
|
TemplateVersionID: ovBuild.TemplateVersion.ID,
|
|
Name: testutil.GetRandomNameHyphenated(t),
|
|
CreatedAt: ovBuild.TemplateVersion.CreatedAt,
|
|
Description: "Preset for override test.",
|
|
})
|
|
dbgen.PresetParameter(t, store, database.InsertPresetParametersParams{
|
|
TemplateVersionPresetID: ovPreset.ID,
|
|
Names: []string{"region"},
|
|
Values: []string{"us-west-2"},
|
|
})
|
|
|
|
// Send conflicting rich_parameters; the preset value
|
|
// should win, per the contract advertised in the
|
|
// template_version_preset_id schema description.
|
|
res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
|
|
User: "me",
|
|
TemplateVersionID: ovBuild.TemplateVersion.ID.String(),
|
|
TemplateVersionPresetID: ovPreset.ID.String(),
|
|
Name: testutil.GetRandomNameHyphenated(t),
|
|
RichParameters: map[string]string{"region": "us-east-1"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, res.ID, "expected a workspace ID")
|
|
|
|
// wsbuilder persists resolved parameters during the
|
|
// build transaction, before provisioning, so the values
|
|
// are readable immediately without waiting for the
|
|
// build job to complete.
|
|
params, err := client.WorkspaceBuildParameters(ctx, res.LatestBuild.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, params, 1)
|
|
require.Equal(t, "region", params[0].Name)
|
|
require.Equal(t, "us-west-2", params[0].Value,
|
|
"preset parameter value must override conflicting rich_parameters entry")
|
|
})
|
|
|
|
t.Run("RejectsInvalidTemplateID", func(t *testing.T) {
|
|
_, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
|
|
User: "me",
|
|
Name: testutil.GetRandomNameHyphenated(t),
|
|
TemplateID: "not-a-uuid",
|
|
})
|
|
require.ErrorContains(t, err, "template_id must be a valid UUID")
|
|
})
|
|
|
|
t.Run("RejectsInvalidTemplateVersionID", func(t *testing.T) {
|
|
_, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
|
|
User: "me",
|
|
Name: testutil.GetRandomNameHyphenated(t),
|
|
TemplateVersionID: "not-a-uuid",
|
|
})
|
|
require.ErrorContains(t, err, "template_version_id must be a valid UUID")
|
|
})
|
|
|
|
t.Run("RejectsInvalidTemplateVersionPresetID", func(t *testing.T) {
|
|
_, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
|
|
User: "me",
|
|
Name: testutil.GetRandomNameHyphenated(t),
|
|
TemplateVersionID: uuid.NewString(),
|
|
TemplateVersionPresetID: "not-a-uuid",
|
|
})
|
|
require.ErrorContains(t, err, "template_version_preset_id must be a valid UUID")
|
|
})
|
|
})
|
|
|
|
t.Run("WorkspaceSSHExec", func(t *testing.T) {
|
|
// Setup workspace exactly like main SSH tests
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
|
|
|
// Start agent and wait for it to be ready (following main SSH test pattern)
|
|
_ = agenttest.New(t, client.URL, agentToken)
|
|
|
|
// Wait for workspace agents to be ready like main SSH tests do
|
|
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
|
|
|
|
// Create tool dependencies using client
|
|
tb, err := toolsdk.NewDeps(client)
|
|
require.NoError(t, err)
|
|
|
|
// Test basic command execution
|
|
result, err := testTool(t, toolsdk.WorkspaceBash, tb, toolsdk.WorkspaceBashArgs{
|
|
Workspace: workspace.Name,
|
|
Command: "echo 'hello world'",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, result.ExitCode)
|
|
require.Equal(t, "hello world", result.Output)
|
|
|
|
// Test output trimming
|
|
result, err = testTool(t, toolsdk.WorkspaceBash, tb, toolsdk.WorkspaceBashArgs{
|
|
Workspace: workspace.Name,
|
|
Command: "echo ' test with whitespace '",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, result.ExitCode)
|
|
require.Equal(t, "test with whitespace", result.Output) // Should be trimmed
|
|
|
|
// Test non-zero exit code
|
|
result, err = testTool(t, toolsdk.WorkspaceBash, tb, toolsdk.WorkspaceBashArgs{
|
|
Workspace: workspace.Name,
|
|
Command: "exit 42",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 42, result.ExitCode)
|
|
require.Empty(t, result.Output)
|
|
|
|
// Test with workspace owner format - using the myuser from setup
|
|
result, err = testTool(t, toolsdk.WorkspaceBash, tb, toolsdk.WorkspaceBashArgs{
|
|
Workspace: "myuser/" + workspace.Name,
|
|
Command: "echo 'owner format works'",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, result.ExitCode)
|
|
require.Equal(t, "owner format works", result.Output)
|
|
|
|
// Regression test: agent-backed tools should also work when the
|
|
// workspace name is a valid dashless UUID.
|
|
const uuidLikeName = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
|
|
uuidClient, uuidWorkspace, uuidAgentToken := setupWorkspaceForAgentWithName(t, nil, uuidLikeName)
|
|
_ = agenttest.New(t, uuidClient.URL, uuidAgentToken)
|
|
coderdtest.NewWorkspaceAgentWaiter(t, uuidClient, uuidWorkspace.ID).Wait()
|
|
|
|
uuidTB, err := toolsdk.NewDeps(uuidClient)
|
|
require.NoError(t, err)
|
|
|
|
result, err = testTool(t, toolsdk.WorkspaceBash, uuidTB, toolsdk.WorkspaceBashArgs{
|
|
Workspace: uuidWorkspace.Name,
|
|
Command: "echo 'uuid-like name works'",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, result.ExitCode)
|
|
require.Equal(t, "uuid-like name works", result.Output)
|
|
})
|
|
|
|
t.Run("WorkspaceLS", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
|
fs := afero.NewMemMapFs()
|
|
_ = agenttest.New(t, client.URL, agentToken, func(opts *agent.Options) {
|
|
opts.Filesystem = fs
|
|
})
|
|
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
|
|
tb, err := toolsdk.NewDeps(client)
|
|
require.NoError(t, err)
|
|
|
|
tmpdir := os.TempDir()
|
|
|
|
dirPath := filepath.Join(tmpdir, "dir1/dir2")
|
|
err = fs.MkdirAll(dirPath, 0o755)
|
|
require.NoError(t, err)
|
|
|
|
filePath := filepath.Join(tmpdir, "dir1", "foo")
|
|
err = afero.WriteFile(fs, filePath, []byte("foo bar"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
_, err = testTool(t, toolsdk.WorkspaceLS, tb, toolsdk.WorkspaceLSArgs{
|
|
Workspace: workspace.Name,
|
|
Path: "relative",
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "path must be absolute")
|
|
|
|
res, err := testTool(t, toolsdk.WorkspaceLS, tb, toolsdk.WorkspaceLSArgs{
|
|
Workspace: workspace.Name,
|
|
Path: filepath.Dir(dirPath),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, []toolsdk.WorkspaceLSFile{
|
|
{
|
|
Path: dirPath,
|
|
IsDir: true,
|
|
},
|
|
{
|
|
Path: filePath,
|
|
IsDir: false,
|
|
},
|
|
}, res.Contents)
|
|
})
|
|
|
|
t.Run("WorkspaceToolsUseInjectedAgentConnFunc", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
|
_ = agenttest.New(t, client.URL, agentToken)
|
|
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
|
|
|
|
ws, err := client.Workspace(t.Context(), workspace.ID)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, ws.LatestBuild.Resources)
|
|
require.NotEmpty(t, ws.LatestBuild.Resources[0].Agents)
|
|
agentID := ws.LatestBuild.Resources[0].Agents[0].ID
|
|
sentinelErr := xerrors.New("injected agent connection function used")
|
|
|
|
tests := []struct {
|
|
name string
|
|
run func(t *testing.T, tb toolsdk.Deps) error
|
|
}{
|
|
{
|
|
name: "WorkspaceLS",
|
|
run: func(t *testing.T, tb toolsdk.Deps) error {
|
|
_, err := testTool(t, toolsdk.WorkspaceLS, tb, toolsdk.WorkspaceLSArgs{
|
|
Workspace: workspace.Name,
|
|
Path: "/tmp",
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "WorkspaceReadFile",
|
|
run: func(t *testing.T, tb toolsdk.Deps) error {
|
|
_, err := testTool(t, toolsdk.WorkspaceReadFile, tb, toolsdk.WorkspaceReadFileArgs{
|
|
Workspace: workspace.Name,
|
|
Path: "/tmp/file",
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "WorkspaceWriteFile",
|
|
run: func(t *testing.T, tb toolsdk.Deps) error {
|
|
_, err := testTool(t, toolsdk.WorkspaceWriteFile, tb, toolsdk.WorkspaceWriteFileArgs{
|
|
Workspace: workspace.Name,
|
|
Path: "/tmp/file",
|
|
Content: []byte("hello from agent connection function"),
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "WorkspaceEditFile",
|
|
run: func(t *testing.T, tb toolsdk.Deps) error {
|
|
_, err := testTool(t, toolsdk.WorkspaceEditFile, tb, toolsdk.WorkspaceEditFileArgs{
|
|
Workspace: workspace.Name,
|
|
Path: "/tmp/file",
|
|
Edits: []workspacesdk.FileEdit{{
|
|
Search: "hello",
|
|
Replace: "goodbye",
|
|
}},
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "WorkspaceEditFiles",
|
|
run: func(t *testing.T, tb toolsdk.Deps) error {
|
|
_, err := testTool(t, toolsdk.WorkspaceEditFiles, tb, toolsdk.WorkspaceEditFilesArgs{
|
|
Workspace: workspace.Name,
|
|
Files: []workspacesdk.FileEdits{{
|
|
Path: "/tmp/file",
|
|
Edits: []workspacesdk.FileEdit{{
|
|
Search: "hello",
|
|
Replace: "goodbye",
|
|
}},
|
|
}},
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "WorkspaceBash",
|
|
run: func(t *testing.T, tb toolsdk.Deps) error {
|
|
_, err := testTool(t, toolsdk.WorkspaceBash, tb, toolsdk.WorkspaceBashArgs{
|
|
Workspace: workspace.Name,
|
|
Command: "echo hello",
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
agentConnFn := &recordingAgentConnFunc{err: sentinelErr}
|
|
tb, err := toolsdk.NewDeps(client, toolsdk.WithAgentConnFunc(agentConnFn.AgentConn))
|
|
require.NoError(t, err)
|
|
|
|
err = tt.run(t, tb)
|
|
require.ErrorIs(t, err, sentinelErr)
|
|
require.ErrorContains(t, err, "failed to dial agent")
|
|
require.Equal(t, 1, agentConnFn.calls)
|
|
require.Equal(t, agentID, agentConnFn.agentID)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("WorkspaceReadFile", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
|
fs := afero.NewMemMapFs()
|
|
_ = agenttest.New(t, client.URL, agentToken, func(opts *agent.Options) {
|
|
opts.Filesystem = fs
|
|
})
|
|
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
|
|
tb, err := toolsdk.NewDeps(client)
|
|
require.NoError(t, err)
|
|
|
|
tmpdir := os.TempDir()
|
|
filePath := filepath.Join(tmpdir, "file")
|
|
err = afero.WriteFile(fs, filePath, []byte("content"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
largeFilePath := filepath.Join(tmpdir, "large")
|
|
largeFile, err := fs.Create(largeFilePath)
|
|
require.NoError(t, err)
|
|
err = largeFile.Truncate(1 << 21)
|
|
require.NoError(t, err)
|
|
|
|
imagePath := filepath.Join(tmpdir, "file.png")
|
|
err = afero.WriteFile(fs, imagePath, []byte("not really an image"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
limit int64
|
|
offset int64
|
|
mimeType string
|
|
bytes []byte
|
|
length int
|
|
error string
|
|
}{
|
|
{
|
|
name: "NonExistent",
|
|
path: filepath.Join(tmpdir, "does-not-exist"),
|
|
error: "file does not exist",
|
|
},
|
|
{
|
|
name: "Exists",
|
|
path: filePath,
|
|
bytes: []byte("content"),
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "Limit1Offset2",
|
|
path: filePath,
|
|
limit: 1,
|
|
offset: 2,
|
|
bytes: []byte("n"),
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "DefaultMaxLimit",
|
|
path: largeFilePath,
|
|
length: 1 << 20,
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "ExceedMaxLimit",
|
|
path: filePath,
|
|
limit: 1 << 21,
|
|
error: "limit must be 1048576 or less, got 2097152",
|
|
},
|
|
{
|
|
name: "ImageMimeType",
|
|
path: imagePath,
|
|
bytes: []byte("not really an image"),
|
|
mimeType: "image/png",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resp, err := testTool(t, toolsdk.WorkspaceReadFile, tb, toolsdk.WorkspaceReadFileArgs{
|
|
Workspace: workspace.Name,
|
|
Path: tt.path,
|
|
Limit: tt.limit,
|
|
Offset: tt.offset,
|
|
})
|
|
if tt.error != "" {
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), tt.error)
|
|
} else {
|
|
require.NoError(t, err)
|
|
if tt.length != 0 {
|
|
require.Len(t, resp.Content, tt.length)
|
|
}
|
|
if tt.bytes != nil {
|
|
require.Equal(t, tt.bytes, resp.Content)
|
|
}
|
|
require.Equal(t, tt.mimeType, resp.MimeType)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("WorkspaceWriteFile", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
|
fs := afero.NewMemMapFs()
|
|
_ = agenttest.New(t, client.URL, agentToken, func(opts *agent.Options) {
|
|
opts.Filesystem = fs
|
|
})
|
|
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
|
|
tb, err := toolsdk.NewDeps(client)
|
|
require.NoError(t, err)
|
|
|
|
tmpdir := os.TempDir()
|
|
filePath := filepath.Join(tmpdir, "write")
|
|
|
|
_, err = testTool(t, toolsdk.WorkspaceWriteFile, tb, toolsdk.WorkspaceWriteFileArgs{
|
|
Workspace: workspace.Name,
|
|
Path: filePath,
|
|
Content: []byte("content"),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
b, err := afero.ReadFile(fs, filePath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, []byte("content"), b)
|
|
})
|
|
|
|
t.Run("WorkspaceEditFile", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
|
fs := afero.NewMemMapFs()
|
|
_ = agenttest.New(t, client.URL, agentToken, func(opts *agent.Options) {
|
|
opts.Filesystem = fs
|
|
})
|
|
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
|
|
tb, err := toolsdk.NewDeps(client)
|
|
require.NoError(t, err)
|
|
|
|
tmpdir := os.TempDir()
|
|
filePath := filepath.Join(tmpdir, "edit")
|
|
err = afero.WriteFile(fs, filePath, []byte("foo bar"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
_, err = testTool(t, toolsdk.WorkspaceEditFile, tb, toolsdk.WorkspaceEditFileArgs{
|
|
Workspace: workspace.Name,
|
|
Path: filePath,
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "must specify at least one edit")
|
|
|
|
_, err = testTool(t, toolsdk.WorkspaceEditFile, tb, toolsdk.WorkspaceEditFileArgs{
|
|
Workspace: workspace.Name,
|
|
Path: filePath,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
b, err := afero.ReadFile(fs, filePath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "bar bar", string(b))
|
|
})
|
|
|
|
t.Run("WorkspaceEditFiles", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
|
fs := afero.NewMemMapFs()
|
|
_ = agenttest.New(t, client.URL, agentToken, func(opts *agent.Options) {
|
|
opts.Filesystem = fs
|
|
})
|
|
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
|
|
tb, err := toolsdk.NewDeps(client)
|
|
require.NoError(t, err)
|
|
|
|
tmpdir := os.TempDir()
|
|
filePath1 := filepath.Join(tmpdir, "edit1")
|
|
err = afero.WriteFile(fs, filePath1, []byte("foo1 bar1"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
filePath2 := filepath.Join(tmpdir, "edit2")
|
|
err = afero.WriteFile(fs, filePath2, []byte("foo2 bar2"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
_, err = testTool(t, toolsdk.WorkspaceEditFiles, tb, toolsdk.WorkspaceEditFilesArgs{
|
|
Workspace: workspace.Name,
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "must specify at least one file")
|
|
|
|
_, err = testTool(t, toolsdk.WorkspaceEditFiles, tb, toolsdk.WorkspaceEditFilesArgs{
|
|
Workspace: workspace.Name,
|
|
Files: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filePath1,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo1",
|
|
Replace: "bar1",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Path: filePath2,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo2",
|
|
Replace: "bar2",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
b, err := afero.ReadFile(fs, filePath1)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "bar1 bar1", string(b))
|
|
|
|
b, err = afero.ReadFile(fs, filePath2)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "bar2 bar2", string(b))
|
|
})
|
|
|
|
t.Run("WorkspacePortForward", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
workspace string
|
|
host string
|
|
port int
|
|
expect string
|
|
error string
|
|
}{
|
|
{
|
|
name: "OK",
|
|
workspace: "myuser/myworkspace",
|
|
port: 1234,
|
|
host: "*.test.coder.com",
|
|
expect: "%s://1234--dev--myworkspace--myuser.test.coder.com:%s",
|
|
},
|
|
{
|
|
name: "NonExistentWorkspace",
|
|
workspace: "doesnotexist",
|
|
port: 1234,
|
|
host: "*.test.coder.com",
|
|
error: "failed to find workspace",
|
|
},
|
|
{
|
|
name: "NoAppHost",
|
|
host: "",
|
|
workspace: "myuser/myworkspace",
|
|
port: 1234,
|
|
error: "no app host",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t, &coderdtest.Options{
|
|
AppHostname: tt.host,
|
|
})
|
|
_ = agenttest.New(t, client.URL, agentToken)
|
|
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
|
|
tb, err := toolsdk.NewDeps(client)
|
|
require.NoError(t, err)
|
|
|
|
res, err := testTool(t, toolsdk.WorkspacePortForward, tb, toolsdk.WorkspacePortForwardArgs{
|
|
Workspace: tt.workspace,
|
|
Port: tt.port,
|
|
})
|
|
if tt.error != "" {
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, tt.error)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.Equal(t, fmt.Sprintf(tt.expect, client.URL.Scheme, client.URL.Port()), res.URL)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("CreateTask", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
presetID := uuid.New()
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{
|
|
OrganizationID: owner.OrganizationID,
|
|
CreatedBy: member.ID,
|
|
HasAITask: sql.NullBool{
|
|
Bool: true,
|
|
Valid: true,
|
|
},
|
|
}).Preset(database.TemplateVersionPreset{
|
|
ID: presetID,
|
|
DesiredInstances: sql.NullInt32{
|
|
Int32: 1,
|
|
Valid: true,
|
|
},
|
|
}).Do()
|
|
|
|
tests := []struct {
|
|
name string
|
|
args toolsdk.CreateTaskArgs
|
|
error string
|
|
}{
|
|
{
|
|
name: "OK",
|
|
args: toolsdk.CreateTaskArgs{
|
|
TemplateVersionID: aiTV.TemplateVersion.ID.String(),
|
|
Input: "do a barrel roll",
|
|
User: "me",
|
|
},
|
|
},
|
|
{
|
|
name: "NoUser",
|
|
args: toolsdk.CreateTaskArgs{
|
|
TemplateVersionID: aiTV.TemplateVersion.ID.String(),
|
|
Input: "do another barrel roll",
|
|
},
|
|
},
|
|
{
|
|
name: "NoInput",
|
|
args: toolsdk.CreateTaskArgs{
|
|
TemplateVersionID: aiTV.TemplateVersion.ID.String(),
|
|
},
|
|
error: "input is required",
|
|
},
|
|
{
|
|
name: "NotTaskTemplate",
|
|
args: toolsdk.CreateTaskArgs{
|
|
TemplateVersionID: r.TemplateVersion.ID.String(),
|
|
Input: "do yet another barrel roll",
|
|
},
|
|
error: "Template does not have a valid \"coder_ai_task\" resource.",
|
|
},
|
|
{
|
|
name: "WithPreset",
|
|
args: toolsdk.CreateTaskArgs{
|
|
TemplateVersionID: aiTV.TemplateVersion.ID.String(),
|
|
TemplateVersionPresetID: presetID.String(),
|
|
Input: "not enough barrel rolls",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
|
|
_, err = testTool(t, toolsdk.CreateTask, tb, tt.args)
|
|
if tt.error != "" {
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, tt.error)
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("DeleteTask", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{
|
|
OrganizationID: owner.OrganizationID,
|
|
CreatedBy: member.ID,
|
|
HasAITask: sql.NullBool{
|
|
Bool: true,
|
|
Valid: true,
|
|
},
|
|
}).Do()
|
|
|
|
build1 := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
Name: "delete-task-workspace-1",
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
TemplateID: aiTV.Template.ID,
|
|
}).WithTask(database.TaskTable{
|
|
Name: "delete-task-1",
|
|
Prompt: "delete task 1",
|
|
}, nil).Do()
|
|
task1 := build1.Task
|
|
|
|
build2 := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
Name: "delete-task-workspace-2",
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
TemplateID: aiTV.Template.ID,
|
|
}).WithTask(database.TaskTable{
|
|
Name: "delete-task-2",
|
|
Prompt: "delete task 2",
|
|
}, nil).Do()
|
|
task2 := build2.Task
|
|
|
|
tests := []struct {
|
|
name string
|
|
args toolsdk.DeleteTaskArgs
|
|
error string
|
|
}{
|
|
{
|
|
name: "ByUUID",
|
|
args: toolsdk.DeleteTaskArgs{
|
|
TaskID: task1.ID.String(),
|
|
},
|
|
},
|
|
{
|
|
name: "ByIdentifier",
|
|
args: toolsdk.DeleteTaskArgs{
|
|
TaskID: task2.Name,
|
|
},
|
|
},
|
|
{
|
|
name: "NoID",
|
|
args: toolsdk.DeleteTaskArgs{},
|
|
error: "task_id is required",
|
|
},
|
|
{
|
|
name: "NoTaskByID",
|
|
args: toolsdk.DeleteTaskArgs{
|
|
TaskID: uuid.New().String(),
|
|
},
|
|
error: "Resource not found",
|
|
},
|
|
{
|
|
name: "NoTaskByWorkspaceIdentifier",
|
|
args: toolsdk.DeleteTaskArgs{
|
|
TaskID: "non-existent",
|
|
},
|
|
error: "Resource not found",
|
|
},
|
|
{
|
|
name: "ExistsButNotATask",
|
|
args: toolsdk.DeleteTaskArgs{
|
|
TaskID: r.Workspace.ID.String(),
|
|
},
|
|
error: "Resource not found",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
|
|
_, err = testTool(t, toolsdk.DeleteTask, tb, tt.args)
|
|
if tt.error != "" {
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, tt.error)
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("ListTasks", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
|
owner := coderdtest.CreateFirstUser(t, client)
|
|
_, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
|
taskClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
|
|
|
// Create a template with AI task support using the proper flow.
|
|
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{
|
|
Parse: echo.ParseComplete,
|
|
ProvisionApply: echo.ApplyComplete,
|
|
ProvisionGraph: []*proto.Response{
|
|
{Type: &proto.Response_Graph{Graph: &proto.GraphComplete{
|
|
HasAiTasks: true,
|
|
}}},
|
|
},
|
|
})
|
|
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
|
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
|
|
|
// This task should not show up since listing is user-scoped.
|
|
_, err := client.CreateTask(ctx, member.Username, codersdk.CreateTaskRequest{
|
|
TemplateVersionID: template.ActiveVersionID,
|
|
Input: "task for member",
|
|
Name: "list-task-workspace-member",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create tasks for taskUser. These should show up in the list.
|
|
for i := range 5 {
|
|
taskName := fmt.Sprintf("list-task-workspace-%d", i)
|
|
task, err := taskClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
|
TemplateVersionID: template.ActiveVersionID,
|
|
Input: fmt.Sprintf("task %d", i),
|
|
Name: taskName,
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, task.WorkspaceID.Valid, "task should have workspace ID")
|
|
|
|
// For the first task, stop the workspace to make it paused.
|
|
if i == 0 {
|
|
ws, err := taskClient.Workspace(ctx, task.WorkspaceID.UUID)
|
|
require.NoError(t, err)
|
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, taskClient, ws.LatestBuild.ID)
|
|
|
|
// Stop the workspace to set task status to paused.
|
|
build, err := taskClient.CreateWorkspaceBuild(ctx, task.WorkspaceID.UUID, codersdk.CreateWorkspaceBuildRequest{
|
|
Transition: codersdk.WorkspaceTransitionStop,
|
|
})
|
|
require.NoError(t, err)
|
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, taskClient, build.ID)
|
|
}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
args toolsdk.ListTasksArgs
|
|
expected []string
|
|
error string
|
|
}{
|
|
{
|
|
name: "ListAllOwned",
|
|
args: toolsdk.ListTasksArgs{},
|
|
expected: []string{
|
|
"list-task-workspace-0",
|
|
"list-task-workspace-1",
|
|
"list-task-workspace-2",
|
|
"list-task-workspace-3",
|
|
"list-task-workspace-4",
|
|
},
|
|
},
|
|
{
|
|
name: "ListFiltered",
|
|
args: toolsdk.ListTasksArgs{
|
|
Status: codersdk.TaskStatusPaused,
|
|
},
|
|
expected: []string{
|
|
"list-task-workspace-0",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tb, err := toolsdk.NewDeps(taskClient)
|
|
require.NoError(t, err)
|
|
|
|
res, err := testTool(t, toolsdk.ListTasks, tb, tt.args)
|
|
if tt.error != "" {
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, tt.error)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.Len(t, res.Tasks, len(tt.expected))
|
|
for _, task := range res.Tasks {
|
|
require.Contains(t, tt.expected, task.Name)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("GetTask", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{
|
|
OrganizationID: owner.OrganizationID,
|
|
CreatedBy: member.ID,
|
|
HasAITask: sql.NullBool{
|
|
Bool: true,
|
|
Valid: true,
|
|
},
|
|
}).Do()
|
|
|
|
build := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
Name: "get-task-workspace-1",
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
TemplateID: aiTV.Template.ID,
|
|
}).WithTask(database.TaskTable{
|
|
Name: "get-task-1",
|
|
Prompt: "get task",
|
|
}, nil).Do()
|
|
task := build.Task
|
|
|
|
tests := []struct {
|
|
name string
|
|
args toolsdk.GetTaskStatusArgs
|
|
expected codersdk.TaskStatus
|
|
error string
|
|
}{
|
|
{
|
|
name: "ByUUID",
|
|
args: toolsdk.GetTaskStatusArgs{
|
|
TaskID: task.ID.String(),
|
|
},
|
|
expected: codersdk.TaskStatusInitializing,
|
|
},
|
|
{
|
|
name: "ByIdentifier",
|
|
args: toolsdk.GetTaskStatusArgs{
|
|
TaskID: task.Name,
|
|
},
|
|
expected: codersdk.TaskStatusInitializing,
|
|
},
|
|
{
|
|
name: "NoID",
|
|
args: toolsdk.GetTaskStatusArgs{},
|
|
error: "task_id is required",
|
|
},
|
|
{
|
|
name: "NoTaskByID",
|
|
args: toolsdk.GetTaskStatusArgs{
|
|
TaskID: uuid.New().String(),
|
|
},
|
|
error: "Resource not found",
|
|
},
|
|
{
|
|
name: "NoTaskByWorkspaceIdentifier",
|
|
args: toolsdk.GetTaskStatusArgs{
|
|
TaskID: "non-existent",
|
|
},
|
|
error: "Resource not found",
|
|
},
|
|
{
|
|
name: "ExistsButNotATask",
|
|
args: toolsdk.GetTaskStatusArgs{
|
|
TaskID: r.Workspace.ID.String(),
|
|
},
|
|
error: "Resource not found",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
|
|
res, err := testTool(t, toolsdk.GetTaskStatus, tb, tt.args)
|
|
if tt.error != "" {
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, tt.error)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.expected, res.Status)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("WorkspaceListApps", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
_ = dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
Name: "list-app-workspace-one-agent",
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
|
|
agents[0].Apps = []*proto.App{
|
|
{
|
|
Slug: "zero",
|
|
Url: "http://zero.dev.coder.com",
|
|
},
|
|
}
|
|
return agents
|
|
}).Do()
|
|
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
_ = dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
Name: "list-app-workspace-multi-agent",
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
|
|
agents[0].Apps = []*proto.App{
|
|
{
|
|
Slug: "one",
|
|
Url: "http://one.dev.coder.com",
|
|
},
|
|
{
|
|
Slug: "two",
|
|
Url: "http://two.dev.coder.com",
|
|
},
|
|
{
|
|
Slug: "three",
|
|
Url: "http://three.dev.coder.com",
|
|
},
|
|
}
|
|
agents = append(agents, &proto.Agent{
|
|
Id: uuid.NewString(),
|
|
Name: "dev2",
|
|
Auth: &proto.Agent_Token{
|
|
Token: uuid.NewString(),
|
|
},
|
|
Env: map[string]string{},
|
|
Apps: []*proto.App{
|
|
{
|
|
Slug: "four",
|
|
Url: "http://four.dev.coder.com",
|
|
},
|
|
},
|
|
})
|
|
return agents
|
|
}).Do()
|
|
|
|
tests := []struct {
|
|
name string
|
|
args toolsdk.WorkspaceListAppsArgs
|
|
expected []toolsdk.WorkspaceListApp
|
|
error string
|
|
}{
|
|
{
|
|
name: "NonExistentWorkspace",
|
|
args: toolsdk.WorkspaceListAppsArgs{
|
|
Workspace: "list-appp-workspace-does-not-exist",
|
|
},
|
|
error: "failed to find workspace",
|
|
},
|
|
{
|
|
name: "OneAgentOneApp",
|
|
args: toolsdk.WorkspaceListAppsArgs{
|
|
Workspace: "list-app-workspace-one-agent",
|
|
},
|
|
expected: []toolsdk.WorkspaceListApp{
|
|
{
|
|
Name: "zero",
|
|
URL: "http://zero.dev.coder.com",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "MultiAgent",
|
|
args: toolsdk.WorkspaceListAppsArgs{
|
|
Workspace: "list-app-workspace-multi-agent",
|
|
},
|
|
error: "multiple agents found, please specify the agent name",
|
|
},
|
|
{
|
|
name: "MultiAgentOneApp",
|
|
args: toolsdk.WorkspaceListAppsArgs{
|
|
Workspace: "list-app-workspace-multi-agent.dev2",
|
|
},
|
|
expected: []toolsdk.WorkspaceListApp{
|
|
{
|
|
Name: "four",
|
|
URL: "http://four.dev.coder.com",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "MultiAgentMultiApp",
|
|
args: toolsdk.WorkspaceListAppsArgs{
|
|
Workspace: "list-app-workspace-multi-agent.dev",
|
|
},
|
|
expected: []toolsdk.WorkspaceListApp{
|
|
{
|
|
Name: "one",
|
|
URL: "http://one.dev.coder.com",
|
|
},
|
|
{
|
|
Name: "three",
|
|
URL: "http://three.dev.coder.com",
|
|
},
|
|
{
|
|
Name: "two",
|
|
URL: "http://two.dev.coder.com",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
|
|
res, err := testTool(t, toolsdk.WorkspaceListApps, tb, tt.args)
|
|
if tt.error != "" {
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, tt.error)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.expected, res.Apps)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("SendTaskInput", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Start a fake AgentAPI that accepts GET /status and POST /message.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/status" {
|
|
httpapi.Write(r.Context(), rw, http.StatusOK, agentapi.GetStatusResponse{
|
|
Status: agentapi.StatusStable,
|
|
})
|
|
return
|
|
}
|
|
if r.Method == http.MethodPost && r.URL.Path == "/message" {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
|
|
var req agentapi.PostMessageParams
|
|
ok := httpapi.Read(r.Context(), rw, r, &req)
|
|
assert.True(t, ok, "failed to read request")
|
|
|
|
assert.Equal(t, req.Content, "frob the baz")
|
|
assert.Equal(t, req.Type, agentapi.MessageTypeUser)
|
|
|
|
httpapi.Write(r.Context(), rw, http.StatusOK, agentapi.PostMessageResponse{
|
|
Ok: true,
|
|
})
|
|
return
|
|
}
|
|
rw.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{
|
|
OrganizationID: owner.OrganizationID,
|
|
CreatedBy: member.ID,
|
|
HasAITask: sql.NullBool{
|
|
Bool: true,
|
|
Valid: true,
|
|
},
|
|
}).Do()
|
|
|
|
ws := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
Name: "send-task-input-ws",
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
TemplateID: aiTV.Template.ID,
|
|
}).WithTask(database.TaskTable{
|
|
Name: "send-task-input",
|
|
Prompt: "send task input",
|
|
}, &proto.App{Url: srv.URL}).Do()
|
|
task := ws.Task
|
|
|
|
_ = agenttest.New(t, client.URL, ws.AgentToken)
|
|
coderdtest.NewWorkspaceAgentWaiter(t, client, ws.Workspace.ID).
|
|
WaitFor(coderdtest.AgentsReady)
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
// Ensure the app is healthy (required to send task input).
|
|
err := store.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{
|
|
ID: task.WorkspaceAppID.UUID,
|
|
Health: database.WorkspaceAppHealthHealthy,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
args toolsdk.SendTaskInputArgs
|
|
error string
|
|
}{
|
|
{
|
|
name: "ByUUID",
|
|
args: toolsdk.SendTaskInputArgs{
|
|
TaskID: task.ID.String(),
|
|
Input: "frob the baz",
|
|
},
|
|
},
|
|
{
|
|
name: "ByIdentifier",
|
|
args: toolsdk.SendTaskInputArgs{
|
|
TaskID: task.Name,
|
|
Input: "frob the baz",
|
|
},
|
|
},
|
|
{
|
|
name: "NoID",
|
|
args: toolsdk.SendTaskInputArgs{},
|
|
error: "task_id is required",
|
|
},
|
|
{
|
|
name: "NoInput",
|
|
args: toolsdk.SendTaskInputArgs{
|
|
TaskID: "send-task-input",
|
|
},
|
|
error: "input is required",
|
|
},
|
|
{
|
|
name: "NoTaskByID",
|
|
args: toolsdk.SendTaskInputArgs{
|
|
TaskID: uuid.New().String(),
|
|
Input: "this is ignored",
|
|
},
|
|
error: "Resource not found",
|
|
},
|
|
{
|
|
name: "NoTaskByWorkspaceIdentifier",
|
|
args: toolsdk.SendTaskInputArgs{
|
|
TaskID: "non-existent",
|
|
Input: "this is ignored",
|
|
},
|
|
error: "Resource not found",
|
|
},
|
|
{
|
|
name: "ExistsButNotATask",
|
|
args: toolsdk.SendTaskInputArgs{
|
|
TaskID: r.Workspace.ID.String(),
|
|
Input: "this is ignored",
|
|
},
|
|
error: "Resource not found",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
|
|
_, err = testTool(t, toolsdk.SendTaskInput, tb, tt.args)
|
|
if tt.error != "" {
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, tt.error)
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("GetTaskLogs", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
messages := []agentapi.Message{
|
|
{
|
|
Id: 0,
|
|
Content: "welcome",
|
|
Role: agentapi.RoleAgent,
|
|
},
|
|
{
|
|
Id: 1,
|
|
Content: "frob the dazzle",
|
|
Role: agentapi.RoleUser,
|
|
},
|
|
{
|
|
Id: 2,
|
|
Content: "frob dazzled",
|
|
Role: agentapi.RoleAgent,
|
|
},
|
|
}
|
|
|
|
// Start a fake AgentAPI that returns some messages.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/messages" {
|
|
httpapi.Write(r.Context(), rw, http.StatusOK, agentapi.GetMessagesResponse{
|
|
Messages: messages,
|
|
})
|
|
return
|
|
}
|
|
rw.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
|
aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{
|
|
OrganizationID: owner.OrganizationID,
|
|
CreatedBy: member.ID,
|
|
HasAITask: sql.NullBool{
|
|
Bool: true,
|
|
Valid: true,
|
|
},
|
|
}).Do()
|
|
|
|
ws := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
Name: "get-task-logs-ws",
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
TemplateID: aiTV.Template.ID,
|
|
}).WithTask(database.TaskTable{
|
|
Name: "get-task-logs",
|
|
Prompt: "get task logs",
|
|
}, &proto.App{Url: srv.URL}).Do()
|
|
task := ws.Task
|
|
|
|
_ = agenttest.New(t, client.URL, ws.AgentToken)
|
|
coderdtest.NewWorkspaceAgentWaiter(t, client, ws.Workspace.ID).
|
|
WaitFor(coderdtest.AgentsReady)
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
// Ensure the app is healthy (required to read task logs).
|
|
err := store.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{
|
|
ID: task.WorkspaceAppID.UUID,
|
|
Health: database.WorkspaceAppHealthHealthy,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
args toolsdk.GetTaskLogsArgs
|
|
expected []agentapi.Message
|
|
error string
|
|
}{
|
|
{
|
|
name: "ByUUID",
|
|
args: toolsdk.GetTaskLogsArgs{
|
|
TaskID: task.ID.String(),
|
|
},
|
|
expected: messages,
|
|
},
|
|
{
|
|
name: "ByIdentifier",
|
|
args: toolsdk.GetTaskLogsArgs{
|
|
TaskID: task.Name,
|
|
},
|
|
expected: messages,
|
|
},
|
|
{
|
|
name: "NoID",
|
|
args: toolsdk.GetTaskLogsArgs{},
|
|
error: "task_id is required",
|
|
},
|
|
{
|
|
name: "NoTaskByID",
|
|
args: toolsdk.GetTaskLogsArgs{
|
|
TaskID: uuid.New().String(),
|
|
},
|
|
error: "Resource not found",
|
|
},
|
|
{
|
|
name: "NoTaskByWorkspaceIdentifier",
|
|
args: toolsdk.GetTaskLogsArgs{
|
|
TaskID: "non-existent",
|
|
},
|
|
error: "Resource not found",
|
|
},
|
|
{
|
|
name: "ExistsButNotATask",
|
|
args: toolsdk.GetTaskLogsArgs{
|
|
TaskID: r.Workspace.ID.String(),
|
|
},
|
|
error: "Resource not found",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tb, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
|
|
res, err := testTool(t, toolsdk.GetTaskLogs, tb, tt.args)
|
|
if tt.error != "" {
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, tt.error)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.Len(t, res.Logs, len(tt.expected))
|
|
for i, msg := range tt.expected {
|
|
require.Equal(t, msg.Id, int64(res.Logs[i].ID))
|
|
require.Equal(t, msg.Content, res.Logs[i].Content)
|
|
if msg.Role == agentapi.RoleUser {
|
|
require.Equal(t, codersdk.TaskLogTypeInput, res.Logs[i].Type)
|
|
} else {
|
|
require.Equal(t, codersdk.TaskLogTypeOutput, res.Logs[i].Type)
|
|
}
|
|
require.Equal(t, msg.Time, res.Logs[i].Time)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestedTools keeps track of which tools have been tested.
|
|
var testedTools sync.Map
|
|
|
|
// testTool is a helper function to test a tool and mark it as tested.
|
|
// Note that we test the _generic_ version of the tool and not the typed one.
|
|
// This is to mimic how we expect external callers to use the tool.
|
|
func testTool[Arg, Ret any](t *testing.T, tool toolsdk.Tool[Arg, Ret], tb toolsdk.Deps, args Arg) (Ret, error) {
|
|
t.Helper()
|
|
defer func() { testedTools.Store(tool.Name, true) }()
|
|
toolArgs, err := json.Marshal(args)
|
|
require.NoError(t, err, "failed to marshal args")
|
|
result, err := tool.Generic().Handler(t.Context(), tb, toolArgs)
|
|
var ret Ret
|
|
require.NoError(t, json.Unmarshal(result, &ret), "failed to unmarshal result %q", string(result))
|
|
return ret, err
|
|
}
|
|
|
|
func TestWithRecovery(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("OK", func(t *testing.T) {
|
|
t.Parallel()
|
|
fakeTool := toolsdk.GenericTool{
|
|
Tool: aisdk.Tool{
|
|
Name: "echo",
|
|
Description: "Echoes the input.",
|
|
},
|
|
Handler: func(ctx context.Context, tb toolsdk.Deps, args json.RawMessage) (json.RawMessage, error) {
|
|
return args, nil
|
|
},
|
|
}
|
|
|
|
wrapped := toolsdk.WithRecover(fakeTool.Handler)
|
|
v, err := wrapped(context.Background(), toolsdk.Deps{}, []byte(`{}`))
|
|
require.NoError(t, err)
|
|
require.JSONEq(t, `{}`, string(v))
|
|
})
|
|
|
|
t.Run("Error", func(t *testing.T) {
|
|
t.Parallel()
|
|
fakeTool := toolsdk.GenericTool{
|
|
Tool: aisdk.Tool{
|
|
Name: "fake_tool",
|
|
Description: "Returns an error for testing.",
|
|
},
|
|
Handler: func(ctx context.Context, tb toolsdk.Deps, args json.RawMessage) (json.RawMessage, error) {
|
|
return nil, assert.AnError
|
|
},
|
|
}
|
|
wrapped := toolsdk.WithRecover(fakeTool.Handler)
|
|
v, err := wrapped(context.Background(), toolsdk.Deps{}, []byte(`{}`))
|
|
require.Nil(t, v)
|
|
require.ErrorIs(t, err, assert.AnError)
|
|
})
|
|
|
|
t.Run("Panic", func(t *testing.T) {
|
|
t.Parallel()
|
|
panicTool := toolsdk.GenericTool{
|
|
Tool: aisdk.Tool{
|
|
Name: "panic_tool",
|
|
Description: "Panics for testing.",
|
|
},
|
|
Handler: func(ctx context.Context, tb toolsdk.Deps, args json.RawMessage) (json.RawMessage, error) {
|
|
panic("you can't sweat this fever out")
|
|
},
|
|
}
|
|
|
|
wrapped := toolsdk.WithRecover(panicTool.Handler)
|
|
v, err := wrapped(context.Background(), toolsdk.Deps{}, []byte("disco"))
|
|
require.Empty(t, v)
|
|
require.ErrorContains(t, err, "you can't sweat this fever out")
|
|
})
|
|
}
|
|
|
|
type testContextKey struct{}
|
|
|
|
func TestWithCleanContext(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("NoContextKeys", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// This test is to ensure that the context values are not set in the
|
|
// toolsdk package.
|
|
ctxTool := toolsdk.GenericTool{
|
|
Tool: aisdk.Tool{
|
|
Name: "context_tool",
|
|
Description: "Returns the context value for testing.",
|
|
},
|
|
Handler: func(toolCtx context.Context, tb toolsdk.Deps, args json.RawMessage) (json.RawMessage, error) {
|
|
v := toolCtx.Value(testContextKey{})
|
|
assert.Nil(t, v, "expected the context value to be nil")
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
wrapped := toolsdk.WithCleanContext(ctxTool.Handler)
|
|
ctx := context.WithValue(context.Background(), testContextKey{}, "test")
|
|
_, _ = wrapped(ctx, toolsdk.Deps{}, []byte(`{}`))
|
|
})
|
|
|
|
t.Run("PropagateCancel", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// This test is to ensure that the context is canceled properly.
|
|
callCh := make(chan struct{})
|
|
ctxTool := toolsdk.GenericTool{
|
|
Tool: aisdk.Tool{
|
|
Name: "context_tool",
|
|
Description: "Returns the context value for testing.",
|
|
},
|
|
Handler: func(toolCtx context.Context, tb toolsdk.Deps, args json.RawMessage) (json.RawMessage, error) {
|
|
defer close(callCh)
|
|
// Wait for the context to be canceled
|
|
<-toolCtx.Done()
|
|
return nil, toolCtx.Err()
|
|
},
|
|
}
|
|
wrapped := toolsdk.WithCleanContext(ctxTool.Handler)
|
|
errCh := make(chan error, 1)
|
|
|
|
tCtx := testutil.Context(t, testutil.WaitShort)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
go func() {
|
|
_, err := wrapped(ctx, toolsdk.Deps{}, []byte(`{}`))
|
|
errCh <- err
|
|
}()
|
|
|
|
cancel()
|
|
|
|
// Ensure the tool is called
|
|
select {
|
|
case <-callCh:
|
|
case <-tCtx.Done():
|
|
require.Fail(t, "test timed out before handler was called")
|
|
}
|
|
|
|
// Ensure the correct error is returned
|
|
select {
|
|
case <-tCtx.Done():
|
|
require.Fail(t, "test timed out")
|
|
case err := <-errCh:
|
|
// Context was canceled and the done channel was closed
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
}
|
|
})
|
|
|
|
t.Run("PropagateDeadline", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// This test ensures that the context deadline is propagated to the child
|
|
// from the parent.
|
|
ctxTool := toolsdk.GenericTool{
|
|
Tool: aisdk.Tool{
|
|
Name: "context_tool_deadline",
|
|
Description: "Checks if context has deadline.",
|
|
},
|
|
Handler: func(toolCtx context.Context, tb toolsdk.Deps, args json.RawMessage) (json.RawMessage, error) {
|
|
_, ok := toolCtx.Deadline()
|
|
assert.True(t, ok, "expected deadline to be set on the child context")
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
wrapped := toolsdk.WithCleanContext(ctxTool.Handler)
|
|
parent, cancel := context.WithTimeout(context.Background(), testutil.IntervalFast)
|
|
t.Cleanup(cancel)
|
|
_, err := wrapped(parent, toolsdk.Deps{}, []byte(`{}`))
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
func TestToolSchemaFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Test that all tools have the required Schema fields (Properties and Required)
|
|
for _, tool := range toolsdk.All {
|
|
t.Run(tool.Name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Check that Properties is not nil
|
|
require.NotNil(t, tool.Schema.Properties,
|
|
"Tool %q missing Schema.Properties", tool.Name)
|
|
|
|
// Check that Required is not nil
|
|
require.NotNil(t, tool.Schema.Required,
|
|
"Tool %q missing Schema.Required", tool.Name)
|
|
|
|
// Ensure Properties has entries for all required fields
|
|
for _, requiredField := range tool.Schema.Required {
|
|
_, exists := tool.Schema.Properties[requiredField]
|
|
require.True(t, exists,
|
|
"Tool %q requires field %q but it is not defined in Properties",
|
|
tool.Name, requiredField)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMain runs after all tests to ensure that all tools in this package have
|
|
// been tested once.
|
|
func TestMain(m *testing.M) {
|
|
// Initialize testedTools
|
|
for _, tool := range toolsdk.All {
|
|
testedTools.Store(tool.Name, false)
|
|
}
|
|
|
|
code := m.Run()
|
|
|
|
// Ensure all tools have been tested
|
|
var untested []string
|
|
for _, tool := range toolsdk.All {
|
|
if tested, ok := testedTools.Load(tool.Name); !ok || !tested.(bool) {
|
|
untested = append(untested, tool.Name)
|
|
}
|
|
}
|
|
|
|
if len(untested) > 0 && code == 0 {
|
|
_, _ = fmt.Fprintln(os.Stderr, "The following tools were not tested:")
|
|
for _, tool := range untested {
|
|
_, _ = fmt.Fprintf(os.Stderr, " - %s\n", tool)
|
|
}
|
|
_, _ = fmt.Fprintln(os.Stderr, "Please ensure that all tools are tested using testTool().")
|
|
_, _ = fmt.Fprintln(os.Stderr, "If you just added a new tool, please add a test for it.")
|
|
// Only fail when the full suite ran. When -run filters to a
|
|
// subset (e.g. CI flake checks use -run ^TestTools), tools
|
|
// covered by other top-level functions appear untested.
|
|
if f := flag.Lookup("test.run"); f == nil || f.Value.String() == "" {
|
|
code = 1
|
|
} else {
|
|
_, _ = fmt.Fprintln(os.Stderr, "NOTE: if you just ran an individual test, this is expected.")
|
|
}
|
|
}
|
|
|
|
// Check for goroutine leaks. Below is adapted from goleak.VerifyTestMain:
|
|
if code == 0 {
|
|
if err := goleak.Find(testutil.GoleakOptions...); err != nil {
|
|
_, _ = fmt.Fprintln(os.Stderr, "goleak: Errors on successful test run:", err.Error())
|
|
code = 1
|
|
}
|
|
}
|
|
|
|
os.Exit(code)
|
|
}
|
|
|
|
func TestReportTaskNilPointerDeref(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create deps without a task reporter (simulating remote MCP server scenario)
|
|
client, _ := coderdtest.NewWithDatabase(t, nil)
|
|
deps, err := toolsdk.NewDeps(client)
|
|
require.NoError(t, err)
|
|
|
|
// Prepare test arguments
|
|
args := toolsdk.ReportTaskArgs{
|
|
Summary: "Test task",
|
|
Link: "https://example.com",
|
|
State: string(codersdk.WorkspaceAppStatusStateWorking),
|
|
}
|
|
|
|
_, err = toolsdk.ReportTask.Handler(t.Context(), deps, args)
|
|
|
|
// We expect an error, not a panic
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "task reporting not available")
|
|
}
|
|
|
|
func TestReportTaskWithReporter(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create deps with a task reporter
|
|
client, _ := coderdtest.NewWithDatabase(t, nil)
|
|
|
|
called := false
|
|
reporter := func(args toolsdk.ReportTaskArgs) error {
|
|
called = true
|
|
require.Equal(t, "Test task", args.Summary)
|
|
require.Equal(t, "https://example.com", args.Link)
|
|
require.Equal(t, string(codersdk.WorkspaceAppStatusStateWorking), args.State)
|
|
return nil
|
|
}
|
|
|
|
deps, err := toolsdk.NewDeps(client, toolsdk.WithTaskReporter(reporter))
|
|
require.NoError(t, err)
|
|
|
|
args := toolsdk.ReportTaskArgs{
|
|
Summary: "Test task",
|
|
Link: "https://example.com",
|
|
State: string(codersdk.WorkspaceAppStatusStateWorking),
|
|
}
|
|
|
|
result, err := toolsdk.ReportTask.Handler(t.Context(), deps, args)
|
|
require.NoError(t, err)
|
|
require.True(t, called)
|
|
|
|
// Verify response
|
|
require.Equal(t, "Thanks for reporting!", result.Message)
|
|
}
|
|
|
|
func TestNormalizeWorkspaceInput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCases := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "SimpleWorkspace",
|
|
input: "workspace",
|
|
expected: "workspace",
|
|
},
|
|
{
|
|
name: "WorkspaceWithAgent",
|
|
input: "workspace.agent",
|
|
expected: "workspace.agent",
|
|
},
|
|
{
|
|
name: "OwnerAndWorkspace",
|
|
input: "owner/workspace",
|
|
expected: "owner/workspace",
|
|
},
|
|
{
|
|
name: "OwnerDashWorkspace",
|
|
input: "owner--workspace",
|
|
expected: "owner/workspace",
|
|
},
|
|
{
|
|
name: "OwnerWorkspaceAgent",
|
|
input: "owner/workspace.agent",
|
|
expected: "owner/workspace.agent",
|
|
},
|
|
{
|
|
name: "OwnerDashWorkspaceAgent",
|
|
input: "owner--workspace.agent",
|
|
expected: "owner/workspace.agent",
|
|
},
|
|
{
|
|
name: "CoderConnectFormat",
|
|
input: "agent.workspace.owner", // Special Coder Connect reverse format
|
|
expected: "owner/workspace.agent",
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
result := toolsdk.NormalizeWorkspaceInput(tc.input)
|
|
require.Equal(t, tc.expected, result, "Input %q should normalize to %q but got %q", tc.input, tc.expected, result)
|
|
})
|
|
}
|
|
}
|