diff --git a/src/terminal/links.test.ts b/src/terminal/links.test.ts new file mode 100644 index 00000000000..7c070f7200d --- /dev/null +++ b/src/terminal/links.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { formatDocsLink } from "./links.js"; + +describe("formatDocsLink", () => { + it("prepends the docs root when given a relative path", () => { + const out = formatDocsLink("/channels/telegram", "telegram"); + expect(out).toContain("https://docs.openclaw.ai/channels/telegram"); + }); + + it("preserves an absolute http url", () => { + const out = formatDocsLink("https://example.com/page", "page"); + expect(out).toContain("https://example.com/page"); + }); + + it("treats whitespace-only path like an empty path and falls back to docs root", () => { + const out = formatDocsLink(" ", "root"); + expect(out).toContain("https://docs.openclaw.ai"); + }); + + it("does not crash when path is undefined (regression: #67076, #67074)", () => { + expect(() => + formatDocsLink(undefined as unknown as string, "label"), + ).not.toThrow(); + const out = formatDocsLink(undefined as unknown as string, "label"); + expect(out).toContain("https://docs.openclaw.ai"); + }); + + it("does not crash when path is null", () => { + expect(() => formatDocsLink(null as unknown as string)).not.toThrow(); + }); +}); diff --git a/src/terminal/links.ts b/src/terminal/links.ts index f65ad959019..98787f4f497 100644 --- a/src/terminal/links.ts +++ b/src/terminal/links.ts @@ -5,15 +5,21 @@ function resolveDocsRoot(): string { } export function formatDocsLink( - path: string, + path: string | undefined | null, label?: string, opts?: { fallback?: string; force?: boolean }, ): string { - const trimmed = path.trim(); const docsRoot = resolveDocsRoot(); - const url = trimmed.startsWith("http") - ? trimmed - : `${docsRoot}${trimmed.startsWith("/") ? trimmed : `/${trimmed}`}`; + const trimmed = typeof path === "string" ? path.trim() : ""; + // When a caller has no docsPath, link to the docs root rather than crashing + // the onboarding/channel-selection flows that pass meta.docsPath through + // here unguarded. The typed contract says docsPath is required, but a + // handful of channel plugins and catalog rows leave it unset at runtime. + const url = trimmed + ? trimmed.startsWith("http") + ? trimmed + : `${docsRoot}${trimmed.startsWith("/") ? trimmed : `/${trimmed}`}` + : docsRoot; return formatTerminalLink(label ?? url, url, { fallback: opts?.fallback ?? url, force: opts?.force,