feat: validate svglide svg artifacts

This commit is contained in:
songtianyi.theo
2026-07-02 23:23:41 +08:00
parent 8a450b6437
commit 9c0c5ae26a
3 changed files with 1304 additions and 0 deletions

153
internal/svglide/receipt.go Normal file
View File

@@ -0,0 +1,153 @@
package svglide
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
type validationLintReceipt struct {
Status string `json:"status"`
Issues []ValidationIssue `json:"issues"`
}
func writeValidationArtifacts(safeRoot string, report ValidationReport) error {
report = normalizeValidationReport(report)
lintPath, err := ensureRunFileTargetForWrite(safeRoot, "receipts/lint.json")
if err != nil {
return err
}
raw, err := json.MarshalIndent(validationLintReceipt{
Status: validationReceiptStatus(report),
Issues: report.Issues,
}, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
if err := validate.AtomicWrite(lintPath, raw, 0o644); err != nil {
return err
}
queuePath, err := ensureRunFileTargetForWrite(safeRoot, "repair_queue.md")
if err != nil {
return err
}
return validate.AtomicWrite(queuePath, []byte(renderRepairQueue(report)), 0o644)
}
func normalizeValidationReport(report ValidationReport) ValidationReport {
if report.Issues == nil {
report.Issues = []ValidationIssue{}
}
report.OK = len(report.Issues) == 0
for i := range report.Issues {
report.Issues[i].Path = strings.TrimSpace(report.Issues[i].Path)
if report.Issues[i].Path == "" {
report.Issues[i].Path = "(deck)"
}
report.Issues[i].Code = strings.TrimSpace(report.Issues[i].Code)
if report.Issues[i].Code == "" {
report.Issues[i].Code = "svglide.validation"
}
report.Issues[i].Severity = strings.TrimSpace(report.Issues[i].Severity)
if report.Issues[i].Severity == "" {
report.Issues[i].Severity = "error"
}
}
return report
}
func validationReceiptStatus(report ValidationReport) string {
if report.OK {
return "passed"
}
return "failed"
}
func renderRepairQueue(report ValidationReport) string {
if report.OK {
return "No repair needed.\n"
}
var b bytes.Buffer
b.WriteString("# SVGlide Repair Queue\n\n")
for _, issue := range report.Issues {
fmt.Fprintf(&b, "- `%s` [%s]: %s\n", issue.Path, issue.Code, issue.Message)
}
return b.String()
}
func ensureRunFileTargetForWrite(safeRoot string, rel string) (string, error) {
cleanRel := filepath.Clean(rel)
if cleanRel == "." {
return "", fmt.Errorf("run file path must not be root")
}
dirRel := filepath.Dir(cleanRel)
if _, err := ensureRunDirectoryForWrite(safeRoot, dirRel); err != nil {
return "", err
}
path, err := safeRunPath(safeRoot, cleanRel)
if err != nil {
return "", err
}
info, err := vfs.Lstat(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return path, nil
}
return "", err
}
if info.Mode()&fs.ModeSymlink != 0 {
return "", fmt.Errorf("run file path %q must not be a symlink", rel)
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("run file path %q must be a regular file", rel)
}
return path, nil
}
func ensureRunDirectoryForWrite(safeRoot string, rel string) (string, error) {
path, err := safeRunPath(safeRoot, rel)
if err != nil {
return "", err
}
cleanRel := filepath.Clean(rel)
if cleanRel == "." {
return path, nil
}
parts := strings.Split(cleanRel, string(filepath.Separator))
cur := safeRoot
for i, part := range parts {
if part == "" || part == "." {
continue
}
cur = filepath.Join(cur, part)
info, err := vfs.Lstat(cur)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return "", err
}
if err := vfs.Mkdir(cur, 0o755); err != nil {
info, err = vfs.Lstat(cur)
if err != nil {
return "", err
}
} else {
continue
}
}
if info.Mode()&fs.ModeSymlink != 0 {
return "", fmt.Errorf("run directory path %q must not contain symlink component %q", rel, filepath.Join(parts[:i+1]...))
}
if !info.IsDir() {
return "", fmt.Errorf("run directory path %q component %q is not a directory", rel, filepath.Join(parts[:i+1]...))
}
}
return path, nil
}

View File

@@ -0,0 +1,440 @@
package svglide
import (
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"math"
"strconv"
"strings"
"github.com/larksuite/cli/internal/vfs"
)
const slideNamespace = "https://slides.bytedance.com/ns"
const svgNamespace = "http://www.w3.org/2000/svg"
const xlinkNamespace = "http://www.w3.org/1999/xlink"
type ValidationReport struct {
OK bool `json:"ok"`
Issues []ValidationIssue `json:"issues"`
}
type ValidationIssue struct {
Path string `json:"path"`
Code string `json:"code,omitempty"`
Message string `json:"message"`
Severity string `json:"severity,omitempty"`
}
type validationDeck struct {
Slides []validationDeckSlide `json:"slides"`
}
type validationDeckSlide struct {
Path string `json:"path"`
}
type svgViewBox struct {
Width float64
Height float64
Valid bool
}
type svgLintElement struct {
Excluded bool
TextCandidate bool
}
func ValidateRun(root string) (ValidationReport, error) {
safeRoot, run, err := readRun(root)
if err != nil {
return ValidationReport{}, err
}
deckPath := strings.TrimSpace(run.Artifacts.Deck)
if deckPath == "" {
return failValidation(safeRoot, ValidationIssue{Code: "svglide.deck", Message: "deck artifact path is empty"}, fmt.Errorf("deck artifact path is empty"))
}
deckRaw, err := readRunRegularArtifact(safeRoot, deckPath)
if err != nil {
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q: %v", deckPath, err)}
return failValidation(safeRoot, issue, fmt.Errorf("read deck %q: %w", deckPath, err))
}
var deck validationDeck
if err := json.Unmarshal(deckRaw, &deck); err != nil {
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q contains invalid JSON: %v", deckPath, err)}
return failValidation(safeRoot, issue, fmt.Errorf("read deck %q: %w", deckPath, err))
}
if len(deck.Slides) == 0 {
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q contains no slides", deckPath)}
return failValidation(safeRoot, issue, fmt.Errorf("deck %q contains no slides", deckPath))
}
report := ValidationReport{Issues: []ValidationIssue{}}
for _, slide := range deck.Slides {
slidePath := strings.TrimSpace(slide.Path)
if slidePath == "" {
report.Issues = append(report.Issues, ValidationIssue{Code: "svglide.path", Message: "slide path must not be empty"})
continue
}
raw, err := readRunRegularArtifact(safeRoot, slidePath)
if err != nil {
report.Issues = append(report.Issues, ValidationIssue{Path: slidePath, Code: "svglide.path", Message: err.Error()})
continue
}
report.Issues = append(report.Issues, lintSVG(slidePath, raw)...)
}
report = normalizeValidationReport(report)
if err := writeValidationArtifacts(safeRoot, report); err != nil {
return report, err
}
return report, nil
}
func failValidation(safeRoot string, issue ValidationIssue, err error) (ValidationReport, error) {
report := ValidationReport{Issues: []ValidationIssue{issue}}
report = normalizeValidationReport(report)
if writeErr := writeValidationArtifacts(safeRoot, report); writeErr != nil {
if err != nil {
return report, fmt.Errorf("%w; write validation artifacts: %v", err, writeErr)
}
return report, writeErr
}
return report, nil
}
func readRunRegularArtifact(safeRoot string, rel string) ([]byte, error) {
info, path, exists, err := lstatRunPath(safeRoot, rel)
if err != nil {
return nil, err
}
if !exists || !info.Mode().IsRegular() {
return nil, fmt.Errorf("run path %q is missing or not a regular file inside run root", rel)
}
raw, err := vfs.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read run path %q: %w", rel, err)
}
return raw, nil
}
func lintSVG(path string, raw []byte) []ValidationIssue {
decoder := xml.NewDecoder(bytes.NewReader(raw))
var issues []ValidationIssue
var rootSeen bool
var rootIsSVG bool
var hasSlideRole bool
var hasViewBox bool
var hasVisibleContent bool
var viewBox svgViewBox
var stack []svgLintElement
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
return []ValidationIssue{{Path: path, Code: "svglide.xml", Message: fmt.Sprintf("invalid XML: %v", err)}}
}
switch typed := token.(type) {
case xml.StartElement:
parentExcluded := len(stack) > 0 && stack[len(stack)-1].Excluded
excluded := parentExcluded || elementIsHidden(typed) || elementIsNonRendering(typed)
ctx := svgLintElement{
Excluded: excluded,
TextCandidate: elementIsTextCandidate(typed),
}
if !rootSeen {
rootSeen = true
rootIsSVG = typed.Name.Local == "svg" && typed.Name.Space == svgNamespace
hasSlideRole = hasRootSlideRole(typed)
viewBox, hasViewBox = rootViewBox(typed)
stack = append(stack, ctx)
continue
}
if elementCountsAsVisibleContent(typed, viewBox, excluded) {
hasVisibleContent = true
}
stack = append(stack, ctx)
case xml.CharData:
if strings.TrimSpace(string(typed)) != "" && activeVisibleTextCandidate(stack) {
hasVisibleContent = true
}
case xml.EndElement:
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
default:
continue
}
}
if !rootSeen {
return []ValidationIssue{{Path: path, Code: "svglide.xml", Message: "invalid XML: missing root element"}}
}
if !rootIsSVG {
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.root", Message: "root element must be <svg>"})
}
if !hasSlideRole {
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.slide_role", Message: `root element must include slide:role="slide"`})
}
if !hasViewBox {
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.viewbox", Message: "root element must include viewBox"})
} else if !viewBox.Valid {
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.viewbox", Message: "root element must include valid viewBox"})
}
if rootIsSVG && !hasVisibleContent {
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.visible_content", Message: "slide contains only background/placeholder content"})
}
return issues
}
func hasRootSlideRole(start xml.StartElement) bool {
for _, attr := range start.Attr {
if strings.TrimSpace(attr.Value) != "slide" {
continue
}
if attr.Name.Local == "role" && attr.Name.Space == slideNamespace {
return true
}
}
return false
}
func rootViewBox(start xml.StartElement) (svgViewBox, bool) {
for _, attr := range start.Attr {
if attr.Name.Space != "" || attr.Name.Local != "viewBox" || strings.TrimSpace(attr.Value) == "" {
continue
}
return parseViewBox(attr.Value), true
}
return svgViewBox{}, false
}
func parseViewBox(value string) svgViewBox {
fields := strings.Fields(strings.ReplaceAll(value, ",", " "))
if len(fields) != 4 {
return svgViewBox{}
}
values := make([]float64, 4)
for i, field := range fields {
parsed, err := strconv.ParseFloat(field, 64)
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
return svgViewBox{}
}
values[i] = parsed
}
width := values[2]
height := values[3]
if width <= 0 || height <= 0 {
return svgViewBox{}
}
return svgViewBox{Width: width, Height: height, Valid: true}
}
func elementCountsAsVisibleContent(start xml.StartElement, viewBox svgViewBox, excluded bool) bool {
if excluded {
return false
}
if start.Name.Space != svgNamespace {
return false
}
if hasSemanticMarker(start, "background", "placeholder") {
return false
}
switch start.Name.Local {
case "text", "tspan":
return false
case "foreignObject", "chart":
return true
case "image", "use":
return elementHasHref(start)
case "g":
return hasSemanticMarker(start, "chart", "shape")
case "path", "circle", "ellipse", "line", "polyline", "polygon":
return true
case "rect":
return !isBackgroundRect(start, viewBox)
default:
return hasSemanticMarker(start, "chart", "shape")
}
}
func activeVisibleTextCandidate(stack []svgLintElement) bool {
for i := len(stack) - 1; i >= 0; i-- {
if stack[i].Excluded {
return false
}
if stack[i].TextCandidate {
return true
}
}
return false
}
func elementIsTextCandidate(start xml.StartElement) bool {
return start.Name.Space == svgNamespace && (start.Name.Local == "text" || start.Name.Local == "tspan")
}
func elementIsHidden(start xml.StartElement) bool {
for _, attr := range start.Attr {
if attr.Name.Space != "" {
continue
}
switch attr.Name.Local {
case "display":
if strings.EqualFold(strings.TrimSpace(attr.Value), "none") {
return true
}
case "visibility":
if strings.EqualFold(strings.TrimSpace(attr.Value), "hidden") {
return true
}
case "opacity":
if opacityIsZero(attr.Value) {
return true
}
case "style":
if styleHidesElement(attr.Value) {
return true
}
}
}
return false
}
func styleHidesElement(style string) bool {
for _, declaration := range strings.Split(style, ";") {
name, value, ok := strings.Cut(declaration, ":")
if !ok {
continue
}
switch strings.ToLower(strings.TrimSpace(name)) {
case "display":
if strings.EqualFold(strings.TrimSpace(value), "none") {
return true
}
case "visibility":
if strings.EqualFold(strings.TrimSpace(value), "hidden") {
return true
}
case "opacity":
if opacityIsZero(value) {
return true
}
}
}
return false
}
func opacityIsZero(value string) bool {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
return false
}
return floatEqual(parsed, 0)
}
func elementIsNonRendering(start xml.StartElement) bool {
if start.Name.Space != svgNamespace {
return false
}
switch start.Name.Local {
case "defs", "symbol", "clipPath", "mask", "pattern", "linearGradient", "radialGradient", "marker", "metadata", "title", "desc", "style", "script":
return true
default:
return false
}
}
func elementHasHref(start xml.StartElement) bool {
for _, attr := range start.Attr {
if attr.Name.Local != "href" || strings.TrimSpace(attr.Value) == "" {
continue
}
if attr.Name.Space == "" || attr.Name.Space == xlinkNamespace {
return true
}
}
return false
}
func hasSemanticMarker(start xml.StartElement, terms ...string) bool {
for _, attr := range start.Attr {
if attr.Name.Space != "" {
continue
}
name := strings.ToLower(attr.Name.Local)
if name != "role" && name != "class" && name != "id" && !strings.HasPrefix(name, "data-") {
continue
}
value := strings.ToLower(attr.Value)
for _, term := range terms {
if strings.Contains(value, term) {
return true
}
}
}
return false
}
func isBackgroundRect(start xml.StartElement, viewBox svgViewBox) bool {
if hasSemanticMarker(start, "background", "placeholder") {
return true
}
width := attrValue(start, "width")
height := attrValue(start, "height")
if width == "100%" && height == "100%" {
return true
}
if !viewBox.Valid {
return false
}
x := attrFloatDefault(start, "x", 0)
y := attrFloatDefault(start, "y", 0)
w, okW := parseAttrFloat(width)
h, okH := parseAttrFloat(height)
if !okW || !okH {
return false
}
return floatEqual(x, 0) && floatEqual(y, 0) && floatEqual(w, viewBox.Width) && floatEqual(h, viewBox.Height)
}
func attrValue(start xml.StartElement, name string) string {
for _, attr := range start.Attr {
if attr.Name.Space == "" && attr.Name.Local == name {
return strings.TrimSpace(attr.Value)
}
}
return ""
}
func attrFloatDefault(start xml.StartElement, name string, fallback float64) float64 {
value := attrValue(start, name)
if value == "" {
return fallback
}
parsed, ok := parseAttrFloat(value)
if !ok {
return fallback
}
return parsed
}
func parseAttrFloat(value string) (float64, bool) {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil {
return 0, false
}
return parsed, true
}
func floatEqual(a float64, b float64) bool {
return math.Abs(a-b) < 0.001
}

View File

@@ -0,0 +1,711 @@
package svglide
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestValidateRunRejectsBackgroundOnlySVGAndWritesRepairArtifacts(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), backgroundOnlySVG())
report, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if report.OK {
t.Fatalf("OK = true, want false")
}
if len(report.Issues) == 0 {
t.Fatal("expected background-only SVG issue")
}
if !validationIssuesContain(report.Issues, "background") {
t.Fatalf("Issues = %+v, want background/placeholder issue", report.Issues)
}
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "lint.json"))
if err != nil {
t.Fatalf("missing lint receipt: %v", err)
}
var receipt ValidationReport
if err := json.Unmarshal(raw, &receipt); err != nil {
t.Fatalf("lint receipt is not ValidationReport JSON: %v", err)
}
if receipt.OK || len(receipt.Issues) == 0 {
t.Fatalf("lint receipt = %+v, want failing issues", receipt)
}
var lintReceipt validationLintReceipt
if err := json.Unmarshal(raw, &lintReceipt); err != nil {
t.Fatalf("lint receipt is not schema-compatible JSON: %v", err)
}
if lintReceipt.Status != "failed" {
t.Fatalf("lint receipt status = %q, want failed", lintReceipt.Status)
}
if lintReceipt.Issues[0].Code == "" || lintReceipt.Issues[0].Severity == "" {
t.Fatalf("lint receipt issue = %+v, want code and severity", lintReceipt.Issues[0])
}
queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md"))
if err != nil {
t.Fatalf("missing repair queue: %v", err)
}
if !strings.Contains(string(queue), "slides/01.svg") {
t.Fatalf("repair queue = %q, want slide path", string(queue))
}
}
func TestValidateRunPassesVisibleTextSVG(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
report, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if !report.OK {
t.Fatalf("OK = false, issues = %+v", report.Issues)
}
if len(report.Issues) != 0 {
t.Fatalf("Issues = %+v, want empty", report.Issues)
}
queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md"))
if err != nil {
t.Fatalf("missing repair queue: %v", err)
}
if strings.TrimSpace(string(queue)) != "No repair needed." {
t.Fatalf("repair queue = %q, want no repair text", string(queue))
}
}
func TestValidateRunRejectsEscapingSlidePath(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo", "../outside.svg")
writeValidateTestFile(t, "outside.svg", visibleTextSVG())
report, err := ValidateRun("demo")
if err == nil && report.OK {
t.Fatalf("ValidateRun OK with escaping slide path: %+v", report)
}
}
func TestValidateRunRejectsSlideSymlinks(t *testing.T) {
tests := []struct {
name string
deckPath string
setupLink func(t *testing.T, outside string)
}{
{
name: "file symlink",
deckPath: "slides/01.svg",
setupLink: func(t *testing.T, outside string) {
if err := os.Symlink(filepath.Join(outside, "01.svg"), filepath.Join("demo", "slides", "01.svg")); err != nil {
t.Fatal(err)
}
},
},
{
name: "intermediate symlink",
deckPath: "slides/link/01.svg",
setupLink: func(t *testing.T, outside string) {
if err := os.Symlink(outside, filepath.Join("demo", "slides", "link")); err != nil {
t.Fatal(err)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cwd := initValidateTestRun(t)
writeMinimalDeck(t, "demo", tt.deckPath)
outside := filepath.Join(filepath.Dir(cwd), "outside")
if err := os.MkdirAll(outside, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "01.svg"), []byte(visibleTextSVG()), 0o644); err != nil {
t.Fatal(err)
}
tt.setupLink(t, outside)
report, err := ValidateRun("demo")
if err == nil && report.OK {
t.Fatalf("ValidateRun OK with symlinked slide path: %+v", report)
}
})
}
}
func TestValidateRunRejectsDeckSymlinks(t *testing.T) {
tests := []struct {
name string
deckPath string
setupLink func(t *testing.T, outside string)
}{
{
name: "file symlink",
deckPath: filepath.Join("demo", "outline", "deck.json"),
setupLink: func(t *testing.T, outside string) {
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(outside, "deck.json"), filepath.Join("demo", "outline", "deck.json")); err != nil {
t.Fatal(err)
}
},
},
{
name: "intermediate symlink",
deckPath: filepath.Join("demo", "outline_link", "deck.json"),
setupLink: func(t *testing.T, outside string) {
run := readValidateTestRunFile(t)
run.Artifacts.Deck = "outline_link/deck.json"
writeValidateTestRunFile(t, run)
if err := os.Symlink(outside, filepath.Join("demo", "outline_link")); err != nil {
t.Fatal(err)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cwd := initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
outside := filepath.Join(filepath.Dir(cwd), "outside")
if err := os.MkdirAll(outside, 0o755); err != nil {
t.Fatal(err)
}
writeMinimalDeckAt(t, filepath.Join(outside, "deck.json"), "slides/01.svg")
tt.setupLink(t, outside)
report, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if report.OK {
t.Fatalf("ValidateRun OK with symlinked deck path %q: %+v", tt.deckPath, report)
}
})
}
}
func TestValidateRunRejectsEmptyDeck(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo")
report, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
assertValidationFailureArtifacts(t, "demo", report, "no slides")
}
func TestValidateRunWritesRepairArtifactsForDeckReadFailures(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T)
wantErr string
}{
{
name: "missing deck",
setup: func(t *testing.T) {
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil {
t.Fatal(err)
}
},
wantErr: "deck",
},
{
name: "invalid deck json",
setup: func(t *testing.T) {
writeValidateTestFile(t, filepath.Join("demo", "outline", "deck.json"), `{`)
},
wantErr: "deck",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
tt.setup(t)
report, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
assertValidationFailureArtifacts(t, "demo", report, tt.wantErr)
})
}
}
func TestValidateRunReadsDeckFromRunArtifacts(t *testing.T) {
initValidateTestRun(t)
run := readValidateTestRunFile(t)
run.Artifacts.Deck = "custom/deck.json"
writeValidateTestRunFile(t, run)
writeMinimalDeck(t, "demo", "slides/bad.svg")
writeMinimalDeckAt(t, filepath.Join("demo", "custom", "deck.json"), "slides/01.svg")
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
report, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if !report.OK {
t.Fatalf("OK = false, issues = %+v", report.Issues)
}
}
func TestValidateRunReportsInvalidXML(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg><text>broken`)
report, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if report.OK {
t.Fatalf("OK = true, want false")
}
if !validationIssuesContain(report.Issues, "XML") && !validationIssuesContain(report.Issues, "xml") {
t.Fatalf("Issues = %+v, want XML parse issue", report.Issues)
}
}
func TestValidateRunRequiresSVGRootSlideRoleAndViewBox(t *testing.T) {
tests := []struct {
name string
svg string
want string
}{
{
name: "non svg root",
svg: `<html><body>not svg</body></html>`,
want: "<svg>",
},
{
name: "wrong svg namespace",
svg: `<svg xmlns="https://wrong.example/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
want: "<svg>",
},
{
name: "missing slide role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540"><text>hello</text></svg>`,
want: `slide:role`,
},
{
name: "missing viewBox",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text>hello</text></svg>`,
want: `viewBox`,
},
{
name: "wrong namespaced slide role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:foo="https://wrong.example" foo:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
want: `slide:role`,
},
{
name: "unbound slide prefix role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" slide:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
want: `slide:role`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
report, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if report.OK {
t.Fatalf("OK = true, want false")
}
if !validationIssuesContain(report.Issues, tt.want) {
t.Fatalf("Issues = %+v, want %q", report.Issues, tt.want)
}
})
}
}
func TestValidateRunRejectsInvalidViewBox(t *testing.T) {
tests := []struct {
name string
svg string
}{
{
name: "bad viewBox with text",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad"><text>hello</text></svg>`,
},
{
name: "bad viewBox origin fields",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad bad 960 540"><text>hello</text></svg>`,
},
{
name: "nan viewBox width",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 NaN 540"><text>hello</text></svg>`,
},
{
name: "zero viewBox with text",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 0 540"><text>hello</text></svg>`,
},
{
name: "bad viewBox with full page rect",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad"><rect width="960" height="540" fill="#fff"/></svg>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
report, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if report.OK {
t.Fatalf("OK = true, want false")
}
if !validationIssuesContain(report.Issues, "viewBox") {
t.Fatalf("Issues = %+v, want viewBox issue", report.Issues)
}
})
}
}
func TestValidateRunIgnoresNonVisibleContent(t *testing.T) {
tests := []struct {
name string
body string
}{
{
name: "text in defs",
body: `<defs><text>hidden template</text></defs>`,
},
{
name: "display none text",
body: `<text display="none">hidden</text>`,
},
{
name: "visibility hidden text",
body: `<text visibility="hidden">hidden</text>`,
},
{
name: "style display none text",
body: `<text style="display:none">hidden</text>`,
},
{
name: "style visibility hidden text",
body: `<text style="visibility:hidden">hidden</text>`,
},
{
name: "opacity zero text",
body: `<text opacity="0">hidden</text>`,
},
{
name: "style opacity zero text",
body: `<text style="opacity:0">hidden</text>`,
},
{
name: "empty text",
body: `<text> </text>`,
},
{
name: "image without href",
body: `<image width="120" height="80"/>`,
},
{
name: "use without href",
body: `<use x="10" y="10"/>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">` + tt.body + `</svg>`
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
report, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if report.OK {
t.Fatalf("OK = true, want false")
}
if !validationIssuesContain(report.Issues, "background") && !validationIssuesContain(report.Issues, "placeholder") {
t.Fatalf("Issues = %+v, want placeholder issue", report.Issues)
}
})
}
}
func TestValidateRunRejectsWrongNamespaceVisibleContent(t *testing.T) {
tests := []struct {
name string
body string
}{
{
name: "wrong namespace path",
body: `<bad:path xmlns:bad="https://wrong.example/svg" d="M10 10h20v20z"/>`,
},
{
name: "wrong namespace text",
body: `<bad:text xmlns:bad="https://wrong.example/svg">hidden by namespace</bad:text>`,
},
{
name: "wrong namespace image href",
body: `<image xmlns:bad="https://wrong.example/svg" bad:href="asset.png" width="120" height="80"/>`,
},
{
name: "wrong namespace viewBox",
body: `<text>hello</text>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
viewBox := `viewBox="0 0 960 540"`
if tt.name == "wrong namespace viewBox" {
viewBox = `bad:viewBox="0 0 960 540" xmlns:bad="https://wrong.example/svg"`
}
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" ` + viewBox + `>` + tt.body + `</svg>`
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
report, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if report.OK {
t.Fatalf("OK = true, want false")
}
if tt.name == "wrong namespace viewBox" {
if !validationIssuesContain(report.Issues, "viewBox") {
t.Fatalf("Issues = %+v, want viewBox issue", report.Issues)
}
return
}
if !validationIssuesContain(report.Issues, "background") && !validationIssuesContain(report.Issues, "placeholder") {
t.Fatalf("Issues = %+v, want placeholder issue", report.Issues)
}
})
}
}
func TestValidateRunAcceptsNamespacedXLinkHref(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" xmlns:xlink="http://www.w3.org/1999/xlink" slide:role="slide" viewBox="0 0 960 540"><image xlink:href="asset.png" width="120" height="80"/></svg>`
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
report, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if !report.OK {
t.Fatalf("OK = false, issues = %+v", report.Issues)
}
}
func TestValidateRunAcceptsPlainHref(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><image href="asset.png" width="120" height="80"/></svg>`
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
report, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if !report.OK {
t.Fatalf("OK = false, issues = %+v", report.Issues)
}
}
func TestValidateRunRejectsReceiptSymlink(t *testing.T) {
cwd := initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
if err := os.RemoveAll(filepath.Join("demo", "receipts")); err != nil {
t.Fatal(err)
}
outside := filepath.Join(filepath.Dir(cwd), "outside-receipts")
if err := os.MkdirAll(outside, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join("demo", "receipts")); err != nil {
t.Fatal(err)
}
if _, err := ValidateRun("demo"); err == nil {
t.Fatal("expected receipt symlink write refusal")
}
if _, err := os.Stat(filepath.Join(outside, "lint.json")); !os.IsNotExist(err) {
t.Fatalf("lint receipt should not be written outside run root, stat err=%v", err)
}
}
func TestValidateRunRejectsLintReceiptFileSymlink(t *testing.T) {
cwd := initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
if err := os.Remove(filepath.Join("demo", "receipts", "lint.json")); err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
outside := filepath.Join(filepath.Dir(cwd), "outside-lint.json")
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join("demo", "receipts", "lint.json")); err != nil {
t.Fatal(err)
}
if _, err := ValidateRun("demo"); err == nil {
t.Fatal("expected lint receipt file symlink write refusal")
}
raw, err := os.ReadFile(outside)
if err != nil {
t.Fatal(err)
}
if string(raw) != "outside" {
t.Fatalf("outside file was overwritten: %q", string(raw))
}
}
func initValidateTestRun(t *testing.T) string {
t.Helper()
cwd := t.TempDir()
t.Chdir(cwd)
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
t.Fatal(err)
}
if err := InitRun("demo", InitOptions{Title: "Demo", Input: "source.md"}); err != nil {
t.Fatal(err)
}
return cwd
}
func writeMinimalDeck(t *testing.T, root string, slidePaths ...string) {
t.Helper()
writeMinimalDeckAt(t, filepath.Join(root, "outline", "deck.json"), slidePaths...)
}
func writeMinimalDeckAt(t *testing.T, path string, slidePaths ...string) {
t.Helper()
slides := make([]map[string]string, 0, len(slidePaths))
for i, path := range slidePaths {
slides = append(slides, map[string]string{
"id": "slide-" + string(rune('1'+i)),
"title": "Slide",
"summary": "Summary",
"role": "content",
"key_message": "Message",
"path": path,
})
}
raw, err := json.MarshalIndent(map[string]any{
"title": "Demo",
"slides": slides,
}, "", " ")
if err != nil {
t.Fatal(err)
}
raw = append(raw, '\n')
writeValidateTestFile(t, path, string(raw))
}
func readValidateTestRunFile(t *testing.T) Run {
t.Helper()
raw, err := os.ReadFile(filepath.Join("demo", "run.json"))
if err != nil {
t.Fatal(err)
}
var run Run
if err := json.Unmarshal(raw, &run); err != nil {
t.Fatal(err)
}
return run
}
func writeValidateTestRunFile(t *testing.T, run Run) {
t.Helper()
raw, err := json.MarshalIndent(run, "", " ")
if err != nil {
t.Fatal(err)
}
raw = append(raw, '\n')
if err := os.WriteFile(filepath.Join("demo", "run.json"), raw, 0o644); err != nil {
t.Fatal(err)
}
}
func writeValidateTestFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func backgroundOnlySVG() string {
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><rect width="960" height="540" fill="#fff"/></svg>`
}
func visibleTextSVG() string {
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><rect width="960" height="540" fill="#fff"/><text x="48" y="80">Hello</text></svg>`
}
func validationIssuesContain(issues []ValidationIssue, needle string) bool {
for _, issue := range issues {
if strings.Contains(issue.Path, needle) || strings.Contains(issue.Message, needle) {
return true
}
}
return false
}
func assertValidationFailureArtifacts(t *testing.T, root string, report ValidationReport, needle string) {
t.Helper()
if report.OK {
t.Fatalf("OK = true, want false")
}
if len(report.Issues) == 0 {
t.Fatal("expected validation issue")
}
if !validationIssuesContain(report.Issues, needle) {
t.Fatalf("Issues = %+v, want %q", report.Issues, needle)
}
raw, err := os.ReadFile(filepath.Join(root, "receipts", "lint.json"))
if err != nil {
t.Fatalf("missing lint receipt: %v", err)
}
var receipt ValidationReport
if err := json.Unmarshal(raw, &receipt); err != nil {
t.Fatalf("lint receipt is not ValidationReport JSON: %v", err)
}
if receipt.OK || !validationIssuesContain(receipt.Issues, needle) {
t.Fatalf("lint receipt = %+v, want failing issue containing %q", receipt, needle)
}
queue, err := os.ReadFile(filepath.Join(root, "repair_queue.md"))
if err != nil {
t.Fatalf("missing repair queue: %v", err)
}
if !strings.Contains(string(queue), needle) {
t.Fatalf("repair queue = %q, want %q", string(queue), needle)
}
}