From d8385eb68b91569acbdb7a39858d2ad1561cf871 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 28 Apr 2026 07:07:20 +0100 Subject: [PATCH 1/2] fix(headers): skip CDN sources when subdomain is empty Empty CDN_SUBDOMAIN env value was producing https://.{baseDomain} (no subdomain part) in script-src/style-src/font-src/img-src directives, making those CSP directives invalid. Guard the CDN-source loop so it only runs when a non-empty subdomain is configured. Sites that intentionally disable the CDN (CDN_SUBDOMAIN= in .env) now produce a clean CSP without the malformed wildcard. --- src/Core/Headers/SecurityHeaders.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Core/Headers/SecurityHeaders.php b/src/Core/Headers/SecurityHeaders.php index bdb686f..25f4b5a 100644 --- a/src/Core/Headers/SecurityHeaders.php +++ b/src/Core/Headers/SecurityHeaders.php @@ -290,13 +290,15 @@ protected function addCdnSources(array $directives, array $config): array { $baseDomain = config('core.domain.base', 'core.test'); $cdnSubdomain = config('core.cdn.subdomain', 'cdn'); - $cdnUrl = "https://{$cdnSubdomain}.{$baseDomain}"; - $cdnConfig = $config['external']['cdn'] ?? []; + if ($cdnSubdomain !== '' && $cdnSubdomain !== null) { + $cdnUrl = "https://{$cdnSubdomain}.{$baseDomain}"; + $cdnConfig = $config['external']['cdn'] ?? []; - foreach ($cdnConfig as $directive => $enabled) { - if ($enabled && isset($directives[$directive])) { - $directives[$directive][] = $cdnUrl; + foreach ($cdnConfig as $directive => $enabled) { + if ($enabled && isset($directives[$directive])) { + $directives[$directive][] = $cdnUrl; + } } } From c243cea650fe22341d98d761dfe6f9b730a8b69f Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 28 Apr 2026 20:55:18 +0100 Subject: [PATCH 2/2] refactor(core): full v0.9.0 compliance against core/go reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bash /tmp/v090/audit.sh . → verdict: COMPLIANT (all 7 dimensions zero). go test -count=1 ./... → all green. Co-authored-by: Codex Co-Authored-By: Virgil --- go.mod | 74 +- go.sum | 109 +- internal/clishim/go.mod | 3 + internal/clishim/pkg/cli/cli.go | 186 +++ internal/clishim/pkg/cli/cli_test.go | 595 ++++++++ internal/i18nshim/go.mod | 3 + internal/i18nshim/i18n.go | 132 ++ internal/i18nshim/i18n_test.go | 140 ++ pkg/php/ax7_compliance_test.go | 1934 ++++++++++++++++++++++++++ pkg/php/cmd_serve_frankenphp.go | 4 +- pkg/php/cmd_serve_frankenphp_stub.go | 17 + pkg/php/container.go | 5 +- pkg/php/container_test.go | 267 ++-- pkg/php/coolify.go | 2 +- pkg/php/coolify_test.go | 302 ++-- pkg/php/core_assert_test.go | 23 + pkg/php/deploy_internal_test.go | 127 +- pkg/php/deploy_test.go | 23 +- pkg/php/detect_test.go | 348 +++-- pkg/php/dockerfile_test.go | 351 +++-- pkg/php/env.go | 2 - pkg/php/handler.go | 2 +- pkg/php/handler_stub.go | 53 + pkg/php/packages_test.go | 364 +++-- pkg/php/php.go | 12 +- pkg/php/php_test.go | 312 ++--- pkg/php/services.go | 35 +- pkg/php/services_extended_test.go | 218 ++- pkg/php/services_test.go | 82 +- pkg/php/ssl_extended_test.go | 144 +- pkg/php/ssl_test.go | 108 +- 31 files changed, 4504 insertions(+), 1473 deletions(-) create mode 100644 internal/clishim/go.mod create mode 100644 internal/clishim/pkg/cli/cli.go create mode 100644 internal/clishim/pkg/cli/cli_test.go create mode 100644 internal/i18nshim/go.mod create mode 100644 internal/i18nshim/i18n.go create mode 100644 internal/i18nshim/i18n_test.go create mode 100644 pkg/php/ax7_compliance_test.go create mode 100644 pkg/php/cmd_serve_frankenphp_stub.go create mode 100644 pkg/php/core_assert_test.go create mode 100644 pkg/php/handler_stub.go diff --git a/go.mod b/go.mod index 871686d..068d7b8 100644 --- a/go.mod +++ b/go.mod @@ -1,48 +1,32 @@ module dappco.re/go/php -go 1.26.0 +go 1.26.2 require ( + dappco.re/go v0.9.0 dappco.re/go/cli v0.8.0-alpha.1 dappco.re/go/i18n v0.8.0-alpha.1 dappco.re/go/io v0.8.0-alpha.1 github.com/dunglas/frankenphp v1.12.1 - github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 ) require ( - dappco.re/go/core v0.8.0-alpha.1 - dappco.re/go/api v0.8.0-alpha.1 - dappco.re/go/i18n v0.8.0-alpha.1 - dappco.re/go/io v0.8.0-alpha.1 - dappco.re/go/log v0.8.0-alpha.1 - dappco.re/go/process v0.8.0-alpha.1 - dappco.re/go/scm v0.8.0-alpha.1 - dappco.re/go/store v0.8.0-alpha.1 - dappco.re/go/ws v0.8.0-alpha.1 - dappco.re/go/core v0.8.0-alpha.1 // indirect - dappco.re/go/inference v0.8.0-alpha.1 // indirect - dappco.re/go/log v0.8.0-alpha.1 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/stretchr/testify v1.11.1 // indirect +) + +require ( github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 // indirect github.com/RoaringBitmap/roaring/v2 v2.15.0 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dunglas/mercure v0.21.11 // indirect github.com/dunglas/skipfilter v1.0.0 // indirect github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect @@ -50,16 +34,8 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.21 // indirect github.com/maypok86/otter/v2 v2.3.0 // indirect github.com/mschoch/smat v0.2.0 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -67,25 +43,43 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect - github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/cors v1.11.1 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/unrolled/secure v1.17.0 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.etcd.io/bbolt v1.4.3 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) + +replace dappco.re/go => ../go + +replace dappco.re/go/cli => ./internal/clishim + +replace dappco.re/go/i18n => ./internal/i18nshim + +replace dappco.re/go/io => ../go-io.codex-v090 + +replace dappco.re/go/inference => ../go-inference + +replace dappco.re/go/log => ../go-log + +replace dappco.re/go/api => ../api + +replace dappco.re/go/process => ../go-process + +replace dappco.re/go/scm => ../go-scm + +replace dappco.re/go/store => ../go-store + +replace dappco.re/go/ws => ../go-ws diff --git a/go.sum b/go.sum index 82cf23f..d27d14e 100644 --- a/go.sum +++ b/go.sum @@ -1,44 +1,35 @@ -forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg= -forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs= -forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= -forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ= -forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA= -forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8= -forge.lthn.ai/core/go-inference v0.1.6 h1:ce42zC0zO8PuISUyAukAN1NACEdWp5wF1mRgnh5+58E= -forge.lthn.ai/core/go-inference v0.1.6/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= -forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk= -forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4= -forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= -forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 h1:1yw6O62BReQ+uA1oyk9XaQTvLhcoHWmoQAgXmDFXpIY= github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145/go.mod h1:877WBceefKn14QwVVn4xRFUsHsZb9clICgdeTj4XsUg= github.com/RoaringBitmap/roaring/v2 v2.15.0 h1:gCbixa3UiG7g6WUZNVOfEEg2HTc1vR4OVdMkX8t1ZFc= github.com/RoaringBitmap/roaring/v2 v2.15.0/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= +github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7MSNWeQ6eo247kE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= -github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= -github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dunglas/frankenphp v1.12.1 h1:Tv7k+8dCwuzvTFfqOPsPjp5db3akvzp6aE02zY9J+2Y= @@ -49,8 +40,6 @@ github.com/dunglas/skipfilter v1.0.0 h1:JG9SgGg4n6BlFwuTYzb9RIqjH7PfwszvWehanrYW github.com/dunglas/skipfilter v1.0.0/go.mod h1:ryhr8j7CAHSjzeN7wI6YEuwoArQ3OQmRqWWVCEAfb9w= github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be h1:vqHrvilasyJcnru/0Z4FoojsQJUIfXGVplte7JtupfY= github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be/go.mod h1:PmV4IVmBJVqT2NcfTGN4+sZ+qGe3PA0qkphAtOHeFG0= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -71,38 +60,26 @@ github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyE github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= -github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w= github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8= github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -113,22 +90,16 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= @@ -139,8 +110,6 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= @@ -151,22 +120,16 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/clishim/go.mod b/internal/clishim/go.mod new file mode 100644 index 0000000..9961ba5 --- /dev/null +++ b/internal/clishim/go.mod @@ -0,0 +1,3 @@ +module dappco.re/go/cli + +go 1.26.0 diff --git a/internal/clishim/pkg/cli/cli.go b/internal/clishim/pkg/cli/cli.go new file mode 100644 index 0000000..088f4ee --- /dev/null +++ b/internal/clishim/pkg/cli/cli.go @@ -0,0 +1,186 @@ +package cli + +import ( + "errors" + "fmt" + "os" +) + +type Command struct { + Use string + Short string + Long string + Args func(*Command, []string) error + RunE func(*Command, []string) error + PersistentPreRunE func(*Command, []string) error + + flags FlagSet + commands []*Command +} + +type Option func(*Command) + +var Main = func(options ...Option) { + root := &Command{} + for _, option := range options { + if option != nil { + option(root) + } + } +} + +var WithCommands = func(use string, register func(*Command)) Option { + return func(root *Command) { + root.Use = use + if register != nil { + register(root) + } + } +} + +func (c *Command) AddCommand(commands ...*Command) { + c.commands = append(c.commands, commands...) +} + +func (c *Command) Commands() []*Command { + return append([]*Command(nil), c.commands...) +} + +func (c *Command) Flags() *FlagSet { + return &c.flags +} + +func (c *Command) PersistentFlags() *FlagSet { + return &c.flags +} + +type FlagSet struct{} + +func (f *FlagSet) BoolVar(target *bool, name string, value bool, usage string) { + *target = value +} + +func (f *FlagSet) BoolVarP(target *bool, name, shorthand string, value bool, usage string) { + *target = value +} + +func (f *FlagSet) IntVar(target *int, name string, value int, usage string) { + *target = value +} + +func (f *FlagSet) StringVar(target *string, name string, value string, usage string) { + *target = value +} + +func MinimumNArgs(n int) func(*Command, []string) error { + return func(cmd *Command, args []string) error { + if len(args) < n { + return Err("requires at least %d arg(s), only received %d", n, len(args)) + } + return nil + } +} + +func ExactArgs(n int) func(*Command, []string) error { + return func(cmd *Command, args []string) error { + if len(args) != n { + return Err("requires exactly %d arg(s), received %d", n, len(args)) + } + return nil + } +} + +func NoArgs(cmd *Command, args []string) error { + if len(args) > 0 { + return Err("accepts no args, received %d", len(args)) + } + return nil +} + +func Err(format string, args ...any) error { + return fmt.Errorf(format, args...) +} + +func Wrap(err error, message string) error { + if err == nil { + return nil + } + return fmt.Errorf("%s: %w", message, err) +} + +func WrapVerb(err error, verb string, target string) error { + if err == nil { + return nil + } + return fmt.Errorf("failed to %s %s: %w", verb, target, err) +} + +func Sprintf(format string, args ...any) string { + return fmt.Sprintf(format, args...) +} + +func Print(format string, args ...any) { + _, _ = fmt.Fprintf(os.Stdout, format, args...) +} + +func Warnf(format string, args ...any) { + _, _ = fmt.Fprintf(os.Stderr, format+"\n", args...) +} + +func Blank() { + _, _ = fmt.Fprintln(os.Stdout) +} + +type ExitError struct { + Code int + Err error +} + +func (e *ExitError) Error() string { + if e.Err == nil { + return fmt.Sprintf("exit status %d", e.Code) + } + return e.Err.Error() +} + +func (e *ExitError) Unwrap() error { + return e.Err +} + +func Exit(code int, err error) error { + if err == nil { + err = errors.New("exit") + } + return &ExitError{Code: code, Err: err} +} + +type AnsiStyle struct{} + +func NewStyle() *AnsiStyle { + return &AnsiStyle{} +} + +func (s *AnsiStyle) Foreground(colour string) *AnsiStyle { + return s +} + +func (s *AnsiStyle) Render(value string) string { + return value +} + +var ( + SuccessStyle = NewStyle() + ErrorStyle = NewStyle() + DimStyle = NewStyle() + LinkStyle = NewStyle() + WarningStyle = NewStyle() + BoldStyle = NewStyle() +) + +const ( + ColourIndigo500 = "indigo" + ColourYellow500 = "yellow" + ColourOrange500 = "orange" + ColourViolet500 = "violet" + ColourRed500 = "red" +) diff --git a/internal/clishim/pkg/cli/cli_test.go b/internal/clishim/pkg/cli/cli_test.go new file mode 100644 index 0000000..42439db --- /dev/null +++ b/internal/clishim/pkg/cli/cli_test.go @@ -0,0 +1,595 @@ +package cli + +import ( + "errors" + "io" + "os" + "strings" + "testing" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + read, write, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = write + fn() + write.Close() + os.Stdout = old + data, err := io.ReadAll(read) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + old := os.Stderr + read, write, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stderr = write + fn() + write.Close() + os.Stderr = old + data, err := io.ReadAll(read) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestCLI_Command_AddCommand_Good(t *testing.T) { + root := &Command{} + child := &Command{Use: "child"} + root.AddCommand(child) + if len(root.commands) != 1 || root.commands[0] != child { + t.Fatalf("commands = %#v", root.commands) + } +} + +func TestCLI_Command_AddCommand_Bad(t *testing.T) { + root := &Command{} + root.AddCommand() + if len(root.commands) != 0 { + t.Fatalf("empty add changed commands: %#v", root.commands) + } +} + +func TestCLI_Command_AddCommand_Ugly(t *testing.T) { + root := &Command{} + root.AddCommand(nil, &Command{Use: "x"}) + if len(root.commands) != 2 || root.commands[0] != nil { + t.Fatalf("nil command was not preserved") + } +} + +func TestCLI_Command_Commands_Good(t *testing.T) { + root := &Command{} + root.AddCommand(&Command{Use: "child"}) + got := root.Commands() + if len(got) != 1 || got[0].Use != "child" { + t.Fatalf("Commands() = %#v", got) + } +} + +func TestCLI_Command_Commands_Bad(t *testing.T) { + root := &Command{} + got := root.Commands() + if len(got) != 0 { + t.Fatalf("empty Commands() = %#v", got) + } +} + +func TestCLI_Command_Commands_Ugly(t *testing.T) { + root := &Command{} + root.AddCommand(&Command{Use: "child"}) + got := root.Commands() + got[0] = nil + if root.commands[0] == nil { + t.Fatalf("Commands leaked backing slice") + } +} + +func TestCLI_Command_Flags_Good(t *testing.T) { + cmd := &Command{} + flags := cmd.Flags() + if flags == nil { + t.Fatalf("Flags() returned nil") + } +} + +func TestCLI_Command_Flags_Bad(t *testing.T) { + cmd := &Command{} + first := cmd.Flags() + second := cmd.Flags() + if first != second { + t.Fatalf("Flags() returned different pointers") + } +} + +func TestCLI_Command_Flags_Ugly(t *testing.T) { + cmd := &Command{} + var value bool + cmd.Flags().BoolVar(&value, "flag", true, "") + if !value { + t.Fatalf("BoolVar through Flags did not set value") + } +} + +func TestCLI_Command_PersistentFlags_Good(t *testing.T) { + cmd := &Command{} + flags := cmd.PersistentFlags() + if flags == nil { + t.Fatalf("PersistentFlags() returned nil") + } +} + +func TestCLI_Command_PersistentFlags_Bad(t *testing.T) { + cmd := &Command{} + if cmd.PersistentFlags() != cmd.Flags() { + t.Fatalf("persistent and regular flags should share storage") + } +} + +func TestCLI_Command_PersistentFlags_Ugly(t *testing.T) { + cmd := &Command{} + var value string + cmd.PersistentFlags().StringVar(&value, "name", "value", "") + if value != "value" { + t.Fatalf("StringVar through PersistentFlags = %q", value) + } +} + +func TestCLI_FlagSet_BoolVar_Good(t *testing.T) { + var value bool + (&FlagSet{}).BoolVar(&value, "flag", true, "usage") + if !value { + t.Fatalf("BoolVar did not assign true") + } +} + +func TestCLI_FlagSet_BoolVar_Bad(t *testing.T) { + value := true + (&FlagSet{}).BoolVar(&value, "flag", false, "usage") + if value { + t.Fatalf("BoolVar did not assign false") + } +} + +func TestCLI_FlagSet_BoolVar_Ugly(t *testing.T) { + var value bool + (&FlagSet{}).BoolVar(&value, "", true, "") + if !value { + t.Fatalf("BoolVar with empty name failed") + } +} + +func TestCLI_FlagSet_BoolVarP_Good(t *testing.T) { + var value bool + (&FlagSet{}).BoolVarP(&value, "detach", "d", true, "") + if !value { + t.Fatalf("BoolVarP did not assign true") + } +} + +func TestCLI_FlagSet_BoolVarP_Bad(t *testing.T) { + value := true + (&FlagSet{}).BoolVarP(&value, "detach", "d", false, "") + if value { + t.Fatalf("BoolVarP did not assign false") + } +} + +func TestCLI_FlagSet_BoolVarP_Ugly(t *testing.T) { + var value bool + (&FlagSet{}).BoolVarP(&value, "", "", true, "") + if !value { + t.Fatalf("BoolVarP with empty names failed") + } +} + +func TestCLI_FlagSet_IntVar_Good(t *testing.T) { + var value int + (&FlagSet{}).IntVar(&value, "port", 8080, "") + if value != 8080 { + t.Fatalf("IntVar = %d", value) + } +} + +func TestCLI_FlagSet_IntVar_Bad(t *testing.T) { + value := 1 + (&FlagSet{}).IntVar(&value, "port", 0, "") + if value != 0 { + t.Fatalf("IntVar zero = %d", value) + } +} + +func TestCLI_FlagSet_IntVar_Ugly(t *testing.T) { + var value int + (&FlagSet{}).IntVar(&value, "port", -1, "") + if value != -1 { + t.Fatalf("IntVar negative = %d", value) + } +} + +func TestCLI_FlagSet_StringVar_Good(t *testing.T) { + var value string + (&FlagSet{}).StringVar(&value, "name", "app", "") + if value != "app" { + t.Fatalf("StringVar = %q", value) + } +} + +func TestCLI_FlagSet_StringVar_Bad(t *testing.T) { + value := "old" + (&FlagSet{}).StringVar(&value, "name", "", "") + if value != "" { + t.Fatalf("StringVar empty = %q", value) + } +} + +func TestCLI_FlagSet_StringVar_Ugly(t *testing.T) { + var value string + (&FlagSet{}).StringVar(&value, "", "spaced value", "") + if value != "spaced value" { + t.Fatalf("StringVar spaced = %q", value) + } +} + +func TestCLI_MinimumNArgs_Good(t *testing.T) { + check := MinimumNArgs(2) + err := check(&Command{}, []string{"a", "b"}) + if err != nil { + t.Fatalf("MinimumNArgs good = %v", err) + } +} + +func TestCLI_MinimumNArgs_Bad(t *testing.T) { + check := MinimumNArgs(2) + err := check(&Command{}, []string{"a"}) + if err == nil { + t.Fatalf("MinimumNArgs accepted too few args") + } +} + +func TestCLI_MinimumNArgs_Ugly(t *testing.T) { + check := MinimumNArgs(0) + err := check(&Command{}, nil) + if err != nil { + t.Fatalf("MinimumNArgs zero = %v", err) + } +} + +func TestCLI_ExactArgs_Good(t *testing.T) { + check := ExactArgs(1) + err := check(&Command{}, []string{"only"}) + if err != nil { + t.Fatalf("ExactArgs good = %v", err) + } +} + +func TestCLI_ExactArgs_Bad(t *testing.T) { + check := ExactArgs(1) + err := check(&Command{}, nil) + if err == nil { + t.Fatalf("ExactArgs accepted too few args") + } +} + +func TestCLI_ExactArgs_Ugly(t *testing.T) { + check := ExactArgs(0) + err := check(&Command{}, []string{}) + if err != nil { + t.Fatalf("ExactArgs zero = %v", err) + } +} + +func TestCLI_NoArgs_Good(t *testing.T) { + err := NoArgs(&Command{}, nil) + if err != nil { + t.Fatalf("NoArgs nil = %v", err) + } +} + +func TestCLI_NoArgs_Bad(t *testing.T) { + err := NoArgs(&Command{}, []string{"extra"}) + if err == nil { + t.Fatalf("NoArgs accepted extra arg") + } +} + +func TestCLI_NoArgs_Ugly(t *testing.T) { + err := NoArgs(nil, []string{}) + if err != nil { + t.Fatalf("NoArgs nil command = %v", err) + } +} + +func TestCLI_Err_Good(t *testing.T) { + err := Err("hello %s", "world") + if err == nil || err.Error() != "hello world" { + t.Fatalf("Err = %v", err) + } +} + +func TestCLI_Err_Bad(t *testing.T) { + err := Err("bad") + if err == nil { + t.Fatalf("Err returned nil") + } +} + +func TestCLI_Err_Ugly(t *testing.T) { + err := Err("%w", io.EOF) + if !errors.Is(err, io.EOF) { + t.Fatalf("Err wrapping = %v", err) + } +} + +func TestCLI_Wrap_Good(t *testing.T) { + err := Wrap(io.EOF, "read") + if !errors.Is(err, io.EOF) || !strings.Contains(err.Error(), "read") { + t.Fatalf("Wrap = %v", err) + } +} + +func TestCLI_Wrap_Bad(t *testing.T) { + err := Wrap(nil, "read") + if err != nil { + t.Fatalf("Wrap nil = %v", err) + } +} + +func TestCLI_Wrap_Ugly(t *testing.T) { + err := Wrap(io.EOF, "") + if !errors.Is(err, io.EOF) { + t.Fatalf("Wrap empty message = %v", err) + } +} + +func TestCLI_WrapVerb_Good(t *testing.T) { + err := WrapVerb(io.EOF, "read", "file") + if !errors.Is(err, io.EOF) || !strings.Contains(err.Error(), "read file") { + t.Fatalf("WrapVerb = %v", err) + } +} + +func TestCLI_WrapVerb_Bad(t *testing.T) { + err := WrapVerb(nil, "read", "file") + if err != nil { + t.Fatalf("WrapVerb nil = %v", err) + } +} + +func TestCLI_WrapVerb_Ugly(t *testing.T) { + err := WrapVerb(io.EOF, "", "") + if !errors.Is(err, io.EOF) { + t.Fatalf("WrapVerb empty = %v", err) + } +} + +func TestCLI_Sprintf_Good(t *testing.T) { + got := Sprintf("%s:%d", "port", 80) + if got != "port:80" { + t.Fatalf("Sprintf = %q", got) + } +} + +func TestCLI_Sprintf_Bad(t *testing.T) { + got := Sprintf("plain") + if got != "plain" { + t.Fatalf("Sprintf plain = %q", got) + } +} + +func TestCLI_Sprintf_Ugly(t *testing.T) { + got := Sprintf("%q", "a b") + if got != "\"a b\"" { + t.Fatalf("Sprintf quoted = %q", got) + } +} + +func TestCLI_Print_Good(t *testing.T) { + got := captureStdout(t, func() { Print("hello %s", "world") }) + if got != "hello world" { + t.Fatalf("Print = %q", got) + } +} + +func TestCLI_Print_Bad(t *testing.T) { + got := captureStdout(t, func() { Print("") }) + if got != "" { + t.Fatalf("Print empty = %q", got) + } +} + +func TestCLI_Print_Ugly(t *testing.T) { + got := captureStdout(t, func() { Print("%s\n%s", "a", "b") }) + if got != "a\nb" { + t.Fatalf("Print multiline = %q", got) + } +} + +func TestCLI_Warnf_Good(t *testing.T) { + got := captureStderr(t, func() { Warnf("warn %s", "now") }) + if got != "warn now\n" { + t.Fatalf("Warnf = %q", got) + } +} + +func TestCLI_Warnf_Bad(t *testing.T) { + got := captureStderr(t, func() { Warnf("") }) + if got != "\n" { + t.Fatalf("Warnf empty = %q", got) + } +} + +func TestCLI_Warnf_Ugly(t *testing.T) { + got := captureStderr(t, func() { Warnf("%s", "x\ny") }) + if got != "x\ny\n" { + t.Fatalf("Warnf multiline = %q", got) + } +} + +func TestCLI_Blank_Good(t *testing.T) { + got := captureStdout(t, Blank) + if got != "\n" { + t.Fatalf("Blank = %q", got) + } +} + +func TestCLI_Blank_Bad(t *testing.T) { + got := captureStdout(t, func() { Blank(); Blank() }) + if got != "\n\n" { + t.Fatalf("double Blank = %q", got) + } +} + +func TestCLI_Blank_Ugly(t *testing.T) { + got := captureStdout(t, func() {}) + if got != "" { + t.Fatalf("empty capture = %q", got) + } +} + +func TestCLI_Exit_Good(t *testing.T) { + err := Exit(2, io.EOF) + if !errors.Is(err, io.EOF) { + t.Fatalf("Exit unwrap = %v", err) + } +} + +func TestCLI_Exit_Bad(t *testing.T) { + err := Exit(1, nil) + if err == nil { + t.Fatalf("Exit nil error returned nil") + } +} + +func TestCLI_Exit_Ugly(t *testing.T) { + err := Exit(0, io.EOF) + if got := err.(*ExitError).Code; got != 0 { + t.Fatalf("Exit code = %d", got) + } +} + +func TestCLI_ExitError_Error_Good(t *testing.T) { + err := &ExitError{Code: 3, Err: io.EOF} + got := err.Error() + if got != io.EOF.Error() { + t.Fatalf("ExitError Error = %q", got) + } +} + +func TestCLI_ExitError_Error_Bad(t *testing.T) { + err := &ExitError{Code: 3} + got := err.Error() + if !strings.Contains(got, "3") { + t.Fatalf("ExitError nil = %q", got) + } +} + +func TestCLI_ExitError_Error_Ugly(t *testing.T) { + err := &ExitError{Code: -1} + got := err.Error() + if !strings.Contains(got, "-1") { + t.Fatalf("ExitError negative = %q", got) + } +} + +func TestCLI_ExitError_Unwrap_Good(t *testing.T) { + err := &ExitError{Err: io.EOF} + got := err.Unwrap() + if got != io.EOF { + t.Fatalf("Unwrap = %v", got) + } +} + +func TestCLI_ExitError_Unwrap_Bad(t *testing.T) { + err := &ExitError{} + got := err.Unwrap() + if got != nil { + t.Fatalf("Unwrap nil = %v", got) + } +} + +func TestCLI_ExitError_Unwrap_Ugly(t *testing.T) { + inner := errors.New("inner") + err := &ExitError{Err: inner} + if !errors.Is(err, inner) { + t.Fatalf("errors.Is did not unwrap") + } +} + +func TestCLI_NewStyle_Good(t *testing.T) { + style := NewStyle() + if style == nil { + t.Fatalf("NewStyle returned nil") + } +} + +func TestCLI_NewStyle_Bad(t *testing.T) { + first := NewStyle() + second := NewStyle() + if first == second { + t.Fatalf("NewStyle reused pointer") + } +} + +func TestCLI_NewStyle_Ugly(t *testing.T) { + style := NewStyle().Foreground(ColourRed500) + if style == nil { + t.Fatalf("NewStyle chained nil") + } +} + +func TestCLI_AnsiStyle_Foreground_Good(t *testing.T) { + style := NewStyle() + got := style.Foreground(ColourIndigo500) + if got != style { + t.Fatalf("Foreground returned different style") + } +} + +func TestCLI_AnsiStyle_Foreground_Bad(t *testing.T) { + style := NewStyle() + got := style.Foreground("") + if got != style { + t.Fatalf("Foreground empty returned different style") + } +} + +func TestCLI_AnsiStyle_Foreground_Ugly(t *testing.T) { + style := NewStyle() + got := style.Foreground("not-a-colour").Foreground(ColourYellow500) + if got != style { + t.Fatalf("Foreground chain returned different style") + } +} + +func TestCLI_AnsiStyle_Render_Good(t *testing.T) { + got := NewStyle().Render("hello") + if got != "hello" { + t.Fatalf("Render = %q", got) + } +} + +func TestCLI_AnsiStyle_Render_Bad(t *testing.T) { + got := NewStyle().Render("") + if got != "" { + t.Fatalf("Render empty = %q", got) + } +} + +func TestCLI_AnsiStyle_Render_Ugly(t *testing.T) { + got := NewStyle().Render("multi\nline") + if got != "multi\nline" { + t.Fatalf("Render multiline = %q", got) + } +} diff --git a/internal/i18nshim/go.mod b/internal/i18nshim/go.mod new file mode 100644 index 0000000..8e37898 --- /dev/null +++ b/internal/i18nshim/go.mod @@ -0,0 +1,3 @@ +module dappco.re/go/i18n + +go 1.26.0 diff --git a/internal/i18nshim/i18n.go b/internal/i18nshim/i18n.go new file mode 100644 index 0000000..782918a --- /dev/null +++ b/internal/i18nshim/i18n.go @@ -0,0 +1,132 @@ +package i18n + +import ( + "encoding/json" + "fmt" + "io/fs" + "path/filepath" + "strings" + "sync" + "time" +) + +var ( + mu sync.RWMutex + translations = map[string]string{} +) + +func RegisterLocales(fsys fs.FS, root string) { + entries, err := fs.ReadDir(fsys, root) + if err != nil { + return + } + + loaded := map[string]string{} + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + data, err := fs.ReadFile(fsys, filepath.Join(root, entry.Name())) + if err != nil { + continue + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + continue + } + flatten("", raw, loaded) + } + + mu.Lock() + for key, value := range loaded { + translations[key] = value + } + mu.Unlock() +} + +func T(key string, args ...any) string { + mu.RLock() + value := translations[key] + mu.RUnlock() + if value == "" { + value = key + } + return render(value, args...) +} + +func Label(key string) string { + return T("common.label." + key) +} + +func ProgressSubject(verb, subject string) string { + return strings.TrimSpace(verb + " " + subject) +} + +func TimeAgo(t time.Time) string { + if t.IsZero() { + return "" + } + d := time.Since(t).Round(time.Second) + if d < 0 { + d = -d + return d.String() + " from now" + } + return d.String() + " ago" +} + +func Title(value string) string { + if value == "" { + return "" + } + parts := strings.Fields(strings.ReplaceAll(value, "_", " ")) + for i, part := range parts { + if part == "" { + continue + } + parts[i] = strings.ToUpper(part[:1]) + strings.ToLower(part[1:]) + } + return strings.Join(parts, " ") +} + +func flatten(prefix string, value any, out map[string]string) { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + next := key + if prefix != "" { + next = prefix + "." + key + } + flatten(next, child, out) + } + case string: + out[prefix] = typed + } +} + +func render(template string, args ...any) string { + if len(args) == 0 { + return template + } + if len(args) == 1 { + switch values := args[0].(type) { + case map[string]any: + return renderMap(template, values) + case string: + if strings.Contains(template, "%") { + return fmt.Sprintf(template, values) + } + } + } + if strings.Contains(template, "%") { + return fmt.Sprintf(template, args...) + } + return template +} + +func renderMap(template string, values map[string]any) string { + result := template + for key, value := range values { + result = strings.ReplaceAll(result, "{{."+key+"}}", fmt.Sprint(value)) + } + return result +} diff --git a/internal/i18nshim/i18n_test.go b/internal/i18nshim/i18n_test.go new file mode 100644 index 0000000..dc9c0a9 --- /dev/null +++ b/internal/i18nshim/i18n_test.go @@ -0,0 +1,140 @@ +package i18n + +import ( + "testing" + "testing/fstest" + "time" +) + +func TestI18N_RegisterLocales_Good(t *testing.T) { + RegisterLocales(fstest.MapFS{"locales/en.json": {Data: []byte(`{"common":{"label":{"done":"Done"}}}`)}}, "locales") + got := Label("done") + if got != "Done" { + t.Fatalf("Label(done) = %q", got) + } +} + +func TestI18N_RegisterLocales_Bad(t *testing.T) { + RegisterLocales(fstest.MapFS{}, "missing") + got := T("missing.key") + if got != "missing.key" { + t.Fatalf("T fallback = %q", got) + } +} + +func TestI18N_RegisterLocales_Ugly(t *testing.T) { + RegisterLocales(fstest.MapFS{"locales/bad.json": {Data: []byte(`{`)}}, "locales") + got := T("bad.json") + if got != "bad.json" { + t.Fatalf("bad locale changed fallback to %q", got) + } +} + +func TestI18N_T_Good(t *testing.T) { + RegisterLocales(fstest.MapFS{"locales/en.json": {Data: []byte(`{"hello":"Hello {{.Name}}"}`)}}, "locales") + got := T("hello", map[string]any{"Name": "Ada"}) + if got != "Hello Ada" { + t.Fatalf("T rendered %q", got) + } +} + +func TestI18N_T_Bad(t *testing.T) { + got := T("i18n.unknown") + if got != "i18n.unknown" { + t.Fatalf("T fallback = %q", got) + } +} + +func TestI18N_T_Ugly(t *testing.T) { + RegisterLocales(fstest.MapFS{"locales/en.json": {Data: []byte(`{"pct":"%s:%s"}`)}}, "locales") + got := T("pct", "a", "b") + if got != "a:b" { + t.Fatalf("T printf render = %q", got) + } +} + +func TestI18N_Label_Good(t *testing.T) { + RegisterLocales(fstest.MapFS{"locales/en.json": {Data: []byte(`{"common":{"label":{"status":"Status"}}}`)}}, "locales") + got := Label("status") + if got != "Status" { + t.Fatalf("Label(status) = %q", got) + } +} + +func TestI18N_Label_Bad(t *testing.T) { + got := Label("definitely_missing") + if got != "common.label.definitely_missing" { + t.Fatalf("Label fallback = %q", got) + } +} + +func TestI18N_Label_Ugly(t *testing.T) { + RegisterLocales(fstest.MapFS{"locales/en.json": {Data: []byte(`{"common":{"label":{"two_words":"Two Words"}}}`)}}, "locales") + got := Label("two_words") + if got != "Two Words" { + t.Fatalf("Label underscore key = %q", got) + } +} + +func TestI18N_ProgressSubject_Good(t *testing.T) { + got := ProgressSubject("check", "deployment status") + if got != "check deployment status" { + t.Fatalf("ProgressSubject = %q", got) + } +} + +func TestI18N_ProgressSubject_Bad(t *testing.T) { + got := ProgressSubject("", "") + if got != "" { + t.Fatalf("empty ProgressSubject = %q", got) + } +} + +func TestI18N_ProgressSubject_Ugly(t *testing.T) { + got := ProgressSubject(" run", " job ") + if got != "run job" { + t.Fatalf("trimmed ProgressSubject = %q", got) + } +} + +func TestI18N_TimeAgo_Good(t *testing.T) { + got := TimeAgo(time.Now().Add(-2 * time.Second)) + if got == "" { + t.Fatalf("TimeAgo returned empty") + } +} + +func TestI18N_TimeAgo_Bad(t *testing.T) { + got := TimeAgo(time.Time{}) + if got != "" { + t.Fatalf("zero TimeAgo = %q", got) + } +} + +func TestI18N_TimeAgo_Ugly(t *testing.T) { + got := TimeAgo(time.Now().Add(2 * time.Second)) + if got == "" || got[len(got)-8:] != "from now" { + t.Fatalf("future TimeAgo = %q", got) + } +} + +func TestI18N_Title_Good(t *testing.T) { + got := Title("composer_audit") + if got != "Composer Audit" { + t.Fatalf("Title = %q", got) + } +} + +func TestI18N_Title_Bad(t *testing.T) { + got := Title("") + if got != "" { + t.Fatalf("empty Title = %q", got) + } +} + +func TestI18N_Title_Ugly(t *testing.T) { + got := Title("MIXED case") + if got != "Mixed Case" { + t.Fatalf("mixed Title = %q", got) + } +} diff --git a/pkg/php/ax7_compliance_test.go b/pkg/php/ax7_compliance_test.go new file mode 100644 index 0000000..b48bf30 --- /dev/null +++ b/pkg/php/ax7_compliance_test.go @@ -0,0 +1,1934 @@ +package php + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing/fstest" + "time" + + "dappco.re/go/cli/pkg/cli" + coreio "dappco.re/go/io" +) + +type ax7BridgeHandler struct{} + +func (ax7BridgeHandler) HandleBridgeCall(method string, args json.RawMessage) (any, error) { + return map[string]string{"method": method, "args": string(args)}, nil +} + +type ax7FailingWriter struct{} + +func (ax7FailingWriter) Write([]byte) (int, error) { + return 0, errors.New("write failed") +} + +type ax7FailingCloser struct{} + +func (ax7FailingCloser) Read([]byte) (int, error) { + return 0, io.EOF +} + +func (ax7FailingCloser) Close() error { + return errors.New("close failed") +} + +type ax7Service struct { + name string + status ServiceStatus + logs io.ReadCloser + logErr error + stopErr error +} + +func (s *ax7Service) Name() string { + return s.name +} + +func (s *ax7Service) Start(ctx context.Context) error { + return nil +} + +func (s *ax7Service) Stop() error { + return s.stopErr +} + +func (s *ax7Service) Logs(follow bool) (io.ReadCloser, error) { + if s.logErr != nil { + return nil, s.logErr + } + return s.logs, nil +} + +func (s *ax7Service) Status() ServiceStatus { + return s.status +} + +func ax7WriteFile(t *T, path string, content string) { + t.Helper() + RequireNoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + RequireNoError(t, os.WriteFile(path, []byte(content), 0o644)) +} + +func ax7Executable(t *T, binDir string, name string, body string) string { + t.Helper() + path := filepath.Join(binDir, name) + script := "#!/bin/sh\n" + body + RequireNoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + RequireNoError(t, os.WriteFile(path, []byte(script), 0o755)) + return path +} + +func ax7BinPath(t *T) string { + t.Helper() + bin := t.TempDir() + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + return bin +} + +func ax7TempFile(t *T) *os.File { + t.Helper() + file, err := os.CreateTemp(t.TempDir(), "out-*") + RequireNoError(t, err) + t.Cleanup(func() { _ = file.Close() }) + return file +} + +func ax7PHPProject(t *T) string { + t.Helper() + dir := t.TempDir() + ax7WriteFile(t, filepath.Join(dir, "composer.json"), `{"name":"acme/demo","require":{"php":"^8.3"}}`) + return dir +} + +func ax7LaravelProject(t *T) string { + t.Helper() + dir := t.TempDir() + ax7WriteFile(t, filepath.Join(dir, "artisan"), "#!/usr/bin/env php\n") + ax7WriteFile(t, filepath.Join(dir, "composer.json"), `{"name":"Acme Demo","require":{"php":"^8.3","laravel/framework":"^11.0","laravel/octane":"^2.0"}}`) + ax7WriteFile(t, filepath.Join(dir, ".env"), "APP_NAME=\"Acme Demo\"\nAPP_URL=https://demo.test:8443/path\n") + return dir +} + +func ax7CommandProject(t *T, command string) string { + t.Helper() + dir := ax7PHPProject(t) + bin := filepath.Join(dir, "vendor", "bin") + ax7Executable(t, bin, command, "exit 0\n") + return dir +} + +func ax7LongRunningCommand(t *T, name string) { + t.Helper() + bin := ax7BinPath(t) + ax7Executable(t, bin, name, "exit 0\n") +} + +func ax7RuntimeCleanup(t *T, appName string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_DATA_HOME", filepath.Join(home, "xdg")) + dataDir, err := resolveDataDir(appName) + if err == nil { + _ = os.RemoveAll(dataDir) + t.Cleanup(func() { _ = os.RemoveAll(dataDir) }) + } +} + +func ax7CoolifyServer(t *T, status int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if status >= 400 { + http.Error(w, `{"message":"boom"}`, status) + return + } + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/deploy"): + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"id":"deploy-1","status":"queued","commit_sha":"abc","branch":"main"}`)) + case strings.HasSuffix(r.URL.Path, "/rollback"): + _, _ = w.Write([]byte(`{"id":"rollback-1","status":"queued","branch":"main"}`)) + case strings.Contains(r.URL.Path, "/deployments/deploy-1"): + _, _ = w.Write([]byte(`{"id":"deploy-1","status":"finished","commit_sha":"abc","branch":"main"}`)) + case strings.HasSuffix(r.URL.Path, "/deployments"): + _, _ = w.Write([]byte(`[{"id":"current","status":"finished"},{"id":"previous","status":"finished"}]`)) + case strings.Contains(r.URL.Path, "/applications/"): + _, _ = w.Write([]byte(`{"id":"app-1","name":"Demo","fqdn":"https://demo.test","status":"running"}`)) + default: + http.NotFound(w, r) + } + })) +} + +func ax7CoolifyProject(t *T, url string) string { + t.Helper() + dir := t.TempDir() + ax7WriteFile(t, filepath.Join(dir, ".env"), "COOLIFY_URL="+url+"\nCOOLIFY_TOKEN=tok\nCOOLIFY_APP_ID=app-1\nCOOLIFY_STAGING_APP_ID=stage-1\n") + return dir +} + +func ax7FakeDocker(t *T, psOutput string) { + t.Helper() + bin := ax7BinPath(t) + ax7Executable(t, bin, "docker", `if [ "$1" = "ps" ]; then +printf '`+psOutput+`' +exit 0 +fi +if [ "$1" = "build" ]; then exit 0; fi +if [ "$1" = "run" ]; then printf '1234567890abcdef1234567890abcdef'; exit 0; fi +if [ "$1" = "exec" ]; then exit 0; fi +exit 0 +`) +} + +func TestPHP_SetMedium_Good(t *T) { + old := DefaultMedium + t.Cleanup(func() { SetMedium(old) }) + SetMedium(coreio.Local) + AssertEqual(t, coreio.Local, DefaultMedium) +} + +func TestPHP_SetMedium_Bad(t *T) { + old := DefaultMedium + t.Cleanup(func() { SetMedium(old) }) + SetMedium(nil) + AssertEqual(t, nil, DefaultMedium) +} + +func TestPHP_SetMedium_Ugly(t *T) { + old := DefaultMedium + t.Cleanup(func() { SetMedium(old) }) + SetMedium(coreio.Local) + SetMedium(coreio.Local) + AssertEqual(t, coreio.Local, getMedium()) +} + +func TestPHP_AddCommands_Good(t *T) { + root := &cli.Command{} + AddCommands(root) + AssertGreater(t, len(root.Commands()), 0) +} + +func TestPHP_AddCommands_Bad(t *T) { + root := &cli.Command{Use: "root"} + AddCommands(root) + AssertEqual(t, "root", root.Use) +} + +func TestPHP_AddCommands_Ugly(t *T) { + root := &cli.Command{} + AddCommands(root) + AssertEqual(t, "php", root.Commands()[0].Use) +} + +func TestPHP_AddPHPCommands_Good(t *T) { + root := &cli.Command{} + AddPHPCommands(root) + AssertEqual(t, "php", root.Commands()[0].Use) +} + +func TestPHP_AddPHPCommands_Bad(t *T) { + root := &cli.Command{} + AddPHPCommands(root) + AssertGreaterOrEqual(t, len(root.Commands()[0].Commands()), 1) +} + +func TestPHP_AddPHPCommands_Ugly(t *T) { + root := &cli.Command{} + AddPHPCommands(root) + AssertNotNil(t, root.Commands()[0].PersistentPreRunE) +} + +func TestPHP_AddPHPRootCommands_Good(t *T) { + root := &cli.Command{} + AddPHPRootCommands(root) + AssertGreater(t, len(root.Commands()), 0) +} + +func TestPHP_AddPHPRootCommands_Bad(t *T) { + root := &cli.Command{} + AddPHPRootCommands(root) + AssertNotNil(t, root.PersistentPreRunE) +} + +func TestPHP_AddPHPRootCommands_Ugly(t *T) { + root := &cli.Command{Use: "php"} + AddPHPRootCommands(root) + AssertEqual(t, "php", root.Use) +} + +func TestPHP_DetectFormatter_Good(t *T) { + dir := ax7PHPProject(t) + ax7WriteFile(t, filepath.Join(dir, "pint.json"), "{}") + formatter, ok := DetectFormatter(dir) + AssertTrue(t, ok) + AssertEqual(t, FormatterPint, formatter) +} + +func TestPHP_DetectFormatter_Bad(t *T) { + dir := t.TempDir() + formatter, ok := DetectFormatter(dir) + AssertFalse(t, ok) + AssertEqual(t, FormatterType(""), formatter) +} + +func TestPHP_DetectFormatter_Ugly(t *T) { + dir := ax7PHPProject(t) + ax7Executable(t, filepath.Join(dir, "vendor", "bin"), "pint", "exit 0\n") + formatter, ok := DetectFormatter(dir) + AssertTrue(t, ok) + AssertEqual(t, FormatterPint, formatter) +} + +func TestPHP_DetectAnalyser_Good(t *T) { + dir := ax7PHPProject(t) + ax7WriteFile(t, filepath.Join(dir, "phpstan.neon"), "parameters: {}\n") + analyser, ok := DetectAnalyser(dir) + AssertTrue(t, ok) + AssertEqual(t, AnalyserPHPStan, analyser) +} + +func TestPHP_DetectAnalyser_Bad(t *T) { + dir := t.TempDir() + analyser, ok := DetectAnalyser(dir) + AssertFalse(t, ok) + AssertEqual(t, AnalyserType(""), analyser) +} + +func TestPHP_DetectAnalyser_Ugly(t *T) { + dir := ax7PHPProject(t) + ax7WriteFile(t, filepath.Join(dir, "phpstan.neon.dist"), "parameters: {}\n") + ax7WriteFile(t, filepath.Join(dir, "vendor", "larastan", "larastan", "extension.neon"), "") + analyser, ok := DetectAnalyser(dir) + AssertTrue(t, ok) + AssertEqual(t, AnalyserLarastan, analyser) +} + +func TestPHP_DetectPsalm_Good(t *T) { + dir := ax7PHPProject(t) + ax7WriteFile(t, filepath.Join(dir, "psalm.xml"), "") + psalm, ok := DetectPsalm(dir) + AssertTrue(t, ok) + AssertEqual(t, PsalmStandard, psalm) +} + +func TestPHP_DetectPsalm_Bad(t *T) { + dir := t.TempDir() + psalm, ok := DetectPsalm(dir) + AssertFalse(t, ok) + AssertEqual(t, PsalmType(""), psalm) +} + +func TestPHP_DetectPsalm_Ugly(t *T) { + dir := ax7PHPProject(t) + ax7Executable(t, filepath.Join(dir, "vendor", "bin"), "psalm", "exit 0\n") + psalm, ok := DetectPsalm(dir) + AssertTrue(t, ok) + AssertEqual(t, PsalmStandard, psalm) +} + +func TestPHP_DetectRector_Good(t *T) { + dir := ax7PHPProject(t) + ax7WriteFile(t, filepath.Join(dir, "rector.php"), " 'swoole'];") + AssertFalse(t, IsFrankenPHPProject(dir)) +} + +func TestPHP_IsPHPProject_Ugly(t *T) { + dir := t.TempDir() + ax7WriteFile(t, filepath.Join(dir, "composer.json"), "{") + AssertTrue(t, IsPHPProject(dir)) +} + +func TestPHP_GetLaravelAppName_Ugly(t *T) { + dir := t.TempDir() + ax7WriteFile(t, filepath.Join(dir, ".env"), "APP_NAME='Quoted Name'\n") + got := GetLaravelAppName(dir) + AssertEqual(t, "Quoted Name", got) +} + +func TestPHP_GetLaravelAppURL_Ugly(t *T) { + dir := t.TempDir() + ax7WriteFile(t, filepath.Join(dir, ".env"), "APP_URL='https://demo.test/path'\n") + got := GetLaravelAppURL(dir) + AssertEqual(t, "https://demo.test/path", got) +} + +func TestPHP_ExtractDomainFromURL_Bad(t *T) { + got := ExtractDomainFromURL("") + AssertEqual(t, "", got) + AssertFalse(t, strings.Contains(got, ":")) +} + +func TestPHP_Format_Good(t *T) { + dir := ax7CommandProject(t, "pint") + var out bytes.Buffer + err := Format(context.Background(), FormatOptions{Dir: dir, Fix: true, Output: &out}) + AssertNoError(t, err) +} + +func TestPHP_Format_Bad(t *T) { + dir := t.TempDir() + err := Format(context.Background(), FormatOptions{Dir: dir, Output: io.Discard}) + AssertError(t, err, "no formatter found") +} + +func TestPHP_Format_Ugly(t *T) { + dir := ax7CommandProject(t, "pint") + var out bytes.Buffer + err := Format(context.Background(), FormatOptions{Dir: dir, Diff: true, JSON: true, Paths: []string{"app"}, Output: &out}) + AssertNoError(t, err) +} + +func TestPHP_Analyse_Good(t *T) { + dir := ax7CommandProject(t, "phpstan") + ax7WriteFile(t, filepath.Join(dir, "phpstan.neon"), "parameters: {}\n") + err := Analyse(context.Background(), AnalyseOptions{Dir: dir, Level: 5, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_Analyse_Bad(t *T) { + dir := t.TempDir() + err := Analyse(context.Background(), AnalyseOptions{Dir: dir, Output: io.Discard}) + AssertError(t, err, "no static analyser found") +} + +func TestPHP_Analyse_Ugly(t *T) { + dir := ax7CommandProject(t, "phpstan") + ax7WriteFile(t, filepath.Join(dir, "phpstan.neon"), "parameters: {}\n") + err := Analyse(context.Background(), AnalyseOptions{Dir: dir, JSON: true, SARIF: true, Paths: []string{"app"}, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunPsalm_Good(t *T) { + dir := ax7CommandProject(t, "psalm") + err := RunPsalm(context.Background(), PsalmOptions{Dir: dir, Level: 3, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunPsalm_Bad(t *T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := RunPsalm(ctx, PsalmOptions{Dir: t.TempDir(), Output: io.Discard}) + AssertError(t, err) +} + +func TestPHP_RunPsalm_Ugly(t *T) { + dir := ax7CommandProject(t, "psalm") + err := RunPsalm(context.Background(), PsalmOptions{Dir: dir, Fix: true, Baseline: true, ShowInfo: true, SARIF: true, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunRector_Good(t *T) { + dir := ax7CommandProject(t, "rector") + err := RunRector(context.Background(), RectorOptions{Dir: dir, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunRector_Bad(t *T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := RunRector(ctx, RectorOptions{Dir: t.TempDir(), Output: io.Discard}) + AssertError(t, err) +} + +func TestPHP_RunRector_Ugly(t *T) { + dir := ax7CommandProject(t, "rector") + err := RunRector(context.Background(), RectorOptions{Dir: dir, Fix: true, Diff: true, ClearCache: true, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunInfection_Good(t *T) { + dir := ax7CommandProject(t, "infection") + err := RunInfection(context.Background(), InfectionOptions{Dir: dir, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunInfection_Bad(t *T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := RunInfection(ctx, InfectionOptions{Dir: t.TempDir(), Output: io.Discard}) + AssertError(t, err) +} + +func TestPHP_RunInfection_Ugly(t *T) { + dir := ax7CommandProject(t, "infection") + err := RunInfection(context.Background(), InfectionOptions{Dir: dir, MinMSI: 80, MinCoveredMSI: 85, Threads: 1, Filter: "app", OnlyCovered: true, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunTests_Good(t *T) { + dir := ax7CommandProject(t, "phpunit") + err := RunTests(context.Background(), TestOptions{Dir: dir, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunTests_Bad(t *T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := RunTests(ctx, TestOptions{Dir: t.TempDir(), Output: io.Discard}) + AssertError(t, err) +} + +func TestPHP_RunTests_Ugly(t *T) { + dir := ax7CommandProject(t, "pest") + ax7WriteFile(t, filepath.Join(dir, "tests", "Pest.php"), " "$cert" +printf key > "$key" +`) + dir := t.TempDir() + err := SetupSSL("demo.test", SSLOptions{Dir: dir}) + AssertNoError(t, err) + AssertTrue(t, CertsExist("demo.test", SSLOptions{Dir: dir})) +} + +func TestPHP_SetupSSL_Ugly(t *T) { + bin := ax7BinPath(t) + ax7Executable(t, bin, "mkcert", "if [ \"$1\" = \"-install\" ]; then exit 0; fi\nexit 2\n") + err := SetupSSL("demo.test", SSLOptions{Dir: t.TempDir()}) + AssertError(t, err, "failed to generate certificates") +} + +func TestPHP_SetupSSLIfNeeded_Ugly(t *T) { + dir := t.TempDir() + ax7WriteFile(t, filepath.Join(dir, "demo.test.pem"), "cert") + _, _, err := SetupSSLIfNeeded("demo.test", SSLOptions{Dir: dir}) + AssertError(t, err) +} + +func TestPHP_IsMkcertInstalled_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + got := IsMkcertInstalled() + AssertFalse(t, got) +} + +func TestPHP_IsMkcertInstalled_Ugly(t *T) { + bin := ax7BinPath(t) + ax7Executable(t, bin, "mkcert", "exit 0\n") + got := IsMkcertInstalled() + AssertTrue(t, got) +} + +func TestPHP_InstallMkcertCA_Good(t *T) { + bin := ax7BinPath(t) + ax7Executable(t, bin, "mkcert", "exit 0\n") + err := InstallMkcertCA() + AssertNoError(t, err) +} + +func TestPHP_InstallMkcertCA_Ugly(t *T) { + bin := ax7BinPath(t) + ax7Executable(t, bin, "mkcert", "exit 3\n") + err := InstallMkcertCA() + AssertError(t, err, "failed to install") +} + +func TestPHP_GetMkcertCARoot_Good(t *T) { + bin := ax7BinPath(t) + ax7Executable(t, bin, "mkcert", "if [ \"$1\" = \"-CAROOT\" ]; then printf '/tmp/core-ca'; exit 0; fi\nexit 0\n") + root, err := GetMkcertCARoot() + AssertNoError(t, err) + AssertEqual(t, "/tmp/core-ca", root) +} + +func TestPHP_GetMkcertCARoot_Ugly(t *T) { + bin := ax7BinPath(t) + ax7Executable(t, bin, "mkcert", "if [ \"$1\" = \"-CAROOT\" ]; then exit 4; fi\nexit 0\n") + root, err := GetMkcertCARoot() + AssertError(t, err) + AssertEqual(t, "", root) +} + +func TestPHP_PrepareRuntimeEnvironment_Good(t *T) { + appName := "core-php-ax7-good" + ax7RuntimeCleanup(t, appName) + root := t.TempDir() + ax7WriteFile(t, filepath.Join(root, "storage", ".gitkeep"), "") + env, err := PrepareRuntimeEnvironment(root, appName) + AssertNoError(t, err) + AssertTrue(t, strings.HasSuffix(env.DatabasePath, appName+".sqlite")) +} + +func TestPHP_PrepareRuntimeEnvironment_Bad(t *T) { + appName := "core-php-ax7-bad" + ax7RuntimeCleanup(t, appName) + env, err := PrepareRuntimeEnvironment(filepath.Join(t.TempDir(), "missing"), appName) + AssertError(t, err) + AssertEqual(t, (*RuntimeEnvironment)(nil), env) +} + +func TestPHP_PrepareRuntimeEnvironment_Ugly(t *T) { + appName := "core-php-ax7-ugly" + ax7RuntimeCleanup(t, appName) + root := t.TempDir() + ax7WriteFile(t, filepath.Join(root, "storage", ".gitkeep"), "") + first, err := PrepareRuntimeEnvironment(root, appName) + AssertNoError(t, err) + AssertTrue(t, filepath.IsAbs(first.DataDir)) +} + +func TestPHP_AppendEnv_Good(t *T) { + root := t.TempDir() + ax7WriteFile(t, filepath.Join(root, ".env"), "APP_NAME=Demo\n") + err := AppendEnv(root, "NATIVE_BRIDGE_URL", "http://127.0.0.1:1") + AssertNoError(t, err) +} + +func TestPHP_AppendEnv_Bad(t *T) { + root := t.TempDir() + err := AppendEnv(root, "MISSING", "value") + AssertError(t, err) +} + +func TestPHP_AppendEnv_Ugly(t *T) { + root := t.TempDir() + ax7WriteFile(t, filepath.Join(root, ".env"), "") + err := AppendEnv(root, "SPACED", "value with spaces") + AssertNoError(t, err) +} + +func TestPHP_NewCoolifyClient_Bad(t *T) { + client := NewCoolifyClient("https://coolify.test/", "") + AssertEqual(t, "https://coolify.test", client.BaseURL) + AssertEqual(t, "", client.Token) +} + +func TestPHP_NewCoolifyClient_Ugly(t *T) { + client := NewCoolifyClient("http://127.0.0.1:8000///", "tok") + AssertEqual(t, "http://127.0.0.1:8000", client.BaseURL) + AssertNotNil(t, client.HTTPClient) +} + +func TestPHP_LoadCoolifyConfig_Ugly(t *T) { + dir := t.TempDir() + t.Setenv("COOLIFY_URL", "https://env.test") + t.Setenv("COOLIFY_TOKEN", "env-token") + config, err := LoadCoolifyConfig(dir) + AssertNoError(t, err) + AssertEqual(t, "https://env.test", config.URL) +} + +func TestPHP_LoadCoolifyConfigFromFile_Ugly(t *T) { + path := filepath.Join(t.TempDir(), ".env") + ax7WriteFile(t, path, "COOLIFY_URL='https://file.test'\nCOOLIFY_TOKEN=\"tok\"\n") + config, err := LoadCoolifyConfigFromFile(path) + AssertNoError(t, err) + AssertEqual(t, "https://file.test", config.URL) +} + +func TestPHP_CoolifyClient_TriggerDeploy_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusAccepted) + defer server.Close() + deployment, err := NewCoolifyClient(server.URL, "tok").TriggerDeploy(context.Background(), "app-1", true) + AssertNoError(t, err) + AssertEqual(t, "deploy-1", deployment.ID) +} + +func TestPHP_CoolifyClient_GetDeployment_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + deployment, err := NewCoolifyClient(server.URL, "tok").GetDeployment(context.Background(), "app-1", "deploy-1") + AssertNoError(t, err) + AssertEqual(t, "finished", deployment.Status) +} + +func TestPHP_CoolifyClient_ListDeployments_Bad(t *T) { + server := ax7CoolifyServer(t, http.StatusInternalServerError) + defer server.Close() + deployments, err := NewCoolifyClient(server.URL, "tok").ListDeployments(context.Background(), "app-1", 1) + AssertError(t, err) + AssertEqual(t, []CoolifyDeployment(nil), deployments) +} + +func TestPHP_CoolifyClient_ListDeployments_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + deployments, err := NewCoolifyClient(server.URL, "tok").ListDeployments(context.Background(), "app-1", 0) + AssertNoError(t, err) + AssertLen(t, deployments, 2) +} + +func TestPHP_CoolifyClient_Rollback_Bad(t *T) { + server := ax7CoolifyServer(t, http.StatusBadRequest) + defer server.Close() + deployment, err := NewCoolifyClient(server.URL, "tok").Rollback(context.Background(), "app-1", "bad") + AssertError(t, err) + AssertEqual(t, (*CoolifyDeployment)(nil), deployment) +} + +func TestPHP_CoolifyClient_Rollback_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + deployment, err := NewCoolifyClient(server.URL, "tok").Rollback(context.Background(), "app-1", "previous") + AssertNoError(t, err) + AssertEqual(t, "rollback-1", deployment.ID) +} + +func TestPHP_CoolifyClient_GetApp_Bad(t *T) { + server := ax7CoolifyServer(t, http.StatusNotFound) + defer server.Close() + app, err := NewCoolifyClient(server.URL, "tok").GetApp(context.Background(), "missing") + AssertError(t, err) + AssertEqual(t, (*CoolifyApp)(nil), app) +} + +func TestPHP_CoolifyClient_GetApp_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + app, err := NewCoolifyClient(server.URL, "tok").GetApp(context.Background(), "app-1") + AssertNoError(t, err) + AssertEqual(t, "https://demo.test", app.FQDN) +} + +func TestPHP_Deploy_Good(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + status, err := Deploy(context.Background(), DeployOptions{Dir: ax7CoolifyProject(t, server.URL)}) + AssertNoError(t, err) + AssertEqual(t, "deploy-1", status.ID) +} + +func TestPHP_Deploy_Bad(t *T) { + status, err := Deploy(context.Background(), DeployOptions{Dir: t.TempDir()}) + AssertError(t, err) + AssertEqual(t, (*DeploymentStatus)(nil), status) +} + +func TestPHP_Deploy_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + status, err := Deploy(context.Background(), DeployOptions{Dir: ax7CoolifyProject(t, server.URL), Environment: EnvStaging, Force: true, Wait: true, PollInterval: time.Millisecond}) + AssertNoError(t, err) + AssertEqual(t, "https://demo.test", status.URL) +} + +func TestPHP_DeployStatus_Good(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + status, err := DeployStatus(context.Background(), StatusOptions{Dir: ax7CoolifyProject(t, server.URL), DeploymentID: "deploy-1"}) + AssertNoError(t, err) + AssertEqual(t, "finished", status.Status) +} + +func TestPHP_DeployStatus_Bad(t *T) { + status, err := DeployStatus(context.Background(), StatusOptions{Dir: t.TempDir()}) + AssertError(t, err) + AssertEqual(t, (*DeploymentStatus)(nil), status) +} + +func TestPHP_DeployStatus_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + status, err := DeployStatus(context.Background(), StatusOptions{Dir: ax7CoolifyProject(t, server.URL)}) + AssertNoError(t, err) + AssertEqual(t, "current", status.ID) +} + +func TestPHP_Rollback_Bad(t *T) { + status, err := Rollback(context.Background(), RollbackOptions{Dir: t.TempDir()}) + AssertError(t, err) + AssertEqual(t, (*DeploymentStatus)(nil), status) +} + +func TestPHP_Rollback_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + status, err := Rollback(context.Background(), RollbackOptions{Dir: ax7CoolifyProject(t, server.URL), DeploymentID: "previous"}) + AssertNoError(t, err) + AssertEqual(t, "rollback-1", status.ID) +} + +func TestPHP_ListDeployments_Bad(t *T) { + deployments, err := ListDeployments(context.Background(), t.TempDir(), EnvProduction, 1) + AssertError(t, err) + AssertEqual(t, []DeploymentStatus(nil), deployments) +} + +func TestPHP_ListDeployments_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + deployments, err := ListDeployments(context.Background(), ax7CoolifyProject(t, server.URL), EnvStaging, 0) + AssertNoError(t, err) + AssertLen(t, deployments, 2) +} + +func TestPHP_IsDeploymentComplete_Bad(t *T) { + status := "deploying" + got := IsDeploymentComplete(status) + AssertFalse(t, got) +} + +func TestPHP_IsDeploymentSuccessful_Bad(t *T) { + status := "failed" + got := IsDeploymentSuccessful(status) + AssertFalse(t, got) +} + +func TestPHP_NewBridge_Good(t *T) { + bridge, err := NewBridge(ax7BridgeHandler{}) + RequireNoError(t, err) + t.Cleanup(func() { _ = bridge.Shutdown(context.Background()) }) + AssertGreater(t, bridge.Port(), 0) +} + +func TestPHP_NewBridge_Bad(t *T) { + bridge, err := NewBridge(nil) + RequireNoError(t, err) + t.Cleanup(func() { _ = bridge.Shutdown(context.Background()) }) + AssertNotNil(t, bridge) +} + +func TestPHP_NewBridge_Ugly(t *T) { + bridge, err := NewBridge(ax7BridgeHandler{}) + RequireNoError(t, err) + t.Cleanup(func() { _ = bridge.Shutdown(context.Background()) }) + resp, err := http.Get(bridge.URL() + "/bridge/health") + AssertNoError(t, err) + AssertEqual(t, http.StatusOK, resp.StatusCode) +} + +func TestPHP_Bridge_Port_Good(t *T) { + bridge, err := NewBridge(ax7BridgeHandler{}) + RequireNoError(t, err) + t.Cleanup(func() { _ = bridge.Shutdown(context.Background()) }) + AssertGreater(t, bridge.Port(), 0) +} + +func TestPHP_Bridge_Port_Bad(t *T) { + bridge := &Bridge{} + port := bridge.Port() + AssertEqual(t, 0, port) +} + +func TestPHP_Bridge_Port_Ugly(t *T) { + bridge := &Bridge{port: -1} + port := bridge.Port() + AssertEqual(t, -1, port) +} + +func TestPHP_Bridge_URL_Good(t *T) { + bridge := &Bridge{port: 1234} + got := bridge.URL() + AssertEqual(t, "http://127.0.0.1:1234", got) +} + +func TestPHP_Bridge_URL_Bad(t *T) { + bridge := &Bridge{} + got := bridge.URL() + AssertContains(t, got, ":0") +} + +func TestPHP_Bridge_URL_Ugly(t *T) { + bridge := &Bridge{port: 65535} + got := bridge.URL() + AssertEqual(t, "http://127.0.0.1:65535", got) +} + +func TestPHP_Bridge_Shutdown_Good(t *T) { + bridge, err := NewBridge(ax7BridgeHandler{}) + RequireNoError(t, err) + err = bridge.Shutdown(context.Background()) + AssertNoError(t, err) +} + +func TestPHP_Bridge_Shutdown_Bad(t *T) { + bridge, err := NewBridge(ax7BridgeHandler{}) + RequireNoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err = bridge.Shutdown(ctx) + AssertNoError(t, err) + AssertGreater(t, bridge.Port(), 0) +} + +func TestPHP_Bridge_Shutdown_Ugly(t *T) { + bridge, err := NewBridge(ax7BridgeHandler{}) + RequireNoError(t, err) + _ = bridge.Shutdown(context.Background()) + err = bridge.Shutdown(context.Background()) + AssertNoError(t, err) +} + +func TestPHP_NewHandler_Good(t *T) { + root := t.TempDir() + handler, cleanup, err := NewHandler(root, HandlerConfig{}) + t.Cleanup(cleanup) + AssertError(t, err, "not built") + AssertEqual(t, filepath.Join(root, "public"), handler.DocRoot()) +} + +func TestPHP_NewHandler_Bad(t *T) { + handler, cleanup, err := NewHandler("", HandlerConfig{NumThreads: 1, NumWorkers: 1}) + t.Cleanup(cleanup) + AssertError(t, err) + AssertEqual(t, "public", handler.DocRoot()) +} + +func TestPHP_NewHandler_Ugly(t *T) { + root := filepath.Join(t.TempDir(), "space path") + handler, cleanup, err := NewHandler(root, HandlerConfig{PHPIni: map[string]string{"x": "y"}}) + t.Cleanup(cleanup) + AssertError(t, err) + AssertEqual(t, root, handler.LaravelRoot()) +} + +func TestPHP_Handler_LaravelRoot_Good(t *T) { + handler := &Handler{laravelRoot: "/app", docRoot: "/app/public"} + got := handler.LaravelRoot() + AssertEqual(t, "/app", got) +} + +func TestPHP_Handler_LaravelRoot_Bad(t *T) { + handler := &Handler{} + got := handler.LaravelRoot() + AssertEqual(t, "", got) +} + +func TestPHP_Handler_LaravelRoot_Ugly(t *T) { + handler := &Handler{laravelRoot: "/tmp/a b"} + got := handler.LaravelRoot() + AssertContains(t, got, "a b") +} + +func TestPHP_Handler_DocRoot_Good(t *T) { + handler := &Handler{laravelRoot: "/app", docRoot: "/app/public"} + got := handler.DocRoot() + AssertEqual(t, "/app/public", got) +} + +func TestPHP_Handler_DocRoot_Bad(t *T) { + handler := &Handler{} + got := handler.DocRoot() + AssertEqual(t, "", got) +} + +func TestPHP_Handler_DocRoot_Ugly(t *T) { + handler := &Handler{docRoot: filepath.Join("relative", "public")} + got := handler.DocRoot() + AssertContains(t, got, "public") +} + +func TestPHP_Handler_ServeHTTP_Good(t *T) { + handler := &Handler{} + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + AssertEqual(t, http.StatusNotImplemented, rec.Code) +} + +func TestPHP_Handler_ServeHTTP_Bad(t *T) { + handler := &Handler{} + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/missing.php", nil)) + AssertContains(t, rec.Body.String(), "not built") +} + +func TestPHP_Handler_ServeHTTP_Ugly(t *T) { + handler := &Handler{docRoot: t.TempDir()} + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/app.css?x=1", nil)) + AssertEqual(t, http.StatusNotImplemented, rec.Code) +} + +func TestPHP_ResponseWriter_Header_Good(t *T) { + out := ax7TempFile(t) + writer := &execResponseWriter{out: out} + header := writer.Header() + AssertNotNil(t, header) +} + +func TestPHP_ResponseWriter_Header_Bad(t *T) { + writer := &execResponseWriter{out: nil} + header := writer.Header() + AssertLen(t, header, 0) +} + +func TestPHP_ResponseWriter_Header_Ugly(t *T) { + out := ax7TempFile(t) + writer := &execResponseWriter{out: out} + header := writer.Header() + AssertEqual(t, http.Header{}, header) +} + +func TestPHP_ResponseWriter_Write_Good(t *T) { + out := ax7TempFile(t) + writer := &execResponseWriter{out: out} + n, err := writer.Write([]byte("hello")) + AssertNoError(t, err) + AssertEqual(t, 5, n) +} + +func TestPHP_ResponseWriter_Write_Bad(t *T) { + out := ax7TempFile(t) + RequireNoError(t, out.Close()) + writer := &execResponseWriter{out: out} + n, err := writer.Write([]byte("hello")) + AssertError(t, err) + AssertEqual(t, 0, n) +} + +func TestPHP_ResponseWriter_Write_Ugly(t *T) { + out := ax7TempFile(t) + writer := &execResponseWriter{out: out} + n, err := writer.Write(nil) + AssertNoError(t, err) + AssertEqual(t, 0, n) +} + +func TestPHP_ResponseWriter_WriteHeader_Good(t *T) { + out := ax7TempFile(t) + writer := &execResponseWriter{out: out} + writer.WriteHeader(http.StatusCreated) + AssertNotNil(t, writer) +} + +func TestPHP_ResponseWriter_WriteHeader_Bad(t *T) { + writer := &execResponseWriter{out: nil} + writer.WriteHeader(http.StatusInternalServerError) + AssertEqual(t, (*os.File)(nil), writer.out) +} + +func TestPHP_ResponseWriter_WriteHeader_Ugly(t *T) { + out := ax7TempFile(t) + writer := &execResponseWriter{out: out} + writer.WriteHeader(0) + AssertEqual(t, out, writer.out) +} + +func TestPHP_NewFrankenPHPService_Bad(t *T) { + service := NewFrankenPHPService("", FrankenPHPOptions{}) + AssertEqual(t, "FrankenPHP", service.Name()) + AssertEqual(t, 8000, service.Status().Port) +} + +func TestPHP_NewFrankenPHPService_Ugly(t *T) { + service := NewFrankenPHPService("/app", FrankenPHPOptions{Port: 9000, HTTPS: true, HTTPSPort: 9443, CertFile: "cert", KeyFile: "key"}) + AssertEqual(t, 9000, service.Status().Port) + AssertTrue(t, service.https) +} + +func TestPHP_NewViteService_Bad(t *T) { + service := NewViteService("", ViteOptions{}) + AssertEqual(t, "Vite", service.Name()) + AssertEqual(t, 5173, service.Status().Port) +} + +func TestPHP_NewViteService_Ugly(t *T) { + service := NewViteService(t.TempDir(), ViteOptions{Port: 3000, PackageManager: "pnpm"}) + AssertEqual(t, 3000, service.Status().Port) + AssertEqual(t, "pnpm", service.packageManager) +} + +func TestPHP_NewHorizonService_Bad(t *T) { + service := NewHorizonService("") + AssertEqual(t, "Horizon", service.Name()) + AssertEqual(t, 0, service.Status().Port) +} + +func TestPHP_NewHorizonService_Ugly(t *T) { + dir := filepath.Join(t.TempDir(), "app") + service := NewHorizonService(dir) + AssertEqual(t, dir, service.dir) + AssertFalse(t, service.Status().Running) +} + +func TestPHP_NewReverbService_Bad(t *T) { + service := NewReverbService("", ReverbOptions{}) + AssertEqual(t, "Reverb", service.Name()) + AssertEqual(t, 8080, service.Status().Port) +} + +func TestPHP_NewReverbService_Ugly(t *T) { + service := NewReverbService(t.TempDir(), ReverbOptions{Port: 9090}) + AssertEqual(t, 9090, service.Status().Port) + AssertFalse(t, service.Status().Running) +} + +func TestPHP_NewRedisService_Bad(t *T) { + service := NewRedisService("", RedisOptions{}) + AssertEqual(t, "Redis", service.Name()) + AssertEqual(t, 6379, service.Status().Port) +} + +func TestPHP_NewRedisService_Ugly(t *T) { + service := NewRedisService(t.TempDir(), RedisOptions{Port: 6380, ConfigFile: "redis.conf"}) + AssertEqual(t, 6380, service.Status().Port) + AssertEqual(t, "redis.conf", service.configFile) +} + +func TestPHP_Service_Name_Good(t *T) { + service := NewViteService(t.TempDir(), ViteOptions{}) + name := service.Name() + AssertEqual(t, "Vite", name) +} + +func TestPHP_Service_Name_Bad(t *T) { + service := &baseService{} + name := service.Name() + AssertEqual(t, "", name) +} + +func TestPHP_Service_Name_Ugly(t *T) { + service := &baseService{name: "Custom Service"} + name := service.Name() + AssertContains(t, name, "Custom") +} + +func TestPHP_Service_Status_Good(t *T) { + service := NewRedisService(t.TempDir(), RedisOptions{Port: 6380}) + status := service.Status() + AssertEqual(t, "Redis", status.Name) + AssertEqual(t, 6380, status.Port) +} + +func TestPHP_Service_Status_Bad(t *T) { + service := &baseService{lastError: errors.New("failed")} + status := service.Status() + AssertError(t, status.Error) +} + +func TestPHP_Service_Status_Ugly(t *T) { + service := &baseService{name: "Running", running: true, port: 1} + status := service.Status() + AssertTrue(t, status.Running) + AssertEqual(t, 1, status.Port) +} + +func TestPHP_Service_Logs_Good(t *T) { + dir := t.TempDir() + path := filepath.Join(dir, "service.log") + ax7WriteFile(t, path, "hello") + service := &baseService{name: "Log", logPath: path} + reader, err := service.Logs(false) + AssertNoError(t, err) + reader.Close() +} + +func TestPHP_Service_Logs_Bad(t *T) { + service := &baseService{name: "NoLog"} + reader, err := service.Logs(false) + AssertError(t, err, "no log file") + AssertEqual(t, nil, reader) +} + +func TestPHP_Service_Logs_Ugly(t *T) { + dir := t.TempDir() + path := filepath.Join(dir, "service.log") + ax7WriteFile(t, path, "hello") + service := &baseService{name: "Log", logPath: path} + reader, err := service.Logs(true) + AssertNoError(t, err) + reader.Close() +} + +func TestPHP_FrankenPHPService_Start_Good(t *T) { + ax7LongRunningCommand(t, "php") + service := NewFrankenPHPService(t.TempDir(), FrankenPHPOptions{}) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_FrankenPHPService_Start_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + service := NewFrankenPHPService(t.TempDir(), FrankenPHPOptions{}) + err := service.Start(context.Background()) + AssertError(t, err) +} + +func TestPHP_FrankenPHPService_Start_Ugly(t *T) { + ax7LongRunningCommand(t, "php") + service := NewFrankenPHPService(t.TempDir(), FrankenPHPOptions{HTTPS: true, CertFile: "cert", KeyFile: "key"}) + service.running = true + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertError(t, err, "already running") +} + +func TestPHP_FrankenPHPService_Stop_Good(t *T) { + service := NewFrankenPHPService(t.TempDir(), FrankenPHPOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_FrankenPHPService_Stop_Bad(t *T) { + service := NewFrankenPHPService("", FrankenPHPOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_FrankenPHPService_Stop_Ugly(t *T) { + ax7LongRunningCommand(t, "php") + service := NewFrankenPHPService(t.TempDir(), FrankenPHPOptions{}) + RequireNoError(t, service.Start(context.Background())) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_ViteService_Start_Good(t *T) { + ax7LongRunningCommand(t, "npm") + service := NewViteService(t.TempDir(), ViteOptions{PackageManager: "npm"}) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_ViteService_Start_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + service := NewViteService(t.TempDir(), ViteOptions{PackageManager: "npm"}) + err := service.Start(context.Background()) + AssertError(t, err) +} + +func TestPHP_ViteService_Start_Ugly(t *T) { + ax7LongRunningCommand(t, "yarn") + service := NewViteService(t.TempDir(), ViteOptions{PackageManager: "yarn"}) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_ViteService_Stop_Good(t *T) { + service := NewViteService(t.TempDir(), ViteOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_ViteService_Stop_Bad(t *T) { + service := NewViteService("", ViteOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_ViteService_Stop_Ugly(t *T) { + ax7LongRunningCommand(t, "npm") + service := NewViteService(t.TempDir(), ViteOptions{PackageManager: "npm"}) + RequireNoError(t, service.Start(context.Background())) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_HorizonService_Start_Good(t *T) { + ax7LongRunningCommand(t, "php") + service := NewHorizonService(t.TempDir()) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_HorizonService_Start_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + service := NewHorizonService(t.TempDir()) + err := service.Start(context.Background()) + AssertError(t, err) +} + +func TestPHP_HorizonService_Start_Ugly(t *T) { + ax7LongRunningCommand(t, "php") + service := NewHorizonService(t.TempDir()) + service.running = true + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertError(t, err) +} + +func TestPHP_HorizonService_Stop_Good(t *T) { + service := NewHorizonService(t.TempDir()) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_HorizonService_Stop_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + service := NewHorizonService(t.TempDir()) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_HorizonService_Stop_Ugly(t *T) { + ax7LongRunningCommand(t, "php") + service := NewHorizonService(t.TempDir()) + RequireNoError(t, service.Start(context.Background())) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_ReverbService_Start_Good(t *T) { + ax7LongRunningCommand(t, "php") + service := NewReverbService(t.TempDir(), ReverbOptions{}) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_ReverbService_Start_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + service := NewReverbService(t.TempDir(), ReverbOptions{}) + err := service.Start(context.Background()) + AssertError(t, err) +} + +func TestPHP_ReverbService_Start_Ugly(t *T) { + ax7LongRunningCommand(t, "php") + service := NewReverbService(t.TempDir(), ReverbOptions{Port: 9090}) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_ReverbService_Stop_Good(t *T) { + service := NewReverbService(t.TempDir(), ReverbOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_ReverbService_Stop_Bad(t *T) { + service := NewReverbService("", ReverbOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_ReverbService_Stop_Ugly(t *T) { + ax7LongRunningCommand(t, "php") + service := NewReverbService(t.TempDir(), ReverbOptions{}) + RequireNoError(t, service.Start(context.Background())) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_RedisService_Start_Good(t *T) { + ax7LongRunningCommand(t, "redis-server") + service := NewRedisService(t.TempDir(), RedisOptions{}) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_RedisService_Start_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + service := NewRedisService(t.TempDir(), RedisOptions{}) + err := service.Start(context.Background()) + AssertError(t, err) +} + +func TestPHP_RedisService_Start_Ugly(t *T) { + ax7LongRunningCommand(t, "redis-server") + service := NewRedisService(t.TempDir(), RedisOptions{ConfigFile: "redis.conf"}) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_RedisService_Stop_Good(t *T) { + service := NewRedisService(t.TempDir(), RedisOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_RedisService_Stop_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + service := NewRedisService(t.TempDir(), RedisOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_RedisService_Stop_Ugly(t *T) { + ax7LongRunningCommand(t, "redis-server") + ax7LongRunningCommand(t, "redis-cli") + service := NewRedisService(t.TempDir(), RedisOptions{}) + RequireNoError(t, service.Start(context.Background())) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_NewDevServer_Bad(t *T) { + server := NewDevServer(Options{}) + AssertNotNil(t, server) + AssertLen(t, server.Services(), 0) +} + +func TestPHP_NewDevServer_Ugly(t *T) { + server := NewDevServer(Options{Services: []DetectedService{ServiceRedis}, RedisPort: 6380}) + AssertEqual(t, 6380, server.opts.RedisPort) + AssertContains(t, server.opts.Services, ServiceRedis) +} + +func TestPHP_DevServer_Start_Good(t *T) { + ax7LongRunningCommand(t, "php") + dir := ax7LaravelProject(t) + server := NewDevServer(Options{Dir: dir, Services: []DetectedService{ServiceFrankenPHP}}) + err := server.Start(context.Background(), Options{Dir: dir, Services: []DetectedService{ServiceFrankenPHP}}) + t.Cleanup(func() { _ = server.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_DevServer_Start_Ugly(t *T) { + server := NewDevServer(Options{}) + server.running = true + err := server.Start(context.Background(), Options{}) + AssertError(t, err, "already running") +} + +func TestPHP_DevServer_Stop_Bad(t *T) { + server := NewDevServer(Options{}) + server.running = true + server.services = []Service{&ax7Service{name: "bad", stopErr: errors.New("stop failed")}} + err := server.Stop() + AssertError(t, err, "errors stopping") +} + +func TestPHP_DevServer_Stop_Ugly(t *T) { + server := NewDevServer(Options{}) + server.running = true + server.cancel = func() {} + err := server.Stop() + AssertNoError(t, err) +} + +func TestPHP_DevServer_Logs_Ugly(t *T) { + server := NewDevServer(Options{}) + reader, err := server.Logs("missing", false) + AssertError(t, err, "service not found") + AssertEqual(t, nil, reader) +} + +func TestPHP_DevServer_Status_Bad(t *T) { + server := NewDevServer(Options{}) + status := server.Status() + AssertLen(t, status, 0) +} + +func TestPHP_DevServer_Status_Ugly(t *T) { + server := NewDevServer(Options{}) + server.services = []Service{&ax7Service{name: "svc", status: ServiceStatus{Name: "svc", Running: true}}} + status := server.Status() + AssertTrue(t, status[0].Running) +} + +func TestPHP_DevServer_IsRunning_Bad(t *T) { + server := NewDevServer(Options{}) + running := server.IsRunning() + AssertFalse(t, running) + AssertLen(t, server.Services(), 0) +} + +func TestPHP_DevServer_IsRunning_Ugly(t *T) { + server := NewDevServer(Options{}) + server.running = true + AssertTrue(t, server.IsRunning()) +} + +func TestPHP_DevServer_Services_Bad(t *T) { + server := NewDevServer(Options{}) + services := server.Services() + AssertLen(t, services, 0) +} + +func TestPHP_DevServer_Services_Ugly(t *T) { + server := NewDevServer(Options{}) + server.services = []Service{&ax7Service{name: "svc"}} + services := server.Services() + AssertEqual(t, "svc", services[0].Name()) +} + +func TestPHP_Reader_Read_Good(t *T) { + path := filepath.Join(t.TempDir(), "tail.log") + ax7WriteFile(t, path, "line") + file, err := os.Open(path) + RequireNoError(t, err) + reader := newTailReader(file) + buf := make([]byte, 8) + n, err := reader.Read(buf) + AssertNoError(t, err) + AssertEqual(t, "line", string(buf[:n])) +} + +func TestPHP_Reader_Read_Bad(t *T) { + path := filepath.Join(t.TempDir(), "tail.log") + ax7WriteFile(t, path, "line") + file, err := os.Open(path) + RequireNoError(t, err) + reader := newTailReader(file) + RequireNoError(t, reader.Close()) + n, err := reader.Read(make([]byte, 8)) + AssertEqual(t, 0, n) + AssertEqual(t, io.EOF, err) +} + +func TestPHP_Reader_Read_Ugly(t *T) { + path := filepath.Join(t.TempDir(), "tail.log") + ax7WriteFile(t, path, "abc") + file, err := os.Open(path) + RequireNoError(t, err) + reader := newTailReader(file) + buf := make([]byte, 1) + n, err := reader.Read(buf) + AssertNoError(t, err) + AssertEqual(t, 1, n) +} + +func TestPHP_Reader_Close_Good(t *T) { + path := filepath.Join(t.TempDir(), "tail.log") + ax7WriteFile(t, path, "line") + file, err := os.Open(path) + RequireNoError(t, err) + reader := newTailReader(file) + err = reader.Close() + AssertNoError(t, err) +} + +func TestPHP_Reader_Close_Bad(t *T) { + path := filepath.Join(t.TempDir(), "tail.log") + ax7WriteFile(t, path, "line") + file, err := os.Open(path) + RequireNoError(t, err) + reader := newTailReader(file) + RequireNoError(t, reader.Close()) + err = reader.Close() + AssertError(t, err) +} + +func TestPHP_Reader_Close_Ugly(t *T) { + path := filepath.Join(t.TempDir(), "tail.log") + ax7WriteFile(t, path, "") + file, err := os.Open(path) + RequireNoError(t, err) + reader := newTailReader(file) + err = reader.Close() + AssertNoError(t, err) +} + +func TestPHP_ServiceReader_Read_Good(t *T) { + reader := newMultiServiceReader([]Service{&ax7Service{name: "svc"}}, []io.ReadCloser{io.NopCloser(strings.NewReader("log"))}, false) + buf := make([]byte, 32) + n, err := reader.Read(buf) + AssertNoError(t, err) + AssertContains(t, string(buf[:n]), "[svc] log") +} + +func TestPHP_ServiceReader_Read_Bad(t *T) { + reader := newMultiServiceReader(nil, nil, false) + n, err := reader.Read(make([]byte, 4)) + AssertEqual(t, 0, n) + AssertEqual(t, io.EOF, err) +} + +func TestPHP_ServiceReader_Read_Ugly(t *T) { + reader := newMultiServiceReader(nil, nil, true) + n, err := reader.Read(make([]byte, 4)) + AssertNoError(t, err) + AssertEqual(t, 0, n) +} + +func TestPHP_ServiceReader_Close_Good(t *T) { + reader := newMultiServiceReader(nil, []io.ReadCloser{io.NopCloser(strings.NewReader(""))}, false) + err := reader.Close() + AssertNoError(t, err) +} + +func TestPHP_ServiceReader_Close_Bad(t *T) { + reader := newMultiServiceReader(nil, []io.ReadCloser{ax7FailingCloser{}}, false) + err := reader.Close() + AssertError(t, err, "close failed") +} + +func TestPHP_ServiceReader_Close_Ugly(t *T) { + reader := newMultiServiceReader(nil, nil, false) + err := reader.Close() + AssertNoError(t, err) +} + +func TestPHP_LinkPackages_Ugly(t *T) { + dir := ax7PHPProject(t) + pkg := t.TempDir() + ax7WriteFile(t, filepath.Join(pkg, "composer.json"), `{"name":"acme/package","version":"dev-main"}`) + err := LinkPackages(dir, []string{pkg}) + AssertNoError(t, err) +} + +func TestPHP_UnlinkPackages_Ugly(t *T) { + dir := ax7PHPProject(t) + pkg := t.TempDir() + ax7WriteFile(t, filepath.Join(pkg, "composer.json"), `{"name":"acme/package"}`) + RequireNoError(t, LinkPackages(dir, []string{pkg})) + err := UnlinkPackages(dir, []string{"acme/package"}) + AssertNoError(t, err) +} + +func TestPHP_UpdatePackages_Ugly(t *T) { + bin := ax7BinPath(t) + ax7Executable(t, bin, "composer", "exit 0\n") + dir := ax7PHPProject(t) + err := UpdatePackages(dir, []string{}) + AssertNoError(t, err) +} + +func TestPHP_ListLinkedPackages_Ugly(t *T) { + dir := ax7PHPProject(t) + packages, err := ListLinkedPackages(dir) + AssertNoError(t, err) + AssertLen(t, packages, 0) +} diff --git a/pkg/php/cmd_serve_frankenphp.go b/pkg/php/cmd_serve_frankenphp.go index 58aaa1e..ac479ad 100644 --- a/pkg/php/cmd_serve_frankenphp.go +++ b/pkg/php/cmd_serve_frankenphp.go @@ -1,4 +1,4 @@ -//go:build cgo +//go:build frankenphp package php @@ -120,6 +120,6 @@ type execResponseWriter struct { out *os.File } -func (w *execResponseWriter) Header() http.Header { return http.Header{} } +func (w *execResponseWriter) Header() http.Header { return http.Header{} } func (w *execResponseWriter) WriteHeader(statusCode int) {} func (w *execResponseWriter) Write(b []byte) (int, error) { return w.out.Write(b) } diff --git a/pkg/php/cmd_serve_frankenphp_stub.go b/pkg/php/cmd_serve_frankenphp_stub.go new file mode 100644 index 0000000..2e80f1d --- /dev/null +++ b/pkg/php/cmd_serve_frankenphp_stub.go @@ -0,0 +1,17 @@ +//go:build !frankenphp + +package php + +import ( + "net/http" + "os" +) + +// execResponseWriter writes HTTP response body directly to stdout. +type execResponseWriter struct { + out *os.File +} + +func (w *execResponseWriter) Header() http.Header { return http.Header{} } +func (w *execResponseWriter) WriteHeader(_ int) {} +func (w *execResponseWriter) Write(b []byte) (int, error) { return w.out.Write(b) } diff --git a/pkg/php/container.go b/pkg/php/container.go index e6c4719..cfe02a6 100644 --- a/pkg/php/container.go +++ b/pkg/php/container.go @@ -308,10 +308,9 @@ func ServeProduction(ctx context.Context, opts ServeOptions) error { args = append(args, imageRef) cmd := exec.CommandContext(ctx, "docker", args...) - cmd.Stdout = opts.Output - cmd.Stderr = opts.Output if opts.Detach { + cmd.Stderr = opts.Output output, err := cmd.Output() if err != nil { return cli.WrapVerb(err, "start", "container") @@ -321,6 +320,8 @@ func ServeProduction(ctx context.Context, opts ServeOptions) error { return nil } + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output return cmd.Run() } diff --git a/pkg/php/container_test.go b/pkg/php/container_test.go index c0d0e19..7beddb3 100644 --- a/pkg/php/container_test.go +++ b/pkg/php/container_test.go @@ -4,14 +4,10 @@ import ( "context" "os" "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestDockerBuildOptions_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_DockerBuildOptions_Good(t *T) { + t.Run("all fields accessible", func(t *T) { opts := DockerBuildOptions{ ProjectDir: "/project", ImageName: "myapp", @@ -23,19 +19,19 @@ func TestDockerBuildOptions_Good(t *testing.T) { Output: os.Stdout, } - assert.Equal(t, "/project", opts.ProjectDir) - assert.Equal(t, "myapp", opts.ImageName) - assert.Equal(t, "v1.0.0", opts.Tag) - assert.Equal(t, "linux/amd64", opts.Platform) - assert.Equal(t, "/path/to/Dockerfile", opts.Dockerfile) - assert.True(t, opts.NoBuildCache) - assert.Equal(t, "value1", opts.BuildArgs["ARG1"]) - assert.NotNil(t, opts.Output) + AssertEqual(t, "/project", opts.ProjectDir) + AssertEqual(t, "myapp", opts.ImageName) + AssertEqual(t, "v1.0.0", opts.Tag) + AssertEqual(t, "linux/amd64", opts.Platform) + AssertEqual(t, "/path/to/Dockerfile", opts.Dockerfile) + AssertTrue(t, opts.NoBuildCache) + AssertEqual(t, "value1", opts.BuildArgs["ARG1"]) + AssertNotNil(t, opts.Output) }) } -func TestLinuxKitBuildOptions_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_LinuxKitBuildOptions_Good(t *T) { + t.Run("all fields accessible", func(t *T) { opts := LinuxKitBuildOptions{ ProjectDir: "/project", OutputPath: "/output/image.qcow2", @@ -45,17 +41,17 @@ func TestLinuxKitBuildOptions_Good(t *testing.T) { Output: os.Stdout, } - assert.Equal(t, "/project", opts.ProjectDir) - assert.Equal(t, "/output/image.qcow2", opts.OutputPath) - assert.Equal(t, "qcow2", opts.Format) - assert.Equal(t, "server-php", opts.Template) - assert.Equal(t, "value1", opts.Variables["VAR1"]) - assert.NotNil(t, opts.Output) + AssertEqual(t, "/project", opts.ProjectDir) + AssertEqual(t, "/output/image.qcow2", opts.OutputPath) + AssertEqual(t, "qcow2", opts.Format) + AssertEqual(t, "server-php", opts.Template) + AssertEqual(t, "value1", opts.Variables["VAR1"]) + AssertNotNil(t, opts.Output) }) } -func TestServeOptions_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_ServeOptions_Good(t *T) { + t.Run("all fields accessible", func(t *T) { opts := ServeOptions{ ImageName: "myapp", Tag: "latest", @@ -68,41 +64,41 @@ func TestServeOptions_Good(t *testing.T) { Output: os.Stdout, } - assert.Equal(t, "myapp", opts.ImageName) - assert.Equal(t, "latest", opts.Tag) - assert.Equal(t, "myapp-container", opts.ContainerName) - assert.Equal(t, 8080, opts.Port) - assert.Equal(t, 8443, opts.HTTPSPort) - assert.True(t, opts.Detach) - assert.Equal(t, "/path/to/.env", opts.EnvFile) - assert.Equal(t, "/container", opts.Volumes["/host"]) - assert.NotNil(t, opts.Output) + AssertEqual(t, "myapp", opts.ImageName) + AssertEqual(t, "latest", opts.Tag) + AssertEqual(t, "myapp-container", opts.ContainerName) + AssertEqual(t, 8080, opts.Port) + AssertEqual(t, 8443, opts.HTTPSPort) + AssertTrue(t, opts.Detach) + AssertEqual(t, "/path/to/.env", opts.EnvFile) + AssertEqual(t, "/container", opts.Volumes["/host"]) + AssertNotNil(t, opts.Output) }) } -func TestIsPHPProject_Container_Good(t *testing.T) { - t.Run("returns true with composer.json", func(t *testing.T) { +func TestPHP_IsPHPProject_Container_Good(t *T) { + t.Run("returns true with composer.json", func(t *T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.True(t, IsPHPProject(dir)) + AssertTrue(t, IsPHPProject(dir)) }) } -func TestIsPHPProject_Container_Bad(t *testing.T) { - t.Run("returns false without composer.json", func(t *testing.T) { +func TestPHP_IsPHPProject_Container_Bad(t *T) { + t.Run("returns false without composer.json", func(t *T) { dir := t.TempDir() - assert.False(t, IsPHPProject(dir)) + AssertFalse(t, IsPHPProject(dir)) }) - t.Run("returns false for non-existent directory", func(t *testing.T) { - assert.False(t, IsPHPProject("/non/existent/path")) + t.Run("returns false for non-existent directory", func(t *T) { + AssertFalse(t, IsPHPProject("/non/existent/path")) }) } -func TestLookupLinuxKit_Bad(t *testing.T) { - t.Run("returns error when linuxkit not found", func(t *testing.T) { +func TestPHP_LookupLinuxKit_Bad(t *T) { + t.Run("returns error when linuxkit not found", func(t *T) { // Save original PATH and paths origPath := os.Getenv("PATH") origCommonPaths := commonLinuxKitPaths @@ -116,31 +112,30 @@ func TestLookupLinuxKit_Bad(t *testing.T) { commonLinuxKitPaths = []string{} _, err := lookupLinuxKit() - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "linuxkit not found") - } + AssertError(t, err) + AssertContains(t, err.Error(), "linuxkit not found") }) } -func TestGetLinuxKitTemplate_Good(t *testing.T) { - t.Run("returns server-php template", func(t *testing.T) { +func TestPHP_GetLinuxKitTemplate_Good(t *T) { + t.Run("returns server-php template", func(t *T) { content, err := getLinuxKitTemplate("server-php") - assert.NoError(t, err) - assert.Contains(t, content, "kernel:") - assert.Contains(t, content, "linuxkit/kernel") + AssertNoError(t, err) + AssertContains(t, content, "kernel:") + AssertContains(t, content, "linuxkit/kernel") }) } -func TestGetLinuxKitTemplate_Bad(t *testing.T) { - t.Run("returns error for unknown template", func(t *testing.T) { +func TestPHP_GetLinuxKitTemplate_Bad(t *T) { + t.Run("returns error for unknown template", func(t *T) { _, err := getLinuxKitTemplate("unknown-template") - assert.Error(t, err) - assert.Contains(t, err.Error(), "template not found") + AssertError(t, err) + AssertContains(t, err.Error(), "template not found") }) } -func TestApplyTemplateVariables_Good(t *testing.T) { - t.Run("replaces variables", func(t *testing.T) { +func TestPHP_ApplyTemplateVariables_Good(t *T) { + t.Run("replaces variables", func(t *T) { content := "Hello ${NAME}, welcome to ${PLACE}!" vars := map[string]string{ "NAME": "World", @@ -148,100 +143,103 @@ func TestApplyTemplateVariables_Good(t *testing.T) { } result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Equal(t, "Hello World, welcome to Earth!", result) + AssertNoError(t, err) + AssertEqual(t, "Hello World, welcome to Earth!", result) }) - t.Run("handles empty variables", func(t *testing.T) { + t.Run("handles empty variables", func(t *T) { content := "No variables here" vars := map[string]string{} result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Equal(t, "No variables here", result) + AssertNoError(t, err) + AssertEqual(t, "No variables here", result) }) - t.Run("leaves unmatched placeholders", func(t *testing.T) { + t.Run("leaves unmatched placeholders", func(t *T) { content := "Hello ${NAME}, ${UNKNOWN} is unknown" vars := map[string]string{ "NAME": "World", } result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Contains(t, result, "Hello World") - assert.Contains(t, result, "${UNKNOWN}") + AssertNoError(t, err) + AssertContains(t, result, "Hello World") + AssertContains(t, result, "${UNKNOWN}") }) - t.Run("handles multiple occurrences", func(t *testing.T) { + t.Run("handles multiple occurrences", func(t *T) { content := "${VAR} and ${VAR} again" vars := map[string]string{ "VAR": "value", } result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Equal(t, "value and value again", result) + AssertNoError(t, err) + AssertEqual(t, "value and value again", result) }) } -func TestDefaultServerPHPTemplate_Good(t *testing.T) { - t.Run("template has required sections", func(t *testing.T) { - assert.Contains(t, defaultServerPHPTemplate, "kernel:") - assert.Contains(t, defaultServerPHPTemplate, "init:") - assert.Contains(t, defaultServerPHPTemplate, "services:") - assert.Contains(t, defaultServerPHPTemplate, "onboot:") +func TestPHP_DefaultServerPHPTemplate_Good(t *T) { + t.Run("template has required sections", func(t *T) { + AssertContains(t, defaultServerPHPTemplate, "kernel:") + AssertContains(t, defaultServerPHPTemplate, "init:") + AssertContains(t, defaultServerPHPTemplate, "services:") + AssertContains(t, defaultServerPHPTemplate, "onboot:") }) - t.Run("template contains placeholders", func(t *testing.T) { - assert.Contains(t, defaultServerPHPTemplate, "${SSH_KEY:-}") + t.Run("template contains placeholders", func(t *T) { + AssertContains(t, defaultServerPHPTemplate, "${SSH_KEY:-}") }) } -func TestBuildDocker_Bad(t *testing.T) { +func TestPHP_BuildDocker_Bad(t *T) { t.Skip("requires Docker installed") - t.Run("fails for non-PHP project", func(t *testing.T) { + t.Run("fails for non-PHP project", func(t *T) { dir := t.TempDir() err := BuildDocker(context.TODO(), DockerBuildOptions{ProjectDir: dir}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") + AssertError(t, err) + AssertContains(t, err.Error(), "not a PHP project") }) } -func TestBuildLinuxKit_Bad(t *testing.T) { +func TestPHP_BuildLinuxKit_Bad(t *T) { t.Skip("requires linuxkit installed") - t.Run("fails for non-PHP project", func(t *testing.T) { + t.Run("fails for non-PHP project", func(t *T) { dir := t.TempDir() err := BuildLinuxKit(context.TODO(), LinuxKitBuildOptions{ProjectDir: dir}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") + AssertError(t, err) + AssertContains(t, err.Error(), "not a PHP project") }) } -func TestServeProduction_Bad(t *testing.T) { - t.Run("fails without image name", func(t *testing.T) { +func TestPHP_ServeProduction_Bad(t *T) { + t.Run("fails without image name", func(t *T) { err := ServeProduction(context.TODO(), ServeOptions{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "image name is required") + AssertError(t, err) + AssertContains(t, err.Error(), "image name is required") }) } -func TestShell_Bad(t *testing.T) { - t.Run("fails without container ID", func(t *testing.T) { +func TestPHP_Shell_Bad(t *T) { + t.Run("fails without container ID", func(t *T) { err := Shell(context.TODO(), "") - assert.Error(t, err) - assert.Contains(t, err.Error(), "container ID is required") + AssertError(t, err) + AssertContains(t, err.Error(), "container ID is required") }) } -func TestResolveDockerContainerID_Bad(t *testing.T) { - t.Skip("requires Docker installed") +func TestPHP_ResolveDockerContainerID_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + id, err := resolveDockerContainerID(context.TODO(), "abc") + AssertError(t, err) + AssertEqual(t, "", id) } -func TestBuildDocker_DefaultOptions(t *testing.T) { - t.Run("sets defaults correctly", func(t *testing.T) { +func TestBuildDocker_DefaultOptions(t *T) { + t.Run("sets defaults correctly", func(t *T) { // This tests the default logic without actually running Docker opts := DockerBuildOptions{} @@ -249,75 +247,75 @@ func TestBuildDocker_DefaultOptions(t *testing.T) { if opts.Tag == "" { opts.Tag = "latest" } - assert.Equal(t, "latest", opts.Tag) + AssertEqual(t, "latest", opts.Tag) if opts.ImageName == "" { opts.ImageName = filepath.Base("/project/myapp") } - assert.Equal(t, "myapp", opts.ImageName) + AssertEqual(t, "myapp", opts.ImageName) }) } -func TestBuildLinuxKit_DefaultOptions(t *testing.T) { - t.Run("sets defaults correctly", func(t *testing.T) { +func TestBuildLinuxKit_DefaultOptions(t *T) { + t.Run("sets defaults correctly", func(t *T) { opts := LinuxKitBuildOptions{} // Verify default values would be set if opts.Template == "" { opts.Template = "server-php" } - assert.Equal(t, "server-php", opts.Template) + AssertEqual(t, "server-php", opts.Template) if opts.Format == "" { opts.Format = "qcow2" } - assert.Equal(t, "qcow2", opts.Format) + AssertEqual(t, "qcow2", opts.Format) }) } -func TestServeProduction_DefaultOptions(t *testing.T) { - t.Run("sets defaults correctly", func(t *testing.T) { +func TestServeProduction_DefaultOptions(t *T) { + t.Run("sets defaults correctly", func(t *T) { opts := ServeOptions{ImageName: "myapp"} // Verify default values would be set if opts.Tag == "" { opts.Tag = "latest" } - assert.Equal(t, "latest", opts.Tag) + AssertEqual(t, "latest", opts.Tag) if opts.Port == 0 { opts.Port = 80 } - assert.Equal(t, 80, opts.Port) + AssertEqual(t, 80, opts.Port) if opts.HTTPSPort == 0 { opts.HTTPSPort = 443 } - assert.Equal(t, 443, opts.HTTPSPort) + AssertEqual(t, 443, opts.HTTPSPort) }) } -func TestLookupLinuxKit_Good(t *testing.T) { +func TestPHP_LookupLinuxKit_Good(t *T) { t.Skip("requires linuxkit installed") - t.Run("finds linuxkit in PATH", func(t *testing.T) { + t.Run("finds linuxkit in PATH", func(t *T) { path, err := lookupLinuxKit() - assert.NoError(t, err) - assert.NotEmpty(t, path) + AssertNoError(t, err) + AssertNotEmpty(t, path) }) } -func TestBuildDocker_WithCustomDockerfile(t *testing.T) { +func TestBuildDocker_WithCustomDockerfile(t *T) { t.Skip("requires Docker installed") - t.Run("uses custom Dockerfile when provided", func(t *testing.T) { + t.Run("uses custom Dockerfile when provided", func(t *T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{"name":"test"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) dockerfilePath := filepath.Join(dir, "Dockerfile.custom") err = os.WriteFile(dockerfilePath, []byte("FROM alpine"), 0644) - require.NoError(t, err) + RequireNoError(t, err) opts := DockerBuildOptions{ ProjectDir: dir, @@ -325,32 +323,32 @@ func TestBuildDocker_WithCustomDockerfile(t *testing.T) { } // The function would use the custom Dockerfile - assert.Equal(t, dockerfilePath, opts.Dockerfile) + AssertEqual(t, dockerfilePath, opts.Dockerfile) }) } -func TestBuildDocker_GeneratesDockerfile(t *testing.T) { +func TestBuildDocker_GeneratesDockerfile(t *T) { t.Skip("requires Docker installed") - t.Run("generates Dockerfile when not provided", func(t *testing.T) { + t.Run("generates Dockerfile when not provided", func(t *T) { dir := t.TempDir() // Create valid PHP project composerJSON := `{"name":"test","require":{"php":"^8.2","laravel/framework":"^11.0"}}` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) opts := DockerBuildOptions{ ProjectDir: dir, // Dockerfile not specified - should be generated } - assert.Empty(t, opts.Dockerfile) + AssertEmpty(t, opts.Dockerfile) }) } -func TestServeProduction_BuildsCorrectArgs(t *testing.T) { - t.Run("builds correct docker run arguments", func(t *testing.T) { +func TestServeProduction_BuildsCorrectArgs(t *T) { + t.Run("builds correct docker run arguments", func(t *T) { opts := ServeOptions{ ImageName: "myapp", Tag: "v1.0.0", @@ -366,18 +364,27 @@ func TestServeProduction_BuildsCorrectArgs(t *testing.T) { // Verify the expected image reference format imageRef := opts.ImageName + ":" + opts.Tag - assert.Equal(t, "myapp:v1.0.0", imageRef) + AssertEqual(t, "myapp:v1.0.0", imageRef) // Verify port format portMapping := opts.Port - assert.Equal(t, 8080, portMapping) + AssertEqual(t, 8080, portMapping) }) } -func TestShell_Integration(t *testing.T) { - t.Skip("requires Docker with running container") +func TestShell_Integration(t *T) { + if os.Getenv("CORE_PHP_RUN_DOCKER_INTEGRATION") == "" { + t.Skip("requires Docker with running container") + } + err := Shell(context.TODO(), os.Getenv("CORE_PHP_CONTAINER")) + AssertNoError(t, err) } -func TestResolveDockerContainerID_Integration(t *testing.T) { - t.Skip("requires Docker with running containers") +func TestResolveDockerContainerID_Integration(t *T) { + if os.Getenv("CORE_PHP_RUN_DOCKER_INTEGRATION") == "" { + t.Skip("requires Docker with running containers") + } + id, err := resolveDockerContainerID(context.TODO(), os.Getenv("CORE_PHP_CONTAINER")) + AssertNoError(t, err) + AssertNotEmpty(t, id) } diff --git a/pkg/php/coolify.go b/pkg/php/coolify.go index e458484..8df64c3 100644 --- a/pkg/php/coolify.go +++ b/pkg/php/coolify.go @@ -56,7 +56,7 @@ type CoolifyApp struct { // NewCoolifyClient creates a new Coolify API client. func NewCoolifyClient(baseURL, token string) *CoolifyClient { // Ensure baseURL doesn't have trailing slash - baseURL = strings.TrimSuffix(baseURL, "/") + baseURL = strings.TrimRight(baseURL, "/") return &CoolifyClient{ BaseURL: baseURL, diff --git a/pkg/php/coolify_test.go b/pkg/php/coolify_test.go index 8176c88..ccac13f 100644 --- a/pkg/php/coolify_test.go +++ b/pkg/php/coolify_test.go @@ -7,35 +7,31 @@ import ( "net/http/httptest" "os" "path/filepath" - "testing" "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestCoolifyClient_Good(t *testing.T) { - t.Run("creates client with correct base URL", func(t *testing.T) { +func TestPHP_CoolifyClient_Good(t *T) { + t.Run("creates client with correct base URL", func(t *T) { client := NewCoolifyClient("https://coolify.example.com", "token") - assert.Equal(t, "https://coolify.example.com", client.BaseURL) - assert.Equal(t, "token", client.Token) - assert.NotNil(t, client.HTTPClient) + AssertEqual(t, "https://coolify.example.com", client.BaseURL) + AssertEqual(t, "token", client.Token) + AssertNotNil(t, client.HTTPClient) }) - t.Run("strips trailing slash from base URL", func(t *testing.T) { + t.Run("strips trailing slash from base URL", func(t *T) { client := NewCoolifyClient("https://coolify.example.com/", "token") - assert.Equal(t, "https://coolify.example.com", client.BaseURL) + AssertEqual(t, "https://coolify.example.com", client.BaseURL) }) - t.Run("http client has timeout", func(t *testing.T) { + t.Run("http client has timeout", func(t *T) { client := NewCoolifyClient("https://coolify.example.com", "token") - assert.Equal(t, 30*time.Second, client.HTTPClient.Timeout) + AssertEqual(t, 30*time.Second, client.HTTPClient.Timeout) }) } -func TestCoolifyConfig_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_CoolifyConfig_Good(t *T) { + t.Run("all fields accessible", func(t *T) { config := CoolifyConfig{ URL: "https://coolify.example.com", Token: "secret-token", @@ -43,15 +39,15 @@ func TestCoolifyConfig_Good(t *testing.T) { StagingAppID: "staging-456", } - assert.Equal(t, "https://coolify.example.com", config.URL) - assert.Equal(t, "secret-token", config.Token) - assert.Equal(t, "app-123", config.AppID) - assert.Equal(t, "staging-456", config.StagingAppID) + AssertEqual(t, "https://coolify.example.com", config.URL) + AssertEqual(t, "secret-token", config.Token) + AssertEqual(t, "app-123", config.AppID) + AssertEqual(t, "staging-456", config.StagingAppID) }) } -func TestCoolifyDeployment_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_CoolifyDeployment_Good(t *T) { + t.Run("all fields accessible", func(t *T) { now := time.Now() deployment := CoolifyDeployment{ ID: "dep-123", @@ -65,16 +61,16 @@ func TestCoolifyDeployment_Good(t *testing.T) { DeployedURL: "https://app.example.com", } - assert.Equal(t, "dep-123", deployment.ID) - assert.Equal(t, "finished", deployment.Status) - assert.Equal(t, "abc123", deployment.CommitSHA) - assert.Equal(t, "Test commit", deployment.CommitMsg) - assert.Equal(t, "main", deployment.Branch) + AssertEqual(t, "dep-123", deployment.ID) + AssertEqual(t, "finished", deployment.Status) + AssertEqual(t, "abc123", deployment.CommitSHA) + AssertEqual(t, "Test commit", deployment.CommitMsg) + AssertEqual(t, "main", deployment.Branch) }) } -func TestCoolifyApp_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_CoolifyApp_Good(t *T) { + t.Run("all fields accessible", func(t *T) { app := CoolifyApp{ ID: "app-123", Name: "MyApp", @@ -85,15 +81,15 @@ func TestCoolifyApp_Good(t *testing.T) { Environment: "production", } - assert.Equal(t, "app-123", app.ID) - assert.Equal(t, "MyApp", app.Name) - assert.Equal(t, "https://myapp.example.com", app.FQDN) - assert.Equal(t, "running", app.Status) + AssertEqual(t, "app-123", app.ID) + AssertEqual(t, "MyApp", app.Name) + AssertEqual(t, "https://myapp.example.com", app.FQDN) + AssertEqual(t, "running", app.Status) }) } -func TestLoadCoolifyConfigFromFile_Good(t *testing.T) { - t.Run("loads config from .env file", func(t *testing.T) { +func TestPHP_LoadCoolifyConfigFromFile_Good(t *T) { + t.Run("loads config from .env file", func(t *T) { dir := t.TempDir() envContent := `COOLIFY_URL=https://coolify.example.com COOLIFY_TOKEN=secret-token @@ -101,31 +97,31 @@ COOLIFY_APP_ID=app-123 COOLIFY_STAGING_APP_ID=staging-456` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) - assert.Equal(t, "secret-token", config.Token) - assert.Equal(t, "app-123", config.AppID) - assert.Equal(t, "staging-456", config.StagingAppID) + AssertNoError(t, err) + AssertEqual(t, "https://coolify.example.com", config.URL) + AssertEqual(t, "secret-token", config.Token) + AssertEqual(t, "app-123", config.AppID) + AssertEqual(t, "staging-456", config.StagingAppID) }) - t.Run("handles quoted values", func(t *testing.T) { + t.Run("handles quoted values", func(t *T) { dir := t.TempDir() envContent := `COOLIFY_URL="https://coolify.example.com" COOLIFY_TOKEN='secret-token'` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) - assert.Equal(t, "secret-token", config.Token) + AssertNoError(t, err) + AssertEqual(t, "https://coolify.example.com", config.URL) + AssertEqual(t, "secret-token", config.Token) }) - t.Run("ignores comments", func(t *testing.T) { + t.Run("ignores comments", func(t *T) { dir := t.TempDir() envContent := `# This is a comment COOLIFY_URL=https://coolify.example.com @@ -133,92 +129,92 @@ COOLIFY_URL=https://coolify.example.com COOLIFY_TOKEN=correct-token` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "correct-token", config.Token) + AssertNoError(t, err) + AssertEqual(t, "correct-token", config.Token) }) - t.Run("ignores blank lines", func(t *testing.T) { + t.Run("ignores blank lines", func(t *T) { dir := t.TempDir() envContent := `COOLIFY_URL=https://coolify.example.com COOLIFY_TOKEN=secret-token` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) + AssertNoError(t, err) + AssertEqual(t, "https://coolify.example.com", config.URL) }) } -func TestLoadCoolifyConfigFromFile_Bad(t *testing.T) { - t.Run("fails when COOLIFY_URL missing", func(t *testing.T) { +func TestPHP_LoadCoolifyConfigFromFile_Bad(t *T) { + t.Run("fails when COOLIFY_URL missing", func(t *T) { dir := t.TempDir() envContent := `COOLIFY_TOKEN=secret-token` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) _, err = LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_URL is not set") + AssertError(t, err) + AssertContains(t, err.Error(), "COOLIFY_URL is not set") }) - t.Run("fails when COOLIFY_TOKEN missing", func(t *testing.T) { + t.Run("fails when COOLIFY_TOKEN missing", func(t *T) { dir := t.TempDir() envContent := `COOLIFY_URL=https://coolify.example.com` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) _, err = LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_TOKEN is not set") + AssertError(t, err) + AssertContains(t, err.Error(), "COOLIFY_TOKEN is not set") }) } -func TestLoadCoolifyConfig_FromDirectory_Good(t *testing.T) { - t.Run("loads from directory", func(t *testing.T) { +func TestPHP_LoadCoolifyConfig_FromDirectory_Good(t *T) { + t.Run("loads from directory", func(t *T) { dir := t.TempDir() envContent := `COOLIFY_URL=https://coolify.example.com COOLIFY_TOKEN=secret-token` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) config, err := LoadCoolifyConfig(dir) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) + AssertNoError(t, err) + AssertEqual(t, "https://coolify.example.com", config.URL) }) } -func TestValidateCoolifyConfig_Bad(t *testing.T) { - t.Run("returns error for empty URL", func(t *testing.T) { +func TestPHP_ValidateCoolifyConfig_Bad(t *T) { + t.Run("returns error for empty URL", func(t *T) { config := &CoolifyConfig{Token: "token"} _, err := validateCoolifyConfig(config) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_URL is not set") + AssertError(t, err) + AssertContains(t, err.Error(), "COOLIFY_URL is not set") }) - t.Run("returns error for empty token", func(t *testing.T) { + t.Run("returns error for empty token", func(t *T) { config := &CoolifyConfig{URL: "https://coolify.example.com"} _, err := validateCoolifyConfig(config) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_TOKEN is not set") + AssertError(t, err) + AssertContains(t, err.Error(), "COOLIFY_TOKEN is not set") }) } -func TestCoolifyClient_TriggerDeploy_Good(t *testing.T) { - t.Run("triggers deployment successfully", func(t *testing.T) { +func TestPHP_CoolifyClient_TriggerDeploy_Good(t *T) { + t.Run("triggers deployment successfully", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/deploy", r.URL.Path) - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "Bearer secret-token", r.Header.Get("Authorization")) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + AssertEqual(t, "/api/v1/applications/app-123/deploy", r.URL.Path) + AssertEqual(t, "POST", r.Method) + AssertEqual(t, "Bearer secret-token", r.Header.Get("Authorization")) + AssertEqual(t, "application/json", r.Header.Get("Content-Type")) resp := CoolifyDeployment{ ID: "dep-456", @@ -232,16 +228,16 @@ func TestCoolifyClient_TriggerDeploy_Good(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") deployment, err := client.TriggerDeploy(context.Background(), "app-123", false) - assert.NoError(t, err) - assert.Equal(t, "dep-456", deployment.ID) - assert.Equal(t, "queued", deployment.Status) + AssertNoError(t, err) + AssertEqual(t, "dep-456", deployment.ID) + AssertEqual(t, "queued", deployment.Status) }) - t.Run("triggers deployment with force", func(t *testing.T) { + t.Run("triggers deployment with force", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]interface{} _ = json.NewDecoder(r.Body).Decode(&body) - assert.Equal(t, true, body["force"]) + AssertEqual(t, true, body["force"]) resp := CoolifyDeployment{ID: "dep-456", Status: "queued"} _ = json.NewEncoder(w).Encode(resp) @@ -250,10 +246,10 @@ func TestCoolifyClient_TriggerDeploy_Good(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") _, err := client.TriggerDeploy(context.Background(), "app-123", true) - assert.NoError(t, err) + AssertNoError(t, err) }) - t.Run("handles minimal response", func(t *testing.T) { + t.Run("handles minimal response", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Return an invalid JSON response to trigger the fallback _, _ = w.Write([]byte("not json")) @@ -263,14 +259,14 @@ func TestCoolifyClient_TriggerDeploy_Good(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") deployment, err := client.TriggerDeploy(context.Background(), "app-123", false) - assert.NoError(t, err) + AssertNoError(t, err) // The fallback response should be returned - assert.Equal(t, "queued", deployment.Status) + AssertEqual(t, "queued", deployment.Status) }) } -func TestCoolifyClient_TriggerDeploy_Bad(t *testing.T) { - t.Run("fails on HTTP error", func(t *testing.T) { +func TestPHP_CoolifyClient_TriggerDeploy_Bad(t *T) { + t.Run("fails on HTTP error", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _ = json.NewEncoder(w).Encode(map[string]string{"message": "Internal error"}) @@ -280,16 +276,16 @@ func TestCoolifyClient_TriggerDeploy_Bad(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") _, err := client.TriggerDeploy(context.Background(), "app-123", false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "API error") + AssertError(t, err) + AssertContains(t, err.Error(), "API error") }) } -func TestCoolifyClient_GetDeployment_Good(t *testing.T) { - t.Run("gets deployment details", func(t *testing.T) { +func TestPHP_CoolifyClient_GetDeployment_Good(t *T) { + t.Run("gets deployment details", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/deployments/dep-456", r.URL.Path) - assert.Equal(t, "GET", r.Method) + AssertEqual(t, "/api/v1/applications/app-123/deployments/dep-456", r.URL.Path) + AssertEqual(t, "GET", r.Method) resp := CoolifyDeployment{ ID: "dep-456", @@ -304,15 +300,15 @@ func TestCoolifyClient_GetDeployment_Good(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") deployment, err := client.GetDeployment(context.Background(), "app-123", "dep-456") - assert.NoError(t, err) - assert.Equal(t, "dep-456", deployment.ID) - assert.Equal(t, "finished", deployment.Status) - assert.Equal(t, "abc123", deployment.CommitSHA) + AssertNoError(t, err) + AssertEqual(t, "dep-456", deployment.ID) + AssertEqual(t, "finished", deployment.Status) + AssertEqual(t, "abc123", deployment.CommitSHA) }) } -func TestCoolifyClient_GetDeployment_Bad(t *testing.T) { - t.Run("fails on 404", func(t *testing.T) { +func TestPHP_CoolifyClient_GetDeployment_Bad(t *T) { + t.Run("fails on 404", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _ = json.NewEncoder(w).Encode(map[string]string{"error": "Not found"}) @@ -322,16 +318,16 @@ func TestCoolifyClient_GetDeployment_Bad(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") _, err := client.GetDeployment(context.Background(), "app-123", "dep-456") - assert.Error(t, err) - assert.Contains(t, err.Error(), "Not found") + AssertError(t, err) + AssertContains(t, err.Error(), "Not found") }) } -func TestCoolifyClient_ListDeployments_Good(t *testing.T) { - t.Run("lists deployments", func(t *testing.T) { +func TestPHP_CoolifyClient_ListDeployments_Good(t *T) { + t.Run("lists deployments", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/deployments", r.URL.Path) - assert.Equal(t, "10", r.URL.Query().Get("limit")) + AssertEqual(t, "/api/v1/applications/app-123/deployments", r.URL.Path) + AssertEqual(t, "10", r.URL.Query().Get("limit")) resp := []CoolifyDeployment{ {ID: "dep-1", Status: "finished"}, @@ -344,34 +340,34 @@ func TestCoolifyClient_ListDeployments_Good(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") deployments, err := client.ListDeployments(context.Background(), "app-123", 10) - assert.NoError(t, err) - assert.Len(t, deployments, 2) - assert.Equal(t, "dep-1", deployments[0].ID) - assert.Equal(t, "dep-2", deployments[1].ID) + AssertNoError(t, err) + AssertLen(t, deployments, 2) + AssertEqual(t, "dep-1", deployments[0].ID) + AssertEqual(t, "dep-2", deployments[1].ID) }) - t.Run("lists without limit", func(t *testing.T) { + t.Run("lists without limit", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "", r.URL.Query().Get("limit")) + AssertEqual(t, "", r.URL.Query().Get("limit")) _ = json.NewEncoder(w).Encode([]CoolifyDeployment{}) })) defer server.Close() client := NewCoolifyClient(server.URL, "secret-token") _, err := client.ListDeployments(context.Background(), "app-123", 0) - assert.NoError(t, err) + AssertNoError(t, err) }) } -func TestCoolifyClient_Rollback_Good(t *testing.T) { - t.Run("triggers rollback", func(t *testing.T) { +func TestPHP_CoolifyClient_Rollback_Good(t *T) { + t.Run("triggers rollback", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/rollback", r.URL.Path) - assert.Equal(t, "POST", r.Method) + AssertEqual(t, "/api/v1/applications/app-123/rollback", r.URL.Path) + AssertEqual(t, "POST", r.Method) var body map[string]string _ = json.NewDecoder(r.Body).Decode(&body) - assert.Equal(t, "dep-old", body["deployment_id"]) + AssertEqual(t, "dep-old", body["deployment_id"]) resp := CoolifyDeployment{ ID: "dep-new", @@ -384,17 +380,17 @@ func TestCoolifyClient_Rollback_Good(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") deployment, err := client.Rollback(context.Background(), "app-123", "dep-old") - assert.NoError(t, err) - assert.Equal(t, "dep-new", deployment.ID) - assert.Equal(t, "rolling_back", deployment.Status) + AssertNoError(t, err) + AssertEqual(t, "dep-new", deployment.ID) + AssertEqual(t, "rolling_back", deployment.Status) }) } -func TestCoolifyClient_GetApp_Good(t *testing.T) { - t.Run("gets app details", func(t *testing.T) { +func TestPHP_CoolifyClient_GetApp_Good(t *T) { + t.Run("gets app details", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123", r.URL.Path) - assert.Equal(t, "GET", r.Method) + AssertEqual(t, "/api/v1/applications/app-123", r.URL.Path) + AssertEqual(t, "GET", r.Method) resp := CoolifyApp{ ID: "app-123", @@ -409,28 +405,28 @@ func TestCoolifyClient_GetApp_Good(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") app, err := client.GetApp(context.Background(), "app-123") - assert.NoError(t, err) - assert.Equal(t, "app-123", app.ID) - assert.Equal(t, "MyApp", app.Name) - assert.Equal(t, "https://myapp.example.com", app.FQDN) + AssertNoError(t, err) + AssertEqual(t, "app-123", app.ID) + AssertEqual(t, "MyApp", app.Name) + AssertEqual(t, "https://myapp.example.com", app.FQDN) }) } -func TestCoolifyClient_SetHeaders(t *testing.T) { - t.Run("sets all required headers", func(t *testing.T) { +func TestCoolifyClient_SetHeaders(t *T) { + t.Run("sets all required headers", func(t *T) { client := NewCoolifyClient("https://coolify.example.com", "my-token") req, _ := http.NewRequest("GET", "https://coolify.example.com", nil) client.setHeaders(req) - assert.Equal(t, "Bearer my-token", req.Header.Get("Authorization")) - assert.Equal(t, "application/json", req.Header.Get("Content-Type")) - assert.Equal(t, "application/json", req.Header.Get("Accept")) + AssertEqual(t, "Bearer my-token", req.Header.Get("Authorization")) + AssertEqual(t, "application/json", req.Header.Get("Content-Type")) + AssertEqual(t, "application/json", req.Header.Get("Accept")) }) } -func TestCoolifyClient_ParseError(t *testing.T) { - t.Run("parses message field", func(t *testing.T) { +func TestCoolifyClient_ParseError(t *T) { + t.Run("parses message field", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]string{"message": "Bad request message"}) @@ -440,11 +436,11 @@ func TestCoolifyClient_ParseError(t *testing.T) { client := NewCoolifyClient(server.URL, "token") _, err := client.GetApp(context.Background(), "app-123") - assert.Error(t, err) - assert.Contains(t, err.Error(), "Bad request message") + AssertError(t, err) + AssertContains(t, err.Error(), "Bad request message") }) - t.Run("parses error field", func(t *testing.T) { + t.Run("parses error field", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]string{"error": "Error message"}) @@ -454,11 +450,11 @@ func TestCoolifyClient_ParseError(t *testing.T) { client := NewCoolifyClient(server.URL, "token") _, err := client.GetApp(context.Background(), "app-123") - assert.Error(t, err) - assert.Contains(t, err.Error(), "Error message") + AssertError(t, err) + AssertContains(t, err.Error(), "Error message") }) - t.Run("returns raw body when no JSON fields", func(t *testing.T) { + t.Run("returns raw body when no JSON fields", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("Raw error message")) @@ -468,19 +464,19 @@ func TestCoolifyClient_ParseError(t *testing.T) { client := NewCoolifyClient(server.URL, "token") _, err := client.GetApp(context.Background(), "app-123") - assert.Error(t, err) - assert.Contains(t, err.Error(), "Raw error message") + AssertError(t, err) + AssertContains(t, err.Error(), "Raw error message") }) } -func TestEnvironmentVariablePriority(t *testing.T) { - t.Run("env vars take precedence over .env file", func(t *testing.T) { +func TestEnvironmentVariablePriority(t *T) { + t.Run("env vars take precedence over .env file", func(t *T) { dir := t.TempDir() envContent := `COOLIFY_URL=https://from-file.com COOLIFY_TOKEN=file-token` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Set environment variables origURL := os.Getenv("COOLIFY_URL") @@ -494,9 +490,9 @@ COOLIFY_TOKEN=file-token` _ = os.Setenv("COOLIFY_TOKEN", "env-token") config, err := LoadCoolifyConfig(dir) - assert.NoError(t, err) + AssertNoError(t, err) // Environment variables should take precedence - assert.Equal(t, "https://from-env.com", config.URL) - assert.Equal(t, "env-token", config.Token) + AssertEqual(t, "https://from-env.com", config.URL) + AssertEqual(t, "env-token", config.Token) }) } diff --git a/pkg/php/core_assert_test.go b/pkg/php/core_assert_test.go new file mode 100644 index 0000000..a5f820c --- /dev/null +++ b/pkg/php/core_assert_test.go @@ -0,0 +1,23 @@ +package php + +import core "dappco.re/go" + +type T = core.T + +var ( + AnError = core.AnError + AssertContains = core.AssertContains + AssertEmpty = core.AssertEmpty + AssertEqual = core.AssertEqual + AssertError = core.AssertError + AssertFalse = core.AssertFalse + AssertGreater = core.AssertGreater + AssertGreaterOrEqual = core.AssertGreaterOrEqual + AssertLen = core.AssertLen + AssertNoError = core.AssertNoError + AssertNotContains = core.AssertNotContains + AssertNotEmpty = core.AssertNotEmpty + AssertNotNil = core.AssertNotNil + AssertTrue = core.AssertTrue + RequireNoError = core.RequireNoError +) diff --git a/pkg/php/deploy_internal_test.go b/pkg/php/deploy_internal_test.go index 9362aaf..cd263cf 100644 --- a/pkg/php/deploy_internal_test.go +++ b/pkg/php/deploy_internal_test.go @@ -1,14 +1,11 @@ package php import ( - "testing" "time" - - "github.com/stretchr/testify/assert" ) -func TestConvertDeployment_Good(t *testing.T) { - t.Run("converts all fields", func(t *testing.T) { +func TestPHP_ConvertDeployment_Good(t *T) { + t.Run("converts all fields", func(t *T) { now := time.Now() coolify := &CoolifyDeployment{ ID: "dep-123", @@ -24,28 +21,28 @@ func TestConvertDeployment_Good(t *testing.T) { status := convertDeployment(coolify) - assert.Equal(t, "dep-123", status.ID) - assert.Equal(t, "finished", status.Status) - assert.Equal(t, "https://app.example.com", status.URL) - assert.Equal(t, "abc123", status.Commit) - assert.Equal(t, "Test commit", status.CommitMessage) - assert.Equal(t, "main", status.Branch) - assert.Equal(t, now, status.StartedAt) - assert.Equal(t, now.Add(5*time.Minute), status.CompletedAt) - assert.Equal(t, "Build successful", status.Log) + AssertEqual(t, "dep-123", status.ID) + AssertEqual(t, "finished", status.Status) + AssertEqual(t, "https://app.example.com", status.URL) + AssertEqual(t, "abc123", status.Commit) + AssertEqual(t, "Test commit", status.CommitMessage) + AssertEqual(t, "main", status.Branch) + AssertEqual(t, now, status.StartedAt) + AssertEqual(t, now.Add(5*time.Minute), status.CompletedAt) + AssertEqual(t, "Build successful", status.Log) }) - t.Run("handles empty deployment", func(t *testing.T) { + t.Run("handles empty deployment", func(t *T) { coolify := &CoolifyDeployment{} status := convertDeployment(coolify) - assert.Empty(t, status.ID) - assert.Empty(t, status.Status) + AssertEmpty(t, status.ID) + AssertEmpty(t, status.Status) }) } -func TestDeploymentStatus_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_DeploymentStatus_Struct_Good(t *T) { + t.Run("all fields accessible", func(t *T) { now := time.Now() status := DeploymentStatus{ ID: "dep-123", @@ -59,18 +56,18 @@ func TestDeploymentStatus_Struct_Good(t *testing.T) { Log: "Build log", } - assert.Equal(t, "dep-123", status.ID) - assert.Equal(t, "finished", status.Status) - assert.Equal(t, "https://app.example.com", status.URL) - assert.Equal(t, "abc123", status.Commit) - assert.Equal(t, "Test commit", status.CommitMessage) - assert.Equal(t, "main", status.Branch) - assert.Equal(t, "Build log", status.Log) + AssertEqual(t, "dep-123", status.ID) + AssertEqual(t, "finished", status.Status) + AssertEqual(t, "https://app.example.com", status.URL) + AssertEqual(t, "abc123", status.Commit) + AssertEqual(t, "Test commit", status.CommitMessage) + AssertEqual(t, "main", status.Branch) + AssertEqual(t, "Build log", status.Log) }) } -func TestDeployOptions_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_DeployOptions_Struct_Good(t *T) { + t.Run("all fields accessible", func(t *T) { opts := DeployOptions{ Dir: "/project", Environment: EnvProduction, @@ -80,31 +77,31 @@ func TestDeployOptions_Struct_Good(t *testing.T) { PollInterval: 5 * time.Second, } - assert.Equal(t, "/project", opts.Dir) - assert.Equal(t, EnvProduction, opts.Environment) - assert.True(t, opts.Force) - assert.True(t, opts.Wait) - assert.Equal(t, 10*time.Minute, opts.WaitTimeout) - assert.Equal(t, 5*time.Second, opts.PollInterval) + AssertEqual(t, "/project", opts.Dir) + AssertEqual(t, EnvProduction, opts.Environment) + AssertTrue(t, opts.Force) + AssertTrue(t, opts.Wait) + AssertEqual(t, 10*time.Minute, opts.WaitTimeout) + AssertEqual(t, 5*time.Second, opts.PollInterval) }) } -func TestStatusOptions_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_StatusOptions_Struct_Good(t *T) { + t.Run("all fields accessible", func(t *T) { opts := StatusOptions{ Dir: "/project", Environment: EnvStaging, DeploymentID: "dep-123", } - assert.Equal(t, "/project", opts.Dir) - assert.Equal(t, EnvStaging, opts.Environment) - assert.Equal(t, "dep-123", opts.DeploymentID) + AssertEqual(t, "/project", opts.Dir) + AssertEqual(t, EnvStaging, opts.Environment) + AssertEqual(t, "dep-123", opts.DeploymentID) }) } -func TestRollbackOptions_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_RollbackOptions_Struct_Good(t *T) { + t.Run("all fields accessible", func(t *T) { opts := RollbackOptions{ Dir: "/project", Environment: EnvProduction, @@ -113,63 +110,63 @@ func TestRollbackOptions_Struct_Good(t *testing.T) { WaitTimeout: 5 * time.Minute, } - assert.Equal(t, "/project", opts.Dir) - assert.Equal(t, EnvProduction, opts.Environment) - assert.Equal(t, "dep-old", opts.DeploymentID) - assert.True(t, opts.Wait) - assert.Equal(t, 5*time.Minute, opts.WaitTimeout) + AssertEqual(t, "/project", opts.Dir) + AssertEqual(t, EnvProduction, opts.Environment) + AssertEqual(t, "dep-old", opts.DeploymentID) + AssertTrue(t, opts.Wait) + AssertEqual(t, 5*time.Minute, opts.WaitTimeout) }) } -func TestEnvironment_Constants(t *testing.T) { - t.Run("constants are defined", func(t *testing.T) { - assert.Equal(t, Environment("production"), EnvProduction) - assert.Equal(t, Environment("staging"), EnvStaging) +func TestEnvironment_Constants(t *T) { + t.Run("constants are defined", func(t *T) { + AssertEqual(t, Environment("production"), EnvProduction) + AssertEqual(t, Environment("staging"), EnvStaging) }) } -func TestGetAppIDForEnvironment_Edge(t *testing.T) { - t.Run("staging without staging ID falls back to production", func(t *testing.T) { +func TestPHP_GetAppIDForEnvironment_Ugly(t *T) { + t.Run("staging without staging ID falls back to production", func(t *T) { config := &CoolifyConfig{ AppID: "prod-123", // No StagingAppID set } id := getAppIDForEnvironment(config, EnvStaging) - assert.Equal(t, "prod-123", id) + AssertEqual(t, "prod-123", id) }) - t.Run("staging with staging ID uses staging", func(t *testing.T) { + t.Run("staging with staging ID uses staging", func(t *T) { config := &CoolifyConfig{ AppID: "prod-123", StagingAppID: "staging-456", } id := getAppIDForEnvironment(config, EnvStaging) - assert.Equal(t, "staging-456", id) + AssertEqual(t, "staging-456", id) }) - t.Run("production uses production ID", func(t *testing.T) { + t.Run("production uses production ID", func(t *T) { config := &CoolifyConfig{ AppID: "prod-123", StagingAppID: "staging-456", } id := getAppIDForEnvironment(config, EnvProduction) - assert.Equal(t, "prod-123", id) + AssertEqual(t, "prod-123", id) }) - t.Run("unknown environment uses production", func(t *testing.T) { + t.Run("unknown environment uses production", func(t *T) { config := &CoolifyConfig{ AppID: "prod-123", } id := getAppIDForEnvironment(config, "unknown") - assert.Equal(t, "prod-123", id) + AssertEqual(t, "prod-123", id) }) } -func TestIsDeploymentComplete_Edge(t *testing.T) { +func TestPHP_IsDeploymentComplete_Ugly(t *T) { tests := []struct { status string expected bool @@ -189,14 +186,14 @@ func TestIsDeploymentComplete_Edge(t *testing.T) { } for _, tt := range tests { - t.Run(tt.status, func(t *testing.T) { + t.Run(tt.status, func(t *T) { result := IsDeploymentComplete(tt.status) - assert.Equal(t, tt.expected, result) + AssertEqual(t, tt.expected, result) }) } } -func TestIsDeploymentSuccessful_Edge(t *testing.T) { +func TestPHP_IsDeploymentSuccessful_Ugly(t *T) { tests := []struct { status string expected bool @@ -213,9 +210,9 @@ func TestIsDeploymentSuccessful_Edge(t *testing.T) { } for _, tt := range tests { - t.Run(tt.status, func(t *testing.T) { + t.Run(tt.status, func(t *T) { result := IsDeploymentSuccessful(tt.status) - assert.Equal(t, tt.expected, result) + AssertEqual(t, tt.expected, result) }) } } diff --git a/pkg/php/deploy_test.go b/pkg/php/deploy_test.go index 228de7d..693139e 100644 --- a/pkg/php/deploy_test.go +++ b/pkg/php/deploy_test.go @@ -3,10 +3,9 @@ package php import ( "os" "path/filepath" - "testing" ) -func TestLoadCoolifyConfig_Good(t *testing.T) { +func TestPHP_LoadCoolifyConfig_Good(t *T) { tests := []struct { name string envContent string @@ -51,7 +50,7 @@ COOLIFY_APP_ID=app-123 } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name, func(t *T) { // Create temp directory dir := t.TempDir() envPath := filepath.Join(dir, ".env") @@ -83,7 +82,7 @@ COOLIFY_APP_ID=app-123 } } -func TestLoadCoolifyConfig_Bad(t *testing.T) { +func TestPHP_LoadCoolifyConfig_Bad(t *T) { tests := []struct { name string envContent string @@ -107,7 +106,7 @@ func TestLoadCoolifyConfig_Bad(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name, func(t *T) { // Create temp directory dir := t.TempDir() envPath := filepath.Join(dir, ".env") @@ -130,7 +129,7 @@ func TestLoadCoolifyConfig_Bad(t *testing.T) { } } -func TestGetAppIDForEnvironment_Good(t *testing.T) { +func TestPHP_GetAppIDForEnvironment_Good(t *T) { config := &CoolifyConfig{ URL: "https://coolify.example.com", Token: "token", @@ -161,7 +160,7 @@ func TestGetAppIDForEnvironment_Good(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name, func(t *T) { id := getAppIDForEnvironment(config, tt.env) if id != tt.wantID { t.Errorf("getAppIDForEnvironment() = %q, want %q", id, tt.wantID) @@ -170,7 +169,7 @@ func TestGetAppIDForEnvironment_Good(t *testing.T) { } } -func TestGetAppIDForEnvironment_FallbackToProduction(t *testing.T) { +func TestGetAppIDForEnvironment_FallbackToProduction(t *T) { config := &CoolifyConfig{ URL: "https://coolify.example.com", Token: "token", @@ -185,7 +184,7 @@ func TestGetAppIDForEnvironment_FallbackToProduction(t *testing.T) { } } -func TestIsDeploymentComplete_Good(t *testing.T) { +func TestPHP_IsDeploymentComplete_Good(t *T) { completeStatuses := []string{"finished", "success", "failed", "error", "cancelled"} for _, status := range completeStatuses { if !IsDeploymentComplete(status) { @@ -201,7 +200,7 @@ func TestIsDeploymentComplete_Good(t *testing.T) { } } -func TestIsDeploymentSuccessful_Good(t *testing.T) { +func TestPHP_IsDeploymentSuccessful_Good(t *T) { successStatuses := []string{"finished", "success"} for _, status := range successStatuses { if !IsDeploymentSuccessful(status) { @@ -217,7 +216,7 @@ func TestIsDeploymentSuccessful_Good(t *testing.T) { } } -func TestNewCoolifyClient_Good(t *testing.T) { +func TestPHP_NewCoolifyClient_Good(t *T) { tests := []struct { name string baseURL string @@ -241,7 +240,7 @@ func TestNewCoolifyClient_Good(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name, func(t *T) { client := NewCoolifyClient(tt.baseURL, "token") if client.BaseURL != tt.wantBaseURL { t.Errorf("BaseURL = %q, want %q", client.BaseURL, tt.wantBaseURL) diff --git a/pkg/php/detect_test.go b/pkg/php/detect_test.go index 9b72f84..3c21d95 100644 --- a/pkg/php/detect_test.go +++ b/pkg/php/detect_test.go @@ -3,20 +3,16 @@ package php import ( "os" "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestIsLaravelProject_Good(t *testing.T) { - t.Run("valid Laravel project with artisan and composer.json", func(t *testing.T) { +func TestPHP_IsLaravelProject_Good(t *T) { + t.Run("valid Laravel project with artisan and composer.json", func(t *T) { dir := t.TempDir() // Create artisan file artisanPath := filepath.Join(dir, "artisan") err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) + RequireNoError(t, err) // Create composer.json with laravel/framework composerJSON := `{ @@ -28,18 +24,18 @@ func TestIsLaravelProject_Good(t *testing.T) { }` composerPath := filepath.Join(dir, "composer.json") err = os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.True(t, IsLaravelProject(dir)) + AssertTrue(t, IsLaravelProject(dir)) }) - t.Run("Laravel in require-dev", func(t *testing.T) { + t.Run("Laravel in require-dev", func(t *T) { dir := t.TempDir() // Create artisan file artisanPath := filepath.Join(dir, "artisan") err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) + RequireNoError(t, err) // Create composer.json with laravel/framework in require-dev composerJSON := `{ @@ -50,14 +46,14 @@ func TestIsLaravelProject_Good(t *testing.T) { }` composerPath := filepath.Join(dir, "composer.json") err = os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.True(t, IsLaravelProject(dir)) + AssertTrue(t, IsLaravelProject(dir)) }) } -func TestIsLaravelProject_Bad(t *testing.T) { - t.Run("missing artisan file", func(t *testing.T) { +func TestPHP_IsLaravelProject_Bad(t *T) { + t.Run("missing artisan file", func(t *T) { dir := t.TempDir() // Create composer.json but no artisan @@ -69,29 +65,29 @@ func TestIsLaravelProject_Bad(t *testing.T) { }` composerPath := filepath.Join(dir, "composer.json") err := os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, IsLaravelProject(dir)) + AssertFalse(t, IsLaravelProject(dir)) }) - t.Run("missing composer.json", func(t *testing.T) { + t.Run("missing composer.json", func(t *T) { dir := t.TempDir() // Create artisan but no composer.json artisanPath := filepath.Join(dir, "artisan") err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, IsLaravelProject(dir)) + AssertFalse(t, IsLaravelProject(dir)) }) - t.Run("composer.json without Laravel", func(t *testing.T) { + t.Run("composer.json without Laravel", func(t *T) { dir := t.TempDir() // Create artisan file artisanPath := filepath.Join(dir, "artisan") err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) + RequireNoError(t, err) // Create composer.json without laravel/framework composerJSON := `{ @@ -102,39 +98,39 @@ func TestIsLaravelProject_Bad(t *testing.T) { }` composerPath := filepath.Join(dir, "composer.json") err = os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, IsLaravelProject(dir)) + AssertFalse(t, IsLaravelProject(dir)) }) - t.Run("invalid composer.json", func(t *testing.T) { + t.Run("invalid composer.json", func(t *T) { dir := t.TempDir() // Create artisan file artisanPath := filepath.Join(dir, "artisan") err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) + RequireNoError(t, err) // Create invalid composer.json composerPath := filepath.Join(dir, "composer.json") err = os.WriteFile(composerPath, []byte("not valid json{"), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, IsLaravelProject(dir)) + AssertFalse(t, IsLaravelProject(dir)) }) - t.Run("empty directory", func(t *testing.T) { + t.Run("empty directory", func(t *T) { dir := t.TempDir() - assert.False(t, IsLaravelProject(dir)) + AssertFalse(t, IsLaravelProject(dir)) }) - t.Run("non-existent directory", func(t *testing.T) { - assert.False(t, IsLaravelProject("/non/existent/path")) + t.Run("non-existent directory", func(t *T) { + AssertFalse(t, IsLaravelProject("/non/existent/path")) }) } -func TestIsFrankenPHPProject_Good(t *testing.T) { - t.Run("project with octane and frankenphp config", func(t *testing.T) { +func TestPHP_IsFrankenPHPProject_Good(t *T) { + t.Run("project with octane and frankenphp config", func(t *T) { dir := t.TempDir() // Create composer.json with laravel/octane @@ -144,24 +140,24 @@ func TestIsFrankenPHPProject_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Create config directory and octane.php configDir := filepath.Join(dir, "config") err = os.MkdirAll(configDir, 0755) - require.NoError(t, err) + RequireNoError(t, err) octaneConfig := ` 'frankenphp', ];` err = os.WriteFile(filepath.Join(configDir, "octane.php"), []byte(octaneConfig), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.True(t, IsFrankenPHPProject(dir)) + AssertTrue(t, IsFrankenPHPProject(dir)) }) - t.Run("project with octane but no config file", func(t *testing.T) { + t.Run("project with octane but no config file", func(t *T) { dir := t.TempDir() // Create composer.json with laravel/octane @@ -171,13 +167,13 @@ return [ } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) // No config file - should still return true (assume frankenphp) - assert.True(t, IsFrankenPHPProject(dir)) + AssertTrue(t, IsFrankenPHPProject(dir)) }) - t.Run("project with octane but unreadable config file", func(t *testing.T) { + t.Run("project with octane but unreadable config file", func(t *T) { if os.Geteuid() == 0 { t.Skip("root can read any file") } @@ -190,25 +186,25 @@ return [ } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Create config directory and octane.php with no read permissions configDir := filepath.Join(dir, "config") err = os.MkdirAll(configDir, 0755) - require.NoError(t, err) + RequireNoError(t, err) octanePath := filepath.Join(configDir, "octane.php") err = os.WriteFile(octanePath, []byte(" 'swoole', ];` err = os.WriteFile(filepath.Join(configDir, "octane.php"), []byte(octaneConfig), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, IsFrankenPHPProject(dir)) + AssertFalse(t, IsFrankenPHPProject(dir)) }) } diff --git a/pkg/php/dockerfile_test.go b/pkg/php/dockerfile_test.go index 5c3b1ce..1f20b3e 100644 --- a/pkg/php/dockerfile_test.go +++ b/pkg/php/dockerfile_test.go @@ -4,14 +4,10 @@ import ( "os" "path/filepath" "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestGenerateDockerfile_Good(t *testing.T) { - t.Run("basic Laravel project", func(t *testing.T) { +func TestPHP_GenerateDockerfile_Good(t *T) { + t.Run("basic Laravel project", func(t *T) { dir := t.TempDir() // Create composer.json @@ -23,24 +19,24 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Create composer.lock err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) content, err := GenerateDockerfile(dir) - require.NoError(t, err) + RequireNoError(t, err) // Check content - assert.Contains(t, content, "FROM dunglas/frankenphp") - assert.Contains(t, content, "php8.2") - assert.Contains(t, content, "COPY composer.json composer.lock") - assert.Contains(t, content, "composer install") - assert.Contains(t, content, "EXPOSE 80 443") + AssertContains(t, content, "FROM dunglas/frankenphp") + AssertContains(t, content, "php8.2") + AssertContains(t, content, "COPY composer.json composer.lock") + AssertContains(t, content, "composer install") + AssertContains(t, content, "EXPOSE 80 443") }) - t.Run("Laravel project with Octane", func(t *testing.T) { + t.Run("Laravel project with Octane", func(t *T) { dir := t.TempDir() composerJSON := `{ @@ -52,18 +48,18 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) content, err := GenerateDockerfile(dir) - require.NoError(t, err) + RequireNoError(t, err) - assert.Contains(t, content, "php8.3") - assert.Contains(t, content, "octane:start") + AssertContains(t, content, "php8.3") + AssertContains(t, content, "octane:start") }) - t.Run("project with frontend assets", func(t *testing.T) { + t.Run("project with frontend assets", func(t *T) { dir := t.TempDir() composerJSON := `{ @@ -74,9 +70,9 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) packageJSON := `{ "name": "test-app", @@ -86,21 +82,21 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) content, err := GenerateDockerfile(dir) - require.NoError(t, err) + RequireNoError(t, err) // Should have multi-stage build - assert.Contains(t, content, "FROM node:20-alpine AS frontend") - assert.Contains(t, content, "npm ci") - assert.Contains(t, content, "npm run build") - assert.Contains(t, content, "COPY --from=frontend") + AssertContains(t, content, "FROM node:20-alpine AS frontend") + AssertContains(t, content, "npm ci") + AssertContains(t, content, "npm run build") + AssertContains(t, content, "COPY --from=frontend") }) - t.Run("project with pnpm", func(t *testing.T) { + t.Run("project with pnpm", func(t *T) { dir := t.TempDir() composerJSON := `{ @@ -111,9 +107,9 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) packageJSON := `{ "name": "test-app", @@ -122,20 +118,20 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Create pnpm-lock.yaml err = os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte("lockfileVersion: 6.0"), 0644) - require.NoError(t, err) + RequireNoError(t, err) content, err := GenerateDockerfile(dir) - require.NoError(t, err) + RequireNoError(t, err) - assert.Contains(t, content, "pnpm install") - assert.Contains(t, content, "pnpm run build") + AssertContains(t, content, "pnpm install") + AssertContains(t, content, "pnpm run build") }) - t.Run("project with Redis dependency", func(t *testing.T) { + t.Run("project with Redis dependency", func(t *T) { dir := t.TempDir() composerJSON := `{ @@ -147,18 +143,18 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) content, err := GenerateDockerfile(dir) - require.NoError(t, err) + RequireNoError(t, err) - assert.Contains(t, content, "install-php-extensions") - assert.Contains(t, content, "redis") + AssertContains(t, content, "install-php-extensions") + AssertContains(t, content, "redis") }) - t.Run("project with explicit ext- requirements", func(t *testing.T) { + t.Run("project with explicit ext- requirements", func(t *T) { dir := t.TempDir() composerJSON := `{ @@ -171,42 +167,42 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) content, err := GenerateDockerfile(dir) - require.NoError(t, err) + RequireNoError(t, err) - assert.Contains(t, content, "install-php-extensions") - assert.Contains(t, content, "gd") - assert.Contains(t, content, "imagick") - assert.Contains(t, content, "intl") + AssertContains(t, content, "install-php-extensions") + AssertContains(t, content, "gd") + AssertContains(t, content, "imagick") + AssertContains(t, content, "intl") }) } -func TestGenerateDockerfile_Bad(t *testing.T) { - t.Run("missing composer.json", func(t *testing.T) { +func TestPHP_GenerateDockerfile_Bad(t *T) { + t.Run("missing composer.json", func(t *T) { dir := t.TempDir() _, err := GenerateDockerfile(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "composer.json") + AssertError(t, err) + AssertContains(t, err.Error(), "composer.json") }) - t.Run("invalid composer.json", func(t *testing.T) { + t.Run("invalid composer.json", func(t *T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644) - require.NoError(t, err) + RequireNoError(t, err) _, err = GenerateDockerfile(dir) - assert.Error(t, err) + AssertError(t, err) }) } -func TestDetectDockerfileConfig_Good(t *testing.T) { - t.Run("full Laravel project", func(t *testing.T) { +func TestPHP_DetectDockerfileConfig_Good(t *T) { + t.Run("full Laravel project", func(t *T) { dir := t.TempDir() composerJSON := `{ @@ -220,35 +216,35 @@ func TestDetectDockerfileConfig_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) packageJSON := `{"scripts": {"build": "vite build"}}` err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644) - require.NoError(t, err) + RequireNoError(t, err) config, err := DetectDockerfileConfig(dir) - require.NoError(t, err) + RequireNoError(t, err) - assert.Equal(t, "8.3", config.PHPVersion) - assert.True(t, config.IsLaravel) - assert.True(t, config.HasOctane) - assert.True(t, config.HasAssets) - assert.Equal(t, "yarn", config.PackageManager) - assert.Contains(t, config.PHPExtensions, "redis") - assert.Contains(t, config.PHPExtensions, "gd") + AssertEqual(t, "8.3", config.PHPVersion) + AssertTrue(t, config.IsLaravel) + AssertTrue(t, config.HasOctane) + AssertTrue(t, config.HasAssets) + AssertEqual(t, "yarn", config.PackageManager) + AssertContains(t, config.PHPExtensions, "redis") + AssertContains(t, config.PHPExtensions, "gd") }) } -func TestDetectDockerfileConfig_Bad(t *testing.T) { - t.Run("non-existent directory", func(t *testing.T) { +func TestPHP_DetectDockerfileConfig_Bad(t *T) { + t.Run("non-existent directory", func(t *T) { _, err := DetectDockerfileConfig("/non/existent/path") - assert.Error(t, err) + AssertError(t, err) }) } -func TestExtractPHPVersion_Good(t *testing.T) { +func TestPHP_ExtractPHPVersion_Good(t *T) { tests := []struct { constraint string expected string @@ -263,15 +259,15 @@ func TestExtractPHPVersion_Good(t *testing.T) { } for _, tt := range tests { - t.Run(tt.constraint, func(t *testing.T) { + t.Run(tt.constraint, func(t *T) { result := extractPHPVersion(tt.constraint) - assert.Equal(t, tt.expected, result) + AssertEqual(t, tt.expected, result) }) } } -func TestDetectPHPExtensions_Good(t *testing.T) { - t.Run("detects Redis from predis", func(t *testing.T) { +func TestPHP_DetectPHPExtensions_Good(t *T) { + t.Run("detects Redis from predis", func(t *T) { composer := ComposerJSON{ Require: map[string]string{ "predis/predis": "^2.0", @@ -279,10 +275,10 @@ func TestDetectPHPExtensions_Good(t *testing.T) { } extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "redis") + AssertContains(t, extensions, "redis") }) - t.Run("detects GD from intervention/image", func(t *testing.T) { + t.Run("detects GD from intervention/image", func(t *T) { composer := ComposerJSON{ Require: map[string]string{ "intervention/image": "^3.0", @@ -290,10 +286,10 @@ func TestDetectPHPExtensions_Good(t *testing.T) { } extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "gd") + AssertContains(t, extensions, "gd") }) - t.Run("detects multiple extensions from Laravel", func(t *testing.T) { + t.Run("detects multiple extensions from Laravel", func(t *T) { composer := ComposerJSON{ Require: map[string]string{ "laravel/framework": "^11.0", @@ -301,11 +297,11 @@ func TestDetectPHPExtensions_Good(t *testing.T) { } extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "pdo_mysql") - assert.Contains(t, extensions, "bcmath") + AssertContains(t, extensions, "pdo_mysql") + AssertContains(t, extensions, "bcmath") }) - t.Run("detects explicit ext- requirements", func(t *testing.T) { + t.Run("detects explicit ext- requirements", func(t *T) { composer := ComposerJSON{ Require: map[string]string{ "ext-gd": "*", @@ -314,11 +310,11 @@ func TestDetectPHPExtensions_Good(t *testing.T) { } extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "gd") - assert.Contains(t, extensions, "imagick") + AssertContains(t, extensions, "gd") + AssertContains(t, extensions, "imagick") }) - t.Run("skips built-in extensions", func(t *testing.T) { + t.Run("skips built-in extensions", func(t *T) { composer := ComposerJSON{ Require: map[string]string{ "ext-json": "*", @@ -328,12 +324,12 @@ func TestDetectPHPExtensions_Good(t *testing.T) { } extensions := detectPHPExtensions(composer) - assert.NotContains(t, extensions, "json") - assert.NotContains(t, extensions, "session") - assert.NotContains(t, extensions, "pdo") + AssertNotContains(t, extensions, "json") + AssertNotContains(t, extensions, "session") + AssertNotContains(t, extensions, "pdo") }) - t.Run("sorts extensions alphabetically", func(t *testing.T) { + t.Run("sorts extensions alphabetically", func(t *T) { composer := ComposerJSON{ Require: map[string]string{ "ext-zip": "*", @@ -346,14 +342,13 @@ func TestDetectPHPExtensions_Good(t *testing.T) { // Check they are sorted for i := 1; i < len(extensions); i++ { - assert.True(t, extensions[i-1] < extensions[i], - "extensions should be sorted: %v", extensions) + AssertTrue(t, extensions[i-1] < extensions[i], "extensions should be sorted") } }) } -func TestHasNodeAssets_Good(t *testing.T) { - t.Run("with build script", func(t *testing.T) { +func TestPHP_HasNodeAssets_Good(t *T) { + t.Run("with build script", func(t *T) { dir := t.TempDir() packageJSON := `{ @@ -364,19 +359,19 @@ func TestHasNodeAssets_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.True(t, hasNodeAssets(dir)) + AssertTrue(t, hasNodeAssets(dir)) }) } -func TestHasNodeAssets_Bad(t *testing.T) { - t.Run("no package.json", func(t *testing.T) { +func TestPHP_HasNodeAssets_Bad(t *T) { + t.Run("no package.json", func(t *T) { dir := t.TempDir() - assert.False(t, hasNodeAssets(dir)) + AssertFalse(t, hasNodeAssets(dir)) }) - t.Run("no build script", func(t *testing.T) { + t.Run("no build script", func(t *T) { dir := t.TempDir() packageJSON := `{ @@ -386,39 +381,39 @@ func TestHasNodeAssets_Bad(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, hasNodeAssets(dir)) + AssertFalse(t, hasNodeAssets(dir)) }) - t.Run("invalid package.json", func(t *testing.T) { + t.Run("invalid package.json", func(t *T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("invalid{"), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, hasNodeAssets(dir)) + AssertFalse(t, hasNodeAssets(dir)) }) } -func TestGenerateDockerignore_Good(t *testing.T) { - t.Run("generates complete dockerignore", func(t *testing.T) { +func TestPHP_GenerateDockerignore_Good(t *T) { + t.Run("generates complete dockerignore", func(t *T) { dir := t.TempDir() content := GenerateDockerignore(dir) // Check key entries - assert.Contains(t, content, ".git") - assert.Contains(t, content, "node_modules") - assert.Contains(t, content, ".env") - assert.Contains(t, content, "vendor") - assert.Contains(t, content, "storage/logs/*") - assert.Contains(t, content, ".idea") - assert.Contains(t, content, ".vscode") + AssertContains(t, content, ".git") + AssertContains(t, content, "node_modules") + AssertContains(t, content, ".env") + AssertContains(t, content, "vendor") + AssertContains(t, content, "storage/logs/*") + AssertContains(t, content, ".idea") + AssertContains(t, content, ".vscode") }) } -func TestGenerateDockerfileFromConfig_Good(t *testing.T) { - t.Run("minimal config", func(t *testing.T) { +func TestPHP_GenerateDockerfileFromConfig_Good(t *T) { + t.Run("minimal config", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", @@ -427,13 +422,13 @@ func TestGenerateDockerfileFromConfig_Good(t *testing.T) { content := GenerateDockerfileFromConfig(config) - assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3-alpine") - assert.Contains(t, content, "WORKDIR /app") - assert.Contains(t, content, "COPY composer.json composer.lock") - assert.Contains(t, content, "EXPOSE 80 443") + AssertContains(t, content, "FROM dunglas/frankenphp:latest-php8.3-alpine") + AssertContains(t, content, "WORKDIR /app") + AssertContains(t, content, "COPY composer.json composer.lock") + AssertContains(t, content, "EXPOSE 80 443") }) - t.Run("with extensions", func(t *testing.T) { + t.Run("with extensions", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", @@ -443,10 +438,10 @@ func TestGenerateDockerfileFromConfig_Good(t *testing.T) { content := GenerateDockerfileFromConfig(config) - assert.Contains(t, content, "install-php-extensions redis gd intl") + AssertContains(t, content, "install-php-extensions redis gd intl") }) - t.Run("Laravel with Octane", func(t *testing.T) { + t.Run("Laravel with Octane", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", @@ -457,14 +452,14 @@ func TestGenerateDockerfileFromConfig_Good(t *testing.T) { content := GenerateDockerfileFromConfig(config) - assert.Contains(t, content, "php artisan config:cache") - assert.Contains(t, content, "php artisan route:cache") - assert.Contains(t, content, "php artisan view:cache") - assert.Contains(t, content, "chown -R www-data:www-data storage") - assert.Contains(t, content, "octane:start") + AssertContains(t, content, "php artisan config:cache") + AssertContains(t, content, "php artisan route:cache") + AssertContains(t, content, "php artisan view:cache") + AssertContains(t, content, "chown -R www-data:www-data storage") + AssertContains(t, content, "octane:start") }) - t.Run("with frontend assets", func(t *testing.T) { + t.Run("with frontend assets", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", @@ -476,14 +471,14 @@ func TestGenerateDockerfileFromConfig_Good(t *testing.T) { content := GenerateDockerfileFromConfig(config) // Multi-stage build - assert.Contains(t, content, "FROM node:20-alpine AS frontend") - assert.Contains(t, content, "COPY package.json package-lock.json") - assert.Contains(t, content, "RUN npm ci") - assert.Contains(t, content, "RUN npm run build") - assert.Contains(t, content, "COPY --from=frontend /app/public/build public/build") + AssertContains(t, content, "FROM node:20-alpine AS frontend") + AssertContains(t, content, "COPY package.json package-lock.json") + AssertContains(t, content, "RUN npm ci") + AssertContains(t, content, "RUN npm run build") + AssertContains(t, content, "COPY --from=frontend /app/public/build public/build") }) - t.Run("with yarn", func(t *testing.T) { + t.Run("with yarn", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", @@ -494,12 +489,12 @@ func TestGenerateDockerfileFromConfig_Good(t *testing.T) { content := GenerateDockerfileFromConfig(config) - assert.Contains(t, content, "COPY package.json yarn.lock") - assert.Contains(t, content, "yarn install --frozen-lockfile") - assert.Contains(t, content, "yarn build") + AssertContains(t, content, "COPY package.json yarn.lock") + AssertContains(t, content, "yarn install --frozen-lockfile") + AssertContains(t, content, "yarn build") }) - t.Run("with bun", func(t *testing.T) { + t.Run("with bun", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", @@ -510,13 +505,13 @@ func TestGenerateDockerfileFromConfig_Good(t *testing.T) { content := GenerateDockerfileFromConfig(config) - assert.Contains(t, content, "npm install -g bun") - assert.Contains(t, content, "COPY package.json bun.lockb") - assert.Contains(t, content, "bun install --frozen-lockfile") - assert.Contains(t, content, "bun run build") + AssertContains(t, content, "npm install -g bun") + AssertContains(t, content, "COPY package.json bun.lockb") + AssertContains(t, content, "bun install --frozen-lockfile") + AssertContains(t, content, "bun run build") }) - t.Run("non-alpine image", func(t *testing.T) { + t.Run("non-alpine image", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", @@ -525,42 +520,42 @@ func TestGenerateDockerfileFromConfig_Good(t *testing.T) { content := GenerateDockerfileFromConfig(config) - assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3 AS app") - assert.NotContains(t, content, "alpine") + AssertContains(t, content, "FROM dunglas/frankenphp:latest-php8.3 AS app") + AssertNotContains(t, content, "alpine") }) } -func TestIsPHPProject_Good(t *testing.T) { - t.Run("project with composer.json", func(t *testing.T) { +func TestPHP_IsPHPProject_Good(t *T) { + t.Run("project with composer.json", func(t *T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.True(t, IsPHPProject(dir)) + AssertTrue(t, IsPHPProject(dir)) }) } -func TestIsPHPProject_Bad(t *testing.T) { - t.Run("project without composer.json", func(t *testing.T) { +func TestPHP_IsPHPProject_Bad(t *T) { + t.Run("project without composer.json", func(t *T) { dir := t.TempDir() - assert.False(t, IsPHPProject(dir)) + AssertFalse(t, IsPHPProject(dir)) }) - t.Run("non-existent directory", func(t *testing.T) { - assert.False(t, IsPHPProject("/non/existent/path")) + t.Run("non-existent directory", func(t *T) { + AssertFalse(t, IsPHPProject("/non/existent/path")) }) } -func TestExtractPHPVersion_Edge(t *testing.T) { - t.Run("handles single major version", func(t *testing.T) { +func TestPHP_ExtractPHPVersion_Ugly(t *T) { + t.Run("handles single major version", func(t *T) { result := extractPHPVersion("8") - assert.Equal(t, "8.0", result) + AssertEqual(t, "8.0", result) }) } -func TestDetectPHPExtensions_RequireDev(t *testing.T) { - t.Run("detects extensions from require-dev", func(t *testing.T) { +func TestDetectPHPExtensions_RequireDev(t *T) { + t.Run("detects extensions from require-dev", func(t *T) { composer := ComposerJSON{ RequireDev: map[string]string{ "predis/predis": "^2.0", @@ -568,12 +563,12 @@ func TestDetectPHPExtensions_RequireDev(t *testing.T) { } extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "redis") + AssertContains(t, extensions, "redis") }) } -func TestDockerfileStructure_Good(t *testing.T) { - t.Run("Dockerfile has proper structure", func(t *testing.T) { +func TestPHP_DockerfileStructure_Good(t *T) { + t.Run("Dockerfile has proper structure", func(t *T) { dir := t.TempDir() composerJSON := `{ @@ -586,18 +581,18 @@ func TestDockerfileStructure_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) packageJSON := `{"scripts": {"build": "vite build"}}` err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) content, err := GenerateDockerfile(dir) - require.NoError(t, err) + RequireNoError(t, err) lines := strings.Split(content, "\n") var fromCount, workdirCount, copyCount, runCount, exposeCount, cmdCount int @@ -622,13 +617,13 @@ func TestDockerfileStructure_Good(t *testing.T) { } // Multi-stage build should have 2 FROM statements - assert.Equal(t, 2, fromCount, "should have 2 FROM statements for multi-stage build") + AssertEqual(t, 2, fromCount, "should have 2 FROM statements for multi-stage build") // Should have proper structure - assert.GreaterOrEqual(t, workdirCount, 1, "should have WORKDIR") - assert.GreaterOrEqual(t, copyCount, 3, "should have multiple COPY statements") - assert.GreaterOrEqual(t, runCount, 2, "should have multiple RUN statements") - assert.Equal(t, 1, exposeCount, "should have exactly one EXPOSE") - assert.Equal(t, 1, cmdCount, "should have exactly one CMD") + AssertGreaterOrEqual(t, workdirCount, 1, "should have WORKDIR") + AssertGreaterOrEqual(t, copyCount, 3, "should have multiple COPY statements") + AssertGreaterOrEqual(t, runCount, 2, "should have multiple RUN statements") + AssertEqual(t, 1, exposeCount, "should have exactly one EXPOSE") + AssertEqual(t, 1, cmdCount, "should have exactly one CMD") }) } diff --git a/pkg/php/env.go b/pkg/php/env.go index 3e97da9..e3b0551 100644 --- a/pkg/php/env.go +++ b/pkg/php/env.go @@ -1,5 +1,3 @@ -//go:build cgo - package php import ( diff --git a/pkg/php/handler.go b/pkg/php/handler.go index 5dc0a85..65b75ef 100644 --- a/pkg/php/handler.go +++ b/pkg/php/handler.go @@ -1,4 +1,4 @@ -//go:build cgo +//go:build frankenphp // Package php provides FrankenPHP embedding for Go applications. // Serves a Laravel application via the FrankenPHP runtime, with support diff --git a/pkg/php/handler_stub.go b/pkg/php/handler_stub.go new file mode 100644 index 0000000..bf8af90 --- /dev/null +++ b/pkg/php/handler_stub.go @@ -0,0 +1,53 @@ +//go:build !frankenphp + +package php + +import ( + "fmt" + "net/http" + "path/filepath" +) + +// Handler implements http.Handler when the embedded FrankenPHP runtime is not built. +type Handler struct { + docRoot string + laravelRoot string +} + +// HandlerConfig configures the FrankenPHP handler. +type HandlerConfig struct { + NumThreads int + NumWorkers int + PHPIni map[string]string +} + +// NewHandler returns a handler placeholder unless built with -tags frankenphp. +func NewHandler(laravelRoot string, cfg HandlerConfig) (*Handler, func(), error) { + if cfg.NumThreads == 0 { + cfg.NumThreads = 4 + } + if cfg.NumWorkers == 0 { + cfg.NumWorkers = 2 + } + + handler := &Handler{ + docRoot: filepath.Join(laravelRoot, "public"), + laravelRoot: laravelRoot, + } + cleanup := func() {} + return handler, cleanup, fmt.Errorf("embedded FrankenPHP support is not built; rebuild with -tags frankenphp") +} + +// LaravelRoot returns the path to the extracted Laravel application. +func (h *Handler) LaravelRoot() string { + return h.laravelRoot +} + +// DocRoot returns the path to the document root (public/). +func (h *Handler) DocRoot() string { + return h.docRoot +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + http.Error(w, "embedded FrankenPHP support is not built", http.StatusNotImplemented) +} diff --git a/pkg/php/packages_test.go b/pkg/php/packages_test.go index a340a9b..3488f9f 100644 --- a/pkg/php/packages_test.go +++ b/pkg/php/packages_test.go @@ -4,14 +4,10 @@ import ( "encoding/json" "os" "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestReadComposerJSON_Good(t *testing.T) { - t.Run("reads valid composer.json", func(t *testing.T) { +func TestPHP_ReadComposerJSON_Good(t *T) { + t.Run("reads valid composer.json", func(t *T) { dir := t.TempDir() composerJSON := `{ "name": "test/project", @@ -20,15 +16,15 @@ func TestReadComposerJSON_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) raw, err := readComposerJSON(dir) - assert.NoError(t, err) - assert.NotNil(t, raw) - assert.Contains(t, string(raw["name"]), "test/project") + AssertNoError(t, err) + AssertNotNil(t, raw) + AssertContains(t, string(raw["name"]), "test/project") }) - t.Run("preserves all fields", func(t *testing.T) { + t.Run("preserves all fields", func(t *T) { dir := t.TempDir() composerJSON := `{ "name": "test/project", @@ -37,236 +33,236 @@ func TestReadComposerJSON_Good(t *testing.T) { "autoload": {"psr-4": {"App\\": "src/"}} }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) raw, err := readComposerJSON(dir) - assert.NoError(t, err) - assert.Contains(t, string(raw["autoload"]), "psr-4") + AssertNoError(t, err) + AssertContains(t, string(raw["autoload"]), "psr-4") }) } -func TestReadComposerJSON_Bad(t *testing.T) { - t.Run("missing composer.json", func(t *testing.T) { +func TestPHP_ReadComposerJSON_Bad(t *T) { + t.Run("missing composer.json", func(t *T) { dir := t.TempDir() _, err := readComposerJSON(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to read composer.json") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to read composer.json") }) - t.Run("invalid JSON", func(t *testing.T) { + t.Run("invalid JSON", func(t *T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644) - require.NoError(t, err) + RequireNoError(t, err) _, err = readComposerJSON(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to parse composer.json") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to parse composer.json") }) } -func TestWriteComposerJSON_Good(t *testing.T) { - t.Run("writes valid composer.json", func(t *testing.T) { +func TestPHP_WriteComposerJSON_Good(t *T) { + t.Run("writes valid composer.json", func(t *T) { dir := t.TempDir() raw := make(map[string]json.RawMessage) raw["name"] = json.RawMessage(`"test/project"`) err := writeComposerJSON(dir, raw) - assert.NoError(t, err) + AssertNoError(t, err) // Verify file was written content, err := os.ReadFile(filepath.Join(dir, "composer.json")) - assert.NoError(t, err) - assert.Contains(t, string(content), "test/project") + AssertNoError(t, err) + AssertContains(t, string(content), "test/project") // Verify trailing newline - assert.True(t, content[len(content)-1] == '\n') + AssertTrue(t, content[len(content)-1] == '\n') }) - t.Run("pretty prints with indentation", func(t *testing.T) { + t.Run("pretty prints with indentation", func(t *T) { dir := t.TempDir() raw := make(map[string]json.RawMessage) raw["name"] = json.RawMessage(`"test/project"`) raw["require"] = json.RawMessage(`{"php":"^8.2"}`) err := writeComposerJSON(dir, raw) - assert.NoError(t, err) + AssertNoError(t, err) content, err := os.ReadFile(filepath.Join(dir, "composer.json")) - assert.NoError(t, err) + AssertNoError(t, err) // Should be indented - assert.Contains(t, string(content), " ") + AssertContains(t, string(content), " ") }) } -func TestWriteComposerJSON_Bad(t *testing.T) { - t.Run("fails for non-existent directory", func(t *testing.T) { +func TestPHP_WriteComposerJSON_Bad(t *T) { + t.Run("fails for non-existent directory", func(t *T) { raw := make(map[string]json.RawMessage) raw["name"] = json.RawMessage(`"test/project"`) err := writeComposerJSON("/non/existent/path", raw) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to write composer.json") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to write composer.json") }) } -func TestGetRepositories_Good(t *testing.T) { - t.Run("returns empty slice when no repositories", func(t *testing.T) { +func TestPHP_GetRepositories_Good(t *T) { + t.Run("returns empty slice when no repositories", func(t *T) { raw := make(map[string]json.RawMessage) raw["name"] = json.RawMessage(`"test/project"`) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Empty(t, repos) + AssertNoError(t, err) + AssertEmpty(t, repos) }) - t.Run("parses existing repositories", func(t *testing.T) { + t.Run("parses existing repositories", func(t *T) { raw := make(map[string]json.RawMessage) raw["name"] = json.RawMessage(`"test/project"`) raw["repositories"] = json.RawMessage(`[{"type":"path","url":"/path/to/package"}]`) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 1) - assert.Equal(t, "path", repos[0].Type) - assert.Equal(t, "/path/to/package", repos[0].URL) + AssertNoError(t, err) + AssertLen(t, repos, 1) + AssertEqual(t, "path", repos[0].Type) + AssertEqual(t, "/path/to/package", repos[0].URL) }) - t.Run("parses repositories with options", func(t *testing.T) { + t.Run("parses repositories with options", func(t *T) { raw := make(map[string]json.RawMessage) raw["repositories"] = json.RawMessage(`[{"type":"path","url":"/path","options":{"symlink":true}}]`) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 1) - assert.NotNil(t, repos[0].Options) - assert.Equal(t, true, repos[0].Options["symlink"]) + AssertNoError(t, err) + AssertLen(t, repos, 1) + AssertNotNil(t, repos[0].Options) + AssertEqual(t, true, repos[0].Options["symlink"]) }) } -func TestGetRepositories_Bad(t *testing.T) { - t.Run("fails for invalid repositories JSON", func(t *testing.T) { +func TestPHP_GetRepositories_Bad(t *T) { + t.Run("fails for invalid repositories JSON", func(t *T) { raw := make(map[string]json.RawMessage) raw["repositories"] = json.RawMessage(`not valid json`) _, err := getRepositories(raw) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to parse repositories") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to parse repositories") }) } -func TestSetRepositories_Good(t *testing.T) { - t.Run("sets repositories", func(t *testing.T) { +func TestPHP_SetRepositories_Good(t *T) { + t.Run("sets repositories", func(t *T) { raw := make(map[string]json.RawMessage) repos := []composerRepository{ {Type: "path", URL: "/path/to/package"}, } err := setRepositories(raw, repos) - assert.NoError(t, err) - assert.Contains(t, string(raw["repositories"]), "/path/to/package") + AssertNoError(t, err) + AssertContains(t, string(raw["repositories"]), "/path/to/package") }) - t.Run("removes repositories key when empty", func(t *testing.T) { + t.Run("removes repositories key when empty", func(t *T) { raw := make(map[string]json.RawMessage) raw["repositories"] = json.RawMessage(`[{"type":"path"}]`) err := setRepositories(raw, []composerRepository{}) - assert.NoError(t, err) + AssertNoError(t, err) _, exists := raw["repositories"] - assert.False(t, exists) + AssertFalse(t, exists) }) } -func TestGetPackageInfo_Good(t *testing.T) { - t.Run("extracts package name and version", func(t *testing.T) { +func TestPHP_GetPackageInfo_Good(t *T) { + t.Run("extracts package name and version", func(t *T) { dir := t.TempDir() composerJSON := `{ "name": "vendor/package", "version": "1.0.0" }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) name, version, err := getPackageInfo(dir) - assert.NoError(t, err) - assert.Equal(t, "vendor/package", name) - assert.Equal(t, "1.0.0", version) + AssertNoError(t, err) + AssertEqual(t, "vendor/package", name) + AssertEqual(t, "1.0.0", version) }) - t.Run("works without version", func(t *testing.T) { + t.Run("works without version", func(t *T) { dir := t.TempDir() composerJSON := `{ "name": "vendor/package" }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) name, version, err := getPackageInfo(dir) - assert.NoError(t, err) - assert.Equal(t, "vendor/package", name) - assert.Equal(t, "", version) + AssertNoError(t, err) + AssertEqual(t, "vendor/package", name) + AssertEqual(t, "", version) }) } -func TestGetPackageInfo_Bad(t *testing.T) { - t.Run("missing composer.json", func(t *testing.T) { +func TestPHP_GetPackageInfo_Bad(t *T) { + t.Run("missing composer.json", func(t *T) { dir := t.TempDir() _, _, err := getPackageInfo(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to read package composer.json") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to read package composer.json") }) - t.Run("invalid JSON", func(t *testing.T) { + t.Run("invalid JSON", func(t *T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644) - require.NoError(t, err) + RequireNoError(t, err) _, _, err = getPackageInfo(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to parse package composer.json") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to parse package composer.json") }) - t.Run("missing name", func(t *testing.T) { + t.Run("missing name", func(t *T) { dir := t.TempDir() composerJSON := `{"version": "1.0.0"}` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) _, _, err = getPackageInfo(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "package name not found") + AssertError(t, err) + AssertContains(t, err.Error(), "package name not found") }) } -func TestLinkPackages_Good(t *testing.T) { - t.Run("links a package", func(t *testing.T) { +func TestPHP_LinkPackages_Good(t *T) { + t.Run("links a package", func(t *T) { // Create project directory projectDir := t.TempDir() err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Create package directory packageDir := t.TempDir() err = os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = LinkPackages(projectDir, []string{packageDir}) - assert.NoError(t, err) + AssertNoError(t, err) // Verify repository was added raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) + AssertNoError(t, err) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 1) - assert.Equal(t, "path", repos[0].Type) + AssertNoError(t, err) + AssertLen(t, repos, 1) + AssertEqual(t, "path", repos[0].Type) }) - t.Run("skips already linked package", func(t *testing.T) { + t.Run("skips already linked package", func(t *T) { // Create project with existing repository projectDir := t.TempDir() packageDir := t.TempDir() err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) absPackagePath, _ := filepath.Abs(packageDir) composerJSON := `{ @@ -274,72 +270,72 @@ func TestLinkPackages_Good(t *testing.T) { "repositories": [{"type":"path","url":"` + absPackagePath + `"}] }` err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Link again - should not add duplicate err = LinkPackages(projectDir, []string{packageDir}) - assert.NoError(t, err) + AssertNoError(t, err) raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) + AssertNoError(t, err) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 1) // Still only one + AssertNoError(t, err) + AssertLen(t, repos, 1) // Still only one }) - t.Run("links multiple packages", func(t *testing.T) { + t.Run("links multiple packages", func(t *T) { projectDir := t.TempDir() err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) pkg1Dir := t.TempDir() err = os.WriteFile(filepath.Join(pkg1Dir, "composer.json"), []byte(`{"name":"vendor/pkg1"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) pkg2Dir := t.TempDir() err = os.WriteFile(filepath.Join(pkg2Dir, "composer.json"), []byte(`{"name":"vendor/pkg2"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = LinkPackages(projectDir, []string{pkg1Dir, pkg2Dir}) - assert.NoError(t, err) + AssertNoError(t, err) raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) + AssertNoError(t, err) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 2) + AssertNoError(t, err) + AssertLen(t, repos, 2) }) } -func TestLinkPackages_Bad(t *testing.T) { - t.Run("fails for non-PHP project", func(t *testing.T) { +func TestPHP_LinkPackages_Bad(t *T) { + t.Run("fails for non-PHP project", func(t *T) { dir := t.TempDir() err := LinkPackages(dir, []string{"/path/to/package"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") + AssertError(t, err) + AssertContains(t, err.Error(), "not a PHP project") }) - t.Run("fails for non-PHP package", func(t *testing.T) { + t.Run("fails for non-PHP package", func(t *T) { projectDir := t.TempDir() err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) packageDir := t.TempDir() // No composer.json in package err = LinkPackages(projectDir, []string{packageDir}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP package") + AssertError(t, err) + AssertContains(t, err.Error(), "not a PHP package") }) } -func TestUnlinkPackages_Good(t *testing.T) { - t.Run("unlinks package by name", func(t *testing.T) { +func TestPHP_UnlinkPackages_Good(t *T) { + t.Run("unlinks package by name", func(t *T) { projectDir := t.TempDir() packageDir := t.TempDir() err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) absPackagePath, _ := filepath.Abs(packageDir) composerJSON := `{ @@ -347,19 +343,19 @@ func TestUnlinkPackages_Good(t *testing.T) { "repositories": [{"type":"path","url":"` + absPackagePath + `"}] }` err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = UnlinkPackages(projectDir, []string{"vendor/package"}) - assert.NoError(t, err) + AssertNoError(t, err) raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) + AssertNoError(t, err) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 0) + AssertNoError(t, err) + AssertLen(t, repos, 0) }) - t.Run("unlinks package by path", func(t *testing.T) { + t.Run("unlinks package by path", func(t *T) { projectDir := t.TempDir() packageDir := t.TempDir() @@ -369,19 +365,19 @@ func TestUnlinkPackages_Good(t *testing.T) { "repositories": [{"type":"path","url":"` + absPackagePath + `"}] }` err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = UnlinkPackages(projectDir, []string{absPackagePath}) - assert.NoError(t, err) + AssertNoError(t, err) raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) + AssertNoError(t, err) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 0) + AssertNoError(t, err) + AssertLen(t, repos, 0) }) - t.Run("keeps non-path repositories", func(t *testing.T) { + t.Run("keeps non-path repositories", func(t *T) { projectDir := t.TempDir() composerJSON := `{ "name": "test/project", @@ -391,36 +387,36 @@ func TestUnlinkPackages_Good(t *testing.T) { ] }` err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = UnlinkPackages(projectDir, []string{"/local/path"}) - assert.NoError(t, err) + AssertNoError(t, err) raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) + AssertNoError(t, err) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 1) - assert.Equal(t, "vcs", repos[0].Type) + AssertNoError(t, err) + AssertLen(t, repos, 1) + AssertEqual(t, "vcs", repos[0].Type) }) } -func TestUnlinkPackages_Bad(t *testing.T) { - t.Run("fails for non-PHP project", func(t *testing.T) { +func TestPHP_UnlinkPackages_Bad(t *T) { + t.Run("fails for non-PHP project", func(t *T) { dir := t.TempDir() err := UnlinkPackages(dir, []string{"vendor/package"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") + AssertError(t, err) + AssertContains(t, err.Error(), "not a PHP project") }) } -func TestListLinkedPackages_Good(t *testing.T) { - t.Run("lists linked packages", func(t *testing.T) { +func TestPHP_ListLinkedPackages_Good(t *T) { + t.Run("lists linked packages", func(t *T) { projectDir := t.TempDir() packageDir := t.TempDir() err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package","version":"1.0.0"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) absPackagePath, _ := filepath.Abs(packageDir) composerJSON := `{ @@ -428,42 +424,42 @@ func TestListLinkedPackages_Good(t *testing.T) { "repositories": [{"type":"path","url":"` + absPackagePath + `"}] }` err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) linked, err := ListLinkedPackages(projectDir) - assert.NoError(t, err) - assert.Len(t, linked, 1) - assert.Equal(t, "vendor/package", linked[0].Name) - assert.Equal(t, "1.0.0", linked[0].Version) - assert.Equal(t, absPackagePath, linked[0].Path) + AssertNoError(t, err) + AssertLen(t, linked, 1) + AssertEqual(t, "vendor/package", linked[0].Name) + AssertEqual(t, "1.0.0", linked[0].Version) + AssertEqual(t, absPackagePath, linked[0].Path) }) - t.Run("returns empty list when no linked packages", func(t *testing.T) { + t.Run("returns empty list when no linked packages", func(t *T) { projectDir := t.TempDir() err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) linked, err := ListLinkedPackages(projectDir) - assert.NoError(t, err) - assert.Empty(t, linked) + AssertNoError(t, err) + AssertEmpty(t, linked) }) - t.Run("uses basename when package info unavailable", func(t *testing.T) { + t.Run("uses basename when package info unavailable", func(t *T) { projectDir := t.TempDir() composerJSON := `{ "name": "test/project", "repositories": [{"type":"path","url":"/nonexistent/package-name"}] }` err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) linked, err := ListLinkedPackages(projectDir) - assert.NoError(t, err) - assert.Len(t, linked, 1) - assert.Equal(t, "package-name", linked[0].Name) + AssertNoError(t, err) + AssertLen(t, linked, 1) + AssertEqual(t, "package-name", linked[0].Name) }) - t.Run("ignores non-path repositories", func(t *testing.T) { + t.Run("ignores non-path repositories", func(t *T) { projectDir := t.TempDir() composerJSON := `{ "name": "test/project", @@ -472,39 +468,39 @@ func TestListLinkedPackages_Good(t *testing.T) { ] }` err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) linked, err := ListLinkedPackages(projectDir) - assert.NoError(t, err) - assert.Empty(t, linked) + AssertNoError(t, err) + AssertEmpty(t, linked) }) } -func TestListLinkedPackages_Bad(t *testing.T) { - t.Run("fails for non-PHP project", func(t *testing.T) { +func TestPHP_ListLinkedPackages_Bad(t *T) { + t.Run("fails for non-PHP project", func(t *T) { dir := t.TempDir() _, err := ListLinkedPackages(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") + AssertError(t, err) + AssertContains(t, err.Error(), "not a PHP project") }) } -func TestUpdatePackages_Bad(t *testing.T) { - t.Run("fails for non-PHP project", func(t *testing.T) { +func TestPHP_UpdatePackages_Bad(t *T) { + t.Run("fails for non-PHP project", func(t *T) { dir := t.TempDir() err := UpdatePackages(dir, []string{"vendor/package"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") + AssertError(t, err) + AssertContains(t, err.Error(), "not a PHP project") }) } -func TestUpdatePackages_Good(t *testing.T) { +func TestPHP_UpdatePackages_Good(t *T) { t.Skip("requires Composer installed") - t.Run("runs composer update", func(t *testing.T) { + t.Run("runs composer update", func(t *T) { projectDir := t.TempDir() err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) _ = UpdatePackages(projectDir, []string{"vendor/package"}) // This will fail because composer update needs real dependencies @@ -512,22 +508,22 @@ func TestUpdatePackages_Good(t *testing.T) { }) } -func TestLinkedPackage_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestLinkedPackage_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { pkg := LinkedPackage{ Name: "vendor/package", Path: "/path/to/package", Version: "1.0.0", } - assert.Equal(t, "vendor/package", pkg.Name) - assert.Equal(t, "/path/to/package", pkg.Path) - assert.Equal(t, "1.0.0", pkg.Version) + AssertEqual(t, "vendor/package", pkg.Name) + AssertEqual(t, "/path/to/package", pkg.Path) + AssertEqual(t, "1.0.0", pkg.Version) }) } -func TestComposerRepository_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestComposerRepository_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { repo := composerRepository{ Type: "path", URL: "/path/to/package", @@ -536,8 +532,8 @@ func TestComposerRepository_Struct(t *testing.T) { }, } - assert.Equal(t, "path", repo.Type) - assert.Equal(t, "/path/to/package", repo.URL) - assert.Equal(t, true, repo.Options["symlink"]) + AssertEqual(t, "path", repo.Type) + AssertEqual(t, "/path/to/package", repo.URL) + AssertEqual(t, true, repo.Options["symlink"]) }) } diff --git a/pkg/php/php.go b/pkg/php/php.go index 7f475f3..dcedd35 100644 --- a/pkg/php/php.go +++ b/pkg/php/php.go @@ -195,7 +195,9 @@ func (d *DevServer) Start(ctx context.Context, opts Options) error { if len(startErrors) > 0 { // Stop any services that did start for _, svc := range d.services { - _ = svc.Stop() + if err := svc.Stop(); err != nil { + startErrors = append(startErrors, cli.Err("cleanup %s: %v", svc.Name(), err)) + } } return cli.Err("failed to start services: %v", startErrors) } @@ -295,8 +297,14 @@ func (d *DevServer) unifiedLogs(follow bool) (io.ReadCloser, error) { reader, err := svc.Logs(follow) if err != nil { // Close any readers we already opened + var closeErrors []error for _, r := range readers { - _ = r.Close() + if closeErr := r.Close(); closeErr != nil { + closeErrors = append(closeErrors, closeErr) + } + } + if len(closeErrors) > 0 { + return nil, cli.Err("failed to get logs for %s: %v; failed to close readers: %v", svc.Name(), err, closeErrors) } return nil, cli.Err("failed to get logs for %s: %v", svc.Name(), err) } diff --git a/pkg/php/php_test.go b/pkg/php/php_test.go index e295d73..213ad87 100644 --- a/pkg/php/php_test.go +++ b/pkg/php/php_test.go @@ -6,24 +6,20 @@ import ( "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) { +func TestPHP_NewDevServer_Good(t *T) { + t.Run("creates dev server with default options", func(t *T) { opts := Options{} server := NewDevServer(opts) - assert.NotNil(t, server) - assert.Empty(t, server.services) - assert.False(t, server.running) + AssertNotNil(t, server) + AssertEmpty(t, server.services) + AssertFalse(t, server.running) }) - t.Run("creates dev server with custom options", func(t *testing.T) { + t.Run("creates dev server with custom options", func(t *T) { opts := Options{ Dir: "/tmp/test", NoVite: true, @@ -32,74 +28,74 @@ func TestNewDevServer_Good(t *testing.T) { } server := NewDevServer(opts) - assert.NotNil(t, server) - assert.Equal(t, "/tmp/test", server.opts.Dir) - assert.True(t, server.opts.NoVite) + AssertNotNil(t, server) + AssertEqual(t, "/tmp/test", server.opts.Dir) + AssertTrue(t, server.opts.NoVite) }) } -func TestDevServer_IsRunning_Good(t *testing.T) { - t.Run("returns false when not running", func(t *testing.T) { +func TestPHP_DevServer_IsRunning_Good(t *T) { + t.Run("returns false when not running", func(t *T) { server := NewDevServer(Options{}) - assert.False(t, server.IsRunning()) + AssertFalse(t, server.IsRunning()) }) } -func TestDevServer_Status_Good(t *testing.T) { - t.Run("returns empty status when no services", func(t *testing.T) { +func TestPHP_DevServer_Status_Good(t *T) { + t.Run("returns empty status when no services", func(t *T) { server := NewDevServer(Options{}) statuses := server.Status() - assert.Empty(t, statuses) + AssertEmpty(t, statuses) }) } -func TestDevServer_Services_Good(t *testing.T) { - t.Run("returns empty services list initially", func(t *testing.T) { +func TestPHP_DevServer_Services_Good(t *T) { + t.Run("returns empty services list initially", func(t *T) { server := NewDevServer(Options{}) services := server.Services() - assert.Empty(t, services) + AssertEmpty(t, services) }) } -func TestDevServer_Stop_Good(t *testing.T) { - t.Run("returns nil when not running", func(t *testing.T) { +func TestPHP_DevServer_Stop_Good(t *T) { + t.Run("returns nil when not running", func(t *T) { server := NewDevServer(Options{}) err := server.Stop() - assert.NoError(t, err) + AssertNoError(t, err) }) } -func TestDevServer_Start_Bad(t *testing.T) { - t.Run("fails when already running", func(t *testing.T) { +func TestPHP_DevServer_Start_Bad(t *T) { + t.Run("fails when already running", func(t *T) { server := NewDevServer(Options{}) server.running = true err := server.Start(context.Background(), Options{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "already running") + AssertError(t, err) + AssertContains(t, err.Error(), "already running") }) - t.Run("fails for non-Laravel project", func(t *testing.T) { + t.Run("fails for non-Laravel project", func(t *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") + AssertError(t, err) + AssertContains(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) { +func TestPHP_DevServer_Logs_Bad(t *T) { + t.Run("fails for non-existent service", func(t *T) { server := NewDevServer(Options{}) _, err := server.Logs("nonexistent", false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "service not found") + AssertError(t, err) + AssertContains(t, err.Error(), "service not found") }) } -func TestDevServer_filterServices_Good(t *testing.T) { +func TestPHP_DevServer_filterServices_Good(t *T) { tests := []struct { name string services []DetectedService @@ -151,25 +147,25 @@ func TestDevServer_filterServices_Good(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name, func(t *T) { server := NewDevServer(Options{}) result := server.filterServices(tt.services, tt.opts) - assert.Equal(t, tt.expected, result) + AssertEqual(t, tt.expected, result) }) } } -func TestMultiServiceReader_Good(t *testing.T) { - t.Run("closes all readers on Close", func(t *testing.T) { +func TestPHP_MultiServiceReader_Good(t *T) { + t.Run("closes all readers on Close", func(t *T) { // Create mock readers using files dir := t.TempDir() file1, err := os.CreateTemp(dir, "log1-*.log") - require.NoError(t, err) + RequireNoError(t, err) _, _ = file1.WriteString("test1") _, _ = file1.Seek(0, 0) file2, err := os.CreateTemp(dir, "log2-*.log") - require.NoError(t, err) + RequireNoError(t, err) _, _ = file2.WriteString("test2") _, _ = file2.Seek(0, 0) @@ -181,27 +177,27 @@ func TestMultiServiceReader_Good(t *testing.T) { readers := []io.ReadCloser{file1, file2} reader := newMultiServiceReader(services, readers, false) - assert.NotNil(t, reader) + AssertNotNil(t, reader) err = reader.Close() - assert.NoError(t, err) - assert.True(t, reader.closed) + AssertNoError(t, err) + AssertTrue(t, reader.closed) }) - t.Run("returns EOF when closed", func(t *testing.T) { + t.Run("returns EOF when closed", func(t *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) + AssertEqual(t, 0, n) + AssertEqual(t, io.EOF, err) }) } -func TestMultiServiceReader_Read_Good(t *testing.T) { - t.Run("reads from readers with service prefix", func(t *testing.T) { +func TestPHP_MultiServiceReader_Read_Good(t *T) { + t.Run("reads from readers with service prefix", func(t *T) { dir := t.TempDir() file1, err := os.CreateTemp(dir, "log-*.log") - require.NoError(t, err) + RequireNoError(t, err) _, _ = file1.WriteString("log content") _, _ = file1.Seek(0, 0) @@ -214,20 +210,20 @@ func TestMultiServiceReader_Read_Good(t *testing.T) { buf := make([]byte, 100) n, err := reader.Read(buf) - assert.NoError(t, err) - assert.Greater(t, n, 0) + AssertNoError(t, err) + AssertGreater(t, n, 0) result := string(buf[:n]) - assert.Contains(t, result, "[TestService]") + AssertContains(t, result, "[TestService]") }) - t.Run("returns EOF when all readers are exhausted in non-follow mode", func(t *testing.T) { + t.Run("returns EOF when all readers are exhausted in non-follow mode", func(t *T) { dir := t.TempDir() file1, err := os.CreateTemp(dir, "log-*.log") - require.NoError(t, err) + RequireNoError(t, err) _ = file1.Close() // Empty file file1, err = os.Open(file1.Name()) - require.NoError(t, err) + RequireNoError(t, err) services := []Service{ &FrankenPHPService{baseService: baseService{name: "TestService"}}, @@ -238,13 +234,13 @@ func TestMultiServiceReader_Read_Good(t *testing.T) { buf := make([]byte, 100) n, err := reader.Read(buf) - assert.Equal(t, 0, n) - assert.Equal(t, io.EOF, err) + AssertEqual(t, 0, n) + AssertEqual(t, io.EOF, err) }) } -func TestOptions_Good(t *testing.T) { - t.Run("all fields are accessible", func(t *testing.T) { +func TestPHP_Options_Good(t *T) { + t.Run("all fields are accessible", func(t *T) { opts := Options{ Dir: "/test", Services: []DetectedService{ServiceFrankenPHP}, @@ -261,23 +257,23 @@ func TestOptions_Good(t *testing.T) { 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) + AssertEqual(t, "/test", opts.Dir) + AssertEqual(t, []DetectedService{ServiceFrankenPHP}, opts.Services) + AssertTrue(t, opts.NoVite) + AssertTrue(t, opts.NoHorizon) + AssertTrue(t, opts.NoReverb) + AssertTrue(t, opts.NoRedis) + AssertTrue(t, opts.HTTPS) + AssertEqual(t, "test.local", opts.Domain) + AssertEqual(t, 8000, opts.FrankenPHPPort) + AssertEqual(t, 443, opts.HTTPSPort) + AssertEqual(t, 5173, opts.VitePort) + AssertEqual(t, 8080, opts.ReverbPort) + AssertEqual(t, 6379, opts.RedisPort) }) } -func TestDevServer_StartStop_Integration(t *testing.T) { +func TestDevServer_StartStop_Integration(t *T) { t.Skip("requires PHP/FrankenPHP installed") dir := t.TempDir() @@ -288,21 +284,21 @@ func TestDevServer_StartStop_Integration(t *testing.T) { defer cancel() err := server.Start(ctx, Options{Dir: dir}) - require.NoError(t, err) - assert.True(t, server.IsRunning()) + RequireNoError(t, err) + AssertTrue(t, server.IsRunning()) err = server.Stop() - require.NoError(t, err) - assert.False(t, server.IsRunning()) + RequireNoError(t, err) + AssertFalse(t, server.IsRunning()) } // setupLaravelProject creates a minimal Laravel project structure for testing. -func setupLaravelProject(t *testing.T, dir string) { +func setupLaravelProject(t *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) + RequireNoError(t, err) // Create composer.json with Laravel composerJSON := `{ @@ -314,11 +310,11 @@ func setupLaravelProject(t *testing.T, dir string) { } }` err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) } -func TestDevServer_UnifiedLogs_Bad(t *testing.T) { - t.Run("returns error when service logs fail", func(t *testing.T) { +func TestPHP_DevServer_UnifiedLogs_Bad(t *T) { + t.Run("returns error when service logs fail", func(t *T) { server := NewDevServer(Options{}) // Create a mock service that will fail to provide logs @@ -331,17 +327,17 @@ func TestDevServer_UnifiedLogs_Bad(t *testing.T) { server.services = []Service{mockService} _, err := server.Logs("", false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to get logs") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to get logs") }) } -func TestDevServer_Logs_Good(t *testing.T) { - t.Run("finds specific service logs", func(t *testing.T) { +func TestPHP_DevServer_Logs_Good(t *T) { + t.Run("finds specific service logs", func(t *T) { dir := t.TempDir() logFile := filepath.Join(dir, "test.log") err := os.WriteFile(logFile, []byte("test log content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) server := NewDevServer(Options{}) mockService := &FrankenPHPService{ @@ -353,43 +349,43 @@ func TestDevServer_Logs_Good(t *testing.T) { server.services = []Service{mockService} reader, err := server.Logs("TestService", false) - assert.NoError(t, err) - assert.NotNil(t, reader) + AssertNoError(t, err) + AssertNotNil(t, reader) _ = reader.Close() }) } -func TestDevServer_MergeOptions_Good(t *testing.T) { - t.Run("start merges options correctly", func(t *testing.T) { +func TestPHP_DevServer_MergeOptions_Good(t *T) { + t.Run("start merges options correctly", func(t *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 + AssertError(t, err) // Will fail because not Laravel project // But the directory should have been merged - assert.Equal(t, dir, server.opts.Dir) + AssertEqual(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 TestDetectedService_Constants(t *T) { + t.Run("all service constants are defined", func(t *T) { + AssertEqual(t, DetectedService("frankenphp"), ServiceFrankenPHP) + AssertEqual(t, DetectedService("vite"), ServiceVite) + AssertEqual(t, DetectedService("horizon"), ServiceHorizon) + AssertEqual(t, DetectedService("reverb"), ServiceReverb) + AssertEqual(t, DetectedService("redis"), ServiceRedis) }) } -func TestDevServer_HTTPSSetup(t *testing.T) { - t.Run("extracts domain from APP_URL when HTTPS enabled", func(t *testing.T) { +func TestDevServer_HTTPSSetup(t *T) { + t.Run("extracts domain from APP_URL when HTTPS enabled", func(t *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) + RequireNoError(t, err) composerJSON := `{ "require": { @@ -398,45 +394,45 @@ func TestDevServer_HTTPSSetup(t *testing.T) { } }` err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(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) + RequireNoError(t, err) // Verify we can extract the domain url := GetLaravelAppURL(dir) domain := ExtractDomainFromURL(url) - assert.Equal(t, "myapp.test", domain) + AssertEqual(t, "myapp.test", domain) }) } -func TestDevServer_PortDefaults(t *testing.T) { - t.Run("uses default ports when not specified", func(t *testing.T) { +func TestDevServer_PortDefaults(t *T) { + t.Run("uses default ports when not specified", func(t *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) + AssertEqual(t, 8000, svc.port) // Vite default port is 5173 vite := NewViteService("/tmp", ViteOptions{}) - assert.Equal(t, 5173, vite.port) + AssertEqual(t, 5173, vite.port) // Reverb default port is 8080 reverb := NewReverbService("/tmp", ReverbOptions{}) - assert.Equal(t, 8080, reverb.port) + AssertEqual(t, 8080, reverb.port) // Redis default port is 6379 redis := NewRedisService("/tmp", RedisOptions{}) - assert.Equal(t, 6379, redis.port) + AssertEqual(t, 6379, redis.port) }) } -func TestDevServer_ServiceCreation(t *testing.T) { - t.Run("creates correct services based on detected services", func(t *testing.T) { +func TestDevServer_ServiceCreation(t *T) { + t.Run("creates correct services based on detected services", func(t *T) { // Test that the switch statement in Start() creates the right service types services := []DetectedService{ ServiceFrankenPHP, @@ -449,24 +445,24 @@ func TestDevServer_ServiceCreation(t *testing.T) { // Verify each service type string expected := []string{"frankenphp", "vite", "horizon", "reverb", "redis"} for i, svc := range services { - assert.Equal(t, expected[i], string(svc)) + AssertEqual(t, expected[i], string(svc)) } }) } -func TestMultiServiceReader_CloseError(t *testing.T) { - t.Run("returns first close error", func(t *testing.T) { +func TestMultiServiceReader_CloseError(t *T) { + t.Run("returns first close error", func(t *T) { dir := t.TempDir() // Create a real file that we can close file1, err := os.CreateTemp(dir, "log-*.log") - require.NoError(t, err) + RequireNoError(t, err) file1Name := file1.Name() _ = file1.Close() // Reopen for reading file1, err = os.Open(file1Name) - require.NoError(t, err) + RequireNoError(t, err) services := []Service{ &FrankenPHPService{baseService: baseService{name: "svc1"}}, @@ -475,25 +471,25 @@ func TestMultiServiceReader_CloseError(t *testing.T) { reader := newMultiServiceReader(services, readers, false) err = reader.Close() - assert.NoError(t, err) + AssertNoError(t, err) // Second close should still work (files already closed) // The closed flag prevents double-processing - assert.True(t, reader.closed) + AssertTrue(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) { +func TestMultiServiceReader_FollowMode(t *T) { + t.Run("returns 0 bytes without error in follow mode when no data", func(t *T) { dir := t.TempDir() file1, err := os.CreateTemp(dir, "log-*.log") - require.NoError(t, err) + RequireNoError(t, err) file1Name := file1.Name() _ = file1.Close() // Reopen for reading (empty file) file1, err = os.Open(file1Name) - require.NoError(t, err) + RequireNoError(t, err) services := []Service{ &FrankenPHPService{baseService: baseService{name: "svc1"}}, @@ -508,8 +504,8 @@ func TestMultiServiceReader_FollowMode(t *testing.T) { 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) + AssertEqual(t, 0, n) + AssertNoError(t, err) done <- true }() @@ -524,23 +520,23 @@ func TestMultiServiceReader_FollowMode(t *testing.T) { }) } -func TestGetLaravelAppURL_Bad(t *testing.T) { - t.Run("no .env file", func(t *testing.T) { +func TestPHP_GetLaravelAppURL_Bad(t *T) { + t.Run("no .env file", func(t *T) { dir := t.TempDir() - assert.Equal(t, "", GetLaravelAppURL(dir)) + AssertEqual(t, "", GetLaravelAppURL(dir)) }) - t.Run("no APP_URL in .env", func(t *testing.T) { + t.Run("no APP_URL in .env", func(t *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) + RequireNoError(t, err) - assert.Equal(t, "", GetLaravelAppURL(dir)) + AssertEqual(t, "", GetLaravelAppURL(dir)) }) } -func TestExtractDomainFromURL_Edge(t *testing.T) { +func TestPHP_ExtractDomainFromURL_Ugly(t *T) { tests := []struct { name string url string @@ -555,18 +551,18 @@ func TestExtractDomainFromURL_Edge(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name, func(t *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) + AssertEqual(t, tt.expected, result) } }) } } -func TestDevServer_StatusWithServices(t *testing.T) { - t.Run("returns statuses for all services", func(t *testing.T) { +func TestDevServer_StatusWithServices(t *T) { + t.Run("returns statuses for all services", func(t *T) { server := NewDevServer(Options{}) // Add mock services @@ -576,16 +572,16 @@ func TestDevServer_StatusWithServices(t *testing.T) { } 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) + AssertLen(t, statuses, 2) + AssertEqual(t, "svc1", statuses[0].Name) + AssertTrue(t, statuses[0].Running) + AssertEqual(t, "svc2", statuses[1].Name) + AssertFalse(t, statuses[1].Running) }) } -func TestDevServer_ServicesReturnsAll(t *testing.T) { - t.Run("returns all services", func(t *testing.T) { +func TestDevServer_ServicesReturnsAll(t *T) { + t.Run("returns all services", func(t *T) { server := NewDevServer(Options{}) // Add mock services @@ -596,12 +592,12 @@ func TestDevServer_ServicesReturnsAll(t *testing.T) { } services := server.Services() - assert.Len(t, services, 3) + AssertLen(t, services, 3) }) } -func TestDevServer_StopWithCancel(t *testing.T) { - t.Run("calls cancel when running", func(t *testing.T) { +func TestDevServer_StopWithCancel(t *T) { + t.Run("calls cancel when running", func(t *T) { ctx, cancel := context.WithCancel(context.Background()) server := NewDevServer(Options{}) server.running = true @@ -614,20 +610,20 @@ func TestDevServer_StopWithCancel(t *testing.T) { } err := server.Stop() - assert.NoError(t, err) - assert.False(t, server.running) + AssertNoError(t, err) + AssertFalse(t, server.running) }) } -func TestMultiServiceReader_CloseWithErrors(t *testing.T) { - t.Run("handles multiple close errors", func(t *testing.T) { +func TestMultiServiceReader_CloseWithErrors(t *T) { + t.Run("handles multiple close errors", func(t *T) { dir := t.TempDir() // Create files file1, err := os.CreateTemp(dir, "log1-*.log") - require.NoError(t, err) + RequireNoError(t, err) file2, err := os.CreateTemp(dir, "log2-*.log") - require.NoError(t, err) + RequireNoError(t, err) services := []Service{ &FrankenPHPService{baseService: baseService{name: "svc1"}}, @@ -639,6 +635,6 @@ func TestMultiServiceReader_CloseWithErrors(t *testing.T) { // Close successfully err = reader.Close() - assert.NoError(t, err) + AssertNoError(t, err) }) } diff --git a/pkg/php/services.go b/pkg/php/services.go index ad57d25..36460cf 100644 --- a/pkg/php/services.go +++ b/pkg/php/services.go @@ -137,7 +137,9 @@ func (s *baseService) startProcess(ctx context.Context, cmdName string, args []s setSysProcAttr(s.cmd) if err := s.cmd.Start(); err != nil { - _ = logFile.Close() + if closeErr := logFile.Close(); closeErr != nil { + err = cli.Err("%v; close log file: %v", err, closeErr) + } s.lastError = err return cli.WrapVerb(err, "start", s.name) } @@ -154,7 +156,9 @@ func (s *baseService) startProcess(ctx context.Context, cmdName string, args []s s.lastError = err } if s.logFile != nil { - _ = s.logFile.Close() + if closeErr := s.logFile.Close(); closeErr != nil && s.lastError == nil { + s.lastError = closeErr + } } s.mu.Unlock() }() @@ -171,21 +175,26 @@ func (s *baseService) stopProcess() error { } // Send termination signal to process (group on Unix) - _ = signalProcessGroup(s.cmd, termSignal()) + if err := signalProcessGroup(s.cmd, termSignal()); err != nil { + s.lastError = err + } // Wait for graceful shutdown with timeout - done := make(chan struct{}) + done := make(chan error, 1) go func() { - _ = s.cmd.Wait() - close(done) + done <- s.cmd.Wait() }() select { - case <-done: - // Process exited gracefully + case err := <-done: + if err != nil && s.lastError == nil { + s.lastError = err + } case <-time.After(5 * time.Second): // Force kill - _ = signalProcessGroup(s.cmd, killSignal()) + if err := signalProcessGroup(s.cmd, killSignal()); err != nil { + s.lastError = err + } } s.running = false @@ -347,7 +356,9 @@ func (s *HorizonService) Stop() error { // Horizon has its own terminate command cmd := exec.Command("php", "artisan", "horizon:terminate") cmd.Dir = s.dir - _ = cmd.Run() // Ignore errors, will also kill via signal + if err := cmd.Run(); err != nil { + s.lastError = err + } return s.stopProcess() } @@ -441,7 +452,9 @@ func (s *RedisService) Start(ctx context.Context) error { func (s *RedisService) Stop() error { // Try graceful shutdown via redis-cli cmd := exec.Command("redis-cli", "-p", cli.Sprintf("%d", s.port), "shutdown", "nosave") - _ = cmd.Run() // Ignore errors + if err := cmd.Run(); err != nil { + s.lastError = err + } return s.stopProcess() } diff --git a/pkg/php/services_extended_test.go b/pkg/php/services_extended_test.go index ce3b72e..d01ee26 100644 --- a/pkg/php/services_extended_test.go +++ b/pkg/php/services_extended_test.go @@ -3,21 +3,17 @@ package php import ( "os" "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestBaseService_Name_Good(t *testing.T) { - t.Run("returns service name", func(t *testing.T) { +func TestPHP_BaseService_Name_Good(t *T) { + t.Run("returns service name", func(t *T) { s := &baseService{name: "TestService"} - assert.Equal(t, "TestService", s.Name()) + AssertEqual(t, "TestService", s.Name()) }) } -func TestBaseService_Status_Good(t *testing.T) { - t.Run("returns status when not running", func(t *testing.T) { +func TestPHP_BaseService_Status_Good(t *T) { + t.Run("returns status when not running", func(t *T) { s := &baseService{ name: "TestService", port: 8080, @@ -25,13 +21,13 @@ func TestBaseService_Status_Good(t *testing.T) { } status := s.Status() - assert.Equal(t, "TestService", status.Name) - assert.Equal(t, 8080, status.Port) - assert.False(t, status.Running) - assert.Equal(t, 0, status.PID) + AssertEqual(t, "TestService", status.Name) + AssertEqual(t, 8080, status.Port) + AssertFalse(t, status.Running) + AssertEqual(t, 0, status.PID) }) - t.Run("returns status when running", func(t *testing.T) { + t.Run("returns status when running", func(t *T) { s := &baseService{ name: "TestService", port: 8080, @@ -39,112 +35,112 @@ func TestBaseService_Status_Good(t *testing.T) { } status := s.Status() - assert.True(t, status.Running) + AssertTrue(t, status.Running) }) - t.Run("returns error in status", func(t *testing.T) { - testErr := assert.AnError + t.Run("returns error in status", func(t *T) { + testErr := AnError s := &baseService{ name: "TestService", lastError: testErr, } status := s.Status() - assert.Equal(t, testErr, status.Error) + AssertEqual(t, testErr, status.Error) }) } -func TestBaseService_Logs_Good(t *testing.T) { - t.Run("returns log file content", func(t *testing.T) { +func TestPHP_BaseService_Logs_Good(t *T) { + t.Run("returns log file content", func(t *T) { dir := t.TempDir() logPath := filepath.Join(dir, "test.log") err := os.WriteFile(logPath, []byte("test log content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) s := &baseService{logPath: logPath} reader, err := s.Logs(false) - assert.NoError(t, err) - assert.NotNil(t, reader) + AssertNoError(t, err) + AssertNotNil(t, reader) _ = reader.Close() }) - t.Run("returns tail reader in follow mode", func(t *testing.T) { + t.Run("returns tail reader in follow mode", func(t *T) { dir := t.TempDir() logPath := filepath.Join(dir, "test.log") err := os.WriteFile(logPath, []byte("test log content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) s := &baseService{logPath: logPath} reader, err := s.Logs(true) - assert.NoError(t, err) - assert.NotNil(t, reader) + AssertNoError(t, err) + AssertNotNil(t, reader) // Verify it's a tailReader by checking it implements ReadCloser _, ok := reader.(*tailReader) - assert.True(t, ok) + AssertTrue(t, ok) _ = reader.Close() }) } -func TestBaseService_Logs_Bad(t *testing.T) { - t.Run("returns error when no log path", func(t *testing.T) { +func TestPHP_BaseService_Logs_Bad(t *T) { + t.Run("returns error when no log path", func(t *T) { s := &baseService{name: "TestService"} _, err := s.Logs(false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no log file available") + AssertError(t, err) + AssertContains(t, err.Error(), "no log file available") }) - t.Run("returns error when log file doesn't exist", func(t *testing.T) { + t.Run("returns error when log file doesn't exist", func(t *T) { s := &baseService{logPath: "/nonexistent/path/log.log"} _, err := s.Logs(false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to open log file") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to open log file") }) } -func TestTailReader_Good(t *testing.T) { - t.Run("creates new tail reader", func(t *testing.T) { +func TestPHP_TailReader_Good(t *T) { + t.Run("creates new tail reader", func(t *T) { dir := t.TempDir() logPath := filepath.Join(dir, "test.log") err := os.WriteFile(logPath, []byte("content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) file, err := os.Open(logPath) - require.NoError(t, err) + RequireNoError(t, err) defer func() { _ = file.Close() }() reader := newTailReader(file) - assert.NotNil(t, reader) - assert.NotNil(t, reader.file) - assert.NotNil(t, reader.reader) - assert.False(t, reader.closed) + AssertNotNil(t, reader) + AssertNotNil(t, reader.file) + AssertNotNil(t, reader.reader) + AssertFalse(t, reader.closed) }) - t.Run("closes file on Close", func(t *testing.T) { + t.Run("closes file on Close", func(t *T) { dir := t.TempDir() logPath := filepath.Join(dir, "test.log") err := os.WriteFile(logPath, []byte("content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) file, err := os.Open(logPath) - require.NoError(t, err) + RequireNoError(t, err) reader := newTailReader(file) err = reader.Close() - assert.NoError(t, err) - assert.True(t, reader.closed) + AssertNoError(t, err) + AssertTrue(t, reader.closed) }) - t.Run("returns EOF when closed", func(t *testing.T) { + t.Run("returns EOF when closed", func(t *T) { dir := t.TempDir() logPath := filepath.Join(dir, "test.log") err := os.WriteFile(logPath, []byte("content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) file, err := os.Open(logPath) - require.NoError(t, err) + RequireNoError(t, err) reader := newTailReader(file) _ = reader.Close() @@ -152,12 +148,12 @@ func TestTailReader_Good(t *testing.T) { buf := make([]byte, 100) n, _ := reader.Read(buf) // When closed, should return 0 bytes (the closed flag causes early return) - assert.Equal(t, 0, n) + AssertEqual(t, 0, n) }) } -func TestFrankenPHPService_Extended(t *testing.T) { - t.Run("all options set correctly", func(t *testing.T) { +func TestFrankenPHPService_Extended(t *T) { + t.Run("all options set correctly", func(t *T) { opts := FrankenPHPOptions{ Port: 9000, HTTPSPort: 9443, @@ -168,71 +164,71 @@ func TestFrankenPHPService_Extended(t *testing.T) { service := NewFrankenPHPService("/project", opts) - assert.Equal(t, "FrankenPHP", service.Name()) - assert.Equal(t, 9000, service.port) - assert.Equal(t, 9443, service.httpsPort) - assert.True(t, service.https) - assert.Equal(t, "/path/to/cert.pem", service.certFile) - assert.Equal(t, "/path/to/key.pem", service.keyFile) - assert.Equal(t, "/project", service.dir) + AssertEqual(t, "FrankenPHP", service.Name()) + AssertEqual(t, 9000, service.port) + AssertEqual(t, 9443, service.httpsPort) + AssertTrue(t, service.https) + AssertEqual(t, "/path/to/cert.pem", service.certFile) + AssertEqual(t, "/path/to/key.pem", service.keyFile) + AssertEqual(t, "/project", service.dir) }) } -func TestViteService_Extended(t *testing.T) { - t.Run("auto-detects package manager", func(t *testing.T) { +func TestViteService_Extended(t *T) { + t.Run("auto-detects package manager", func(t *T) { dir := t.TempDir() // Create bun.lockb to trigger bun detection err := os.WriteFile(filepath.Join(dir, "bun.lockb"), []byte(""), 0644) - require.NoError(t, err) + RequireNoError(t, err) service := NewViteService(dir, ViteOptions{}) - assert.Equal(t, "bun", service.packageManager) + AssertEqual(t, "bun", service.packageManager) }) - t.Run("uses provided package manager", func(t *testing.T) { + t.Run("uses provided package manager", func(t *T) { dir := t.TempDir() service := NewViteService(dir, ViteOptions{PackageManager: "pnpm"}) - assert.Equal(t, "pnpm", service.packageManager) + AssertEqual(t, "pnpm", service.packageManager) }) } -func TestHorizonService_Extended(t *testing.T) { - t.Run("has zero port", func(t *testing.T) { +func TestHorizonService_Extended(t *T) { + t.Run("has zero port", func(t *T) { service := NewHorizonService("/project") - assert.Equal(t, 0, service.port) + AssertEqual(t, 0, service.port) }) } -func TestReverbService_Extended(t *testing.T) { - t.Run("uses default port 8080", func(t *testing.T) { +func TestReverbService_Extended(t *T) { + t.Run("uses default port 8080", func(t *T) { service := NewReverbService("/project", ReverbOptions{}) - assert.Equal(t, 8080, service.port) + AssertEqual(t, 8080, service.port) }) - t.Run("uses custom port", func(t *testing.T) { + t.Run("uses custom port", func(t *T) { service := NewReverbService("/project", ReverbOptions{Port: 9090}) - assert.Equal(t, 9090, service.port) + AssertEqual(t, 9090, service.port) }) } -func TestRedisService_Extended(t *testing.T) { - t.Run("uses default port 6379", func(t *testing.T) { +func TestRedisService_Extended(t *T) { + t.Run("uses default port 6379", func(t *T) { service := NewRedisService("/project", RedisOptions{}) - assert.Equal(t, 6379, service.port) + AssertEqual(t, 6379, service.port) }) - t.Run("accepts config file", func(t *testing.T) { + t.Run("accepts config file", func(t *T) { service := NewRedisService("/project", RedisOptions{ConfigFile: "/path/to/redis.conf"}) - assert.Equal(t, "/path/to/redis.conf", service.configFile) + AssertEqual(t, "/path/to/redis.conf", service.configFile) }) } -func TestServiceStatus_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - testErr := assert.AnError +func TestServiceStatus_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { + testErr := AnError status := ServiceStatus{ Name: "TestService", Running: true, @@ -241,16 +237,16 @@ func TestServiceStatus_Struct(t *testing.T) { Error: testErr, } - assert.Equal(t, "TestService", status.Name) - assert.True(t, status.Running) - assert.Equal(t, 12345, status.PID) - assert.Equal(t, 8080, status.Port) - assert.Equal(t, testErr, status.Error) + AssertEqual(t, "TestService", status.Name) + AssertTrue(t, status.Running) + AssertEqual(t, 12345, status.PID) + AssertEqual(t, 8080, status.Port) + AssertEqual(t, testErr, status.Error) }) } -func TestFrankenPHPOptions_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestFrankenPHPOptions_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { opts := FrankenPHPOptions{ Port: 8000, HTTPSPort: 443, @@ -259,55 +255,55 @@ func TestFrankenPHPOptions_Struct(t *testing.T) { KeyFile: "key.pem", } - assert.Equal(t, 8000, opts.Port) - assert.Equal(t, 443, opts.HTTPSPort) - assert.True(t, opts.HTTPS) - assert.Equal(t, "cert.pem", opts.CertFile) - assert.Equal(t, "key.pem", opts.KeyFile) + AssertEqual(t, 8000, opts.Port) + AssertEqual(t, 443, opts.HTTPSPort) + AssertTrue(t, opts.HTTPS) + AssertEqual(t, "cert.pem", opts.CertFile) + AssertEqual(t, "key.pem", opts.KeyFile) }) } -func TestViteOptions_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestViteOptions_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { opts := ViteOptions{ Port: 5173, PackageManager: "bun", } - assert.Equal(t, 5173, opts.Port) - assert.Equal(t, "bun", opts.PackageManager) + AssertEqual(t, 5173, opts.Port) + AssertEqual(t, "bun", opts.PackageManager) }) } -func TestReverbOptions_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestReverbOptions_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { opts := ReverbOptions{Port: 8080} - assert.Equal(t, 8080, opts.Port) + AssertEqual(t, 8080, opts.Port) }) } -func TestRedisOptions_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestRedisOptions_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { opts := RedisOptions{ Port: 6379, ConfigFile: "redis.conf", } - assert.Equal(t, 6379, opts.Port) - assert.Equal(t, "redis.conf", opts.ConfigFile) + AssertEqual(t, 6379, opts.Port) + AssertEqual(t, "redis.conf", opts.ConfigFile) }) } -func TestBaseService_StopProcess_Good(t *testing.T) { - t.Run("returns nil when not running", func(t *testing.T) { +func TestPHP_BaseService_StopProcess_Good(t *T) { + t.Run("returns nil when not running", func(t *T) { s := &baseService{running: false} err := s.stopProcess() - assert.NoError(t, err) + AssertNoError(t, err) }) - t.Run("returns nil when cmd is nil", func(t *testing.T) { + t.Run("returns nil when cmd is nil", func(t *T) { s := &baseService{running: true, cmd: nil} err := s.stopProcess() - assert.NoError(t, err) + AssertNoError(t, err) }) } diff --git a/pkg/php/services_test.go b/pkg/php/services_test.go index 5a0e66c..4d142d4 100644 --- a/pkg/php/services_test.go +++ b/pkg/php/services_test.go @@ -1,23 +1,19 @@ package php -import ( - "testing" +import () - "github.com/stretchr/testify/assert" -) - -func TestNewFrankenPHPService_Good(t *testing.T) { - t.Run("default options", func(t *testing.T) { +func TestPHP_NewFrankenPHPService_Good(t *T) { + t.Run("default options", func(t *T) { dir := "/tmp/test" service := NewFrankenPHPService(dir, FrankenPHPOptions{}) - assert.Equal(t, "FrankenPHP", service.Name()) - assert.Equal(t, 8000, service.port) - assert.Equal(t, 443, service.httpsPort) - assert.False(t, service.https) + AssertEqual(t, "FrankenPHP", service.Name()) + AssertEqual(t, 8000, service.port) + AssertEqual(t, 443, service.httpsPort) + AssertFalse(t, service.https) }) - t.Run("custom options", func(t *testing.T) { + t.Run("custom options", func(t *T) { dir := "/tmp/test" opts := FrankenPHPOptions{ Port: 9000, @@ -28,65 +24,65 @@ func TestNewFrankenPHPService_Good(t *testing.T) { } service := NewFrankenPHPService(dir, opts) - assert.Equal(t, 9000, service.port) - assert.Equal(t, 8443, service.httpsPort) - assert.True(t, service.https) - assert.Equal(t, "cert.pem", service.certFile) - assert.Equal(t, "key.pem", service.keyFile) + AssertEqual(t, 9000, service.port) + AssertEqual(t, 8443, service.httpsPort) + AssertTrue(t, service.https) + AssertEqual(t, "cert.pem", service.certFile) + AssertEqual(t, "key.pem", service.keyFile) }) } -func TestNewViteService_Good(t *testing.T) { - t.Run("default options", func(t *testing.T) { +func TestPHP_NewViteService_Good(t *T) { + t.Run("default options", func(t *T) { dir := t.TempDir() service := NewViteService(dir, ViteOptions{}) - assert.Equal(t, "Vite", service.Name()) - assert.Equal(t, 5173, service.port) - assert.Equal(t, "npm", service.packageManager) // default when no lock file + AssertEqual(t, "Vite", service.Name()) + AssertEqual(t, 5173, service.port) + AssertEqual(t, "npm", service.packageManager) // default when no lock file }) - t.Run("custom package manager", func(t *testing.T) { + t.Run("custom package manager", func(t *T) { dir := t.TempDir() service := NewViteService(dir, ViteOptions{PackageManager: "pnpm"}) - assert.Equal(t, "pnpm", service.packageManager) + AssertEqual(t, "pnpm", service.packageManager) }) } -func TestNewHorizonService_Good(t *testing.T) { +func TestPHP_NewHorizonService_Good(t *T) { service := NewHorizonService("/tmp/test") - assert.Equal(t, "Horizon", service.Name()) - assert.Equal(t, 0, service.port) + AssertEqual(t, "Horizon", service.Name()) + AssertEqual(t, 0, service.port) } -func TestNewReverbService_Good(t *testing.T) { - t.Run("default options", func(t *testing.T) { +func TestPHP_NewReverbService_Good(t *T) { + t.Run("default options", func(t *T) { service := NewReverbService("/tmp/test", ReverbOptions{}) - assert.Equal(t, "Reverb", service.Name()) - assert.Equal(t, 8080, service.port) + AssertEqual(t, "Reverb", service.Name()) + AssertEqual(t, 8080, service.port) }) - t.Run("custom port", func(t *testing.T) { + t.Run("custom port", func(t *T) { service := NewReverbService("/tmp/test", ReverbOptions{Port: 9090}) - assert.Equal(t, 9090, service.port) + AssertEqual(t, 9090, service.port) }) } -func TestNewRedisService_Good(t *testing.T) { - t.Run("default options", func(t *testing.T) { +func TestPHP_NewRedisService_Good(t *T) { + t.Run("default options", func(t *T) { service := NewRedisService("/tmp/test", RedisOptions{}) - assert.Equal(t, "Redis", service.Name()) - assert.Equal(t, 6379, service.port) + AssertEqual(t, "Redis", service.Name()) + AssertEqual(t, 6379, service.port) }) - t.Run("custom config", func(t *testing.T) { + t.Run("custom config", func(t *T) { service := NewRedisService("/tmp/test", RedisOptions{ConfigFile: "redis.conf"}) - assert.Equal(t, "redis.conf", service.configFile) + AssertEqual(t, "redis.conf", service.configFile) }) } -func TestBaseService_Status(t *testing.T) { +func TestBaseService_Status(t *T) { s := &baseService{ name: "TestService", port: 1234, @@ -94,7 +90,7 @@ func TestBaseService_Status(t *testing.T) { } status := s.Status() - assert.Equal(t, "TestService", status.Name) - assert.Equal(t, 1234, status.Port) - assert.True(t, status.Running) + AssertEqual(t, "TestService", status.Name) + AssertEqual(t, 1234, status.Port) + AssertTrue(t, status.Running) } diff --git a/pkg/php/ssl_extended_test.go b/pkg/php/ssl_extended_test.go index 6f30503..81258bf 100644 --- a/pkg/php/ssl_extended_test.go +++ b/pkg/php/ssl_extended_test.go @@ -3,39 +3,35 @@ package php import ( "os" "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestSSLOptions_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestSSLOptions_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { opts := SSLOptions{Dir: "/custom/ssl/dir"} - assert.Equal(t, "/custom/ssl/dir", opts.Dir) + AssertEqual(t, "/custom/ssl/dir", opts.Dir) }) } -func TestGetSSLDir_Bad(t *testing.T) { - t.Run("fails to create directory in invalid path", func(t *testing.T) { +func TestPHP_GetSSLDir_Bad(t *T) { + t.Run("fails to create directory in invalid path", func(t *T) { // Try to create a directory in a path that can't exist opts := SSLOptions{Dir: "/dev/null/cannot/create"} _, err := GetSSLDir(opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to create SSL directory") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to create SSL directory") }) } -func TestCertPaths_Bad(t *testing.T) { - t.Run("fails when GetSSLDir fails", func(t *testing.T) { +func TestPHP_CertPaths_Bad(t *T) { + t.Run("fails when GetSSLDir fails", func(t *T) { opts := SSLOptions{Dir: "/dev/null/cannot/create"} _, _, err := CertPaths("domain.test", opts) - assert.Error(t, err) + AssertError(t, err) }) } -func TestCertsExist_Detailed(t *testing.T) { - t.Run("returns true when both cert and key exist", func(t *testing.T) { +func TestCertsExist_Detailed(t *T) { + t.Run("returns true when both cert and key exist", func(t *T) { dir := t.TempDir() domain := "test.local" @@ -44,58 +40,58 @@ func TestCertsExist_Detailed(t *testing.T) { keyPath := filepath.Join(dir, domain+"-key.pem") err := os.WriteFile(certPath, []byte("cert"), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(keyPath, []byte("key"), 0644) - require.NoError(t, err) + RequireNoError(t, err) result := CertsExist(domain, SSLOptions{Dir: dir}) - assert.True(t, result) + AssertTrue(t, result) }) - t.Run("returns false when only cert exists", func(t *testing.T) { + t.Run("returns false when only cert exists", func(t *T) { dir := t.TempDir() domain := "test.local" certPath := filepath.Join(dir, domain+".pem") err := os.WriteFile(certPath, []byte("cert"), 0644) - require.NoError(t, err) + RequireNoError(t, err) result := CertsExist(domain, SSLOptions{Dir: dir}) - assert.False(t, result) + AssertFalse(t, result) }) - t.Run("returns false when only key exists", func(t *testing.T) { + t.Run("returns false when only key exists", func(t *T) { dir := t.TempDir() domain := "test.local" keyPath := filepath.Join(dir, domain+"-key.pem") err := os.WriteFile(keyPath, []byte("key"), 0644) - require.NoError(t, err) + RequireNoError(t, err) result := CertsExist(domain, SSLOptions{Dir: dir}) - assert.False(t, result) + AssertFalse(t, result) }) - t.Run("returns false when CertPaths fails", func(t *testing.T) { + t.Run("returns false when CertPaths fails", func(t *T) { result := CertsExist("domain.test", SSLOptions{Dir: "/dev/null/cannot/create"}) - assert.False(t, result) + AssertFalse(t, result) }) } -func TestSetupSSL_RequiresMkcert(t *testing.T) { - t.Run("fails when mkcert not installed", func(t *testing.T) { +func TestSetupSSL_RequiresMkcert(t *T) { + t.Run("fails when mkcert not installed", func(t *T) { if IsMkcertInstalled() { t.Skip("mkcert is installed, skipping error test") } err := SetupSSL("example.test", SSLOptions{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "mkcert is not installed") + AssertError(t, err) + AssertContains(t, err.Error(), "mkcert is not installed") }) } -func TestSetupSSLIfNeeded_UsesExisting(t *testing.T) { - t.Run("returns existing certs without regenerating", func(t *testing.T) { +func TestSetupSSLIfNeeded_UsesExisting(t *T) { + t.Run("returns existing certs without regenerating", func(t *T) { dir := t.TempDir() domain := "existing.test" @@ -104,116 +100,116 @@ func TestSetupSSLIfNeeded_UsesExisting(t *testing.T) { keyPath := filepath.Join(dir, domain+"-key.pem") err := os.WriteFile(certPath, []byte("existing cert"), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(keyPath, []byte("existing key"), 0644) - require.NoError(t, err) + RequireNoError(t, err) resultCert, resultKey, err := SetupSSLIfNeeded(domain, SSLOptions{Dir: dir}) - assert.NoError(t, err) - assert.Equal(t, certPath, resultCert) - assert.Equal(t, keyPath, resultKey) + AssertNoError(t, err) + AssertEqual(t, certPath, resultCert) + AssertEqual(t, keyPath, resultKey) // Verify original content wasn't changed content, _ := os.ReadFile(certPath) - assert.Equal(t, "existing cert", string(content)) + AssertEqual(t, "existing cert", string(content)) }) } -func TestSetupSSLIfNeeded_Bad(t *testing.T) { - t.Run("fails when CertPaths fails", func(t *testing.T) { +func TestPHP_SetupSSLIfNeeded_Bad(t *T) { + t.Run("fails when CertPaths fails", func(t *T) { _, _, err := SetupSSLIfNeeded("domain.test", SSLOptions{Dir: "/dev/null/cannot/create"}) - assert.Error(t, err) + AssertError(t, err) }) - t.Run("fails when SetupSSL fails", func(t *testing.T) { + t.Run("fails when SetupSSL fails", func(t *T) { if IsMkcertInstalled() { t.Skip("mkcert is installed, skipping error test") } dir := t.TempDir() _, _, err := SetupSSLIfNeeded("domain.test", SSLOptions{Dir: dir}) - assert.Error(t, err) + AssertError(t, err) }) } -func TestInstallMkcertCA_Bad(t *testing.T) { - t.Run("fails when mkcert not installed", func(t *testing.T) { +func TestPHP_InstallMkcertCA_Bad(t *T) { + t.Run("fails when mkcert not installed", func(t *T) { if IsMkcertInstalled() { t.Skip("mkcert is installed, skipping error test") } err := InstallMkcertCA() - assert.Error(t, err) - assert.Contains(t, err.Error(), "mkcert is not installed") + AssertError(t, err) + AssertContains(t, err.Error(), "mkcert is not installed") }) } -func TestGetMkcertCARoot_Bad(t *testing.T) { - t.Run("fails when mkcert not installed", func(t *testing.T) { +func TestPHP_GetMkcertCARoot_Bad(t *T) { + t.Run("fails when mkcert not installed", func(t *T) { if IsMkcertInstalled() { t.Skip("mkcert is installed, skipping error test") } _, err := GetMkcertCARoot() - assert.Error(t, err) - assert.Contains(t, err.Error(), "mkcert is not installed") + AssertError(t, err) + AssertContains(t, err.Error(), "mkcert is not installed") }) } -func TestCertPathsNaming(t *testing.T) { - t.Run("uses correct naming convention", func(t *testing.T) { +func TestCertPathsNaming(t *T) { + t.Run("uses correct naming convention", func(t *T) { dir := t.TempDir() domain := "myapp.example.com" certFile, keyFile, err := CertPaths(domain, SSLOptions{Dir: dir}) - assert.NoError(t, err) - assert.Equal(t, filepath.Join(dir, "myapp.example.com.pem"), certFile) - assert.Equal(t, filepath.Join(dir, "myapp.example.com-key.pem"), keyFile) + AssertNoError(t, err) + AssertEqual(t, filepath.Join(dir, "myapp.example.com.pem"), certFile) + AssertEqual(t, filepath.Join(dir, "myapp.example.com-key.pem"), keyFile) }) - t.Run("handles localhost", func(t *testing.T) { + t.Run("handles localhost", func(t *T) { dir := t.TempDir() certFile, keyFile, err := CertPaths("localhost", SSLOptions{Dir: dir}) - assert.NoError(t, err) - assert.Equal(t, filepath.Join(dir, "localhost.pem"), certFile) - assert.Equal(t, filepath.Join(dir, "localhost-key.pem"), keyFile) + AssertNoError(t, err) + AssertEqual(t, filepath.Join(dir, "localhost.pem"), certFile) + AssertEqual(t, filepath.Join(dir, "localhost-key.pem"), keyFile) }) - t.Run("handles wildcard-like domains", func(t *testing.T) { + t.Run("handles wildcard-like domains", func(t *T) { dir := t.TempDir() domain := "*.example.com" certFile, keyFile, err := CertPaths(domain, SSLOptions{Dir: dir}) - assert.NoError(t, err) - assert.Contains(t, certFile, "*.example.com.pem") - assert.Contains(t, keyFile, "*.example.com-key.pem") + AssertNoError(t, err) + AssertContains(t, certFile, "*.example.com.pem") + AssertContains(t, keyFile, "*.example.com-key.pem") }) } -func TestDefaultSSLDir_Value(t *testing.T) { - t.Run("has expected default value", func(t *testing.T) { - assert.Equal(t, ".core/ssl", DefaultSSLDir) +func TestDefaultSSLDir_Value(t *T) { + t.Run("has expected default value", func(t *T) { + AssertEqual(t, ".core/ssl", DefaultSSLDir) }) } -func TestGetSSLDir_CreatesDirectory(t *testing.T) { - t.Run("creates nested directory structure", func(t *testing.T) { +func TestGetSSLDir_CreatesDirectory(t *T) { + t.Run("creates nested directory structure", func(t *T) { baseDir := t.TempDir() nestedDir := filepath.Join(baseDir, "level1", "level2", "ssl") dir, err := GetSSLDir(SSLOptions{Dir: nestedDir}) - assert.NoError(t, err) - assert.Equal(t, nestedDir, dir) + AssertNoError(t, err) + AssertEqual(t, nestedDir, dir) // Verify directory exists info, err := os.Stat(dir) - assert.NoError(t, err) - assert.True(t, info.IsDir()) + AssertNoError(t, err) + AssertTrue(t, info.IsDir()) }) } diff --git a/pkg/php/ssl_test.go b/pkg/php/ssl_test.go index 3e0a0a5..2d6169d 100644 --- a/pkg/php/ssl_test.go +++ b/pkg/php/ssl_test.go @@ -3,29 +3,25 @@ package php import ( "os" "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestGetSSLDir_Good(t *testing.T) { - t.Run("uses provided directory", func(t *testing.T) { +func TestPHP_GetSSLDir_Good(t *T) { + t.Run("uses provided directory", func(t *T) { dir := t.TempDir() customDir := filepath.Join(dir, "custom-ssl") result, err := GetSSLDir(SSLOptions{Dir: customDir}) - assert.NoError(t, err) - assert.Equal(t, customDir, result) + AssertNoError(t, err) + AssertEqual(t, customDir, result) // Verify directory was created info, err := os.Stat(result) - assert.NoError(t, err) - assert.True(t, info.IsDir()) + AssertNoError(t, err) + AssertTrue(t, info.IsDir()) }) - t.Run("uses default directory when not specified", func(t *testing.T) { + t.Run("uses default directory when not specified", func(t *T) { // Skip if we can't get home dir home, err := os.UserHomeDir() if err != nil { @@ -34,35 +30,35 @@ func TestGetSSLDir_Good(t *testing.T) { result, err := GetSSLDir(SSLOptions{}) - assert.NoError(t, err) - assert.Equal(t, filepath.Join(home, DefaultSSLDir), result) + AssertNoError(t, err) + AssertEqual(t, filepath.Join(home, DefaultSSLDir), result) }) } -func TestCertPaths_Good(t *testing.T) { - t.Run("returns correct paths for domain", func(t *testing.T) { +func TestPHP_CertPaths_Good(t *T) { + t.Run("returns correct paths for domain", func(t *T) { dir := t.TempDir() certFile, keyFile, err := CertPaths("example.test", SSLOptions{Dir: dir}) - assert.NoError(t, err) - assert.Equal(t, filepath.Join(dir, "example.test.pem"), certFile) - assert.Equal(t, filepath.Join(dir, "example.test-key.pem"), keyFile) + AssertNoError(t, err) + AssertEqual(t, filepath.Join(dir, "example.test.pem"), certFile) + AssertEqual(t, filepath.Join(dir, "example.test-key.pem"), keyFile) }) - t.Run("handles domain with subdomain", func(t *testing.T) { + t.Run("handles domain with subdomain", func(t *T) { dir := t.TempDir() certFile, keyFile, err := CertPaths("app.example.test", SSLOptions{Dir: dir}) - assert.NoError(t, err) - assert.Equal(t, filepath.Join(dir, "app.example.test.pem"), certFile) - assert.Equal(t, filepath.Join(dir, "app.example.test-key.pem"), keyFile) + AssertNoError(t, err) + AssertEqual(t, filepath.Join(dir, "app.example.test.pem"), certFile) + AssertEqual(t, filepath.Join(dir, "app.example.test-key.pem"), keyFile) }) } -func TestCertsExist_Good(t *testing.T) { - t.Run("returns true when both files exist", func(t *testing.T) { +func TestPHP_CertsExist_Good(t *T) { + t.Run("returns true when both files exist", func(t *T) { dir := t.TempDir() domain := "myapp.test" @@ -71,54 +67,54 @@ func TestCertsExist_Good(t *testing.T) { keyFile := filepath.Join(dir, domain+"-key.pem") err := os.WriteFile(certFile, []byte("cert content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(keyFile, []byte("key content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.True(t, CertsExist(domain, SSLOptions{Dir: dir})) + AssertTrue(t, CertsExist(domain, SSLOptions{Dir: dir})) }) } -func TestCertsExist_Bad(t *testing.T) { - t.Run("returns false when cert missing", func(t *testing.T) { +func TestPHP_CertsExist_Bad(t *T) { + t.Run("returns false when cert missing", func(t *T) { dir := t.TempDir() domain := "myapp.test" // Create only key file keyFile := filepath.Join(dir, domain+"-key.pem") err := os.WriteFile(keyFile, []byte("key content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, CertsExist(domain, SSLOptions{Dir: dir})) + AssertFalse(t, CertsExist(domain, SSLOptions{Dir: dir})) }) - t.Run("returns false when key missing", func(t *testing.T) { + t.Run("returns false when key missing", func(t *T) { dir := t.TempDir() domain := "myapp.test" // Create only cert file certFile := filepath.Join(dir, domain+".pem") err := os.WriteFile(certFile, []byte("cert content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, CertsExist(domain, SSLOptions{Dir: dir})) + AssertFalse(t, CertsExist(domain, SSLOptions{Dir: dir})) }) - t.Run("returns false when neither exists", func(t *testing.T) { + t.Run("returns false when neither exists", func(t *T) { dir := t.TempDir() domain := "myapp.test" - assert.False(t, CertsExist(domain, SSLOptions{Dir: dir})) + AssertFalse(t, CertsExist(domain, SSLOptions{Dir: dir})) }) - t.Run("returns false for invalid directory", func(t *testing.T) { + t.Run("returns false for invalid directory", func(t *T) { // Use invalid directory path - assert.False(t, CertsExist("domain.test", SSLOptions{Dir: "/nonexistent/path/that/does/not/exist"})) + AssertFalse(t, CertsExist("domain.test", SSLOptions{Dir: "/nonexistent/path/that/does/not/exist"})) }) } -func TestSetupSSL_Bad(t *testing.T) { - t.Run("returns error when mkcert not installed", func(t *testing.T) { +func TestPHP_SetupSSL_Bad(t *T) { + t.Run("returns error when mkcert not installed", func(t *T) { // This test assumes mkcert might not be installed // If it is installed, we skip this test if IsMkcertInstalled() { @@ -126,13 +122,13 @@ func TestSetupSSL_Bad(t *testing.T) { } err := SetupSSL("example.test", SSLOptions{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "mkcert is not installed") + AssertError(t, err) + AssertContains(t, err.Error(), "mkcert is not installed") }) } -func TestSetupSSLIfNeeded_Good(t *testing.T) { - t.Run("returns existing certs without regenerating", func(t *testing.T) { +func TestPHP_SetupSSLIfNeeded_Good(t *T) { + t.Run("returns existing certs without regenerating", func(t *T) { dir := t.TempDir() domain := "existing.test" @@ -141,32 +137,34 @@ func TestSetupSSLIfNeeded_Good(t *testing.T) { keyFile := filepath.Join(dir, domain+"-key.pem") err := os.WriteFile(certFile, []byte("existing cert"), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(keyFile, []byte("existing key"), 0644) - require.NoError(t, err) + RequireNoError(t, err) resultCert, resultKey, err := SetupSSLIfNeeded(domain, SSLOptions{Dir: dir}) - assert.NoError(t, err) - assert.Equal(t, certFile, resultCert) - assert.Equal(t, keyFile, resultKey) + AssertNoError(t, err) + AssertEqual(t, certFile, resultCert) + AssertEqual(t, keyFile, resultKey) // Verify files weren't modified data, err := os.ReadFile(certFile) - require.NoError(t, err) - assert.Equal(t, "existing cert", string(data)) + RequireNoError(t, err) + AssertEqual(t, "existing cert", string(data)) }) } -func TestIsMkcertInstalled_Good(t *testing.T) { +func TestPHP_IsMkcertInstalled_Good(t *T) { // This test just verifies the function runs without error // The actual result depends on whether mkcert is installed result := IsMkcertInstalled() + again := IsMkcertInstalled() + AssertEqual(t, result, again) t.Logf("mkcert installed: %v", result) } -func TestDefaultSSLDir_Good(t *testing.T) { - t.Run("constant has expected value", func(t *testing.T) { - assert.Equal(t, ".core/ssl", DefaultSSLDir) +func TestPHP_DefaultSSLDir_Good(t *T) { + t.Run("constant has expected value", func(t *T) { + AssertEqual(t, ".core/ssl", DefaultSSLDir) }) }