// Command streamhub-e2e is an end-to-end test client for the C++ StreamHub. // // It connects to a running StreamHub WebSocket endpoint (with at least one // connected UDPStreamer source, e.g. the stack launched by run_e2e_test.sh) // and verifies the full protocol: // // 1. "sources" event with at least one connected source // 2. "config" event per source with at least one signal // 3. binary v1 data pushes: parseable, per-signal monotonic time, // timestamps within a few seconds of wall clock (Unix time base) // 4. "stats" event with a positive receive rate // 5. WS zoom round-trip: reqId echoed, points returned in [t0,t1] // 6. hub-side trigger: setTrigger+arm → triggerState(armed) → binary v2 // capture frame with the latched pre/post window // // Exit code 0 on success; 1 with a FAIL message otherwise. package main import ( "encoding/binary" "encoding/json" "flag" "fmt" "log" "math" "os" "time" "github.com/gorilla/websocket" ) var hub = flag.String("hub", "127.0.0.1:8090", "StreamHub host:port") var timeout = flag.Duration("timeout", 30*time.Second, "overall test timeout") var verbose = flag.Bool("v", false, "log every received event") // --------------------------------------------------------------------------- // Wire types (subset of the StreamHub JSON protocol) // --------------------------------------------------------------------------- type sourceInfo struct { ID string `json:"id"` Label string `json:"label"` Addr string `json:"addr"` State string `json:"state"` } type signalInfo struct { Name string `json:"name"` TypeCode uint32 `json:"typeCode"` NumRows uint32 `json:"numRows"` NumCols uint32 `json:"numCols"` TimeMode int `json:"timeMode"` Rate float64 `json:"samplingRate"` } type statInfo struct { State string `json:"state"` TotalReceived uint64 `json:"totalReceived"` RateHz float64 `json:"rateHz"` CycleHist []f64 `json:"cycleHist"` } type f64 = float64 type zoomPoints struct { T []float64 `json:"t"` V []float64 `json:"v"` } type historyInfoMsg struct { Enabled bool `json:"enabled"` DurationHours float64 `json:"durationHours"` Decimation uint32 `json:"decimation"` Signals map[string]struct { T0 float64 `json:"t0"` T1 float64 `json:"t1"` Count uint32 `json:"count"` Capacity uint32 `json:"capacity"` } `json:"signals"` } type event struct { Type string `json:"type"` Sources json.RawMessage `json:"sources"` SourceID string `json:"sourceId"` Signals json.RawMessage `json:"signals"` ReqID uint32 `json:"reqId"` State string `json:"state"` TrigTime float64 `json:"trigTime"` } // Parsed binary v1 push frame: sourceId → signal → samples. type pushFrame struct { sourceID string signals map[string]zoomPoints } // Parsed binary v2 capture frame. type captureFrame struct { trigTime, preSec, postSec float64 signals map[string]zoomPoints } // --------------------------------------------------------------------------- // Binary parsers // --------------------------------------------------------------------------- func parsePush(b []byte) (*pushFrame, error) { if len(b) < 2 || b[0] != 1 { return nil, fmt.Errorf("not a v1 frame") } idLen := int(b[1]) off := 2 if len(b) < off+idLen+4 { return nil, fmt.Errorf("truncated header") } f := &pushFrame{sourceID: string(b[off : off+idLen]), signals: map[string]zoomPoints{}} off += idLen nSig := int(binary.LittleEndian.Uint32(b[off:])) off += 4 for s := 0; s < nSig; s++ { if len(b) < off+2 { return nil, fmt.Errorf("truncated keyLen (sig %d)", s) } keyLen := int(binary.LittleEndian.Uint16(b[off:])) off += 2 if len(b) < off+keyLen+4 { return nil, fmt.Errorf("truncated key (sig %d)", s) } key := string(b[off : off+keyLen]) off += keyLen n := int(binary.LittleEndian.Uint32(b[off:])) off += 4 if len(b) < off+16*n { return nil, fmt.Errorf("truncated data (sig %s n=%d)", key, n) } pts := zoomPoints{T: make([]float64, n), V: make([]float64, n)} for i := 0; i < n; i++ { pts.T[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:])) } off += 8 * n for i := 0; i < n; i++ { pts.V[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:])) } off += 8 * n f.signals[key] = pts } return f, nil } func parseCapture(b []byte) (*captureFrame, error) { if len(b) < 1+24+4 || b[0] != 2 { return nil, fmt.Errorf("not a v2 frame") } rdF64 := func(off int) float64 { return math.Float64frombits(binary.LittleEndian.Uint64(b[off:])) } f := &captureFrame{ trigTime: rdF64(1), preSec: rdF64(9), postSec: rdF64(17), signals: map[string]zoomPoints{}, } off := 25 nSig := int(binary.LittleEndian.Uint32(b[off:])) off += 4 for s := 0; s < nSig; s++ { keyLen := int(binary.LittleEndian.Uint16(b[off:])) off += 2 key := string(b[off : off+keyLen]) off += keyLen n := int(binary.LittleEndian.Uint32(b[off:])) off += 4 if len(b) < off+16*n { return nil, fmt.Errorf("truncated capture (sig %s n=%d)", key, n) } pts := zoomPoints{T: make([]float64, n), V: make([]float64, n)} for i := 0; i < n; i++ { pts.T[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:])) } off += 8 * n for i := 0; i < n; i++ { pts.V[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:])) } off += 8 * n f.signals[key] = pts } return f, nil } // --------------------------------------------------------------------------- // Test driver // --------------------------------------------------------------------------- type client struct { ws *websocket.Conn deadline time.Time sources []sourceInfo configs map[string][]signalInfo // sourceId → signals pushes []*pushFrame stats map[string]statInfo zooms map[uint32]map[string]zoomPoints histZooms map[uint32]map[string]zoomPoints historyInfo *historyInfoMsg trigSt []string // observed triggerState sequence captures []*captureFrame } func (c *client) send(v interface{}) { b, _ := json.Marshal(v) if err := c.ws.WriteMessage(websocket.TextMessage, b); err != nil { fail("ws write: %v", err) } } // pump reads one WS message (with a short read deadline) and dispatches it. func (c *client) pump() { c.ws.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) mt, data, err := c.ws.ReadMessage() if err != nil { if websocket.IsUnexpectedCloseError(err) { fail("ws closed: %v", err) } return // read timeout — fine } switch mt { case websocket.BinaryMessage: if len(data) == 0 { return } switch data[0] { case 1: if f, err := parsePush(data); err == nil { c.pushes = append(c.pushes, f) } else { fail("bad v1 frame: %v", err) } case 2: if f, err := parseCapture(data); err == nil { c.captures = append(c.captures, f) } else { fail("bad v2 frame: %v", err) } default: fail("unknown binary frame version %d", data[0]) } case websocket.TextMessage: var ev event if err := json.Unmarshal(data, &ev); err != nil { fail("bad JSON event: %v (%.120s)", err, data) } if *verbose { log.Printf("event %-12s %.160s", ev.Type, data) } switch ev.Type { case "sources": var srcs []sourceInfo if err := json.Unmarshal(ev.Sources, &srcs); err == nil { c.sources = srcs } case "config": var sigs []signalInfo if err := json.Unmarshal(ev.Signals, &sigs); err == nil { c.configs[ev.SourceID] = sigs } else { log.Printf("config parse error: %v (%.200s)", err, data) } case "stats": var st map[string]statInfo if err := json.Unmarshal(ev.Sources, &st); err == nil { c.stats = st } case "zoom": var body struct { Signals map[string]zoomPoints `json:"signals"` } if err := json.Unmarshal(data, &body); err == nil { c.zooms[ev.ReqID] = body.Signals } case "historyZoom": var body struct { Signals map[string]zoomPoints `json:"signals"` } if err := json.Unmarshal(data, &body); err == nil { c.histZooms[ev.ReqID] = body.Signals } case "historyInfo": var hi historyInfoMsg if err := json.Unmarshal(data, &hi); err == nil { c.historyInfo = &hi } case "triggerState": c.trigSt = append(c.trigSt, ev.State) } } } // waitFor pumps messages until cond() or the step deadline expires. func (c *client) waitFor(what string, d time.Duration, cond func() bool) { end := time.Now().Add(d) if end.After(c.deadline) { end = c.deadline } for time.Now().Before(end) { if cond() { log.Printf("OK %s", what) return } c.pump() } fail("timeout waiting for %s", what) } func fail(format string, args ...interface{}) { fmt.Printf("FAIL "+format+"\n", args...) os.Exit(1) } func main() { flag.Parse() url := "ws://" + *hub + "/ws" log.Printf("connecting to %s", url) ws, _, err := websocket.DefaultDialer.Dial(url, nil) if err != nil { fail("dial %s: %v", url, err) } defer ws.Close() c := &client{ ws: ws, deadline: time.Now().Add(*timeout), configs: map[string][]signalInfo{}, zooms: map[uint32]map[string]zoomPoints{}, histZooms: map[uint32]map[string]zoomPoints{}, } // ── 1. sources ──────────────────────────────────────────────────────── c.send(map[string]interface{}{"type": "getSources"}) c.waitFor("sources event with a connected source", 10*time.Second, func() bool { for _, s := range c.sources { if s.State == "connected" { return true } } return false }) // ── 2. config per connected source ─────────────────────────────────── for _, s := range c.sources { log.Printf("source %s (%s): state=%s", s.ID, s.Label, s.State) c.send(map[string]interface{}{"type": "getConfig", "sourceId": s.ID}) } c.waitFor("config with signals for every connected source", 10*time.Second, func() bool { for _, s := range c.sources { if s.State != "connected" { continue } if len(c.configs[s.ID]) == 0 { return false } } return len(c.sources) > 0 }) // ── 3. binary pushes: wall-clock time base + monotonicity ──────────── c.waitFor("binary v1 data pushes (>=10 frames)", 10*time.Second, func() bool { return len(c.pushes) >= 10 }) now := float64(time.Now().UnixNano()) / 1e9 seen := map[string][]float64{} // last times per src:sig for _, f := range c.pushes { for key, pts := range f.signals { full := f.sourceID + ":" + key for i, t := range pts.T { if math.Abs(t-now) > 30.0 { fail("timestamp not wall-clock: %s t=%.3f now=%.3f", full, t, now) } prev := seen[full] if len(prev) > 0 && t < prev[len(prev)-1]-1e-9 { fail("non-monotonic time on %s: %.9f after %.9f (i=%d)", full, t, prev[len(prev)-1], i) } seen[full] = append(seen[full], t) } } } if len(seen) == 0 { fail("pushes contained no signal data") } log.Printf("OK wall-clock & monotonic time on %d signal streams", len(seen)) // ── 4. stats ────────────────────────────────────────────────────────── c.send(map[string]interface{}{"type": "getStats"}) c.waitFor("stats with positive rate", 10*time.Second, func() bool { for _, st := range c.stats { if st.State == "connected" && st.RateHz > 0 && st.TotalReceived > 0 { return true } } return false }) // ── 5. zoom round-trip ─────────────────────────────────────────────── // Use the busiest streamed signal and the time range we actually saw. var zoomKey string var zMax int for k, ts := range seen { if len(ts) > zMax { zMax, zoomKey = len(ts), k } } ts := seen[zoomKey] t1 := ts[len(ts)-1] t0 := t1 - 0.5 const reqID = 4242 c.send(map[string]interface{}{ "type": "zoom", "reqId": reqID, "t0": t0, "t1": t1, "n": 200, "signals": zoomKey, }) c.waitFor(fmt.Sprintf("zoom reply (reqId=%d, %s)", reqID, zoomKey), 10*time.Second, func() bool { sigs, ok := c.zooms[reqID] if !ok { return false } pts, ok := sigs[zoomKey] if !ok || len(pts.T) < 2 { fail("zoom reply missing %s (got %d signals)", zoomKey, len(sigs)) } for _, t := range pts.T { if t < t0-1e-6 || t > t1+1e-6 { fail("zoom point outside range: t=%.9f not in [%.9f,%.9f]", t, t0, t1) } } return true }) // ── 5b. historyInfo — check the hub broadcast it on connect ───────── if c.historyInfo != nil && c.historyInfo.Enabled { log.Printf("OK historyInfo: enabled, %.1fh, decimation=%d, %d signals", c.historyInfo.DurationHours, c.historyInfo.Decimation, len(c.historyInfo.Signals)) // ── 5c. historyZoom round-trip ────────────────────────────────── const hReqID = 4243 c.send(map[string]interface{}{ "type": "historyZoom", "reqId": hReqID, "t0": t0, "t1": t1, "n": 200, "signals": zoomKey, }) c.waitFor(fmt.Sprintf("historyZoom reply (reqId=%d, %s)", hReqID, zoomKey), 10*time.Second, func() bool { sigs, ok := c.histZooms[hReqID] if !ok { return false } pts, ok := sigs[zoomKey] if !ok || len(pts.T) < 1 { // History data may still be sparse right after startup return true } for _, ht := range pts.T { if ht < t0-1e-6 || ht > t1+1e-6 { fail("historyZoom point outside range: t=%.9f not in [%.9f,%.9f]", ht, t0, t1) } } return true }) } else { log.Println(" (history not enabled — skipping historyZoom test)") } // ── 6. trigger: arm → capture ──────────────────────────────────────── // Trigger on an *oscillating* signal at its mean observed value: a // monotonic ramp (counter, time array) crosses its past mean only once, // before arming, so a rising edge would never fire on it. Pick the // busiest signal whose last push frame is non-monotonic (a sine). lastVals := map[string][]float64{} for _, f := range c.pushes { for name, pts := range f.signals { if len(pts.V) >= 4 { lastVals[f.sourceID+":"+name] = pts.V } } } trigKey := "" tMaxPts := 0 for k, vs := range lastVals { monotonic := true for i := 1; i < len(vs); i++ { if vs[i] < vs[i-1] { monotonic = false break } } if !monotonic && len(seen[k]) > tMaxPts { tMaxPts, trigKey = len(seen[k]), k } } if trigKey == "" { fail("no oscillating signal found for trigger test") } vals := lastVals[trigKey] mean := 0.0 for _, v := range vals { mean += v } mean /= float64(len(vals)) log.Printf(" trigger signal %s, threshold %.6g", trigKey, mean) c.send(map[string]interface{}{ "type": "setTrigger", "signal": trigKey, "edge": "rising", "threshold": mean, "windowSec": 0.1, "prePercent": 20.0, "mode": "single", }) c.send(map[string]interface{}{"type": "arm"}) // The trigger can fire within microseconds of arming (5 MS/s sine), so // the broadcast emitted by the arm command may already say "collecting" // or even "triggered" — any of these proves the arm was accepted. c.waitFor("triggerState: armed/collecting/triggered", 5*time.Second, func() bool { for _, s := range c.trigSt { if s == "armed" || s == "collecting" || s == "triggered" { return true } } return false }) c.waitFor("binary v2 capture frame", 15*time.Second, func() bool { return len(c.captures) > 0 }) cap0 := c.captures[0] if math.Abs(cap0.preSec-0.02) > 1e-9 || math.Abs(cap0.postSec-0.08) > 1e-9 { fail("capture window mismatch: pre=%.6f post=%.6f (want 0.02/0.08)", cap0.preSec, cap0.postSec) } pts, ok := cap0.signals[trigKey] if !ok || len(pts.T) == 0 { fail("capture missing trigger signal %s (%d signals)", trigKey, len(cap0.signals)) } for _, t := range pts.T { if t < cap0.trigTime-cap0.preSec-1e-3 || t > cap0.trigTime+cap0.postSec+1e-3 { fail("capture point outside window: t=%.9f trig=%.9f", t, cap0.trigTime) } } log.Printf("OK capture: trig=%.6f pre=%.3fs post=%.3fs %d signals", cap0.trigTime, cap0.preSec, cap0.postSec, len(cap0.signals)) c.waitFor("triggerState: triggered", 5*time.Second, func() bool { for _, s := range c.trigSt { if s == "triggered" { return true } } return false }) c.send(map[string]interface{}{"type": "disarm"}) fmt.Println("PASS streamhub-e2e: all checks passed") }