mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 00:41:17 +00:00
495 lines
15 KiB
TypeScript
495 lines
15 KiB
TypeScript
// Nodes camera tests cover camera node command media handling and file inputs.
|
|
import * as fs from "node:fs/promises";
|
|
import * as path from "node:path";
|
|
import { afterEach, beforeAll, beforeEach, 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";
|
|
|
|
const fetchGuardMocks = vi.hoisted(() => ({
|
|
fetchWithSsrFGuard: vi.fn(
|
|
async (params: { url: string; timeoutMs?: number; requireHttps?: boolean }) => {
|
|
return {
|
|
response: await globalThis.fetch(params.url),
|
|
finalUrl: params.url,
|
|
release: async () => {},
|
|
};
|
|
},
|
|
),
|
|
}));
|
|
|
|
vi.mock("../infra/net/fetch-guard.js", () => ({
|
|
fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard,
|
|
}));
|
|
|
|
let cameraTempPath: typeof import("./nodes-camera.js").cameraTempPath;
|
|
let parseCameraClipPayload: typeof import("./nodes-camera.js").parseCameraClipPayload;
|
|
let parseCameraSnapPayload: typeof import("./nodes-camera.js").parseCameraSnapPayload;
|
|
let writeCameraClipPayloadToFile: typeof import("./nodes-camera.js").writeCameraClipPayloadToFile;
|
|
let writeBase64ToFile: typeof import("./nodes-camera.js").writeBase64ToFile;
|
|
let writeUrlToFile: typeof import("./nodes-camera.js").writeUrlToFile;
|
|
let parseScreenRecordPayload: typeof import("./nodes-screen.js").parseScreenRecordPayload;
|
|
let parseScreenSnapshotPayload: typeof import("./nodes-screen.js").parseScreenSnapshotPayload;
|
|
let screenRecordTempPath: typeof import("./nodes-screen.js").screenRecordTempPath;
|
|
let screenSnapshotTempPath: typeof import("./nodes-screen.js").screenSnapshotTempPath;
|
|
let writeScreenRecordToFile: typeof import("./nodes-screen.js").writeScreenRecordToFile;
|
|
let writeScreenSnapshotToFile: typeof import("./nodes-screen.js").writeScreenSnapshotToFile;
|
|
|
|
async function withCameraTempDir<T>(run: (dir: string) => Promise<T>): Promise<T> {
|
|
return await withTempDir("openclaw-test-", run);
|
|
}
|
|
|
|
async function expectPathMissing(targetPath: string): Promise<void> {
|
|
try {
|
|
await fs.stat(targetPath);
|
|
} catch (error) {
|
|
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
|
|
return;
|
|
}
|
|
throw new Error(`expected missing path: ${targetPath}`);
|
|
}
|
|
|
|
function cancelTrackedResponse(init?: ResponseInit): {
|
|
response: Response;
|
|
wasCanceled: () => boolean;
|
|
} {
|
|
let canceled = false;
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode("ignored"));
|
|
},
|
|
cancel() {
|
|
canceled = true;
|
|
},
|
|
});
|
|
return {
|
|
response: new Response(stream, init),
|
|
wasCanceled: () => canceled,
|
|
};
|
|
}
|
|
|
|
describe("nodes camera helpers", () => {
|
|
beforeAll(async () => {
|
|
({
|
|
cameraTempPath,
|
|
parseCameraClipPayload,
|
|
parseCameraSnapPayload,
|
|
writeCameraClipPayloadToFile,
|
|
writeBase64ToFile,
|
|
writeUrlToFile,
|
|
} = await import("./nodes-camera.js"));
|
|
({
|
|
parseScreenRecordPayload,
|
|
parseScreenSnapshotPayload,
|
|
screenRecordTempPath,
|
|
screenSnapshotTempPath,
|
|
writeScreenRecordToFile,
|
|
writeScreenSnapshotToFile,
|
|
} = await import("./nodes-screen.js"));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("parses camera.snap payload", () => {
|
|
expect(
|
|
parseCameraSnapPayload({
|
|
format: "jpg",
|
|
base64: "aGk=",
|
|
width: 10,
|
|
height: 20,
|
|
}),
|
|
).toEqual({ format: "jpg", base64: "aGk=", width: 10, height: 20 });
|
|
});
|
|
|
|
it("rejects invalid camera.snap payload", () => {
|
|
expect(() => parseCameraSnapPayload({ format: "jpg" })).toThrow(
|
|
/invalid camera\.snap payload/i,
|
|
);
|
|
});
|
|
|
|
it("parses camera.clip payload", () => {
|
|
expect(
|
|
parseCameraClipPayload({
|
|
format: "mp4",
|
|
base64: "AAEC",
|
|
durationMs: 1234,
|
|
hasAudio: true,
|
|
}),
|
|
).toEqual({
|
|
format: "mp4",
|
|
base64: "AAEC",
|
|
durationMs: 1234,
|
|
hasAudio: true,
|
|
});
|
|
});
|
|
|
|
it("rejects invalid camera.clip payload", () => {
|
|
expect(() =>
|
|
parseCameraClipPayload({ format: "mp4", base64: "AAEC", durationMs: 1234 }),
|
|
).toThrow(/invalid camera\.clip payload/i);
|
|
});
|
|
|
|
it("builds stable temp paths when id provided", () => {
|
|
const p = cameraTempPath({
|
|
kind: "snap",
|
|
facing: "front",
|
|
ext: "jpg",
|
|
tmpDir: "/tmp",
|
|
id: "id1",
|
|
});
|
|
expect(p).toBe(path.join("/tmp", "openclaw-camera-snap-front-id1.jpg"));
|
|
});
|
|
|
|
it("rejects media format path traversal", () => {
|
|
expect(() =>
|
|
cameraTempPath({
|
|
kind: "snap",
|
|
ext: "../escaped",
|
|
tmpDir: "/tmp",
|
|
id: "id1",
|
|
}),
|
|
).toThrow(/invalid media format/i);
|
|
expect(() =>
|
|
screenRecordTempPath({
|
|
ext: "mp4/../../escaped",
|
|
tmpDir: "/tmp",
|
|
id: "id1",
|
|
}),
|
|
).toThrow(/invalid media format/i);
|
|
expect(() =>
|
|
screenSnapshotTempPath({
|
|
ext: "png/../../escaped",
|
|
tmpDir: "/tmp",
|
|
id: "id1",
|
|
}),
|
|
).toThrow(/invalid media format/i);
|
|
});
|
|
|
|
it("writes camera clip payload to temp path", async () => {
|
|
await withCameraTempDir(async (dir) => {
|
|
const out = await writeCameraClipPayloadToFile({
|
|
payload: {
|
|
format: "mp4",
|
|
base64: "aGk=",
|
|
durationMs: 200,
|
|
hasAudio: false,
|
|
},
|
|
facing: "front",
|
|
tmpDir: dir,
|
|
id: "clip1",
|
|
});
|
|
expect(out).toBe(path.join(dir, "openclaw-camera-clip-front-clip1.mp4"));
|
|
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("hi");
|
|
});
|
|
});
|
|
|
|
it("writes camera clip payload from url", async () => {
|
|
stubFetchResponse(new Response("url-clip", { status: 200 }));
|
|
await withCameraTempDir(async (dir) => {
|
|
const expectedHost = "198.51.100.42";
|
|
const out = await writeCameraClipPayloadToFile({
|
|
payload: {
|
|
format: "mp4",
|
|
url: `https://${expectedHost}/clip.mp4`,
|
|
durationMs: 200,
|
|
hasAudio: false,
|
|
},
|
|
facing: "back",
|
|
tmpDir: dir,
|
|
id: "clip2",
|
|
expectedHost,
|
|
});
|
|
expect(out).toBe(path.join(dir, "openclaw-camera-clip-back-clip2.mp4"));
|
|
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("url-clip");
|
|
});
|
|
});
|
|
|
|
it("rejects camera clip url payloads without node remoteIp", async () => {
|
|
stubFetchResponse(new Response("url-clip", { status: 200 }));
|
|
await expect(
|
|
writeCameraClipPayloadToFile({
|
|
payload: {
|
|
format: "mp4",
|
|
url: "https://198.51.100.42/clip.mp4",
|
|
durationMs: 200,
|
|
hasAudio: false,
|
|
},
|
|
facing: "back",
|
|
}),
|
|
).rejects.toThrow(/node remoteip/i);
|
|
});
|
|
|
|
it("normalizes valid base64 before writing", async () => {
|
|
await withCameraTempDir(async (dir) => {
|
|
const out = path.join(dir, "x.bin");
|
|
await writeBase64ToFile(out, " aGk\n");
|
|
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("hi");
|
|
});
|
|
});
|
|
|
|
it("rejects oversized base64 payloads before writing", async () => {
|
|
await withCameraTempDir(async (dir) => {
|
|
const out = path.join(dir, "x.bin");
|
|
await expect(writeBase64ToFile(out, "aGk=", { maxBytes: 1 })).rejects.toThrow(/exceeds max/i);
|
|
await expectPathMissing(out);
|
|
await expect(writeScreenRecordToFile(out, "aGk=", { maxBytes: 1 })).rejects.toThrow(
|
|
/exceeds max/i,
|
|
);
|
|
await expectPathMissing(out);
|
|
await expect(writeScreenSnapshotToFile(out, "aGk=", { maxBytes: 1 })).rejects.toThrow(
|
|
/exceeds max/i,
|
|
);
|
|
await expectPathMissing(out);
|
|
});
|
|
});
|
|
|
|
it("rejects empty and malformed base64 payloads before writing", async () => {
|
|
await withCameraTempDir(async (dir) => {
|
|
const out = path.join(dir, "x.bin");
|
|
for (const base64 of ["", " \n", "a", "a===", "not-base64!"]) {
|
|
await expect(writeBase64ToFile(out, base64)).rejects.toThrow(/invalid base64/i);
|
|
await expectPathMissing(out);
|
|
}
|
|
await expect(writeScreenRecordToFile(out, "not-base64!")).rejects.toThrow(/invalid base64/i);
|
|
await expectPathMissing(out);
|
|
await expect(writeScreenSnapshotToFile(out, "not-base64!")).rejects.toThrow(
|
|
/invalid base64/i,
|
|
);
|
|
await expectPathMissing(out);
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("writes url payload to file", async () => {
|
|
stubFetchResponse(new Response("url-content", { status: 200 }));
|
|
await withCameraTempDir(async (dir) => {
|
|
const out = path.join(dir, "x.bin");
|
|
await writeUrlToFile(out, "https://198.51.100.42/clip.mp4", {
|
|
expectedHost: "198.51.100.42",
|
|
});
|
|
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("url-content");
|
|
expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
|
|
expect.objectContaining({ requireHttps: true, timeoutMs: 15 * 60_000 }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("rejects url host mismatches", async () => {
|
|
stubFetchResponse(new Response("url-content", { status: 200 }));
|
|
await expect(
|
|
writeUrlToFile("/tmp/ignored", "https://198.51.100.42/clip.mp4", {
|
|
expectedHost: "198.51.100.43",
|
|
}),
|
|
).rejects.toThrow(/must match node host/i);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "non-https url",
|
|
url: "http://198.51.100.42/x.bin",
|
|
expectedMessage: /only https/i,
|
|
},
|
|
{
|
|
name: "oversized content-length",
|
|
url: "https://198.51.100.42/huge.bin",
|
|
response: new Response("tiny", {
|
|
status: 200,
|
|
headers: { "content-length": String(999_999_999) },
|
|
}),
|
|
expectedMessage: /exceeds max/i,
|
|
},
|
|
{
|
|
name: "malformed content-length",
|
|
url: "https://198.51.100.42/weird.bin",
|
|
response: new Response("tiny", {
|
|
status: 200,
|
|
headers: { "content-length": "0x3" },
|
|
}),
|
|
expectedMessage: /invalid content-length header: 0x3/i,
|
|
},
|
|
{
|
|
name: "non-ok status",
|
|
url: "https://198.51.100.42/down.bin",
|
|
response: new Response("down", { status: 503, statusText: "Service Unavailable" }),
|
|
expectedMessage: /503/i,
|
|
},
|
|
{
|
|
name: "empty response body",
|
|
url: "https://198.51.100.42/empty.bin",
|
|
response: new Response(null, { status: 200 }),
|
|
expectedMessage: /empty response body/i,
|
|
},
|
|
] as const)(
|
|
"rejects invalid url payload response: $name",
|
|
async ({ url, response, expectedMessage }) => {
|
|
if (response) {
|
|
stubFetchResponse(response);
|
|
}
|
|
await expect(
|
|
writeUrlToFile("/tmp/ignored", url, { expectedHost: "198.51.100.42" }),
|
|
).rejects.toThrow(expectedMessage);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
name: "non-ok status",
|
|
response: () => cancelTrackedResponse({ status: 503, statusText: "Service Unavailable" }),
|
|
expectedMessage: /503/i,
|
|
},
|
|
{
|
|
name: "oversized content-length",
|
|
response: () =>
|
|
cancelTrackedResponse({
|
|
status: 200,
|
|
headers: { "content-length": String(999_999_999) },
|
|
}),
|
|
expectedMessage: /exceeds max/i,
|
|
},
|
|
{
|
|
name: "malformed content-length",
|
|
response: () =>
|
|
cancelTrackedResponse({
|
|
status: 200,
|
|
headers: { "content-length": "0x3" },
|
|
}),
|
|
expectedMessage: /invalid content-length/i,
|
|
},
|
|
] as const)(
|
|
"cancels rejected url response bodies: $name",
|
|
async ({ response, expectedMessage }) => {
|
|
const tracked = response();
|
|
stubFetchResponse(tracked.response);
|
|
|
|
await expect(
|
|
writeUrlToFile("/tmp/ignored", "https://198.51.100.42/down.bin", {
|
|
expectedHost: "198.51.100.42",
|
|
}),
|
|
).rejects.toThrow(expectedMessage);
|
|
expect(tracked.wasCanceled()).toBe(true);
|
|
},
|
|
);
|
|
|
|
it("cancels response bodies when a redirect changes host", async () => {
|
|
const tracked = cancelTrackedResponse({ status: 200 });
|
|
fetchGuardMocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
|
response: tracked.response,
|
|
finalUrl: "https://198.51.100.43/clip.mp4",
|
|
release: async () => {},
|
|
});
|
|
|
|
await expect(
|
|
writeUrlToFile("/tmp/ignored", "https://198.51.100.42/clip.mp4", {
|
|
expectedHost: "198.51.100.42",
|
|
}),
|
|
).rejects.toThrow(/redirect host/i);
|
|
expect(tracked.wasCanceled()).toBe(true);
|
|
});
|
|
|
|
it("removes partially written file when url stream fails", async () => {
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode("partial"));
|
|
controller.error(new Error("stream exploded"));
|
|
},
|
|
});
|
|
stubFetchResponse(new Response(stream, { status: 200 }));
|
|
|
|
await withCameraTempDir(async (dir) => {
|
|
const out = path.join(dir, "broken.bin");
|
|
await expect(
|
|
writeUrlToFile(out, "https://198.51.100.42/broken.bin", { expectedHost: "198.51.100.42" }),
|
|
).rejects.toThrow(/stream exploded/i);
|
|
await expectPathMissing(out);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("nodes screen helpers", () => {
|
|
it("parses screen.record payload", () => {
|
|
const payload = parseScreenRecordPayload({
|
|
format: "mp4",
|
|
base64: "Zm9v",
|
|
durationMs: 1000,
|
|
fps: 12,
|
|
screenIndex: 0,
|
|
hasAudio: true,
|
|
});
|
|
expect(payload.format).toBe("mp4");
|
|
expect(payload.base64).toBe("Zm9v");
|
|
expect(payload.durationMs).toBe(1000);
|
|
expect(payload.fps).toBe(12);
|
|
expect(payload.screenIndex).toBe(0);
|
|
expect(payload.hasAudio).toBe(true);
|
|
});
|
|
|
|
it("drops invalid optional fields instead of throwing", () => {
|
|
const payload = parseScreenRecordPayload({
|
|
format: "mp4",
|
|
base64: "Zm9v",
|
|
durationMs: "nope",
|
|
fps: null,
|
|
screenIndex: "0",
|
|
hasAudio: 1,
|
|
});
|
|
expect(payload.durationMs).toBeUndefined();
|
|
expect(payload.fps).toBeUndefined();
|
|
expect(payload.screenIndex).toBeUndefined();
|
|
expect(payload.hasAudio).toBeUndefined();
|
|
});
|
|
|
|
it("rejects invalid screen.record payload", () => {
|
|
expect(() => parseScreenRecordPayload({ format: "mp4" })).toThrow(
|
|
/invalid screen\.record payload/i,
|
|
);
|
|
});
|
|
|
|
it("builds screen record temp path", () => {
|
|
const p = screenRecordTempPath({
|
|
ext: "mp4",
|
|
tmpDir: "/tmp",
|
|
id: "id1",
|
|
});
|
|
expect(p).toBe(path.join("/tmp", "openclaw-screen-record-id1.mp4"));
|
|
});
|
|
|
|
it("parses screen.snapshot payload", () => {
|
|
expect(
|
|
parseScreenSnapshotPayload({
|
|
format: "png",
|
|
base64: "Zm9v",
|
|
displayFrameId: "display-42-frame",
|
|
screenIndex: 1,
|
|
width: 1200,
|
|
height: 800,
|
|
}),
|
|
).toEqual({
|
|
format: "png",
|
|
base64: "Zm9v",
|
|
displayFrameId: "display-42-frame",
|
|
screenIndex: 1,
|
|
width: 1200,
|
|
height: 800,
|
|
});
|
|
});
|
|
|
|
it("rejects invalid screen.snapshot payload", () => {
|
|
expect(() => parseScreenSnapshotPayload({ format: "png" })).toThrow(
|
|
/invalid screen\.snapshot payload/i,
|
|
);
|
|
});
|
|
|
|
it("builds screen snapshot temp path", () => {
|
|
expect(screenSnapshotTempPath({ tmpDir: "/tmp", id: "id1" })).toBe(
|
|
path.join("/tmp", "openclaw-screen-snapshot-id1.png"),
|
|
);
|
|
});
|
|
});
|