143 lines
4.5 KiB
Go
143 lines
4.5 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|
||
}
|