multica/server/internal/daemon/execenv/execenv_test.go
Jiayuan 7d126cc549 feat(daemon): group task directories by workspace ID
Task execution environments were all created flat under WorkspacesRoot,
mixing tasks from different workspaces. Now tasks are nested under their
workspace ID for clearer organization and easier per-workspace cleanup.
2026-03-30 20:13:30 +08:00

576 lines
16 KiB
Go

package execenv
import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
)
func testLogger() *slog.Logger {
return slog.Default()
}
func TestShortID(t *testing.T) {
t.Parallel()
tests := []struct {
input, want string
}{
{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "a1b2c3d4"},
{"abcdef12", "abcdef12"},
{"ab", "ab"},
{"a1b2c3d4e5f67890", "a1b2c3d4"},
}
for _, tt := range tests {
if got := shortID(tt.input); got != tt.want {
t.Errorf("shortID(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestSanitizeName(t *testing.T) {
t.Parallel()
tests := []struct {
input, want string
}{
{"Code Reviewer", "code-reviewer"},
{"my_agent!@#v2", "my-agent-v2"},
{" spaces ", "spaces"},
{"UPPERCASE", "uppercase"},
{"a-very-long-name-that-exceeds-thirty-characters-total", "a-very-long-name-that-exceeds"},
{"", "agent"},
{"---", "agent"},
{"日本語テスト", "agent"},
}
for _, tt := range tests {
if got := sanitizeName(tt.input); got != tt.want {
t.Errorf("sanitizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestRepoNameFromURL(t *testing.T) {
t.Parallel()
tests := []struct {
input, want string
}{
{"https://github.com/org/my-repo.git", "my-repo"},
{"https://github.com/org/my-repo", "my-repo"},
{"git@github.com:org/my-repo.git", "my-repo"},
{"https://github.com/org/repo/", "repo"},
{"my-repo", "my-repo"},
{"", "repo"},
}
for _, tt := range tests {
if got := repoNameFromURL(tt.input); got != tt.want {
t.Errorf("repoNameFromURL(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestPrepareDirectoryMode(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-test-001",
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
AgentName: "Test Agent",
Task: TaskContextForEnv{
IssueID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
AgentSkills: []SkillContextForEnv{
{Name: "Code Review", Content: "Be concise."},
},
},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
// Verify directory structure.
for _, sub := range []string{"workdir", "output", "logs"} {
path := filepath.Join(env.RootDir, sub)
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatalf("expected %s to exist", path)
}
}
// Verify context file contains issue ID and CLI hints.
content, err := os.ReadFile(filepath.Join(env.WorkDir, ".agent_context", "issue_context.md"))
if err != nil {
t.Fatalf("failed to read issue_context.md: %v", err)
}
for _, want := range []string{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "multica issue get", "Code Review"} {
if !strings.Contains(string(content), want) {
t.Fatalf("issue_context.md missing %q", want)
}
}
// Verify skill files.
skillContent, err := os.ReadFile(filepath.Join(env.WorkDir, ".agent_context", "skills", "code-review", "SKILL.md"))
if err != nil {
t.Fatalf("failed to read SKILL.md: %v", err)
}
if !strings.Contains(string(skillContent), "Be concise.") {
t.Fatal("SKILL.md missing content")
}
}
func TestPrepareWithRepoContext(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()
taskCtx := TaskContextForEnv{
IssueID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
Repos: []RepoContextForEnv{
{URL: "https://github.com/org/backend", Description: "Go backend"},
{URL: "https://github.com/org/frontend", Description: "React frontend"},
},
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-test-002",
TaskID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
AgentName: "Code Reviewer",
Provider: "claude",
Task: taskCtx,
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
// Inject runtime config (done separately in daemon, replicate here).
if err := InjectRuntimeConfig(env.WorkDir, "claude", taskCtx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
// Workdir should be empty (no pre-created repo dirs).
entries, err := os.ReadDir(env.WorkDir)
if err != nil {
t.Fatalf("failed to read workdir: %v", err)
}
for _, e := range entries {
name := e.Name()
if name != ".agent_context" && name != "CLAUDE.md" && name != ".claude" {
t.Errorf("unexpected entry in workdir: %s", name)
}
}
// CLAUDE.md should contain repo info.
content, err := os.ReadFile(filepath.Join(env.WorkDir, "CLAUDE.md"))
if err != nil {
t.Fatalf("failed to read CLAUDE.md: %v", err)
}
s := string(content)
for _, want := range []string{
"multica repo checkout",
"https://github.com/org/backend",
"Go backend",
"https://github.com/org/frontend",
"React frontend",
} {
if !strings.Contains(s, want) {
t.Errorf("CLAUDE.md missing %q", want)
}
}
}
func TestWriteContextFiles(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "test-issue-id-1234",
AgentSkills: []SkillContextForEnv{
{
Name: "Go Conventions",
Content: "Follow Go conventions.",
Files: []SkillFileContextForEnv{
{Path: "templates/example.go", Content: "package main"},
},
},
},
}
if err := writeContextFiles(dir, "", ctx); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, ".agent_context", "issue_context.md"))
if err != nil {
t.Fatalf("failed to read: %v", err)
}
s := string(content)
for _, want := range []string{
"test-issue-id-1234",
"multica issue get",
"## Agent Skills",
"Go Conventions",
} {
if !strings.Contains(s, want) {
t.Errorf("content missing %q", want)
}
}
// Issue details should NOT be in the context file (agent fetches via CLI).
for _, absent := range []string{"## Description", "## Workspace Context"} {
if strings.Contains(s, absent) {
t.Errorf("content should NOT contain %q — agent fetches details via CLI", absent)
}
}
// Verify skill directory and files.
skillMd, err := os.ReadFile(filepath.Join(dir, ".agent_context", "skills", "go-conventions", "SKILL.md"))
if err != nil {
t.Fatalf("failed to read SKILL.md: %v", err)
}
if !strings.Contains(string(skillMd), "Follow Go conventions.") {
t.Error("SKILL.md missing content")
}
supportFile, err := os.ReadFile(filepath.Join(dir, ".agent_context", "skills", "go-conventions", "templates", "example.go"))
if err != nil {
t.Fatalf("failed to read supporting file: %v", err)
}
if string(supportFile) != "package main" {
t.Errorf("supporting file content = %q, want %q", string(supportFile), "package main")
}
}
func TestWriteContextFilesOmitsSkillsWhenEmpty(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "minimal-issue-id",
}
if err := writeContextFiles(dir, "", ctx); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, ".agent_context", "issue_context.md"))
if err != nil {
t.Fatalf("failed to read: %v", err)
}
s := string(content)
if !strings.Contains(s, "minimal-issue-id") {
t.Error("expected issue ID to be present")
}
if strings.Contains(s, "## Agent Skills") {
t.Error("expected skills section to be omitted when no skills")
}
}
func TestWriteContextFilesClaudeNativeSkills(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "claude-skill-test",
AgentSkills: []SkillContextForEnv{
{
Name: "Go Conventions",
Content: "Follow Go conventions.",
Files: []SkillFileContextForEnv{
{Path: "templates/example.go", Content: "package main"},
},
},
},
}
if err := writeContextFiles(dir, "claude", ctx); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
// Skills should be in .claude/skills/ (native discovery), NOT .agent_context/skills/.
skillMd, err := os.ReadFile(filepath.Join(dir, ".claude", "skills", "go-conventions", "SKILL.md"))
if err != nil {
t.Fatalf("failed to read .claude/skills/go-conventions/SKILL.md: %v", err)
}
if !strings.Contains(string(skillMd), "Follow Go conventions.") {
t.Error("SKILL.md missing content")
}
// Supporting files should also be under .claude/skills/.
supportFile, err := os.ReadFile(filepath.Join(dir, ".claude", "skills", "go-conventions", "templates", "example.go"))
if err != nil {
t.Fatalf("failed to read supporting file: %v", err)
}
if string(supportFile) != "package main" {
t.Errorf("supporting file content = %q, want %q", string(supportFile), "package main")
}
// .agent_context/skills/ should NOT exist for Claude.
if _, err := os.Stat(filepath.Join(dir, ".agent_context", "skills")); !os.IsNotExist(err) {
t.Error("expected .agent_context/skills/ to NOT exist for Claude provider")
}
// issue_context.md should still be in .agent_context/.
if _, err := os.Stat(filepath.Join(dir, ".agent_context", "issue_context.md")); os.IsNotExist(err) {
t.Error("expected .agent_context/issue_context.md to exist")
}
}
func TestCleanupPreservesLogs(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-test-003",
TaskID: "d4e5f6a7-b8c9-0123-defa-234567890123",
AgentName: "Preserve Test",
Task: TaskContextForEnv{IssueID: "preserve-test-id"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
// Write something to logs/.
os.WriteFile(filepath.Join(env.RootDir, "logs", "test.log"), []byte("log data"), 0o644)
// Cleanup with removeAll=false.
if err := env.Cleanup(false); err != nil {
t.Fatalf("Cleanup failed: %v", err)
}
// workdir should be gone.
if _, err := os.Stat(env.WorkDir); !os.IsNotExist(err) {
t.Fatal("expected workdir to be removed")
}
// logs should still exist.
logFile := filepath.Join(env.RootDir, "logs", "test.log")
if _, err := os.Stat(logFile); os.IsNotExist(err) {
t.Fatal("expected logs/test.log to be preserved")
}
}
func TestInjectRuntimeConfigClaude(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "test-issue-id",
AgentSkills: []SkillContextForEnv{
{Name: "Go Conventions", Content: "Follow Go conventions.", Files: []SkillFileContextForEnv{
{Path: "example.go", Content: "package main"},
}},
{Name: "PR Review", Content: "Review PRs carefully."},
},
}
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("failed to read CLAUDE.md: %v", err)
}
s := string(content)
for _, want := range []string{
"Multica Agent Runtime",
"multica issue get",
"multica issue comment list",
"Go Conventions",
"PR Review",
"discovered automatically",
} {
if !strings.Contains(s, want) {
t.Errorf("CLAUDE.md missing %q", want)
}
}
}
func TestInjectRuntimeConfigCodex(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "test-issue-id",
AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}},
}
if err := InjectRuntimeConfig(dir, "codex", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
if err != nil {
t.Fatalf("failed to read AGENTS.md: %v", err)
}
s := string(content)
if !strings.Contains(s, "Multica Agent Runtime") {
t.Error("AGENTS.md missing meta skill header")
}
if !strings.Contains(s, "Coding") {
t.Error("AGENTS.md missing skill name")
}
}
func TestInjectRuntimeConfigNoSkills(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{IssueID: "test-issue-id"}
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("failed to read CLAUDE.md: %v", err)
}
s := string(content)
if !strings.Contains(s, "multica issue get") {
t.Error("should reference multica CLI even without skills")
}
if strings.Contains(s, "## Skills") {
t.Error("should not have Skills section when there are no skills")
}
}
func TestInjectRuntimeConfigUnknownProvider(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// Unknown provider should be a no-op.
if err := InjectRuntimeConfig(dir, "unknown", TaskContextForEnv{}); err != nil {
t.Fatalf("expected no error for unknown provider, got: %v", err)
}
// No files should be created.
entries, _ := os.ReadDir(dir)
if len(entries) != 0 {
t.Fatalf("expected empty dir for unknown provider, got %d entries", len(entries))
}
}
func TestPrepareCodexHomeSeedsFromShared(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
// Create a fake shared codex home.
sharedHome := t.TempDir()
os.WriteFile(filepath.Join(sharedHome, "auth.json"), []byte(`{"token":"secret"}`), 0o644)
os.WriteFile(filepath.Join(sharedHome, "config.json"), []byte(`{"model":"o3"}`), 0o644)
os.WriteFile(filepath.Join(sharedHome, "config.toml"), []byte(`model = "o3"`), 0o644)
os.WriteFile(filepath.Join(sharedHome, "instructions.md"), []byte("Be helpful."), 0o644)
// Point CODEX_HOME to our fake shared home.
t.Setenv("CODEX_HOME", sharedHome)
codexHome := filepath.Join(t.TempDir(), "codex-home")
if err := prepareCodexHome(codexHome, testLogger()); err != nil {
t.Fatalf("prepareCodexHome failed: %v", err)
}
// auth.json should be a symlink.
authPath := filepath.Join(codexHome, "auth.json")
fi, err := os.Lstat(authPath)
if err != nil {
t.Fatalf("auth.json not found: %v", err)
}
if fi.Mode()&os.ModeSymlink == 0 {
t.Error("auth.json should be a symlink")
}
target, _ := os.Readlink(authPath)
if target != filepath.Join(sharedHome, "auth.json") {
t.Errorf("auth.json symlink target = %q, want %q", target, filepath.Join(sharedHome, "auth.json"))
}
// Verify content is accessible through symlink.
data, _ := os.ReadFile(authPath)
if string(data) != `{"token":"secret"}` {
t.Errorf("auth.json content = %q", data)
}
// config.json should be a copy (not symlink).
configPath := filepath.Join(codexHome, "config.json")
fi, err = os.Lstat(configPath)
if err != nil {
t.Fatalf("config.json not found: %v", err)
}
if fi.Mode()&os.ModeSymlink != 0 {
t.Error("config.json should be a copy, not a symlink")
}
data, _ = os.ReadFile(configPath)
if string(data) != `{"model":"o3"}` {
t.Errorf("config.json content = %q", data)
}
// config.toml should be copied.
data, _ = os.ReadFile(filepath.Join(codexHome, "config.toml"))
if string(data) != `model = "o3"` {
t.Errorf("config.toml content = %q", data)
}
// instructions.md should be copied.
data, _ = os.ReadFile(filepath.Join(codexHome, "instructions.md"))
if string(data) != "Be helpful." {
t.Errorf("instructions.md content = %q", data)
}
}
func TestPrepareCodexHomeSkipsMissingFiles(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
// Empty shared home — no files to seed.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
codexHome := filepath.Join(t.TempDir(), "codex-home")
if err := prepareCodexHome(codexHome, testLogger()); err != nil {
t.Fatalf("prepareCodexHome failed: %v", err)
}
// Directory should exist but be empty (no auth.json, no config.json, etc.).
entries, err := os.ReadDir(codexHome)
if err != nil {
t.Fatalf("failed to read codex-home: %v", err)
}
if len(entries) != 0 {
names := make([]string, len(entries))
for i, e := range entries {
names[i] = e.Name()
}
t.Errorf("expected empty codex-home, got: %v", names)
}
}
func TestEnsureSymlinkRepairsBrokenLink(t *testing.T) {
t.Parallel()
dir := t.TempDir()
src := filepath.Join(dir, "source.json")
dst := filepath.Join(dir, "link.json")
os.WriteFile(src, []byte("real"), 0o644)
// Create a broken symlink pointing to a non-existent file.
os.Symlink(filepath.Join(dir, "old-source.json"), dst)
if err := ensureSymlink(src, dst); err != nil {
t.Fatalf("ensureSymlink failed: %v", err)
}
// Should now point to src.
target, _ := os.Readlink(dst)
if target != src {
t.Errorf("symlink target = %q, want %q", target, src)
}
data, _ := os.ReadFile(dst)
if string(data) != "real" {
t.Errorf("content = %q, want %q", data, "real")
}
}