test: introduce afero-based mock filesystem for hermetic tests

This commit is contained in:
2026-05-23 13:12:15 +01:00
parent 2334ed7922
commit 7bd70bfaeb
10 changed files with 379 additions and 47 deletions
+47 -24
View File
@@ -6,11 +6,22 @@ import (
"path/filepath"
"strings"
"github.com/spf13/afero"
"gopkg.in/yaml.v3"
"github.com/lukaszraczylo/harness-sync/internal/fsx"
)
// Load loads a canonical Bundle from the given root directory using the OS
// filesystem. It is a thin wrapper around LoadFS.
func Load(root string) (*Bundle, error) {
info, err := os.Stat(root)
return LoadFS(fsx.OS(), root)
}
// LoadFS loads a canonical Bundle from root using the provided filesystem.
// Use fsx.Mem() in tests to avoid touching real disk.
func LoadFS(fs fsx.FS, root string) (*Bundle, error) {
info, err := fs.Stat(root)
if err != nil {
return nil, fmt.Errorf("canonical root %s: %w", root, err)
}
@@ -20,7 +31,7 @@ func Load(root string) (*Bundle, error) {
b := &Bundle{Root: root}
err = loadYAML(filepath.Join(root, "config.yaml"), &b.Config)
err = loadYAMLFS(fs, filepath.Join(root, "config.yaml"), &b.Config)
if err != nil {
return nil, err
}
@@ -29,7 +40,7 @@ func Load(root string) (*Bundle, error) {
}
profPath := filepath.Join(root, "profiles", b.Config.ActiveProfile+".yaml")
err = loadYAML(profPath, &b.Profile)
err = loadYAMLFS(fs, profPath, &b.Profile)
if err != nil {
return nil, fmt.Errorf("profile %q: %w", b.Config.ActiveProfile, err)
}
@@ -38,14 +49,14 @@ func Load(root string) (*Bundle, error) {
}
mcpPath := filepath.Join(root, "mcp.yaml")
if fileExists(mcpPath) {
err = loadYAML(mcpPath, &b.MCP)
if fileExistsFS(fs, mcpPath) {
err = loadYAMLFS(fs, mcpPath, &b.MCP)
if err != nil {
return nil, err
}
}
skills, err := loadMarkdownDir(filepath.Join(root, "skills"), "SKILL.md")
skills, err := loadMarkdownDirFS(fs, filepath.Join(root, "skills"), "SKILL.md")
if err != nil {
return nil, err
}
@@ -53,7 +64,7 @@ func Load(root string) (*Bundle, error) {
b.Skills = append(b.Skills, Skill(m))
}
agents, err := loadMarkdownDir(filepath.Join(root, "agents"), "")
agents, err := loadMarkdownDirFS(fs, filepath.Join(root, "agents"), "")
if err != nil {
return nil, err
}
@@ -62,24 +73,35 @@ func Load(root string) (*Bundle, error) {
}
b.Instructions.PerHarness = map[string]string{}
body, err := readFileIfExists(filepath.Join(root, "instructions", "global.md"))
body, err := readFileIfExistsFS(fs, filepath.Join(root, "instructions", "global.md"))
if err != nil {
return nil, err
}
b.Instructions.Global = body
perHarnessDir := filepath.Join(root, "instructions", "per-harness")
if entries, err := os.ReadDir(perHarnessDir); err == nil {
if dirExistsFS(fs, perHarnessDir) {
dir, err := fs.Open(perHarnessDir)
if err != nil {
return nil, err
}
entries, err := dir.Readdir(-1)
if closeErr := dir.Close(); closeErr != nil && err == nil {
err = closeErr
}
if err != nil {
return nil, err
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
body, err := os.ReadFile(filepath.Join(perHarnessDir, e.Name()))
entryBody, err := afero.ReadFile(fs, filepath.Join(perHarnessDir, e.Name()))
if err != nil {
return nil, err
}
harness := strings.TrimSuffix(e.Name(), ".md")
b.Instructions.PerHarness[harness] = string(body)
b.Instructions.PerHarness[harness] = string(entryBody)
}
}
@@ -93,12 +115,13 @@ type markdownDoc struct {
Path string
}
func loadMarkdownDir(dir, requiredFilename string) ([]markdownDoc, error) {
// loadMarkdownDirFS walks dir on fs and returns one markdownDoc per matching file.
func loadMarkdownDirFS(fs fsx.FS, dir, requiredFilename string) ([]markdownDoc, error) {
var docs []markdownDoc
if !dirExists(dir) {
if !dirExistsFS(fs, dir) {
return docs, nil
}
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
err := afero.Walk(fs, dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
@@ -111,7 +134,7 @@ func loadMarkdownDir(dir, requiredFilename string) ([]markdownDoc, error) {
if requiredFilename == "" && !strings.HasSuffix(path, ".md") {
return nil
}
body, err := os.ReadFile(path)
body, err := afero.ReadFile(fs, path)
if err != nil {
return err
}
@@ -149,8 +172,8 @@ func parseFrontmatter(b []byte) (name, description string) {
return meta.Name, meta.Description
}
func loadYAML(path string, out interface{}) error {
b, err := os.ReadFile(path)
func loadYAMLFS(fs fsx.FS, path string, out interface{}) error {
b, err := afero.ReadFile(fs, path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
@@ -160,9 +183,9 @@ func loadYAML(path string, out interface{}) error {
return nil
}
func readFileIfExists(path string) (string, error) {
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
func readFileIfExistsFS(fs fsx.FS, path string) (string, error) {
b, err := afero.ReadFile(fs, path)
if err != nil && os.IsNotExist(err) {
return "", nil
}
if err != nil {
@@ -171,12 +194,12 @@ func readFileIfExists(path string) (string, error) {
return string(b), nil
}
func fileExists(path string) bool {
info, err := os.Stat(path)
func fileExistsFS(fs fsx.FS, path string) bool {
info, err := fs.Stat(path)
return err == nil && !info.IsDir()
}
func dirExists(path string) bool {
info, err := os.Stat(path)
func dirExistsFS(fs fsx.FS, path string) bool {
info, err := fs.Stat(path)
return err == nil && info.IsDir()
}
+79
View File
@@ -0,0 +1,79 @@
package canonical_test
import (
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
"github.com/lukaszraczylo/harness-sync/internal/fsx"
)
// writeFile is a convenience helper: writes bytes to path on fs, creating any
// parent directories first.
func writeFile(t *testing.T, fs fsx.FS, path string, content []byte) {
t.Helper()
require.NoError(t, fs.MkdirAll(parentDir(path), 0o755))
require.NoError(t, afero.WriteFile(fs, path, content, 0o644))
}
func parentDir(path string) string {
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '/' {
return path[:i]
}
}
return "/"
}
func TestLoadFSFromMemFS(t *testing.T) {
mem := fsx.Mem()
configYAML := []byte("active_profile: test\n")
profileYAML := []byte("name: Test Profile\ngateway:\n url: https://example.com\n token: tok\n default_model: gpt4\n")
skillBody := []byte("---\nname: my-skill\ndescription: A test skill\n---\n# My Skill\nsome content\n")
writeFile(t, mem, "/root/config.yaml", configYAML)
writeFile(t, mem, "/root/profiles/test.yaml", profileYAML)
writeFile(t, mem, "/root/skills/my-skill/SKILL.md", skillBody)
bundle, err := canonical.LoadFS(mem, "/root")
require.NoError(t, err)
require.NotNil(t, bundle)
assert.Equal(t, "test", bundle.Config.ActiveProfile)
assert.Equal(t, "Test Profile", bundle.Profile.Name)
require.Len(t, bundle.Skills, 1)
assert.Equal(t, "my-skill", bundle.Skills[0].Name)
assert.Equal(t, "A test skill", bundle.Skills[0].Description)
}
func TestLoadFSEmptyProfile(t *testing.T) {
mem := fsx.Mem()
// config.yaml present but profile file is missing
writeFile(t, mem, "/root/config.yaml", []byte("active_profile: missing\n"))
_, err := canonical.LoadFS(mem, "/root")
require.Error(t, err)
assert.Contains(t, err.Error(), "missing")
}
func TestLoadFSMissingRoot(t *testing.T) {
mem := fsx.Mem()
_, err := canonical.LoadFS(mem, "/does-not-exist")
require.Error(t, err)
}
func TestLoadFSActiveProfileRequired(t *testing.T) {
mem := fsx.Mem()
writeFile(t, mem, "/root/config.yaml", []byte("enabled_harnesses: []\n"))
_, err := canonical.LoadFS(mem, "/root")
require.Error(t, err)
assert.Contains(t, err.Error(), "active_profile is required")
}