//go:build zcode_e2e // ZCode 后端的**真机**端到端判据。 // // 与 pi 的 e2e 同一形状,但驱动的是**随包分发的** driver.json + sidecar.mjs // 与**真实 ZCode CLI** —— 而不是我的临时 harness。要求真实改动工程文件, // 且不破坏原有代码。 // // 跑法(需要本机已能跑通 zcode): // // HA_ZCODE_CLI= \ // HA_ZCODE_DATA_DIR= \ // go test -tags zcode_e2e -run TestZcodeFullPipeline -v . package main import ( "bufio" "encoding/json" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) func zcodeE2EReady(t *testing.T) (nodeBin, cliPath, dataDir string) { t.Helper() cliPath = os.Getenv("HA_ZCODE_CLI") dataDir = os.Getenv("HA_ZCODE_DATA_DIR") if cliPath == "" || dataDir == "" { t.Skip("需要 HA_ZCODE_CLI 与 HA_ZCODE_DATA_DIR(真实 zcode 环境)") } if _, err := os.Stat(cliPath); err != nil { t.Skipf("HA_ZCODE_CLI 不存在: %v", err) } for _, c := range []string{"node", "nodejs"} { if p, err := exec.LookPath(c); err == nil { nodeBin = p break } } if nodeBin == "" { t.Skip("本机无 node") } return } // TestZcodeFullPipeline:登记 → 开线 → 准备目录 → 用**随包 driver**起 zcode // → 投递 → 断言真改了工程文件 → 收尾。 func TestZcodeFullPipeline(t *testing.T) { nodeBin, cliPath, dataDir := zcodeE2EReady(t) base := t.TempDir() origin := filepath.Join(base, "origin") gitQ(t, base, "init", "-q", "-b", "main", origin) if err := os.MkdirAll(filepath.Join(origin, "src"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(origin, "src/calc.py"), []byte("def add(a, b):\n return a + b\n"), 0o644); err != nil { t.Fatal(err) } gitQ(t, origin, "add", "-A") gitQ(t, origin, "commit", "-qm", "initial") p := newFlowPlugin(t) r1, _ := p.handleProjectAdd(map[string]interface{}{ "name": "zdemo", "remote_url": origin, "default_branch": "main", }) if m := r1.(map[string]interface{}); m["status"] != "ok" { t.Fatalf("登记项目失败: %v", m) } r2, _ := p.handleBranchOpen(map[string]interface{}{ "project": "zdemo", "branch": "feat/z", "work_desc": "加乘", }) m2 := r2.(map[string]interface{}) if m2["status"] != "ok" { t.Fatalf("开线失败: %v", m2) } branchID := m2["branch_id"].(string) workdir := m2["workdir"].(string) if err := p.prepareWorkdir(p.ledger.Projects["zdemo"], "feat/z", workdir); err != nil { t.Fatalf("目录准备失败: %v", err) } // ★ 用**随包分发**的 driver.json 解析描述(不是测试里手拼参数) rawRoot := filepath.Join(mustAbs(t, "."), "rawfile") pz := newTestPluginWithRawfiles(t, rawRoot) pz.path = filepath.Join(base, "commondev") // 用户目录空 → 强制走随包 drv, cliDir, err := pz.loadDriver("zcode") if err != nil { t.Fatalf("★ 随包 zcode 驱动解析失败: %v", err) } // cliPath 指向随包位置(不存在),本判据用 HA_ZCODE_CLI 覆盖成本机真实路径 script, err := pz.locateSidecarScript(drv, cliDir, "zcode") if err != nil { t.Fatalf("★ 随包 sidecar 脚本定位失败: %v", err) } t.Logf("随包解析:script=%s cliPath=%s", script, drv.CLIPath) ctx := driverCtx{ Workdir: workdir, SessionID: "br-" + branchID, SessionName: "br-" + branchID, DataDir: dataDir, PluginDir: filepath.Dir(rawRoot), RawfileDir: rawRoot, } argv := drv.buildArgs(ctx) argv = append(argv, "--cli", cliPath) cmd := exec.Command(nodeBin, append([]string{script}, argv...)...) cmd.Dir = workdir env := append(os.Environ(), drv.buildEnv(ctx)...) env = append(env, "ZCODE_DATA_BASE_DIR="+dataDir) cmd.Env = env stdin, err := cmd.StdinPipe() if err != nil { t.Fatal(err) } stdout, err := cmd.StdoutPipe() if err != nil { t.Fatal(err) } cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { t.Fatalf("启动 sidecar 失败: %v", err) } defer func() { _ = cmd.Process.Kill(); _, _ = cmd.Process.Wait() }() w := bufio.NewWriter(stdin) rd := bufio.NewReader(stdout) if ping := callSidecar(t, w, rd, 1, "ping", nil); ping.Error != nil { t.Fatalf("握手失败: %s", ping.Error.Message) } resp := callSidecar(t, w, rd, 2, "prompt", map[string]interface{}{ "session_id": "br-" + branchID, "text": "在 src/calc.py 里新增 multiply(a, b) 返回 a*b,保留 add。只回一句完成。", }) if resp.Error != nil { t.Fatalf("投递失败: %s", resp.Error.Message) } var pr map[string]interface{} if err := json.Unmarshal(resp.Result, &pr); err != nil { t.Fatal(err) } // ★ 异步投递(2026-10-17):prompt 立即回 "accepted",此时还没有真实 // sess_ id —— 它要等 zcode 真正建好会话。真实 id 与任务结果都随 // **完成通知**(id=0)推回。所以这里必须等通知,而不是看应答。 if st, _ := pr["status"].(string); st != "accepted" { t.Fatalf("投递应回 status=accepted(异步),实际 %q", st) } notif := waitPromptDone(t, rd) sid, _ := notif["session_id"].(string) if !strings.HasPrefix(sid, "sess_") { t.Fatalf("★ 完成通知未含真实 ZCode 会话 id: %q(通知=%v)", sid, notif) } if reply, _ := notif["reply"].(string); strings.TrimSpace(reply) == "" { t.Logf("提示:完成通知里 reply 为空(zcode 可能只回了工具动作)") } t.Logf("zcode 会话: %s(任务已完成)", sid) // ★ 关键断言:真的改了**这个工程**的文件,且没破坏原代码 b2, err := os.ReadFile(filepath.Join(workdir, "src", "calc.py")) if err != nil { t.Fatal(err) } if !strings.Contains(string(b2), "multiply") { t.Fatalf("★ zcode 没有在工程里加 multiply:\n%s", b2) } if !strings.Contains(string(b2), "def add") { t.Errorf("★ 原有代码被破坏:\n%s", b2) } } func mustAbs(t *testing.T, p string) string { t.Helper() a, err := filepath.Abs(p) if err != nil { t.Fatal(err) } return a } // TestZcodeOverSSH:**同一套随包 driver/script 走 SSH 远端**。 // // 用户的要求是"本地 + 通过 ssh 支持的远程 zcodecli"。传输层早就与后端正交 // (Runtime×AgentCLI),但当时**没有任何判据证明二者能组合**: // `pushSidecar` 只推脚本、`--cli` 也没法分别声明本地/远端路径。 // // 这条判据用 localhost 做真实 ssh 传输(真 ssh 客户端、BatchMode、scp), // 把随包的 driver.json + sidecar.mjs 推到远端目录,再在**远端**用真实 // ZCode CLI 跑一轮并要求它改动工程文件。 func TestZcodeOverSSH(t *testing.T) { if os.Getenv("HA_SSH_E2E") != "1" { t.Skip("设 HA_SSH_E2E=1 以运行(需要 sshd + localhost 免密)") } nodeBin, cliPath, dataDir := zcodeE2EReady(t) _ = nodeBin base := t.TempDir() origin := filepath.Join(base, "origin") gitQ(t, base, "init", "-q", "-b", "main", origin) if err := os.MkdirAll(filepath.Join(origin, "src"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(origin, "src/calc.py"), []byte("def add(a, b):\n return a + b\n"), 0o644); err != nil { t.Fatal(err) } gitQ(t, origin, "add", "-A") gitQ(t, origin, "commit", "-qm", "initial") p := newFlowPlugin(t) if r, _ := p.handleProjectAdd(map[string]interface{}{ "name": "sshz", "remote_url": origin, "default_branch": "main", }); r.(map[string]interface{})["status"] != "ok" { t.Fatalf("登记失败: %v", r) } r2, _ := p.handleBranchOpen(map[string]interface{}{ "project": "sshz", "branch": "feat/ssh", "work_desc": "远程", "runtime": "ssh", "ssh_host": "localhost", }) m2 := r2.(map[string]interface{}) if m2["status"] != "ok" { t.Fatalf("开 SSH 线失败: %v", m2) } branchID := m2["branch_id"].(string) // 远端目录:既是运行时根,也是脚本落地处 remoteRoot := filepath.Join(base, "remote-runtime") remoteWork := filepath.Join(base, "remote-work") if err := os.MkdirAll(remoteWork, 0o755); err != nil { t.Fatal(err) } // ★ 走**真实** loadDriver + locateSidecarScript(随包来源) rawRoot := filepath.Join(mustAbs(t, "."), "rawfile") pz := newTestPluginWithRawfiles(t, rawRoot) pz.path = filepath.Join(base, "commondev") drv, cliDir, err := pz.loadDriver("zcode") if err != nil { t.Fatalf("随包 zcode 驱动解析失败: %v", err) } target := sshTarget{Host: "localhost", Port: 0, NodePath: nodeBin} if err := pz.probeSSH(target); err != nil { t.Skipf("ssh 不可用: %v", err) } // pushSidecar 推的是 drv.Script —— 先把随包脚本放进 cwd 让它可以被推 stage := t.TempDir() localScript, err := pz.locateSidecarScript(drv, cliDir, "zcode") if err != nil { t.Fatalf("随包脚本定位失败: %v", err) } src, err := os.ReadFile(localScript) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(stage, drv.Script), src, 0o644); err != nil { t.Fatal(err) } if err := pz.pushSidecar(target, drv, stage, remoteRoot); err != nil { t.Fatalf("★ 推送随包脚本失败: %v", err) } // 远端脚本必须真的存在 if out, err := exec.Command("ssh", "-o", "BatchMode=yes", "localhost", "test -f "+remoteRoot+"/"+drv.Script+" && echo OK").Output(); err != nil || !strings.Contains(string(out), "OK") { t.Fatalf("★ 远端没有 sidecar 脚本: %v %s", err, out) } // ★ 在**远端**跑:node --workdir ... --cli <远端 cli> // 本机与 localhost 是同一文件系统,故 cliPath/dataDir 直接复用。 ctx := driverCtx{ Workdir: remoteWork, SessionID: "br-" + branchID, SessionName: "br-" + branchID, DataDir: dataDir, PluginDir: filepath.Dir(rawRoot), RawfileDir: rawRoot, } argv := drv.buildArgs(ctx) argv = append(argv, "--cli", cliPath) remoteCmd := "ZCODE_DATA_BASE_DIR=" + dataDir + " " + nodeBin + " " + remoteRoot + "/" + drv.Script + " " + shellJoin(argv) full := append(target.sshBaseArgs(), "localhost", remoteCmd) cmd := exec.Command("ssh", full...) cmd.Dir = remoteWork stdin, err := cmd.StdinPipe() if err != nil { t.Fatal(err) } stdout, err := cmd.StdoutPipe() if err != nil { t.Fatal(err) } cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { t.Fatalf("远端启动失败: %v", err) } defer func() { _ = cmd.Process.Kill(); _, _ = cmd.Process.Wait() }() w := bufio.NewWriter(stdin) rd := bufio.NewReader(stdout) if ping := callSidecar(t, w, rd, 1, "ping", nil); ping.Error != nil { t.Fatalf("远端握手失败: %s", ping.Error.Message) } resp := callSidecar(t, w, rd, 2, "prompt", map[string]interface{}{ "session_id": "br-" + branchID, "text": "在 src/calc.py 里新增 multiply(a, b) 返回 a*b,保留 add。只回一句完成。", }) if resp.Error != nil { t.Fatalf("远端投递失败: %s", resp.Error.Message) } // ★ 异步投递:必须等完成通知,否则读文件时远端任务还没开始跑 // (实测:不等的话 calc.py 根本还没被创建)。 notif := waitPromptDone(t, rd) if sid, _ := notif["session_id"].(string); !strings.HasPrefix(sid, "sess_") { t.Fatalf("远端完成通知未含真实会话 id: %q", sid) } b2, err := os.ReadFile(filepath.Join(remoteWork, "src", "calc.py")) if err != nil { t.Fatal(err) } if !strings.Contains(string(b2), "multiply") { t.Fatalf("★ 远端 zcode 没有改文件:\n%s", b2) } if !strings.Contains(string(b2), "def add") { t.Errorf("★ 远端原有代码被破坏:\n%s", b2) } } func shellJoin(args []string) string { parts := make([]string, 0, len(args)) for _, a := range args { parts = append(parts, shellQuote(a)) } return strings.Join(parts, " ") } // TestZcodeOverSSHWithPerHostEndpoints:**两台"机器"各配各的 CLI 路径**, // 走端点表而不是全局 settings —— 这是用户提的需求本体。 // // 用两个 host 别名指向同一个 sshd(`localhost` 与 `127.0.0.1`), // 但配置**不同的** CLI 路径与 runtime 目录,从而证明: // // · 每台机器的路径确实分别生效(不是被后配的覆盖) // · 发布到远端的 --cli 是该机器自己那条 // // 为什么用别名而不是真两台机器:判据要能在 CI/本机跑。别名走的是 // **同一条真实 ssh 代码路径**(解析 host → 端点表 → ssh → scp → 远端 node), // 与真两台机器的差别只在网络延迟,不足以掩盖端点选错这类缺陷。 func TestZcodeOverSSHWithPerHostEndpoints(t *testing.T) { if os.Getenv("HA_SSH_E2E") != "1" { t.Skip("设 HA_SSH_E2E=1 以运行(需要 sshd + localhost 免密)") } nodeBin, cliPath, dataDir := zcodeE2EReady(t) base := t.TempDir() // 两个"机器"各自的 runtime 目录(互不相干) rtA := filepath.Join(base, "rtA") rtB := filepath.Join(base, "rtB") workA := filepath.Join(base, "workA") workB := filepath.Join(base, "workB") for _, d := range []string{rtA, rtB, workA, workB} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } // 机器 B 上放一个**不同的** CLI 入口(软链到真 CLI,路径不同即为不同) cliB := filepath.Join(base, "zcode-on-B.cjs") if err := os.Symlink(cliPath, cliB); err != nil { t.Fatal(err) } // ① 端点表:两台机器各自的路径 p := newTestPluginForShell(t) if err := p.loadEndpoints(); err != nil { t.Fatal(err) } // pushSidecar 从 <插件数据>/runtime//