Files
Agent-System/internal/template/docker_context_test.go
weichenandCursor 35b2e922bf fix(deploy): keep official templates in the image build context
*.md was excluding templates/docs from Docker context, so COPY and
go:embed could not see prelim_design.md at /app. Un-ignore those trees
and assert the smoke image can cat the official file.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 19:21:56 +00:00

222 lines
6.3 KiB
Go

package template_test
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"chat-agent-system/internal/template"
)
func repoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller")
}
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
}
func TestDockerfileCopiesTemplatesAndConfigsToApp(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(repoRoot(t), "deploy", "app", "Dockerfile"))
if err != nil {
t.Fatal(err)
}
text := string(raw)
for _, line := range []string{
"COPY templates /app/templates",
"COPY configs /app/configs",
"WORKDIR /app",
} {
if !strings.Contains(text, line) {
t.Fatalf("deploy/app/Dockerfile must contain %q so the image runtime can read official docs", line)
}
}
}
func TestDockerignoreKeepsOfficialTemplateDocs(t *testing.T) {
root := repoRoot(t)
patterns := readDockerignore(t, filepath.Join(root, ".dockerignore"))
docs, err := filepath.Glob(filepath.Join(root, "templates", "docs", "*.md"))
if err != nil || len(docs) == 0 {
t.Fatalf("templates/docs/*.md missing on disk: %v", err)
}
for _, abs := range docs {
rel, err := filepath.Rel(root, abs)
if err != nil {
t.Fatal(err)
}
rel = filepath.ToSlash(rel)
if dockerIgnored(patterns, rel) {
t.Fatalf(".dockerignore excludes %s; COPY templates and go:embed docs/*.md cannot see it in the image", rel)
}
}
if dockerIgnored(patterns, "templates/embed.go") {
t.Fatal("templates/embed.go must stay in the go-builder context")
}
if dockerIgnored(patterns, "configs/agents/writer.md") {
t.Fatal("configs/agents/*.md must stay in the image for GetAgentInstruction")
}
}
func TestGetDocTemplate_AppRootLayout(t *testing.T) {
root := repoRoot(t)
app := filepath.Join(t.TempDir(), "app")
dest := filepath.Join(app, "templates", "docs")
if err := os.MkdirAll(dest, 0o755); err != nil {
t.Fatal(err)
}
src := filepath.Join(root, "templates", "docs", "prelim_design.md")
body, err := os.ReadFile(src)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dest, "prelim_design.md"), body, 0o644); err != nil {
t.Fatal(err)
}
agentDir := filepath.Join(app, "configs", "agents")
if err := os.MkdirAll(agentDir, 0o755); err != nil {
t.Fatal(err)
}
writer, err := os.ReadFile(filepath.Join(root, "configs", "agents", "writer.md"))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(agentDir, "writer.md"), writer, 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("APP_ROOT", app)
t.Chdir(app)
got, err := template.GetDocTemplate("prelim_design")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "系统初步设计方案") || !strings.Contains(got, "子系统划分") {
t.Fatalf(" /app layout must yield the official template, got %d bytes", len(got))
}
if strings.Contains(got, "## 1. 结论") && !strings.Contains(got, "子系统划分") {
t.Fatal("must not use the two-section stub")
}
inst, err := template.GetAgentInstruction("writer")
if err != nil {
t.Fatalf("image /app/configs/agents must be readable: %v", err)
}
if strings.TrimSpace(inst) == "" {
t.Fatal("writer instruction empty")
}
}
func TestDockerBuildContext_TemplatesDocsReadable(t *testing.T) {
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker CLI not available")
}
root := repoRoot(t)
df := filepath.Join(t.TempDir(), "Dockerfile")
// Same context and COPY destinations as deploy/app/Dockerfile final stage.
if err := os.WriteFile(df, []byte("FROM debian:bookworm-slim\nWORKDIR /app\nCOPY templates /app/templates\nCOPY configs /app/configs\n"), 0o644); err != nil {
t.Fatal(err)
}
tag := "agent-system-templates-smoke:local"
build := exec.Command("docker", "build", "-f", df, "-t", tag, ".")
build.Dir = root
out, err := runTimed(build, 3*time.Minute)
if err != nil {
t.Fatalf("docker build context failed: %v\n%s", err, out)
}
t.Cleanup(func() {
_ = exec.Command("docker", "rmi", "-f", tag).Run()
})
cat := exec.Command("docker", "run", "--rm", "--entrypoint", "cat", tag, "/app/templates/docs/prelim_design.md")
body, err := runTimed(cat, 30*time.Second)
if err != nil {
t.Fatalf("image missing /app/templates/docs/prelim_design.md: %v\n%s", err, body)
}
if !bytes.Contains(body, []byte("系统初步设计方案")) || !bytes.Contains(body, []byte("子系统划分")) {
t.Fatalf("image template is not the official prelim_design (%d bytes)", len(body))
}
ls := exec.Command("docker", "run", "--rm", "--entrypoint", "ls", tag, "/app/configs/agents")
agents, err := runTimed(ls, 30*time.Second)
if err != nil {
t.Fatalf("image missing /app/configs/agents: %v\n%s", err, agents)
}
if !bytes.Contains(agents, []byte("writer.md")) {
t.Fatalf("image configs/agents incomplete: %s", agents)
}
}
func runTimed(cmd *exec.Cmd, d time.Duration) ([]byte, error) {
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
if err := cmd.Start(); err != nil {
return buf.Bytes(), err
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
return buf.Bytes(), err
case <-time.After(d):
_ = cmd.Process.Kill()
return buf.Bytes(), os.ErrDeadlineExceeded
}
}
func readDockerignore(t *testing.T, path string) []string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return strings.Split(string(raw), "\n")
}
func dockerIgnored(patterns []string, rel string) bool {
rel = filepath.ToSlash(rel)
ignored := false
for _, raw := range patterns {
p := strings.TrimSpace(raw)
if p == "" || strings.HasPrefix(p, "#") {
continue
}
negate := false
if strings.HasPrefix(p, "!") {
negate = true
p = strings.TrimPrefix(p, "!")
}
if matchDockerPattern(p, rel) {
ignored = !negate
}
}
return ignored
}
func matchDockerPattern(pattern, rel string) bool {
pattern = filepath.ToSlash(pattern)
if pattern == rel {
return true
}
if strings.HasSuffix(pattern, "/**/*.md") {
prefix := strings.TrimSuffix(pattern, "/**/*.md")
return strings.HasPrefix(rel, prefix+"/") && strings.HasSuffix(rel, ".md")
}
if pattern == "*.md" {
return strings.HasSuffix(rel, ".md")
}
if !strings.Contains(pattern, "/") && !strings.Contains(pattern, "*") {
return rel == pattern || strings.HasPrefix(rel, pattern+"/")
}
ok, _ := filepath.Match(pattern, rel)
if ok {
return true
}
ok, _ = filepath.Match(pattern, filepath.Base(rel))
return ok
}