chore: simplify workflows and unify tool logging and mail presentation
This commit is contained in:
1 parent
f4d96de79e
commit
c93f2bf8b2
40 files changed
+523
-1515
No files matched your search
+2
-2
@@ -1,8 +1,8 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/builder"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
"github.com/urfave/cli/v2"
|
||||
"path/filepath"
|
||||
@@ -26,7 +26,7 @@ func BuildCommand() *cli.Command {
|
||||
if err := builder.Write(output, s, packages); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[PASS] Built %d themes for %s at %s\n", len(packages), s.Lock.Tag, output)
|
||||
logging.Info("Builder", "Built %d themes for %s at %s", len(packages), s.Lock.Tag, output)
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
+2
-1
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/builder"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"github.com/urfave/cli/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -36,7 +37,7 @@ func runCreate(c *cli.Context) error {
|
||||
if err := os.WriteFile(filepath.Join(dir, "theme.css"), []byte(css), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[PASS] Created framework theme %s\n", name)
|
||||
logging.Info("CLI", "Created framework theme %s", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+3
-2
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -44,7 +45,7 @@ func runDelete(c *cli.Context) error {
|
||||
}
|
||||
|
||||
if _, err := os.Stat(styleDir); os.IsNotExist(err) {
|
||||
fmt.Printf("\033[33m[W]\033[0m [CLI] '%s' does not exist, skipped\n", styleName)
|
||||
logging.Warn("CLI", "'%s' does not exist, skipped", styleName)
|
||||
continue
|
||||
}
|
||||
if _, err := builder.LoadTheme(styleDir); err != nil {
|
||||
@@ -54,7 +55,7 @@ func runDelete(c *cli.Context) error {
|
||||
if err := os.RemoveAll(styleDir); err != nil {
|
||||
return fmt.Errorf("cannot delete %s: %w", styleDir, err)
|
||||
}
|
||||
fmt.Printf("\033[32m[I]\033[0m [CLI] Deleted style '%s'\n", styleName)
|
||||
logging.Info("CLI", "Deleted style '%s'", styleName)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -69,7 +70,7 @@ func runDev(c *cli.Context) error {
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
fmt.Println("\n\033[32m[I]\033[0m [Server] Shutting down")
|
||||
logging.Info("Server", "Shutting down")
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -77,7 +78,7 @@ func runPreview(c *cli.Context) error {
|
||||
|
||||
preview.PrintDetailedSummary(result, folder, cfg)
|
||||
|
||||
fmt.Printf("\033[32m[I]\033[0m [Preview] Wrote %d themes x %d templates to %s\n",
|
||||
logging.Info("Preview", "Wrote %d themes x %d templates to %s",
|
||||
len(result.Themes), len(cfg.Templates), outputPath)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
@@ -19,10 +19,10 @@ func UpstreamCommand() *cli.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[PASS] %s: %d mail files, %d languages\n", s.Lock.Tag, len(s.Templates), len(s.Locales))
|
||||
logging.Info("Upstream", "%s: %d mail files, %d languages", s.Lock.Tag, len(s.Templates), len(s.Locales))
|
||||
for _, lang := range s.Languages() {
|
||||
if keys := missing[lang]; len(keys) > 0 {
|
||||
fmt.Printf("[FALLBACK] %s -> en-US: %v\n", lang, keys)
|
||||
logging.Warn("Locale", "%s -> en-US: %v", lang, keys)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
// Package integration exercises an explicitly supplied official Gitea binary.
|
||||
// Everything runs on loopback with disposable users, SQLite and captured SMTP.
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea-mail-templates/tools/builder"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
)
|
||||
|
||||
type safeLog struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (l *safeLog) Write(p []byte) (int, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.buf.Write(p)
|
||||
}
|
||||
func (l *safeLog) String() string { l.mu.Lock(); defer l.mu.Unlock(); return l.buf.String() }
|
||||
|
||||
func smtpCapture(t *testing.T) (int, <-chan []byte) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { listener.Close() })
|
||||
mailbox := make(chan []byte, 4)
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(time.Minute))
|
||||
r := textproto.NewReader(bufio.NewReader(conn))
|
||||
fmt.Fprint(conn, "220 localhost SMTP capture\r\n")
|
||||
for {
|
||||
line, err := r.ReadLine()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
command := strings.ToUpper(fields[0])
|
||||
switch command {
|
||||
case "EHLO", "HELO":
|
||||
fmt.Fprint(conn, "250 localhost\r\n")
|
||||
case "DATA":
|
||||
fmt.Fprint(conn, "354 Send mail\r\n")
|
||||
data, err := r.ReadDotBytes()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mailbox <- data
|
||||
fmt.Fprint(conn, "250 Captured\r\n")
|
||||
case "QUIT":
|
||||
fmt.Fprint(conn, "221 Goodbye\r\n")
|
||||
return
|
||||
default:
|
||||
fmt.Fprint(conn, "250 OK\r\n")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return listener.Addr().(*net.TCPAddr).Port, mailbox
|
||||
}
|
||||
|
||||
func TestGiteaMailSmoke(t *testing.T) {
|
||||
binary := os.Getenv("GITEA_SMOKE_BINARY")
|
||||
if binary == "" {
|
||||
t.Skip("set GITEA_SMOKE_BINARY to a checksum-verified official Gitea binary")
|
||||
}
|
||||
binary, err := filepath.Abs(binary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
version, err := exec.Command(binary, "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fields := strings.Fields(strings.ToLower(string(version)))
|
||||
if len(fields) < 3 || fields[0] != "gitea" || fields[1] != "version" || fields[2] != strings.TrimPrefix(s.Lock.Tag, "v") {
|
||||
t.Fatalf("binary does not match snapshot: %s", version)
|
||||
}
|
||||
for _, mode := range []string{"shared", "framed"} {
|
||||
t.Run(mode, func(t *testing.T) { smokeMode(t, binary, s, mode) })
|
||||
}
|
||||
}
|
||||
|
||||
func smokeMode(t *testing.T, binary string, s *upstream.Snapshot, mode string) {
|
||||
port, mailbox := smtpCapture(t)
|
||||
probe, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
webPort := probe.Addr().(*net.TCPAddr).Port
|
||||
probe.Close()
|
||||
work := t.TempDir()
|
||||
// Preseed only Gitea's disposable Git home; never touch the user's Git config.
|
||||
gitHome := filepath.Join(work, "data", "home")
|
||||
if err := os.MkdirAll(gitHome, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gitConfig := "[user]\nname = Gitea\nemail = gitea@fake.local\n[core]\nquotepath = false\nlongpaths = true\ncommitGraph = true\n[gc]\nwriteCommitGraph = true\n[fetch]\nwriteCommitGraph = true\n[init]\ndefaultBranch = main\n[receive]\nadvertisePushOptions = true\nprocReceiveRefs = refs/for\n[uploadpack]\nallowfilter = true\nallowAnySHA1InWant = true\n[safe]\ndirectory = *\n"
|
||||
if err := os.WriteFile(filepath.Join(gitHome, ".gitconfig"), []byte(gitConfig), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
custom := filepath.Join(work, "custom")
|
||||
configFile := filepath.Join(work, "app.ini")
|
||||
rootURL := fmt.Sprintf("http://127.0.0.1:%d/", webPort)
|
||||
ini := fmt.Sprintf("APP_NAME = Smoke Gitea\nRUN_MODE = prod\n[server]\nAPP_DATA_PATH = %s/data\nHTTP_ADDR = 127.0.0.1\nHTTP_PORT = %d\nROOT_URL = %s\nDISABLE_SSH = true\n[database]\nDB_TYPE = sqlite3\nPATH = %s/gitea.db\n[repository]\nROOT = %s/repos\n[security]\nINSTALL_LOCK = true\n[service]\nDISABLE_REGISTRATION = true\nREGISTER_EMAIL_CONFIRM = false\nENABLE_NOTIFY_MAIL = true\n[mailer]\nENABLED = true\nPROTOCOL = smtp\nSMTP_ADDR = 127.0.0.1\nSMTP_PORT = %d\nFROM = Smoke Gitea <smoke@example.invalid>\n[log]\nMODE = console\nLEVEL = warn\n", filepath.ToSlash(work), webPort, rootURL, filepath.ToSlash(work), filepath.ToSlash(work), port)
|
||||
if err := os.WriteFile(configFile, []byte(ini), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Disable key-file maintenance and isolate even its unused paths from HOME.
|
||||
ini = strings.Replace(ini, "DISABLE_SSH = true", "DISABLE_SSH = true\nSSH_ROOT_PATH = "+filepath.ToSlash(filepath.Join(work, "ssh"))+"\nSSH_CREATE_AUTHORIZED_KEYS_FILE = false\nSSH_CREATE_AUTHORIZED_PRINCIPALS_FILE = false", 1)
|
||||
if err := os.WriteFile(configFile, []byte(ini), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var p *builder.Package
|
||||
if mode == "framed" {
|
||||
theme, err := builder.LoadTheme(filepath.Join("..", "..", "themes", "horizon"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err = builder.Build(s, theme)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
p, err = builder.Build(s, &builder.Theme{Name: "smoke", Mode: "shared", CSS: "a { color: #123456; }"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for name, data := range p.Files {
|
||||
target := filepath.Join(custom, "templates", filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
args := []string{"--work-path", work, "--custom-path", custom, "--config", configFile}
|
||||
run := func(more ...string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, binary, append(append([]string{}, args...), more...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("Gitea %v: %v\n%s", more, err, output)
|
||||
}
|
||||
}
|
||||
run("migrate")
|
||||
run("admin", "user", "create", "--username", "smoke-user", "--password", "local-smoke-password-123!", "--email", "smoke@example.invalid", "--admin", "--must-change-password=false")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var logs safeLog
|
||||
web := exec.CommandContext(ctx, binary, append(args, "web")...)
|
||||
web.Stdout = &logs
|
||||
web.Stderr = &logs
|
||||
if err := web.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { _ = web.Wait(); close(done) }()
|
||||
t.Cleanup(func() { cancel(); <-done })
|
||||
jar, _ := cookiejar.New(nil)
|
||||
client := &http.Client{Jar: jar, Timeout: 5 * time.Second}
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
var page []byte
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatalf("Gitea exited: %s", logs.String())
|
||||
default:
|
||||
}
|
||||
resp, err := client.Get(rootURL + "user/forgot_password")
|
||||
if err == nil {
|
||||
page, _ = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if len(page) == 0 {
|
||||
t.Fatalf("Gitea did not start: %s", logs.String())
|
||||
}
|
||||
if !bytes.Contains(page, []byte(`name="email"`)) {
|
||||
t.Fatalf("no reset form: %s\n%s", page, logs.String())
|
||||
}
|
||||
// Gitea 28 uses origin checks rather than a hidden _csrf form field.
|
||||
req, err := http.NewRequest(http.MethodPost, rootURL+"user/forgot_password", strings.NewReader(url.Values{"email": {"smoke@example.invalid"}}.Encode()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Origin", strings.TrimSuffix(rootURL, "/"))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("password reset HTTP %d: %s", resp.StatusCode, logs.String())
|
||||
}
|
||||
select {
|
||||
case raw := <-mailbox:
|
||||
message, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts, err := mimeBodies(message.Header.Get("Content-Type"), message.Header.Get("Content-Transfer-Encoding"), message.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := parts["text/html"]
|
||||
if !strings.Contains(body, "/user/recover_account?code=") || !strings.Contains(body, "<style>") {
|
||||
t.Fatalf("official reset body or injected head missing: %s", body)
|
||||
}
|
||||
if mode == "framed" && !strings.Contains(body, `class="email-wrap`) {
|
||||
t.Fatal("framed override was not loaded")
|
||||
}
|
||||
if mode == "framed" && (!strings.Contains(body, `class="email-btn">Recover your account</a>`) || !strings.Contains(body, `src="`+rootURL+`assets/img/favicon.png"`) || strings.Count(body, "/user/recover_account?code=") < 3) {
|
||||
t.Fatal("shared action label, fallback or instance logo missing from real mail")
|
||||
}
|
||||
if mode == "shared" && (!strings.Contains(body, "#123456") || strings.Contains(body, `class="email-wrap`)) {
|
||||
t.Fatal("shared partial was not loaded")
|
||||
}
|
||||
if !strings.Contains(parts["text/plain"], "recover_account") {
|
||||
t.Fatal("Gitea plain-text alternative missing official recovery link")
|
||||
}
|
||||
t.Logf("[PASS] %s %s: real password-reset mail captured; HTML override and plain-text link verified", s.Lock.Tag, mode)
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatalf("no mail captured: %s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func mimeBodies(contentType, encoding string, r io.Reader) (map[string]string, error) {
|
||||
media, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[string]string{}
|
||||
if strings.HasPrefix(media, "multipart/") {
|
||||
reader := multipart.NewReader(r, params["boundary"])
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodies, err := mimeBodies(part.Header.Get("Content-Type"), part.Header.Get("Content-Transfer-Encoding"), part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range bodies {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
switch strings.ToLower(encoding) {
|
||||
case "quoted-printable":
|
||||
r = quotedprintable.NewReader(r)
|
||||
case "base64":
|
||||
r = base64.NewDecoder(base64.StdEncoding, r)
|
||||
}
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[media] = string(b)
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Package logging formats tool diagnostics consistently across commands.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var outputMu sync.Mutex
|
||||
|
||||
// Info reports normal progress.
|
||||
func Info(name, format string, args ...any) { output(os.Stdout, "I", name, format, args...) }
|
||||
|
||||
// Warn reports a condition that does not prevent the operation from continuing.
|
||||
func Warn(name, format string, args ...any) { output(os.Stderr, "W", name, format, args...) }
|
||||
|
||||
// Error reports a recoverable operation failure.
|
||||
func Error(name, format string, args ...any) { output(os.Stderr, "E", name, format, args...) }
|
||||
|
||||
// Fatal reports a command-ending failure. The caller controls the exit status.
|
||||
func Fatal(name, format string, args ...any) { output(os.Stderr, "F", name, format, args...) }
|
||||
|
||||
func output(file *os.File, level, name, format string, args ...any) {
|
||||
stat, err := file.Stat()
|
||||
_, noColor := os.LookupEnv("NO_COLOR")
|
||||
color := err == nil && stat.Mode()&os.ModeCharDevice != 0 && !noColor && os.Getenv("TERM") != "dumb"
|
||||
outputMu.Lock()
|
||||
defer outputMu.Unlock()
|
||||
write(file, color, level, name, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func write(w io.Writer, color bool, level, name, message string) {
|
||||
label := "[" + level + "]"
|
||||
if color {
|
||||
code := map[string]string{"I": "32", "W": "33", "E": "31", "F": "35"}[level]
|
||||
label = "\033[" + code + "m" + label + "\033[0m"
|
||||
}
|
||||
// Each physical line retains its level and component, including wrapped errors.
|
||||
message = strings.ReplaceAll(message, "\r\n", "\n")
|
||||
for _, line := range strings.Split(strings.TrimRight(message, "\n"), "\n") {
|
||||
fmt.Fprintf(w, "%s [%s] %s\n", label, name, line)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormat(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
level, code string
|
||||
}{{"I", "32"}, {"W", "33"}, {"E", "31"}, {"F", "35"}} {
|
||||
t.Run(tc.level, func(t *testing.T) {
|
||||
for _, color := range []bool{false, true} {
|
||||
var buf bytes.Buffer
|
||||
write(&buf, color, tc.level, "Builder", "first\r\nsecond\n")
|
||||
label := "[" + tc.level + "]"
|
||||
if color {
|
||||
label = "\033[" + tc.code + "m" + label + "\033[0m"
|
||||
}
|
||||
want := label + " [Builder] first\n" + label + " [Builder] second\n"
|
||||
if buf.String() != want {
|
||||
t.Fatalf("got %q, want %q", buf.String(), want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -259,15 +260,15 @@ func WriteRenderedJS(result *PreviewResult, output string) error {
|
||||
}
|
||||
|
||||
func PrintDetailedSummary(result *PreviewResult, _ string, _ *config.TemplatesConfig) {
|
||||
fmt.Printf("[PASS] %s: %d themes × %d mail types × %d languages\n", result.Upstream, len(result.Themes), len(result.Registry), len(result.Languages))
|
||||
logging.Info("Preview", "%s: %d themes × %d mail types × %d languages", result.Upstream, len(result.Themes), len(result.Registry), len(result.Languages))
|
||||
for _, lang := range result.Languages {
|
||||
for _, warnings := range result.Warnings[lang] {
|
||||
for _, warning := range warnings {
|
||||
fmt.Println(warning)
|
||||
logging.Warn("Upstream", "%s", strings.TrimPrefix(warning, "[UPSTREAM-WARN] "))
|
||||
}
|
||||
}
|
||||
if keys := result.Fallback[lang]; len(keys) > 0 {
|
||||
fmt.Printf("[FALLBACK] %s -> en-US: %s\n", lang, strings.Join(keys, ", "))
|
||||
logging.Warn("Locale", "%s -> en-US: %s", lang, strings.Join(keys, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-9
@@ -3,6 +3,7 @@ package preview
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -62,10 +63,10 @@ func (s *DevServer) Start() error {
|
||||
mux.Handle("/", previewFS)
|
||||
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Port)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Server] Gitea Mail Templates — Dev Server\n")
|
||||
fmt.Printf("\033[32m[I]\033[0m [Server] http://%s\n", addr)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Watcher] Watching theme CSS, framework, lock/cache and fixtures\n")
|
||||
fmt.Printf("\033[32m[I]\033[0m [Server] SSE endpoint http://%s/events\n\n", addr)
|
||||
logging.Info("Server", "Gitea Mail Templates — Dev Server")
|
||||
logging.Info("Server", "http://%s", addr)
|
||||
logging.Info("Watcher", "Watching theme CSS, framework, lock/cache and fixtures")
|
||||
logging.Info("Server", "SSE endpoint http://%s/events", addr)
|
||||
|
||||
return http.ListenAndServe(addr, mux)
|
||||
}
|
||||
@@ -207,18 +208,20 @@ func (s *DevServer) watchThemes() {
|
||||
last, ok := s.lastMods[path]
|
||||
if !ok || mod.After(last) {
|
||||
rel, _ := filepath.Rel(s.cfg.ThemesDir, path)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Watcher] %s changed\n", filepath.ToSlash(rel))
|
||||
logging.Info("Watcher", "%s changed", filepath.ToSlash(rel))
|
||||
}
|
||||
}
|
||||
for path := range s.lastMods {
|
||||
if _, ok := current[path]; !ok {
|
||||
rel, _ := filepath.Rel(s.cfg.ThemesDir, path)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Watcher] %s deleted\n", filepath.ToSlash(rel))
|
||||
logging.Info("Watcher", "%s deleted", filepath.ToSlash(rel))
|
||||
}
|
||||
}
|
||||
s.lastMods = current
|
||||
s.modsMu.Unlock()
|
||||
s.rebuildAll()
|
||||
if err := s.rebuildAll(); err != nil {
|
||||
logging.Error("Builder", "Rebuild failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
s.modsMu.Unlock()
|
||||
}
|
||||
@@ -234,7 +237,7 @@ func (s *DevServer) rebuildAll() error {
|
||||
defer rebuildMu.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
fmt.Printf("\033[32m[I]\033[0m [Builder] Rebuilding all themes...\n")
|
||||
logging.Info("Builder", "Rebuilding all themes...")
|
||||
|
||||
cfg, err := config.Load(filepath.Join(filepath.Dir(s.cfg.ThemesDir), "tools", "data", "templates_config.json"))
|
||||
if err != nil {
|
||||
@@ -254,7 +257,7 @@ func (s *DevServer) rebuildAll() error {
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Builder] Rebuild done in %v\n", elapsed.Round(time.Millisecond))
|
||||
logging.Info("Builder", "Rebuild done in %v", elapsed.Round(time.Millisecond))
|
||||
|
||||
s.broadcast("reload", map[string]interface{}{"elapsed": elapsed.Milliseconds()})
|
||||
return nil
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "gitea-mail-preview-qa",
|
||||
"private": true,
|
||||
"scripts": { "test": "node preview.cjs" },
|
||||
"devDependencies": { "playwright": "1.62.1", "sharp": "0.35.5" }
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// Optional browser QA: npm install in this directory, then npm test.
|
||||
const { chromium } = require('playwright');
|
||||
const sharp = require('sharp');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
|
||||
async function ready(page) {
|
||||
await page.waitForFunction(() => document.getElementById('preview-frame').style.display === 'block' && document.getElementById('loading').style.display === 'none');
|
||||
const frame = page.frameLocator('#preview-frame');
|
||||
await frame.locator('body').waitFor();
|
||||
return frame;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({headless:true, executablePath:process.env.BROWSER_EXECUTABLE_PATH || undefined});
|
||||
try {
|
||||
const page = await browser.newPage({viewport:{width:1650,height:1050}});
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(pathToFileURL(path.join(root,'preview/index.html')).href);
|
||||
await page.addStyleTag({content:'*{transition:none!important;animation:none!important}'});
|
||||
await ready(page);
|
||||
// Floating inspector is preview chrome, not part of the rendered email.
|
||||
if (await page.locator('#panel-close').isVisible()) await page.click('#panel-close');
|
||||
const languages = await page.locator('#sel-locale option').evaluateAll(options => options.map(o=>o.value));
|
||||
const themes = await page.locator('#sel-theme option').evaluateAll(options => options.map(o=>o.value));
|
||||
const types = await page.locator('#sel-template option').evaluateAll(options => options.map(o=>o.value));
|
||||
const buttonColors = {aurora:'rgb(224, 245, 243)',bloom:'rgb(255, 255, 255)',ember:'rgb(255, 255, 255)',heritage:'rgb(255, 255, 255)',horizon:'rgb(255, 255, 255)',ink:'rgb(254, 253, 251)',mono:'rgb(0, 0, 0)',neon:'rgb(10, 10, 15)',terminal:'rgb(13, 17, 23)',terra:'rgb(255, 255, 255)'};
|
||||
if (!languages.includes('en-US') || !languages.includes('zh-CN')) throw new Error('official languages missing');
|
||||
for (const lang of languages) {
|
||||
await page.selectOption('#sel-locale',lang);
|
||||
const frame = await ready(page);
|
||||
if (!(await frame.locator('body').textContent()).trim()) throw new Error('empty '+lang+' preview');
|
||||
}
|
||||
await page.selectOption('#sel-locale','pl-PL');
|
||||
await page.selectOption('#sel-template','team_invite');
|
||||
await ready(page);
|
||||
await page.waitForFunction(()=>document.getElementById('locale-message')?.textContent.includes('[UPSTREAM-WARN]'));
|
||||
await page.selectOption('#sel-locale','zh-CN');
|
||||
await page.selectOption('#sel-locale','en-US');
|
||||
await ready(page);
|
||||
const screenshotDir = path.join(root,'build','screenshots');
|
||||
await fs.mkdir(screenshotDir,{recursive:true});
|
||||
for (const theme of themes) {
|
||||
await page.selectOption('#sel-theme',theme);
|
||||
await page.selectOption('#sel-template','register_notify');
|
||||
await page.click('#btn-desk');
|
||||
const desktop = await ready(page);
|
||||
const cta = desktop.locator('.card-body .email-btn').first();
|
||||
const color = await cta.evaluate(el=>getComputedStyle(el).color);
|
||||
if (buttonColors[theme] && color!==buttonColors[theme]) throw new Error('CTA styling overridden: '+theme+' '+color);
|
||||
const label = (await cta.textContent()).trim();
|
||||
if (!label || /^https?:/i.test(label)) throw new Error('button displays URL: '+theme);
|
||||
const fallback = desktop.locator('.email-link-url a').first();
|
||||
if (await cta.getAttribute('href') !== await fallback.getAttribute('href')) throw new Error('button/fallback target mismatch: '+theme);
|
||||
const logo = desktop.locator('img.email-logo');
|
||||
if (await logo.count()) {
|
||||
if (!(await logo.getAttribute('src')).startsWith('data:image/png;base64,')) throw new Error('preview logo is not generated official asset: '+theme);
|
||||
if (!(await logo.evaluate(img=>img.complete && img.naturalWidth>0))) throw new Error('logo failed to load: '+theme);
|
||||
}
|
||||
// Capture the actual 600px email, not the wide preview canvas/inspector.
|
||||
await page.waitForFunction(()=>document.getAnimations().every(a=>a.playState!=='running'));
|
||||
const box = await desktop.locator('.email-wrap').boundingBox();
|
||||
if (!box) throw new Error('email frame missing: '+theme);
|
||||
const png = await page.screenshot({clip:box,animations:'disabled'});
|
||||
const background = await desktop.locator('body').evaluate(el=>getComputedStyle(el).backgroundColor);
|
||||
const image = await sharp(png).extend({top:16,bottom:16,left:16,right:16,background}).resize({width:600}).png({compressionLevel:9}).toBuffer();
|
||||
await fs.writeFile(path.join(screenshotDir,theme+'.png'),image);
|
||||
for (const type of types) {
|
||||
await page.selectOption('#sel-template',type);
|
||||
await page.click('#btn-mob');
|
||||
const frame = await ready(page);
|
||||
const overflow = await frame.locator('html').evaluate(el=>el.scrollWidth > window.innerWidth + 1);
|
||||
if (overflow) throw new Error('mobile overflow: '+theme+'/'+type);
|
||||
}
|
||||
}
|
||||
if (errors.length) throw new Error(errors.join('\n'));
|
||||
console.log('[PASS] file:// language bundles, warning, rapid switching, '+themes.length+' theme screenshots and mobile overflow');
|
||||
// A running dev server can be included without making static QA depend on it.
|
||||
if (process.env.PREVIEW_DEV_URL) {
|
||||
await page.goto(process.env.PREVIEW_DEV_URL);
|
||||
await ready(page);
|
||||
await page.selectOption('#sel-locale','zh-CN');
|
||||
await ready(page);
|
||||
console.log('[PASS] HTTP preview and language switching');
|
||||
}
|
||||
if (process.argv.includes('--update-gallery')) {
|
||||
for (const theme of themes) {
|
||||
const image=await fs.readFile(path.join(screenshotDir,theme+'.png'));
|
||||
if (image.length>50*1024) throw new Error('gallery image exceeds 50 KiB: '+theme);
|
||||
await fs.writeFile(path.join(root,'docs','images',theme+'.png'),image);
|
||||
}
|
||||
console.log('[PASS] gallery screenshots updated');
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -26,7 +26,7 @@ func main() {
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\033[31m[E]\033[0m [CLI] %v\n", err)
|
||||
logging.Fatal("CLI", "%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -116,7 +117,7 @@ func prepareFrom(ctx context.Context, root, rawBase string) (*Snapshot, error) {
|
||||
if err = os.Rename(stage, dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Printf("[PASS] Downloaded locked Gitea %s (%s) to build cache\n", lock.Tag, lock.Commit)
|
||||
logging.Info("Upstream", "Downloaded locked Gitea %s (%s) to build cache", lock.Tag, lock.Commit)
|
||||
return LoadPinned(root)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -159,6 +160,6 @@ func syncFrom(ctx context.Context, tag, dir, api, raw string) error {
|
||||
} else if err := os.Rename(stage, abs); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[PASS] Locked %s (%s): %d templates, %d languages\n", tag, commit.SHA, len(s.Templates), len(s.Locales))
|
||||
logging.Info("Upstream", "Locked %s (%s): %d templates, %d languages", tag, commit.SHA, len(s.Templates), len(s.Locales))
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user