* feat(mcp): add workspace root validation to prevent path traversal - Add workspaceRoot field to Service for restricting file operations - Add WithWorkspaceRoot() option for configuring the workspace directory - Add validatePath() helper to check paths are within workspace - Apply validation to all file operation handlers - Default to current working directory for security - Add comprehensive tests for path validation Closes #82 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: move CLI commands from pkg/ to internal/cmd/ - Move 18 CLI command packages to internal/cmd/ (not externally importable) - Keep 16 library packages in pkg/ (externally importable) - Update all import paths throughout codebase - Cleaner separation between CLI logic and reusable libraries CLI commands moved: ai, ci, dev, docs, doctor, gitcmd, go, monitor, php, pkgcmd, qa, sdk, security, setup, test, updater, vm, workspace Libraries remaining: agentic, build, cache, cli, container, devops, errors, framework, git, i18n, io, log, mcp, process, release, repos Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(mcp): use pkg/io Medium for sandboxed file operations Replace manual path validation with pkg/io.Medium for all file operations. This delegates security (path traversal, symlink bypass) to the sandboxed local.Medium implementation. Changes: - Add io.NewSandboxed() for creating sandboxed Medium instances - Refactor MCP Service to use io.Medium instead of direct os.* calls - Remove validatePath and resolvePathWithSymlinks functions - Update tests to verify Medium-based behaviour Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: correct import path and workflow references - Fix pkg/io/io.go import from core-gui to core - Update CI workflows to use internal/cmd/updater path Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(security): address CodeRabbit review issues for path validation - pkg/io/local: add symlink resolution and boundary-aware containment - Reject absolute paths in sandboxed Medium - Use filepath.EvalSymlinks to prevent symlink bypass attacks - Fix prefix check to prevent /tmp/root matching /tmp/root2 - pkg/mcp: fix resolvePath to validate and return errors - Changed resolvePath from (string) to (string, error) - Update deleteFile, renameFile, listDirectory, fileExists to handle errors - Changed New() to return (*Service, error) instead of *Service - Properly propagate option errors instead of silently discarding - pkg/io: wrap errors with E() helper for consistent context - Copy() and MockMedium.Read() now use coreerr.E() - tests: rename to use _Good/_Bad/_Ugly suffixes per coding guidelines - Fix hardcoded /tmp in TestPath to use t.TempDir() - Add TestResolvePath_Bad_SymlinkTraversal test Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: fix gofmt formatting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: fix gofmt formatting across all files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
644 lines
18 KiB
Go
644 lines
18 KiB
Go
package php
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestNewDevServer_Good(t *testing.T) {
|
|
t.Run("creates dev server with default options", func(t *testing.T) {
|
|
opts := Options{}
|
|
server := NewDevServer(opts)
|
|
|
|
assert.NotNil(t, server)
|
|
assert.Empty(t, server.services)
|
|
assert.False(t, server.running)
|
|
})
|
|
|
|
t.Run("creates dev server with custom options", func(t *testing.T) {
|
|
opts := Options{
|
|
Dir: "/tmp/test",
|
|
NoVite: true,
|
|
NoHorizon: true,
|
|
FrankenPHPPort: 9000,
|
|
}
|
|
server := NewDevServer(opts)
|
|
|
|
assert.NotNil(t, server)
|
|
assert.Equal(t, "/tmp/test", server.opts.Dir)
|
|
assert.True(t, server.opts.NoVite)
|
|
})
|
|
}
|
|
|
|
func TestDevServer_IsRunning_Good(t *testing.T) {
|
|
t.Run("returns false when not running", func(t *testing.T) {
|
|
server := NewDevServer(Options{})
|
|
assert.False(t, server.IsRunning())
|
|
})
|
|
}
|
|
|
|
func TestDevServer_Status_Good(t *testing.T) {
|
|
t.Run("returns empty status when no services", func(t *testing.T) {
|
|
server := NewDevServer(Options{})
|
|
statuses := server.Status()
|
|
assert.Empty(t, statuses)
|
|
})
|
|
}
|
|
|
|
func TestDevServer_Services_Good(t *testing.T) {
|
|
t.Run("returns empty services list initially", func(t *testing.T) {
|
|
server := NewDevServer(Options{})
|
|
services := server.Services()
|
|
assert.Empty(t, services)
|
|
})
|
|
}
|
|
|
|
func TestDevServer_Stop_Good(t *testing.T) {
|
|
t.Run("returns nil when not running", func(t *testing.T) {
|
|
server := NewDevServer(Options{})
|
|
err := server.Stop()
|
|
assert.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
func TestDevServer_Start_Bad(t *testing.T) {
|
|
t.Run("fails when already running", func(t *testing.T) {
|
|
server := NewDevServer(Options{})
|
|
server.running = true
|
|
|
|
err := server.Start(context.Background(), Options{})
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "already running")
|
|
})
|
|
|
|
t.Run("fails for non-Laravel project", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
server := NewDevServer(Options{Dir: dir})
|
|
|
|
err := server.Start(context.Background(), Options{Dir: dir})
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "not a Laravel project")
|
|
})
|
|
}
|
|
|
|
func TestDevServer_Logs_Bad(t *testing.T) {
|
|
t.Run("fails for non-existent service", func(t *testing.T) {
|
|
server := NewDevServer(Options{})
|
|
|
|
_, err := server.Logs("nonexistent", false)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "service not found")
|
|
})
|
|
}
|
|
|
|
func TestDevServer_filterServices_Good(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
services []DetectedService
|
|
opts Options
|
|
expected []DetectedService
|
|
}{
|
|
{
|
|
name: "no filtering with default options",
|
|
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
|
|
opts: Options{},
|
|
expected: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
|
|
},
|
|
{
|
|
name: "filters Vite when NoVite is true",
|
|
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
|
|
opts: Options{NoVite: true},
|
|
expected: []DetectedService{ServiceFrankenPHP, ServiceHorizon},
|
|
},
|
|
{
|
|
name: "filters Horizon when NoHorizon is true",
|
|
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
|
|
opts: Options{NoHorizon: true},
|
|
expected: []DetectedService{ServiceFrankenPHP, ServiceVite},
|
|
},
|
|
{
|
|
name: "filters Reverb when NoReverb is true",
|
|
services: []DetectedService{ServiceFrankenPHP, ServiceReverb},
|
|
opts: Options{NoReverb: true},
|
|
expected: []DetectedService{ServiceFrankenPHP},
|
|
},
|
|
{
|
|
name: "filters Redis when NoRedis is true",
|
|
services: []DetectedService{ServiceFrankenPHP, ServiceRedis},
|
|
opts: Options{NoRedis: true},
|
|
expected: []DetectedService{ServiceFrankenPHP},
|
|
},
|
|
{
|
|
name: "filters multiple services",
|
|
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon, ServiceReverb, ServiceRedis},
|
|
opts: Options{NoVite: true, NoHorizon: true, NoReverb: true, NoRedis: true},
|
|
expected: []DetectedService{ServiceFrankenPHP},
|
|
},
|
|
{
|
|
name: "keeps unknown services",
|
|
services: []DetectedService{ServiceFrankenPHP},
|
|
opts: Options{NoVite: true},
|
|
expected: []DetectedService{ServiceFrankenPHP},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
server := NewDevServer(Options{})
|
|
result := server.filterServices(tt.services, tt.opts)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMultiServiceReader_Good(t *testing.T) {
|
|
t.Run("closes all readers on Close", func(t *testing.T) {
|
|
// Create mock readers using files
|
|
dir := t.TempDir()
|
|
file1, err := os.CreateTemp(dir, "log1-*.log")
|
|
require.NoError(t, err)
|
|
file1.WriteString("test1")
|
|
file1.Seek(0, 0)
|
|
|
|
file2, err := os.CreateTemp(dir, "log2-*.log")
|
|
require.NoError(t, err)
|
|
file2.WriteString("test2")
|
|
file2.Seek(0, 0)
|
|
|
|
// Create mock services
|
|
services := []Service{
|
|
&FrankenPHPService{baseService: baseService{name: "svc1"}},
|
|
&ViteService{baseService: baseService{name: "svc2"}},
|
|
}
|
|
readers := []io.ReadCloser{file1, file2}
|
|
|
|
reader := newMultiServiceReader(services, readers, false)
|
|
assert.NotNil(t, reader)
|
|
|
|
err = reader.Close()
|
|
assert.NoError(t, err)
|
|
assert.True(t, reader.closed)
|
|
})
|
|
|
|
t.Run("returns EOF when closed", func(t *testing.T) {
|
|
reader := &multiServiceReader{closed: true}
|
|
buf := make([]byte, 10)
|
|
n, err := reader.Read(buf)
|
|
assert.Equal(t, 0, n)
|
|
assert.Equal(t, io.EOF, err)
|
|
})
|
|
}
|
|
|
|
func TestMultiServiceReader_Read_Good(t *testing.T) {
|
|
t.Run("reads from readers with service prefix", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
file1, err := os.CreateTemp(dir, "log-*.log")
|
|
require.NoError(t, err)
|
|
file1.WriteString("log content")
|
|
file1.Seek(0, 0)
|
|
|
|
services := []Service{
|
|
&FrankenPHPService{baseService: baseService{name: "TestService"}},
|
|
}
|
|
readers := []io.ReadCloser{file1}
|
|
|
|
reader := newMultiServiceReader(services, readers, false)
|
|
buf := make([]byte, 100)
|
|
n, err := reader.Read(buf)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Greater(t, n, 0)
|
|
result := string(buf[:n])
|
|
assert.Contains(t, result, "[TestService]")
|
|
})
|
|
|
|
t.Run("returns EOF when all readers are exhausted in non-follow mode", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
file1, err := os.CreateTemp(dir, "log-*.log")
|
|
require.NoError(t, err)
|
|
file1.Close() // Empty file
|
|
|
|
file1, err = os.Open(file1.Name())
|
|
require.NoError(t, err)
|
|
|
|
services := []Service{
|
|
&FrankenPHPService{baseService: baseService{name: "TestService"}},
|
|
}
|
|
readers := []io.ReadCloser{file1}
|
|
|
|
reader := newMultiServiceReader(services, readers, false)
|
|
buf := make([]byte, 100)
|
|
n, err := reader.Read(buf)
|
|
|
|
assert.Equal(t, 0, n)
|
|
assert.Equal(t, io.EOF, err)
|
|
})
|
|
}
|
|
|
|
func TestOptions_Good(t *testing.T) {
|
|
t.Run("all fields are accessible", func(t *testing.T) {
|
|
opts := Options{
|
|
Dir: "/test",
|
|
Services: []DetectedService{ServiceFrankenPHP},
|
|
NoVite: true,
|
|
NoHorizon: true,
|
|
NoReverb: true,
|
|
NoRedis: true,
|
|
HTTPS: true,
|
|
Domain: "test.local",
|
|
FrankenPHPPort: 8000,
|
|
HTTPSPort: 443,
|
|
VitePort: 5173,
|
|
ReverbPort: 8080,
|
|
RedisPort: 6379,
|
|
}
|
|
|
|
assert.Equal(t, "/test", opts.Dir)
|
|
assert.Equal(t, []DetectedService{ServiceFrankenPHP}, opts.Services)
|
|
assert.True(t, opts.NoVite)
|
|
assert.True(t, opts.NoHorizon)
|
|
assert.True(t, opts.NoReverb)
|
|
assert.True(t, opts.NoRedis)
|
|
assert.True(t, opts.HTTPS)
|
|
assert.Equal(t, "test.local", opts.Domain)
|
|
assert.Equal(t, 8000, opts.FrankenPHPPort)
|
|
assert.Equal(t, 443, opts.HTTPSPort)
|
|
assert.Equal(t, 5173, opts.VitePort)
|
|
assert.Equal(t, 8080, opts.ReverbPort)
|
|
assert.Equal(t, 6379, opts.RedisPort)
|
|
})
|
|
}
|
|
|
|
func TestDevServer_StartStop_Integration(t *testing.T) {
|
|
t.Skip("requires PHP/FrankenPHP installed")
|
|
|
|
dir := t.TempDir()
|
|
setupLaravelProject(t, dir)
|
|
|
|
server := NewDevServer(Options{Dir: dir})
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
err := server.Start(ctx, Options{Dir: dir})
|
|
require.NoError(t, err)
|
|
assert.True(t, server.IsRunning())
|
|
|
|
err = server.Stop()
|
|
require.NoError(t, err)
|
|
assert.False(t, server.IsRunning())
|
|
}
|
|
|
|
// setupLaravelProject creates a minimal Laravel project structure for testing.
|
|
func setupLaravelProject(t *testing.T, dir string) {
|
|
t.Helper()
|
|
|
|
// Create artisan file
|
|
err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755)
|
|
require.NoError(t, err)
|
|
|
|
// Create composer.json with Laravel
|
|
composerJSON := `{
|
|
"name": "test/laravel-project",
|
|
"require": {
|
|
"php": "^8.2",
|
|
"laravel/framework": "^11.0",
|
|
"laravel/octane": "^2.0"
|
|
}
|
|
}`
|
|
err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestDevServer_UnifiedLogs_Bad(t *testing.T) {
|
|
t.Run("returns error when service logs fail", func(t *testing.T) {
|
|
server := NewDevServer(Options{})
|
|
|
|
// Create a mock service that will fail to provide logs
|
|
mockService := &FrankenPHPService{
|
|
baseService: baseService{
|
|
name: "FailingService",
|
|
logPath: "", // No log path set will cause error
|
|
},
|
|
}
|
|
server.services = []Service{mockService}
|
|
|
|
_, err := server.Logs("", false)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "failed to get logs")
|
|
})
|
|
}
|
|
|
|
func TestDevServer_Logs_Good(t *testing.T) {
|
|
t.Run("finds specific service logs", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
logFile := filepath.Join(dir, "test.log")
|
|
err := os.WriteFile(logFile, []byte("test log content"), 0644)
|
|
require.NoError(t, err)
|
|
|
|
server := NewDevServer(Options{})
|
|
mockService := &FrankenPHPService{
|
|
baseService: baseService{
|
|
name: "TestService",
|
|
logPath: logFile,
|
|
},
|
|
}
|
|
server.services = []Service{mockService}
|
|
|
|
reader, err := server.Logs("TestService", false)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, reader)
|
|
reader.Close()
|
|
})
|
|
}
|
|
|
|
func TestDevServer_MergeOptions_Good(t *testing.T) {
|
|
t.Run("start merges options correctly", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
server := NewDevServer(Options{Dir: "/original"})
|
|
|
|
// Setup a minimal non-Laravel project to trigger an error
|
|
// but still test the options merge happens first
|
|
err := server.Start(context.Background(), Options{Dir: dir})
|
|
assert.Error(t, err) // Will fail because not Laravel project
|
|
// But the directory should have been merged
|
|
assert.Equal(t, dir, server.opts.Dir)
|
|
})
|
|
}
|
|
|
|
func TestDetectedService_Constants(t *testing.T) {
|
|
t.Run("all service constants are defined", func(t *testing.T) {
|
|
assert.Equal(t, DetectedService("frankenphp"), ServiceFrankenPHP)
|
|
assert.Equal(t, DetectedService("vite"), ServiceVite)
|
|
assert.Equal(t, DetectedService("horizon"), ServiceHorizon)
|
|
assert.Equal(t, DetectedService("reverb"), ServiceReverb)
|
|
assert.Equal(t, DetectedService("redis"), ServiceRedis)
|
|
})
|
|
}
|
|
|
|
func TestDevServer_HTTPSSetup(t *testing.T) {
|
|
t.Run("extracts domain from APP_URL when HTTPS enabled", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Create Laravel project
|
|
err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755)
|
|
require.NoError(t, err)
|
|
|
|
composerJSON := `{
|
|
"require": {
|
|
"laravel/framework": "^11.0",
|
|
"laravel/octane": "^2.0"
|
|
}
|
|
}`
|
|
err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
|
require.NoError(t, err)
|
|
|
|
// Create .env with APP_URL
|
|
envContent := "APP_URL=https://myapp.test"
|
|
err = os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
|
require.NoError(t, err)
|
|
|
|
// Verify we can extract the domain
|
|
url := GetLaravelAppURL(dir)
|
|
domain := ExtractDomainFromURL(url)
|
|
assert.Equal(t, "myapp.test", domain)
|
|
})
|
|
}
|
|
|
|
func TestDevServer_PortDefaults(t *testing.T) {
|
|
t.Run("uses default ports when not specified", func(t *testing.T) {
|
|
// This tests the logic in Start() for default port assignment
|
|
// We verify the constants/defaults by checking what would be created
|
|
|
|
// FrankenPHP default port is 8000
|
|
svc := NewFrankenPHPService("/tmp", FrankenPHPOptions{})
|
|
assert.Equal(t, 8000, svc.port)
|
|
|
|
// Vite default port is 5173
|
|
vite := NewViteService("/tmp", ViteOptions{})
|
|
assert.Equal(t, 5173, vite.port)
|
|
|
|
// Reverb default port is 8080
|
|
reverb := NewReverbService("/tmp", ReverbOptions{})
|
|
assert.Equal(t, 8080, reverb.port)
|
|
|
|
// Redis default port is 6379
|
|
redis := NewRedisService("/tmp", RedisOptions{})
|
|
assert.Equal(t, 6379, redis.port)
|
|
})
|
|
}
|
|
|
|
func TestDevServer_ServiceCreation(t *testing.T) {
|
|
t.Run("creates correct services based on detected services", func(t *testing.T) {
|
|
// Test that the switch statement in Start() creates the right service types
|
|
services := []DetectedService{
|
|
ServiceFrankenPHP,
|
|
ServiceVite,
|
|
ServiceHorizon,
|
|
ServiceReverb,
|
|
ServiceRedis,
|
|
}
|
|
|
|
// Verify each service type string
|
|
expected := []string{"frankenphp", "vite", "horizon", "reverb", "redis"}
|
|
for i, svc := range services {
|
|
assert.Equal(t, expected[i], string(svc))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMultiServiceReader_CloseError(t *testing.T) {
|
|
t.Run("returns first close error", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Create a real file that we can close
|
|
file1, err := os.CreateTemp(dir, "log-*.log")
|
|
require.NoError(t, err)
|
|
file1Name := file1.Name()
|
|
file1.Close()
|
|
|
|
// Reopen for reading
|
|
file1, err = os.Open(file1Name)
|
|
require.NoError(t, err)
|
|
|
|
services := []Service{
|
|
&FrankenPHPService{baseService: baseService{name: "svc1"}},
|
|
}
|
|
readers := []io.ReadCloser{file1}
|
|
|
|
reader := newMultiServiceReader(services, readers, false)
|
|
err = reader.Close()
|
|
assert.NoError(t, err)
|
|
|
|
// Second close should still work (files already closed)
|
|
// The closed flag prevents double-processing
|
|
assert.True(t, reader.closed)
|
|
})
|
|
}
|
|
|
|
func TestMultiServiceReader_FollowMode(t *testing.T) {
|
|
t.Run("returns 0 bytes without error in follow mode when no data", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
file1, err := os.CreateTemp(dir, "log-*.log")
|
|
require.NoError(t, err)
|
|
file1Name := file1.Name()
|
|
file1.Close()
|
|
|
|
// Reopen for reading (empty file)
|
|
file1, err = os.Open(file1Name)
|
|
require.NoError(t, err)
|
|
|
|
services := []Service{
|
|
&FrankenPHPService{baseService: baseService{name: "svc1"}},
|
|
}
|
|
readers := []io.ReadCloser{file1}
|
|
|
|
reader := newMultiServiceReader(services, readers, true) // follow=true
|
|
|
|
// Use a channel to timeout the read since follow mode waits
|
|
done := make(chan bool)
|
|
go func() {
|
|
buf := make([]byte, 100)
|
|
n, err := reader.Read(buf)
|
|
// In follow mode, should return 0 bytes and nil error (waiting for more data)
|
|
assert.Equal(t, 0, n)
|
|
assert.NoError(t, err)
|
|
done <- true
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Good, read completed
|
|
case <-time.After(500 * time.Millisecond):
|
|
// Also acceptable - follow mode is waiting
|
|
}
|
|
|
|
reader.Close()
|
|
})
|
|
}
|
|
|
|
func TestGetLaravelAppURL_Bad(t *testing.T) {
|
|
t.Run("no .env file", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
assert.Equal(t, "", GetLaravelAppURL(dir))
|
|
})
|
|
|
|
t.Run("no APP_URL in .env", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
envContent := "APP_NAME=Test\nAPP_ENV=local"
|
|
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "", GetLaravelAppURL(dir))
|
|
})
|
|
}
|
|
|
|
func TestExtractDomainFromURL_Edge(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
expected string
|
|
}{
|
|
{"empty string", "", ""},
|
|
{"just domain", "example.com", "example.com"},
|
|
{"http only", "http://", ""},
|
|
{"https only", "https://", ""},
|
|
{"domain with trailing slash", "https://example.com/", "example.com"},
|
|
{"complex path", "https://example.com:8080/path/to/page?query=1", "example.com"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Strip protocol
|
|
result := ExtractDomainFromURL(tt.url)
|
|
if tt.url != "" && !strings.HasPrefix(tt.url, "http://") && !strings.HasPrefix(tt.url, "https://") && !strings.Contains(tt.url, ":") && !strings.Contains(tt.url, "/") {
|
|
assert.Equal(t, tt.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDevServer_StatusWithServices(t *testing.T) {
|
|
t.Run("returns statuses for all services", func(t *testing.T) {
|
|
server := NewDevServer(Options{})
|
|
|
|
// Add mock services
|
|
server.services = []Service{
|
|
&FrankenPHPService{baseService: baseService{name: "svc1", running: true, port: 8000}},
|
|
&ViteService{baseService: baseService{name: "svc2", running: false, port: 5173}},
|
|
}
|
|
|
|
statuses := server.Status()
|
|
assert.Len(t, statuses, 2)
|
|
assert.Equal(t, "svc1", statuses[0].Name)
|
|
assert.True(t, statuses[0].Running)
|
|
assert.Equal(t, "svc2", statuses[1].Name)
|
|
assert.False(t, statuses[1].Running)
|
|
})
|
|
}
|
|
|
|
func TestDevServer_ServicesReturnsAll(t *testing.T) {
|
|
t.Run("returns all services", func(t *testing.T) {
|
|
server := NewDevServer(Options{})
|
|
|
|
// Add mock services
|
|
server.services = []Service{
|
|
&FrankenPHPService{baseService: baseService{name: "svc1"}},
|
|
&ViteService{baseService: baseService{name: "svc2"}},
|
|
&HorizonService{baseService: baseService{name: "svc3"}},
|
|
}
|
|
|
|
services := server.Services()
|
|
assert.Len(t, services, 3)
|
|
})
|
|
}
|
|
|
|
func TestDevServer_StopWithCancel(t *testing.T) {
|
|
t.Run("calls cancel when running", func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
server := NewDevServer(Options{})
|
|
server.running = true
|
|
server.cancel = cancel
|
|
server.ctx = ctx
|
|
|
|
// Add a mock service that won't error
|
|
server.services = []Service{
|
|
&FrankenPHPService{baseService: baseService{name: "svc1", running: false}},
|
|
}
|
|
|
|
err := server.Stop()
|
|
assert.NoError(t, err)
|
|
assert.False(t, server.running)
|
|
})
|
|
}
|
|
|
|
func TestMultiServiceReader_CloseWithErrors(t *testing.T) {
|
|
t.Run("handles multiple close errors", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Create files
|
|
file1, err := os.CreateTemp(dir, "log1-*.log")
|
|
require.NoError(t, err)
|
|
file2, err := os.CreateTemp(dir, "log2-*.log")
|
|
require.NoError(t, err)
|
|
|
|
services := []Service{
|
|
&FrankenPHPService{baseService: baseService{name: "svc1"}},
|
|
&ViteService{baseService: baseService{name: "svc2"}},
|
|
}
|
|
readers := []io.ReadCloser{file1, file2}
|
|
|
|
reader := newMultiServiceReader(services, readers, false)
|
|
|
|
// Close successfully
|
|
err = reader.Close()
|
|
assert.NoError(t, err)
|
|
})
|
|
}
|