diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bcbbe25 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# =========================================== +# Vessel Configuration +# =========================================== +# Copy this file to .env and adjust values as needed. +# All variables have sensible defaults - only set what you need to change. + +# ----- Backend ----- +# Server port (default: 8080, but 9090 recommended for local dev) +PORT=9090 + +# SQLite database path (relative to backend working directory) +DB_PATH=./data/vessel.db + +# Ollama API endpoint +OLLAMA_URL=http://localhost:11434 + +# GitHub repo for version checking (format: owner/repo) +GITHUB_REPO=VikingOwl91/vessel + +# ----- Frontend ----- +# Ollama API endpoint (for frontend proxy) +OLLAMA_API_URL=http://localhost:11434 + +# Backend API endpoint +BACKEND_URL=http://localhost:9090 + +# Development server port +DEV_PORT=7842 diff --git a/.gitignore b/.gitignore index 461a096..2c1204a 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ backend/data-dev/ # Generated files frontend/static/pdf.worker.min.mjs + +# Test artifacts +frontend/playwright-report/ +frontend/test-results/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78fba65..5398c59 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,11 +2,63 @@ Thanks for your interest in Vessel. -- Issues and pull requests are handled on GitHub: - https://github.com/VikingOwl91/vessel +### Where to Contribute -- Keep changes focused and small. -- UI and UX improvements are welcome. -- Vessel intentionally avoids becoming a platform. +- **Issues**: Open on GitHub at https://github.com/VikingOwl91/vessel +- **Pull Requests**: Submit via GitHub (for external contributors) or Gitea (for maintainers) -If you’re unsure whether something fits, open an issue first. +### Branching Strategy + +``` +main (protected - releases only) + └── dev (default development branch) + └── feature/your-feature + └── fix/bug-description +``` + +- **main**: Production releases only. No direct pushes allowed. +- **dev**: Active development. All changes merge here first. +- **feature/***: New features, branch from `dev` +- **fix/***: Bug fixes, branch from `dev` + +### Workflow + +1. **Fork** the repository (external contributors) +2. **Clone** and switch to dev: + ```bash + git clone https://github.com/VikingOwl91/vessel.git + cd vessel + git checkout dev + ``` +3. **Create a feature branch**: + ```bash + git checkout -b feature/your-feature + ``` +4. **Make changes** with clear, focused commits +5. **Test** your changes +6. **Push** and create a PR targeting `dev`: + ```bash + git push -u origin feature/your-feature + ``` +7. Open a PR from your branch to `dev` + +### Commit Messages + +Follow conventional commits: +- `feat:` New features +- `fix:` Bug fixes +- `docs:` Documentation changes +- `refactor:` Code refactoring +- `test:` Adding tests +- `chore:` Maintenance tasks + +### Guidelines + +- Keep changes focused and small +- UI and UX improvements are welcome +- Vessel intentionally avoids becoming a platform +- If unsure whether something fits, open an issue first + +### Development Setup + +See the [Development Wiki](https://github.com/VikingOwl91/vessel/wiki/Development) for detailed setup instructions. diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c8f9953..8dc15fd 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -18,7 +18,7 @@ import ( ) // Version is set at build time via -ldflags, or defaults to dev -var Version = "0.5.2" +var Version = "0.6.0" func getEnvOrDefault(key, defaultValue string) string { if value := os.Getenv(key); value != "" { diff --git a/backend/internal/api/fetcher_test.go b/backend/internal/api/fetcher_test.go new file mode 100644 index 0000000..e703bdf --- /dev/null +++ b/backend/internal/api/fetcher_test.go @@ -0,0 +1,196 @@ +package api + +import ( + "testing" +) + +func TestDefaultFetchOptions(t *testing.T) { + opts := DefaultFetchOptions() + + if opts.MaxLength != 500000 { + t.Errorf("expected MaxLength 500000, got %d", opts.MaxLength) + } + if opts.Timeout.Seconds() != 30 { + t.Errorf("expected Timeout 30s, got %v", opts.Timeout) + } + if opts.UserAgent == "" { + t.Error("expected non-empty UserAgent") + } + if opts.Headers == nil { + t.Error("expected Headers to be initialized") + } + if !opts.FollowRedirects { + t.Error("expected FollowRedirects to be true") + } + if opts.WaitTime.Seconds() != 2 { + t.Errorf("expected WaitTime 2s, got %v", opts.WaitTime) + } +} + +func TestStripHTMLTags(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "removes simple tags", + input: "
Hello World
", + expected: "Hello World", + }, + { + name: "removes nested tags", + input: "Before
After
", + expected: "Before After", + }, + { + name: "removes style tags with content", + input: "Text
More
", + expected: "Text More", + }, + { + name: "collapses whitespace", + input: "Lots of spaces
", + expected: "Lots of spaces", + }, + { + name: "handles empty input", + input: "", + expected: "", + }, + { + name: "handles plain text", + input: "No HTML here", + expected: "No HTML here", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := stripHTMLTags(tt.input) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestIsJSRenderedPage(t *testing.T) { + f := &Fetcher{} + + tests := []struct { + name string + content string + expected bool + }{ + { + name: "short content indicates JS rendering", + content: "", + expected: true, + }, + { + name: "React root div with minimal content", + content: "", + expected: true, + }, + { + name: "Next.js pattern", + content: "", + expected: true, + }, + { + name: "Nuxt.js pattern", + content: "", + expected: true, + }, + { + name: "noscript indicator", + content: "", + expected: true, + }, + { + name: "substantial content is not JS-rendered", + content: generateLongContent(2000), + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := f.isJSRenderedPage(tt.content) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +// generateLongContent creates content of specified length +func generateLongContent(length int) string { + base := "A foundation model
+ 1.5M + 8b + 70b + vision + 2 weeks ago + + +Fast model
+ 500K + 7b + + ` + + models, err := parseLibraryHTML(html) + if err != nil { + t.Fatalf("parseLibraryHTML failed: %v", err) + } + + if len(models) != 2 { + t.Fatalf("expected 2 models, got %d", len(models)) + } + + // Find llama3.2 model + var llama *ScrapedModel + for i := range models { + if models[i].Slug == "llama3.2" { + llama = &models[i] + break + } + } + + if llama == nil { + t.Fatal("llama3.2 model not found") + } + + if llama.Description != "A foundation model" { + t.Errorf("description = %q, want %q", llama.Description, "A foundation model") + } + + if llama.PullCount != 1500000 { + t.Errorf("pull count = %d, want 1500000", llama.PullCount) + } + + if len(llama.Tags) != 2 || llama.Tags[0] != "8b" || llama.Tags[1] != "70b" { + t.Errorf("tags = %v, want [8b, 70b]", llama.Tags) + } + + if len(llama.Capabilities) != 1 || llama.Capabilities[0] != "vision" { + t.Errorf("capabilities = %v, want [vision]", llama.Capabilities) + } + + if !strings.HasPrefix(llama.URL, "https://ollama.com/library/") { + t.Errorf("URL = %q, want prefix https://ollama.com/library/", llama.URL) + } +} + +func TestParseModelPageForSizes(t *testing.T) { + html := ` + + 8b + 2.0GB + + + 70b + 40.5GB + + + 1b + 500MB + + ` + + sizes, err := parseModelPageForSizes(html) + if err != nil { + t.Fatalf("parseModelPageForSizes failed: %v", err) + } + + expected := map[string]int64{ + "8b": int64(2.0 * 1024 * 1024 * 1024), + "70b": int64(40.5 * 1024 * 1024 * 1024), + "1b": int64(500 * 1024 * 1024), + } + + for tag, expectedSize := range expected { + if sizes[tag] != expectedSize { + t.Errorf("sizes[%q] = %d, want %d", tag, sizes[tag], expectedSize) + } + } +} + +func TestStripHTML(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"simple tags", "Hello
", " Hello "}, + {"nested tags", "