test(org-external): integration + e2e for !external resolver (PR-B + PR-C) #106

Merged
claude-ceo-assistant merged 1 commits from feature/external-ref-pr-bc-tests into staging 2026-05-08 12:33:45 +00:00
2 changed files with 421 additions and 0 deletions

View File

@ -3,10 +3,13 @@ package handlers
import (
"archive/tar"
"bytes"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"gopkg.in/yaml.v3"
)
@ -247,3 +250,126 @@ func TestLocalE2E_FilesDirConsumption(t *testing.T) {
t.Errorf("expected ~28 workspaces with files_dir (post-atomization); only saw %d", checked)
}
}
// PR-C from the Phase 3a phasing (task #234): real-Gitea e2e for the
// !external resolver against the LIVE molecule-ai/molecule-dev-department
// repo. Verifies the production gitFetcher fetches the dev tree and the
// resolver grafts it correctly into a parent template that has NO
// symlink — composition is purely platform-side.
//
// Skipped if Gitea isn't reachable (offline / firewall / CI without
// network). Requires `git` binary on PATH.
func TestLocalE2E_ExternalDevDepartment(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skipf("git binary not found: %v", err)
}
// Skip if Gitea host isn't reachable (TCP probe). Avoids network-
// dependent tests failing on offline runners.
conn, err := net.DialTimeout("tcp", "git.moleculesai.app:443", 3*time.Second)
if err != nil {
t.Skipf("git.moleculesai.app:443 unreachable: %v", err)
}
conn.Close()
// Build a minimal parent template inline — no need for the
// /tmp/local-e2e-deploy/ symlinked fixture. The whole point of
// !external is that the parent template is self-contained;
// composition resolves over the network at import time.
parent := t.TempDir()
orgYAML := []byte(`name: External-Only Test Parent
description: Parent template that pulls the entire dev tree via !external.
defaults:
runtime: claude-code
tier: 2
workspaces:
- !external
repo: molecule-ai/molecule-dev-department
ref: main
path: dev-lead/workspace.yaml
`)
if err := os.WriteFile(filepath.Join(parent, "org.yaml"), orgYAML, 0o644); err != nil {
t.Fatalf("write org.yaml: %v", err)
}
out, err := resolveYAMLIncludes(orgYAML, parent)
if err != nil {
t.Fatalf("resolveYAMLIncludes (!external against live Gitea): %v", err)
}
var tmpl OrgTemplate
if err := yaml.Unmarshal(out, &tmpl); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// Walk the workspace tree, collect names + check files_dir paths.
flat := []OrgWorkspace{}
var walk func([]OrgWorkspace)
walk = func(ws []OrgWorkspace) {
for _, w := range ws {
flat = append(flat, w)
walk(w.Children)
}
}
walk(tmpl.Workspaces)
t.Logf("workspaces resolved through !external: %d", len(flat))
if len(flat) < 25 {
t.Errorf("expected ~28 dev-tree workspaces via !external; got %d", len(flat))
}
// Sentinel checks — same as TestLocalE2E_DevDepartmentExtraction
// (Q1+Q2 placements verified).
expected := []string{
"Dev Lead",
"Core Platform Lead",
"Controlplane Lead",
"App & Docs Lead",
"Documentation Specialist", // Q1
"Triage Operator", // Q2
}
found := map[string]bool{}
for _, w := range flat {
found[w.Name] = true
}
for _, want := range expected {
if !found[want] {
t.Errorf("missing expected workspace %q", want)
}
}
// Every workspace's files_dir must be cache-prefixed (proves the
// path-rewrite ran end-to-end).
cachePrefix := ".external-cache"
for _, w := range flat {
if w.FilesDir == "" {
continue
}
if !strings.HasPrefix(w.FilesDir, cachePrefix) {
t.Errorf("workspace %q files_dir %q missing cache prefix %q", w.Name, w.FilesDir, cachePrefix)
}
}
// Verify the fetched cache exists and resolveInsideRoot accepts
// every workspace's files_dir (would cause provisioning to fail
// if not).
for _, w := range flat {
if w.FilesDir == "" {
continue
}
abs, err := resolveInsideRoot(parent, w.FilesDir)
if err != nil {
t.Errorf("workspace %q files_dir %q: resolveInsideRoot: %v", w.Name, w.FilesDir, err)
continue
}
info, err := os.Stat(abs)
if err != nil {
t.Errorf("workspace %q: stat %q: %v", w.Name, abs, err)
continue
}
if !info.IsDir() {
t.Errorf("workspace %q files_dir %q is not a directory", w.Name, w.FilesDir)
}
}
}

View File

@ -0,0 +1,295 @@
package handlers
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
// PR-B integration test: exercises the REAL gitFetcher (no fakeFetcher
// injection) against a local bare-git repo. Uses git's `insteadOf`
// config to rewrite the configured Gitea URL to the local bare path
// at clone time, so the fetcher's URL-building, ls-remote, clone,
// atomic-rename, and cache-hit paths all run against real git
// without requiring network or modifying production code.
//
// Internal#77 task #233 (PR-B from the design's phasing).
// TestGitFetcher_RealClone_LocalRedirect proves the production
// gitFetcher round-trips correctly against a real git repository.
// Steps:
// 1. Set up a local bare-git repo with workspace content.
// 2. Configure git's `insteadOf` to rewrite the gitea URL → local path
// via GIT_CONFIG_COUNT/KEY/VALUE env vars (process-scoped).
// 3. Run resolveYAMLIncludes with !external pointing at the gitea URL.
// 4. Assert: cache dir populated; content materialized; path rewrite
// applied; second invocation hits cache (no second clone).
func TestGitFetcher_RealClone_LocalRedirect(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skipf("git binary not found: %v", err)
}
if runtime.GOOS == "windows" {
t.Skip("path-based git URLs behave differently on Windows; skipping")
}
// Step 1: create a local bare-git repo at <fixtures>/test-dev-dept.git
// with workspace content. Use a working clone to add content, then
// push to the bare.
fixtures := t.TempDir()
barePath := filepath.Join(fixtures, "test-dev-dept.git")
workPath := filepath.Join(fixtures, "work")
mustGit(t, "", "init", "--bare", "-b", "main", barePath)
mustGit(t, "", "clone", barePath, workPath)
mustGit(t, workPath, "config", "user.email", "test@example.com")
mustGit(t, workPath, "config", "user.name", "Integration Test")
mustWriteFile(t, filepath.Join(workPath, "dev-lead/workspace.yaml"), `name: Dev Lead
files_dir: dev-lead
children:
- !include ./core-be/workspace.yaml
`)
mustWriteFile(t, filepath.Join(workPath, "dev-lead/system-prompt.md"), "Dev Lead persona body.\n")
mustWriteFile(t, filepath.Join(workPath, "dev-lead/core-be/workspace.yaml"), `name: Core BE
files_dir: dev-lead/core-be
`)
mustWriteFile(t, filepath.Join(workPath, "dev-lead/core-be/system-prompt.md"), "Core BE persona body.\n")
mustGit(t, workPath, "add", ".")
mustGit(t, workPath, "commit", "-m", "seed dev tree")
mustGit(t, workPath, "push", "origin", "main")
// Step 2: configure git's insteadOf rewrite. The fetcher will try
// to clone https://git.moleculesai.app/molecule-ai/test-dev-dept.git;
// git rewrites to file://<barePath>.
//
// GIT_CONFIG_COUNT/KEY/VALUE injects config without touching
// ~/.gitconfig — process-scoped, no test pollution.
geesUrl := "https://git.moleculesai.app/molecule-ai/test-dev-dept.git"
t.Setenv("GIT_CONFIG_COUNT", "1")
t.Setenv("GIT_CONFIG_KEY_0", "url."+barePath+".insteadOf")
t.Setenv("GIT_CONFIG_VALUE_0", geesUrl)
// Step 3: run resolveYAMLIncludes with !external pointing at the
// gitea URL. Allowlist is the default (molecule-ai/* on Gitea host).
rootDir := t.TempDir()
src := []byte(`workspaces:
- !external
repo: molecule-ai/test-dev-dept
ref: main
path: dev-lead/workspace.yaml
`)
out, err := resolveYAMLIncludes(src, rootDir)
if err != nil {
t.Fatalf("resolveYAMLIncludes: %v", err)
}
var tmpl OrgTemplate
if err := yaml.Unmarshal(out, &tmpl); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(tmpl.Workspaces) != 1 {
t.Fatalf("workspaces: %+v", tmpl.Workspaces)
}
dev := tmpl.Workspaces[0]
if dev.Name != "Dev Lead" {
t.Errorf("dev.Name = %q; want Dev Lead", dev.Name)
}
if !strings.Contains(dev.FilesDir, ".external-cache") {
t.Errorf("dev.FilesDir = %q; want cache prefix", dev.FilesDir)
}
if !strings.HasSuffix(dev.FilesDir, "dev-lead") {
t.Errorf("dev.FilesDir = %q; want suffix dev-lead", dev.FilesDir)
}
if len(dev.Children) != 1 {
t.Fatalf("expected nested core-be child; got %+v", dev.Children)
}
core := dev.Children[0]
if core.Name != "Core BE" {
t.Errorf("core.Name = %q; want Core BE", core.Name)
}
if !strings.HasSuffix(core.FilesDir, filepath.Join("dev-lead", "core-be")) {
t.Errorf("core.FilesDir = %q; want suffix dev-lead/core-be", core.FilesDir)
}
// Step 4: verify the cache dir actually exists and contains the
// materialized files (CopyTemplateToContainer would tar these).
cacheRoot := filepath.Join(rootDir, ".external-cache")
entries, err := os.ReadDir(cacheRoot)
if err != nil {
t.Fatalf("read cache root: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 cached repo, got %d: %v", len(entries), entries)
}
repoDir := filepath.Join(cacheRoot, entries[0].Name())
shaDirs, _ := os.ReadDir(repoDir)
if len(shaDirs) != 1 {
t.Fatalf("expected 1 SHA cache dir, got %d", len(shaDirs))
}
cacheDir := filepath.Join(repoDir, shaDirs[0].Name())
if _, err := os.Stat(filepath.Join(cacheDir, "dev-lead/system-prompt.md")); err != nil {
t.Errorf("expected dev-lead/system-prompt.md in cache: %v", err)
}
if _, err := os.Stat(filepath.Join(cacheDir, "dev-lead/core-be/system-prompt.md")); err != nil {
t.Errorf("expected dev-lead/core-be/system-prompt.md in cache: %v", err)
}
// Step 5: re-run; verify cache hit (no second clone). Set a
// "marker" file in the cache that a second clone would clobber.
marker := filepath.Join(cacheDir, ".cache-hit-marker")
if err := os.WriteFile(marker, []byte("hit"), 0o644); err != nil {
t.Fatal(err)
}
out2, err := resolveYAMLIncludes(src, rootDir)
if err != nil {
t.Fatalf("resolveYAMLIncludes second call: %v", err)
}
if string(out) != string(out2) {
t.Errorf("cached output differs from initial — non-deterministic resolve")
}
if _, err := os.Stat(marker); err != nil {
t.Errorf("cache hit not honored — marker file disappeared: %v", err)
}
}
// TestGitFetcher_RealClone_BadRefFails: pointing at a ref that doesn't
// exist in the bare-repo surfaces git's error cleanly.
func TestGitFetcher_RealClone_BadRefFails(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skipf("git binary not found: %v", err)
}
if runtime.GOOS == "windows" {
t.Skip("skipping on windows")
}
fixtures := t.TempDir()
barePath := filepath.Join(fixtures, "empty-repo.git")
workPath := filepath.Join(fixtures, "work")
mustGit(t, "", "init", "--bare", "-b", "main", barePath)
mustGit(t, "", "clone", barePath, workPath)
mustGit(t, workPath, "config", "user.email", "test@example.com")
mustGit(t, workPath, "config", "user.name", "Test")
mustWriteFile(t, filepath.Join(workPath, "README.md"), "x")
mustGit(t, workPath, "add", ".")
mustGit(t, workPath, "commit", "-m", "seed")
mustGit(t, workPath, "push", "origin", "main")
t.Setenv("GIT_CONFIG_COUNT", "1")
t.Setenv("GIT_CONFIG_KEY_0", "url."+barePath+".insteadOf")
t.Setenv("GIT_CONFIG_VALUE_0", "https://git.moleculesai.app/molecule-ai/empty-repo.git")
rootDir := t.TempDir()
src := []byte(`workspaces:
- !external
repo: molecule-ai/empty-repo
ref: nonexistent-branch
path: anything.yaml
`)
_, err := resolveYAMLIncludes(src, rootDir)
if err == nil {
t.Fatalf("expected error for nonexistent ref; got nil")
}
if !strings.Contains(err.Error(), "ref") && !strings.Contains(err.Error(), "ls-remote") && !strings.Contains(err.Error(), "not found") {
t.Errorf("error doesn't mention ref/ls-remote: %v", err)
}
}
// ---------- helpers ----------
func mustGit(t *testing.T, cwd string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
if cwd != "" {
cmd.Dir = cwd
}
// Ensure user.email/name are set globally for non-cwd commands too.
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_EMAIL=test@example.com",
"GIT_AUTHOR_NAME=Integration Test",
"GIT_COMMITTER_EMAIL=test@example.com",
"GIT_COMMITTER_NAME=Integration Test",
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, string(out))
}
}
func mustWriteFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// Verify gitFetcher.Fetch direct invocation (no resolver wrapping) for
// the cache-hit path, exercising the bare API against a local bare-repo.
func TestGitFetcher_DirectFetch_CacheHit(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skipf("git binary not found: %v", err)
}
if runtime.GOOS == "windows" {
t.Skip("skipping on windows")
}
fixtures := t.TempDir()
barePath := filepath.Join(fixtures, "direct.git")
workPath := filepath.Join(fixtures, "w")
mustGit(t, "", "init", "--bare", "-b", "main", barePath)
mustGit(t, "", "clone", barePath, workPath)
mustGit(t, workPath, "config", "user.email", "t@e")
mustGit(t, workPath, "config", "user.name", "T")
mustWriteFile(t, filepath.Join(workPath, "marker.txt"), "hello")
mustGit(t, workPath, "add", ".")
mustGit(t, workPath, "commit", "-m", "seed")
mustGit(t, workPath, "push", "origin", "main")
t.Setenv("GIT_CONFIG_COUNT", "1")
t.Setenv("GIT_CONFIG_KEY_0", "url."+barePath+".insteadOf")
t.Setenv("GIT_CONFIG_VALUE_0", "https://git.moleculesai.app/molecule-ai/direct.git")
rootDir := t.TempDir()
g := &gitFetcher{}
ctx := context.Background()
cacheDir1, sha1, err := g.Fetch(ctx, rootDir, "git.moleculesai.app", "molecule-ai/direct", "main")
if err != nil {
t.Fatalf("first Fetch: %v", err)
}
if sha1 == "" || len(sha1) < 7 {
t.Errorf("expected SHA-like string, got %q", sha1)
}
if _, err := os.Stat(filepath.Join(cacheDir1, "marker.txt")); err != nil {
t.Errorf("first fetch missing marker.txt: %v", err)
}
// Second call: cache hit, returns same dir + sha, no re-clone.
stamp := filepath.Join(cacheDir1, ".not-clobbered-by-second-fetch")
if err := os.WriteFile(stamp, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
cacheDir2, sha2, err := g.Fetch(ctx, rootDir, "git.moleculesai.app", "molecule-ai/direct", "main")
if err != nil {
t.Fatalf("second Fetch: %v", err)
}
if cacheDir2 != cacheDir1 || sha2 != sha1 {
t.Errorf("cache miss on second call: %q/%q vs %q/%q", cacheDir1, sha1, cacheDir2, sha2)
}
if _, err := os.Stat(stamp); err != nil {
t.Errorf("cache hit not honored — stamp file disappeared: %v", err)
}
_ = fmt.Sprint
}