Files
ctask/cmd/delete_test.go
typebasedio 8120c399df feat(v0.6): AgentSpec field on TaskMeta with backward-compat unmarshal
Replace TaskMeta.Agent (string) with TaskMeta.Agent (AgentSpec) carrying
type/command/args/env. Custom UnmarshalYAML preserves the legacy scalar
form: a built-in name (claude, opencode) maps to that type; any other
scalar maps to type=custom with the scalar as command. A missing agent
field leaves Type empty so the resolver fills in default_agent at launch.

ValidateAgentSpec enforces: known type (claude|opencode|custom),
type=custom requires command, command must be an executable name or
path with no whitespace or shell metacharacters.

Launch-path wiring (Task 3) and the --agent flag rework (Task 4) are
intentionally not part of this commit; cmd/* call sites are patched to
the minimum needed for the build to compile.
2026-05-15 10:58:06 -04:00

173 lines
5.3 KiB
Go

package cmd
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/warrenronsiek/ctask/internal/workspace"
)
// createTestWs creates a minimal workspace for delete testing.
func createTestWs(t *testing.T, root, category, dirName, status string) string {
t.Helper()
dir := filepath.Join(root, category, dirName)
os.MkdirAll(dir, 0755)
os.MkdirAll(filepath.Join(dir, "context"), 0755)
os.MkdirAll(filepath.Join(dir, "output"), 0755)
os.MkdirAll(filepath.Join(dir, "logs"), 0755)
now := time.Now().UTC().Truncate(time.Second)
slug := dirName[11:] // skip "YYYY-MM-DD_"
meta := &workspace.TaskMeta{
ID: "test",
Slug: slug,
Title: slug,
CreatedAt: now,
UpdatedAt: now,
Status: status,
Category: category,
Mode: "local",
Agent: workspace.AgentSpec{Type: "claude"},
}
workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta)
// Write seed files
os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("test claude"), 0644)
os.WriteFile(filepath.Join(dir, "notes.md"), []byte("test notes"), 0644)
return dir
}
func TestActiveSessionManifestBlocksDelete(t *testing.T) {
root := t.TempDir()
wsDir := createTestWs(t, root, "general", "2026-04-06_active-ws", "active")
// Simulate an active session by creating .ctask/manifest-start.json
ctaskDir := filepath.Join(wsDir, ".ctask")
os.MkdirAll(ctaskDir, 0755)
os.WriteFile(filepath.Join(ctaskDir, "manifest-start.json"), []byte(`{"captured_at":"2026-04-06T00:00:00Z","files":[]}`), 0644)
// Verify manifest exists
manifestPath := filepath.Join(wsDir, ".ctask", "manifest-start.json")
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
t.Fatal("manifest should exist for this test")
}
// Verify all workspace files still exist
for _, name := range []string{"task.yaml", "CLAUDE.md", "notes.md"} {
p := filepath.Join(wsDir, name)
if _, err := os.Stat(p); os.IsNotExist(err) {
t.Errorf("pre-check: file %s should exist", name)
}
}
}
func TestNoFileMutationOnActiveSessionRefusal(t *testing.T) {
root := t.TempDir()
wsDir := createTestWs(t, root, "general", "2026-04-06_protected-ws", "active")
// Add extra files to verify nothing gets touched
os.WriteFile(filepath.Join(wsDir, "output", "artifact.txt"), []byte("important data"), 0644)
os.WriteFile(filepath.Join(wsDir, "context", "reference.md"), []byte("reference"), 0644)
// Simulate active session
ctaskDir := filepath.Join(wsDir, ".ctask")
os.MkdirAll(ctaskDir, 0755)
os.WriteFile(filepath.Join(ctaskDir, "manifest-start.json"), []byte(`{"captured_at":"2026-04-06T00:00:00Z","files":[]}`), 0644)
// Record all files before the (would-be) delete attempt
var filesBefore []string
filepath.Walk(wsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(wsDir, path)
filesBefore = append(filesBefore, rel)
return nil
})
// The delete command would check for manifest and refuse.
// We verify the check logic directly:
manifestPath := filepath.Join(wsDir, ".ctask", "manifest-start.json")
_, manifestErr := os.Stat(manifestPath)
if manifestErr != nil {
t.Fatal("manifest should exist, blocking delete")
}
// Verify ALL files still exist after the protection check
var filesAfter []string
filepath.Walk(wsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(wsDir, path)
filesAfter = append(filesAfter, rel)
return nil
})
if len(filesBefore) != len(filesAfter) {
t.Errorf("file count changed: before=%d, after=%d", len(filesBefore), len(filesAfter))
}
// Verify specific important files
for _, name := range []string{"task.yaml", "CLAUDE.md", "notes.md", "output/artifact.txt", "context/reference.md"} {
p := filepath.Join(wsDir, name)
if _, err := os.Stat(p); os.IsNotExist(err) {
t.Errorf("file %s was deleted/modified during protection check", name)
}
}
}
func TestInactiveWorkspaceCanBeDeleted(t *testing.T) {
root := t.TempDir()
wsDir := createTestWs(t, root, "general", "2026-04-06_deletable-ws", "active")
// No manifest = no active session
manifestPath := filepath.Join(wsDir, ".ctask", "manifest-start.json")
if _, err := os.Stat(manifestPath); err == nil {
t.Fatal("manifest should NOT exist for this test")
}
// Verify the workspace exists
if _, err := os.Stat(wsDir); os.IsNotExist(err) {
t.Fatal("workspace should exist before delete")
}
// Simulate deletion (what the command does after all checks pass)
if err := os.RemoveAll(wsDir); err != nil {
t.Fatalf("delete failed: %v", err)
}
// Verify it's gone
if _, err := os.Stat(wsDir); !os.IsNotExist(err) {
t.Error("workspace should be deleted")
}
}
func TestEnvVarAlsoBlocksDelete(t *testing.T) {
root := t.TempDir()
wsDir := createTestWs(t, root, "general", "2026-04-06_envvar-ws", "active")
// Set CTASK_WORKSPACE to match
os.Setenv("CTASK_WORKSPACE", wsDir)
defer os.Unsetenv("CTASK_WORKSPACE")
absTarget, _ := filepath.Abs(wsDir)
absActive, _ := filepath.Abs(os.Getenv("CTASK_WORKSPACE"))
// Verify the env var check would match
if absTarget != absActive {
t.Fatalf("paths should match: %q vs %q", absTarget, absActive)
}
// Verify files are untouched
for _, name := range []string{"task.yaml", "CLAUDE.md", "notes.md"} {
p := filepath.Join(wsDir, name)
if _, err := os.Stat(p); os.IsNotExist(err) {
t.Errorf("file %s should still exist", name)
}
}
}