All checks were successful
release / build-and-release (push) Successful in 43s
On any ERROR step, xetup now files an issue in the private x9/xetup-runs tracker (title "FAILED <host> - <version> - <date>", label "failed", body = step table + tail-capped Deploy.log) via the xetup-bot write:issue token, baked in through -ldflags. Best-effort and non-blocking; successful runs stay silent and the email is the fallback. The deployment email now attaches the zipped Deploy.log and links to this run's issue plus the run history filtered to this machine. - internal/buildinfo: ldflags-injected Version + RunsToken - internal/runreport: issue filing, retries, tail-capped log - internal/report: multipart/mixed with zip attachment, tracker links; buildMessage split out and covered by report_test.go - release.yml: inject Version (tag/SHA) + FORGEJO_RUNS_TOKEN via ldflags Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
155 lines
4.9 KiB
Go
155 lines
4.9 KiB
Go
package report
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/base64"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/mail"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var sampleResults = []StepResult{
|
|
{Num: "01", Name: "Bloatware", Status: "OK", Elapsed: 3 * time.Second},
|
|
{Num: "08", Name: "Activation", Status: "ERROR", Elapsed: 2 * time.Second},
|
|
}
|
|
|
|
// writeLog writes a temp Deploy.log and returns its path.
|
|
func writeLog(t *testing.T, content []byte) string {
|
|
t.Helper()
|
|
p := filepath.Join(t.TempDir(), "Deploy.log")
|
|
if err := os.WriteFile(p, content, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// buildHTML must embed the direct issue link and the per-machine tracker filter.
|
|
func TestBuildHTMLLinks(t *testing.T) {
|
|
issueURL := "https://git.xetup.x9.cz/x9/xetup-runs/issues/42"
|
|
html := buildHTML(sampleResults, "PC-TEST", "2026-07-28 10:00", issueURL)
|
|
|
|
if !strings.Contains(html, issueURL) {
|
|
t.Errorf("html does not link the run issue %q", issueURL)
|
|
}
|
|
if !strings.Contains(html, "xetup-runs/issues?q=PC-TEST") {
|
|
t.Errorf("html missing per-machine tracker filter link; got:\n%s", html)
|
|
}
|
|
}
|
|
|
|
// With no issue (clean run / post failed), only the tracker link is shown.
|
|
func TestBuildHTMLNoIssue(t *testing.T) {
|
|
html := buildHTML(sampleResults, "PC-TEST", "2026-07-28 10:00", "")
|
|
if strings.Contains(html, "Chyby tohoto behu") {
|
|
t.Error("issue-specific line shown when issueURL is empty")
|
|
}
|
|
if !strings.Contains(html, "xetup-runs/issues?q=PC-TEST") {
|
|
t.Error("tracker filter link missing")
|
|
}
|
|
}
|
|
|
|
// zipLog must produce a valid zip whose Deploy.log entry round-trips.
|
|
func TestZipLogRoundTrip(t *testing.T) {
|
|
content := []byte("line one\nERROR something failed\nline three\n")
|
|
name, data := zipLog(writeLog(t, content), "PC-TEST")
|
|
|
|
if name != "PC-TEST-Deploy.log.zip" {
|
|
t.Errorf("unexpected attachment name %q", name)
|
|
}
|
|
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
t.Fatalf("attachment is not a valid zip: %v", err)
|
|
}
|
|
if len(zr.File) != 1 || zr.File[0].Name != "Deploy.log" {
|
|
t.Fatalf("zip should contain exactly Deploy.log, got %v", zr.File)
|
|
}
|
|
rc, _ := zr.File[0].Open()
|
|
got, _ := io.ReadAll(rc)
|
|
rc.Close()
|
|
if !bytes.Equal(got, content) {
|
|
t.Errorf("zip content mismatch: got %q want %q", got, content)
|
|
}
|
|
}
|
|
|
|
// zipLog returns no attachment when the log is unreadable (email still sends).
|
|
func TestZipLogMissing(t *testing.T) {
|
|
name, data := zipLog(filepath.Join(t.TempDir(), "nope.log"), "PC-TEST")
|
|
if name != "" || data != nil {
|
|
t.Errorf("expected no attachment for missing log, got name=%q len=%d", name, len(data))
|
|
}
|
|
}
|
|
|
|
// buildMessage must yield a parseable multipart/mixed message: an HTML part and
|
|
// a base64 application/zip attachment that unzips back to the log.
|
|
func TestBuildMessageMultipart(t *testing.T) {
|
|
logContent := []byte("deploy log body\nERROR boom\n")
|
|
_, zipData := zipLog(writeLog(t, logContent), "PC-TEST")
|
|
html := buildHTML(sampleResults, "PC-TEST", "2026-07-28 10:00", "https://x/issues/1")
|
|
|
|
raw := buildMessage("xetup report PC-TEST", html, "PC-TEST-Deploy.log.zip", zipData)
|
|
|
|
m, err := mail.ReadMessage(bytes.NewReader(raw))
|
|
if err != nil {
|
|
t.Fatalf("message does not parse: %v", err)
|
|
}
|
|
mediaType, params, err := mime.ParseMediaType(m.Header.Get("Content-Type"))
|
|
if err != nil || mediaType != "multipart/mixed" {
|
|
t.Fatalf("expected multipart/mixed, got %q (%v)", mediaType, err)
|
|
}
|
|
|
|
mr := multipart.NewReader(m.Body, params["boundary"])
|
|
var sawHTML, sawZip bool
|
|
for {
|
|
p, err := mr.NextPart()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("part read error: %v", err)
|
|
}
|
|
ct := p.Header.Get("Content-Type")
|
|
body, _ := io.ReadAll(p)
|
|
switch {
|
|
case strings.HasPrefix(ct, "text/html"):
|
|
sawHTML = true
|
|
if !strings.Contains(string(body), "xetup report") {
|
|
t.Error("html part missing report content")
|
|
}
|
|
case strings.HasPrefix(ct, "application/zip"):
|
|
sawZip = true
|
|
if enc := p.Header.Get("Content-Transfer-Encoding"); enc != "base64" {
|
|
t.Errorf("zip part not base64, got %q", enc)
|
|
}
|
|
// multipart does not auto-decode base64; strip wraps and decode.
|
|
clean := strings.NewReplacer("\r", "", "\n", "").Replace(string(body))
|
|
dec, err := base64.StdEncoding.DecodeString(clean)
|
|
if err != nil {
|
|
t.Fatalf("attachment base64 decode failed: %v", err)
|
|
}
|
|
if _, err := zip.NewReader(bytes.NewReader(dec), int64(len(dec))); err != nil {
|
|
t.Errorf("decoded attachment is not a zip: %v", err)
|
|
}
|
|
}
|
|
}
|
|
if !sawHTML || !sawZip {
|
|
t.Errorf("missing parts: html=%v zip=%v", sawHTML, sawZip)
|
|
}
|
|
}
|
|
|
|
// Plain message (no attachment) must be single-part text/html.
|
|
func TestBuildMessagePlain(t *testing.T) {
|
|
raw := buildMessage("subj", "<html>x</html>", "", nil)
|
|
m, err := mail.ReadMessage(bytes.NewReader(raw))
|
|
if err != nil {
|
|
t.Fatalf("plain message does not parse: %v", err)
|
|
}
|
|
if ct := m.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
|
t.Errorf("expected text/html, got %q", ct)
|
|
}
|
|
}
|