mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-25 17:02:46 +00:00
test: harden vitest no-isolate coverage
This commit is contained in:
@@ -68,9 +68,11 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
|
||||
through the real `run.ts` / `compact.ts` paths; helper-only tests are not a
|
||||
sufficient substitute for those integration paths.
|
||||
- Pool note:
|
||||
- OpenClaw uses Vitest `vmForks` on Node 22, 23, and 24 for faster unit shards.
|
||||
- On Node 25+, OpenClaw automatically falls back to regular `forks` until the repo is re-validated there.
|
||||
- Override manually with `OPENCLAW_TEST_VM_FORKS=0` (force `forks`) or `OPENCLAW_TEST_VM_FORKS=1` (force `vmForks`).
|
||||
- OpenClaw uses Vitest `forks` by default for unit shards.
|
||||
- `pnpm test` also defaults to `--isolate=false` at the wrapper level for faster file startup.
|
||||
- Opt back into Vitest file isolation with `OPENCLAW_TEST_ISOLATE=1 pnpm test`.
|
||||
- `OPENCLAW_TEST_NO_ISOLATE=0` or `OPENCLAW_TEST_NO_ISOLATE=false` also force isolated runs.
|
||||
- `OPENCLAW_TEST_VM_FORKS=1` remains an opt-in experiment on Node 22, 23, and 24 only.
|
||||
|
||||
### E2E (gateway smoke)
|
||||
|
||||
@@ -78,8 +80,8 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
|
||||
- Config: `vitest.e2e.config.ts`
|
||||
- Files: `src/**/*.e2e.test.ts`, `test/**/*.e2e.test.ts`
|
||||
- Runtime defaults:
|
||||
- Uses Vitest `vmForks` for faster file startup.
|
||||
- Uses adaptive workers (CI: 2-4, local: 4-8).
|
||||
- Uses Vitest `forks` for deterministic cross-file isolation.
|
||||
- Uses adaptive workers (CI: up to 2, local: 1 by default).
|
||||
- Runs in silent mode by default to reduce console I/O overhead.
|
||||
- Useful overrides:
|
||||
- `OPENCLAW_E2E_WORKERS=<n>` to force worker count (capped at 16).
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||
getOAuthApiKey: vi.fn(),
|
||||
getOAuthProviders: vi.fn(() => []),
|
||||
}));
|
||||
vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
|
||||
"@mariozechner/pi-ai/oauth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getOAuthApiKey: vi.fn(),
|
||||
getOAuthProviders: vi.fn(() => []),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-models", () => ({
|
||||
normalizeModelCompat: (model: Record<string, unknown>) => model,
|
||||
|
||||
@@ -94,7 +94,12 @@ const isMacMiniProfile = testProfile === "macmini";
|
||||
// Preserve OPENCLAW_TEST_VM_FORKS=1 as the explicit override/debug escape hatch.
|
||||
const supportsVmForks = Number.isFinite(nodeMajor) ? nodeMajor <= 24 : true;
|
||||
const useVmForks = process.env.OPENCLAW_TEST_VM_FORKS === "1" && supportsVmForks;
|
||||
const disableIsolation = process.env.OPENCLAW_TEST_NO_ISOLATE === "1";
|
||||
const forceIsolation =
|
||||
process.env.OPENCLAW_TEST_ISOLATE === "1" || process.env.OPENCLAW_TEST_ISOLATE === "true";
|
||||
const disableIsolation =
|
||||
!forceIsolation &&
|
||||
process.env.OPENCLAW_TEST_NO_ISOLATE !== "0" &&
|
||||
process.env.OPENCLAW_TEST_NO_ISOLATE !== "false";
|
||||
const includeGatewaySuite = process.env.OPENCLAW_TEST_INCLUDE_GATEWAY === "1";
|
||||
const includeExtensionsSuite = process.env.OPENCLAW_TEST_INCLUDE_EXTENSIONS === "1";
|
||||
// Even on low-memory hosts, keep the isolated lane split so files like
|
||||
@@ -699,10 +704,40 @@ const createTargetedEntry = (owner, isolated, filters) => {
|
||||
],
|
||||
};
|
||||
};
|
||||
const formatPerFileEntryName = (owner, file) => {
|
||||
const baseName = path
|
||||
.basename(file)
|
||||
.replace(/\.live\.test\.ts$/u, "")
|
||||
.replace(/\.e2e\.test\.ts$/u, "")
|
||||
.replace(/\.test\.ts$/u, "");
|
||||
return `${owner}-${baseName}`;
|
||||
};
|
||||
const createPerFileTargetedEntry = (file) => {
|
||||
const target = inferTarget(file);
|
||||
const owner = isThreadSingletonUnitFile(file)
|
||||
? "unit-threads"
|
||||
: isVmForkSingletonUnitFile(file)
|
||||
? "unit-vmforks"
|
||||
: target.owner;
|
||||
return {
|
||||
...createTargetedEntry(owner, target.isolated, [file]),
|
||||
name: formatPerFileEntryName(owner, file),
|
||||
};
|
||||
};
|
||||
const targetedEntries = (() => {
|
||||
if (passthroughFileFilters.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (disableIsolation) {
|
||||
const matchedFiles = passthroughFileFilters.flatMap((fileFilter) => {
|
||||
const resolved = resolveFilterMatches(fileFilter);
|
||||
if (resolved.length > 0) {
|
||||
return resolved;
|
||||
}
|
||||
return [normalizeRepoPath(fileFilter)];
|
||||
});
|
||||
return [...new Set(matchedFiles)].map((file) => createPerFileTargetedEntry(file));
|
||||
}
|
||||
const groups = passthroughFileFilters.reduce((acc, fileFilter) => {
|
||||
const matchedFiles = resolveFilterMatches(fileFilter);
|
||||
if (matchedFiles.length === 0) {
|
||||
@@ -738,6 +773,9 @@ const targetedEntries = (() => {
|
||||
return createTargetedEntry(owner, mode === "isolated", [...new Set(filters)]);
|
||||
});
|
||||
})();
|
||||
if (disableIsolation && passthroughFileFilters.length === 0) {
|
||||
runs = allKnownUnitFiles.map((file) => createPerFileTargetedEntry(file));
|
||||
}
|
||||
// Node 25 local runs still show cross-process worker shutdown contention even
|
||||
// after moving the known heavy files into singleton lanes.
|
||||
const topLevelParallelEnabled =
|
||||
@@ -745,8 +783,17 @@ const topLevelParallelEnabled =
|
||||
testProfile !== "serial" &&
|
||||
!(!isCI && nodeMajor >= 25) &&
|
||||
!isMacMiniProfile;
|
||||
const defaultTopLevelParallelLimit =
|
||||
testProfile === "serial"
|
||||
const defaultTopLevelParallelLimit = disableIsolation
|
||||
? isCI
|
||||
? isWindows
|
||||
? 2
|
||||
: 4
|
||||
: highMemLocalHost
|
||||
? Math.min(16, hostCpuCount)
|
||||
: lowMemLocalHost
|
||||
? Math.min(8, hostCpuCount)
|
||||
: Math.min(12, hostCpuCount)
|
||||
: testProfile === "serial"
|
||||
? 1
|
||||
: testProfile === "low"
|
||||
? lowMemLocalHost
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { setTimeout as scheduleNativeTimeout } from "node:timers";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { AcpSessionRuntimeOptions, SessionAcpMeta } from "../../config/sessions/types.js";
|
||||
@@ -148,6 +150,7 @@ function extractRuntimeOptionsFromUpserts(): Array<AcpSessionRuntimeOptions | un
|
||||
|
||||
describe("AcpSessionManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
hoisted.listAcpSessionEntriesMock.mockReset().mockResolvedValue([]);
|
||||
hoisted.readAcpSessionEntryMock.mockReset();
|
||||
hoisted.upsertAcpSessionMetaMock.mockReset().mockResolvedValue(null);
|
||||
@@ -240,7 +243,7 @@ describe("AcpSessionManager", () => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await sleep(10);
|
||||
yield { type: "done" };
|
||||
} finally {
|
||||
inFlight -= 1;
|
||||
@@ -321,7 +324,7 @@ describe("AcpSessionManager", () => {
|
||||
(error) => ({ status: "rejected" as const, error }),
|
||||
),
|
||||
new Promise<{ status: "pending" }>((resolve) => {
|
||||
setTimeout(() => resolve({ status: "pending" }), 100);
|
||||
scheduleNativeTimeout(() => resolve({ status: "pending" }), 100);
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -538,7 +541,7 @@ describe("AcpSessionManager", () => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
await sleep(15);
|
||||
yield { type: "done" as const };
|
||||
} finally {
|
||||
inFlight -= 1;
|
||||
|
||||
@@ -13,8 +13,10 @@ const hoisted = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", () => {
|
||||
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
|
||||
return {
|
||||
...original,
|
||||
streamAnthropic: (model: unknown, context: unknown, options: unknown) =>
|
||||
hoisted.streamAnthropicMock(model, context, options),
|
||||
};
|
||||
|
||||
@@ -29,13 +29,19 @@ const {
|
||||
buildProviderAuthDoctorHintWithPluginMock: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||
getOAuthApiKey: getOAuthApiKeyMock,
|
||||
getOAuthProviders: () => [
|
||||
{ id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret
|
||||
{ id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret
|
||||
],
|
||||
}));
|
||||
vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
|
||||
"@mariozechner/pi-ai/oauth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getOAuthApiKey: getOAuthApiKeyMock,
|
||||
getOAuthProviders: () => [
|
||||
{ id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret
|
||||
{ id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
|
||||
refreshProviderOAuthCredentialWithPlugin: refreshProviderOAuthCredentialWithPluginMock,
|
||||
|
||||
@@ -16,9 +16,13 @@ const resolveSessionAuthProfileOverrideMock = vi.fn();
|
||||
const getActiveEmbeddedRunSnapshotMock = vi.fn();
|
||||
const diagDebugMock = vi.fn();
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", () => ({
|
||||
streamSimple: (...args: unknown[]) => streamSimpleMock(...args),
|
||||
}));
|
||||
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
|
||||
return {
|
||||
...original,
|
||||
streamSimple: (...args: unknown[]) => streamSimpleMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
||||
SessionManager: {
|
||||
|
||||
@@ -192,10 +192,16 @@ export async function loadCompactHooksHarness(): Promise<{
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock("@mariozechner/pi-ai/oauth", () => ({
|
||||
getOAuthApiKey: vi.fn(),
|
||||
getOAuthProviders: vi.fn(() => []),
|
||||
}));
|
||||
vi.doMock("@mariozechner/pi-ai/oauth", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
|
||||
"@mariozechner/pi-ai/oauth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getOAuthApiKey: vi.fn(),
|
||||
getOAuthProviders: vi.fn(() => []),
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock("@mariozechner/pi-coding-agent", () => ({
|
||||
AuthStorage: class AuthStorage {},
|
||||
|
||||
@@ -2,12 +2,16 @@ import type { Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runExtraParamsCase } from "./extra-params.test-support.js";
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", () => ({
|
||||
streamSimple: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
result: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
|
||||
return {
|
||||
...original,
|
||||
streamSimple: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
result: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe("extra-params: Google thinking payload compatibility", () => {
|
||||
it("strips negative thinking budgets and fills Gemini 3.1 thinkingLevel", () => {
|
||||
|
||||
@@ -2,12 +2,16 @@ import type { Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runExtraParamsCase } from "./extra-params.test-support.js";
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", () => ({
|
||||
streamSimple: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
result: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
|
||||
return {
|
||||
...original,
|
||||
streamSimple: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
result: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe("extra-params: xAI tool payload compatibility", () => {
|
||||
it("strips function.strict for xai providers", () => {
|
||||
|
||||
@@ -4,12 +4,16 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { runExtraParamsCase } from "./extra-params.test-support.js";
|
||||
|
||||
// Mock streamSimple for testing
|
||||
vi.mock("@mariozechner/pi-ai", () => ({
|
||||
streamSimple: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
result: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
|
||||
return {
|
||||
...original,
|
||||
streamSimple: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
result: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
type ToolStreamCase = {
|
||||
applyProvider: string;
|
||||
|
||||
@@ -10,10 +10,16 @@ vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||
getOAuthApiKey: () => undefined,
|
||||
getOAuthProviders: () => [],
|
||||
}));
|
||||
vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
|
||||
"@mariozechner/pi-ai/oauth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getOAuthApiKey: () => undefined,
|
||||
getOAuthProviders: () => [],
|
||||
};
|
||||
});
|
||||
|
||||
import { createOpenClawCodingTools } from "./pi-tools.js";
|
||||
|
||||
|
||||
@@ -34,10 +34,16 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||
getOAuthApiKey: () => "",
|
||||
getOAuthProviders: () => [],
|
||||
}));
|
||||
vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
|
||||
"@mariozechner/pi-ai/oauth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getOAuthApiKey: () => "",
|
||||
getOAuthProviders: () => [],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./subagent-registry.js", () => ({
|
||||
countActiveRunsForSession: () => 0,
|
||||
|
||||
@@ -7,9 +7,13 @@ const resolveDefaultModelForAgent = vi.hoisted(() => vi.fn());
|
||||
const resolveModelAsync = vi.hoisted(() => vi.fn());
|
||||
const prepareModelForSimpleCompletion = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", () => ({
|
||||
completeSimple,
|
||||
}));
|
||||
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
|
||||
return {
|
||||
...original,
|
||||
completeSimple,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/model-auth.js", () => ({
|
||||
getApiKeyForModel,
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
|
||||
import { createCanvasHostHandler } from "./server.js";
|
||||
|
||||
describe("canvas host state dir defaults", () => {
|
||||
let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createCanvasHostHandler } = await import("./server.js"));
|
||||
});
|
||||
|
||||
it("uses OPENCLAW_STATE_DIR for the default canvas root", async () => {
|
||||
await withStateDirEnv("openclaw-canvas-state-", async ({ stateDir }) => {
|
||||
const handler = await createCanvasHostHandler({
|
||||
|
||||
@@ -3,20 +3,20 @@ import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
clearTimeout as clearNativeTimeout,
|
||||
setTimeout as scheduleNativeTimeout,
|
||||
} from "node:timers";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH, injectCanvasLiveReload } from "./a2ui.js";
|
||||
import { createCanvasHostHandler, startCanvasHost } from "./server.js";
|
||||
|
||||
const chokidarMockState = vi.hoisted(() => ({
|
||||
watchers: [] as Array<{
|
||||
on: (event: string, cb: (...args: unknown[]) => void) => unknown;
|
||||
close: () => Promise<void>;
|
||||
__emit: (event: string, ...args: unknown[]) => void;
|
||||
}>,
|
||||
}));
|
||||
type MockWatcher = {
|
||||
on: (event: string, cb: (...args: unknown[]) => void) => MockWatcher;
|
||||
close: () => Promise<void>;
|
||||
__emit: (event: string, ...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
const CANVAS_WS_OPEN_TIMEOUT_MS = 2_000;
|
||||
const CANVAS_RELOAD_TIMEOUT_MS = 4_000;
|
||||
@@ -27,11 +27,11 @@ function isLoopbackBindDenied(error: unknown) {
|
||||
return code === "EPERM" || code === "EACCES";
|
||||
}
|
||||
|
||||
// Tests: avoid chokidar polling/fsevents; trigger "all" events manually.
|
||||
vi.mock("chokidar", () => {
|
||||
function createMockWatcherState() {
|
||||
const watchers: MockWatcher[] = [];
|
||||
const createWatcher = () => {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
const api = {
|
||||
const api: MockWatcher = {
|
||||
on: (event: string, cb: (...args: unknown[]) => void) => {
|
||||
const list = handlers.get(event) ?? [];
|
||||
list.push(cb);
|
||||
@@ -45,22 +45,26 @@ vi.mock("chokidar", () => {
|
||||
}
|
||||
},
|
||||
};
|
||||
chokidarMockState.watchers.push(api);
|
||||
watchers.push(api);
|
||||
return api;
|
||||
};
|
||||
|
||||
const watch = () => createWatcher();
|
||||
return {
|
||||
default: { watch },
|
||||
watch,
|
||||
watchers,
|
||||
watchFactory: () => createWatcher(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("canvas host", () => {
|
||||
const quietRuntime = {
|
||||
...defaultRuntime,
|
||||
log: (..._args: Parameters<typeof console.log>) => {},
|
||||
};
|
||||
let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler;
|
||||
let startCanvasHost: typeof import("./server.js").startCanvasHost;
|
||||
let realFetch: typeof import("undici").fetch;
|
||||
let WebSocketClient: typeof import("ws").WebSocket;
|
||||
let WebSocketServerClass: typeof import("ws").WebSocketServer;
|
||||
let watcherState: ReturnType<typeof createMockWatcherState>;
|
||||
let fixtureRoot = "";
|
||||
let fixtureCount = 0;
|
||||
|
||||
@@ -80,19 +84,34 @@ describe("canvas host", () => {
|
||||
port: 0,
|
||||
listenHost: "127.0.0.1",
|
||||
allowInTests: true,
|
||||
watchFactory: watcherState.watchFactory as unknown as Parameters<
|
||||
typeof startCanvasHost
|
||||
>[0]["watchFactory"],
|
||||
webSocketServerClass: WebSocketServerClass,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const fetchCanvasHtml = async (port: number) => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}${CANVAS_HOST_PATH}/`);
|
||||
const res = await realFetch(`http://127.0.0.1:${port}${CANVAS_HOST_PATH}/`);
|
||||
const html = await res.text();
|
||||
return { res, html };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createCanvasHostHandler, startCanvasHost } = await import("./server.js"));
|
||||
const undiciModule = await vi.importActual<typeof import("undici")>("undici");
|
||||
realFetch = undiciModule.fetch;
|
||||
const wsModule = await vi.importActual<typeof import("ws")>("ws");
|
||||
WebSocketClient = wsModule.WebSocket;
|
||||
WebSocketServerClass = wsModule.WebSocketServer;
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-fixtures-"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
watcherState = createMockWatcherState();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
@@ -147,7 +166,7 @@ describe("canvas host", () => {
|
||||
expect(html).toContain("no-reload");
|
||||
expect(html).not.toContain(CANVAS_WS_PATH);
|
||||
|
||||
const wsRes = await fetch(`http://127.0.0.1:${server.port}${CANVAS_WS_PATH}`);
|
||||
const wsRes = await realFetch(`http://127.0.0.1:${server.port}${CANVAS_WS_PATH}`);
|
||||
expect(wsRes.status).toBe(404);
|
||||
} finally {
|
||||
await server.close();
|
||||
@@ -163,6 +182,10 @@ describe("canvas host", () => {
|
||||
rootDir: dir,
|
||||
basePath: CANVAS_HOST_PATH,
|
||||
allowInTests: true,
|
||||
watchFactory: watcherState.watchFactory as unknown as Parameters<
|
||||
typeof createCanvasHostHandler
|
||||
>[0]["watchFactory"],
|
||||
webSocketServerClass: WebSocketServerClass,
|
||||
});
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
@@ -205,13 +228,13 @@ describe("canvas host", () => {
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}${CANVAS_HOST_PATH}/`);
|
||||
const res = await realFetch(`http://127.0.0.1:${port}${CANVAS_HOST_PATH}/`);
|
||||
const html = await res.text();
|
||||
expect(res.status).toBe(200);
|
||||
expect(html).toContain("v1");
|
||||
expect(html).toContain(CANVAS_WS_PATH);
|
||||
|
||||
const miss = await fetch(`http://127.0.0.1:${port}/`);
|
||||
const miss = await realFetch(`http://127.0.0.1:${port}/`);
|
||||
expect(miss.status).toBe(404);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
@@ -247,7 +270,7 @@ describe("canvas host", () => {
|
||||
const index = path.join(dir, "index.html");
|
||||
await fs.writeFile(index, "<html><body>v1</body></html>", "utf8");
|
||||
|
||||
const watcherStart = chokidarMockState.watchers.length;
|
||||
const watcherStart = watcherState.watchers.length;
|
||||
let server: Awaited<ReturnType<typeof startFixtureCanvasHost>>;
|
||||
try {
|
||||
server = await startFixtureCanvasHost(dir);
|
||||
@@ -259,7 +282,7 @@ describe("canvas host", () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const watcher = chokidarMockState.watchers[watcherStart];
|
||||
const watcher = watcherState.watchers[watcherStart];
|
||||
expect(watcher).toBeTruthy();
|
||||
|
||||
const { res, html } = await fetchCanvasHtml(server.port);
|
||||
@@ -267,29 +290,29 @@ describe("canvas host", () => {
|
||||
expect(html).toContain("v1");
|
||||
expect(html).toContain(CANVAS_WS_PATH);
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.port}${CANVAS_WS_PATH}`);
|
||||
const ws = new WebSocketClient(`ws://127.0.0.1:${server.port}${CANVAS_WS_PATH}`);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
const timer = scheduleNativeTimeout(
|
||||
() => reject(new Error("ws open timeout")),
|
||||
CANVAS_WS_OPEN_TIMEOUT_MS,
|
||||
);
|
||||
ws.on("open", () => {
|
||||
clearTimeout(timer);
|
||||
clearNativeTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
ws.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
clearNativeTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
const msg = new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
const timer = scheduleNativeTimeout(
|
||||
() => reject(new Error("reload timeout")),
|
||||
CANVAS_RELOAD_TIMEOUT_MS,
|
||||
);
|
||||
ws.on("message", (data) => {
|
||||
clearTimeout(timer);
|
||||
clearNativeTimeout(timer);
|
||||
resolve(rawDataToString(data));
|
||||
});
|
||||
});
|
||||
@@ -297,7 +320,7 @@ describe("canvas host", () => {
|
||||
await fs.writeFile(index, "<html><body>v2</body></html>", "utf8");
|
||||
watcher.__emit("all", "change", index);
|
||||
expect(await msg).toBe("reload");
|
||||
ws.close();
|
||||
ws.terminate();
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
@@ -335,24 +358,24 @@ describe("canvas host", () => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${server.port}/__openclaw__/a2ui/`);
|
||||
const res = await realFetch(`http://127.0.0.1:${server.port}/__openclaw__/a2ui/`);
|
||||
const html = await res.text();
|
||||
expect(res.status).toBe(200);
|
||||
expect(html).toContain("openclaw-a2ui-host");
|
||||
expect(html).toContain("openclawCanvasA2UIAction");
|
||||
|
||||
const bundleRes = await fetch(
|
||||
const bundleRes = await realFetch(
|
||||
`http://127.0.0.1:${server.port}/__openclaw__/a2ui/a2ui.bundle.js`,
|
||||
);
|
||||
const js = await bundleRes.text();
|
||||
expect(bundleRes.status).toBe(200);
|
||||
expect(js).toContain("openclawA2UI");
|
||||
const traversalRes = await fetch(
|
||||
const traversalRes = await realFetch(
|
||||
`http://127.0.0.1:${server.port}${A2UI_PATH}/%2e%2e%2fpackage.json`,
|
||||
);
|
||||
expect(traversalRes.status).toBe(404);
|
||||
expect(await traversalRes.text()).toBe("not found");
|
||||
const symlinkRes = await fetch(`http://127.0.0.1:${server.port}${A2UI_PATH}/${linkName}`);
|
||||
const symlinkRes = await realFetch(`http://127.0.0.1:${server.port}${A2UI_PATH}/${linkName}`);
|
||||
expect(symlinkRes.status).toBe(404);
|
||||
expect(await symlinkRes.text()).toBe("not found");
|
||||
} finally {
|
||||
|
||||
@@ -4,6 +4,10 @@ import http, { type IncomingMessage, type Server, type ServerResponse } from "no
|
||||
import type { Socket } from "node:net";
|
||||
import path from "node:path";
|
||||
import type { Duplex } from "node:stream";
|
||||
import {
|
||||
clearTimeout as clearNativeTimeout,
|
||||
setTimeout as scheduleNativeTimeout,
|
||||
} from "node:timers";
|
||||
import chokidar from "chokidar";
|
||||
import { type WebSocket, WebSocketServer } from "ws";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
@@ -26,6 +30,8 @@ export type CanvasHostOpts = {
|
||||
listenHost?: string;
|
||||
allowInTests?: boolean;
|
||||
liveReload?: boolean;
|
||||
watchFactory?: typeof chokidar.watch;
|
||||
webSocketServerClass?: typeof WebSocketServer;
|
||||
};
|
||||
|
||||
export type CanvasHostServerOpts = CanvasHostOpts & {
|
||||
@@ -45,6 +51,8 @@ export type CanvasHostHandlerOpts = {
|
||||
basePath?: string;
|
||||
allowInTests?: boolean;
|
||||
liveReload?: boolean;
|
||||
watchFactory?: typeof chokidar.watch;
|
||||
webSocketServerClass?: typeof WebSocketServer;
|
||||
};
|
||||
|
||||
export type CanvasHostHandler = {
|
||||
@@ -224,7 +232,8 @@ export async function createCanvasHostHandler(
|
||||
const reloadDebounceMs = testMode ? 12 : 75;
|
||||
const writeStabilityThresholdMs = testMode ? 12 : 75;
|
||||
const writePollIntervalMs = testMode ? 5 : 10;
|
||||
const wss = liveReload ? new WebSocketServer({ noServer: true }) : null;
|
||||
const WebSocketServerClass = opts.webSocketServerClass ?? WebSocketServer;
|
||||
const wss = liveReload ? new WebSocketServerClass({ noServer: true }) : null;
|
||||
const sockets = new Set<WebSocket>();
|
||||
if (wss) {
|
||||
wss.on("connection", (ws) => {
|
||||
@@ -248,9 +257,9 @@ export async function createCanvasHostHandler(
|
||||
};
|
||||
const scheduleReload = () => {
|
||||
if (debounce) {
|
||||
clearTimeout(debounce);
|
||||
clearNativeTimeout(debounce);
|
||||
}
|
||||
debounce = setTimeout(() => {
|
||||
debounce = scheduleNativeTimeout(() => {
|
||||
debounce = null;
|
||||
broadcastReload();
|
||||
}, reloadDebounceMs);
|
||||
@@ -258,8 +267,9 @@ export async function createCanvasHostHandler(
|
||||
};
|
||||
|
||||
let watcherClosed = false;
|
||||
const watchFactory = opts.watchFactory ?? chokidar.watch.bind(chokidar);
|
||||
const watcher = liveReload
|
||||
? chokidar.watch(rootReal, {
|
||||
? watchFactory(rootReal, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: writeStabilityThresholdMs,
|
||||
@@ -385,10 +395,17 @@ export async function createCanvasHostHandler(
|
||||
handleUpgrade,
|
||||
close: async () => {
|
||||
if (debounce) {
|
||||
clearTimeout(debounce);
|
||||
clearNativeTimeout(debounce);
|
||||
}
|
||||
watcherClosed = true;
|
||||
await watcher?.close().catch(() => {});
|
||||
for (const ws of sockets) {
|
||||
try {
|
||||
ws.terminate?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (wss) {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
}
|
||||
@@ -409,6 +426,8 @@ export async function startCanvasHost(opts: CanvasHostServerOpts): Promise<Canva
|
||||
basePath: CANVAS_HOST_PATH,
|
||||
allowInTests: opts.allowInTests,
|
||||
liveReload: opts.liveReload,
|
||||
watchFactory: opts.watchFactory,
|
||||
webSocketServerClass: opts.webSocketServerClass,
|
||||
}));
|
||||
const ownsHandler = opts.ownsHandler ?? opts.handler === undefined;
|
||||
|
||||
|
||||
@@ -1,26 +1,54 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, 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";
|
||||
import {
|
||||
cameraTempPath,
|
||||
parseCameraClipPayload,
|
||||
parseCameraSnapPayload,
|
||||
writeCameraClipPayloadToFile,
|
||||
writeBase64ToFile,
|
||||
writeUrlToFile,
|
||||
} from "./nodes-camera.js";
|
||||
import { parseScreenRecordPayload, screenRecordTempPath } from "./nodes-screen.js";
|
||||
|
||||
const fetchGuardMocks = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuard: vi.fn(async (params: { url: string }) => {
|
||||
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 screenRecordTempPath: typeof import("./nodes-screen.js").screenRecordTempPath;
|
||||
|
||||
async function withCameraTempDir<T>(run: (dir: string) => Promise<T>): Promise<T> {
|
||||
return await withTempDir("openclaw-test-", run);
|
||||
}
|
||||
|
||||
describe("nodes camera helpers", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
({
|
||||
cameraTempPath,
|
||||
parseCameraClipPayload,
|
||||
parseCameraSnapPayload,
|
||||
writeCameraClipPayloadToFile,
|
||||
writeBase64ToFile,
|
||||
writeUrlToFile,
|
||||
} = await import("./nodes-camera.js"));
|
||||
({ parseScreenRecordPayload, screenRecordTempPath } = await import("./nodes-screen.js"));
|
||||
});
|
||||
|
||||
it("parses camera.snap payload", () => {
|
||||
expect(
|
||||
parseCameraSnapPayload({
|
||||
|
||||
@@ -35,8 +35,8 @@ vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime: runtime,
|
||||
}));
|
||||
|
||||
const { registerQrCli } = await import("./qr-cli.js");
|
||||
const { registerMaintenanceCommands } = await import("./program/register.maintenance.js");
|
||||
let registerQrCli: typeof import("./qr-cli.js").registerQrCli;
|
||||
let registerMaintenanceCommands: typeof import("./program/register.maintenance.js").registerMaintenanceCommands;
|
||||
|
||||
function createGatewayTokenRefFixture() {
|
||||
return {
|
||||
@@ -100,6 +100,12 @@ describe("cli integration: qr + dashboard token SecretRef", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
({ registerQrCli } = await import("./qr-cli.js"));
|
||||
({ registerMaintenanceCommands } = await import("./program/register.maintenance.js"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
envSnapshot.restore();
|
||||
});
|
||||
|
||||
@@ -8,9 +8,15 @@ const mocks = vi.hoisted(() => ({
|
||||
formatOpenAIOAuthTlsPreflightFix: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||
loginOpenAICodex: mocks.loginOpenAICodex,
|
||||
}));
|
||||
vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
|
||||
"@mariozechner/pi-ai/oauth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loginOpenAICodex: mocks.loginOpenAICodex,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/provider-openai-codex-oauth-tls.js", () => ({
|
||||
runOpenAIOAuthTlsPreflight: mocks.runOpenAIOAuthTlsPreflight,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "./home-env.test-harness.js";
|
||||
import { createConfigIO } from "./io.js";
|
||||
|
||||
@@ -14,12 +15,16 @@ async function waitForPersistedSecret(configPath: string, expectedSecret: string
|
||||
if (parsed.commands?.ownerDisplaySecret === expectedSecret) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await sleep(5);
|
||||
}
|
||||
throw new Error("timed out waiting for ownerDisplaySecret persistence");
|
||||
}
|
||||
|
||||
describe("config io owner display secret autofill", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("auto-generates and persists commands.ownerDisplaySecret in hash mode", async () => {
|
||||
await withTempHome("openclaw-owner-display-secret-", async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { setTimeout as scheduleNativeTimeout } from "node:timers";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createCronStoreHarness } from "./service.test-harness.js";
|
||||
import { loadCronStore, resolveCronStorePath, saveCronStore } from "./store.js";
|
||||
import type { CronStoreFile } from "./types.js";
|
||||
@@ -149,6 +150,10 @@ describe("cron store", () => {
|
||||
describe("saveCronStore", () => {
|
||||
const dummyStore: CronStoreFile = { version: 1, jobs: [] };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("persists and round-trips a store file", async () => {
|
||||
const { storePath } = await makeStorePath();
|
||||
await saveCronStore(storePath, dummyStore);
|
||||
@@ -158,11 +163,10 @@ describe("saveCronStore", () => {
|
||||
|
||||
it("retries rename on EBUSY then succeeds", async () => {
|
||||
const { storePath } = await makeStorePath();
|
||||
const realSetTimeout = globalThis.setTimeout;
|
||||
const setTimeoutSpy = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockImplementation(((handler: TimerHandler, _timeout?: number, ...args: unknown[]) =>
|
||||
realSetTimeout(handler, 0, ...args)) as typeof setTimeout);
|
||||
scheduleNativeTimeout(handler, 0, ...args)) as typeof setTimeout);
|
||||
const origRename = fs.rename.bind(fs);
|
||||
let ebusyCount = 0;
|
||||
const spy = vi.spyOn(fs, "rename").mockImplementation(async (src, dest) => {
|
||||
|
||||
@@ -435,17 +435,21 @@ describe("tilde expansion in file tools", () => {
|
||||
it("keeps tilde expansion behavior aligned", async () => {
|
||||
const { expandHomePrefix } = await import("./home-dir.js");
|
||||
const originalHome = process.env.HOME;
|
||||
const originalOpenClawHome = process.env.OPENCLAW_HOME;
|
||||
const fakeHome = path.resolve(path.sep, "tmp", "fake-home-test");
|
||||
process.env.HOME = fakeHome;
|
||||
process.env.OPENCLAW_HOME = fakeHome;
|
||||
try {
|
||||
const result = expandHomePrefix("~/file.txt");
|
||||
expect(path.normalize(result)).toBe(path.join(fakeHome, "file.txt"));
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
process.env.OPENCLAW_HOME = originalOpenClawHome;
|
||||
}
|
||||
|
||||
const root = await tempDirs.make("openclaw-tilde-test-");
|
||||
process.env.HOME = root;
|
||||
process.env.OPENCLAW_HOME = root;
|
||||
try {
|
||||
await fs.writeFile(path.join(root, "hello.txt"), "tilde-works");
|
||||
const result = await openFileWithinRoot({
|
||||
@@ -466,6 +470,7 @@ describe("tilde expansion in file tools", () => {
|
||||
expect(content).toBe("tilde-write-works");
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
process.env.OPENCLAW_HOME = originalOpenClawHome;
|
||||
}
|
||||
|
||||
const outsideRoot = await tempDirs.make("openclaw-tilde-outside-");
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { buildProviderRegistry, runCapability } from "./runner.js";
|
||||
import { withAudioFixture, withVideoFixture } from "./runner.test-utils.js";
|
||||
import type { AudioTranscriptionRequest, VideoDescriptionRequest } from "./types.js";
|
||||
|
||||
const proxyFetchMocks = vi.hoisted(() => {
|
||||
const proxyFetch = vi.fn() as unknown as typeof fetch;
|
||||
const resolveProxyFetchFromEnv = vi.fn((env: NodeJS.ProcessEnv = process.env) => {
|
||||
const hasProxy = Boolean(
|
||||
env.https_proxy?.trim() ||
|
||||
env.HTTPS_PROXY?.trim() ||
|
||||
env.http_proxy?.trim() ||
|
||||
env.HTTP_PROXY?.trim(),
|
||||
);
|
||||
return hasProxy ? proxyFetch : undefined;
|
||||
});
|
||||
return { proxyFetch, resolveProxyFetchFromEnv };
|
||||
});
|
||||
|
||||
vi.mock("../infra/net/proxy-fetch.js", () => ({
|
||||
resolveProxyFetchFromEnv: proxyFetchMocks.resolveProxyFetchFromEnv,
|
||||
}));
|
||||
|
||||
let buildProviderRegistry: typeof import("./runner.js").buildProviderRegistry;
|
||||
let runCapability: typeof import("./runner.js").runCapability;
|
||||
|
||||
async function runAudioCapabilityWithFetchCapture(params: {
|
||||
fixturePrefix: string;
|
||||
outputText: string;
|
||||
@@ -55,7 +75,12 @@ async function runAudioCapabilityWithFetchCapture(params: {
|
||||
}
|
||||
|
||||
describe("runCapability proxy fetch passthrough", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
({ buildProviderRegistry, runCapability } = await import("./runner.js"));
|
||||
});
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it("passes fetchFn to audio provider when HTTPS_PROXY is set", async () => {
|
||||
@@ -64,8 +89,7 @@ describe("runCapability proxy fetch passthrough", () => {
|
||||
fixturePrefix: "openclaw-audio-proxy",
|
||||
outputText: "transcribed",
|
||||
});
|
||||
expect(seenFetchFn).toBeDefined();
|
||||
expect(seenFetchFn).not.toBe(globalThis.fetch);
|
||||
expect(seenFetchFn).toBe(proxyFetchMocks.proxyFetch);
|
||||
});
|
||||
|
||||
it("passes fetchFn to video provider when HTTPS_PROXY is set", async () => {
|
||||
@@ -113,8 +137,7 @@ describe("runCapability proxy fetch passthrough", () => {
|
||||
});
|
||||
|
||||
expect(result.outputs[0]?.text).toBe("video ok");
|
||||
expect(seenFetchFn).toBeDefined();
|
||||
expect(seenFetchFn).not.toBe(globalThis.fetch);
|
||||
expect(seenFetchFn).toBe(proxyFetchMocks.proxyFetch);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fetchRemoteMedia } from "./fetch.js";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../infra/net/fetch-guard.js", () => ({
|
||||
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
|
||||
withStrictGuardedFetchMode: <T>(params: T) => params,
|
||||
}));
|
||||
|
||||
type FetchRemoteMedia = typeof import("./fetch.js").fetchRemoteMedia;
|
||||
type LookupFn = NonNullable<Parameters<FetchRemoteMedia>[0]["lookupFn"]>;
|
||||
let fetchRemoteMedia: FetchRemoteMedia;
|
||||
|
||||
function makeStream(chunks: Uint8Array[]) {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
@@ -25,10 +35,8 @@ function makeStallingFetch(firstChunk: Uint8Array) {
|
||||
});
|
||||
}
|
||||
|
||||
function makeLookupFn() {
|
||||
return vi.fn(async () => [{ address: "149.154.167.220", family: 4 }]) as unknown as NonNullable<
|
||||
Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]
|
||||
>;
|
||||
function makeLookupFn(): LookupFn {
|
||||
return vi.fn(async () => ({ address: "149.154.167.220", family: 4 })) as unknown as LookupFn;
|
||||
}
|
||||
|
||||
async function expectRedactedTelegramFetchError(params: {
|
||||
@@ -59,10 +67,38 @@ describe("fetchRemoteMedia", () => {
|
||||
const redactedTelegramToken = `${telegramToken.slice(0, 6)}…${telegramToken.slice(-4)}`;
|
||||
const telegramFileUrl = `https://api.telegram.org/file/bot${telegramToken}/photos/1.jpg`;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ fetchRemoteMedia } = await import("./fetch.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
fetchWithSsrFGuardMock.mockReset().mockImplementation(async (paramsUnknown: unknown) => {
|
||||
const params = paramsUnknown as {
|
||||
url: string;
|
||||
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
init?: RequestInit;
|
||||
};
|
||||
if (params.url.startsWith("http://127.0.0.1/")) {
|
||||
throw new Error("Blocked hostname or private/internal/special-use IP address");
|
||||
}
|
||||
const fetcher = params.fetchImpl ?? globalThis.fetch;
|
||||
if (!fetcher) {
|
||||
throw new Error("fetch is not available");
|
||||
}
|
||||
return {
|
||||
response: await fetcher(params.url, params.init),
|
||||
finalUrl: params.url,
|
||||
release: async () => {},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects when content-length exceeds maxBytes", async () => {
|
||||
const lookupFn = vi.fn(async () => [
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
]) as unknown as NonNullable<Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]>;
|
||||
const lookupFn = vi.fn(async () => ({
|
||||
address: "93.184.216.34",
|
||||
family: 4,
|
||||
})) as unknown as LookupFn;
|
||||
const fetchImpl = async () =>
|
||||
new Response(makeStream([new Uint8Array([1, 2, 3, 4, 5])]), {
|
||||
status: 200,
|
||||
@@ -80,9 +116,10 @@ describe("fetchRemoteMedia", () => {
|
||||
});
|
||||
|
||||
it("rejects when streamed payload exceeds maxBytes", async () => {
|
||||
const lookupFn = vi.fn(async () => [
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
]) as unknown as NonNullable<Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]>;
|
||||
const lookupFn = vi.fn(async () => ({
|
||||
address: "93.184.216.34",
|
||||
family: 4,
|
||||
})) as unknown as LookupFn;
|
||||
const fetchImpl = async () =>
|
||||
new Response(makeStream([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])]), {
|
||||
status: 200,
|
||||
@@ -99,23 +136,30 @@ describe("fetchRemoteMedia", () => {
|
||||
});
|
||||
|
||||
it("aborts stalled body reads when idle timeout expires", async () => {
|
||||
const lookupFn = vi.fn(async () => [
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
]) as unknown as NonNullable<Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]>;
|
||||
const fetchImpl = makeStallingFetch(new Uint8Array([1, 2]));
|
||||
|
||||
await expect(
|
||||
fetchRemoteMedia({
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const lookupFn = vi.fn(async () => ({
|
||||
address: "93.184.216.34",
|
||||
family: 4,
|
||||
})) as unknown as LookupFn;
|
||||
const fetchImpl = makeStallingFetch(new Uint8Array([1, 2]));
|
||||
const fetchPromise = fetchRemoteMedia({
|
||||
url: "https://example.com/file.bin",
|
||||
fetchImpl,
|
||||
lookupFn,
|
||||
maxBytes: 1024,
|
||||
readIdleTimeoutMs: 20,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "fetch_failed",
|
||||
name: "MediaFetchError",
|
||||
});
|
||||
});
|
||||
const rejection = expect(fetchPromise).rejects.toMatchObject({
|
||||
code: "fetch_failed",
|
||||
name: "MediaFetchError",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
await rejection;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
}, 5_000);
|
||||
|
||||
it("redacts Telegram bot tokens from fetch failure messages", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { readResponseWithLimit } from "./read-response-with-limit.js";
|
||||
|
||||
function makeStream(chunks: Uint8Array[], delayMs?: number) {
|
||||
@@ -26,6 +26,10 @@ function makeStallingStream(initialChunks: Uint8Array[]) {
|
||||
}
|
||||
|
||||
describe("readResponseWithLimit", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("reads all chunks within the limit", async () => {
|
||||
const body = makeStream([new Uint8Array([1, 2]), new Uint8Array([3, 4])]);
|
||||
const res = new Response(body);
|
||||
@@ -50,17 +54,30 @@ describe("readResponseWithLimit", () => {
|
||||
});
|
||||
|
||||
it("times out when no new chunk arrives before idle timeout", async () => {
|
||||
const body = makeStallingStream([new Uint8Array([1, 2])]);
|
||||
const res = new Response(body);
|
||||
await expect(readResponseWithLimit(res, 1024, { chunkTimeoutMs: 50 })).rejects.toThrow(
|
||||
/stalled/i,
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const body = makeStallingStream([new Uint8Array([1, 2])]);
|
||||
const res = new Response(body);
|
||||
const readPromise = readResponseWithLimit(res, 1024, { chunkTimeoutMs: 50 });
|
||||
const rejection = expect(readPromise).rejects.toThrow(/stalled/i);
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
await rejection;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
}, 5_000);
|
||||
|
||||
it("does not time out while chunks keep arriving", async () => {
|
||||
const body = makeStream([new Uint8Array([1]), new Uint8Array([2])], 10);
|
||||
const res = new Response(body);
|
||||
const buf = await readResponseWithLimit(res, 100, { chunkTimeoutMs: 500 });
|
||||
expect(buf).toEqual(Buffer.from([1, 2]));
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const body = makeStream([new Uint8Array([1]), new Uint8Array([2])], 10);
|
||||
const res = new Response(body);
|
||||
const readPromise = readResponseWithLimit(res, 100, { chunkTimeoutMs: 500 });
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
const buf = await readPromise;
|
||||
expect(buf).toEqual(Buffer.from([1, 2]));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GeminiEmbeddingClient } from "./embeddings-gemini.js";
|
||||
|
||||
vi.mock("./remote-http.js", () => ({
|
||||
withRemoteHttpResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
function magnitude(values: number[]) {
|
||||
return Math.sqrt(values.reduce((sum, value) => sum + value * value, 0));
|
||||
}
|
||||
|
||||
describe("runGeminiEmbeddingBatches", () => {
|
||||
let runGeminiEmbeddingBatches: typeof import("./batch-gemini.js").runGeminiEmbeddingBatches;
|
||||
let withRemoteHttpResponse: typeof import("./remote-http.js").withRemoteHttpResponse;
|
||||
let remoteHttpMock: ReturnType<typeof vi.mocked<typeof withRemoteHttpResponse>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
({ runGeminiEmbeddingBatches } = await import("./batch-gemini.js"));
|
||||
({ withRemoteHttpResponse } = await import("./remote-http.js"));
|
||||
remoteHttpMock = vi.mocked(withRemoteHttpResponse);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -27,24 +37,26 @@ describe("runGeminiEmbeddingBatches", () => {
|
||||
};
|
||||
|
||||
it("includes outputDimensionality in batch upload requests", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/upload/v1beta/files?uploadType=multipart")) {
|
||||
const body = init?.body;
|
||||
if (!(body instanceof Blob)) {
|
||||
throw new Error("expected multipart blob body");
|
||||
}
|
||||
const text = await body.text();
|
||||
expect(text).toContain('"taskType":"RETRIEVAL_DOCUMENT"');
|
||||
expect(text).toContain('"outputDimensionality":1536');
|
||||
return new Response(JSON.stringify({ name: "files/file-123" }), {
|
||||
remoteHttpMock.mockImplementationOnce(async (params) => {
|
||||
expect(params.url).toContain("/upload/v1beta/files?uploadType=multipart");
|
||||
const body = params.init?.body;
|
||||
if (!(body instanceof Blob)) {
|
||||
throw new Error("expected multipart blob body");
|
||||
}
|
||||
const text = await body.text();
|
||||
expect(text).toContain('"taskType":"RETRIEVAL_DOCUMENT"');
|
||||
expect(text).toContain('"outputDimensionality":1536');
|
||||
return await params.onResponse(
|
||||
new Response(JSON.stringify({ name: "files/file-123" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.endsWith(":asyncBatchEmbedContent")) {
|
||||
return new Response(
|
||||
}),
|
||||
);
|
||||
});
|
||||
remoteHttpMock.mockImplementationOnce(async (params) => {
|
||||
expect(params.url).toMatch(/:asyncBatchEmbedContent$/u);
|
||||
return await params.onResponse(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
name: "batches/batch-1",
|
||||
state: "COMPLETED",
|
||||
@@ -54,10 +66,13 @@ describe("runGeminiEmbeddingBatches", () => {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/files/output-1:download")) {
|
||||
return new Response(
|
||||
),
|
||||
);
|
||||
});
|
||||
remoteHttpMock.mockImplementationOnce(async (params) => {
|
||||
expect(params.url).toMatch(/\/files\/output-1:download$/u);
|
||||
return await params.onResponse(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
key: "req-1",
|
||||
response: { embedding: { values: [3, 4] } },
|
||||
@@ -66,13 +81,10 @@ describe("runGeminiEmbeddingBatches", () => {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/jsonl" },
|
||||
},
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const results = await runGeminiEmbeddingBatches({
|
||||
gemini: mockClient,
|
||||
agentId: "main",
|
||||
@@ -97,6 +109,6 @@ describe("runGeminiEmbeddingBatches", () => {
|
||||
expect(embedding?.[0]).toBeCloseTo(0.6, 5);
|
||||
expect(embedding?.[1]).toBeCloseTo(0.8, 5);
|
||||
expect(magnitude(embedding ?? [])).toBeCloseTo(1, 5);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(remoteHttpMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ReadableStream } from "node:stream/web";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { VoyageBatchOutputLine, VoyageBatchRequest } from "./batch-voyage.js";
|
||||
import type { VoyageEmbeddingClient } from "./embeddings-voyage.js";
|
||||
import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js";
|
||||
|
||||
// Mock internal.js if needed, but runWithConcurrency is simple enough to keep real.
|
||||
// We DO need to mock retryAsync to avoid actual delays/retries logic complicating tests
|
||||
@@ -10,11 +9,21 @@ vi.mock("../infra/retry.js", () => ({
|
||||
retryAsync: async <T>(fn: () => Promise<T>) => fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./remote-http.js", () => ({
|
||||
withRemoteHttpResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("runVoyageEmbeddingBatches", () => {
|
||||
let runVoyageEmbeddingBatches: typeof import("./batch-voyage.js").runVoyageEmbeddingBatches;
|
||||
let withRemoteHttpResponse: typeof import("./remote-http.js").withRemoteHttpResponse;
|
||||
let remoteHttpMock: ReturnType<typeof vi.mocked<typeof withRemoteHttpResponse>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
({ runVoyageEmbeddingBatches } = await import("./batch-voyage.js"));
|
||||
({ withRemoteHttpResponse } = await import("./remote-http.js"));
|
||||
remoteHttpMock = vi.mocked(withRemoteHttpResponse);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -34,39 +43,6 @@ describe("runVoyageEmbeddingBatches", () => {
|
||||
];
|
||||
|
||||
it("successfully submits batch, waits, and streams results", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
|
||||
// Sequence of fetch calls:
|
||||
// 1. Upload file
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: "file-123" }),
|
||||
});
|
||||
|
||||
// 2. Create batch
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: "batch-abc", status: "pending" }),
|
||||
});
|
||||
|
||||
// 3. Poll status (pending) - Optional depending on wait loop, let's say it finishes immediately for this test
|
||||
// Actually the code does: initial check (if completed) -> wait loop.
|
||||
// If create returns "pending", it enters waitForVoyageBatch.
|
||||
// waitForVoyageBatch fetches status.
|
||||
|
||||
// 3. Poll status (completed)
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: "batch-abc",
|
||||
status: "completed",
|
||||
output_file_id: "file-out-999",
|
||||
}),
|
||||
});
|
||||
|
||||
// 4. Download content (Streaming)
|
||||
const outputLines: VoyageBatchOutputLine[] = [
|
||||
{
|
||||
custom_id: "req-1",
|
||||
@@ -86,10 +62,64 @@ describe("runVoyageEmbeddingBatches", () => {
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
body: stream,
|
||||
remoteHttpMock.mockImplementationOnce(async (params) => {
|
||||
expect(params.url).toContain("/files");
|
||||
const uploadBody = params.init?.body;
|
||||
expect(uploadBody).toBeInstanceOf(FormData);
|
||||
expect((uploadBody as FormData).get("purpose")).toBe("batch");
|
||||
return await params.onResponse(
|
||||
new Response(JSON.stringify({ id: "file-123" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
remoteHttpMock.mockImplementationOnce(async (params) => {
|
||||
expect(params.url).toContain("/batches");
|
||||
const body = params.init?.body;
|
||||
expect(typeof body).toBe("string");
|
||||
const createBody = JSON.parse(body as string) as {
|
||||
input_file_id: string;
|
||||
completion_window: string;
|
||||
request_params: { model: string; input_type: string };
|
||||
};
|
||||
expect(createBody.input_file_id).toBe("file-123");
|
||||
expect(createBody.completion_window).toBe("12h");
|
||||
expect(createBody.request_params).toEqual({
|
||||
model: "voyage-4-large",
|
||||
input_type: "document",
|
||||
});
|
||||
return await params.onResponse(
|
||||
new Response(JSON.stringify({ id: "batch-abc", status: "pending" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
remoteHttpMock.mockImplementationOnce(async (params) => {
|
||||
expect(params.url).toContain("/batches/batch-abc");
|
||||
return await params.onResponse(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: "batch-abc",
|
||||
status: "completed",
|
||||
output_file_id: "file-out-999",
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
remoteHttpMock.mockImplementationOnce(async (params) => {
|
||||
expect(params.url).toContain("/files/file-out-999/content");
|
||||
return await params.onResponse(
|
||||
new Response(stream as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/x-ndjson" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const results = await runVoyageEmbeddingBatches({
|
||||
@@ -105,43 +135,10 @@ describe("runVoyageEmbeddingBatches", () => {
|
||||
expect(results.size).toBe(2);
|
||||
expect(results.get("req-1")).toEqual([0.1, 0.1]);
|
||||
expect(results.get("req-2")).toEqual([0.2, 0.2]);
|
||||
|
||||
// Verify calls
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
|
||||
// Verify File Upload
|
||||
expect(fetchMock.mock.calls[0][0]).toContain("/files");
|
||||
const uploadBody = fetchMock.mock.calls[0][1].body as FormData;
|
||||
expect(uploadBody).toBeInstanceOf(FormData);
|
||||
expect(uploadBody.get("purpose")).toBe("batch");
|
||||
|
||||
// Verify Batch Create
|
||||
expect(fetchMock.mock.calls[1][0]).toContain("/batches");
|
||||
const createBody = JSON.parse(fetchMock.mock.calls[1][1].body);
|
||||
expect(createBody.input_file_id).toBe("file-123");
|
||||
expect(createBody.completion_window).toBe("12h");
|
||||
expect(createBody.request_params).toEqual({
|
||||
model: "voyage-4-large",
|
||||
input_type: "document",
|
||||
});
|
||||
|
||||
// Verify Content Fetch
|
||||
expect(fetchMock.mock.calls[3][0]).toContain("/files/file-out-999/content");
|
||||
expect(remoteHttpMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("handles empty lines and stream chunks correctly", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockPublicPinnedHostname();
|
||||
|
||||
// 1. Upload
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "f1" }) });
|
||||
// 2. Create (completed immediately)
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: "b1", status: "completed", output_file_id: "out1" }),
|
||||
});
|
||||
// 3. Download Content (Streaming with chunks and newlines)
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const line1 = JSON.stringify({
|
||||
@@ -160,8 +157,22 @@ describe("runVoyageEmbeddingBatches", () => {
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, body: stream });
|
||||
remoteHttpMock.mockImplementationOnce(async (params) => {
|
||||
expect(params.url).toContain("/files");
|
||||
return await params.onResponse(new Response(JSON.stringify({ id: "f1" }), { status: 200 }));
|
||||
});
|
||||
remoteHttpMock.mockImplementationOnce(async (params) => {
|
||||
expect(params.url).toContain("/batches");
|
||||
return await params.onResponse(
|
||||
new Response(JSON.stringify({ id: "b1", status: "completed", output_file_id: "out1" }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
});
|
||||
remoteHttpMock.mockImplementationOnce(async (params) => {
|
||||
expect(params.url).toContain("/files/out1/content");
|
||||
return await params.onResponse(new Response(stream as unknown as BodyInit, { status: 200 }));
|
||||
});
|
||||
|
||||
const results = await runVoyageEmbeddingBatches({
|
||||
client: mockClient,
|
||||
|
||||
@@ -34,11 +34,29 @@ vi.mock("../media/mime.js", () => ({
|
||||
detectMime: vi.fn(async () => "image/png"),
|
||||
}));
|
||||
|
||||
import { runBrowserProxyCommand } from "./invoke-browser.js";
|
||||
let runBrowserProxyCommand: typeof import("./invoke-browser.js").runBrowserProxyCommand;
|
||||
|
||||
describe("runBrowserProxyCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
beforeEach(async () => {
|
||||
// No-isolate runs can reuse a cached invoke-browser module that was loaded
|
||||
// via node-host entrypoints before this file's mocks were declared.
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
dispatcherMocks.dispatch.mockReset();
|
||||
dispatcherMocks.createBrowserRouteDispatcher.mockReset().mockImplementation(() => ({
|
||||
dispatch: dispatcherMocks.dispatch,
|
||||
}));
|
||||
controlServiceMocks.createBrowserControlContext.mockReset().mockReturnValue({ control: true });
|
||||
controlServiceMocks.startBrowserControlServiceFromConfig.mockReset().mockResolvedValue(true);
|
||||
configMocks.loadConfig.mockReset().mockReturnValue({
|
||||
browser: {},
|
||||
nodeHost: { browserProxy: { enabled: true } },
|
||||
});
|
||||
browserConfigMocks.resolveBrowserConfig.mockReset().mockReturnValue({
|
||||
enabled: true,
|
||||
defaultProfile: "openclaw",
|
||||
});
|
||||
({ runBrowserProxyCommand } = await import("./invoke-browser.js"));
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: {},
|
||||
nodeHost: { browserProxy: { enabled: true } },
|
||||
@@ -51,6 +69,7 @@ describe("runBrowserProxyCommand", () => {
|
||||
});
|
||||
|
||||
it("adds profile and browser status details on ws-backed timeouts", async () => {
|
||||
vi.useFakeTimers();
|
||||
dispatcherMocks.dispatch
|
||||
.mockImplementationOnce(async () => {
|
||||
await new Promise(() => {});
|
||||
@@ -65,7 +84,7 @@ describe("runBrowserProxyCommand", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
const result = expect(
|
||||
runBrowserProxyCommand(
|
||||
JSON.stringify({
|
||||
method: "GET",
|
||||
@@ -77,9 +96,12 @@ describe("runBrowserProxyCommand", () => {
|
||||
).rejects.toThrow(
|
||||
/browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=openclaw; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await result;
|
||||
});
|
||||
|
||||
it("includes chrome-mcp transport in timeout diagnostics when no CDP URL exists", async () => {
|
||||
vi.useFakeTimers();
|
||||
dispatcherMocks.dispatch
|
||||
.mockImplementationOnce(async () => {
|
||||
await new Promise(() => {});
|
||||
@@ -95,7 +117,7 @@ describe("runBrowserProxyCommand", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
const result = expect(
|
||||
runBrowserProxyCommand(
|
||||
JSON.stringify({
|
||||
method: "GET",
|
||||
@@ -107,9 +129,12 @@ describe("runBrowserProxyCommand", () => {
|
||||
).rejects.toThrow(
|
||||
/browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=user; status\(running=true, cdpHttp=true, cdpReady=false, transport=chrome-mcp\)/,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await result;
|
||||
});
|
||||
|
||||
it("redacts sensitive cdpUrl details in timeout diagnostics", async () => {
|
||||
vi.useFakeTimers();
|
||||
dispatcherMocks.dispatch
|
||||
.mockImplementationOnce(async () => {
|
||||
await new Promise(() => {});
|
||||
@@ -125,7 +150,7 @@ describe("runBrowserProxyCommand", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
const result = expect(
|
||||
runBrowserProxyCommand(
|
||||
JSON.stringify({
|
||||
method: "GET",
|
||||
@@ -137,6 +162,8 @@ describe("runBrowserProxyCommand", () => {
|
||||
).rejects.toThrow(
|
||||
/status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=https:\/\/example\.com\/chrome\?token=supers…7890\)/,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await result;
|
||||
});
|
||||
|
||||
it("keeps non-timeout browser errors intact", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, type Mock, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest";
|
||||
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
|
||||
import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js";
|
||||
import { loadExecApprovals, saveExecApprovals } from "../infra/exec-approvals.js";
|
||||
@@ -31,6 +31,29 @@ describe("formatSystemRunAllowlistMissMessage", () => {
|
||||
});
|
||||
|
||||
describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||
let testOpenClawHome = "";
|
||||
let previousOpenClawHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
previousOpenClawHome = process.env.OPENCLAW_HOME;
|
||||
testOpenClawHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-host-home-"));
|
||||
process.env.OPENCLAW_HOME = testOpenClawHome;
|
||||
clearRuntimeConfigSnapshot();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
if (previousOpenClawHome === undefined) {
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
} else {
|
||||
process.env.OPENCLAW_HOME = previousOpenClawHome;
|
||||
}
|
||||
if (testOpenClawHome) {
|
||||
fs.rmSync(testOpenClawHome, { recursive: true, force: true });
|
||||
testOpenClawHome = "";
|
||||
}
|
||||
});
|
||||
|
||||
function createLocalRunResult(stdout = "local-ok") {
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -4,6 +4,7 @@ const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
|
||||
let monolithicSdk = null;
|
||||
let diagnosticEventsModule = null;
|
||||
const jitiLoaders = new Map();
|
||||
const pluginSdkSubpathsCache = new Map();
|
||||
|
||||
@@ -64,10 +65,14 @@ function resolveControlCommandGate(params) {
|
||||
|
||||
function onDiagnosticEvent(listener) {
|
||||
const monolithic = loadMonolithicSdk();
|
||||
if (!monolithic || typeof monolithic.onDiagnosticEvent !== "function") {
|
||||
if (monolithic && typeof monolithic.onDiagnosticEvent === "function") {
|
||||
return monolithic.onDiagnosticEvent(listener);
|
||||
}
|
||||
const diagnosticEvents = loadDiagnosticEventsModule();
|
||||
if (!diagnosticEvents || typeof diagnosticEvents.onDiagnosticEvent !== "function") {
|
||||
throw new Error("openclaw/plugin-sdk root alias could not resolve onDiagnosticEvent");
|
||||
}
|
||||
return monolithic.onDiagnosticEvent(listener);
|
||||
return diagnosticEvents.onDiagnosticEvent(listener);
|
||||
}
|
||||
|
||||
function getPackageRoot() {
|
||||
@@ -150,6 +155,34 @@ function loadMonolithicSdk() {
|
||||
return monolithicSdk;
|
||||
}
|
||||
|
||||
function loadDiagnosticEventsModule() {
|
||||
if (diagnosticEventsModule) {
|
||||
return diagnosticEventsModule;
|
||||
}
|
||||
|
||||
const distCandidate = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"dist",
|
||||
"infra",
|
||||
"diagnostic-events.js",
|
||||
);
|
||||
if (fs.existsSync(distCandidate)) {
|
||||
try {
|
||||
diagnosticEventsModule = getJiti(true)(distCandidate);
|
||||
return diagnosticEventsModule;
|
||||
} catch {
|
||||
// Fall through to source path if dist is unavailable or stale.
|
||||
}
|
||||
}
|
||||
|
||||
diagnosticEventsModule = getJiti(false)(
|
||||
path.join(__dirname, "..", "infra", "diagnostic-events.ts"),
|
||||
);
|
||||
return diagnosticEventsModule;
|
||||
}
|
||||
|
||||
function tryLoadMonolithicSdk() {
|
||||
try {
|
||||
return loadMonolithicSdk();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
BaseProbeResult as ContractBaseProbeResult,
|
||||
BaseTokenResolution as ContractBaseTokenResolution,
|
||||
@@ -47,12 +46,10 @@ import { pluginSdkSubpaths } from "./entrypoints.js";
|
||||
|
||||
const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const PLUGIN_SDK_DIR = resolve(ROOT_DIR, "plugin-sdk");
|
||||
const requireFromHere = createRequire(import.meta.url);
|
||||
const sourceCache = new Map<string, string>();
|
||||
const representativeRuntimeSmokeSubpaths = ["channel-runtime", "conversation-runtime"] as const;
|
||||
|
||||
const importResolvedPluginSdkSubpath = async (specifier: string) =>
|
||||
import(pathToFileURL(requireFromHere.resolve(specifier)).href);
|
||||
const importResolvedPluginSdkSubpath = async (specifier: string) => import(specifier);
|
||||
|
||||
function readPluginSdkSource(subpath: string): string {
|
||||
const file = resolve(PLUGIN_SDK_DIR, `${subpath}.ts`);
|
||||
|
||||
@@ -15,10 +15,16 @@ vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||
getOAuthProviders: () => [],
|
||||
getOAuthApiKey: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
|
||||
"@mariozechner/pi-ai/oauth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getOAuthProviders: () => [],
|
||||
getOAuthApiKey: vi.fn(async () => null),
|
||||
};
|
||||
});
|
||||
|
||||
function createResolvedModel(provider: string, modelId: string, api = "openai-completions") {
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import {
|
||||
Input,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
type SelectListTheme,
|
||||
getEditorKeybindings,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import chalk from "chalk";
|
||||
import { fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js";
|
||||
@@ -110,7 +110,8 @@ export class FilterableSelectList implements Component {
|
||||
}
|
||||
|
||||
// Escape: clear filter or cancel
|
||||
if (matchesKey(keyData, Key.escape)) {
|
||||
const kb = getEditorKeybindings();
|
||||
if (kb.matches(keyData, "selectCancel")) {
|
||||
if (this.filterText) {
|
||||
this.filterText = "";
|
||||
this.input.setValue("");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
type Component,
|
||||
getEditorKeybindings,
|
||||
Input,
|
||||
isKeyRelease,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
type SelectListTheme,
|
||||
@@ -362,7 +362,8 @@ export class SearchableSelectList implements Component {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(keyData, Key.escape)) {
|
||||
const kb = getEditorKeybindings();
|
||||
if (kb.matches(keyData, "selectCancel")) {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { setTimeout as nativeSetTimeout } from "node:timers";
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function useFastShortTimeouts(maxDelayMs = 2000): () => void {
|
||||
const realSetTimeout = setTimeout;
|
||||
const spy = vi.spyOn(global, "setTimeout").mockImplementation(((
|
||||
handler: TimerHandler,
|
||||
timeout?: number,
|
||||
@@ -9,9 +9,9 @@ export function useFastShortTimeouts(maxDelayMs = 2000): () => void {
|
||||
) => {
|
||||
const delay = typeof timeout === "number" ? timeout : 0;
|
||||
if (delay > 0 && delay <= maxDelayMs) {
|
||||
return realSetTimeout(handler, 0, ...args);
|
||||
return nativeSetTimeout(handler, 0, ...args);
|
||||
}
|
||||
return realSetTimeout(handler, delay, ...args);
|
||||
return nativeSetTimeout(handler, delay, ...args);
|
||||
}) as typeof setTimeout);
|
||||
return () => spy.mockRestore();
|
||||
}
|
||||
|
||||
68
test/non-isolated-runner.ts
Normal file
68
test/non-isolated-runner.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import fs from "node:fs";
|
||||
import { TestRunner, type RunnerTestSuite, vi } from "vitest";
|
||||
|
||||
type EvaluatedModuleNode = {
|
||||
promise?: unknown;
|
||||
exports?: unknown;
|
||||
evaluated?: boolean;
|
||||
importers: Set<string>;
|
||||
};
|
||||
|
||||
type EvaluatedModules = {
|
||||
idToModuleMap: Map<string, EvaluatedModuleNode>;
|
||||
};
|
||||
|
||||
function resetEvaluatedModules(modules: EvaluatedModules, resetMocks: boolean) {
|
||||
const skipPaths = [
|
||||
/\/vitest\/dist\//,
|
||||
/vitest-virtual-\w+\/dist/u,
|
||||
/@vitest\/dist/u,
|
||||
...(resetMocks ? [] : [/^mock:/u]),
|
||||
];
|
||||
|
||||
modules.idToModuleMap.forEach((node, modulePath) => {
|
||||
if (skipPaths.some((pattern) => pattern.test(modulePath))) {
|
||||
return;
|
||||
}
|
||||
node.promise = undefined;
|
||||
node.exports = undefined;
|
||||
node.evaluated = false;
|
||||
node.importers.clear();
|
||||
});
|
||||
}
|
||||
|
||||
export default class OpenClawNonIsolatedRunner extends TestRunner {
|
||||
override onCollectStart(file: { filepath: string }) {
|
||||
super.onCollectStart(file);
|
||||
const orderLogPath = process.env.OPENCLAW_VITEST_FILE_ORDER_LOG?.trim();
|
||||
if (orderLogPath) {
|
||||
fs.appendFileSync(orderLogPath, `START ${file.filepath}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
override async onAfterRunSuite(suite: RunnerTestSuite) {
|
||||
await super.onAfterRunSuite(suite);
|
||||
if (this.config.isolate || !("filepath" in suite) || typeof suite.filepath !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderLogPath = process.env.OPENCLAW_VITEST_FILE_ORDER_LOG?.trim();
|
||||
if (orderLogPath) {
|
||||
fs.appendFileSync(orderLogPath, `END ${suite.filepath}\n`);
|
||||
}
|
||||
|
||||
// Mirror the missing cleanup from Vitest isolate mode so shared workers do
|
||||
// not carry file-scoped timers, stubs, spies, or stale module state
|
||||
// forward into the next file.
|
||||
if (vi.isFakeTimers()) {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
this.moduleRunner?.mocker?.reset?.();
|
||||
resetEvaluatedModules(this.workerState.evaluatedModules as EvaluatedModules, true);
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,60 @@ const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => {
|
||||
return deps?.[id] as ((...args: unknown[]) => Promise<unknown>) | undefined;
|
||||
};
|
||||
|
||||
type VitestEvaluatedModuleNode = {
|
||||
promise?: unknown;
|
||||
exports?: unknown;
|
||||
evaluated?: boolean;
|
||||
importers: Set<string>;
|
||||
};
|
||||
|
||||
type VitestEvaluatedModules = {
|
||||
idToModuleMap: Map<string, VitestEvaluatedModuleNode>;
|
||||
};
|
||||
|
||||
const resetVitestWorkerModules = (resetMocks: boolean) => {
|
||||
const workerState = (
|
||||
globalThis as typeof globalThis & {
|
||||
__vitest_worker__?: {
|
||||
evaluatedModules?: VitestEvaluatedModules;
|
||||
};
|
||||
}
|
||||
).__vitest_worker__;
|
||||
const modules = workerState?.evaluatedModules;
|
||||
if (!modules) {
|
||||
return;
|
||||
}
|
||||
|
||||
const skipPaths = [
|
||||
/\/vitest\/dist\//,
|
||||
/vitest-virtual-\w+\/dist/u,
|
||||
/@vitest\/dist/u,
|
||||
...(resetMocks ? [] : [/^mock:/u]),
|
||||
];
|
||||
|
||||
modules.idToModuleMap.forEach((node, modulePath) => {
|
||||
if (skipPaths.some((pattern) => pattern.test(modulePath))) {
|
||||
return;
|
||||
}
|
||||
node.promise = undefined;
|
||||
node.exports = undefined;
|
||||
node.evaluated = false;
|
||||
node.importers.clear();
|
||||
});
|
||||
};
|
||||
|
||||
const resetVitestWorkerFileState = () => {
|
||||
const mocker = (
|
||||
globalThis as typeof globalThis & {
|
||||
__vitest_mocker__?: {
|
||||
reset?: () => void;
|
||||
};
|
||||
}
|
||||
).__vitest_mocker__;
|
||||
mocker?.reset?.();
|
||||
resetVitestWorkerModules(true);
|
||||
};
|
||||
|
||||
const createStubOutbound = (
|
||||
id: ChannelId,
|
||||
deliveryMode: ChannelOutboundAdapter["deliveryMode"] = "direct",
|
||||
@@ -274,8 +328,17 @@ afterEach(() => {
|
||||
globalRegistryState.key = null;
|
||||
globalRegistryState.version += 1;
|
||||
}
|
||||
// Guard against leaked fake timers across test files/workers.
|
||||
if (vi.isFakeTimers()) {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
// Always normalize timer/date state. Some suites call `vi.setSystemTime()`
|
||||
// without leaving fake timers enabled, which still leaks mocked time into
|
||||
// later files under `--isolate=false`.
|
||||
vi.useRealTimers();
|
||||
// Non-isolated runs reuse the same module graph across files. Clear it so
|
||||
// hoisted per-file mocks still apply when later files import the same modules.
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Mirror Vitest's isolate-mode file cleanup so `--isolate=false` does not
|
||||
// carry hoisted mocks or stale module graphs into the next test file.
|
||||
resetVitestWorkerFileState();
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ export default defineConfig({
|
||||
...base,
|
||||
test: {
|
||||
...baseTest,
|
||||
runner: "./test/non-isolated-runner.ts",
|
||||
include: loadIncludePatternsFromEnv() ?? unitTestIncludePatterns,
|
||||
exclude: [
|
||||
...new Set([
|
||||
|
||||
Reference in New Issue
Block a user