summaryrefslogtreecommitdiff
path: root/integrationtests
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-21 16:13:40 +0200
committerPaul Buetow <paul@buetow.org>2026-02-21 16:13:40 +0200
commit2c2cbe07f5e10fdb996e2a039cde84be44866f18 (patch)
tree97654c2c9ba9fc91cb569ab0521c4c67247abc0b /integrationtests
parenteebc9cba272c1b20296ab998262298c5da99e047 (diff)
Add integration test framework: plan, workload binary, harness scaffolding
- INTEGRATIONTESTS-PLAN.md: full design for e2e integration tests - integrationtests/cmd/ioworkload: standalone binary with 13 I/O scenarios - integrationtests/expectations.go: ExpectedEvent type and assertion helpers - integrationtests/parse.go: .ior.zst parser producing TestResult - Export IterRecord and LoadFromFile in flamegraph package - Fix TraceId -> TraceID, StringByName returns error instead of panic Amp-Thread-ID: https://ampcode.com/threads/T-019c8031-c106-757a-95a0-7a5457163ce7 Co-authored-by: Amp <amp@ampcode.com>
Diffstat (limited to 'integrationtests')
-rw-r--r--integrationtests/README.md30
-rw-r--r--integrationtests/cmd/ioworkload/main.go46
-rw-r--r--integrationtests/cmd/ioworkload/scenarios.go336
-rw-r--r--integrationtests/doc.go4
-rw-r--r--integrationtests/expectations.go83
-rw-r--r--integrationtests/parse.go25
6 files changed, 524 insertions, 0 deletions
diff --git a/integrationtests/README.md b/integrationtests/README.md
new file mode 100644
index 0000000..741d14d
--- /dev/null
+++ b/integrationtests/README.md
@@ -0,0 +1,30 @@
+# Integration Tests
+
+End-to-end integration tests for ior. A standalone I/O workload binary performs
+deterministic syscalls, ior traces the workload by PID via BPF, and the test
+harness asserts the captured `.ior.zst` output matches expectations.
+
+## Prerequisites
+
+- Built `ior` binary and `ior.bpf.o` (`mage all`)
+- Root privileges or `CAP_BPF` (required for BPF tracepoint attachment)
+
+## Running
+
+```bash
+mage integrationTest
+```
+
+This builds everything (ior, ioworkload) and runs the test suite with `sudo`.
+
+Tests automatically skip with `t.Skip` when not running as root.
+
+## Structure
+
+- `cmd/ioworkload/` — Standalone binary performing known I/O patterns
+- `harness.go` — Test orchestration (start ior + workload, collect output)
+- `parse.go` — Parse `.ior.zst` into assertable `TestResult`
+- `expectations.go` — `ExpectedEvent` type and assertion helpers
+- `*_test.go` — One file per syscall family
+
+See `../INTEGRATIONTESTS-PLAN.md` for the full design.
diff --git a/integrationtests/cmd/ioworkload/main.go b/integrationtests/cmd/ioworkload/main.go
new file mode 100644
index 0000000..3ed9cb2
--- /dev/null
+++ b/integrationtests/cmd/ioworkload/main.go
@@ -0,0 +1,46 @@
+// ioworkload is a standalone binary that performs deterministic I/O operations
+// for integration testing of ior. It prints its PID to stdout, sleeps to allow
+// ior to attach BPF tracepoints, then executes the requested I/O scenario.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "slices"
+ "time"
+)
+
+const startupDelay = 2 * time.Second
+
+func main() {
+ scenario := flag.String("scenario", "", "I/O scenario to execute")
+ flag.Parse()
+
+ if *scenario == "" {
+ fmt.Fprintln(os.Stderr, "usage: ioworkload --scenario=<name>")
+ os.Exit(2)
+ }
+
+ run, ok := scenarios[*scenario]
+ if !ok {
+ fmt.Fprintf(os.Stderr, "unknown scenario: %s\navailable scenarios:\n", *scenario)
+ var names []string
+ for name := range scenarios {
+ names = append(names, name)
+ }
+ slices.Sort(names)
+ for _, name := range names {
+ fmt.Fprintf(os.Stderr, " %s\n", name)
+ }
+ os.Exit(2)
+ }
+
+ fmt.Println(os.Getpid())
+ time.Sleep(startupDelay)
+
+ if err := run(); err != nil {
+ fmt.Fprintf(os.Stderr, "scenario %s failed: %v\n", *scenario, err)
+ os.Exit(1)
+ }
+}
diff --git a/integrationtests/cmd/ioworkload/scenarios.go b/integrationtests/cmd/ioworkload/scenarios.go
new file mode 100644
index 0000000..41563d8
--- /dev/null
+++ b/integrationtests/cmd/ioworkload/scenarios.go
@@ -0,0 +1,336 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "syscall"
+)
+
+// scenarios maps scenario names to their execution functions.
+var scenarios = map[string]func() error{
+ "open-basic": openBasic,
+ "open-creat": openCreat,
+ "readwrite-basic": readwriteBasic,
+ "close-basic": closeBasic,
+ "dup-basic": dupBasic,
+ "fcntl-dupfd": fcntlDupfd,
+ "rename-basic": renameBasic,
+ "link-basic": linkBasic,
+ "unlink-basic": unlinkBasic,
+ "dir-basic": dirBasic,
+ "stat-basic": statBasic,
+ "sync-basic": syncBasic,
+ "truncate-basic": truncateBasic,
+}
+
+func makeTempDir(prefix string) (string, func(), error) {
+ dir, err := os.MkdirTemp("", fmt.Sprintf("ioworkload-%s-", prefix))
+ if err != nil {
+ return "", nil, fmt.Errorf("create temp dir: %w", err)
+ }
+ cleanup := func() { os.RemoveAll(dir) }
+ return dir, cleanup, nil
+}
+
+// openBasic opens a file with O_RDWR|O_CREAT, then closes it.
+func openBasic() error {
+ dir, cleanup, err := makeTempDir("open-basic")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "testfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ return syscall.Close(fd)
+}
+
+// openCreat uses the creat syscall to create a file.
+func openCreat() error {
+ dir, cleanup, err := makeTempDir("open-creat")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "creatfile.txt")
+ fd, err := syscall.Creat(path, 0o644)
+ if err != nil {
+ return fmt.Errorf("creat: %w", err)
+ }
+ return syscall.Close(fd)
+}
+
+// readwriteBasic opens a file, writes data, seeks to start, reads it back.
+func readwriteBasic() error {
+ dir, cleanup, err := makeTempDir("readwrite-basic")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "rwfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ data := []byte("hello from ioworkload")
+ if _, err := syscall.Write(fd, data); err != nil {
+ return fmt.Errorf("write: %w", err)
+ }
+ if _, err := syscall.Seek(fd, 0, 0); err != nil {
+ return fmt.Errorf("seek: %w", err)
+ }
+
+ buf := make([]byte, len(data))
+ if _, err := syscall.Read(fd, buf); err != nil {
+ return fmt.Errorf("read: %w", err)
+ }
+ return nil
+}
+
+// closeBasic opens multiple files and closes them.
+func closeBasic() error {
+ dir, cleanup, err := makeTempDir("close-basic")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ var fds []int
+ for i := range 3 {
+ path := filepath.Join(dir, fmt.Sprintf("closefile-%d.txt", i))
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open %d: %w", i, err)
+ }
+ fds = append(fds, fd)
+ }
+ for _, fd := range fds {
+ if err := syscall.Close(fd); err != nil {
+ return fmt.Errorf("close fd %d: %w", fd, err)
+ }
+ }
+ return nil
+}
+
+// dupBasic opens a file, dups the fd, writes via the dup, closes both.
+func dupBasic() error {
+ dir, cleanup, err := makeTempDir("dup-basic")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "dupfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ newFd, err := syscall.Dup(fd)
+ if err != nil {
+ return fmt.Errorf("dup: %w", err)
+ }
+ defer syscall.Close(newFd)
+
+ if _, err := syscall.Write(newFd, []byte("via dup")); err != nil {
+ return fmt.Errorf("write via dup: %w", err)
+ }
+ return nil
+}
+
+// fcntlDupfd uses fcntl F_DUPFD to duplicate a file descriptor.
+func fcntlDupfd() error {
+ dir, cleanup, err := makeTempDir("fcntl-dupfd")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "fcntlfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ newFd, _, errno := syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), syscall.F_DUPFD, 0)
+ if errno != 0 {
+ return fmt.Errorf("fcntl F_DUPFD: %w", errno)
+ }
+ defer syscall.Close(int(newFd))
+
+ if _, err := syscall.Write(int(newFd), []byte("via fcntl")); err != nil {
+ return fmt.Errorf("write via fcntl dup: %w", err)
+ }
+ return nil
+}
+
+// renameBasic creates a file and renames it.
+func renameBasic() error {
+ dir, cleanup, err := makeTempDir("rename-basic")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ oldPath := filepath.Join(dir, "oldname.txt")
+ newPath := filepath.Join(dir, "newname.txt")
+
+ fd, err := syscall.Open(oldPath, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ if err := syscall.Close(fd); err != nil {
+ return fmt.Errorf("close: %w", err)
+ }
+
+ return syscall.Rename(oldPath, newPath)
+}
+
+// linkBasic creates a file, hard links it, and symlinks it.
+func linkBasic() error {
+ dir, cleanup, err := makeTempDir("link-basic")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ origPath := filepath.Join(dir, "original.txt")
+ fd, err := syscall.Open(origPath, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ if err := syscall.Close(fd); err != nil {
+ return fmt.Errorf("close: %w", err)
+ }
+
+ hardPath := filepath.Join(dir, "hardlink.txt")
+ if err := syscall.Link(origPath, hardPath); err != nil {
+ return fmt.Errorf("link: %w", err)
+ }
+
+ symPath := filepath.Join(dir, "symlink.txt")
+ if err := syscall.Symlink(origPath, symPath); err != nil {
+ return fmt.Errorf("symlink: %w", err)
+ }
+
+ buf := make([]byte, 256)
+ if _, err := syscall.Readlink(symPath, buf); err != nil {
+ return fmt.Errorf("readlink: %w", err)
+ }
+ return nil
+}
+
+// unlinkBasic creates a file and unlinks it.
+func unlinkBasic() error {
+ dir, cleanup, err := makeTempDir("unlink-basic")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "unlinkme.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ if err := syscall.Close(fd); err != nil {
+ return fmt.Errorf("close: %w", err)
+ }
+
+ return syscall.Unlink(path)
+}
+
+// dirBasic creates a directory, checks access, then removes it.
+func dirBasic() error {
+ dir, cleanup, err := makeTempDir("dir-basic")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ subDir := filepath.Join(dir, "subdir")
+ if err := syscall.Mkdir(subDir, 0o755); err != nil {
+ return fmt.Errorf("mkdir: %w", err)
+ }
+ if err := syscall.Access(subDir, syscall.F_OK); err != nil {
+ return fmt.Errorf("access: %w", err)
+ }
+ return syscall.Rmdir(subDir)
+}
+
+// statBasic creates a file and stats it.
+func statBasic() error {
+ dir, cleanup, err := makeTempDir("stat-basic")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "statfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ var stat syscall.Stat_t
+ if err := syscall.Fstat(fd, &stat); err != nil {
+ return fmt.Errorf("fstat: %w", err)
+ }
+ if err := syscall.Stat(path, &stat); err != nil {
+ return fmt.Errorf("stat: %w", err)
+ }
+ return nil
+}
+
+// syncBasic opens a file, writes data, and fsyncs it.
+func syncBasic() error {
+ dir, cleanup, err := makeTempDir("sync-basic")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "syncfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ if _, err := syscall.Write(fd, []byte("sync me")); err != nil {
+ return fmt.Errorf("write: %w", err)
+ }
+ return syscall.Fsync(fd)
+}
+
+// truncateBasic opens a file, writes data, and truncates it.
+func truncateBasic() error {
+ dir, cleanup, err := makeTempDir("truncate-basic")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "truncfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ if _, err := syscall.Write(fd, []byte("truncate this content")); err != nil {
+ return fmt.Errorf("write: %w", err)
+ }
+ return syscall.Ftruncate(fd, 5)
+}
diff --git a/integrationtests/doc.go b/integrationtests/doc.go
new file mode 100644
index 0000000..027ba7f
--- /dev/null
+++ b/integrationtests/doc.go
@@ -0,0 +1,4 @@
+// Package integrationtests provides end-to-end integration tests for ior.
+// It verifies that ior correctly captures real I/O syscalls from a known
+// workload process via BPF tracepoints.
+package integrationtests
diff --git a/integrationtests/expectations.go b/integrationtests/expectations.go
new file mode 100644
index 0000000..ed155bc
--- /dev/null
+++ b/integrationtests/expectations.go
@@ -0,0 +1,83 @@
+package integrationtests
+
+import (
+ "ior/internal/flamegraph"
+ "strings"
+ "testing"
+)
+
+// ExpectedEvent describes an I/O event that should appear in the test output.
+type ExpectedEvent struct {
+ PathContains string // substring match on file path
+ Tracepoint string // tracepoint name substring, e.g. "openat"
+ Comm string // expected comm name, e.g. "ioworkload"
+ MinCount uint64 // minimum total occurrences across all matching records
+}
+
+// AssertEventsPresent verifies that each expected event is found in the test result.
+// Counts are summed across all matching records before comparing to MinCount.
+func AssertEventsPresent(t *testing.T, result TestResult, expected []ExpectedEvent) {
+ t.Helper()
+ for _, exp := range expected {
+ var totalCount uint64
+ var matched bool
+ for _, rec := range result.Records {
+ if matchesExpectation(rec, exp) {
+ matched = true
+ totalCount += rec.Cnt.Count
+ }
+ }
+ if !matched {
+ t.Errorf("expected event not found: %+v", exp)
+ continue
+ }
+ if exp.MinCount > 0 && totalCount < exp.MinCount {
+ t.Errorf("event matching %+v has total count %d, want >= %d",
+ exp, totalCount, exp.MinCount)
+ }
+ }
+}
+
+// AssertNoUnexpectedComm verifies all records have the expected comm name.
+// Fails fast on the first mismatch and reports the total count of unexpected records.
+func AssertNoUnexpectedComm(t *testing.T, result TestResult, expectedComm string) {
+ t.Helper()
+ var count int
+ for _, rec := range result.Records {
+ if rec.Comm != expectedComm {
+ count++
+ }
+ }
+ if count > 0 {
+ t.Fatalf("found %d records with unexpected comm (want %q)", count, expectedComm)
+ }
+}
+
+// AssertNoUnexpectedPID verifies all records belong to the expected PID.
+// Accepts int to match os.Getpid() return type.
+func AssertNoUnexpectedPID(t *testing.T, result TestResult, expectedPID int) {
+ t.Helper()
+ pid := uint32(expectedPID)
+ var count int
+ for _, rec := range result.Records {
+ if rec.Pid != pid {
+ count++
+ }
+ }
+ if count > 0 {
+ t.Fatalf("found %d records with unexpected PID (want %d)", count, expectedPID)
+ }
+}
+
+func matchesExpectation(rec flamegraph.IterRecord, exp ExpectedEvent) bool {
+ if exp.PathContains != "" && !strings.Contains(rec.Path, exp.PathContains) {
+ return false
+ }
+ if exp.Tracepoint != "" && !strings.Contains(rec.TraceID.String(), exp.Tracepoint) {
+ return false
+ }
+ if exp.Comm != "" && rec.Comm != exp.Comm {
+ return false
+ }
+ return true
+}
diff --git a/integrationtests/parse.go b/integrationtests/parse.go
new file mode 100644
index 0000000..cebb0ba
--- /dev/null
+++ b/integrationtests/parse.go
@@ -0,0 +1,25 @@
+package integrationtests
+
+import (
+ "fmt"
+ "ior/internal/flamegraph"
+)
+
+// TestResult holds all captured I/O records from a single ior run.
+type TestResult struct {
+ Records []flamegraph.IterRecord
+}
+
+// LoadTestResult parses an .ior.zst file into a TestResult.
+func LoadTestResult(iorZstFile string) (TestResult, error) {
+ iter, err := flamegraph.LoadFromFile(iorZstFile)
+ if err != nil {
+ return TestResult{}, fmt.Errorf("load test result: %w", err)
+ }
+
+ var result TestResult
+ for record := range iter {
+ result.Records = append(result.Records, record)
+ }
+ return result, nil
+}