refactor(nodes): dedupe camera payload and node resolve helpers

This commit is contained in:
Peter Steinberger
2026-03-02 23:32:34 +00:00
parent a282b459b9
commit bb60687b89
9 changed files with 130 additions and 92 deletions

View File

@@ -1,5 +1,8 @@
import * as fs from "node:fs/promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
readFileUtf8AndCleanup,
stubFetchTextResponse,
} from "../test-utils/camera-url-test-helpers.js";
const { callGateway } = vi.hoisted(() => ({
callGateway: vi.fn(),
@@ -206,10 +209,7 @@ describe("nodes camera_snap", () => {
});
it("downloads camera_snap url payloads when node remoteIp is available", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("url-image", { status: 200 })),
);
stubFetchTextResponse("url-image");
setupNodeInvokeMock({
remoteIp: "198.51.100.42",
invokePayload: {
@@ -230,18 +230,11 @@ describe("nodes camera_snap", () => {
const mediaPath = String((result.content?.[0] as { text?: string } | undefined)?.text ?? "")
.replace(/^MEDIA:/, "")
.trim();
try {
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("url-image");
} finally {
await fs.unlink(mediaPath).catch(() => {});
}
await expect(readFileUtf8AndCleanup(mediaPath)).resolves.toBe("url-image");
});
it("rejects camera_snap url payloads when node remoteIp is missing", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("url-image", { status: 200 })),
);
stubFetchTextResponse("url-image");
setupNodeInvokeMock({
invokePayload: {
format: "jpg",
@@ -263,10 +256,7 @@ describe("nodes camera_snap", () => {
describe("nodes camera_clip", () => {
it("downloads camera_clip url payloads when node remoteIp is available", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("url-clip", { status: 200 })),
);
stubFetchTextResponse("url-clip");
setupNodeInvokeMock({
remoteIp: "198.51.100.42",
invokePayload: {
@@ -285,18 +275,11 @@ describe("nodes camera_clip", () => {
const filePath = String((result.content?.[0] as { text?: string } | undefined)?.text ?? "")
.replace(/^FILE:/, "")
.trim();
try {
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("url-clip");
} finally {
await fs.unlink(filePath).catch(() => {});
}
await expect(readFileUtf8AndCleanup(filePath)).resolves.toBe("url-clip");
});
it("rejects camera_clip url payloads when node remoteIp is missing", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("url-clip", { status: 200 })),
);
stubFetchTextResponse("url-clip");
setupNodeInvokeMock({
invokePayload: {
format: "mp4",

View File

@@ -7,8 +7,7 @@ import {
parseCameraClipPayload,
parseCameraSnapPayload,
writeCameraClipPayloadToFile,
writeBase64ToFile,
writeUrlToFile,
writeCameraPayloadToFile,
} from "../../cli/nodes-camera.js";
import { parseEnvPairs, parseTimeoutMs } from "../../cli/nodes-run.js";
import {
@@ -295,16 +294,12 @@ export function createNodesTool(options?: {
facing,
ext: isJpeg ? "jpg" : "png",
});
if (payload.url) {
if (!resolvedNode.remoteIp) {
throw new Error("camera URL payload requires node remoteIp");
}
await writeUrlToFile(filePath, payload.url, {
expectedHost: resolvedNode.remoteIp,
});
} else if (payload.base64) {
await writeBase64ToFile(filePath, payload.base64);
}
await writeCameraPayloadToFile({
filePath,
payload,
expectedHost: resolvedNode.remoteIp,
invalidPayloadMessage: "invalid camera.snap payload",
});
content.push({ type: "text", text: `MEDIA:${filePath}` });
if (payload.base64) {
content.push({

View File

@@ -1,6 +1,6 @@
import { parseNodeList, parsePairingList } from "../../shared/node-list-parse.js";
import type { NodeListNode } from "../../shared/node-list-types.js";
import { resolveNodeIdFromCandidates } from "../../shared/node-match.js";
import { resolveNodeFromNodeList, resolveNodeIdFromNodeList } from "../../shared/node-resolve.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
export type { NodeListNode };
@@ -142,17 +142,10 @@ export function resolveNodeIdFromList(
query?: string,
allowDefault = false,
): string {
const q = String(query ?? "").trim();
if (!q) {
if (allowDefault) {
const picked = pickDefaultNode(nodes);
if (picked) {
return picked.nodeId;
}
}
throw new Error("node required");
}
return resolveNodeIdFromCandidates(nodes, q);
return resolveNodeIdFromNodeList(nodes, query, {
allowDefault,
pickDefaultNode: pickDefaultNode,
});
}
export async function resolveNodeId(
@@ -169,6 +162,8 @@ export async function resolveNode(
allowDefault = false,
): Promise<NodeListNode> {
const nodes = await loadNodes(opts);
const nodeId = resolveNodeIdFromList(nodes, query, allowDefault);
return nodes.find((node) => node.nodeId === nodeId) ?? { nodeId };
return resolveNodeFromNodeList(nodes, query, {
allowDefault,
pickDefaultNode: pickDefaultNode,
});
}

View File

@@ -1,6 +1,10 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
readFileUtf8AndCleanup,
stubFetchResponse,
} from "../test-utils/camera-url-test-helpers.js";
import { withTempDir } from "../test-utils/temp-dir.js";
import {
cameraTempPath,
@@ -17,13 +21,6 @@ async function withCameraTempDir<T>(run: (dir: string) => Promise<T>): Promise<T
}
describe("nodes camera helpers", () => {
function stubFetchResponse(response: Response) {
vi.stubGlobal(
"fetch",
vi.fn(async () => response),
);
}
it("parses camera.snap payload", () => {
expect(
parseCameraSnapPayload({
@@ -88,7 +85,7 @@ describe("nodes camera helpers", () => {
id: "clip1",
});
expect(out).toBe(path.join(dir, "openclaw-camera-clip-front-clip1.mp4"));
await expect(fs.readFile(out, "utf8")).resolves.toBe("hi");
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("hi");
});
});
@@ -109,7 +106,7 @@ describe("nodes camera helpers", () => {
expectedHost,
});
expect(out).toBe(path.join(dir, "openclaw-camera-clip-back-clip2.mp4"));
await expect(fs.readFile(out, "utf8")).resolves.toBe("url-clip");
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("url-clip");
});
});
@@ -132,7 +129,7 @@ describe("nodes camera helpers", () => {
await withCameraTempDir(async (dir) => {
const out = path.join(dir, "x.bin");
await writeBase64ToFile(out, "aGk=");
await expect(fs.readFile(out, "utf8")).resolves.toBe("hi");
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("hi");
});
});
@@ -147,7 +144,7 @@ describe("nodes camera helpers", () => {
await writeUrlToFile(out, "https://198.51.100.42/clip.mp4", {
expectedHost: "198.51.100.42",
});
await expect(fs.readFile(out, "utf8")).resolves.toBe("url-content");
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("url-content");
});
});

View File

@@ -182,6 +182,33 @@ export async function writeBase64ToFile(filePath: string, base64: string) {
return { path: filePath, bytes: buf.length };
}
export function requireNodeRemoteIp(remoteIp?: string): string {
const normalized = remoteIp?.trim();
if (!normalized) {
throw new Error("camera URL payload requires node remoteIp");
}
return normalized;
}
export async function writeCameraPayloadToFile(params: {
filePath: string;
payload: { url?: string; base64?: string };
expectedHost?: string;
invalidPayloadMessage?: string;
}) {
if (params.payload.url) {
await writeUrlToFile(params.filePath, params.payload.url, {
expectedHost: requireNodeRemoteIp(params.expectedHost),
});
return;
}
if (params.payload.base64) {
await writeBase64ToFile(params.filePath, params.payload.base64);
return;
}
throw new Error(params.invalidPayloadMessage ?? "invalid camera payload");
}
export async function writeCameraClipPayloadToFile(params: {
payload: CameraClipPayload;
facing: CameraFacing;
@@ -196,15 +223,11 @@ export async function writeCameraClipPayloadToFile(params: {
tmpDir: params.tmpDir,
id: params.id,
});
if (params.payload.url) {
if (!params.expectedHost) {
throw new Error("camera URL payload requires node remoteIp");
}
await writeUrlToFile(filePath, params.payload.url, { expectedHost: params.expectedHost });
} else if (params.payload.base64) {
await writeBase64ToFile(filePath, params.payload.base64);
} else {
throw new Error("invalid camera.clip payload");
}
await writeCameraPayloadToFile({
filePath,
payload: params.payload,
expectedHost: params.expectedHost,
invalidPayloadMessage: "invalid camera.clip payload",
});
return filePath;
}

View File

@@ -7,9 +7,8 @@ import {
cameraTempPath,
parseCameraClipPayload,
parseCameraSnapPayload,
writeBase64ToFile,
writeCameraPayloadToFile,
writeCameraClipPayloadToFile,
writeUrlToFile,
} from "../nodes-camera.js";
import { parseDurationMs } from "../parse-duration.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
@@ -166,14 +165,12 @@ export function registerNodesCameraCommands(nodes: Command) {
facing,
ext: payload.format === "jpeg" ? "jpg" : payload.format,
});
if (payload.url) {
if (!node.remoteIp) {
throw new Error("camera URL payload requires node remoteIp");
}
await writeUrlToFile(filePath, payload.url, { expectedHost: node.remoteIp });
} else if (payload.base64) {
await writeBase64ToFile(filePath, payload.base64);
}
await writeCameraPayloadToFile({
filePath,
payload,
expectedHost: node.remoteIp,
invalidPayloadMessage: "invalid camera.snap payload",
});
results.push({
facing,
path: filePath,

View File

@@ -1,6 +1,6 @@
import type { Command } from "commander";
import { callGateway, randomIdempotencyKey } from "../../gateway/call.js";
import { resolveNodeIdFromCandidates } from "../../shared/node-match.js";
import { resolveNodeFromNodeList } from "../../shared/node-resolve.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import { withProgress } from "../progress.js";
import { parseNodeList, parsePairingList } from "./format.js";
@@ -77,11 +77,6 @@ export async function resolveNodeId(opts: NodesRpcOpts, query: string) {
}
export async function resolveNode(opts: NodesRpcOpts, query: string): Promise<NodeListNode> {
const q = String(query ?? "").trim();
if (!q) {
throw new Error("node required");
}
let nodes: NodeListNode[] = [];
try {
const res = await callGatewayCli("node.list", opts, {});
@@ -97,6 +92,5 @@ export async function resolveNode(opts: NodesRpcOpts, query: string): Promise<No
remoteIp: n.remoteIp,
}));
}
const nodeId = resolveNodeIdFromCandidates(nodes, q);
return nodes.find((node) => node.nodeId === nodeId) ?? { nodeId };
return resolveNodeFromNodeList(nodes, query);
}

View File

@@ -0,0 +1,33 @@
import { type NodeMatchCandidate, resolveNodeIdFromCandidates } from "./node-match.js";
type ResolveNodeFromListOptions<TNode extends NodeMatchCandidate> = {
allowDefault?: boolean;
pickDefaultNode?: (nodes: TNode[]) => TNode | null;
};
export function resolveNodeIdFromNodeList<TNode extends NodeMatchCandidate>(
nodes: TNode[],
query?: string,
options: ResolveNodeFromListOptions<TNode> = {},
): string {
const q = String(query ?? "").trim();
if (!q) {
if (options.allowDefault === true && options.pickDefaultNode) {
const picked = options.pickDefaultNode(nodes);
if (picked) {
return picked.nodeId;
}
}
throw new Error("node required");
}
return resolveNodeIdFromCandidates(nodes, q);
}
export function resolveNodeFromNodeList<TNode extends NodeMatchCandidate>(
nodes: TNode[],
query?: string,
options: ResolveNodeFromListOptions<TNode> = {},
): TNode {
const nodeId = resolveNodeIdFromNodeList(nodes, query, options);
return nodes.find((node) => node.nodeId === nodeId) ?? ({ nodeId } as TNode);
}

View File

@@ -0,0 +1,21 @@
import * as fs from "node:fs/promises";
import { vi } from "vitest";
export function stubFetchResponse(response: Response) {
vi.stubGlobal(
"fetch",
vi.fn(async () => response),
);
}
export function stubFetchTextResponse(text: string, init?: ResponseInit) {
stubFetchResponse(new Response(text, { status: 200, ...init }));
}
export async function readFileUtf8AndCleanup(filePath: string): Promise<string> {
try {
return await fs.readFile(filePath, "utf8");
} finally {
await fs.unlink(filePath).catch(() => {});
}
}