Files
玉澜 b7aa6bddf5 feat(corecmd): implement event and auto wait modes
Completes the wait capability per review guidance ("add corresponding
execution hooks and fallback tests for the modes"):

- contract.WaitSpec restores event/auto modes with event_key,
  match_field, and a new resource_query (dotted path into the accepted
  result data yielding the identifier events correlate against);
  per-mode validation of required fields
- internal/wait adds EventStream (leaf-owned transport) and RunEvent:
  correlated-event filtering, the same terminal/pending/unknown mapping
  as polling, timed-out pending on deadline during consumption, and an
  ErrEventStreamEnded sentinel distinguishing stream termination from
  fail-closed status errors
- Spec.WaitEvents hook; validateWaitDecl pairs mode with hooks
  (poll<->WaitPoll, event<->WaitEvents, auto<->both; surplus hooks
  rejected too)
- the wait phase runs event-first in auto mode and falls back to polling
  when the stream ends or the subscription fails, under one deadline
  spanning both phases; strict event mode surfaces stream errors
- output.CommandResult gains Data() (deep copy) so the framework can
  resolve the resource identifier without exposing mutable state

Changed-code coverage re-verified at 100% (CI cross-package recipe).
2026-08-15 19:29:22 +08:00

366 lines
11 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 wait
import (
"context"
"errors"
"io"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func loopSpec() LoopSpec {
return LoopSpec{
StatusQuery: "result.status",
Terminal: map[string]contract.ResultOutcome{
"COMPLETED": contract.ResultOutcomeSuccess,
"REJECTED": contract.ResultOutcomeFailure,
},
Pending: []string{"NEW", "RUNNING"},
Interval: time.Millisecond,
}
}
func TestExtractStatusResolvesDottedPaths(t *testing.T) {
doc := PollDoc{
"result": map[string]any{
"instance": map[string]any{"status": "RUNNING"},
"count": float64(3),
},
}
if status, ok := ExtractStatus(doc, "result.instance.status"); !ok || status != "RUNNING" {
t.Fatalf("status=%q ok=%v", status, ok)
}
if status, ok := ExtractStatus(doc, "result.count"); !ok || status != "3" {
t.Fatalf("numeric status=%q ok=%v", status, ok)
}
if _, ok := ExtractStatus(doc, "result.missing"); ok {
t.Fatal("missing path resolved")
}
if _, ok := ExtractStatus(doc, "result.instance.status.deep"); ok {
t.Fatal("descending into a scalar resolved")
}
if _, ok := ExtractStatus(doc, ""); ok {
t.Fatal("empty query resolved")
}
}
func TestRunReturnsTerminalOnFirstPoll(t *testing.T) {
polls := 0
outcome, err := Run(context.Background(), loopSpec(), func(context.Context) (PollDoc, error) {
polls++
return PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
})
if err != nil {
t.Fatal(err)
}
if polls != 1 || outcome.Attempts != 1 {
t.Fatalf("polls=%d attempts=%d", polls, outcome.Attempts)
}
if outcome.Outcome != contract.ResultOutcomeSuccess || outcome.Status != "COMPLETED" {
t.Fatalf("outcome=%s status=%s", outcome.Outcome, outcome.Status)
}
}
func TestRunPollsUntilTerminal(t *testing.T) {
seen := []string{"NEW", "RUNNING", "RUNNING", "COMPLETED"}
index := 0
outcome, err := Run(context.Background(), loopSpec(), func(context.Context) (PollDoc, error) {
status := seen[index]
index++
return PollDoc{"result": map[string]any{"status": status}}, nil
})
if err != nil {
t.Fatal(err)
}
if outcome.Attempts != len(seen) || outcome.Outcome != contract.ResultOutcomeSuccess {
t.Fatalf("attempts=%d outcome=%s", outcome.Attempts, outcome.Outcome)
}
}
func TestRunTimesOutAsPendingDuringWait(t *testing.T) {
spec := loopSpec()
spec.Timeout = 5 * time.Millisecond
polls := 0
outcome, err := Run(context.Background(), spec, func(context.Context) (PollDoc, error) {
polls++
return PollDoc{"result": map[string]any{"status": "RUNNING"}}, nil
})
if err != nil {
t.Fatal(err)
}
if !outcome.TimedOut || outcome.Outcome != contract.ResultOutcomePending {
t.Fatalf("timedOut=%v outcome=%s", outcome.TimedOut, outcome.Outcome)
}
if outcome.Status != "RUNNING" {
t.Fatalf("status=%q, want last observed", outcome.Status)
}
if polls == 0 {
t.Fatal("timeout during wait must still have polled at least once")
}
}
func TestRunTimesOutAsPendingWhenPollerRespectsDeadline(t *testing.T) {
spec := loopSpec()
spec.Timeout = 5 * time.Millisecond
polls := 0
// A context-aware poller: blocks until the deadline, then reports the
// cancellation as an error — the loop must close it as timed-out pending,
// never as a poll failure.
outcome, err := Run(context.Background(), spec, func(ctx context.Context) (PollDoc, error) {
polls++
<-ctx.Done()
return nil, ctx.Err()
})
if err != nil {
t.Fatalf("deadline during poll closed as error: %v", err)
}
if !outcome.TimedOut || outcome.Outcome != contract.ResultOutcomePending {
t.Fatalf("timedOut=%v outcome=%s", outcome.TimedOut, outcome.Outcome)
}
}
func TestRunTimesOutBeforeFirstPoll(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
outcome, err := Run(ctx, loopSpec(), func(context.Context) (PollDoc, error) {
t.Fatal("poller ran on a pre-cancelled context")
return nil, nil
})
if err != nil {
t.Fatal(err)
}
if !outcome.TimedOut || outcome.Attempts != 0 || outcome.Outcome != contract.ResultOutcomePending {
t.Fatalf("timedOut=%v attempts=%d outcome=%s", outcome.TimedOut, outcome.Attempts, outcome.Outcome)
}
}
func TestRunFailsClosedOnUnknownStatus(t *testing.T) {
_, err := Run(context.Background(), loopSpec(), func(context.Context) (PollDoc, error) {
return PollDoc{"result": map[string]any{"status": "Mystery"}}, nil
})
if !IsUnknownStatus(err) {
t.Fatalf("err=%v want unknown-status", err)
}
}
func TestRunFailsOnMissingStatusQuery(t *testing.T) {
_, err := Run(context.Background(), loopSpec(), func(context.Context) (PollDoc, error) {
return PollDoc{"unexpected": true}, nil
})
if err == nil || !errors.Is(err, err) {
t.Fatalf("err=%v", err)
}
}
func TestRunPropagatesPollerError(t *testing.T) {
boom := errors.New("rpc down")
_, err := Run(context.Background(), loopSpec(), func(context.Context) (PollDoc, error) {
return nil, boom
})
if !errors.Is(err, boom) {
t.Fatalf("err=%v", err)
}
}
func TestUnknownStatusErrorCarriesStatusAndQuery(t *testing.T) {
err := &ErrUnknownStatus{Status: "Mystery", Query: "result.status"}
message := err.Error()
if !strings.Contains(message, "Mystery") || !strings.Contains(message, "result.status") {
t.Fatalf("message=%q", message)
}
}
func TestRunAppliesDefaultIntervalWhenUnset(t *testing.T) {
spec := loopSpec()
spec.Interval = 0
polls := 0
// Terminal on the second poll forces one interval wait; with Interval=0
// the loop must still work using DefaultPollInterval (not spin/panic).
_, err := Run(context.Background(), spec, func(context.Context) (PollDoc, error) {
polls++
if polls == 1 {
return PollDoc{"result": map[string]any{"status": "NEW"}}, nil
}
return PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
})
if err != nil {
t.Fatal(err)
}
if polls != 2 {
t.Fatalf("polls=%d", polls)
}
}
func TestRunTimesOutDuringWaitBetweenPolls(t *testing.T) {
spec := loopSpec()
spec.Interval = time.Hour // the deadline wins long before the next poll
spec.Timeout = 5 * time.Millisecond
outcome, err := Run(context.Background(), spec, func(context.Context) (PollDoc, error) {
return PollDoc{"result": map[string]any{"status": "RUNNING"}}, nil
})
if err != nil {
t.Fatal(err)
}
if !outcome.TimedOut || outcome.Outcome != contract.ResultOutcomePending || outcome.Status != "RUNNING" {
t.Fatalf("outcome=%+v", outcome)
}
}
type stringStatus string
func (s stringStatus) String() string { return string(s) }
func TestExtractStatusCoversScalarShapes(t *testing.T) {
doc := PollDoc{
"result": map[string]any{
"flag": true,
"small": 7,
"big": int64(9007199254740993),
"fraction": 1.5,
"custom": stringStatus("CUSTOM"),
"nested": map[string]any{"deep": "x"},
},
}
cases := map[string]string{
"result.flag": "true",
"result.small": "7",
"result.big": "9007199254740993",
"result.fraction": "1.5",
"result.custom": "CUSTOM",
}
for query, want := range cases {
if got, ok := ExtractStatus(doc, query); !ok || got != want {
t.Fatalf("query=%s got=%q ok=%v want=%q", query, got, ok, want)
}
}
if _, ok := ExtractStatus(doc, "result.nested"); ok {
t.Fatal("non-scalar nested map must not resolve")
}
if _, ok := ExtractStatus(doc, "result..flag"); ok {
t.Fatal("empty segment must not resolve")
}
}
func TestNextIntervalCapsAtMax(t *testing.T) {
if got := nextInterval(MaxPollInterval); got != MaxPollInterval {
t.Fatalf("nextInterval(max)=%s", got)
}
if got := nextInterval(10 * time.Millisecond); got != 15*time.Millisecond {
t.Fatalf("nextInterval(10ms)=%s", got)
}
}
type fakeEventStream struct {
events []PollDoc
err error // returned after events are exhausted (nil = clean end)
block bool // hold until the context deadline
}
func (f *fakeEventStream) Recv(ctx context.Context) (PollDoc, error) {
if f.block {
<-ctx.Done()
return nil, ctx.Err()
}
if len(f.events) > 0 {
doc := f.events[0]
f.events = f.events[1:]
return doc, nil
}
if f.err != nil {
return nil, f.err
}
return nil, io.EOF
}
func eventLoopSpec() EventLoopSpec {
return EventLoopSpec{
StatusQuery: "result.status",
MatchField: "process_instance_id",
Terminal: map[string]contract.ResultOutcome{
"COMPLETED": contract.ResultOutcomeSuccess,
"REJECTED": contract.ResultOutcomeFailure,
},
Pending: []string{"RUNNING"},
}
}
func approvalEvent(instance, status string) PollDoc {
return PollDoc{"process_instance_id": instance, "result": map[string]any{"status": status}}
}
func TestRunEventReturnsCorrelatedTerminal(t *testing.T) {
stream := &fakeEventStream{events: []PollDoc{
approvalEvent("other-instance", "COMPLETED"), // other resource: ignored
approvalEvent("job-1", "RUNNING"), // correlated pending: kept waiting
approvalEvent("job-1", "COMPLETED"),
}}
outcome, err := RunEvent(context.Background(), eventLoopSpec(), "job-1", stream)
if err != nil {
t.Fatal(err)
}
if outcome.Outcome != contract.ResultOutcomeSuccess || outcome.Status != "COMPLETED" {
t.Fatalf("outcome=%+v", outcome)
}
}
func TestRunEventFailsClosedOnUnknownCorrelatedStatus(t *testing.T) {
stream := &fakeEventStream{events: []PollDoc{approvalEvent("job-1", "Mystery")}}
_, err := RunEvent(context.Background(), eventLoopSpec(), "job-1", stream)
if !IsUnknownStatus(err) {
t.Fatalf("err=%v", err)
}
}
func TestRunEventRejectsCorrelatedEventWithoutStatus(t *testing.T) {
stream := &fakeEventStream{events: []PollDoc{
{"process_instance_id": "job-1"}, // correlated but no status document
}}
_, err := RunEvent(context.Background(), eventLoopSpec(), "job-1", stream)
if err == nil || !strings.Contains(err.Error(), "status query") {
t.Fatalf("err=%v", err)
}
}
func TestRunEventStreamEndSurfacesFallbackSentinel(t *testing.T) {
stream := &fakeEventStream{events: []PollDoc{approvalEvent("job-1", "RUNNING")}}
_, err := RunEvent(context.Background(), eventLoopSpec(), "job-1", stream)
if !errors.Is(err, ErrEventStreamEnded) {
t.Fatalf("err=%v, want ErrEventStreamEnded", err)
}
failing := &fakeEventStream{err: errors.New("transport reset")}
_, err = RunEvent(context.Background(), eventLoopSpec(), "job-1", failing)
if !errors.Is(err, ErrEventStreamEnded) {
t.Fatalf("err=%v, want ErrEventStreamEnded wrapping the transport error", err)
}
}
func TestRunEventTimesOutAsPendingWhileBlocked(t *testing.T) {
spec := eventLoopSpec()
spec.Timeout = 5 * time.Millisecond
stream := &fakeEventStream{block: true}
outcome, err := RunEvent(context.Background(), spec, "job-1", stream)
if err != nil {
t.Fatal(err)
}
if !outcome.TimedOut || outcome.Outcome != contract.ResultOutcomePending {
t.Fatalf("outcome=%+v", outcome)
}
}