chore: simplify workflows and unify tool logging and mail presentation
Release / Validate Templates (push) Successful in 2m55s
Release / Package & Release (push) Skipped
Release / Remind to Update Release Documentation (push) Skipped

This commit is contained in:
KenanZhu committed 2026-10-10 14:54:40 +08:00
1 parent f4d96de79e
commit c93f2bf8b2
40 files changed
+523 -1515

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
+3 -3
View File
@@ -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
-315
View File
@@ -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
}
+46
View File
@@ -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)
}
}
+27
View File
@@ -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)
}
}
})
}
}
+4 -3
View File
@@ -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
View File
@@ -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
-6
View File
@@ -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" }
}
-98
View File
@@ -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
View File
@@ -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)
}
}
+2 -1
View File
@@ -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)
}
+2 -1
View File
@@ -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
}