737 lines
28 KiB
Go
737 lines
28 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 output
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||
)
|
||
|
||
var errEmitterInjected = errors.New("emitter injected write failure")
|
||
|
||
// emitterFailingWriter 记录被接受的字节并在写出时失败,
|
||
// 用于 B25「写一半失败不产生残缺 JSON」断言。
|
||
type emitterFailingWriter struct {
|
||
acceptN int // >0 时先接受 acceptN 字节再报错(模拟写到一半失败)
|
||
accepted []byte
|
||
}
|
||
|
||
func (w *emitterFailingWriter) Write(p []byte) (int, error) {
|
||
if w.acceptN > 0 && len(w.accepted) < w.acceptN {
|
||
k := w.acceptN - len(w.accepted)
|
||
if k > len(p) {
|
||
k = len(p)
|
||
}
|
||
w.accepted = append(w.accepted, p[:k]...)
|
||
return k, errEmitterInjected
|
||
}
|
||
return 0, errEmitterInjected
|
||
}
|
||
|
||
// emitterCountingWriter 统计 Write 调用次数并留存全部字节。
|
||
type emitterCountingWriter struct {
|
||
writes int
|
||
buf bytes.Buffer
|
||
}
|
||
|
||
func (w *emitterCountingWriter) Write(p []byte) (int, error) {
|
||
w.writes++
|
||
return w.buf.Write(p)
|
||
}
|
||
|
||
// emitterShortWriter 接受一半字节但谎报成功(n < len(p), err == nil),
|
||
// Emitter 必须检出短写而不是把截断输出当成功。
|
||
type emitterShortWriter struct {
|
||
n int
|
||
}
|
||
|
||
func (w *emitterShortWriter) Write(p []byte) (int, error) {
|
||
w.n = len(p) / 2
|
||
return w.n, nil
|
||
}
|
||
|
||
func newPartialEnvelopeFixture() *Envelope {
|
||
return NewPartialEnvelope(map[string]any{
|
||
"total": 3,
|
||
"succeeded": []any{map[string]any{"id": "a", "messageId": "m_1"}},
|
||
"failed": []any{map[string]any{"id": "b", "error": map[string]any{"type": "api", "code": 40001}}},
|
||
"unknown": []any{map[string]any{"id": "c", "reason": "timeout after submit"}},
|
||
})
|
||
}
|
||
|
||
// --- B24:Emitter 类型与构造 ---
|
||
|
||
func TestNewEmitterHandlesNilWriters(t *testing.T) {
|
||
em := NewEmitter(nil, nil, FormatJSON, "", "")
|
||
if em == nil {
|
||
t.Fatal("NewEmitter returned nil")
|
||
}
|
||
// nil writer 等价 io.Discard:不 panic,写出成功且无副作用。
|
||
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err != nil {
|
||
t.Fatalf("Emit with nil writers: %v", err)
|
||
}
|
||
if err := em.Emit(NewFailureEnvelope(&ErrorInfo{Type: "api"})); err != nil {
|
||
t.Fatalf("Emit failure with nil writers: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestNewEmitterKeepsConfig(t *testing.T) {
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatTable, "id,name", ".data")
|
||
if em.format != FormatTable || em.fields != "id,name" || em.jq != ".data" {
|
||
t.Fatalf("NewEmitter dropped config: format=%q fields=%q jq=%q", em.format, em.fields, em.jq)
|
||
}
|
||
}
|
||
|
||
// --- B26:成功信封 stdout 出口(恰为一个完整 JSON 文档)---
|
||
|
||
func TestEmitterSuccessEnvelopeGoesToStdoutOnly(t *testing.T) {
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||
env := NewSuccessEnvelope(map[string]any{"todoId": "t_123"})
|
||
env.Meta = &Meta{Count: NewCount(1)}
|
||
|
||
if err := em.Emit(env); err != nil {
|
||
t.Fatalf("Emit success: %v", err)
|
||
}
|
||
if stderr.Len() != 0 {
|
||
t.Fatalf("success envelope must not touch stderr, got %d bytes: %s", stderr.Len(), stderr.String())
|
||
}
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||
t.Fatalf("stdout is not exactly one complete JSON document: %v\n%s", err, stdout.String())
|
||
}
|
||
dec := json.NewDecoder(bytes.NewReader(stdout.Bytes()))
|
||
if err := dec.Decode(&map[string]any{}); err != nil {
|
||
t.Fatalf("first JSON document invalid: %v", err)
|
||
}
|
||
if dec.More() {
|
||
t.Fatalf("stdout must contain exactly one JSON document, got trailing content: %s", stdout.String())
|
||
}
|
||
if okVal, isBool := decoded["ok"].(bool); !isBool || !okVal {
|
||
t.Fatalf("ok = %v (%T), want true (bool)", decoded["ok"], decoded["ok"])
|
||
}
|
||
if decoded["outcome"] != "success" {
|
||
t.Fatalf("outcome = %v, want success", decoded["outcome"])
|
||
}
|
||
}
|
||
|
||
func TestEmitterPendingEnvelopeGoesToStdoutOnly(t *testing.T) {
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||
env := NewPendingEnvelope(&OperationInfo{ID: "task_abc", State: "processing"})
|
||
|
||
if err := em.Emit(env); err != nil {
|
||
t.Fatalf("Emit pending: %v", err)
|
||
}
|
||
if stderr.Len() != 0 {
|
||
t.Fatalf("pending envelope must not touch stderr, got %d bytes", stderr.Len())
|
||
}
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||
}
|
||
if okVal, isBool := decoded["ok"].(bool); !isBool || !okVal {
|
||
t.Fatalf("pending envelope ok = %v, want true", decoded["ok"])
|
||
}
|
||
if decoded["outcome"] != "pending" {
|
||
t.Fatalf("outcome = %v, want pending", decoded["outcome"])
|
||
}
|
||
}
|
||
|
||
// --- B27:失败信封 stderr 出口(stdout 严格零字节)---
|
||
|
||
func TestEmitterFailureEnvelopeGoesToStderrWithZeroStdout(t *testing.T) {
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||
env := NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "rate_limit", Code: 90018})
|
||
|
||
if err := em.Emit(env); err != nil {
|
||
t.Fatalf("Emit failure: %v", err)
|
||
}
|
||
if stdout.Len() != 0 {
|
||
t.Fatalf("failure envelope must leave stdout empty, got %d bytes: %s", stdout.Len(), stdout.String())
|
||
}
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(stderr.Bytes(), &decoded); err != nil {
|
||
t.Fatalf("stderr failure envelope is not valid JSON: %v\n%s", err, stderr.String())
|
||
}
|
||
if okVal, isBool := decoded["ok"].(bool); !isBool || okVal {
|
||
t.Fatalf("failure envelope ok = %v, want false (bool)", decoded["ok"])
|
||
}
|
||
if decoded["outcome"] != "failure" {
|
||
t.Fatalf("outcome = %v, want failure", decoded["outcome"])
|
||
}
|
||
if _, hasError := decoded["error"]; !hasError {
|
||
t.Fatalf("failure envelope must carry error detail: %s", stderr.String())
|
||
}
|
||
}
|
||
|
||
func TestEmitterNilEnvelopeDegradesToFailureOnStderr(t *testing.T) {
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||
if err := em.Emit(nil); err != nil {
|
||
t.Fatalf("Emit(nil): %v", err)
|
||
}
|
||
if stdout.Len() != 0 {
|
||
t.Fatalf("nil envelope must leave stdout empty, got %d bytes", stdout.Len())
|
||
}
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(stderr.Bytes(), &decoded); err != nil {
|
||
t.Fatalf("stderr is not valid JSON: %v\n%s", err, stderr.String())
|
||
}
|
||
if decoded["outcome"] != "failure" {
|
||
t.Fatalf("nil envelope must degrade to failure: %s", stderr.String())
|
||
}
|
||
// B208(轮8顺带核查①:兜底修正后同步强化):降级信封必须是 I3
|
||
// 合法形态——ok:false 恒真、error 明细存在且为 internal 类,
|
||
// 兜底信封自身通过 Validate()。
|
||
if okVal, isBool := decoded["ok"].(bool); !isBool || okVal {
|
||
t.Fatalf("nil fallback ok = %v, want false (bool): %s", decoded["ok"], stderr.String())
|
||
}
|
||
errObj, hasErr := decoded["error"].(map[string]any)
|
||
if !hasErr {
|
||
t.Fatalf("nil fallback must carry error detail (I3, B208): %s", stderr.String())
|
||
}
|
||
if errObj["type"] != "internal" {
|
||
t.Fatalf("fallback error.type = %v, want internal: %s", errObj["type"], stderr.String())
|
||
}
|
||
if err := nilFallbackEnvelope().Validate(); err != nil {
|
||
t.Fatalf("nil fallback envelope must pass Validate(), got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestEmitterFailureEnvelopeBypassesFormatAndJQ(t *testing.T) {
|
||
// 失败信封恒以完整 JSON 信封落 stderr,不受 -f table/--jq 影响:
|
||
// 失败信封无 data 载荷(I3),按 format 分发只会渲染空值,
|
||
// Agent 必须总能读到结构化错误明细(§5.1/§2.4)。
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatTable, "id", ".data")
|
||
env := NewFailureEnvelope(&ErrorInfo{Type: "validation", Message: "bad flag"})
|
||
|
||
if err := em.Emit(env); err != nil {
|
||
t.Fatalf("Emit failure with table/jq config: %v", err)
|
||
}
|
||
if stdout.Len() != 0 {
|
||
t.Fatalf("stdout must stay empty for failure, got %d bytes", stdout.Len())
|
||
}
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(stderr.Bytes(), &decoded); err != nil {
|
||
t.Fatalf("stderr failure envelope must stay full JSON regardless of format: %v\n%s", err, stderr.String())
|
||
}
|
||
if decoded["outcome"] != "failure" || decoded["error"] == nil {
|
||
t.Fatalf("stderr lost failure envelope shape under -f table: %s", stderr.String())
|
||
}
|
||
}
|
||
|
||
// --- B28:partial_failure 明细落 stdout、stderr 零输出 ---
|
||
|
||
func TestEmitterPartialFailureStdoutOnly(t *testing.T) {
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||
|
||
if err := em.Emit(newPartialEnvelopeFixture()); err != nil {
|
||
t.Fatalf("Emit partial_failure: %v", err)
|
||
}
|
||
if stderr.Len() != 0 {
|
||
t.Fatalf("partial_failure must keep stderr empty (§2.3), got %d bytes: %s", stderr.Len(), stderr.String())
|
||
}
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||
}
|
||
if okVal, isBool := decoded["ok"].(bool); !isBool || okVal {
|
||
t.Fatalf("partial_failure ok = %v, want false (bool)", decoded["ok"])
|
||
}
|
||
if decoded["outcome"] != "partial_failure" {
|
||
t.Fatalf("outcome = %v, want partial_failure", decoded["outcome"])
|
||
}
|
||
data, isMap := decoded["data"].(map[string]any)
|
||
if !isMap {
|
||
t.Fatalf("partial data must be the three-channel detail object: %s", stdout.String())
|
||
}
|
||
for _, channel := range []string{"total", "succeeded", "failed", "unknown"} {
|
||
if _, ok := data[channel]; !ok {
|
||
t.Fatalf("partial data missing %q channel: %s", channel, stdout.String())
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- B25:渲染先进 buffer 再一次性写出 ---
|
||
|
||
func TestEmitterRenderErrorLeavesZeroBytes(t *testing.T) {
|
||
// 渲染失败(marshal 报错)必须不向目标流泄漏任何字节。
|
||
testseam.Swap(t, &marshalJSONIndent, func(any, string, string) ([]byte, error) {
|
||
return nil, errEmitterInjected
|
||
})
|
||
target := &emitterFailingWriter{acceptN: 64}
|
||
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
|
||
err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"}))
|
||
if err == nil {
|
||
t.Fatal("Emit must surface render failure")
|
||
}
|
||
if len(target.accepted) != 0 {
|
||
t.Fatalf("render failure leaked %d bytes to the stream: %s", len(target.accepted), target.accepted)
|
||
}
|
||
}
|
||
|
||
func TestEmitterWriteFailureLeavesZeroBytes(t *testing.T) {
|
||
// 写出失败:buffer 先行意味着目标流要么收到完整文档、要么零字节。
|
||
target := &emitterFailingWriter{} // 第一次 Write 即整体拒绝
|
||
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
|
||
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err == nil {
|
||
t.Fatal("Emit must surface write failure")
|
||
}
|
||
if len(target.accepted) != 0 {
|
||
t.Fatalf("write failure leaked %d bytes", len(target.accepted))
|
||
}
|
||
}
|
||
|
||
func TestEmitterWriteOnceEmitsSingleWriteOfFullDocument(t *testing.T) {
|
||
// 「一次性写出」的直接断言:目标流只收到一次 Write,
|
||
// 且字节与独立重渲染结果逐字节一致(无分块流式拼接)。
|
||
target := &emitterCountingWriter{}
|
||
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
|
||
env := NewSuccessEnvelope(map[string]any{"id": "x"})
|
||
if err := em.Emit(env); err != nil {
|
||
t.Fatalf("Emit: %v", err)
|
||
}
|
||
if target.writes != 1 {
|
||
t.Fatalf("expected exactly one Write call (buffer-first single flush), got %d", target.writes)
|
||
}
|
||
var want bytes.Buffer
|
||
if err := WriteEnvelopeTo(&want, env, FormatJSON, "", ""); err != nil {
|
||
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||
}
|
||
if !bytes.Equal(target.buf.Bytes(), want.Bytes()) {
|
||
t.Fatalf("streamed bytes differ from re-render:\n got: %s\nwant: %s", target.buf.String(), want.String())
|
||
}
|
||
}
|
||
|
||
func TestEmitterDetectsShortWrite(t *testing.T) {
|
||
// writer 谎报成功但只接受一半字节(短写):Emitter 必须报
|
||
// io.ErrShortWrite,绝不把截断(残缺)JSON 当成功。
|
||
target := &emitterShortWriter{}
|
||
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
|
||
err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"}))
|
||
if !errors.Is(err, io.ErrShortWrite) {
|
||
t.Fatalf("Emit on short write = %v, want io.ErrShortWrite", err)
|
||
}
|
||
}
|
||
|
||
// --- B29:meta.count 渲染位置(meta 层而非 data 层)---
|
||
|
||
func TestEmitterMetaCountRenderedInMetaLayer(t *testing.T) {
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||
env := NewSuccessEnvelope([]any{
|
||
map[string]any{"id": "a"},
|
||
map[string]any{"id": "b"},
|
||
map[string]any{"id": "c"},
|
||
})
|
||
env.Meta = &Meta{Count: NewCount(3)}
|
||
|
||
if err := em.Emit(env); err != nil {
|
||
t.Fatalf("Emit: %v", err)
|
||
}
|
||
raw := stdout.String()
|
||
// count 只允许出现在 meta 层:wire 上 "count" 键恰出现一次。
|
||
if got := strings.Count(raw, `"count"`); got != 1 {
|
||
t.Fatalf(`"count" appears %d times on the wire, want exactly 1 (meta layer only): %s`, got, raw)
|
||
}
|
||
var decoded struct {
|
||
Data []map[string]any `json:"data"`
|
||
Meta struct {
|
||
Count *int `json:"count"`
|
||
} `json:"meta"`
|
||
}
|
||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||
t.Fatalf("stdout is not valid JSON: %v\n%s", err, raw)
|
||
}
|
||
if decoded.Meta.Count == nil || *decoded.Meta.Count != 3 {
|
||
t.Fatalf("meta.count = %v, want 3: %s", decoded.Meta.Count, raw)
|
||
}
|
||
for i, row := range decoded.Data {
|
||
if _, hasCount := row["count"]; hasCount {
|
||
t.Fatalf("data[%d] must not carry count (belongs to meta layer): %s", i, raw)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- B30:dry_run 顶层字段注入断言(契约规范 §6 场景6)---
|
||
|
||
func TestEmitterDryRunFieldInjectedAndOmitEmpty(t *testing.T) {
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||
env := NewSuccessEnvelope(map[string]any{"plan": "create app DemoApp"})
|
||
env.DryRun = true
|
||
|
||
if err := em.Emit(env); err != nil {
|
||
t.Fatalf("Emit dry-run envelope: %v", err)
|
||
}
|
||
if stderr.Len() != 0 {
|
||
t.Fatalf("dry-run success envelope must not touch stderr, got %d bytes", stderr.Len())
|
||
}
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||
}
|
||
dryRun, isBool := decoded["dry_run"].(bool)
|
||
if !isBool {
|
||
t.Fatalf("dry_run must be a JSON boolean, got %v (%T): %s", decoded["dry_run"], decoded["dry_run"], stdout.String())
|
||
}
|
||
if !dryRun {
|
||
t.Fatalf("dry_run = %v, want true (§6 场景6): %s", dryRun, stdout.String())
|
||
}
|
||
if okVal, _ := decoded["ok"].(bool); !okVal {
|
||
t.Fatalf("dry-run preview keeps ok=true (exit 0 semantics): %s", stdout.String())
|
||
}
|
||
}
|
||
|
||
func TestEmitterDryRunFalseStaysOmitted(t *testing.T) {
|
||
// dry_run 为 false(非 dry-run)时必须整体缺席(omitempty,不输出 false)。
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err != nil {
|
||
t.Fatalf("Emit: %v", err)
|
||
}
|
||
raw := stdout.String()
|
||
if strings.Contains(raw, `"dry_run"`) {
|
||
t.Fatalf("dry_run=false must be omitted from wire (§2.5 omitempty): %s", raw)
|
||
}
|
||
}
|
||
|
||
// --- B31:identity 字段(user/app)omitempty 注入断言(契约规范 §2.5)---
|
||
|
||
func TestEmitterIdentityInjectedAndOmitEmpty(t *testing.T) {
|
||
for _, identity := range []string{"user", "app"} {
|
||
t.Run(identity, func(t *testing.T) {
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||
env := NewSuccessEnvelope(map[string]any{"id": "x"})
|
||
env.Identity = identity
|
||
|
||
if err := em.Emit(env); err != nil {
|
||
t.Fatalf("Emit: %v", err)
|
||
}
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||
}
|
||
if decoded["identity"] != identity {
|
||
t.Fatalf("identity = %v, want %q: %s", decoded["identity"], identity, stdout.String())
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestEmitterIdentityEmptyStaysOmitted(t *testing.T) {
|
||
// identity 未注入(空串)时字段缺席,wire 中不出现 "identity"。
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err != nil {
|
||
t.Fatalf("Emit: %v", err)
|
||
}
|
||
if raw := stdout.String(); strings.Contains(raw, `"identity"`) {
|
||
t.Fatalf("empty identity must be omitted (§2.5 omitempty): %s", raw)
|
||
}
|
||
}
|
||
|
||
// --- B32:pending 信封出口完整化(ok:true + operation + exit 0 语义标注)---
|
||
|
||
func TestEmitterPendingOutletFullFormWithOperationAndExitZero(t *testing.T) {
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||
env := NewPendingEnvelope(&OperationInfo{
|
||
ID: "task_abc",
|
||
State: "processing",
|
||
TimedOut: true, // §2.2:轮询超时保持真实 state + timed_out:true,禁止伪装 success
|
||
NextCommand: "dws wiki task-result --task task_abc",
|
||
})
|
||
env.Data = map[string]any{"spaceId": "s_1", "nodeId": "n_9"}
|
||
|
||
if err := em.Emit(env); err != nil {
|
||
t.Fatalf("Emit pending: %v", err)
|
||
}
|
||
if stderr.Len() != 0 {
|
||
t.Fatalf("pending envelope keeps stderr empty (§2.2 走 stdout), got %d bytes", stderr.Len())
|
||
}
|
||
var decoded struct {
|
||
OK bool `json:"ok"`
|
||
Outcome string `json:"outcome"`
|
||
Data any `json:"data"`
|
||
Meta struct {
|
||
Operation *OperationInfo `json:"operation"`
|
||
} `json:"meta"`
|
||
}
|
||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||
}
|
||
if !decoded.OK || decoded.Outcome != "pending" {
|
||
t.Fatalf("pending envelope = ok:%v outcome:%q, want ok:true outcome:pending: %s",
|
||
decoded.OK, decoded.Outcome, stdout.String())
|
||
}
|
||
op := decoded.Meta.Operation
|
||
if op == nil {
|
||
t.Fatalf("pending envelope must carry meta.operation (§2.2): %s", stdout.String())
|
||
}
|
||
if op.State != "processing" || !op.TimedOut {
|
||
t.Fatalf("operation must keep real state + timed_out:true (§2.2 anti-spoof): got state=%q timed_out=%v", op.State, op.TimedOut)
|
||
}
|
||
if op.NextCommand != "dws wiki task-result --task task_abc" {
|
||
t.Fatalf("next_command = %q, want executable recovery command (§2.2): %s", op.NextCommand, stdout.String())
|
||
}
|
||
if decoded.Data == nil {
|
||
t.Fatalf("pending envelope data payload lost: %s", stdout.String())
|
||
}
|
||
// exit 0 语义标注(B32 与 B33 的耦合点):异步受理 = ok:true → exit 0。
|
||
if code := ExitCodeForEnvelope(env); code != 0 {
|
||
t.Fatalf("pending envelope exit code = %d, want 0 (§1/§4/I2)", code)
|
||
}
|
||
// 非 json format 下 pending 同样走 stdout(数据通道),stderr 保持零字节。
|
||
var outTable, errTable bytes.Buffer
|
||
if err := NewEmitter(&outTable, &errTable, FormatTable, "", "").Emit(env); err != nil {
|
||
t.Fatalf("Emit pending under -f table: %v", err)
|
||
}
|
||
if errTable.Len() != 0 {
|
||
t.Fatalf("pending under -f table must keep stderr empty, got %d bytes", errTable.Len())
|
||
}
|
||
if err := env.Validate(); err != nil {
|
||
t.Fatalf("pending envelope must pass Validate(): %v", err)
|
||
}
|
||
}
|
||
|
||
// --- B33:ExitCodeForEnvelope 的 outcome→exit code 映射 ---
|
||
|
||
func TestExitCodeForEnvelopeOutcomeCategoryMapping(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
env *Envelope
|
||
want int
|
||
}{
|
||
{"success → 0", NewSuccessEnvelope(map[string]any{"id": "x"}), 0},
|
||
{"pending → 0", NewPendingEnvelope(&OperationInfo{ID: "t_1", State: "processing"}), 0},
|
||
{"partial_failure → 7", newPartialEnvelopeFixture(), 7},
|
||
{"failure api → 1", NewFailureEnvelope(&ErrorInfo{Type: "api", Code: 90018}), 1},
|
||
{"failure auth → 2", NewFailureEnvelope(&ErrorInfo{Type: "auth"}), 2},
|
||
{"failure validation → 3", NewFailureEnvelope(&ErrorInfo{Type: "validation", Param: "--name"}), 3},
|
||
{"failure discovery → 6", NewFailureEnvelope(&ErrorInfo{Type: "discovery"}), 6},
|
||
{"failure internal → 5", NewFailureEnvelope(&ErrorInfo{Type: "internal"}), 5},
|
||
{"failure unknown type → 5 (internal fallback)", NewFailureEnvelope(&ErrorInfo{Type: "mystery"}), 5},
|
||
// confirmation 子类 → 3(AC-13;subtype 优先于 type,挂在任何 type 下恒 3)
|
||
{"failure confirmation subtype on api → 3",
|
||
NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "confirmation_required",
|
||
Actions: []string{"dws dev app delete --app-id a1 --yes"}}), 3},
|
||
{"failure confirmation subtype on validation → 3",
|
||
NewFailureEnvelope(&ErrorInfo{Type: "validation", Subtype: "confirmation_required"}), 3},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
if got := ExitCodeForEnvelope(c.env); got != c.want {
|
||
t.Fatalf("ExitCodeForEnvelope = %d, want %d", got, c.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestExitCodeForEnvelopeNilEnvelopeIsInternal(t *testing.T) {
|
||
// nil 信封与 WriteEnvelopeTo 兜底的 internal 类 failure 信封同源(B208)。
|
||
if got := ExitCodeForEnvelope(nil); got != 5 {
|
||
t.Fatalf("ExitCodeForEnvelope(nil) = %d, want 5 (internal-class fallback)", got)
|
||
}
|
||
}
|
||
|
||
func TestExitCodeForEnvelopeIgnoresTamperedOKAndInvalidOutcome(t *testing.T) {
|
||
// 只看 outcome(I1 同口径):被篡改 OK 字段的失败信封仍非零。
|
||
tampered := &Envelope{OK: true, Outcome: OutcomeFailure, Error: &ErrorInfo{Type: "api"}}
|
||
if got := ExitCodeForEnvelope(tampered); got == 0 {
|
||
t.Fatal("ExitCodeForEnvelope must ignore tampered OK field (outcome=failure is never exit 0)")
|
||
}
|
||
// 非法 outcome 属框架缺陷 → internal(5),不得落入 0。
|
||
invalid := &Envelope{OK: true, Outcome: "succeeded"}
|
||
if got := ExitCodeForEnvelope(invalid); got != 5 {
|
||
t.Fatalf("ExitCodeForEnvelope(invalid outcome) = %d, want 5", got)
|
||
}
|
||
}
|
||
|
||
// --- B34:I2 穷举断言——exit 0 ⇔ ok(对 ExitCodeForEnvelope 全组合)---
|
||
|
||
func TestInvariantI2ExhaustiveExitZeroIffOK(t *testing.T) {
|
||
// I2:exit code == 0 ⇔ ok == true。穷举全部规范 outcome(合法装配形态,
|
||
// 满足 I3)+ nil 信封 + 非法 outcome + 全部 failure 类别细分。
|
||
legal := []struct {
|
||
outcome Outcome
|
||
env *Envelope
|
||
}{
|
||
{OutcomeSuccess, NewSuccessEnvelope(map[string]any{"id": "x"})},
|
||
{OutcomePending, NewPendingEnvelope(&OperationInfo{ID: "t_1", State: "processing"})},
|
||
{OutcomePartialFailure, newPartialEnvelopeFixture()},
|
||
{OutcomeFailure, NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "rate_limit"})},
|
||
}
|
||
if len(legal) != 4 {
|
||
t.Fatalf("I2 exhaustive table must cover exactly the four canonical outcomes, got %d", len(legal))
|
||
}
|
||
for _, c := range legal {
|
||
wantOK := c.outcome == OutcomeSuccess || c.outcome == OutcomePending // I1
|
||
code := ExitCodeForEnvelope(c.env)
|
||
if (code == 0) != wantOK {
|
||
t.Fatalf("outcome=%q: exit=%d but ok=%v (I2: exit 0 iff ok)", c.outcome, code, wantOK)
|
||
}
|
||
if (c.env.IsOK() != wantOK) || ((code == 0) != c.env.IsOK()) {
|
||
t.Fatalf("outcome=%q: IsOK()=%v exit=%d disagree (I1/I2 cross-check)", c.outcome, c.env.IsOK(), code)
|
||
}
|
||
}
|
||
|
||
// failure 全类别细分(api/auth/validation/discovery/internal/unknown/无明细)
|
||
// 无一 exit 0:ok=false 分支的反向穷举。
|
||
failureVariants := []*Envelope{
|
||
NewFailureEnvelope(&ErrorInfo{Type: "api"}),
|
||
NewFailureEnvelope(&ErrorInfo{Type: "auth"}),
|
||
NewFailureEnvelope(&ErrorInfo{Type: "validation"}),
|
||
NewFailureEnvelope(&ErrorInfo{Type: "discovery"}),
|
||
NewFailureEnvelope(&ErrorInfo{Type: "internal"}),
|
||
NewFailureEnvelope(&ErrorInfo{Type: "mystery"}),
|
||
NewFailureEnvelope(&ErrorInfo{Subtype: "confirmation_required"}), // type 缺席 + confirmation 子类:仍 3,非 0
|
||
}
|
||
for i, env := range failureVariants {
|
||
if got := ExitCodeForEnvelope(env); got == 0 {
|
||
t.Fatalf("failure variant[%d] must never be exit 0 (I2)", i)
|
||
}
|
||
}
|
||
|
||
// 边界组合:nil 信封与非法 outcome 同样非零(ok=false 语义)。
|
||
if ExitCodeForEnvelope(nil) == 0 {
|
||
t.Fatal("nil envelope must never be exit 0 (I2)")
|
||
}
|
||
if ExitCodeForEnvelope(&Envelope{OK: true, Outcome: "succeeded"}) == 0 {
|
||
t.Fatal("invalid outcome must never be exit 0 (I2)")
|
||
}
|
||
// partial_failure 专用码精确断言(非零且恰为 7,§4)。
|
||
if got := ExitCodeForEnvelope(newPartialEnvelopeFixture()); got != 7 {
|
||
t.Fatalf("partial_failure exit code = %d, want dedicated 7 (§4)", got)
|
||
}
|
||
}
|
||
|
||
// --- B32/B33 联动:Emitter 流路由与 ExitCodeForEnvelope 一致 ---
|
||
|
||
func TestEmitterStreamRoutingConsistentWithExitCode(t *testing.T) {
|
||
// Emit 的流选择与退出码语义一一对应(exit 0 语义标注的行为端):
|
||
// - ok:true(success/pending)→ stdout + exit 0(I2);
|
||
// - partial_failure(ok:false)→ stdout(§2.3 stderr 零输出)+ exit 7;
|
||
// - failure(含 nil 降级)→ stderr + exit 非零,stdout 严格零字节。
|
||
cases := []struct {
|
||
name string
|
||
env *Envelope
|
||
wantStdout bool // 信封数据通道是否落 stdout
|
||
wantExitZero bool // I2:exit 0 ⇔ ok
|
||
}{
|
||
{"success", NewSuccessEnvelope(map[string]any{"id": "x"}), true, true},
|
||
{"pending", NewPendingEnvelope(&OperationInfo{ID: "t_1", State: "processing"}), true, true},
|
||
{"partial_failure", newPartialEnvelopeFixture(), true, false},
|
||
{"failure", NewFailureEnvelope(&ErrorInfo{Type: "api"}), false, false},
|
||
{"nil→fallback failure", nil, false, false},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||
if err := em.Emit(c.env); err != nil {
|
||
t.Fatalf("Emit: %v", err)
|
||
}
|
||
code := ExitCodeForEnvelope(c.env)
|
||
if (code == 0) != c.wantExitZero {
|
||
t.Fatalf("exit code = %d, wantExitZero=%v (I2)", code, c.wantExitZero)
|
||
}
|
||
if c.wantStdout {
|
||
if stdout.Len() == 0 {
|
||
t.Fatal("stdout-routed envelope must carry payload on stdout")
|
||
}
|
||
if stderr.Len() != 0 {
|
||
t.Fatalf("stdout-routed envelope must keep stderr empty (§2.3/§5.1), got %d bytes", stderr.Len())
|
||
}
|
||
} else {
|
||
if stdout.Len() != 0 {
|
||
t.Fatalf("failure must leave stdout empty, got %d bytes", stdout.Len())
|
||
}
|
||
if stderr.Len() == 0 {
|
||
t.Fatal("failure envelope must be emitted on stderr")
|
||
}
|
||
}
|
||
// partial_failure 专用码精确断言(§4):stdout 通道但非零。
|
||
if c.env != nil && c.env.Outcome == OutcomePartialFailure && code != 7 {
|
||
t.Fatalf("partial_failure exit code = %d, want dedicated 7", code)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// --- B208:nil 兜底信封 I3 合法化(轮6裁决⑨)---
|
||
|
||
func TestNilFallbackEnvelopeCarriesInternalErrorInfo(t *testing.T) {
|
||
// 兜底信封必须携带 internal 类 ErrorInfo(框架内部缺陷),
|
||
// 自身通过 Validate()——不得再是被校验器拒绝的形态。
|
||
env := nilFallbackEnvelope()
|
||
if env.Error == nil {
|
||
t.Fatal("nil fallback envelope must carry ErrorInfo (I3: error 非空 ⇔ failure)")
|
||
}
|
||
if env.Error.Type != "internal" {
|
||
t.Fatalf("fallback error.type = %q, want internal", env.Error.Type)
|
||
}
|
||
if env.Error.Message != "nil envelope: framework fallback" {
|
||
t.Fatalf("fallback error.message = %q, want %q", env.Error.Message, "nil envelope: framework fallback")
|
||
}
|
||
if env.OK || env.Outcome != OutcomeFailure {
|
||
t.Fatalf("fallback envelope = ok:%v outcome:%q, want ok:false outcome:failure", env.OK, env.Outcome)
|
||
}
|
||
if err := env.Validate(); err != nil {
|
||
t.Fatalf("nil fallback envelope must pass Validate(), got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestWriteEnvelopeToNilEnvelopeEmitsLegalFailure(t *testing.T) {
|
||
// WriteEnvelopeTo 的 nil 兜底:不 panic、必产合法 failure,
|
||
// error 明细在 wire 上可解码且类型/消息正确。
|
||
var buf bytes.Buffer
|
||
if err := WriteEnvelopeTo(&buf, nil, FormatJSON, "", ""); err != nil {
|
||
t.Fatalf("WriteEnvelopeTo(nil) must not fail: %v", err)
|
||
}
|
||
var decoded struct {
|
||
OK bool `json:"ok"`
|
||
Outcome string `json:"outcome"`
|
||
Error *ErrorInfo `json:"error"`
|
||
}
|
||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||
t.Fatalf("fallback envelope is not valid JSON: %v\n%s", err, buf.String())
|
||
}
|
||
if decoded.OK || decoded.Outcome != "failure" {
|
||
t.Fatalf("fallback = ok:%v outcome:%q, want ok:false outcome:failure: %s", decoded.OK, decoded.Outcome, buf.String())
|
||
}
|
||
if decoded.Error == nil {
|
||
t.Fatalf("fallback envelope must carry error detail on wire: %s", buf.String())
|
||
}
|
||
if decoded.Error.Type != "internal" || decoded.Error.Message != "nil envelope: framework fallback" {
|
||
t.Fatalf("fallback error = %+v, want internal / nil envelope: framework fallback", decoded.Error)
|
||
}
|
||
// 兜底信封与 ExitCodeForEnvelope 同源:internal → 5(非零,I2)。
|
||
if code := ExitCodeForEnvelope(nil); code == 0 {
|
||
t.Fatal("nil fallback must map to non-zero exit code")
|
||
}
|
||
}
|