Two-slot shell topbar (home, left, center, bell, user menu) with SDK API (setLeft/setSlot/setTitle/hide/show). All 4 primary surfaces migrated: Settings and Team Admin to Pattern B (flat tabs), Admin to Pattern C (category tabs + sidebar), Docs to Pattern A (default). Backend WS events: package.changed (broadcast), auth.changed (targeted), notification.all_read. User menu re-fetches on package/auth changes. Bell syncs read state across tabs. Error handling pass with .sw-inline-error CSS primitive. Empty state guidance for Admin Workflows/Groups. Announcement global dismiss via localStorage. Rebrand assets deployed (both b/e icon variants, wordmarks, full icon library). Docs outline scroll-to-heading fix. Bug fixes: ICD security assertion tightened, workflow-demo error surfacing, signoff display names, hello-dashboard + team-admin/groups.js deleted. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
265 lines
7.4 KiB
Go
265 lines
7.4 KiB
Go
package events
|
|
|
|
import (
|
|
"encoding/json"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestMatch(t *testing.T) {
|
|
tests := []struct {
|
|
label, pattern string
|
|
want bool
|
|
}{
|
|
{"workflow.assigned.abc", "workflow.assigned.abc", true},
|
|
{"workflow.assigned.abc", "workflow.assigned.*", true},
|
|
{"workflow.assigned.abc", "workflow.*", true},
|
|
{"workflow.assigned.abc", "*", true},
|
|
{"workflow.assigned.abc", "workflow.assigned.xyz", false},
|
|
{"workflow.assigned.abc", "notification.new.*", false},
|
|
{"workflow.assigned.abc", "workflow.assigned", false},
|
|
{"ping", "ping", true},
|
|
{"ping", "pong", false},
|
|
{"plugin.hook.pre_completion", "plugin.hook.*", true},
|
|
{"plugin.hook.pre_completion", "plugin.*", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := match(tt.label, tt.pattern)
|
|
if got != tt.want {
|
|
t.Errorf("match(%q, %q) = %v, want %v", tt.label, tt.pattern, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBusPublishExact(t *testing.T) {
|
|
bus := NewBus()
|
|
var count int32
|
|
|
|
bus.Subscribe("workflow.assigned.abc", func(e Event) {
|
|
atomic.AddInt32(&count, 1)
|
|
})
|
|
|
|
bus.Publish(Event{Label: "workflow.assigned.abc", Payload: json.RawMessage(`{}`)})
|
|
bus.Publish(Event{Label: "workflow.assigned.xyz", Payload: json.RawMessage(`{}`)})
|
|
|
|
if atomic.LoadInt32(&count) != 1 {
|
|
t.Errorf("expected 1 dispatch, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestBusPublishWildcard(t *testing.T) {
|
|
bus := NewBus()
|
|
var count int32
|
|
|
|
bus.Subscribe("workflow.assigned.*", func(e Event) {
|
|
atomic.AddInt32(&count, 1)
|
|
})
|
|
|
|
bus.Publish(Event{Label: "workflow.assigned.abc", Payload: json.RawMessage(`{}`)})
|
|
bus.Publish(Event{Label: "workflow.assigned.xyz", Payload: json.RawMessage(`{}`)})
|
|
bus.Publish(Event{Label: "workflow.claimed.abc", Payload: json.RawMessage(`{}`)})
|
|
|
|
if atomic.LoadInt32(&count) != 2 {
|
|
t.Errorf("expected 2 dispatches, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestBusUnsubscribe(t *testing.T) {
|
|
bus := NewBus()
|
|
var count int32
|
|
|
|
unsub := bus.Subscribe("test.event", func(e Event) {
|
|
atomic.AddInt32(&count, 1)
|
|
})
|
|
|
|
bus.Publish(Event{Label: "test.event", Payload: json.RawMessage(`{}`)})
|
|
unsub()
|
|
bus.Publish(Event{Label: "test.event", Payload: json.RawMessage(`{}`)})
|
|
|
|
if atomic.LoadInt32(&count) != 1 {
|
|
t.Errorf("expected 1 dispatch after unsub, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestBusGlobalWildcard(t *testing.T) {
|
|
bus := NewBus()
|
|
var count int32
|
|
|
|
bus.Subscribe("*", func(e Event) {
|
|
atomic.AddInt32(&count, 1)
|
|
})
|
|
|
|
bus.Publish(Event{Label: "workflow.assigned.abc", Payload: json.RawMessage(`{}`)})
|
|
bus.Publish(Event{Label: "system.notify", Payload: json.RawMessage(`{}`)})
|
|
bus.Publish(Event{Label: "plugin.hook.pre_completion", Payload: json.RawMessage(`{}`)})
|
|
|
|
if atomic.LoadInt32(&count) != 3 {
|
|
t.Errorf("expected 3 dispatches, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestRouteFor(t *testing.T) {
|
|
tests := []struct {
|
|
label string
|
|
want Direction
|
|
}{
|
|
{"system.notify", DirToClient},
|
|
{"plugin.hook.pre_completion", DirLocal},
|
|
{"internal.db.write", DirLocal},
|
|
{"unknown.event", DirLocal}, // default
|
|
{"ping", DirFromClient},
|
|
{"pong", DirToClient},
|
|
// Tool bridge routes
|
|
{"tool.call.abc123", DirToClient},
|
|
{"tool.result.abc123", DirBoth},
|
|
// Extension lifecycle
|
|
{"extension.loaded", DirLocal},
|
|
{"extension.error", DirLocal},
|
|
// Realtime
|
|
{"realtime.chat.message", DirToClient},
|
|
{"realtime.custom.event", DirToClient},
|
|
// Room management
|
|
{"room.subscribe", DirFromClient},
|
|
{"room.unsubscribe", DirFromClient},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := RouteFor(tt.label)
|
|
if got != tt.want {
|
|
t.Errorf("RouteFor(%q) = %d, want %d", tt.label, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWaitFor_Success(t *testing.T) {
|
|
bus := NewBus()
|
|
|
|
// Publish after a short delay
|
|
go func() {
|
|
time.Sleep(10 * time.Millisecond)
|
|
bus.Publish(Event{
|
|
Label: "tool.result.abc123",
|
|
Payload: json.RawMessage(`{"result":"hello"}`),
|
|
})
|
|
}()
|
|
|
|
event, ok := bus.WaitFor("tool.result.abc123", 1*time.Second)
|
|
if !ok {
|
|
t.Fatal("WaitFor timed out, expected success")
|
|
}
|
|
if event.Label != "tool.result.abc123" {
|
|
t.Errorf("expected label tool.result.abc123, got %s", event.Label)
|
|
}
|
|
if string(event.Payload) != `{"result":"hello"}` {
|
|
t.Errorf("unexpected payload: %s", string(event.Payload))
|
|
}
|
|
}
|
|
|
|
func TestWaitFor_Timeout(t *testing.T) {
|
|
bus := NewBus()
|
|
|
|
_, ok := bus.WaitFor("tool.result.never", 50*time.Millisecond)
|
|
if ok {
|
|
t.Error("WaitFor should have timed out")
|
|
}
|
|
}
|
|
|
|
func TestWaitFor_IgnoresOtherLabels(t *testing.T) {
|
|
bus := NewBus()
|
|
|
|
go func() {
|
|
time.Sleep(5 * time.Millisecond)
|
|
bus.Publish(Event{Label: "tool.result.other", Payload: json.RawMessage(`{}`)})
|
|
time.Sleep(5 * time.Millisecond)
|
|
bus.Publish(Event{Label: "tool.result.target", Payload: json.RawMessage(`{"ok":true}`)})
|
|
}()
|
|
|
|
event, ok := bus.WaitFor("tool.result.target", 1*time.Second)
|
|
if !ok {
|
|
t.Fatal("WaitFor timed out")
|
|
}
|
|
if event.Label != "tool.result.target" {
|
|
t.Errorf("got wrong label: %s", event.Label)
|
|
}
|
|
}
|
|
|
|
func TestWaitFor_CleansUpSubscription(t *testing.T) {
|
|
bus := NewBus()
|
|
|
|
// WaitFor with immediate timeout
|
|
bus.WaitFor("tool.result.cleanup", 1*time.Millisecond)
|
|
time.Sleep(5 * time.Millisecond)
|
|
|
|
// Verify no panic when publishing to the label after cleanup
|
|
bus.Publish(Event{Label: "tool.result.cleanup", Payload: json.RawMessage(`{}`)})
|
|
}
|
|
|
|
func TestRealtimeRouting(t *testing.T) {
|
|
// realtime.* events should be sent to clients but not accepted from them
|
|
if !ShouldSendToClient("realtime.chat.message") {
|
|
t.Error("realtime.* should be sent to client")
|
|
}
|
|
if ShouldAcceptFromClient("realtime.chat.message") {
|
|
t.Error("realtime.* should NOT be accepted from client")
|
|
}
|
|
|
|
// room.subscribe/unsubscribe should be accepted from clients
|
|
if !ShouldAcceptFromClient("room.subscribe") {
|
|
t.Error("room.subscribe should be accepted from client")
|
|
}
|
|
if !ShouldAcceptFromClient("room.unsubscribe") {
|
|
t.Error("room.unsubscribe should be accepted from client")
|
|
}
|
|
if ShouldSendToClient("room.subscribe") {
|
|
t.Error("room.subscribe should NOT be sent to client")
|
|
}
|
|
}
|
|
|
|
func TestToolCallRouteToClient(t *testing.T) {
|
|
if !ShouldSendToClient("tool.call.abc123") {
|
|
t.Error("tool.call.* should be sent to client")
|
|
}
|
|
if ShouldAcceptFromClient("tool.call.abc123") {
|
|
t.Error("tool.call.* should NOT be accepted from client")
|
|
}
|
|
}
|
|
|
|
func TestToolResultRouteBoth(t *testing.T) {
|
|
// The WS subscriber explicitly filters out tool.result events to
|
|
// prevent re-sending to clients (see subscribeToBus in ws.go).
|
|
if !ShouldAcceptFromClient("tool.result.abc123") {
|
|
t.Error("tool.result.* should be accepted from client")
|
|
}
|
|
if !ShouldSendToClient("tool.result.abc123") {
|
|
t.Error("tool.result.* should route DirBoth (filtered by WS subscriber, not routing table)")
|
|
}
|
|
}
|
|
|
|
func TestShellContractEventRoutes(t *testing.T) {
|
|
// package.changed — broadcast to all clients
|
|
if !ShouldSendToClient("package.changed") {
|
|
t.Error("package.changed should be sent to client")
|
|
}
|
|
if ShouldAcceptFromClient("package.changed") {
|
|
t.Error("package.changed should NOT be accepted from client")
|
|
}
|
|
|
|
// auth.changed — targeted to specific user
|
|
if !ShouldSendToClient("auth.changed") {
|
|
t.Error("auth.changed should be sent to client")
|
|
}
|
|
if ShouldAcceptFromClient("auth.changed") {
|
|
t.Error("auth.changed should NOT be accepted from client")
|
|
}
|
|
|
|
// notification.all_read — targeted to specific user
|
|
if !ShouldSendToClient("notification.all_read") {
|
|
t.Error("notification.all_read should be sent to client")
|
|
}
|
|
if ShouldAcceptFromClient("notification.all_read") {
|
|
t.Error("notification.all_read should NOT be accepted from client")
|
|
}
|
|
}
|