diff --git a/src/shared/entry-metadata.test.ts b/src/shared/entry-metadata.test.ts new file mode 100644 index 00000000000..64afb728a14 --- /dev/null +++ b/src/shared/entry-metadata.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { resolveEmojiAndHomepage } from "./entry-metadata.js"; + +describe("shared/entry-metadata", () => { + it("prefers metadata emoji and homepage when present", () => { + expect( + resolveEmojiAndHomepage({ + metadata: { emoji: "🦀", homepage: " https://openclaw.ai " }, + frontmatter: { emoji: "🙂", homepage: "https://example.com" }, + }), + ).toEqual({ + emoji: "🦀", + homepage: "https://openclaw.ai", + }); + }); + + it("falls back through frontmatter homepage aliases and drops blanks", () => { + expect( + resolveEmojiAndHomepage({ + frontmatter: { emoji: "🙂", website: " https://docs.openclaw.ai " }, + }), + ).toEqual({ + emoji: "🙂", + homepage: "https://docs.openclaw.ai", + }); + expect( + resolveEmojiAndHomepage({ + metadata: { homepage: " " }, + frontmatter: { url: " " }, + }), + ).toEqual({}); + }); +}); diff --git a/src/shared/model-param-b.test.ts b/src/shared/model-param-b.test.ts new file mode 100644 index 00000000000..21c3dce79c8 --- /dev/null +++ b/src/shared/model-param-b.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { inferParamBFromIdOrName } from "./model-param-b.js"; + +describe("shared/model-param-b", () => { + it("extracts the largest valid b-sized parameter token", () => { + expect(inferParamBFromIdOrName("llama-8b mixtral-22b")).toBe(22); + expect(inferParamBFromIdOrName("Qwen 0.5B Instruct")).toBe(0.5); + }); + + it("ignores malformed, zero, and non-delimited matches", () => { + expect(inferParamBFromIdOrName("abc70beta 0b x70b2")).toBeNull(); + expect(inferParamBFromIdOrName("model 0b")).toBeNull(); + }); +}); diff --git a/src/shared/node-resolve.test.ts b/src/shared/node-resolve.test.ts new file mode 100644 index 00000000000..4af0c5a8a9b --- /dev/null +++ b/src/shared/node-resolve.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { resolveNodeFromNodeList, resolveNodeIdFromNodeList } from "./node-resolve.js"; + +describe("shared/node-resolve", () => { + const nodes = [ + { nodeId: "mac-123", displayName: "Mac Studio", connected: true }, + { nodeId: "pi-456", displayName: "Raspberry Pi", connected: false }, + ]; + + it("resolves node ids through candidate matching", () => { + expect(resolveNodeIdFromNodeList(nodes, "Mac Studio")).toBe("mac-123"); + }); + + it("supports optional default-node selection when query is blank", () => { + expect( + resolveNodeIdFromNodeList(nodes, " ", { + allowDefault: true, + pickDefaultNode: (entries) => entries.find((entry) => entry.connected) ?? null, + }), + ).toBe("mac-123"); + }); + + it("still throws when default selection is disabled or returns null", () => { + expect(() => resolveNodeIdFromNodeList(nodes, " ")).toThrow(/node required/); + expect(() => + resolveNodeIdFromNodeList(nodes, "", { + allowDefault: true, + pickDefaultNode: () => null, + }), + ).toThrow(/node required/); + }); + + it("returns the full node object and falls back to a synthetic entry when needed", () => { + expect(resolveNodeFromNodeList(nodes, "pi-456")).toEqual(nodes[1]); + expect( + resolveNodeFromNodeList([], "", { + allowDefault: true, + pickDefaultNode: () => ({ nodeId: "synthetic-1" }), + }), + ).toEqual({ nodeId: "synthetic-1" }); + }); +}); diff --git a/src/shared/tailscale-status.test.ts b/src/shared/tailscale-status.test.ts new file mode 100644 index 00000000000..5826e4b00b3 --- /dev/null +++ b/src/shared/tailscale-status.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveTailnetHostWithRunner } from "./tailscale-status.js"; + +describe("shared/tailscale-status", () => { + it("returns null when no runner is provided", async () => { + await expect(resolveTailnetHostWithRunner()).resolves.toBeNull(); + }); + + it("prefers DNS names and trims trailing dots from status json", async () => { + const run = vi.fn().mockResolvedValue({ + code: 0, + stdout: 'noise\n{"Self":{"DNSName":"mac.tail123.ts.net.","TailscaleIPs":["100.64.0.8"]}}', + }); + + await expect(resolveTailnetHostWithRunner(run)).resolves.toBe("mac.tail123.ts.net"); + expect(run).toHaveBeenCalledWith(["tailscale", "status", "--json"], { timeoutMs: 5000 }); + }); + + it("falls back across command candidates and then to the first tailscale ip", async () => { + const run = vi.fn().mockRejectedValueOnce(new Error("missing binary")).mockResolvedValueOnce({ + code: 0, + stdout: '{"Self":{"TailscaleIPs":["100.64.0.9","fd7a::1"]}}', + }); + + await expect(resolveTailnetHostWithRunner(run)).resolves.toBe("100.64.0.9"); + expect(run).toHaveBeenNthCalledWith( + 2, + ["/Applications/Tailscale.app/Contents/MacOS/Tailscale", "status", "--json"], + { + timeoutMs: 5000, + }, + ); + }); + + it("returns null for non-zero exits, blank output, or invalid json", async () => { + const run = vi + .fn() + .mockResolvedValueOnce({ code: 1, stdout: "boom" }) + .mockResolvedValueOnce({ code: 0, stdout: " " }); + + await expect(resolveTailnetHostWithRunner(run)).resolves.toBeNull(); + + const invalid = vi.fn().mockResolvedValue({ + code: 0, + stdout: "not-json", + }); + await expect(resolveTailnetHostWithRunner(invalid)).resolves.toBeNull(); + }); +}); diff --git a/src/shared/usage-aggregates.test.ts b/src/shared/usage-aggregates.test.ts new file mode 100644 index 00000000000..e5ba960ad95 --- /dev/null +++ b/src/shared/usage-aggregates.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + buildUsageAggregateTail, + mergeUsageDailyLatency, + mergeUsageLatency, +} from "./usage-aggregates.js"; + +describe("shared/usage-aggregates", () => { + it("merges latency totals and ignores empty inputs", () => { + const totals = { + count: 1, + sum: 100, + min: 100, + max: 100, + p95Max: 100, + }; + + mergeUsageLatency(totals, undefined); + mergeUsageLatency(totals, { + count: 2, + avgMs: 50, + minMs: 20, + maxMs: 90, + p95Ms: 80, + }); + + expect(totals).toEqual({ + count: 3, + sum: 200, + min: 20, + max: 100, + p95Max: 100, + }); + }); + + it("merges daily latency by date and computes aggregate tail sorting", () => { + const dailyLatencyMap = new Map< + string, + { + date: string; + count: number; + sum: number; + min: number; + max: number; + p95Max: number; + } + >(); + + mergeUsageDailyLatency(dailyLatencyMap, [ + { date: "2026-03-12", count: 2, avgMs: 50, minMs: 20, maxMs: 90, p95Ms: 80 }, + { date: "2026-03-12", count: 1, avgMs: 120, minMs: 120, maxMs: 120, p95Ms: 120 }, + { date: "2026-03-11", count: 1, avgMs: 30, minMs: 30, maxMs: 30, p95Ms: 30 }, + ]); + + const tail = buildUsageAggregateTail({ + byChannelMap: new Map([ + ["discord", { totalCost: 4 }], + ["telegram", { totalCost: 8 }], + ]), + latencyTotals: { + count: 3, + sum: 200, + min: 20, + max: 120, + p95Max: 120, + }, + dailyLatencyMap, + modelDailyMap: new Map([ + ["b", { date: "2026-03-12", cost: 1 }], + ["a", { date: "2026-03-12", cost: 2 }], + ["c", { date: "2026-03-11", cost: 9 }], + ]), + dailyMap: new Map([ + ["b", { date: "2026-03-12" }], + ["a", { date: "2026-03-11" }], + ]), + }); + + expect(tail.byChannel.map((entry) => entry.channel)).toEqual(["telegram", "discord"]); + expect(tail.latency).toEqual({ + count: 3, + avgMs: 200 / 3, + minMs: 20, + maxMs: 120, + p95Ms: 120, + }); + expect(tail.dailyLatency).toEqual([ + { date: "2026-03-11", count: 1, avgMs: 30, minMs: 30, maxMs: 30, p95Ms: 30 }, + { date: "2026-03-12", count: 3, avgMs: 220 / 3, minMs: 20, maxMs: 120, p95Ms: 120 }, + ]); + expect(tail.modelDaily).toEqual([ + { date: "2026-03-11", cost: 9 }, + { date: "2026-03-12", cost: 2 }, + { date: "2026-03-12", cost: 1 }, + ]); + expect(tail.daily).toEqual([{ date: "2026-03-11" }, { date: "2026-03-12" }]); + }); + + it("omits latency when no requests were counted", () => { + const tail = buildUsageAggregateTail({ + byChannelMap: new Map(), + latencyTotals: { + count: 0, + sum: 0, + min: Number.POSITIVE_INFINITY, + max: 0, + p95Max: 0, + }, + dailyLatencyMap: new Map(), + modelDailyMap: new Map(), + dailyMap: new Map(), + }); + + expect(tail.latency).toBeUndefined(); + expect(tail.dailyLatency).toEqual([]); + }); +});