diff --git a/Makefile b/Makefile index 90f889d..471d737 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: check check-fast fmt fmt-check vet lint tidy tidy-check test test-node build clean \ build-linux-amd64 build-linux-arm64 bundle-php bundle-ext gc-bundles-dry-run \ - local-ci + local-ci ci-cell ci # Path to the native phpup binary used by bundle-php / bundle-ext. Overridable # so CI / power users can point at a pre-built binary. @@ -130,6 +130,58 @@ bundle-ext: $(PHPUP_BIN) --registry $(or $(REGISTRY),oci-layout:./out/oci-layout) \ --repo . +# Run one compat-harness cell via `phpup test`: loads the published (or local +# OCI-layout) bundles for the requested OS/ARCH/PHP inside docker and exercises +# every matching row in test/compat/fixtures.yaml. Caller supplies OS, ARCH, +# PHP; REGISTRY defaults to the local OCI layout used by bundle-php/bundle-ext. +# Exercised end-to-end via `make ci` and from test/smoke/local-ci.sh. +# +# The cell container is always linux/, so phpup must be cross-compiled +# to match — a darwin/arm64 host binary or a linux/amd64 host binary running +# an aarch64 cell would trip `exec format error`. We resolve the right +# bin/phpup-linux- via a shell case, rebuild both the host-native and +# linux binaries on demand, then thread the linux binary via --self-binary. +ci-cell: + @case "$(ARCH)" in \ + x86_64|amd64) phpup_linux_bin="bin/phpup-linux-amd64" ;; \ + aarch64|arm64) phpup_linux_bin="bin/phpup-linux-arm64" ;; \ + *) echo "ci-cell: unknown ARCH=$(ARCH), want x86_64 or aarch64" >&2; exit 2 ;; \ + esac; \ + $(MAKE) "$$phpup_linux_bin" && \ + $(MAKE) $(PHPUP_BIN) && \ + $(PHPUP_BIN) test \ + --os $(OS) \ + --arch $(ARCH) \ + --php $(PHP) \ + --self-binary "$$PWD/$$phpup_linux_bin" \ + --registry $(or $(REGISTRY),oci-layout:./out/oci-layout) \ + --fixtures test/compat/fixtures.yaml \ + --repo . + +# Cross-compiled phpup binaries used by ci-cell to mount the correct +# architecture-matching binary into the bare-ubuntu container. Declared as +# file targets so Make only rebuilds when the embedded lockfile changes. +bin/phpup-linux-amd64: cmd/phpup/bundles.lock + @mkdir -p bin + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o bin/phpup-linux-amd64 ./cmd/phpup + +bin/phpup-linux-arm64: cmd/phpup/bundles.lock + @mkdir -p bin + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o bin/phpup-linux-arm64 ./cmd/phpup + +# Iterate ci-cell across jammy+noble × x86_64+aarch64 × PHP 8.1-8.5. Short- +# circuits on first failure thanks to `set -e`. Requires docker + the relevant +# bundles in REGISTRY (published GHCR by default). +ci: $(PHPUP_BIN) + @set -e; for os in jammy noble; do \ + for arch in x86_64 aarch64; do \ + for php in 8.1 8.2 8.3 8.4 8.5; do \ + echo "==> ci-cell OS=$$os ARCH=$$arch PHP=$$php"; \ + $(MAKE) ci-cell OS=$$os ARCH=$$arch PHP=$$php; \ + done; \ + done; \ + done + # Clean build artifacts clean: rm -rf bin/ dist/ diff --git a/cmd/phpup/main.go b/cmd/phpup/main.go index f89bb9e..6a8d817 100644 --- a/cmd/phpup/main.go +++ b/cmd/phpup/main.go @@ -25,6 +25,7 @@ import ( "github.com/buildrush/setup-php/internal/oci" "github.com/buildrush/setup-php/internal/plan" "github.com/buildrush/setup-php/internal/resolve" + "github.com/buildrush/setup-php/internal/testsuite" "github.com/buildrush/setup-php/internal/version" ) @@ -64,6 +65,29 @@ func main() { return } + // `phpup test …` is dispatched the same way as `phpup build …`: its + // own FlagSet, its own argv universe. Used by maintainers to run the + // compat-harness fixtures locally against a registry (oci-layout or + // remote). See internal/testsuite.Main for the flag surface. + if len(os.Args) > 1 && os.Args[1] == "test" { + if err := testsuite.Main(os.Args[2:]); err != nil { + log.Fatalf("%v", err) + } + return + } + + // `phpup internal …` is the maintainer-only umbrella for + // commands that are driven by the outer tooling rather than by end + // users. Currently only "test-cell" (the inner, container-side part + // of `phpup test`). Kept separate so the user-facing help surface + // stays lean. + if len(os.Args) > 1 && os.Args[1] == "internal" { + if err := testsuite.InternalMain(os.Args[2:]); err != nil { + log.Fatalf("%v", err) + } + return + } + registryFlag := flag.String("registry", "", "OCI artifact store (e.g., ghcr.io/buildrush or oci-layout:./out/oci-layout). Overrides INPUT_REGISTRY / PHPUP_REGISTRY; defaults to ghcr.io/buildrush.") flag.Parse() diff --git a/internal/build/build.go b/internal/build/build.go index 02c3c59..3009c89 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -102,11 +102,11 @@ func BuildPHP(ctx context.Context, args []string) error { } // 4. Invoke builder in docker. - image, err := ubuntuImage(opts.OS) + image, err := UbuntuImage(opts.OS) if err != nil { return fmt.Errorf("phpup build php: %w", err) } - platform, err := dockerPlatform(opts.Arch) + platform, err := DockerPlatform(opts.Arch) if err != nil { return fmt.Errorf("phpup build php: %w", err) } @@ -264,11 +264,11 @@ func BuildExt(ctx context.Context, args []string) error { // 6. Run build container on the sidecar's network so // fetch-core.sh can reach the sidecar by in-network hostname. - image, err := ubuntuImage(opts.OS) + image, err := UbuntuImage(opts.OS) if err != nil { return fmt.Errorf("phpup build ext: %w", err) } - platform, err := dockerPlatform(opts.Arch) + platform, err := DockerPlatform(opts.Arch) if err != nil { return fmt.Errorf("phpup build ext: %w", err) } @@ -573,12 +573,13 @@ func prepareOutDir(path string) error { return nil } -// ubuntuImage maps a short OS flavour name onto the concrete docker image +// UbuntuImage maps a short OS flavour name onto the concrete docker image // tag that builders/linux/build-php.sh expects. Accepts both the short // ("jammy"/"noble") and long ("ubuntu-22.04"/"ubuntu-24.04") spellings // because the planner emits the long form and humans tend to type the -// short form; either is unambiguous. -func ubuntuImage(osFlag string) (string, error) { +// short form; either is unambiguous. Exported so the testsuite package +// can reuse the same OS-to-image mapping without duplicating it. +func UbuntuImage(osFlag string) (string, error) { switch strings.ToLower(osFlag) { case "jammy", "ubuntu-22.04": return "ubuntu:22.04", nil @@ -604,9 +605,11 @@ func normalizeArch(arch string) (string, error) { } } -// dockerPlatform maps the canonical arch name (as produced by -// normalizeArch) onto the docker --platform value. -func dockerPlatform(arch string) (string, error) { +// DockerPlatform maps the canonical arch name (as produced by +// normalizeArch) onto the docker --platform value. Exported so the +// testsuite package can reuse the same arch-to-platform mapping without +// duplicating it. +func DockerPlatform(arch string) (string, error) { switch arch { case "x86_64": return "linux/amd64", nil diff --git a/internal/testsuite/fixtures.go b/internal/testsuite/fixtures.go new file mode 100644 index 0000000..ebec2f7 --- /dev/null +++ b/internal/testsuite/fixtures.go @@ -0,0 +1,120 @@ +// Package testsuite parses and filters test/compat/fixtures.yaml for use by +// the phpup test subcommand (local+CI test runner). +// +// A Fixture mirrors one entry in that YAML. Filter selects the subset of +// fixtures that apply to a given (os, arch, php) cell, applying tolerant +// aliasing on the runner OS (jammy/noble <-> ubuntu-22.04/24.04) and arch +// (amd64/x86_64 and arm64/aarch64 treated as equivalent). +package testsuite + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Fixture mirrors a single entry in test/compat/fixtures.yaml. Field names +// match shivammathur/setup-php@v2 inputs (php-version, extensions, ini-values, +// coverage) so the same YAML feeds both the legacy compat harness and the +// new phpup test subcommand. +type Fixture struct { + Name string `yaml:"name"` + PHPVersion string `yaml:"php-version"` + // Extensions is a comma-separated list. Tokens may be "none" (reset), + // or prefixed with ":" to exclude (e.g. ":opcache"). Empty means no + // extensions beyond the core bundle. + Extensions string `yaml:"extensions"` + // INIValues is a comma-separated list of key=value pairs. + INIValues string `yaml:"ini-values"` + // Coverage is "none", "pcov", or "xdebug". + Coverage string `yaml:"coverage"` + // INIFile is optional: "production" or "development". + INIFile string `yaml:"ini-file,omitempty"` + // Arch is optional; "aarch64" or "x86_64". Empty means x86_64. + Arch string `yaml:"arch,omitempty"` + // RunnerOS is optional; e.g. "ubuntu-22.04". Empty means any OS. + RunnerOS string `yaml:"runner_os,omitempty"` +} + +// FixtureSet is the top-level document shape of test/compat/fixtures.yaml. +type FixtureSet struct { + Fixtures []Fixture `yaml:"fixtures"` +} + +// Load reads the YAML file at path and returns the parsed FixtureSet. +func Load(path string) (*FixtureSet, error) { + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return nil, fmt.Errorf("testsuite.Load: read %s: %w", path, err) + } + var s FixtureSet + if err := yaml.Unmarshal(data, &s); err != nil { + return nil, fmt.Errorf("testsuite.Load: parse %s: %w", path, err) + } + return &s, nil +} + +// Filter returns fixtures matching the given (os, arch, php) tuple. +// +// Matching rules: +// - php: exact match on Fixture.PHPVersion. +// - arch: Fixture.Arch (default "x86_64" if empty) must equal arch input. +// Both canonical ("x86_64"/"aarch64") and docker aliases +// ("amd64"/"arm64") are accepted on the input side and normalized +// before comparison. +// - os: if Fixture.RunnerOS == "", the fixture is eligible for any OS. +// Otherwise Fixture.RunnerOS must equal the os input, with tolerant +// aliasing: "jammy" <-> "ubuntu-22.04", "noble" <-> "ubuntu-24.04". +// +// The returned slice is never nil; an empty match yields an empty slice. +func (s *FixtureSet) Filter(osName, arch, php string) []Fixture { + wantArch := normalizeArch(arch) + wantOS := normalizeOS(osName) + out := make([]Fixture, 0) + for i := range s.Fixtures { + f := &s.Fixtures[i] + if f.PHPVersion != php { + continue + } + fArch := f.Arch + if fArch == "" { + fArch = "x86_64" + } + if normalizeArch(fArch) != wantArch { + continue + } + if f.RunnerOS != "" && normalizeOS(f.RunnerOS) != wantOS { + continue + } + out = append(out, *f) + } + return out +} + +// normalizeArch maps the docker aliases (amd64, arm64) to the canonical +// forms (x86_64, aarch64) used in fixtures.yaml. Unknown inputs are +// returned unchanged so Filter silently drops them instead of panicking. +func normalizeArch(a string) string { + switch a { + case "amd64", "x86_64": + return "x86_64" + case "arm64", "aarch64": + return "aarch64" + } + return a +} + +// normalizeOS maps the short codenames (jammy, noble) to the long +// ubuntu-XX.YY form used in fixtures.yaml so either spelling works on +// input. Unknown inputs are returned unchanged. +func normalizeOS(o string) string { + switch o { + case "jammy": + return "ubuntu-22.04" + case "noble": + return "ubuntu-24.04" + } + return o +} diff --git a/internal/testsuite/fixtures_test.go b/internal/testsuite/fixtures_test.go new file mode 100644 index 0000000..843142d --- /dev/null +++ b/internal/testsuite/fixtures_test.go @@ -0,0 +1,181 @@ +package testsuite + +import ( + "os" + "path/filepath" + "testing" +) + +// TestLoad_RealFixturesFile parses the checked-in test/compat/fixtures.yaml +// (reached from this package's directory via ../../test/compat/fixtures.yaml) +// and asserts the file has at least the expected number of entries plus one +// known-shape entry. This guards against accidental schema drift between the +// Go structs here and the YAML the compat harness consumes. +func TestLoad_RealFixturesFile(t *testing.T) { + path := filepath.Join("..", "..", "test", "compat", "fixtures.yaml") + set, err := Load(path) + if err != nil { + t.Fatalf("Load(%q) returned error: %v", path, err) + } + if got := len(set.Fixtures); got < 40 { + t.Fatalf("expected >= 40 fixtures in %s, got %d", path, got) + } + + // Spot-check the "bare" fixture: first entry in the file, PHP 8.4, + // empty extensions/ini-values, coverage=none, no arch/runner_os. + var bare *Fixture + for i := range set.Fixtures { + if set.Fixtures[i].Name == "bare" { + bare = &set.Fixtures[i] + break + } + } + if bare == nil { + t.Fatalf("expected fixture named %q in %s", "bare", path) + } + if bare.PHPVersion != "8.4" { + t.Errorf("bare.PHPVersion = %q, want %q", bare.PHPVersion, "8.4") + } + if bare.Extensions != "" { + t.Errorf("bare.Extensions = %q, want empty", bare.Extensions) + } + if bare.INIValues != "" { + t.Errorf("bare.INIValues = %q, want empty", bare.INIValues) + } + if bare.Coverage != "none" { + t.Errorf("bare.Coverage = %q, want %q", bare.Coverage, "none") + } + if bare.Arch != "" { + t.Errorf("bare.Arch = %q, want empty (implicit x86_64)", bare.Arch) + } + if bare.RunnerOS != "" { + t.Errorf("bare.RunnerOS = %q, want empty (any OS)", bare.RunnerOS) + } +} + +func TestLoad_MissingFile_Errors(t *testing.T) { + missing := filepath.Join(t.TempDir(), "does-not-exist.yaml") + if _, err := Load(missing); err == nil { + t.Fatalf("Load(%q) returned nil error for missing file", missing) + } +} + +func TestLoad_MalformedYAML_Errors(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.yaml") + // Not valid YAML: unterminated flow sequence. + if err := os.WriteFile(path, []byte("fixtures: [ this is: not valid"), 0o600); err != nil { + t.Fatalf("seed malformed YAML: %v", err) + } + if _, err := Load(path); err == nil { + t.Fatalf("Load(%q) returned nil error for malformed YAML", path) + } +} + +func TestFilter_PHPVersionMatch(t *testing.T) { + set := &FixtureSet{ + Fixtures: []Fixture{ + {Name: "a", PHPVersion: "8.4"}, + {Name: "b", PHPVersion: "8.5"}, + {Name: "c", PHPVersion: "8.4"}, + }, + } + got := set.Filter("jammy", "x86_64", "8.4") + if len(got) != 2 { + t.Fatalf("expected 2 matches for PHP 8.4, got %d: %+v", len(got), got) + } + for _, f := range got { + if f.PHPVersion != "8.4" { + t.Errorf("Filter returned wrong version: %+v", f) + } + if f.Name != "a" && f.Name != "c" { + t.Errorf("Filter returned wrong fixture: %+v", f) + } + } +} + +func TestFilter_ArchMatch_DefaultX86(t *testing.T) { + set := &FixtureSet{ + Fixtures: []Fixture{ + {Name: "bare", PHPVersion: "8.4"}, // empty Arch => x86_64 + }, + } + if got := set.Filter("jammy", "x86_64", "8.4"); len(got) != 1 { + t.Errorf("arch=x86_64 should match default-x86_64 fixture; got %d", len(got)) + } + if got := set.Filter("jammy", "amd64", "8.4"); len(got) != 1 { + t.Errorf("arch=amd64 should alias to x86_64 and match; got %d", len(got)) + } + if got := set.Filter("jammy", "aarch64", "8.4"); len(got) != 0 { + t.Errorf("arch=aarch64 should NOT match default-x86_64 fixture; got %d", len(got)) + } +} + +func TestFilter_ArchMatch_ExplicitArm64(t *testing.T) { + set := &FixtureSet{ + Fixtures: []Fixture{ + {Name: "bare-arm", PHPVersion: "8.4", Arch: "aarch64"}, + }, + } + if got := set.Filter("jammy", "aarch64", "8.4"); len(got) != 1 { + t.Errorf("arch=aarch64 should match explicit-aarch64 fixture; got %d", len(got)) + } + if got := set.Filter("jammy", "arm64", "8.4"); len(got) != 1 { + t.Errorf("arch=arm64 should alias to aarch64 and match; got %d", len(got)) + } + if got := set.Filter("jammy", "x86_64", "8.4"); len(got) != 0 { + t.Errorf("arch=x86_64 should NOT match explicit-aarch64 fixture; got %d", len(got)) + } +} + +func TestFilter_RunnerOSUnset_MatchesAny(t *testing.T) { + set := &FixtureSet{ + Fixtures: []Fixture{ + {Name: "bare", PHPVersion: "8.4"}, // empty RunnerOS => any OS + }, + } + if got := set.Filter("jammy", "x86_64", "8.4"); len(got) != 1 { + t.Errorf("RunnerOS-unset fixture should match jammy; got %d", len(got)) + } + if got := set.Filter("noble", "x86_64", "8.4"); len(got) != 1 { + t.Errorf("RunnerOS-unset fixture should match noble; got %d", len(got)) + } + if got := set.Filter("ubuntu-22.04", "x86_64", "8.4"); len(got) != 1 { + t.Errorf("RunnerOS-unset fixture should match ubuntu-22.04; got %d", len(got)) + } +} + +func TestFilter_RunnerOSExplicit_MatchesOnly(t *testing.T) { + set := &FixtureSet{ + Fixtures: []Fixture{ + {Name: "bare-jammy", PHPVersion: "8.4", RunnerOS: "ubuntu-22.04"}, + }, + } + if got := set.Filter("jammy", "x86_64", "8.4"); len(got) != 1 { + t.Errorf("ubuntu-22.04 fixture should match os=jammy alias; got %d", len(got)) + } + if got := set.Filter("ubuntu-22.04", "x86_64", "8.4"); len(got) != 1 { + t.Errorf("ubuntu-22.04 fixture should match os=ubuntu-22.04; got %d", len(got)) + } + if got := set.Filter("noble", "x86_64", "8.4"); len(got) != 0 { + t.Errorf("ubuntu-22.04 fixture should NOT match os=noble; got %d", len(got)) + } + if got := set.Filter("ubuntu-24.04", "x86_64", "8.4"); len(got) != 0 { + t.Errorf("ubuntu-22.04 fixture should NOT match os=ubuntu-24.04; got %d", len(got)) + } +} + +func TestFilter_EmptyResult_NoMatch(t *testing.T) { + set := &FixtureSet{ + Fixtures: []Fixture{ + {Name: "a", PHPVersion: "8.4"}, + }, + } + got := set.Filter("jammy", "x86_64", "7.4") + if got == nil { + t.Fatalf("Filter returned nil for no-match; want empty slice") + } + if len(got) != 0 { + t.Fatalf("Filter returned %d fixtures for no-match PHP version; want 0", len(got)) + } +} diff --git a/internal/testsuite/internalmain.go b/internal/testsuite/internalmain.go new file mode 100644 index 0000000..ecf82f8 --- /dev/null +++ b/internal/testsuite/internalmain.go @@ -0,0 +1,25 @@ +package testsuite + +import ( + "context" + "errors" + "fmt" +) + +// InternalMain is the entry point for `phpup internal …` maintainer-only +// subcommands. These are sibling commands to `phpup build` and `phpup test` +// but are separated under the `internal` umbrella because they're meant to +// be invoked by the outer tooling (e.g. `phpup test` re-enters via +// `phpup internal test-cell` inside the per-cell container), not driven +// directly by users. +func InternalMain(args []string) error { + if len(args) == 0 { + return errors.New("phpup internal: usage: phpup internal [flags]") + } + switch args[0] { + case "test-cell": + return RunTestCell(context.Background(), args[1:]) + default: + return fmt.Errorf("phpup internal: unknown subcommand %q", args[0]) + } +} diff --git a/internal/testsuite/runner.go b/internal/testsuite/runner.go new file mode 100644 index 0000000..df463a2 --- /dev/null +++ b/internal/testsuite/runner.go @@ -0,0 +1,369 @@ +package testsuite + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/buildrush/setup-php/internal/build" +) + +// Main is the entry point for `phpup test …`. Parses flags, expands the +// (os × arch × php) cartesian product, and runs each cell in a bare-ubuntu +// container via internal/build.DockerRun. Aggregates outcomes; returns +// non-nil error if any cell failed. +func Main(args []string) error { + opts, err := parseTestFlags(args) + if err != nil { + return err + } + ctx := context.Background() + return runAllCells(ctx, opts) +} + +// testOpts is the parsed flag set for `phpup test`. Fields are populated +// from the flag parser and then augmented with absolute paths (AbsRepo, +// AbsFixtures, SelfBinary) so downstream code can mount them into the +// per-cell container without re-resolving. +type testOpts struct { + RegistryURI string + OSes []string // normalized to "jammy" / "noble" + Arches []string // normalized to "x86_64" / "aarch64" + PHPVersions []string + Fixtures string + Repo string + Parallel int + Cache string + // Resolved once at parse time. + AbsFixtures string + AbsRepo string + SelfBinary string +} + +// parseTestFlags parses the flag tail for `phpup test`. Uses ContinueOnError +// so callers get an error back instead of a process exit — keeps the surface +// testable without os.Exit acrobatics. +func parseTestFlags(args []string) (*testOpts, error) { + fs := flag.NewFlagSet("phpup test", flag.ContinueOnError) + registryFlag := fs.String("registry", "oci-layout:./out/oci-layout", + "OCI artifact store (oci-layout: or ghcr.io/)") + osFlag := fs.String("os", "jammy,noble", + "Comma-separated Ubuntu flavours: jammy (22.04), noble (24.04)") + archFlag := fs.String("arch", "x86_64,aarch64", + "Comma-separated target archs: x86_64, aarch64 (amd64/arm64 aliases accepted)") + phpFlag := fs.String("php", "", + "Comma-separated PHP minor versions (e.g. 8.1,8.4); REQUIRED") + fixturesFlag := fs.String("fixtures", "test/compat/fixtures.yaml", + "Path to fixtures YAML (relative to --repo or absolute)") + repoFlag := fs.String("repo", ".", + "Path to setup-php repo root") + parallelFlag := fs.Int("parallel", 1, + "Number of cells to run concurrently (default 1)") + cacheFlag := fs.String("cache", "./.cache/phpup-test", + "Cache directory (reserved for future use)") + selfBinaryFlag := fs.String("self-binary", "", + "Absolute path to a linux/ phpup binary to mount as "+ + "/usr/local/bin/phpup inside the bare-ubuntu container. "+ + "When empty, os.Executable() is used — only correct when the "+ + "host arch matches the cell arch (e.g. linux CI runners). On "+ + "macOS hosts or cross-arch runs, pass a cross-compiled binary.") + + if err := fs.Parse(args); err != nil { + return nil, err + } + if *phpFlag == "" { + return nil, errors.New("phpup test: --php is required") + } + + absRepo, err := filepath.Abs(*repoFlag) + if err != nil { + return nil, fmt.Errorf("phpup test: resolve repo path: %w", err) + } + fixturesPath := *fixturesFlag + if !filepath.IsAbs(fixturesPath) { + fixturesPath = filepath.Join(absRepo, fixturesPath) + } + + selfAbs, err := resolveSelfBinary(*selfBinaryFlag) + if err != nil { + return nil, err + } + + return &testOpts{ + RegistryURI: *registryFlag, + OSes: splitNormalizedOS(*osFlag), + Arches: splitNormalizedArch(*archFlag), + PHPVersions: splitCSV(*phpFlag), + Fixtures: *fixturesFlag, + Repo: *repoFlag, + Parallel: *parallelFlag, + Cache: *cacheFlag, + AbsFixtures: fixturesPath, + AbsRepo: absRepo, + SelfBinary: selfAbs, + }, nil +} + +// resolveSelfBinary picks the absolute path to the phpup binary that should +// be mounted at /usr/local/bin/phpup inside the bare-ubuntu cell container. +// When override is non-empty it is absolutized and checked for existence — +// this is the path CI/Makefile use to supply a cross-compiled +// linux/ binary when the host arch or OS doesn't match the cell. +// When override is empty the caller is trusted (host is a linux/ +// runner) and os.Executable() is used. An empty override on a mismatched +// host will surface as `exec format error` when the container tries to run +// the darwin/amd64/wrong-arch binary, which is why CI threads --self-binary. +func resolveSelfBinary(override string) (string, error) { + if override != "" { + abs, err := filepath.Abs(override) + if err != nil { + return "", fmt.Errorf("phpup test: resolve --self-binary: %w", err) + } + if _, err := os.Stat(abs); err != nil { + return "", fmt.Errorf("phpup test: --self-binary missing: %w", err) + } + return abs, nil + } + self, err := os.Executable() + if err != nil { + return "", fmt.Errorf("phpup test: resolve self binary: %w", err) + } + abs, err := filepath.Abs(self) + if err != nil { + return "", fmt.Errorf("phpup test: absolutize self binary: %w", err) + } + return abs, nil +} + +// splitCSV splits s on commas, trims whitespace, and drops empty tokens. +// Returns an empty slice for empty input rather than a nil one-element +// slice containing the empty string. +func splitCSV(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// splitNormalizedOS splits s on commas and canonicalizes each entry to the +// short codename form used by Filter ("jammy"/"noble"). The ubuntu-XX.YY +// long form is accepted on input. Deduplicates while preserving order. +func splitNormalizedOS(s string) []string { + raw := splitCSV(s) + seen := map[string]struct{}{} + var out []string + for _, o := range raw { + // Canonicalize to jammy/noble; the reverse of normalizeOS's direction. + // normalizeOS takes short codename → long form; here we go long → short + // so cell labels (and docker image lookup via UbuntuImage which takes + // both) stay consistent. + switch o { + case "ubuntu-22.04": + o = "jammy" + case "ubuntu-24.04": + o = "noble" + } + if _, ok := seen[o]; !ok { + seen[o] = struct{}{} + out = append(out, o) + } + } + return out +} + +// splitNormalizedArch splits s on commas and canonicalizes each entry to +// the uname form ("x86_64"/"aarch64") via normalizeArch. Silently drops +// entries that normalizeArch can't canonicalize (returns empty). Dedupes +// while preserving first-seen order. +func splitNormalizedArch(s string) []string { + raw := splitCSV(s) + seen := map[string]struct{}{} + var out []string + for _, a := range raw { + a = normalizeArch(a) + if a == "" { + continue + } + if _, ok := seen[a]; !ok { + seen[a] = struct{}{} + out = append(out, a) + } + } + return out +} + +// cellResult captures the outcome of running one (os, arch, php) cell. +// A cell that matched zero fixtures records FixtureCount=0 and Err=nil +// (skip), which printCellSummary renders as "SKIP (no fixtures)". A cell +// that ran with matching fixtures but the docker invocation failed +// records the error and the count; the cell is counted as failed. +type cellResult struct { + OS, Arch, PHP string + FixtureCount int + Err error +} + +// runAllCells loads the fixtures file once, then walks the cartesian +// product of opts.OSes × opts.Arches × opts.PHPVersions. Each cell is run +// serially; the --parallel flag is reserved but unused in PR 3 (Task 2). +// A cell with no matching fixtures is not an error. The returned error +// is non-nil iff at least one cell's DockerRun failed. +func runAllCells(ctx context.Context, opts *testOpts) error { + set, err := Load(opts.AbsFixtures) + if err != nil { + return fmt.Errorf("phpup test: load fixtures: %w", err) + } + + var results []cellResult + for _, o := range opts.OSes { + for _, a := range opts.Arches { + for _, p := range opts.PHPVersions { + res := runCell(ctx, opts, set, o, a, p) + results = append(results, res) + } + } + } + + var failed []cellResult + for _, r := range results { + if r.Err != nil { + failed = append(failed, r) + } + } + printCellSummary(os.Stdout, results) + if len(failed) > 0 { + return fmt.Errorf("phpup test: %d cell(s) failed", len(failed)) + } + return nil +} + +// runCell executes one (os, arch, php) cell. Returns a cellResult whose +// Err is nil on success, nil on "no fixtures" (a skip), or the docker +// error otherwise. Never panics; all failure modes funnel through the +// cellResult.Err field so the caller can aggregate without special cases. +func runCell(ctx context.Context, opts *testOpts, set *FixtureSet, cellOS, cellArch, cellPHP string) cellResult { + res := cellResult{OS: cellOS, Arch: cellArch, PHP: cellPHP} + fixtures := set.Filter(cellOS, cellArch, cellPHP) + res.FixtureCount = len(fixtures) + if len(fixtures) == 0 { + fmt.Printf("phpup test: [skip] cell os=%s arch=%s php=%s: no fixtures\n", cellOS, cellArch, cellPHP) + return res + } + + image, err := build.UbuntuImage(cellOS) + if err != nil { + res.Err = err + return res + } + platform, err := build.DockerPlatform(cellArch) + if err != nil { + res.Err = err + return res + } + + mounts, env, err := buildCellMounts(opts, cellOS, cellArch, cellPHP) + if err != nil { + res.Err = err + return res + } + + runOpts := &build.DockerRunOpts{ + Image: image, + Platform: platform, + Mounts: mounts, + Env: env, + Cmd: []string{"/usr/local/bin/phpup", "internal", "test-cell", + "--os", cellOS, "--arch", cellArch, "--php", cellPHP, + "--fixtures", "/test-compat/fixtures.yaml", + "--probe", "/test-compat/probe.sh", + "--registry", "oci-layout:/registry", + }, + } + + fmt.Printf("phpup test: [run] cell os=%s arch=%s php=%s (%d fixtures)\n", cellOS, cellArch, cellPHP, len(fixtures)) + if err := build.DockerRun(ctx, runOpts); err != nil { + res.Err = fmt.Errorf("cell os=%s arch=%s php=%s: %w", cellOS, cellArch, cellPHP, err) + } + return res +} + +// buildCellMounts builds the docker mount list and container env for a +// cell. Expects opts.RegistryURI to be oci-layout:; if it's a +// remote URI, returns an error because mounting a remote registry inside +// the test container isn't wired up yet (PR 4 scope). +// +// Three mounts are produced: +// - oci-layout directory → /registry (read-only) +// - phpup self-binary → /usr/local/bin/phpup (read-only) +// - repo's test/compat → /test-compat (read-only) +// +// Env is minimal: PHPUP_REGISTRY=oci-layout:/registry so the inner +// phpup invocation inherits the right registry without needing to pass +// --registry through again. The --registry flag IS still passed on the +// inner Cmd (see runCell) so the inner binary works even if env-inherit +// semantics change. +func buildCellMounts(opts *testOpts, _, _, _ string) ([]build.Mount, map[string]string, error) { + const ociLayoutPrefix = "oci-layout:" + if !strings.HasPrefix(opts.RegistryURI, ociLayoutPrefix) { + return nil, nil, fmt.Errorf("phpup test: --registry must be oci-layout: (got %q); remote registries inside the test container are PR 4 scope", opts.RegistryURI) + } + layoutPath := strings.TrimPrefix(opts.RegistryURI, ociLayoutPrefix) + absLayout, err := filepath.Abs(layoutPath) + if err != nil { + return nil, nil, fmt.Errorf("resolve layout: %w", err) + } + + testCompat := filepath.Join(opts.AbsRepo, "test", "compat") + if _, err := os.Stat(testCompat); err != nil { + return nil, nil, fmt.Errorf("test/compat dir missing under repo: %w", err) + } + if _, err := os.Stat(opts.SelfBinary); err != nil { + return nil, nil, fmt.Errorf("self binary missing: %w", err) + } + + mounts := []build.Mount{ + {Host: absLayout, Container: "/registry", ReadOnly: true}, + {Host: opts.SelfBinary, Container: "/usr/local/bin/phpup", ReadOnly: true}, + {Host: testCompat, Container: "/test-compat", ReadOnly: true}, + } + env := map[string]string{ + "PHPUP_REGISTRY": "oci-layout:/registry", + } + return mounts, env, nil +} + +// printCellSummary writes a per-cell summary table to w. Entries are +// emitted in iteration order (matches the cartesian product walk in +// runAllCells). Each row is one of: +// +// / php= fixtures= — OK +// / php= fixtures=0 — SKIP (no fixtures) +// / php= fixtures= — FAIL: +// +// The Fprint* writes are intentionally fire-and-forget: the summary is +// purely informational and its write errors (e.g. broken pipe when the +// operator pipes into `head`) shouldn't clobber the real exit code +// that runAllCells computes from the aggregated cell results. +func printCellSummary(w io.Writer, results []cellResult) { + _, _ = fmt.Fprintln(w, "\n=== phpup test summary ===") + for _, r := range results { + status := "OK" + switch { + case r.Err != nil: + status = "FAIL: " + r.Err.Error() + case r.FixtureCount == 0: + status = "SKIP (no fixtures)" + } + _, _ = fmt.Fprintf(w, " %s/%s php=%s fixtures=%d — %s\n", r.OS, r.Arch, r.PHP, r.FixtureCount, status) + } +} diff --git a/internal/testsuite/runner_test.go b/internal/testsuite/runner_test.go new file mode 100644 index 0000000..c8932be --- /dev/null +++ b/internal/testsuite/runner_test.go @@ -0,0 +1,216 @@ +package testsuite + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/buildrush/setup-php/internal/build" +) + +// writeTestsuiteFixture creates a minimal repo layout under dir with: +// - test/compat/fixtures.yaml (a small synthetic FixtureSet) +// - test/compat/probe.sh (empty but exists) +// - out/oci-layout/ (empty dir to pass stat check) +// - a fake "phpup" binary file (content doesn't matter — only presence is checked) +// +// Returns the absolute path of the fake binary. All paths written under +// dir so t.TempDir cleanup removes everything on test completion. +func writeTestsuiteFixture(t *testing.T, dir string) string { + t.Helper() + mustWrite := func(rel, content string, mode os.FileMode) { + p := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil { + t.Fatalf("mkdir %s: %v", rel, err) + } + if err := os.WriteFile(p, []byte(content), mode); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + mustWrite("test/compat/fixtures.yaml", `fixtures: + - name: bare-84 + php-version: "8.4" + extensions: "" + coverage: none + - name: bare-82 + php-version: "8.2" + extensions: "" + coverage: none +`, 0o644) + mustWrite("test/compat/probe.sh", "#!/bin/bash\n", 0o755) + if err := os.MkdirAll(filepath.Join(dir, "out", "oci-layout"), 0o750); err != nil { + t.Fatal(err) + } + fakeBinary := filepath.Join(dir, "phpup-fake") + mustWrite(filepath.Base(fakeBinary), "", 0o755) + return fakeBinary +} + +// testOptsWith builds a testOpts pointing at the synthetic fixture dir +// that writeTestsuiteFixture produced. Registry URI points at the +// layout-like empty dir so buildCellMounts' stat check succeeds. +func testOptsWith(t *testing.T, dir, fakeBinary string, oses, arches, phps []string) *testOpts { + t.Helper() + absRepo, err := filepath.Abs(dir) + if err != nil { + t.Fatalf("abs(%s): %v", dir, err) + } + return &testOpts{ + RegistryURI: "oci-layout:" + filepath.Join(absRepo, "out", "oci-layout"), + OSes: oses, + Arches: arches, + PHPVersions: phps, + Fixtures: "test/compat/fixtures.yaml", + Repo: dir, + Parallel: 1, + AbsFixtures: filepath.Join(absRepo, "test", "compat", "fixtures.yaml"), + AbsRepo: absRepo, + SelfBinary: fakeBinary, + } +} + +func TestMain_UnknownArgs_Errors(t *testing.T) { + err := Main([]string{}) + if err == nil || !strings.Contains(err.Error(), "--php") { + t.Errorf("Main(empty) err = %v, want --php required", err) + } +} + +func TestMain_FixturesMissing_Errors(t *testing.T) { + // Point at a nonexistent fixtures path; Main should error when Load + // fails inside runAllCells (os.ReadFile on a missing file). + err := Main([]string{"--php", "8.4", "--fixtures", "/tmp/phpup-testsuite-nonexistent.yaml"}) + if err == nil { + t.Fatal("Main with missing fixtures: want error, got nil") + } +} + +func TestRunAllCells_AllPass(t *testing.T) { + dir := t.TempDir() + fakeBinary := writeTestsuiteFixture(t, dir) + opts := testOptsWith(t, dir, fakeBinary, []string{"jammy"}, []string{"x86_64"}, []string{"8.4"}) + + var runnerCalls int + restore := build.SetRunner(func(_ context.Context, ro *build.DockerRunOpts) error { + runnerCalls++ + // Sanity: the mounted /registry exists, the cmd is correct. + if len(ro.Cmd) < 3 || ro.Cmd[0] != "/usr/local/bin/phpup" || ro.Cmd[1] != "internal" || ro.Cmd[2] != "test-cell" { + return errors.New("unexpected Cmd: " + strings.Join(ro.Cmd, " ")) + } + return nil + }) + defer restore() + + if err := runAllCells(context.Background(), opts); err != nil { + t.Fatalf("runAllCells: %v", err) + } + if runnerCalls != 1 { + t.Errorf("runnerCalls = %d, want 1", runnerCalls) + } +} + +func TestRunAllCells_CellFailure_Aggregates(t *testing.T) { + dir := t.TempDir() + fakeBinary := writeTestsuiteFixture(t, dir) + opts := testOptsWith(t, dir, fakeBinary, []string{"jammy"}, []string{"x86_64"}, []string{"8.4", "8.2"}) + + restore := build.SetRunner(func(_ context.Context, ro *build.DockerRunOpts) error { + if strings.Contains(strings.Join(ro.Cmd, " "), "--php 8.2") { + return errors.New("synthetic failure for 8.2") + } + return nil + }) + defer restore() + + err := runAllCells(context.Background(), opts) + if err == nil || !strings.Contains(err.Error(), "1 cell(s) failed") { + t.Errorf("runAllCells err = %v, want 1 cell(s) failed", err) + } +} + +func TestRunAllCells_NoFixturesForCell_SkipsNotFails(t *testing.T) { + dir := t.TempDir() + fakeBinary := writeTestsuiteFixture(t, dir) + // PHP 7.4 has no matching fixture → skip, don't fail. + opts := testOptsWith(t, dir, fakeBinary, []string{"jammy"}, []string{"x86_64"}, []string{"7.4"}) + + var runnerCalls int + restore := build.SetRunner(func(_ context.Context, _ *build.DockerRunOpts) error { + runnerCalls++ + return nil + }) + defer restore() + + err := runAllCells(context.Background(), opts) + if err != nil { + t.Fatalf("runAllCells with no matches should not error: %v", err) + } + if runnerCalls != 0 { + t.Errorf("runnerCalls = %d, want 0 (no matching fixtures)", runnerCalls) + } +} + +func TestBuildCellMounts_RemoteRegistry_Errors(t *testing.T) { + opts := &testOpts{ + RegistryURI: "ghcr.io/buildrush", + AbsRepo: "/tmp", + SelfBinary: "/tmp/phpup", + } + _, _, err := buildCellMounts(opts, "jammy", "x86_64", "8.4") + if err == nil || !strings.Contains(err.Error(), "oci-layout:") { + t.Errorf("buildCellMounts err = %v, want oci-layout: requirement", err) + } +} + +func TestParseTestFlags_SelfBinaryOverride(t *testing.T) { + dir := t.TempDir() + fake := filepath.Join(dir, "phpup-fake") + if err := os.WriteFile(fake, []byte(""), 0o755); err != nil { + t.Fatal(err) + } + opts, err := parseTestFlags([]string{ + "--php", "8.4", + "--self-binary", fake, + }) + if err != nil { + t.Fatalf("parseTestFlags: %v", err) + } + absFake, err := filepath.Abs(fake) + if err != nil { + t.Fatalf("filepath.Abs(%s): %v", fake, err) + } + // filepath.Abs may normalize (e.g. resolve /var → /private/var on macOS). + // Accept either the exact input or its absolutized form. + if opts.SelfBinary != fake && opts.SelfBinary != absFake { + t.Errorf("SelfBinary = %q, want %q (or abs %q)", opts.SelfBinary, fake, absFake) + } +} + +func TestParseTestFlags_SelfBinaryMissing_Errors(t *testing.T) { + _, err := parseTestFlags([]string{ + "--php", "8.4", + "--self-binary", "/tmp/definitely-not-here-XYZ", + }) + if err == nil || !strings.Contains(err.Error(), "--self-binary") { + t.Errorf("parseTestFlags err = %v, want mentions --self-binary", err) + } +} + +func TestCellSummary_FormatsOK(t *testing.T) { + var buf bytes.Buffer + printCellSummary(&buf, []cellResult{ + {OS: "jammy", Arch: "x86_64", PHP: "8.4", FixtureCount: 3}, + {OS: "noble", Arch: "aarch64", PHP: "8.2", FixtureCount: 0}, + {OS: "jammy", Arch: "x86_64", PHP: "8.1", FixtureCount: 2, Err: errors.New("boom")}, + }) + out := buf.String() + for _, want := range []string{"jammy/x86_64 php=8.4 fixtures=3 — OK", "SKIP (no fixtures)", "FAIL: boom"} { + if !strings.Contains(out, want) { + t.Errorf("summary missing %q\nfull output:\n%s", want, out) + } + } +} diff --git a/internal/testsuite/testcell.go b/internal/testsuite/testcell.go new file mode 100644 index 0000000..c4fa332 --- /dev/null +++ b/internal/testsuite/testcell.go @@ -0,0 +1,481 @@ +package testsuite + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" +) + +// InstallFunc runs `phpup install` for a single fixture with the given env +// overlay. The default is realInstall which execs os.Args[0] as a child +// process with the env composed into os.Environ(). Tests override via +// SetInstaller. +type InstallFunc func(ctx context.Context, env map[string]string, stdout, stderr io.Writer) error + +// installerMu protects currInstaller during SetInstaller swaps. Mirrors the +// pattern in internal/build.SetRunner: tests using SetInstaller MUST NOT run +// in parallel since the installer is a package-level global. +var ( + installerMu sync.Mutex + currInstaller InstallFunc = realInstall +) + +// SetInstaller swaps the package-level InstallFunc and returns a restore +// function that callers MUST defer to revert. Same pattern as +// internal/build.SetRunner — tests substitute a fake to exercise fixture +// orchestration without invoking the real install path. +func SetInstaller(fn InstallFunc) (restore func()) { + installerMu.Lock() + prev := currInstaller + currInstaller = fn + installerMu.Unlock() + return func() { + installerMu.Lock() + currInstaller = prev + installerMu.Unlock() + } +} + +// getInstaller returns the currently-installed InstallFunc under the mutex. +func getInstaller() InstallFunc { + installerMu.Lock() + defer installerMu.Unlock() + return currInstaller +} + +// realInstall spawns a child process invoking the current binary's +// default subcommand (phpup install). The child inherits os.Environ() +// plus the env overlay passed in. +func realInstall(ctx context.Context, env map[string]string, stdout, stderr io.Writer) error { + self, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve self: %w", err) + } + //nolint:gosec // G204 false positive: self is os.Executable() — the path of the + // currently-running phpup binary — and argv is empty (no user-controlled args), + // so `phpup install` is dispatched as the default subcommand. + cmd := exec.CommandContext(ctx, self) + cmd.Env = composeEnv(os.Environ(), env) + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() +} + +// composeEnv merges overlay into base, with overlay winning on key conflicts. +// Output is deterministic: keys from base retain their original relative +// order; overlay keys are appended alphabetically. Tests rely on the +// alphabetical overlay ordering. +func composeEnv(base []string, overlay map[string]string) []string { + keys := map[string]struct{}{} + for k := range overlay { + keys[k] = struct{}{} + } + out := make([]string, 0, len(base)+len(overlay)) + for _, kv := range base { + eq := strings.IndexByte(kv, '=') + if eq < 0 { + out = append(out, kv) + continue + } + k := kv[:eq] + if _, ok := keys[k]; !ok { + out = append(out, kv) + } + } + names := make([]string, 0, len(overlay)) + for k := range overlay { + names = append(names, k) + } + sort.Strings(names) + for _, k := range names { + out = append(out, k+"="+overlay[k]) + } + return out +} + +// testCellOpts is the parsed flag set for `phpup internal test-cell`. +type testCellOpts struct { + OS, Arch, PHP string + FixturesPath string + ProbePath string + RegistryURI string +} + +// parseTestCellFlags parses the flag tail for `phpup internal test-cell`. +// Uses ContinueOnError so callers get an error back instead of a process +// exit — keeps the surface testable without os.Exit acrobatics. +func parseTestCellFlags(args []string) (*testCellOpts, error) { + fs := flag.NewFlagSet("phpup internal test-cell", flag.ContinueOnError) + osFlag := fs.String("os", "", "Ubuntu flavour (jammy|noble); REQUIRED") + archFlag := fs.String("arch", "", "Target arch (x86_64|aarch64); REQUIRED") + phpFlag := fs.String("php", "", "PHP minor version (e.g. 8.4); REQUIRED") + fixturesFlag := fs.String("fixtures", "/test-compat/fixtures.yaml", "Path to fixtures YAML") + probeFlag := fs.String("probe", "/test-compat/probe.sh", "Path to probe.sh") + registryFlag := fs.String("registry", "", "Registry URI for phpup install (propagated via PHPUP_REGISTRY env)") + if err := fs.Parse(args); err != nil { + return nil, err + } + if *osFlag == "" || *archFlag == "" || *phpFlag == "" { + return nil, errors.New("phpup internal test-cell: --os, --arch, and --php are required") + } + return &testCellOpts{ + OS: *osFlag, Arch: *archFlag, PHP: *phpFlag, + FixturesPath: *fixturesFlag, ProbePath: *probeFlag, + RegistryURI: *registryFlag, + }, nil +} + +// fixtureOutcome captures the result of running a single fixture. Err is +// nil on full success, or set to the first failure encountered (install, +// probe, parse, or invariant assertion). +type fixtureOutcome struct { + Name string + Err error + ProbeOut map[string]any +} + +// RunTestCell is the entry point for `phpup internal test-cell`. Invokes +// phpup install + probe.sh for each matching fixture, asserts invariants, +// aggregates outcomes, and returns a non-nil error if any fixture failed. +func RunTestCell(ctx context.Context, args []string) error { + opts, err := parseTestCellFlags(args) + if err != nil { + return err + } + set, err := Load(opts.FixturesPath) + if err != nil { + return fmt.Errorf("phpup internal test-cell: load fixtures: %w", err) + } + fixtures := set.Filter(opts.OS, opts.Arch, opts.PHP) + if len(fixtures) == 0 { + fmt.Printf("phpup internal test-cell: no matching fixtures for os=%s arch=%s php=%s\n", opts.OS, opts.Arch, opts.PHP) + return nil + } + + if _, err := os.Stat(opts.ProbePath); err != nil { + return fmt.Errorf("phpup internal test-cell: probe.sh unavailable: %w", err) + } + + var outcomes []fixtureOutcome + for i := range fixtures { + outcomes = append(outcomes, runFixture(ctx, opts, &fixtures[i])) + } + + printFixtureSummary(os.Stdout, outcomes) + var failed int + for _, o := range outcomes { + if o.Err != nil { + failed++ + } + } + if failed > 0 { + return fmt.Errorf("phpup internal test-cell: %d of %d fixture(s) failed", failed, len(outcomes)) + } + return nil +} + +// runFixture composes the env for the fixture, runs phpup install, then +// probe.sh, parses the probe output, and asserts invariants. Returns a +// fixtureOutcome with Err set on the first failure encountered. +func runFixture(ctx context.Context, opts *testCellOpts, f *Fixture) fixtureOutcome { + out := fixtureOutcome{Name: f.Name} + fmt.Printf("phpup internal test-cell: [run] fixture=%s\n", f.Name) + + // 1. Snapshot env + PATH for probe.sh's delta computation. + workDir, err := os.MkdirTemp("", "phpup-test-cell-"+f.Name+"-*") + if err != nil { + out.Err = fmt.Errorf("mktemp: %w", err) + return out + } + // Leave workDir around for debug; caller decides to clean up. In a + // container the filesystem is discarded on exit anyway. + + envBefore := filepath.Join(workDir, "env-before") + pathBefore := filepath.Join(workDir, "path-before") + iniKeys := filepath.Join(workDir, "ini-keys.txt") + probeOut := filepath.Join(workDir, "probe-out.json") + stdoutLog := filepath.Join(workDir, "install-stdout.log") + stderrLog := filepath.Join(workDir, "install-stderr.log") + + if err := writeEnvSnapshot(envBefore); err != nil { + out.Err = fmt.Errorf("snapshot env: %w", err) + return out + } + if err := os.WriteFile(pathBefore, []byte(os.Getenv("PATH")), 0o600); err != nil { + out.Err = fmt.Errorf("snapshot PATH: %w", err) + return out + } + if err := writeIniKeysFromFixture(iniKeys, f); err != nil { + out.Err = fmt.Errorf("write ini-keys: %w", err) + return out + } + + // 2. Run phpup install. + stdoutFile, err := os.Create(stdoutLog) + if err != nil { + out.Err = err + return out + } + defer func() { _ = stdoutFile.Close() }() + stderrFile, err := os.Create(stderrLog) + if err != nil { + out.Err = err + return out + } + defer func() { _ = stderrFile.Close() }() + + installEnv := composeInstallEnv(opts, f) + if err := getInstaller()(ctx, installEnv, stdoutFile, stderrFile); err != nil { + stderrBytes, _ := os.ReadFile(stderrLog) + out.Err = fmt.Errorf("phpup install failed: %w; stderr tail:\n%s", err, tailBytes(stderrBytes, 20)) + return out + } + + // 3. Invoke probe.sh via bash so the script doesn't need an exec bit + // (mount boundaries sometimes strip it) and so gosec's taint analysis + // has a fixed executable to reason about. + //nolint:gosec // G204 false positive: bash is a fixed compile-time argv[0]; + // opts.ProbePath is set by the --probe flag (trusted operator input, + // defaulted to the container-mounted path); remaining argv is a fixed + // set of filepath.Join-derived temp paths. + cmd := exec.CommandContext(ctx, "bash", opts.ProbePath, + probeOut, envBefore, pathBefore, iniKeys) + probeStdout, err := cmd.CombinedOutput() + if err != nil { + out.Err = fmt.Errorf("probe.sh failed: %w; output:\n%s", err, string(probeStdout)) + return out + } + + // 4. Parse probe output. + pdata, err := os.ReadFile(probeOut) + if err != nil { + out.Err = fmt.Errorf("read probe output: %w", err) + return out + } + var parsed map[string]any + if err := json.Unmarshal(pdata, &parsed); err != nil { + out.Err = fmt.Errorf("parse probe JSON: %w; raw:\n%s", err, string(pdata)) + return out + } + out.ProbeOut = parsed + + // 5. Assert invariants. + if err := assertFixtureInvariants(f, parsed); err != nil { + out.Err = err + return out + } + return out +} + +// writeEnvSnapshot captures os.Environ() into path, one KEY=value per line, +// sorted alphabetically. Used by probe.sh to diff against the post-install +// env for the env_delta computation. +func writeEnvSnapshot(path string) error { + env := os.Environ() + sort.Strings(env) + data := strings.Join(env, "\n") + "\n" + return os.WriteFile(path, []byte(data), 0o600) +} + +// writeIniKeysFromFixture emits the ini keys probe.sh should query. +// Combines a curated default list with any keys present in the fixture's +// INIValues. Newline-separated; duplicates are de-duped. +func writeIniKeysFromFixture(path string, f *Fixture) error { + defaults := []string{ + "memory_limit", "date.timezone", "error_reporting", + "display_errors", "post_max_size", "upload_max_filesize", + "max_execution_time", + } + keys := map[string]struct{}{} + for _, d := range defaults { + keys[d] = struct{}{} + } + for _, kv := range strings.Split(f.INIValues, ",") { + kv = strings.TrimSpace(kv) + if kv == "" { + continue + } + if eq := strings.IndexByte(kv, '='); eq > 0 { + keys[strings.TrimSpace(kv[:eq])] = struct{}{} + } + } + names := make([]string, 0, len(keys)) + for k := range keys { + names = append(names, k) + } + sort.Strings(names) + data := strings.Join(names, "\n") + "\n" + return os.WriteFile(path, []byte(data), 0o600) +} + +// composeInstallEnv builds the env overlay phpup install expects for this +// fixture: INPUT_PHP-VERSION, INPUT_EXTENSIONS, INPUT_INI-VALUES, +// INPUT_COVERAGE, INPUT_INI-FILE. PHPUP_REGISTRY inherited from the +// cell opts (which got it from --registry). +// +// plan.FromEnv reads RUNNER_OS and RUNNER_ARCH (GHA convention) to build +// the lockfile key `php::::`. Inside our bare-ubuntu +// test container there are no GHA variables set, so without these the +// key becomes malformed (e.g. `php:8.4://nts`) and resolve fails. Inject +// them explicitly: OS is always Linux in the container; ARCH is mapped +// from the cell's canonical arch back to GHA's X64/ARM64 spelling so +// plan.normalizeArch round-trips cleanly. +func composeInstallEnv(opts *testCellOpts, f *Fixture) map[string]string { + env := map[string]string{ + "INPUT_PHP-VERSION": f.PHPVersion, + "INPUT_EXTENSIONS": f.Extensions, + "INPUT_INI-VALUES": f.INIValues, + "INPUT_COVERAGE": f.Coverage, + "RUNNER_OS": "Linux", + "RUNNER_ARCH": runnerArchFromCellArch(opts.Arch), + } + if f.INIFile != "" { + env["INPUT_INI-FILE"] = f.INIFile + } + if opts.RegistryURI != "" { + env["PHPUP_REGISTRY"] = opts.RegistryURI + } + return env +} + +// runnerArchFromCellArch maps the test-cell's canonical arch to GHA's +// RUNNER_ARCH values: x86_64 -> X64, aarch64 -> ARM64. Unknown arches +// are passed through unchanged so downstream normalizeArch can still +// make a best-effort match. +func runnerArchFromCellArch(arch string) string { + switch arch { + case "x86_64": + return "X64" + case "aarch64": + return "ARM64" + default: + return arch + } +} + +// assertFixtureInvariants checks the probe output against what the fixture +// requested: +// - php_version non-empty and starts with fixture.PHPVersion. +// - every extension in fixture.Extensions (minus exclusions) is present +// in probe.extensions (case-insensitive, ignoring "none" reset marker). +// - every ini key/value in fixture.INIValues matches probe.ini[key]. +func assertFixtureInvariants(f *Fixture, probe map[string]any) error { + // php_version + pv, _ := probe["php_version"].(string) + if pv == "" { + return fmt.Errorf("probe has no php_version") + } + if !strings.HasPrefix(pv, f.PHPVersion+".") && pv != f.PHPVersion { + return fmt.Errorf("php_version = %q, want prefix %q", pv, f.PHPVersion+".") + } + + // extensions + wantedExts, excluded := parseExtensionsList(f.Extensions) + loaded := map[string]struct{}{} + if extArr, ok := probe["extensions"].([]any); ok { + for _, e := range extArr { + if s, ok := e.(string); ok { + loaded[strings.ToLower(s)] = struct{}{} + } + } + } + for _, want := range wantedExts { + w := strings.ToLower(want) + if _, ok := loaded[w]; !ok { + return fmt.Errorf("extension %q not loaded (got %s)", want, setKeys(loaded)) + } + } + for _, nope := range excluded { + n := strings.ToLower(nope) + if _, ok := loaded[n]; ok { + return fmt.Errorf("extension %q was excluded but still loaded", nope) + } + } + + // ini-values + iniMap, _ := probe["ini"].(map[string]any) + for _, kv := range strings.Split(f.INIValues, ",") { + kv = strings.TrimSpace(kv) + if kv == "" { + continue + } + eq := strings.IndexByte(kv, '=') + if eq < 0 { + continue + } + key := strings.TrimSpace(kv[:eq]) + want := strings.TrimSpace(kv[eq+1:]) + got, _ := iniMap[key].(string) + if got != want { + return fmt.Errorf("ini[%s] = %q, want %q", key, got, want) + } + } + return nil +} + +// parseExtensionsList splits fixture.Extensions into (wanted, excluded). +// Tokens starting with ':' are exclusions; "none" is an ignored reset marker. +func parseExtensionsList(s string) (wanted, excluded []string) { + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + switch { + case p == "" || p == "none": + continue + case strings.HasPrefix(p, ":"): + excluded = append(excluded, strings.TrimPrefix(p, ":")) + default: + wanted = append(wanted, p) + } + } + return wanted, excluded +} + +// setKeys returns a sorted, bracketed rendering of m's keys. Used only for +// error diagnostics (so the operator can see what WAS loaded when an +// expected extension is missing). +func setKeys(m map[string]struct{}) string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return "[" + strings.Join(keys, ",") + "]" +} + +// printFixtureSummary writes a per-fixture summary table to w. Errors +// rendered inline: "OK" or "FAIL: ". Fire-and-forget Fprintf — +// summary is informational; the real exit status is computed in RunTestCell. +func printFixtureSummary(w io.Writer, outcomes []fixtureOutcome) { + _, _ = fmt.Fprintln(w, "\n=== test-cell fixture summary ===") + for _, o := range outcomes { + status := "OK" + if o.Err != nil { + status = "FAIL: " + o.Err.Error() + } + _, _ = fmt.Fprintf(w, " %-40s %s\n", o.Name, status) + } +} + +// tailBytes returns the last `lines` lines of b as a newline-joined string. +// Used for compact install-stderr tails in error diagnostics. +func tailBytes(b []byte, lines int) string { + s := bufio.NewScanner(strings.NewReader(string(b))) + var rows []string + for s.Scan() { + rows = append(rows, s.Text()) + } + if len(rows) > lines { + rows = rows[len(rows)-lines:] + } + return strings.Join(rows, "\n") +} diff --git a/internal/testsuite/testcell_test.go b/internal/testsuite/testcell_test.go new file mode 100644 index 0000000..1f6d520 --- /dev/null +++ b/internal/testsuite/testcell_test.go @@ -0,0 +1,307 @@ +package testsuite + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// fakeProbeScript writes a bash script at /probe.sh that, when invoked, +// writes a synthetic probe JSON to its $1 argument. The JSON mimics what +// the real probe.sh would emit for a fixed PHP environment (version, +// extensions, ini-values). Tests use this to exercise RunTestCell's +// orchestration path without needing a real PHP binary. +func fakeProbeScript(t *testing.T, dir, php string, exts []string, iniKV map[string]string) string { + t.Helper() + extQuoted := make([]string, 0, len(exts)) + for _, e := range exts { + extQuoted = append(extQuoted, `"`+e+`"`) + } + iniPairs := make([]string, 0, len(iniKV)) + for k, v := range iniKV { + iniPairs = append(iniPairs, `"`+k+`":"`+v+`"`) + } + script := `#!/usr/bin/env bash +set -euo pipefail +cat > "$1" < …`, + // so no exec bit is required on the script itself. + if err := os.WriteFile(probePath, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + return probePath +} + +// fakeInstallOK does nothing and returns nil. Probe output is synthetic +// in tests, so no real install work needs to happen. +func fakeInstallOK(_ context.Context, _ map[string]string, _, _ io.Writer) error { + return nil +} + +// fakeInstallFail simulates an install failure: writes a synthetic error +// to stderr and returns a non-nil error. Used to assert that install +// failures propagate into the fixtureOutcome. +func fakeInstallFail(_ context.Context, _ map[string]string, _, stderr io.Writer) error { + _, _ = stderr.Write([]byte("synthetic install failure\n")) + return errors.New("install boom") +} + +// writeTestCellFixtures serializes a slice of Fixture into a synthetic +// fixtures.yaml at /fixtures.yaml and returns the path. Used by the +// RunTestCell tests to control the fixture set per test case. +func writeTestCellFixtures(t *testing.T, dir string, entries []Fixture) string { + t.Helper() + path := filepath.Join(dir, "fixtures.yaml") + fs := FixtureSet{Fixtures: entries} + out := "fixtures:\n" + for i := range fs.Fixtures { + f := &fs.Fixtures[i] + out += " - name: " + f.Name + "\n" + out += ` php-version: "` + f.PHPVersion + `"` + "\n" + out += ` extensions: "` + f.Extensions + `"` + "\n" + out += ` ini-values: "` + f.INIValues + `"` + "\n" + out += ` coverage: ` + f.Coverage + "\n" + if f.INIFile != "" { + out += ` ini-file: ` + f.INIFile + "\n" + } + if f.Arch != "" { + out += ` arch: ` + f.Arch + "\n" + } + } + if err := os.WriteFile(path, []byte(out), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestRunTestCell_AllPass(t *testing.T) { + dir := t.TempDir() + fx := writeTestCellFixtures(t, dir, []Fixture{ + {Name: "bare", PHPVersion: "8.4", Coverage: "none"}, + {Name: "with-redis", PHPVersion: "8.4", Extensions: "redis", Coverage: "none"}, + }) + probe := fakeProbeScript(t, dir, "8.4.20", []string{"core", "redis"}, map[string]string{"memory_limit": "-1"}) + + restore := SetInstaller(fakeInstallOK) + defer restore() + + err := RunTestCell(context.Background(), []string{ + "--os", "jammy", "--arch", "x86_64", "--php", "8.4", + "--fixtures", fx, "--probe", probe, + }) + if err != nil { + t.Fatalf("RunTestCell: %v", err) + } +} + +func TestRunTestCell_FixtureInvariantFails_ExtensionMissing(t *testing.T) { + dir := t.TempDir() + fx := writeTestCellFixtures(t, dir, []Fixture{ + {Name: "expects-redis", PHPVersion: "8.4", Extensions: "redis", Coverage: "none"}, + }) + // Probe returns only "core" — redis missing → fixture should fail. + probe := fakeProbeScript(t, dir, "8.4.20", []string{"core"}, nil) + + restore := SetInstaller(fakeInstallOK) + defer restore() + + err := RunTestCell(context.Background(), []string{ + "--os", "jammy", "--arch", "x86_64", "--php", "8.4", + "--fixtures", fx, "--probe", probe, + }) + if err == nil || !strings.Contains(err.Error(), "fixture(s) failed") { + t.Fatalf("RunTestCell err = %v, want mention fixture(s) failed", err) + } +} + +func TestRunTestCell_INIValueMismatch_Fails(t *testing.T) { + dir := t.TempDir() + fx := writeTestCellFixtures(t, dir, []Fixture{ + {Name: "memory", PHPVersion: "8.4", INIValues: "memory_limit=256M", Coverage: "none"}, + }) + probe := fakeProbeScript(t, dir, "8.4.20", []string{"core"}, map[string]string{"memory_limit": "-1"}) + + restore := SetInstaller(fakeInstallOK) + defer restore() + + err := RunTestCell(context.Background(), []string{ + "--os", "jammy", "--arch", "x86_64", "--php", "8.4", + "--fixtures", fx, "--probe", probe, + }) + if err == nil || !strings.Contains(err.Error(), "fixture(s) failed") { + t.Fatalf("RunTestCell err = %v, want failure", err) + } +} + +func TestRunTestCell_InstallFails_Propagates(t *testing.T) { + dir := t.TempDir() + fx := writeTestCellFixtures(t, dir, []Fixture{ + {Name: "bare", PHPVersion: "8.4", Coverage: "none"}, + }) + probe := fakeProbeScript(t, dir, "8.4.20", []string{"core"}, nil) + + restore := SetInstaller(fakeInstallFail) + defer restore() + + err := RunTestCell(context.Background(), []string{ + "--os", "jammy", "--arch", "x86_64", "--php", "8.4", + "--fixtures", fx, "--probe", probe, + }) + if err == nil || !strings.Contains(err.Error(), "1 of 1 fixture(s) failed") { + t.Fatalf("RunTestCell err = %v, want 1 of 1 failure", err) + } +} + +func TestRunTestCell_NoFixturesForCell_NoError(t *testing.T) { + dir := t.TempDir() + fx := writeTestCellFixtures(t, dir, []Fixture{ + {Name: "other", PHPVersion: "8.5", Coverage: "none"}, + }) + probe := fakeProbeScript(t, dir, "8.4.20", []string{"core"}, nil) + + restore := SetInstaller(fakeInstallOK) + defer restore() + + err := RunTestCell(context.Background(), []string{ + "--os", "jammy", "--arch", "x86_64", "--php", "8.4", + "--fixtures", fx, "--probe", probe, + }) + if err != nil { + t.Errorf("RunTestCell no-match = %v, want nil", err) + } +} + +func TestRunTestCell_MissingFlags_Errors(t *testing.T) { + for _, args := range [][]string{ + {"--arch", "x86_64", "--php", "8.4"}, + {"--os", "jammy", "--php", "8.4"}, + {"--os", "jammy", "--arch", "x86_64"}, + } { + err := RunTestCell(context.Background(), args) + if err == nil { + t.Errorf("RunTestCell(%v) want error", args) + } + } +} + +func TestComposeEnv_OverlayReplaces(t *testing.T) { + base := []string{"FOO=bar", "BAZ=qux"} + overlay := map[string]string{"FOO": "new", "NEW": "val"} + got := composeEnv(base, overlay) + want := []string{"BAZ=qux", "FOO=new", "NEW=val"} + if !reflect.DeepEqual(got, want) { + t.Errorf("composeEnv = %v, want %v", got, want) + } +} + +func TestParseExtensionsList(t *testing.T) { + cases := []struct { + in, wantW, wantE string + }{ + {"", "", ""}, + {"none", "", ""}, + {"redis,grpc", "redis,grpc", ""}, + {"redis,:opcache", "redis", "opcache"}, + {"none,redis,:xdebug,grpc", "redis,grpc", "xdebug"}, + } + for _, c := range cases { + w, e := parseExtensionsList(c.in) + if strings.Join(w, ",") != c.wantW || strings.Join(e, ",") != c.wantE { + t.Errorf("parseExtensionsList(%q) = (%v, %v), want (%q, %q)", c.in, w, e, c.wantW, c.wantE) + } + } +} + +func TestAssertFixtureInvariants_PHPVersionMismatch(t *testing.T) { + probe := map[string]any{"php_version": "8.2.10", "extensions": []any{"core"}, "ini": map[string]any{}} + err := assertFixtureInvariants(&Fixture{PHPVersion: "8.4"}, probe) + if err == nil || !strings.Contains(err.Error(), "php_version") { + t.Errorf("err = %v, want php_version mismatch", err) + } +} + +func TestAssertFixtureInvariants_ExcludedExtStillLoaded(t *testing.T) { + probe := map[string]any{"php_version": "8.4.0", "extensions": []any{"core", "opcache"}, "ini": map[string]any{}} + err := assertFixtureInvariants(&Fixture{PHPVersion: "8.4", Extensions: ":opcache"}, probe) + if err == nil || !strings.Contains(err.Error(), "excluded") { + t.Errorf("err = %v, want excluded-but-loaded", err) + } +} + +func TestWriteEnvSnapshot_DeterministicOrder(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "env") + if err := writeEnvSnapshot(path); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + sorted := append([]string(nil), lines...) + for i := 1; i < len(sorted); i++ { + if sorted[i] < sorted[i-1] { + t.Errorf("line %d not sorted: %q < %q", i, sorted[i], sorted[i-1]) + } + } +} + +func TestProbeOutputRoundTrip_JSONParses(t *testing.T) { + // Sanity: the synthetic probe output shape parses as map[string]any. + sample := `{"php_version":"8.4.0","sapi":"cli","zts":false,"extensions":["core"],"ini":{},"env_delta":[],"path_additions":[]}` + var got map[string]any + if err := json.Unmarshal([]byte(sample), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["php_version"] != "8.4.0" { + t.Error("parse mismatch") + } +} + +func TestComposeInstallEnv_SetsRunnerEnv(t *testing.T) { + opts := &testCellOpts{Arch: "x86_64", RegistryURI: "oci-layout:/reg"} + env := composeInstallEnv(opts, &Fixture{PHPVersion: "8.4", Extensions: "", Coverage: "none"}) + if env["RUNNER_OS"] != "Linux" { + t.Errorf("RUNNER_OS = %q, want Linux", env["RUNNER_OS"]) + } + if env["RUNNER_ARCH"] != "X64" { + t.Errorf("RUNNER_ARCH = %q, want X64", env["RUNNER_ARCH"]) + } + // Aarch64 mapping. + opts.Arch = "aarch64" + env = composeInstallEnv(opts, &Fixture{PHPVersion: "8.4"}) + if env["RUNNER_ARCH"] != "ARM64" { + t.Errorf("RUNNER_ARCH aarch64 = %q, want ARM64", env["RUNNER_ARCH"]) + } +} + +func TestPrintFixtureSummary_IncludesAllRows(t *testing.T) { + var buf bytes.Buffer + printFixtureSummary(&buf, []fixtureOutcome{ + {Name: "alpha"}, + {Name: "beta", Err: errors.New("nope")}, + }) + out := buf.String() + for _, want := range []string{"alpha", "OK", "beta", "FAIL: nope"} { + if !strings.Contains(out, want) { + t.Errorf("summary missing %q in:\n%s", want, out) + } + } +} diff --git a/test/smoke/local-ci.sh b/test/smoke/local-ci.sh index b7928e9..b0d2bb5 100755 --- a/test/smoke/local-ci.sh +++ b/test/smoke/local-ci.sh @@ -21,6 +21,17 @@ # Requires: docker, jq, oras, yq on the host. zstd + tar used inside containers. set -euo pipefail +# test/smoke/local-ci.sh — soon-to-be-deprecated. +# +# PR 3 of the local+CI unification rollout introduced `phpup test` + +# `make ci-cell` / `make ci` as the future local-smoke path. This script +# still runs the OLD shell-orchestrated path (GHCR pull via oras + bash +# loops) because the new path needs pre-populated bundles that PR 4 will +# wire via the unified ci.yml. Once that lands, this script becomes a +# thin wrapper — tracked for deletion. +# +# Prefer `make ci-cell OS=... ARCH=... PHP=...` for new work. +echo "NOTE: test/smoke/local-ci.sh will be superseded by 'make ci-cell' / 'make ci' once PR 4 lands. See CHANGELOG or docs/superpowers/specs/2026-04-23-local-ci-unification-design.md." >&2 PHP_VERSION="8.4" # Default: exercise BOTH arches. x86_64 runs under docker's QEMU on ARM hosts