package agent import ( "os" "os/exec" "path/filepath" "regexp" "runtime" "strings" "testing" "time" ) func piAgentRepoRoot(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 TestPiAgentDockerfileCopiesRunnerRelativeImports(t *testing.T) { root := piAgentRepoRoot(t) runnerRel := filepath.ToSlash("deploy/pi-agent/runner.mjs") df, err := os.ReadFile(filepath.Join(root, "deploy", "pi-agent", "Dockerfile")) if err != nil { t.Fatal(err) } copied := dockerCopyDestinations(string(df)) if len(copied) == 0 { t.Fatal("deploy/pi-agent/Dockerfile has no COPY lines") } seen := map[string]bool{} var missing []string var walk func(repoRel string) walk = func(repoRel string) { repoRel = filepath.ToSlash(repoRel) if seen[repoRel] { return } seen[repoRel] = true abs := filepath.Join(root, filepath.FromSlash(repoRel)) body, err := os.ReadFile(abs) if err != nil { t.Fatalf("read %s: %v", repoRel, err) } dir := filepath.ToSlash(filepath.Dir(repoRel)) for _, spec := range relativeModuleSpecs(string(body)) { child := filepath.ToSlash(filepath.Clean(dir + "/" + strings.TrimPrefix(spec, "./"))) imageDest := runnerImportImagePath(child) if !dockerCopyCovers(copied, imageDest) { missing = append(missing, spec+" -> "+imageDest+" (from "+repoRel+")") } walk(child) } } walk(runnerRel) if len(missing) > 0 { t.Fatalf("pi-agent image COPY set is missing runner runtime modules:\n %s", strings.Join(missing, "\n ")) } if !dockerCopyCovers(copied, "/app/settle-policy.mjs") { t.Fatal("deploy/pi-agent/Dockerfile must COPY settle-policy.mjs to /app/settle-policy.mjs") } } func TestPiAgentDockerBuildContext_RunnerModulesPresent(t *testing.T) { if testing.Short() { t.Skip("docker build smoke skipped under -short") } if _, err := exec.LookPath("docker"); err != nil { t.Skip("docker CLI not available") } root := piAgentRepoRoot(t) dfBody, err := os.ReadFile(filepath.Join(root, "deploy", "pi-agent", "Dockerfile")) if err != nil { t.Fatal(err) } var copies []string for _, raw := range strings.Split(string(dfBody), "\n") { line := strings.TrimSpace(raw) if strings.HasPrefix(line, "COPY deploy/pi-agent/") && !strings.Contains(line, "--from=") { copies = append(copies, line) } } if len(copies) == 0 { t.Fatal("no deploy/pi-agent COPY lines to replay") } slim := "FROM debian:bookworm-slim\nWORKDIR /app\n" + strings.Join(copies, "\n") + "\n" df := filepath.Join(t.TempDir(), "Dockerfile") if err := os.WriteFile(df, []byte(slim), 0o644); err != nil { t.Fatal(err) } tag := "agent-system-pi-runner-modules-smoke:local" build := exec.Command("docker", "build", "-f", df, "-t", tag, ".") build.Dir = root out, err := runCmdTimed(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() }) for _, path := range []string{"/app/runner.mjs", "/app/settle-policy.mjs", "/app/extensions/tavily-search.mjs"} { cat := exec.Command("docker", "run", "--rm", "--entrypoint", "test", tag, "-f", path) if body, err := runCmdTimed(cat, 30*time.Second); err != nil { t.Fatalf("image missing %s: %v\n%s", path, err, body) } } } func runCmdTimed(cmd *exec.Cmd, d time.Duration) ([]byte, error) { var buf strings.Builder cmd.Stdout = &buf cmd.Stderr = &buf if err := cmd.Start(); err != nil { return []byte(buf.String()), err } done := make(chan error, 1) go func() { done <- cmd.Wait() }() select { case err := <-done: return []byte(buf.String()), err case <-time.After(d): _ = cmd.Process.Kill() return []byte(buf.String()), os.ErrDeadlineExceeded } } var relativeFromRe = regexp.MustCompile(`(?m)from\s+["'](\./[^"']+)["']`) func relativeModuleSpecs(src string) []string { var out []string for _, m := range relativeFromRe.FindAllStringSubmatch(src, -1) { if len(m) > 1 { out = append(out, m[1]) } } return out } func runnerImportImagePath(repoRel string) string { const prefix = "deploy/pi-agent/" rel := filepath.ToSlash(repoRel) if strings.HasPrefix(rel, prefix) { return "/app/" + strings.TrimPrefix(rel, prefix) } return "/app/" + filepath.Base(rel) } type dockerCopy struct { src string dest string } func dockerCopyDestinations(df string) []dockerCopy { var out []dockerCopy for _, raw := range strings.Split(df, "\n") { line := strings.TrimSpace(raw) if !strings.HasPrefix(line, "COPY ") || strings.HasPrefix(line, "COPY --from=") { continue } fields := strings.Fields(line) if len(fields) < 3 { continue } src := filepath.ToSlash(fields[1]) dest := filepath.ToSlash(fields[len(fields)-1]) out = append(out, dockerCopy{src: src, dest: dest}) } return out } func dockerCopyCovers(copies []dockerCopy, imagePath string) bool { imagePath = filepath.ToSlash(imagePath) for _, c := range copies { dest := c.dest src := c.src if dest == imagePath { return true } if strings.HasSuffix(dest, "/") && strings.HasPrefix(imagePath, dest) { return true } if strings.HasSuffix(src, "/") && strings.HasSuffix(dest, "/") { relSrc := strings.TrimPrefix(src, "deploy/pi-agent/") if strings.HasPrefix(imagePath, dest+relSrc) || strings.HasPrefix(imagePath, dest) { // /app/extensions/ + tavily-search.mjs base := strings.TrimPrefix(imagePath, dest) if base != imagePath && !strings.Contains(base, "..") { return true } } } } return false }