diff --git a/src/media/server.outside-workspace.test.ts b/src/media/server.outside-workspace.test.ts index 2f8c26bd767..2a1a9c1df80 100644 --- a/src/media/server.outside-workspace.test.ts +++ b/src/media/server.outside-workspace.test.ts @@ -1,8 +1,11 @@ -import { createRequire } from "node:module"; -import type { AddressInfo } from "node:net"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { withEnvAsync } from "../test-utils/env.js"; +import { + LOOPBACK_FETCH_ENV, + startMediaServerTestHarness, + type MediaServerTestHarness, +} from "./server.test-support.js"; const mocks = vi.hoisted(() => ({ readFileWithinRoot: vi.fn(), @@ -24,59 +27,29 @@ vi.mock("./server.runtime.js", () => { }; }); -let startMediaServer: typeof import("./server.js").startMediaServer; -let realFetch: typeof import("undici").fetch; +let mediaHarness: MediaServerTestHarness | undefined; const mediaRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-media-outside-workspace-", }); -const LOOPBACK_FETCH_ENV = { - HTTP_PROXY: undefined, - HTTPS_PROXY: undefined, - ALL_PROXY: undefined, - http_proxy: undefined, - https_proxy: undefined, - all_proxy: undefined, - NO_PROXY: "127.0.0.1,localhost", - no_proxy: "127.0.0.1,localhost", -} as const; async function expectOutsideWorkspaceServerResponse(url: string) { - const response = await withEnvAsync(LOOPBACK_FETCH_ENV, () => realFetch(url)); + const response = await withEnvAsync(LOOPBACK_FETCH_ENV, () => mediaHarness!.fetch(url)); expect(response.status).toBe(400); expect(await response.text()).toBe("file is outside workspace root"); } describe("media server outside-workspace mapping", () => { - let server: Awaited> | undefined; - let listenBlocked = false; - let port = 0; - beforeAll(async () => { - vi.useRealTimers(); - vi.doUnmock("undici"); - const require = createRequire(import.meta.url); - ({ startMediaServer } = await import("./server.js")); - ({ fetch: realFetch } = require("undici") as typeof import("undici")); - await mediaRootTracker.setup(); - mediaDir = await mediaRootTracker.make("case"); - try { - server = await startMediaServer(0, 1_000); - } catch (error) { - if ( - error instanceof Error && - "code" in error && - (error.code === "EPERM" || error.code === "EACCES") - ) { - listenBlocked = true; - return; - } - throw error; - } - const boundServer = server; - if (!boundServer) { - return; - } - port = (boundServer.address() as AddressInfo).port; + mediaHarness = await startMediaServerTestHarness({ + setupMediaRoot: async () => { + await mediaRootTracker.setup(); + mediaDir = await mediaRootTracker.make("case"); + }, + cleanupMediaRoot: async () => { + await mediaRootTracker.cleanup(); + mediaDir = ""; + }, + }); }); beforeEach(() => { @@ -85,16 +58,12 @@ describe("media server outside-workspace mapping", () => { }); afterAll(async () => { - const boundServer = server; - if (boundServer) { - await new Promise((resolve) => boundServer.close(resolve)); - } - await mediaRootTracker.cleanup(); - mediaDir = ""; + await mediaHarness?.cleanup(); + mediaHarness = undefined; }); it("returns 400 with a specific outside-workspace message", async () => { - if (listenBlocked) { + if (mediaHarness?.listenBlocked) { return; } mocks.readFileWithinRoot.mockRejectedValueOnce({ @@ -102,6 +71,6 @@ describe("media server outside-workspace mapping", () => { message: "file is outside workspace root", }); - await expectOutsideWorkspaceServerResponse(`http://127.0.0.1:${port}/media/ok-id`); + await expectOutsideWorkspaceServerResponse(mediaHarness!.url("ok-id")); }); }); diff --git a/src/media/server.test-support.ts b/src/media/server.test-support.ts new file mode 100644 index 00000000000..7b2ff450dc5 --- /dev/null +++ b/src/media/server.test-support.ts @@ -0,0 +1,76 @@ +import { createRequire } from "node:module"; +import type { AddressInfo } from "node:net"; +import { vi } from "vitest"; + +type MediaTestServer = Awaited>; +type UndiciFetch = typeof import("undici").fetch; + +export const LOOPBACK_FETCH_ENV = { + HTTP_PROXY: undefined, + HTTPS_PROXY: undefined, + ALL_PROXY: undefined, + http_proxy: undefined, + https_proxy: undefined, + all_proxy: undefined, + NO_PROXY: "127.0.0.1,localhost", + no_proxy: "127.0.0.1,localhost", +} as const; + +export interface MediaServerTestHarness { + fetch: UndiciFetch; + listenBlocked: boolean; + port: number; + url: (mediaPath: string) => string; + cleanup: () => Promise; +} + +function isListenPermissionError(error: unknown): boolean { + return ( + error instanceof Error && "code" in error && (error.code === "EPERM" || error.code === "EACCES") + ); +} + +export async function startMediaServerTestHarness(params: { + setupMediaRoot: () => Promise; + cleanupMediaRoot: () => Promise; + cleanupTtlMs?: number; +}): Promise { + vi.useRealTimers(); + vi.doUnmock("undici"); + + const require = createRequire(import.meta.url); + const { startMediaServer } = await import("./server.js"); + const { fetch } = require("undici") as typeof import("undici"); + + let server: MediaTestServer | undefined; + let listenBlocked = false; + let port = 0; + + await params.setupMediaRoot(); + + try { + server = await startMediaServer(0, params.cleanupTtlMs ?? 1_000); + } catch (error) { + if (!isListenPermissionError(error)) { + throw error; + } + listenBlocked = true; + } + + if (server) { + port = (server.address() as AddressInfo).port; + } + + return { + fetch, + listenBlocked, + port, + url: (mediaPath: string) => `http://127.0.0.1:${port}/media/${mediaPath}`, + cleanup: async () => { + if (server) { + await new Promise((resolve) => server?.close(resolve)); + } + await params.cleanupMediaRoot(); + }, + }; +} diff --git a/src/media/server.test.ts b/src/media/server.test.ts index 09290521424..6da12cc038c 100644 --- a/src/media/server.test.ts +++ b/src/media/server.test.ts @@ -1,10 +1,13 @@ import fs from "node:fs/promises"; -import { createRequire } from "node:module"; -import type { AddressInfo } from "node:net"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { withEnvAsync } from "../test-utils/env.js"; +import { + LOOPBACK_FETCH_ENV, + startMediaServerTestHarness, + type MediaServerTestHarness, +} from "./server.test-support.js"; let MEDIA_DIR = ""; const cleanOldMedia = vi.fn().mockResolvedValue(undefined); @@ -18,20 +21,9 @@ vi.mock("./store.js", async () => { }; }); -let startMediaServer: typeof import("./server.js").startMediaServer; let MEDIA_MAX_BYTES: typeof import("./store.js").MEDIA_MAX_BYTES; -let realFetch: typeof import("undici").fetch; +let mediaHarness: MediaServerTestHarness | undefined; const mediaRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-media-test-" }); -const LOOPBACK_FETCH_ENV = { - HTTP_PROXY: undefined, - HTTPS_PROXY: undefined, - ALL_PROXY: undefined, - http_proxy: undefined, - https_proxy: undefined, - all_proxy: undefined, - NO_PROXY: "127.0.0.1,localhost", - no_proxy: "127.0.0.1,localhost", -} as const; async function waitForFileRemoval(filePath: string, maxTicks = 1000) { for (let tick = 0; tick < maxTicks; tick += 1) { @@ -46,12 +38,8 @@ async function waitForFileRemoval(filePath: string, maxTicks = 1000) { } describe("media server", () => { - let server: Awaited> | undefined; - let listenBlocked = false; - let port = 0; - function mediaUrl(id: string) { - return `http://127.0.0.1:${port}/media/${id}`; + return mediaHarness?.url(id) ?? ""; } async function writeMediaFile(id: string, contents: string) { @@ -70,7 +58,7 @@ describe("media server", () => { } function expectFetchedResponse( - response: Awaited>, + response: Awaited>, expected: { status: number; noSniff?: boolean }, ) { expect(response.status).toBe(expected.status); @@ -89,7 +77,9 @@ describe("media server", () => { }) { const file = await writeMediaFile(params.id, params.contents); await params.mutateFile?.(file); - const res = await withEnvAsync(LOOPBACK_FETCH_ENV, () => realFetch(mediaUrl(params.id))); + const res = await withEnvAsync(LOOPBACK_FETCH_ENV, () => + mediaHarness!.fetch(mediaUrl(params.id)), + ); expectFetchedResponse(res, { status: params.expectedStatus }); if (params.expectedBody !== undefined) { expect(await res.text()).toBe(params.expectedBody); @@ -105,7 +95,9 @@ describe("media server", () => { setup?: () => Promise; }) { await params.setup?.(); - const res = await withEnvAsync(LOOPBACK_FETCH_ENV, () => realFetch(mediaUrl(params.mediaPath))); + const res = await withEnvAsync(LOOPBACK_FETCH_ENV, () => + mediaHarness!.fetch(mediaUrl(params.mediaPath)), + ); expectFetchedResponse(res, { status: params.expectedStatus, ...(params.expectedNoSniff ? { noSniff: true } : {}), @@ -116,41 +108,22 @@ describe("media server", () => { } beforeAll(async () => { - vi.useRealTimers(); - vi.doUnmock("undici"); - const require = createRequire(import.meta.url); - ({ startMediaServer } = await import("./server.js")); ({ MEDIA_MAX_BYTES } = await import("./store.js")); - ({ fetch: realFetch } = require("undici") as typeof import("undici")); - await mediaRootTracker.setup(); - MEDIA_DIR = await mediaRootTracker.make("case"); - try { - server = await startMediaServer(0, 1_000); - } catch (error) { - if ( - error instanceof Error && - "code" in error && - (error.code === "EPERM" || error.code === "EACCES") - ) { - listenBlocked = true; - return; - } - throw error; - } - const boundServer = server; - if (!boundServer) { - return; - } - port = (boundServer.address() as AddressInfo).port; + mediaHarness = await startMediaServerTestHarness({ + setupMediaRoot: async () => { + await mediaRootTracker.setup(); + MEDIA_DIR = await mediaRootTracker.make("case"); + }, + cleanupMediaRoot: async () => { + await mediaRootTracker.cleanup(); + MEDIA_DIR = ""; + }, + }); }); afterAll(async () => { - const boundServer = server; - if (boundServer) { - await new Promise((r) => boundServer.close(r)); - } - await mediaRootTracker.cleanup(); - MEDIA_DIR = ""; + await mediaHarness?.cleanup(); + mediaHarness = undefined; }); it.each([ @@ -173,7 +146,7 @@ describe("media server", () => { assertAfterFetch: expectMissingMediaFile, }, ] as const)("$name", async (testCase) => { - if (listenBlocked) { + if (mediaHarness?.listenBlocked) { return; } await expectMediaFileLifecycleCase(testCase); @@ -235,7 +208,7 @@ describe("media server", () => { expectedBody: "invalid path", }, ] as const)("%#", async (testCase) => { - if (listenBlocked) { + if (mediaHarness?.listenBlocked) { return; } await expectFetchedMediaCase(testCase);