Skip to content

Commit 61a814b

Browse files
authored
test: add E2E tests that verify full hook → export flow (#76)
1 parent 273ae3f commit 61a814b

4 files changed

Lines changed: 410 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,34 @@ jobs:
6363
claude plugin marketplace add anthropics/claude-plugins-official --scope user
6464
claude plugin install dash0@claude-plugins-official --scope user
6565
66+
e2e:
67+
runs-on: ubuntu-latest
68+
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
69+
steps:
70+
- name: Checkout repository
71+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
72+
73+
- name: Set up Go
74+
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5
75+
with:
76+
go-version-file: go.mod
77+
78+
- name: Install Claude Code
79+
run: npm install -g @anthropic-ai/claude-code
80+
81+
- name: Build plugin binary
82+
run: go build -o bin/on-event-linux-amd64 ./cmd/on-event
83+
84+
- name: Run E2E tests
85+
env:
86+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
87+
run: |
88+
if [ -z "$ANTHROPIC_API_KEY" ]; then
89+
echo "::warning::ANTHROPIC_API_KEY not set — skipping E2E tests"
90+
exit 0
91+
fi
92+
go test -tags=e2e -v -timeout=60s ./test/e2e/
93+
6694
consistency-checks:
6795
runs-on: ubuntu-latest
6896
steps:

.github/workflows/e2e-release.yml

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
name: E2E Release Verification
2+
3+
# Runs after a release is published or on merge to main.
4+
# Tests the full flow: on-event.sh downloads the real binary from GitHub
5+
# Releases, invokes it with a SessionStart event, and verifies the mock
6+
# OTLP server receives the connectivity check.
7+
8+
on:
9+
release:
10+
types: [published]
11+
push:
12+
branches: [main]
13+
workflow_dispatch:
14+
15+
jobs:
16+
e2e-release:
17+
runs-on: ubuntu-latest
18+
steps:
19+
- name: Checkout repository
20+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
21+
22+
- name: Set up Go
23+
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5
24+
with:
25+
go-version-file: go.mod
26+
27+
- name: Build mock OTLP server
28+
run: go build -o /tmp/mock-otlp ./test/e2e/mock-otlp-server
29+
30+
- name: Start mock OTLP server
31+
run: |
32+
/tmp/mock-otlp &
33+
sleep 1
34+
echo "Mock OTLP server started on :4319"
35+
36+
- name: Run on-event.sh with SessionStart
37+
env:
38+
CLAUDE_PLUGIN_DATA: /tmp/e2e-plugin-data
39+
DASH0_OTLP_URL: http://localhost:4319
40+
CLAUDE_PLUGIN_OPTION_OTLP_URL: http://localhost:4319
41+
CLAUDE_PLUGIN_OPTION_AUTH_TOKEN: e2e-release-test-token
42+
run: |
43+
mkdir -p "$CLAUDE_PLUGIN_DATA"
44+
echo '{"hook_event_name":"SessionStart","session_id":"e2e-release-test","model":"opus"}' | \
45+
bash scripts/on-event.sh
46+
echo "on-event.sh completed"
47+
48+
- name: Run on-event.sh with full turn
49+
env:
50+
CLAUDE_PLUGIN_DATA: /tmp/e2e-plugin-data
51+
DASH0_OTLP_URL: http://localhost:4319
52+
CLAUDE_PLUGIN_OPTION_OTLP_URL: http://localhost:4319
53+
CLAUDE_PLUGIN_OPTION_AUTH_TOKEN: e2e-release-test-token
54+
run: |
55+
echo '{"hook_event_name":"UserPromptSubmit","session_id":"e2e-release-test","prompt":"hello"}' | \
56+
bash scripts/on-event.sh
57+
echo '{"hook_event_name":"PostToolUse","session_id":"e2e-release-test","tool_name":"Bash","tool_use_id":"tu1","duration_ms":100}' | \
58+
bash scripts/on-event.sh
59+
echo '{"hook_event_name":"Stop","session_id":"e2e-release-test","model":"opus","stop_reason":"end_turn"}' | \
60+
bash scripts/on-event.sh
61+
echo "Full turn completed"
62+
63+
- name: Verify mock received requests
64+
run: |
65+
sleep 2
66+
RESULT=$(curl -s http://localhost:4319/requests)
67+
echo "Requests received:"
68+
echo "$RESULT" | jq .
69+
COUNT=$(echo "$RESULT" | jq '.count')
70+
if [ "$COUNT" -lt 3 ]; then
71+
echo "::error::Expected at least 3 requests (connectivity check + tool span + chat span), got $COUNT"
72+
exit 1
73+
fi
74+
# Verify auth header was sent on at least one request
75+
HAS_AUTH=$(echo "$RESULT" | jq '[.requests[] | select(.auth == "Bearer e2e-release-test-token")] | length')
76+
if [ "$HAS_AUTH" -lt 1 ]; then
77+
echo "::error::No request had the expected auth header"
78+
exit 1
79+
fi
80+
echo "E2E release verification passed ($COUNT requests received, $HAS_AUTH with auth)"
81+
82+
- name: Verify binary was downloaded (not pre-existing)
83+
env:
84+
CLAUDE_PLUGIN_DATA: /tmp/e2e-plugin-data
85+
run: |
86+
ls -la "$CLAUDE_PLUGIN_DATA/bin/"
87+
BINARY=$(ls "$CLAUDE_PLUGIN_DATA/bin/" | head -1)
88+
echo "Downloaded binary: $BINARY"
89+
if [ -z "$BINARY" ]; then
90+
echo "::error::No binary found — on-event.sh failed to download"
91+
exit 1
92+
fi

test/e2e/e2e_test.go

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc.
2+
3+
//go:build e2e
4+
5+
package e2e
6+
7+
import (
8+
"encoding/json"
9+
"io"
10+
"net/http"
11+
"net/http/httptest"
12+
"os"
13+
"os/exec"
14+
"path/filepath"
15+
"sync"
16+
"testing"
17+
"time"
18+
19+
"github.com/stretchr/testify/assert"
20+
"github.com/stretchr/testify/require"
21+
)
22+
23+
// TestE2EHookInvocation simulates what Claude Code does when firing a hook:
24+
// builds the binary, invokes on-event.sh with a SessionStart event on stdin,
25+
// and verifies the mock OTLP server receives the connectivity check.
26+
func TestE2EHookInvocation(t *testing.T) {
27+
pluginDir := findPluginDir(t)
28+
29+
// Build the binary fresh.
30+
binDir := t.TempDir()
31+
binary := filepath.Join(binDir, "on-event-test-linux-amd64")
32+
build := exec.Command("go", "build", "-o", binary, "./cmd/on-event")
33+
build.Dir = pluginDir
34+
out, err := build.CombinedOutput()
35+
require.NoError(t, err, "build failed: %s", string(out))
36+
37+
var (
38+
mu sync.Mutex
39+
requests []capturedRequest
40+
)
41+
42+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43+
body, _ := io.ReadAll(r.Body)
44+
mu.Lock()
45+
requests = append(requests, capturedRequest{
46+
path: r.URL.Path,
47+
auth: r.Header.Get("Authorization"),
48+
body: body,
49+
method: r.Method,
50+
})
51+
mu.Unlock()
52+
w.WriteHeader(http.StatusOK)
53+
}))
54+
defer srv.Close()
55+
56+
dataDir := t.TempDir()
57+
58+
t.Run("SessionStart fires connectivity check", func(t *testing.T) {
59+
event := `{"hook_event_name":"SessionStart","session_id":"e2e-test-session","model":"claude-opus-4-7"}`
60+
runBinary(t, binary, event, dataDir, srv.URL)
61+
62+
time.Sleep(500 * time.Millisecond)
63+
64+
mu.Lock()
65+
defer mu.Unlock()
66+
67+
var traceReqs []capturedRequest
68+
for _, r := range requests {
69+
if r.path == "/v1/traces" {
70+
traceReqs = append(traceReqs, r)
71+
}
72+
}
73+
74+
assert.NotEmpty(t, traceReqs, "connectivity check should hit /v1/traces on SessionStart")
75+
if len(traceReqs) > 0 {
76+
assert.Equal(t, "Bearer e2e-test-token", traceReqs[0].auth)
77+
}
78+
})
79+
80+
t.Run("full turn produces chat and tool spans", func(t *testing.T) {
81+
mu.Lock()
82+
requests = nil
83+
mu.Unlock()
84+
85+
// UserPromptSubmit — creates trace context.
86+
runBinary(t, binary, `{"hook_event_name":"UserPromptSubmit","session_id":"e2e-test-session","prompt":"hello"}`, dataDir, srv.URL)
87+
88+
// PostToolUse — emits a tool span.
89+
runBinary(t, binary, `{"hook_event_name":"PostToolUse","session_id":"e2e-test-session","tool_name":"Bash","tool_use_id":"tu1","tool_input":"ls","tool_response":"file.txt","duration_ms":100}`, dataDir, srv.URL)
90+
91+
// Stop — emits a chat span.
92+
runBinary(t, binary, `{"hook_event_name":"Stop","session_id":"e2e-test-session","model":"claude-opus-4-7","stop_reason":"end_turn"}`, dataDir, srv.URL)
93+
94+
time.Sleep(500 * time.Millisecond)
95+
96+
mu.Lock()
97+
defer mu.Unlock()
98+
99+
var traceReqs []capturedRequest
100+
for _, r := range requests {
101+
if r.path == "/v1/traces" {
102+
traceReqs = append(traceReqs, r)
103+
}
104+
}
105+
106+
// Expect at least 2 trace exports: tool span + chat span.
107+
assert.GreaterOrEqual(t, len(traceReqs), 2, "expected tool span + chat span")
108+
109+
// Verify spans contain expected attributes.
110+
for _, r := range traceReqs {
111+
body := string(r.body)
112+
assert.Contains(t, body, "e2e-test-session", "span should contain conversation ID")
113+
}
114+
})
115+
116+
t.Run("SessionEnd cleans up session directory", func(t *testing.T) {
117+
sessionDir := filepath.Join(dataDir, "e2e-test-session")
118+
require.DirExists(t, sessionDir, "session dir should exist before SessionEnd")
119+
120+
runBinary(t, binary, `{"hook_event_name":"SessionEnd","session_id":"e2e-test-session"}`, dataDir, srv.URL)
121+
122+
assert.NoDirExists(t, sessionDir, "session dir should be cleaned up after SessionEnd")
123+
})
124+
}
125+
126+
func TestE2EFullFlowWithClaude(t *testing.T) {
127+
claudeBin, err := exec.LookPath("claude")
128+
if err != nil {
129+
t.Skip("claude CLI not found in PATH")
130+
}
131+
if os.Getenv("ANTHROPIC_API_KEY") == "" {
132+
t.Skip("ANTHROPIC_API_KEY not set — cannot run full Claude flow")
133+
}
134+
135+
pluginDir := findPluginDir(t)
136+
137+
var (
138+
mu sync.Mutex
139+
requests []capturedRequest
140+
)
141+
142+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
143+
body, _ := io.ReadAll(r.Body)
144+
mu.Lock()
145+
requests = append(requests, capturedRequest{
146+
path: r.URL.Path,
147+
auth: r.Header.Get("Authorization"),
148+
body: body,
149+
method: r.Method,
150+
})
151+
mu.Unlock()
152+
w.WriteHeader(http.StatusOK)
153+
}))
154+
defer srv.Close()
155+
156+
// Write a .env in a temp work dir so the plugin picks up our mock URL.
157+
workDir := t.TempDir()
158+
envContent := "DASH0_OTLP_URL=" + srv.URL + "\nDASH0_AUTH_TOKEN=e2e-test-token\n"
159+
require.NoError(t, os.WriteFile(filepath.Join(workDir, ".env"), []byte(envContent), 0o644))
160+
161+
cmd := exec.Command(claudeBin, "--print", "--plugin-dir", pluginDir)
162+
cmd.Stdin = nil // empty prompt — claude will just respond and exit
163+
cmd.Dir = workDir
164+
cmd.Env = os.Environ()
165+
166+
output, _ := cmd.CombinedOutput()
167+
t.Logf("claude output: %s", string(output))
168+
169+
time.Sleep(3 * time.Second)
170+
171+
mu.Lock()
172+
defer mu.Unlock()
173+
174+
t.Logf("requests received: %d", len(requests))
175+
for _, r := range requests {
176+
t.Logf(" %s %s (%d bytes)", r.method, r.path, len(r.body))
177+
}
178+
179+
assert.NotEmpty(t, requests, "expected at least one request to mock OTLP server from Claude session")
180+
}
181+
182+
type capturedRequest struct {
183+
path string
184+
auth string
185+
body []byte
186+
method string
187+
}
188+
189+
func runBinary(t *testing.T, binary, event, dataDir, otlpURL string) {
190+
t.Helper()
191+
cmd := exec.Command(binary)
192+
cmd.Stdin = stringReader(event)
193+
cmd.Env = []string{
194+
"CLAUDE_PLUGIN_DATA=" + dataDir,
195+
"CLAUDE_PLUGIN_OPTION_OTLP_URL=" + otlpURL,
196+
"CLAUDE_PLUGIN_OPTION_AUTH_TOKEN=e2e-test-token",
197+
"CLAUDE_PLUGIN_OPTION_OMIT_USER_INFO=false",
198+
"CLAUDE_PLUGIN_OPTION_OMIT_IO=false",
199+
"HOME=" + os.Getenv("HOME"),
200+
"PATH=" + os.Getenv("PATH"),
201+
}
202+
out, err := cmd.CombinedOutput()
203+
if err != nil {
204+
t.Logf("binary output: %s (err: %v)", string(out), err)
205+
}
206+
}
207+
208+
func stringReader(s string) *os.File {
209+
r, w, _ := os.Pipe()
210+
go func() {
211+
w.Write([]byte(s))
212+
w.Close()
213+
}()
214+
return r
215+
}
216+
217+
func findPluginDir(t *testing.T) string {
218+
t.Helper()
219+
dir, err := os.Getwd()
220+
require.NoError(t, err)
221+
for {
222+
if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err == nil {
223+
return dir
224+
}
225+
parent := filepath.Dir(dir)
226+
if parent == dir {
227+
t.Fatal("could not find plugin root (no .claude-plugin/plugin.json)")
228+
}
229+
dir = parent
230+
}
231+
}
232+
233+
// Unused but needed to satisfy json import.
234+
var _ = json.Marshal

0 commit comments

Comments
 (0)