package exec import ( "encoding/json" "os" "os/exec" "path/filepath" "testing" ) // settleParityCase 是 PI settle-policy.mjs 与 Go AssessStepFailure 的共享 // 行为夹具(U9b Batch1-②):同一场景两侧必须得到一致的成功/可重试判定。 // events 供 node 事件流分类(applyAgentEvent→classifyRunOutcome); // signals 供 Go 结果信号分类(AssessStepFailure);expect 为共同期望。 type settleParityCase struct { Name string `json:"name"` Events []map[string]any `json:"events"` Signals struct { Success bool `json:"success"` ErrorReason string `json:"error_reason"` Retryable bool `json:"retryable"` TargetMissing bool `json:"target_missing"` } `json:"signals"` Expect struct { OK bool `json:"ok"` Retryable bool `json:"retryable"` } `json:"expect"` // GoOnly 为 true 的场景(如 target-exists)没有 node 对应信号,只断言 Go。 GoOnly bool `json:"go_only"` } func nodeParityScript(casesAbsPath string) string { return ` import { newSettleState, applyAgentEvent, classifyRunOutcome } from './settle-policy.mjs'; import fs from 'fs'; const cases = JSON.parse(fs.readFileSync(` + "`" + casesAbsPath + "`" + `, 'utf8')); const out = []; for (const c of cases) { if (c.go_only) { out.push({name: c.name, go_only: true}); continue; } const st = newSettleState(); for (const ev of c.events || []) { applyAgentEvent(st, ev); } const r = classifyRunOutcome({ succeeded: st.succeeded, retryFailed: st.retryFailed, providerTransient: st.providerTransient, lastErr: st.lastErr, missing: c.signals && c.signals.target_missing ? ["output.md"] : [], }); out.push({name: c.name, ok: r.ok, retryable: r.retryable}); } process.stdout.write(JSON.stringify(out)); ` } func TestSettlePolicyParityWithNode(t *testing.T) { if _, err := exec.LookPath("node"); err != nil { t.Skip("node not available") } casesPath, err := filepath.Abs(filepath.Join("testdata", "settle-parity-cases.json")) if err != nil { t.Fatal(err) } data, err := os.ReadFile(casesPath) if err != nil { t.Fatalf("read fixture: %v", err) } var cases []settleParityCase if err := json.Unmarshal(data, &cases); err != nil { t.Fatalf("parse fixture: %v", err) } if len(cases) < 8 { t.Fatalf("expect a meaningful scenario set, got %d", len(cases)) } // node 侧:cwd 置于 settle-policy.mjs 所在 testdata,脚本相对导入(同 digest 交叉验证模式)。 tdDir, err := filepath.Abs(filepath.Join("..", "adapters", "pitransport", "testdata")) if err != nil { t.Fatal(err) } nodeCmd := exec.Command("node", "--input-type=module", "-e", nodeParityScript(casesPath)) nodeCmd.Dir = tdDir // './settle-policy.mjs' 相对 cwd 解析 out, err := nodeCmd.Output() if err != nil { t.Fatalf("node parity runner: %v", err) } var nodeResults []struct { Name string `json:"name"` OK *bool `json:"ok"` Retryable *bool `json:"retryable"` GoOnly bool `json:"go_only"` } if err := json.Unmarshal(out, &nodeResults); err != nil { t.Fatalf("parse node output: %v: %s", err, out) } nodeBy := map[string]int{} for i, r := range nodeResults { nodeBy[r.Name] = i } for _, c := range cases { c := c t.Run(c.Name, func(t *testing.T) { sig := StepFailureSignals{ Result: &Result{ Success: c.Signals.Success, ErrorReason: c.Signals.ErrorReason, Retryable: c.Signals.Retryable, }, TargetMissing: c.Signals.TargetMissing, } got := AssessStepFailure(sig) // 契约:Go 是终裁者——成功不得触发重试;mjs 判可重试的必须保持 // Transient;mjs 判永久的不得被翻成 Transient。 switch { case c.Expect.OK: if got.Class == FailureTransient { t.Fatalf("Go flipped success to transient: %s", got.Reason) } case c.Expect.Retryable: if got.Class != FailureTransient { t.Fatalf("Go must stay transient: class=%v reason=%s", got.Class, got.Reason) } default: if got.Class == FailureTransient { t.Fatalf("Go flipped permanent to transient: %s", got.Reason) } } if c.GoOnly { t.Log("Go-only signal; node side n/a") return } idx, ok := nodeBy[c.Name] if !ok || nodeResults[idx].OK == nil { t.Fatalf("node result missing for %s", c.Name) } if *nodeResults[idx].OK != c.Expect.OK || *nodeResults[idx].Retryable != c.Expect.Retryable { t.Fatalf("node: ok=%v retryable=%v; want ok=%v retryable=%v", *nodeResults[idx].OK, *nodeResults[idx].Retryable, c.Expect.OK, c.Expect.Retryable) } }) } }