

























@@ -65,6 +65,10 @@ vi.mock("../logging/log-tail.js", () => ({
6565) => readConfiguredLogTail(...args),
6666}));
676768+vi.mock("../infra/backoff.js", () => ({
69+computeBackoff: vi.fn().mockReturnValue(0),
70+}));
71+6872vi.mock("./gateway-rpc.js", async () => {
6973const actual = await vi.importActual<typeof import("./gateway-rpc.js")>("./gateway-rpc.js");
7074return {
@@ -271,6 +275,147 @@ describe("logs cli", () => {
271275expect(stderrWrites.join("")).toContain("Local Gateway RPC unavailable");
272276});
273277278+describe("--follow retry behavior", () => {
279+it("uses local fallback (not retry warning) for loopback close errors in --follow mode", async () => {
280+// Loopback close errors are absorbed by shouldUseLocalLogsFallback inside fetchLogs —
281+// they never reach the retry path, so no "gateway disconnected" warning is emitted.
282+callGatewayFromCli.mockRejectedValueOnce(
283+new GatewayTransportError({
284+kind: "closed",
285+code: 1006,
286+reason: "abnormal closure",
287+connectionDetails: {
288+url: "ws://127.0.0.1:18789",
289+urlSource: "local loopback",
290+message: "",
291+},
292+message: "gateway closed (1006 abnormal closure): abnormal closure",
293+}),
294+);
295+readConfiguredLogTail.mockResolvedValueOnce({
296+file: "/tmp/openclaw.log",
297+cursor: 5,
298+lines: ["local fallback line"],
299+truncated: false,
300+reset: false,
301+});
302+303+const stderrWrites = captureStderrWrites();
304+const stdoutWrites = captureStdoutWrites();
305+const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
306+307+await runLogsCli(["logs", "--follow"]);
308+309+expect(stderrWrites.join("")).toContain("Local Gateway RPC unavailable");
310+expect(stderrWrites.join("")).not.toContain("gateway disconnected");
311+expect(stdoutWrites.join("")).toContain("local fallback line");
312+expect(exitSpy).toHaveBeenCalledWith(1);
313+});
314+315+it("exits after exhausting max retries in --follow mode with explicit URL", async () => {
316+// Explicit --url bypasses shouldUseLocalLogsFallback so close errors reach the retry path.
317+// initial attempt + 8 retries = 9 total calls before fatal exit.
318+const closeError = new GatewayTransportError({
319+kind: "closed",
320+code: 1006,
321+reason: "abnormal closure",
322+connectionDetails: {
323+url: "ws://127.0.0.1:18789",
324+urlSource: "cli",
325+message: "",
326+},
327+message: "gateway closed (1006 abnormal closure): abnormal closure",
328+});
329+for (let i = 0; i <= 8; i += 1) {
330+callGatewayFromCli.mockRejectedValueOnce(closeError);
331+}
332+333+const stderrWrites = captureStderrWrites();
334+const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
335+336+await runLogsCli(["logs", "--follow", "--url", "ws://127.0.0.1:18789"]);
337+338+expect((stderrWrites.join("").match(/gateway disconnected/g) ?? []).length).toBe(8);
339+expect(stderrWrites.join("")).toContain("Gateway not reachable");
340+expect(exitSpy).toHaveBeenCalledWith(1);
341+});
342+343+it("retries on transient close errors in --follow mode with explicit URL (no local fallback)", async () => {
344+callGatewayFromCli
345+.mockRejectedValueOnce(
346+new GatewayTransportError({
347+kind: "closed",
348+code: 1006,
349+reason: "abnormal closure",
350+connectionDetails: {
351+url: "ws://remote.example.com:18789",
352+urlSource: "cli",
353+message: "",
354+},
355+message: "gateway closed (1006 abnormal closure): abnormal closure",
356+}),
357+)
358+.mockResolvedValueOnce({
359+file: "/tmp/openclaw.log",
360+cursor: 10,
361+lines: ["line from remote"],
362+});
363+364+const stderrWrites = captureStderrWrites();
365+const stdoutWrites = captureStdoutWrites();
366+const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
367+368+await runLogsCli(["logs", "--follow", "--url", "ws://remote.example.com:18789"]);
369+370+expect(readConfiguredLogTail).not.toHaveBeenCalled();
371+expect(stderrWrites.join("")).toContain("gateway disconnected");
372+expect(stdoutWrites.join("")).toContain("line from remote");
373+expect(exitSpy).toHaveBeenCalledWith(1);
374+});
375+376+it("exits immediately on pairing-required close errors in --follow mode with explicit URL", async () => {
377+callGatewayFromCli.mockRejectedValueOnce(
378+new GatewayTransportError({
379+kind: "closed",
380+code: 1008,
381+reason: "pairing required",
382+connectionDetails: { url: "ws://127.0.0.1:18789", urlSource: "cli", message: "" },
383+message: "gateway closed (1008 policy violation): pairing required",
384+}),
385+);
386+387+const stderrWrites = captureStderrWrites();
388+const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
389+390+await runLogsCli(["logs", "--follow", "--url", "ws://127.0.0.1:18789"]);
391+392+expect(stderrWrites.join("")).not.toContain("gateway disconnected");
393+expect(stderrWrites.join("")).toContain("Gateway not reachable");
394+expect(exitSpy).toHaveBeenCalledWith(1);
395+});
396+397+it("exits immediately on app-defined auth errors (4xxx) in --follow mode with explicit URL", async () => {
398+callGatewayFromCli.mockRejectedValueOnce(
399+new GatewayTransportError({
400+kind: "closed",
401+code: 4001,
402+reason: "unauthorized",
403+connectionDetails: { url: "ws://127.0.0.1:18789", urlSource: "cli", message: "" },
404+message: "gateway closed (4001 unauthorized): unauthorized",
405+}),
406+);
407+408+const stderrWrites = captureStderrWrites();
409+const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
410+411+await runLogsCli(["logs", "--follow", "--url", "ws://127.0.0.1:18789"]);
412+413+expect(stderrWrites.join("")).not.toContain("gateway disconnected");
414+expect(stderrWrites.join("")).toContain("Gateway not reachable");
415+expect(exitSpy).toHaveBeenCalledWith(1);
416+});
417+});
418+274419it("does not use local fallback for explicit Gateway URLs", async () => {
275420callGatewayFromCli.mockRejectedValueOnce(
276421new GatewayTransportError({
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。