*.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>
222 lines
6.3 KiB
Go
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
|
|
}
|