Files
dingtalk-workspace-cli/internal/event/consume/run_test.go
wxianfeng adc528c206 Merge remote-tracking branch 'upstream/main' into feat/dws-event-im-2phase
# Conflicts:
#	.github/badges/coverage.svg
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
#	internal/event/consume/formatter.go
2026-07-16 14:27:39 +08:00

549 lines
16 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package consume
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func skipOnWindows(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("uses Unix socket")
}
}
func shortTempDir(t *testing.T) string {
t.Helper()
if runtime.GOOS == "windows" {
return t.TempDir()
}
dir, err := os.MkdirTemp("/tmp", "dws-consume-")
if err != nil {
t.Fatalf("mktemp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
return dir
}
// fakeSource mirrors the one in bus tests; reproduced here to keep the
// integration test self-contained.
type fakeSource struct {
events []dwsevent.RawEvent
trigger <-chan struct{}
}
func (f *fakeSource) Start(ctx context.Context, emit dwsevent.EmitFn) error {
if f.trigger != nil {
select {
case <-f.trigger:
case <-ctx.Done():
return ctx.Err()
}
}
for i := range f.events {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
ev := f.events[i]
ev.ReceivedAt = time.Now().UTC()
emit(&ev)
time.Sleep(5 * time.Millisecond)
}
<-ctx.Done()
return ctx.Err()
}
// bringUpBus starts a bus.Run in a goroutine and waits for its socket.
// Returns (workDir, sockPath, cancelFunc, runDone, fakeSource trigger).
func bringUpBus(t *testing.T, events []dwsevent.RawEvent) (string, string, context.CancelFunc, <-chan error, chan struct{}) {
t.Helper()
dir := shortTempDir(t)
sock := filepath.Join(dir, "bus.sock")
ctx, cancel := context.WithCancel(context.Background())
trigger := make(chan struct{})
src := &fakeSource{events: events, trigger: trigger}
done := make(chan error, 1)
go func() {
done <- bus.Run(ctx, bus.Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Edition: "open",
Source: src,
})
}()
// Wait for socket.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(sock); err == nil {
return dir, sock, cancel, done, trigger
}
time.Sleep(10 * time.Millisecond)
}
cancel()
t.Fatalf("bus socket did not appear")
return "", "", nil, nil, nil
}
// dialOnlyDiscover is a Discover-impl-bypass: tests don't want consume.Run
// to exec a real dws binary, so we sidestep by pre-bringing-up the bus and
// letting Discover succeed on its first dial attempt. No Spawn is required.
func TestRun_StdoutNDJSON(t *testing.T) {
skipOnWindows(t)
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
{EventID: "1", EventType: "im.message.receive_v1", Data: `{"text":"hi"}`},
{EventID: "2", EventType: "im.message.at_v1", Data: `{"at":1}`},
})
defer func() { cancel(); <-runDone }()
// Trigger source emission after we've started consume (otherwise events
// race ahead of consumer registration).
go func() {
time.Sleep(150 * time.Millisecond)
close(trigger)
}()
var stdout bytes.Buffer
var stderr bytes.Buffer
ctx, consumeCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer consumeCancel()
err := Run(ctx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &stdout,
Stderr: &stderr,
EventTypes: []string{"im.*"},
EventKey: "im.message.receive_v1",
ReadySubscribeID: "sub-1",
MaxEvents: 2,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
// Verify NDJSON: each non-empty line is a valid Event JSON.
lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 NDJSON lines, got %d:\n%s", len(lines), stdout.String())
}
for i, line := range lines {
var ev transport.Event
if err := json.Unmarshal([]byte(line), &ev); err != nil {
t.Errorf("line %d not valid JSON: %v\n%s", i, err, line)
}
if ev.Type != transport.FrameTypeEvent {
t.Errorf("line %d type = %s, want event", i, ev.Type)
}
}
// Stderr should carry the standardized ready marker.
if !strings.Contains(stderr.String(), "[event] ready ") {
t.Errorf("stderr missing ready marker:\n%s", stderr.String())
}
if !strings.Contains(stderr.String(), "[event] ready event_key=im.message.receive_v1 bus_pid=") ||
!strings.Contains(stderr.String(), " subscribe_id=sub-1") {
t.Errorf("stderr missing ready marker:\n%s", stderr.String())
}
}
func TestRun_QuietSuppressesStderr(t *testing.T) {
skipOnWindows(t)
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
})
defer func() { cancel(); <-runDone }()
go func() { time.Sleep(150 * time.Millisecond); close(trigger) }()
var stdout, stderr bytes.Buffer
ctx, consumeCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer consumeCancel()
err := Run(ctx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &stdout,
Stderr: &stderr,
Quiet: true,
EventKey: "im.message.receive_v1",
ReadySubscribeID: "sub-1",
MaxEvents: 1,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if stderr.Len() != 0 {
t.Errorf("--quiet should suppress all stderr; got: %s", stderr.String())
}
if stdout.Len() == 0 {
t.Error("stdout should still contain the NDJSON event")
}
}
func TestRun_WritesFirstProjectedEventBeforeConsumeExits(t *testing.T) {
skipOnWindows(t)
dir, sock, cancelBus, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
{EventID: "1", EventType: "personal.message", Data: `{"content":"hello"}`},
})
defer func() { cancelBus(); <-runDone }()
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer reader.Close()
defer writer.Close()
ctx, cancelConsume := context.WithCancel(context.Background())
consumeDone := make(chan error, 1)
go func() {
consumeDone <- Run(ctx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: writer,
Stderr: io.Discard,
Projector: func(ev transport.Event) (any, error) {
return map[string]any{"type": ev.EventType, "content": "hello"}, nil
},
})
}()
go func() {
time.Sleep(150 * time.Millisecond)
close(trigger)
}()
lineCh := make(chan string, 1)
errCh := make(chan error, 1)
go func() {
line, err := bufio.NewReader(reader).ReadString('\n')
if err != nil {
errCh <- err
return
}
lineCh <- line
}()
select {
case line := <-lineCh:
var got map[string]any
if err := json.Unmarshal([]byte(line), &got); err != nil || got["content"] != "hello" {
t.Fatalf("first line = %q, err = %v", line, err)
}
case err := <-errCh:
t.Fatalf("read first line: %v", err)
case <-time.After(2 * time.Second):
t.Fatal("first event was not readable while consume was running")
}
select {
case err := <-consumeDone:
t.Fatalf("consume exited before cancellation: %v", err)
default:
}
cancelConsume()
select {
case err := <-consumeDone:
if err != nil {
t.Fatalf("Run() after cancellation = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("consume did not exit after cancellation")
}
}
func TestRun_CtxCancelReturnsCleanly(t *testing.T) {
skipOnWindows(t)
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
defer func() { cancel(); <-runDone }()
ctx, consumeCancel := context.WithCancel(context.Background())
consumeDone := make(chan error, 1)
go func() {
consumeDone <- Run(ctx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: io.Discard,
Stderr: io.Discard,
})
}()
// Let consume connect.
time.Sleep(100 * time.Millisecond)
consumeCancel()
select {
case err := <-consumeDone:
if err != nil && !errors.Is(err, context.Canceled) {
t.Fatalf("Run returned %v, want nil or canceled", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Run did not return after ctx cancel")
}
}
func TestRun_MaxEventsZeroIsUnlimited(t *testing.T) {
skipOnWindows(t)
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
{EventID: "1", EventType: "x", Data: `{}`},
{EventID: "2", EventType: "x", Data: `{}`},
{EventID: "3", EventType: "x", Data: `{}`},
})
defer func() { cancel(); <-runDone }()
var stdout bytes.Buffer
ctx, consumeCancel := context.WithCancel(context.Background())
consumeDone := make(chan error, 1)
go func() {
consumeDone <- Run(ctx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &stdout,
Stderr: io.Discard,
MaxEvents: 0, // unlimited
})
}()
// Wait for consume to dial + Hello (otherwise events fire before
// consumer registers and the Hub drops them silently — no consumer
// to deliver to).
time.Sleep(150 * time.Millisecond)
close(trigger)
// Let all 3 events flow.
time.Sleep(200 * time.Millisecond)
consumeCancel()
<-consumeDone
lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n")
if len(lines) != 3 {
t.Fatalf("expected 3 events with MaxEvents=0, got %d:\n%s", len(lines), stdout.String())
}
}
// TestRun_MultipleConsumersOneBus exercises the daemon's multi-consumer
// fan-out via the real consume.Run path. Both consumers should receive
// every matching event independently.
func TestRun_MultipleConsumersOneBus(t *testing.T) {
skipOnWindows(t)
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
{EventID: "2", EventType: "im.message.receive_v1", Data: `{}`},
})
defer func() { cancel(); <-runDone }()
var wg sync.WaitGroup
bufs := make([]*bytes.Buffer, 2)
for i := 0; i < 2; i++ {
i := i
bufs[i] = &bytes.Buffer{}
wg.Add(1)
go func() {
defer wg.Done()
_ = Run(context.Background(), Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: bufs[i],
Stderr: io.Discard,
MaxEvents: 2,
})
}()
}
// Both consumers should be Hello'd before trigger.
time.Sleep(200 * time.Millisecond)
close(trigger)
wg.Wait()
for i, buf := range bufs {
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 2 {
t.Errorf("consumer %d: got %d lines, want 2:\n%s", i, len(lines), buf.String())
}
}
}
// --- AI subprocess contract tests (event-subprocess-contract.md) ---
// T1a/T1b: the ready marker carries event_key and precedes the first
// stdout event.
func TestRun_ReadyMarkerContract(t *testing.T) {
skipOnWindows(t)
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
})
defer func() { cancel(); <-runDone }()
go func() { time.Sleep(150 * time.Millisecond); close(trigger) }()
var stdout, stderr bytes.Buffer
ctx, cc := context.WithTimeout(context.Background(), 3*time.Second)
defer cc()
if err := Run(ctx, Config{
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
Stdout: &stdout, Stderr: &stderr,
EventKey: "im.message.receive_v1", MaxEvents: 1,
}); err != nil {
t.Fatalf("Run: %v", err)
}
// T1a: ready line with event_key.
if !strings.Contains(stderr.String(), "[event] ready event_key=im.message.receive_v1") {
t.Errorf("missing ready marker with event_key:\n%s", stderr.String())
}
// T1b: ready is on stderr, the event is on stdout — the ready line is
// emitted before Deliver writes the first stdout line. Assert stdout
// got exactly the event and stderr got ready before exited.
rIdx := strings.Index(stderr.String(), "[event] ready")
xIdx := strings.Index(stderr.String(), "[event] exited")
if rIdx < 0 || xIdx < 0 || rIdx > xIdx {
t.Errorf("ready must precede exited:\n%s", stderr.String())
}
if !strings.Contains(stdout.String(), "im.message.receive_v1") {
t.Errorf("stdout missing the event:\n%s", stdout.String())
}
}
// T3a: --max-events exit → reason=limit.
func TestRun_ExitReasonLimit(t *testing.T) {
skipOnWindows(t)
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
})
defer func() { cancel(); <-runDone }()
go func() { time.Sleep(150 * time.Millisecond); close(trigger) }()
var stdout, stderr bytes.Buffer
ctx, cc := context.WithTimeout(context.Background(), 3*time.Second)
defer cc()
if err := Run(ctx, Config{
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
Stdout: &stdout, Stderr: &stderr, MaxEvents: 1,
}); err != nil {
t.Fatalf("Run: %v", err)
}
if !strings.Contains(stderr.String(), "reason: limit") {
t.Errorf("expected reason: limit:\n%s", stderr.String())
}
}
// T3b: --duration deadline with no events → reason=timeout, exit clean.
func TestRun_ExitReasonTimeout(t *testing.T) {
skipOnWindows(t)
// Bring up bus but never trigger emission → consume blocks until the
// duration deadline fires.
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
defer func() { cancel(); <-runDone }()
var stdout, stderr bytes.Buffer
ctx, cc := context.WithTimeout(context.Background(), 5*time.Second)
defer cc()
if err := Run(ctx, Config{
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
Stdout: &stdout, Stderr: &stderr, Duration: 300 * time.Millisecond,
}); err != nil {
t.Fatalf("Run should exit clean on duration: %v", err)
}
if !strings.Contains(stderr.String(), "reason: timeout") {
t.Errorf("expected reason: timeout:\n%s", stderr.String())
}
}
// T3c: parent ctx cancel (signal) → reason=signal.
func TestRun_ExitReasonSignal(t *testing.T) {
skipOnWindows(t)
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
defer func() { cancel(); <-runDone }()
var stdout, stderr bytes.Buffer
ctx, cc := context.WithCancel(context.Background())
go func() { time.Sleep(250 * time.Millisecond); cc() }()
if err := Run(ctx, Config{
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
Stdout: &stdout, Stderr: &stderr,
}); err != nil {
t.Fatalf("Run should exit clean on signal: %v", err)
}
if !strings.Contains(stderr.String(), "reason: signal") {
t.Errorf("expected reason: signal:\n%s", stderr.String())
}
}
// T2c: closing stdin triggers a graceful shutdown (reason=signal), with no
// --max-events / --duration set.
func TestRun_StdinEOFShutsDown(t *testing.T) {
skipOnWindows(t)
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
defer func() { cancel(); <-runDone }()
pr, pw := io.Pipe()
go func() { time.Sleep(250 * time.Millisecond); _ = pw.Close() }() // EOF
var stdout, stderr bytes.Buffer
ctx, cc := context.WithTimeout(context.Background(), 5*time.Second)
defer cc()
done := make(chan error, 1)
go func() {
done <- Run(ctx, Config{
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
Stdout: &stdout, Stderr: &stderr, Stdin: pr,
})
}()
select {
case err := <-done:
if err != nil {
t.Fatalf("Run should exit clean on stdin EOF: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("Run did not exit after stdin EOF")
}
if !strings.Contains(stderr.String(), "reason: signal") {
t.Errorf("expected reason: signal on stdin EOF:\n%s", stderr.String())
}
}
// T3d: a startup failure returns non-nil and emits NO `exited` line (the
// cobra layer prints an Error: line instead).
func TestRun_FailureHasNoExitedLine(t *testing.T) {
skipOnWindows(t)
var stderr bytes.Buffer
// Missing WorkDir/IPCEndpoint/ClientID → immediate config error, before
// the exit-reason machinery is armed.
err := Run(context.Background(), Config{Stderr: &stderr})
if err == nil {
t.Fatal("expected error for missing required config")
}
if strings.Contains(stderr.String(), "[event] exited") {
t.Errorf("failure must not print an exited line:\n%s", stderr.String())
}
}