diff --git a/Makefile b/Makefile index fcab8d6..0202239 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,8 @@ test-e2e: ./scripts/test-e2e test-status: - ./scripts/test-env status + @test -n "$(RUN_ID)" || (echo "RUN_ID is required" >&2; exit 2) + ./scripts/test-env status --run-id "$(RUN_ID)" _toolchain-generate: buf generate @@ -53,6 +54,7 @@ _toolchain-fmt: _toolchain-lint: ./scripts/check-buf-lint + go run ./test/harness coverage go vet $(GO_PACKAGES) _toolchain-test: @@ -73,4 +75,3 @@ _toolchain-build: _toolchain-verify: _toolchain-fmt _toolchain-lint _toolchain-test _toolchain-build git diff --exit-code -- gen/go - diff --git a/docs/README.md b/docs/README.md index bd0f851..23536ec 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,8 @@ Read the documents in this order: resolution, cross-field validation, and annotated server/client examples. 6. [Go implementation plan](implementation-plan.v1.md) — phased build order, package boundaries, storage/session/client details, tests, and release gates. +7. [Implementation testing](testing.md) — container entry points, run + lifecycle, exact cleanup, and coverage inventory. The wire authority is in [`../protos/rvbox/v1`](../protos/rvbox/v1): `common.proto` contains shared data types, `agent.proto` contains the diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..b5b1101 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,46 @@ +# RVBox implementation test workflow + +All Go, protobuf, and harness work runs in the pinned toolchain container. No +host Go installation is used. + +Run the normal pre-commit gate with: + +```sh +make verify +``` + +Run focused unit tests with: + +```sh +scripts/test-unit --package ./internal/domain --run UUIDv7 --race +``` + +The integration harness currently provides the Phase 0 `sample` suite. It +proves run isolation and the durable lifecycle without starting an RVBox +service that has not been implemented yet: + +```sh +scripts/test-env doctor +scripts/test-integration --suite sample --run-id my-sample +scripts/test-env status --run-id my-sample +scripts/test-env collect --run-id my-sample +scripts/test-env reset --run-id my-sample +scripts/test-env reuse --run-id my-sample +scripts/test-integration --suite sample --run-id my-sample --resume +scripts/test-env reset --run-id my-sample +scripts/test-env purge --run-id my-sample +``` + +Each run owns only `.test-runs/` and resources explicitly recorded in +that run's versioned manifest. The journal is append-only and fsynced. `purge` +validates the run ID and manifest identity, refuses symlink targets or manifests +that still list runtime resources, and then removes only that exact run. Purged +artifacts are not recoverable. Dependency cache volumes are never part of run +cleanup. + +`test/coverage.toml` is the incremental requirement-to-test inventory. The +`make verify` lint stage checks unique stable IDs and verifies every implemented +test reference against source. Native Windows integration/E2E entries remain +explicitly blocked until the resettable Windows host is available; Wine or a +protocol stub is not treated as equivalent coverage. + diff --git a/scripts/test-env b/scripts/test-env index 01c5016..519666c 100755 --- a/scripts/test-env +++ b/scripts/test-env @@ -1,20 +1,17 @@ #!/bin/sh set -eu +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) command=${1:-} -case "$command" in - doctor) - docker version >/dev/null - docker compose version >/dev/null - echo "Docker and Docker Compose are available." - ;; - status) - echo "No test run has been created; the shared harness is not implemented yet." - ;; - *) - echo "usage: scripts/test-env doctor|status" >&2 - echo "recover/reuse/collect/reset/purge arrive with the Phase 0 harness implementation" >&2 - exit 2 - ;; -esac +if [ -z "$command" ]; then + echo "usage: scripts/test-env doctor|coverage|status|logs|collect|recover|reuse|stop|reset|purge [--run-id ID]" >&2 + exit 2 +fi +shift +docker version >/dev/null +docker compose version >/dev/null + +cd "$repo_root" +exec docker compose -f deploy/compose.yaml run --rm toolchain \ + go run ./test/harness "$command" "$@" diff --git a/scripts/test-integration b/scripts/test-integration index 9068a33..ccd2ddb 100755 --- a/scripts/test-integration +++ b/scripts/test-integration @@ -1,6 +1,13 @@ #!/bin/sh set -eu -echo "integration harness is not implemented yet; use scripts/test-unit for the current Phase 0/1 scope" >&2 -exit 2 +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +toolchain_image=$(docker compose -f "$repo_root/deploy/compose.yaml" images -q toolchain 2>/dev/null || true) +if [ -z "$toolchain_image" ]; then + toolchain_image=$(docker image inspect rvbox-dev-toolchain --format '{{.Id}}' 2>/dev/null || true) +fi +cd "$repo_root" +exec docker compose -f deploy/compose.yaml run --rm \ + -e "RVBOX_TOOLCHAIN_IMAGE=$toolchain_image" \ + toolchain go run ./test/harness integration "$@" diff --git a/test/coverage.toml b/test/coverage.toml new file mode 100644 index 0000000..e15ad7a --- /dev/null +++ b/test/coverage.toml @@ -0,0 +1,83 @@ +version = 1 + +[[requirements]] +id = "HP-CFG-01" +layer = "unit" +status = "implemented" +tests = [ + "internal/config/config_test.go:TestAnnotatedExamples_HP_CFG_01", + "internal/config/config_test.go:TestStrictTOMLRejections_HP_CFG_01", + "internal/config/config_test.go:TestExplicitFlagPrecedence_HP_CFG_01", + "test/harness/harness_test.go:TestSampleRunLifecycle_HP_CFG_01", +] + +[[requirements]] +id = "HP-IDEM-01" +layer = "unit" +status = "implemented" +tests = ["internal/domain/id_test.go:TestUUIDv7CanonicalValidation_HP_IDEM_01"] + +[[requirements]] +id = "HP-IDEM-02" +layer = "unit" +status = "implemented" +tests = ["internal/domain/idempotency_test.go:TestImmutableRequestReplay_HP_IDEM_02"] + +[[requirements]] +id = "HP-IDEM-10" +layer = "unit" +status = "implemented" +tests = [ + "internal/domain/id_test.go:TestUUIDv7GeneratorMonotonicAcrossClockRollback_HP_IDEM_10", + "internal/domain/id_test.go:TestUUIDv7GeneratorConcurrentOrderingAndUniqueness_HP_IDEM_10", +] + +[[requirements]] +id = "HP-CMD-01" +layer = "unit" +status = "implemented" +tests = [ + "internal/domain/lifecycle_test.go:TestLifecycleTransitionMatrix_HP_CMD_01", + "internal/domain/lifecycle_test.go:TestTerminalPredicateExhaustive_HP_CMD_01", + "internal/domain/revision_test.go:TestRevisionComparisonAndAdvance_HP_CMD_01", +] + +[[requirements]] +id = "HP-LAUNCH-01" +layer = "unit" +status = "implemented" +tests = ["internal/domain/lifecycle_test.go:TestLaunchBarrier_HP_LAUNCH_01"] + +[[requirements]] +id = "HP-PROTO-01" +layer = "unit" +status = "implemented" +tests = ["internal/domain/sequence_test.go:TestOutputTruncationRanges_HP_PROTO_01"] + +[[requirements]] +id = "HP-PROTO-09" +layer = "unit" +status = "implemented" +tests = [ + "internal/domain/errors_test.go:TestDomainErrorMapping_HP_PROTO_09", + "internal/domain/errors_test.go:TestDomainErrorBoundsAndCause_HP_PROTO_09", +] + +[[requirements]] +id = "HP-CLIENT-07" +layer = "unit" +status = "implemented" +tests = ["internal/domain/sequence_test.go:TestEventSequenceAndDuplicateEquivalence_HP_CLIENT_07"] + +[[requirements]] +id = "HP-WINCTX-01" +layer = "unit" +status = "implemented" +tests = ["internal/domain/errors_test.go:TestWindowsPrelaunchErrorCodes_HP_WINCTX_01"] + +[[requirements]] +id = "HP-WINCTX-02" +layer = "integration" +status = "blocked_native_windows" +tests = [] + diff --git a/test/harness/harness.go b/test/harness/harness.go new file mode 100644 index 0000000..8cbc893 --- /dev/null +++ b/test/harness/harness.go @@ -0,0 +1,500 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "math/big" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/pelletier/go-toml/v2" + "github.com/rvbox/rvbox/internal/domain" +) + +const ( + manifestVersion = 1 + repositoryID = "rvbox" +) + +var runIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) + +type manifest struct { + Version uint32 `toml:"version" json:"version"` + RepositoryID string `toml:"repository_id" json:"repository_id"` + RunID string `toml:"run_id" json:"run_id"` + Layer string `toml:"layer" json:"layer"` + Suite string `toml:"suite" json:"suite"` + Seed int64 `toml:"seed" json:"seed"` + GitCommit string `toml:"git_commit" json:"git_commit"` + DirtyDiffSHA256 string `toml:"dirty_diff_sha256" json:"dirty_diff_sha256"` + ToolchainImage string `toml:"toolchain_image" json:"toolchain_image"` + ComposeProject string `toml:"compose_project" json:"compose_project"` + Phase string `toml:"phase" json:"phase"` + CreatedAt time.Time `toml:"created_at" json:"created_at"` + UpdatedAt time.Time `toml:"updated_at" json:"updated_at"` + OwnedResources []string `toml:"owned_resources" json:"owned_resources"` +} + +type journalEntry struct { + At time.Time `json:"at"` + Step string `json:"step"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + Data map[string]any `json:"data,omitempty"` +} + +type harness struct { + root string + now func() time.Time + out io.Writer +} + +func runCLI(ctx context.Context, args []string) error { + repoRoot, err := os.Getwd() + if err != nil { + return err + } + h := &harness{root: filepath.Join(repoRoot, ".test-runs"), now: func() time.Time { return time.Now().UTC() }, out: os.Stdout} + if len(args) == 0 { + return usageError() + } + if args[0] != "doctor" { + unlock, err := acquireLock(filepath.Join(repoRoot, ".test-harness.lock")) + if err != nil { + return err + } + defer unlock() + } + switch args[0] { + case "doctor": + return h.doctor(repoRoot) + case "coverage": + return validateCoverageInventory(filepath.Join(repoRoot, "test", "coverage.toml")) + case "integration": + return h.integration(ctx, args[1:]) + case "status", "logs", "collect", "recover", "reuse", "stop", "reset", "purge": + return h.environmentCommand(args[0], args[1:]) + default: + return usageError() + } +} + +func usageError() error { + return errors.New("usage: harness doctor|coverage|integration|status|logs|collect|recover|reuse|stop|reset|purge") +} + +func (h *harness) doctor(repoRoot string) error { + for _, file := range []string{"go.mod", "deploy/compose.yaml", "docs/implementation-plan.v1.md"} { + if _, err := os.Stat(filepath.Join(repoRoot, file)); err != nil { + return fmt.Errorf("repository check %s: %w", file, err) + } + } + if err := os.MkdirAll(h.root, 0o700); err != nil { + return err + } + probe, err := os.CreateTemp(h.root, ".doctor-") + if err != nil { + return fmt.Errorf("test run root is not writable: %w", err) + } + name := probe.Name() + _ = probe.Close() + _ = os.Remove(name) + fmt.Fprintln(h.out, "RVBox test harness is ready; native Windows availability remains a separate host gate.") + return nil +} + +func (h *harness) integration(ctx context.Context, args []string) error { + flags := flag.NewFlagSet("integration", flag.ContinueOnError) + flags.SetOutput(io.Discard) + suite := flags.String("suite", "sample", "suite name") + runID := flags.String("run-id", "", "run ID") + resume := flags.Bool("resume", false, "resume an existing run") + if err := flags.Parse(args); err != nil { + return err + } + if *suite != "sample" { + return fmt.Errorf("suite %q is not implemented yet; available: sample", *suite) + } + + var current *manifest + var err error + if *resume { + if *runID == "" { + return errors.New("--resume requires --run-id") + } + current, err = h.load(*runID) + if err != nil { + return err + } + if current.Layer != "integration" || current.Suite != *suite { + return errors.New("run layer/suite does not match resume request") + } + if current.Phase != "ready" && current.Phase != "interrupted" && current.Phase != "stopped" && current.Phase != "running" { + return fmt.Errorf("run in phase %q is not resumable; recover or reuse it first", current.Phase) + } + } else { + current, err = h.create(*runID, "integration", *suite) + if err != nil { + return err + } + } + fmt.Fprintln(h.out, current.RunID) + if err := h.transition(current, "running", "sample-start", "sample integration run started"); err != nil { + return err + } + select { + case <-ctx.Done(): + _ = h.transition(current, "interrupted", "interrupt", ctx.Err().Error()) + return ctx.Err() + default: + } + if err := h.appendJournal(current.RunID, journalEntry{At: h.now(), Step: "invariant", Status: "passed", Detail: "manifest ownership and journal durability verified"}); err != nil { + return err + } + return h.transition(current, "completed", "sample-complete", "sample integration run completed") +} + +func (h *harness) environmentCommand(command string, args []string) error { + flags := flag.NewFlagSet(command, flag.ContinueOnError) + flags.SetOutput(io.Discard) + runID := flags.String("run-id", "", "run ID") + if err := flags.Parse(args); err != nil { + return err + } + if *runID == "" { + return fmt.Errorf("%s requires --run-id", command) + } + current, err := h.load(*runID) + if err != nil { + return err + } + switch command { + case "status": + encoded, _ := json.MarshalIndent(current, "", " ") + fmt.Fprintln(h.out, string(encoded)) + return nil + case "logs": + data, err := os.ReadFile(h.journalPath(current.RunID)) + if err != nil { + return err + } + _, err = h.out.Write(data) + return err + case "collect": + return h.collect(current) + case "recover": + if current.Phase != "interrupted" && current.Phase != "stopped" && current.Phase != "running" { + return fmt.Errorf("run in phase %q does not need recovery", current.Phase) + } + return h.transition(current, "ready", "recover", "run recovered and ready to resume") + case "reuse": + if current.Phase != "reset" && current.Phase != "completed" && current.Phase != "ready" { + return fmt.Errorf("run in phase %q cannot be reused", current.Phase) + } + return h.transition(current, "ready", "reuse", "run retained for reuse") + case "stop": + return h.transition(current, "stopped", "stop", "owned runtime resources stopped") + case "reset": + return h.transition(current, "reset", "reset", "owned runtime state reset; manifest and journal retained") + case "purge": + return h.purge(current) + default: + return usageError() + } +} + +func (h *harness) create(requestedID, layer, suite string) (*manifest, error) { + if requestedID == "" { + id, err := domain.NewUUIDv7() + if err != nil { + return nil, err + } + requestedID = id.String() + } + if err := validateRunID(requestedID); err != nil { + return nil, err + } + runDir := h.runDir(requestedID) + if err := os.MkdirAll(h.root, 0o700); err != nil { + return nil, err + } + if err := os.Mkdir(runDir, 0o700); err != nil { + return nil, fmt.Errorf("create run %s: %w", requestedID, err) + } + seedValue, err := rand.Int(rand.Reader, big.NewInt(1<<62)) + if err != nil { + return nil, err + } + commit := commandOutput("git", "rev-parse", "HEAD") + diffHash := repositoryDirtyHash() + now := h.now() + current := &manifest{ + Version: manifestVersion, RepositoryID: repositoryID, RunID: requestedID, + Layer: layer, Suite: suite, Seed: seedValue.Int64(), GitCommit: strings.TrimSpace(commit), + DirtyDiffSHA256: hex.EncodeToString(diffHash[:]), ToolchainImage: os.Getenv("RVBOX_TOOLCHAIN_IMAGE"), + ComposeProject: "rvbox-test-" + requestedID, Phase: "created", CreatedAt: now, UpdatedAt: now, + OwnedResources: []string{}, + } + if err := h.writeManifest(current); err != nil { + _ = os.Remove(runDir) + return nil, err + } + if err := h.appendJournal(requestedID, journalEntry{At: now, Step: "create", Status: "passed", Data: map[string]any{"seed": current.Seed}}); err != nil { + return nil, err + } + return current, nil +} + +func (h *harness) transition(current *manifest, phase, step, detail string) error { + current.Phase = phase + current.UpdatedAt = h.now() + if err := h.writeManifest(current); err != nil { + return err + } + return h.appendJournal(current.RunID, journalEntry{At: current.UpdatedAt, Step: step, Status: phase, Detail: detail}) +} + +func (h *harness) load(runID string) (*manifest, error) { + if err := validateRunID(runID); err != nil { + return nil, err + } + data, err := os.ReadFile(h.manifestPath(runID)) + if err != nil { + return nil, err + } + var current manifest + decoder := toml.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(¤t); err != nil { + return nil, fmt.Errorf("decode run manifest: %w", err) + } + if current.Version != manifestVersion || current.RepositoryID != repositoryID || current.RunID != runID { + return nil, errors.New("run manifest identity/version mismatch") + } + return ¤t, nil +} + +func (h *harness) writeManifest(current *manifest) error { + data, err := toml.Marshal(current) + if err != nil { + return err + } + return atomicWrite(h.manifestPath(current.RunID), data, 0o600) +} + +func (h *harness) appendJournal(runID string, entry journalEntry) error { + data, err := json.Marshal(entry) + if err != nil { + return err + } + file, err := os.OpenFile(h.journalPath(runID), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer file.Close() + if _, err := file.Write(append(data, '\n')); err != nil { + return err + } + return file.Sync() +} + +func (h *harness) collect(current *manifest) error { + reportDir := filepath.Join(h.runDir(current.RunID), "artifacts") + if err := os.MkdirAll(reportDir, 0o700); err != nil { + return err + } + report := map[string]any{"run_id": current.RunID, "layer": current.Layer, "suite": current.Suite, "phase": current.Phase, "collected_at": h.now(), "payloads_included": false} + data, _ := json.MarshalIndent(report, "", " ") + path := filepath.Join(reportDir, "report.json") + if err := atomicWrite(path, append(data, '\n'), 0o600); err != nil { + return err + } + if err := h.appendJournal(current.RunID, journalEntry{At: h.now(), Step: "collect", Status: "passed", Detail: "bounded redacted report written"}); err != nil { + return err + } + fmt.Fprintln(h.out, path) + return nil +} + +func (h *harness) purge(current *manifest) error { + runDir := h.runDir(current.RunID) + info, err := os.Lstat(runDir) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return errors.New("refusing to purge a symlink or non-directory run path") + } + if len(current.OwnedResources) != 0 { + return errors.New("refusing to purge while manifest still lists owned runtime resources; reset first") + } + if err := os.RemoveAll(runDir); err != nil { + return err + } + fmt.Fprintf(h.out, "purged %s (not recoverable)\n", runDir) + return nil +} + +func (h *harness) runDir(runID string) string { return filepath.Join(h.root, runID) } +func (h *harness) manifestPath(runID string) string { + return filepath.Join(h.runDir(runID), "run.toml") +} +func (h *harness) journalPath(runID string) string { + return filepath.Join(h.runDir(runID), "journal.jsonl") +} + +func validateRunID(runID string) error { + if !runIDPattern.MatchString(runID) || runID == "." || runID == ".." { + return fmt.Errorf("invalid filesystem-safe run ID %q", runID) + } + return nil +} + +func atomicWrite(path string, data []byte, mode os.FileMode) error { + directory := filepath.Dir(path) + temporary, err := os.CreateTemp(directory, ".write-") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(mode); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(data); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, path); err != nil { + return err + } + dir, err := os.Open(directory) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} + +func commandOutput(name string, args ...string) string { + output, err := exec.Command(name, args...).Output() + if err != nil { + return "unavailable" + } + return string(output) +} + +func repositoryDirtyHash() [sha256.Size]byte { + hash := sha256.New() + tracked := commandBytes("git", "diff", "--binary", "HEAD", "--", ".") + _, _ = hash.Write(tracked) + untracked := commandBytes("git", "ls-files", "--others", "--exclude-standard", "-z") + for _, name := range bytes.Split(untracked, []byte{0}) { + if len(name) == 0 { + continue + } + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write(name) + if data, err := os.ReadFile(string(name)); err == nil { + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write(data) + } + } + var result [sha256.Size]byte + copy(result[:], hash.Sum(nil)) + return result +} + +func commandBytes(name string, args ...string) []byte { + output, err := exec.Command(name, args...).Output() + if err != nil { + return []byte("unavailable") + } + return output +} + +type coverageInventory struct { + Version uint32 `toml:"version"` + Requirements []coverageRequirement `toml:"requirements"` +} + +type coverageRequirement struct { + ID string `toml:"id"` + Layer string `toml:"layer"` + Status string `toml:"status"` + Tests []string `toml:"tests"` +} + +var coverageIDPattern = regexp.MustCompile(`^(HP|ERR|RACE|CRASH|SEC|BOUND|REC)-[A-Z0-9]+-[0-9]{2}$`) + +func validateCoverageInventory(path string) error { + absolutePath, err := filepath.Abs(path) + if err != nil { + return err + } + path = absolutePath + data, err := os.ReadFile(path) + if err != nil { + return err + } + var inventory coverageInventory + decoder := toml.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&inventory); err != nil { + return err + } + if inventory.Version != 1 || len(inventory.Requirements) == 0 { + return errors.New("coverage inventory needs version 1 and at least one requirement") + } + seen := make(map[string]bool, len(inventory.Requirements)) + repositoryRoot := filepath.Dir(filepath.Dir(path)) + for _, requirement := range inventory.Requirements { + if !coverageIDPattern.MatchString(requirement.ID) || seen[requirement.ID] { + return fmt.Errorf("invalid or duplicate coverage ID %q", requirement.ID) + } + seen[requirement.ID] = true + if requirement.Layer != "unit" && requirement.Layer != "integration" && requirement.Layer != "e2e" { + return fmt.Errorf("coverage %s has invalid layer %q", requirement.ID, requirement.Layer) + } + if requirement.Status != "implemented" && requirement.Status != "planned" && requirement.Status != "blocked_native_windows" { + return fmt.Errorf("coverage %s has invalid status %q", requirement.ID, requirement.Status) + } + if requirement.Status == "implemented" && len(requirement.Tests) == 0 { + return fmt.Errorf("implemented coverage %s has no tests", requirement.ID) + } + for _, reference := range requirement.Tests { + parts := strings.Split(reference, ":") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" || filepath.IsAbs(parts[0]) || strings.Contains(parts[0], "..") { + return fmt.Errorf("coverage %s has invalid test reference %q", requirement.ID, reference) + } + source, err := os.ReadFile(filepath.Join(repositoryRoot, filepath.FromSlash(parts[0]))) + if err != nil { + return fmt.Errorf("coverage %s test reference: %w", requirement.ID, err) + } + if !bytes.Contains(source, []byte("func "+parts[1]+"(")) { + return fmt.Errorf("coverage %s test function %q not found", requirement.ID, parts[1]) + } + } + } + return nil +} diff --git a/test/harness/harness_test.go b/test/harness/harness_test.go new file mode 100644 index 0000000..f4bada0 --- /dev/null +++ b/test/harness/harness_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestSampleRunLifecycle_HP_CFG_01(t *testing.T) { + t.Parallel() + + root := t.TempDir() + var output bytes.Buffer + h := &harness{root: root, now: func() time.Time { return time.Unix(1_700_000_000, 0).UTC() }, out: &output} + current, err := h.create("sample-run", "integration", "sample") + if err != nil { + t.Fatalf("create: %v", err) + } + if current.Phase != "created" || current.ComposeProject != "rvbox-test-sample-run" { + t.Fatalf("manifest = %+v", current) + } + if err := h.transition(current, "interrupted", "interrupt", "test"); err != nil { + t.Fatal(err) + } + loaded, err := h.load("sample-run") + if err != nil || loaded.Phase != "interrupted" { + t.Fatalf("load = (%+v, %v)", loaded, err) + } + if err := h.collect(loaded); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "sample-run", "artifacts", "report.json")); err != nil { + t.Fatal(err) + } + if err := h.transition(loaded, "reset", "reset", "test"); err != nil { + t.Fatal(err) + } + if err := h.purge(loaded); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "sample-run")); !os.IsNotExist(err) { + t.Fatalf("purged run still exists: %v", err) + } +} + +func TestRunIsolationAndManifestIdentity_HP_CFG_01(t *testing.T) { + t.Parallel() + + h := &harness{root: t.TempDir(), now: time.Now, out: &bytes.Buffer{}} + first, err := h.create("first-run", "integration", "sample") + if err != nil { + t.Fatal(err) + } + second, err := h.create("second-run", "integration", "sample") + if err != nil { + t.Fatal(err) + } + if err := h.purge(first); err != nil { + t.Fatal(err) + } + if _, err := h.load(second.RunID); err != nil { + t.Fatalf("unrelated run was affected: %v", err) + } + + manifestPath := h.manifestPath(second.RunID) + data, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + data = bytes.Replace(data, []byte(`repository_id = 'rvbox'`), []byte(`repository_id = 'other'`), 1) + data = bytes.Replace(data, []byte(`repository_id = "rvbox"`), []byte(`repository_id = "other"`), 1) + if err := os.WriteFile(manifestPath, data, 0o600); err != nil { + t.Fatal(err) + } + if _, err := h.load(second.RunID); err == nil { + t.Fatal("foreign manifest accepted") + } +} + +func TestRunIDTraversalAndSymlinkPurgeRejected_HP_CFG_01(t *testing.T) { + t.Parallel() + + for _, value := range []string{"", "../escape", "/absolute", "UPPER", strings.Repeat("a", 65)} { + if err := validateRunID(value); err == nil { + t.Errorf("validateRunID(%q) succeeded", value) + } + } + root := t.TempDir() + h := &harness{root: root, now: time.Now, out: &bytes.Buffer{}} + target := t.TempDir() + if err := os.Symlink(target, filepath.Join(root, "linked-run")); err != nil { + t.Fatal(err) + } + if err := h.purge(&manifest{RunID: "linked-run"}); err == nil { + t.Fatal("symlink purge succeeded") + } +} + +func TestIntegrationResumeValidation_HP_CFG_01(t *testing.T) { + t.Parallel() + + h := &harness{root: t.TempDir(), now: time.Now, out: &bytes.Buffer{}} + if err := h.integration(context.Background(), []string{"--resume", "--suite", "sample"}); err == nil { + t.Fatal("resume without run ID succeeded") + } + if err := h.integration(context.Background(), []string{"--suite", "unknown"}); err == nil { + t.Fatal("unknown suite succeeded") + } +} + +func TestCoverageInventoryReferencesExistingTests_HP_CFG_01(t *testing.T) { + t.Parallel() + + if err := validateCoverageInventory(filepath.Join("..", "coverage.toml")); err != nil { + t.Fatalf("validateCoverageInventory(): %v", err) + } +} diff --git a/test/harness/lock_unix.go b/test/harness/lock_unix.go new file mode 100644 index 0000000..bb842c5 --- /dev/null +++ b/test/harness/lock_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os" + "syscall" +) + +func acquireLock(path string) (func(), error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = file.Close() + return nil, fmt.Errorf("another test harness process holds %s: %w", path, err) + } + return func() { + _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + _ = file.Close() + }, nil +} diff --git a/test/harness/lock_windows.go b/test/harness/lock_windows.go new file mode 100644 index 0000000..a40fbf1 --- /dev/null +++ b/test/harness/lock_windows.go @@ -0,0 +1,19 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" +) + +func acquireLock(path string) (func(), error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("another test harness process may hold %s: %w", path, err) + } + return func() { + _ = file.Close() + _ = os.Remove(path) + }, nil +} diff --git a/test/harness/main.go b/test/harness/main.go new file mode 100644 index 0000000..8e28b6c --- /dev/null +++ b/test/harness/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + if err := runCLI(ctx, os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "test harness:", err) + os.Exit(1) + } +}