// Package integrationtests runs end-to-end tests of the snonux generator pipeline. // Each test creates temporary input/output directories, places fixture files, runs // the full processor+generator pipeline, and asserts the expected outputs. package integrationtests import ( "encoding/json" "encoding/xml" "fmt" "image" "image/color" "image/color/palette" "image/gif" "image/jpeg" "image/png" "os" "path/filepath" "strings" "testing" "codeberg.org/snonux/snonux/internal/config" "codeberg.org/snonux/snonux/internal/generator" "codeberg.org/snonux/snonux/internal/processor" ) // runPipeline executes both pipeline stages and returns the config used. func runPipeline(t *testing.T, inputDir, outputDir string) *config.Config { t.Helper() cfg := &config.Config{ InputDir: inputDir, OutputDir: outputDir, BaseURL: "https://snonux.foo", Theme: "neon", } _, err := processor.Run(cfg) if err != nil { t.Fatalf("processor.Run: %v", err) } if err := generator.Run(cfg); err != nil { t.Fatalf("generator.Run: %v", err) } return cfg } // makeDirs creates temporary input and output directories for a test. func makeDirs(t *testing.T) (inputDir, outputDir string) { t.Helper() base := t.TempDir() inputDir = filepath.Join(base, "inbox") outputDir = filepath.Join(base, "outdir") if err := os.MkdirAll(inputDir, 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(outputDir, 0o755); err != nil { t.Fatal(err) } return inputDir, outputDir } // readFile is a helper that reads a file and fails the test on error. func readFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) } return string(data) } // assertContains fails the test if content does not contain substr. func assertContains(t *testing.T, content, substr, label string) { t.Helper() if !strings.Contains(content, substr) { t.Errorf("%s: expected to contain %q\ngot:\n%s", label, substr, content[:min(len(content), 500)]) } } func min(a, b int) int { if a < b { return a } return b } // TestTxtInput verifies plain text files are converted to posts. func TestTxtInput(t *testing.T) { inputDir, outputDir := makeDirs(t) if err := os.WriteFile(filepath.Join(inputDir, "hello.txt"), []byte("Hello, Nexus!"), 0o644); err != nil { t.Fatal(err) } runPipeline(t, inputDir, outputDir) // Source file should have been removed after processing. if _, err := os.Stat(filepath.Join(inputDir, "hello.txt")); !os.IsNotExist(err) { t.Error("source file should have been deleted from input dir") } // A post directory should exist under outdir/posts/. entries, err := os.ReadDir(filepath.Join(outputDir, "posts")) if err != nil { t.Fatalf("read posts dir: %v", err) } if len(entries) != 1 { t.Fatalf("expected 1 post dir, got %d", len(entries)) } // index.html must contain the post text. index := readFile(t, filepath.Join(outputDir, "index.html")) assertContains(t, index, "Hello, Nexus!", "index.html") // splash WebGL canvas is part of the per-theme splash markup baked in. assertContains(t, index, "splash-gl-canvas", "index.html splash WebGL canvas") assertContains(t, index, `href="atom.xml"`, "index.html atom feed link") // shared bundles must also be written. if _, err := os.Stat(filepath.Join(outputDir, "shared.css")); err != nil { t.Fatalf("shared.css missing: %v", err) } if _, err := os.Stat(filepath.Join(outputDir, "shared.js")); err != nil { t.Fatalf("shared.js missing: %v", err) } } // TestMarkdownInput verifies Markdown files are converted to HTML. func TestMarkdownInput(t *testing.T) { inputDir, outputDir := makeDirs(t) md := "# Hello Nexus\n\nThis is **bold** text." if err := os.WriteFile(filepath.Join(inputDir, "post.md"), []byte(md), 0o644); err != nil { t.Fatal(err) } runPipeline(t, inputDir, outputDir) index := readFile(t, filepath.Join(outputDir, "index.html")) assertContains(t, index, "bold", "index.html markdown bold") assertContains(t, index, "

", "index.html markdown h1") } // assertStandaloneImagePost checks index.html and posts//image.jpg after a lone image input. func assertStandaloneImagePost(t *testing.T, outputDir string) { t.Helper() index := readFile(t, filepath.Join(outputDir, "index.html")) assertContains(t, index, `p music", "index.html nav hint p=ambient") assertContains(t, index, "f flash", "index.html nav hint f=flash") } // TestIndexHTMLBakesSounds verifies that the generated index.html bakes the // default theme's sounds into window.SNONUX_SOUNDS so the ambient engine can // start before any async theme fetches complete. func TestIndexHTMLBakesSounds(t *testing.T) { inputDir, outputDir := makeDirs(t) if err := os.WriteFile(filepath.Join(inputDir, "hello.txt"), []byte("sounds test"), 0o644); err != nil { t.Fatal(err) } runPipeline(t, inputDir, outputDir) index := readFile(t, filepath.Join(outputDir, "index.html")) assertContains(t, index, "window.SNONUX_SOUNDS", "index.html bakes SNONUX_SOUNDS") assertContains(t, index, `"ambient"`, "index.html sounds include ambient key") assertContains(t, index, `"normal"`, "index.html sounds include ambient.normal") assertContains(t, index, `"wild"`, "index.html sounds include ambient.wild") } // TestThemeSelection verifies that every registered theme renders a valid // index.html containing core structural elements (post text, nav script). func TestThemeSelection(t *testing.T) { themes := generator.ListThemes() if len(themes) == 0 { t.Fatal("no themes returned by ListThemes()") } for _, theme := range themes { theme := theme // capture for parallel sub-test t.Run(theme, func(t *testing.T) { inputDir, outputDir := makeDirs(t) if err := os.WriteFile(filepath.Join(inputDir, "hello.txt"), []byte("theme test post"), 0o644); err != nil { t.Fatal(err) } cfg := &config.Config{ InputDir: inputDir, OutputDir: outputDir, BaseURL: "https://snonux.foo", Theme: theme, } if _, err := processor.Run(cfg); err != nil { t.Fatalf("processor.Run: %v", err) } if err := generator.Run(cfg); err != nil { t.Fatalf("generator.Run for theme %q: %v", theme, err) } index := readFile(t, filepath.Join(outputDir, "index.html")) assertContains(t, index, "theme test post", "post text") assertContains(t, index, `data-index="0"`, "data-index attribute") assertContains(t, index, `SNONUX_DEFAULT_THEME = "`+theme+`"`, "default theme baked in") // Per-theme assets are written under themes//. for _, fname := range []string{"theme.css", "theme.js", "meta.json", "sounds.json"} { path := filepath.Join(outputDir, "themes", theme, fname) info, err := os.Stat(path) if err != nil { t.Fatalf("theme asset missing %s: %v", path, err) } if info.Size() == 0 { t.Fatalf("theme asset %s is empty", path) } } // sounds.json must include ambient data. soundsPath := filepath.Join(outputDir, "themes", theme, "sounds.json") soundsData, err := os.ReadFile(soundsPath) if err != nil { t.Fatalf("read sounds.json: %v", err) } var sounds map[string]interface{} if err := json.Unmarshal(soundsData, &sounds); err != nil { t.Fatalf("sounds.json invalid JSON: %v", err) } ambient, ok := sounds["ambient"].(map[string]interface{}) if !ok { t.Fatalf("sounds.json missing ambient object for theme %q", theme) } for _, key := range []string{"normal", "wild"} { if _, ok := ambient[key]; !ok { t.Errorf("sounds.json ambient missing %q variant for theme %q", key, theme) } } // shared.js holds the nav logic. sharedJS := readFile(t, filepath.Join(outputDir, "shared.js")) assertContains(t, sharedJS, "playNavSound", "shared.js playNavSound") }) } } func sampleRGBAImage() *image.RGBA { img := image.NewRGBA(image.Rect(0, 0, 10, 10)) for y := 0; y < 10; y++ { for x := 0; x < 10; x++ { img.Set(x, y, color.RGBA{R: 0, G: 245, B: 255, A: 255}) } } return img } // writeSamplePNG writes a small 10×10 solid-colour PNG to path. func writeSamplePNG(t *testing.T, path string) { t.Helper() f, err := os.Create(path) if err != nil { t.Fatal(err) } defer f.Close() if err := png.Encode(f, sampleRGBAImage()); err != nil { t.Fatal(err) } } // writeSampleJPEG writes a small valid JPEG to path. func writeSampleJPEG(t *testing.T, path string) { t.Helper() f, err := os.Create(path) if err != nil { t.Fatal(err) } defer f.Close() if err := jpeg.Encode(f, sampleRGBAImage(), &jpeg.Options{Quality: 90}); err != nil { t.Fatal(err) } } // writeSampleGIF writes a small single-frame GIF to path. func writeSampleGIF(t *testing.T, path string) { t.Helper() bounds := image.Rect(0, 0, 10, 10) paletted := image.NewPaletted(bounds, palette.Plan9) for y := 0; y < 10; y++ { for x := 0; x < 10; x++ { paletted.SetColorIndex(x, y, 1) } } f, err := os.Create(path) if err != nil { t.Fatal(err) } defer f.Close() if err := gif.Encode(f, paletted, &gif.Options{}); err != nil { t.Fatal(err) } }