Files
dingtalk-workspace-cli/internal/output/emitter_phase_b_test.go

737 lines
28 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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")
}
}