mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
test: consolidate redundant suites and speed attachment tests
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { downloadMSTeamsAttachments } from "./attachments/download.js";
|
||||
import { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
|
||||
import { buildMSTeamsAttachmentPlaceholder } from "./attachments/html.js";
|
||||
import { buildMSTeamsMediaPayload } from "./attachments/payload.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
/** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */
|
||||
@@ -49,10 +53,6 @@ const runtimeStub = {
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
describe("msteams attachments", () => {
|
||||
const load = async () => {
|
||||
return await import("./attachments.js");
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
detectMimeMock.mockClear();
|
||||
saveMediaBufferMock.mockClear();
|
||||
@@ -62,13 +62,11 @@ describe("msteams attachments", () => {
|
||||
|
||||
describe("buildMSTeamsAttachmentPlaceholder", () => {
|
||||
it("returns empty string when no attachments", async () => {
|
||||
const { buildMSTeamsAttachmentPlaceholder } = await load();
|
||||
expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe("");
|
||||
expect(buildMSTeamsAttachmentPlaceholder([])).toBe("");
|
||||
});
|
||||
|
||||
it("returns image placeholder for image attachments", async () => {
|
||||
const { buildMSTeamsAttachmentPlaceholder } = await load();
|
||||
expect(
|
||||
buildMSTeamsAttachmentPlaceholder([
|
||||
{ contentType: "image/png", contentUrl: "https://x/img.png" },
|
||||
@@ -83,7 +81,6 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("treats Teams file.download.info image attachments as images", async () => {
|
||||
const { buildMSTeamsAttachmentPlaceholder } = await load();
|
||||
expect(
|
||||
buildMSTeamsAttachmentPlaceholder([
|
||||
{
|
||||
@@ -95,7 +92,6 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("returns document placeholder for non-image attachments", async () => {
|
||||
const { buildMSTeamsAttachmentPlaceholder } = await load();
|
||||
expect(
|
||||
buildMSTeamsAttachmentPlaceholder([
|
||||
{ contentType: "application/pdf", contentUrl: "https://x/x.pdf" },
|
||||
@@ -110,7 +106,6 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("counts inline images in text/html attachments", async () => {
|
||||
const { buildMSTeamsAttachmentPlaceholder } = await load();
|
||||
expect(
|
||||
buildMSTeamsAttachmentPlaceholder([
|
||||
{
|
||||
@@ -132,7 +127,6 @@ describe("msteams attachments", () => {
|
||||
|
||||
describe("downloadMSTeamsAttachments", () => {
|
||||
it("downloads and stores image contentUrl attachments", async () => {
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(Buffer.from("png"), {
|
||||
status: 200,
|
||||
@@ -155,7 +149,6 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("supports Teams file.download.info downloadUrl attachments", async () => {
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(Buffer.from("png"), {
|
||||
status: 200,
|
||||
@@ -181,7 +174,6 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("downloads non-image file attachments (PDF)", async () => {
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(Buffer.from("pdf"), {
|
||||
status: 200,
|
||||
@@ -209,7 +201,6 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("downloads inline image URLs from html attachments", async () => {
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(Buffer.from("png"), {
|
||||
status: 200,
|
||||
@@ -235,7 +226,6 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("stores inline data:image base64 payloads", async () => {
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const base64 = Buffer.from("png").toString("base64");
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [
|
||||
@@ -253,7 +243,6 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("retries with auth when the first request is unauthorized", async () => {
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
|
||||
const headers = new Headers(opts?.headers);
|
||||
const hasAuth = Boolean(headers.get("Authorization"));
|
||||
@@ -281,7 +270,6 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("skips auth retries when the host is not in auth allowlist", async () => {
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const tokenProvider = { getAccessToken: vi.fn(async () => "token") };
|
||||
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
|
||||
const headers = new Headers(opts?.headers);
|
||||
@@ -313,7 +301,6 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("skips urls outside the allowlist", async () => {
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn();
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }],
|
||||
@@ -329,7 +316,6 @@ describe("msteams attachments", () => {
|
||||
|
||||
describe("buildMSTeamsGraphMessageUrls", () => {
|
||||
it("builds channel message urls", async () => {
|
||||
const { buildMSTeamsGraphMessageUrls } = await load();
|
||||
const urls = buildMSTeamsGraphMessageUrls({
|
||||
conversationType: "channel",
|
||||
conversationId: "19:thread@thread.tacv2",
|
||||
@@ -340,7 +326,6 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("builds channel reply urls when replyToId is present", async () => {
|
||||
const { buildMSTeamsGraphMessageUrls } = await load();
|
||||
const urls = buildMSTeamsGraphMessageUrls({
|
||||
conversationType: "channel",
|
||||
messageId: "reply-id",
|
||||
@@ -353,7 +338,6 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("builds chat message urls", async () => {
|
||||
const { buildMSTeamsGraphMessageUrls } = await load();
|
||||
const urls = buildMSTeamsGraphMessageUrls({
|
||||
conversationType: "groupChat",
|
||||
conversationId: "19:chat@thread.v2",
|
||||
@@ -365,7 +349,6 @@ describe("msteams attachments", () => {
|
||||
|
||||
describe("downloadMSTeamsGraphMedia", () => {
|
||||
it("downloads hostedContents images", async () => {
|
||||
const { downloadMSTeamsGraphMedia } = await load();
|
||||
const base64 = Buffer.from("png").toString("base64");
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.endsWith("/hostedContents")) {
|
||||
@@ -401,7 +384,6 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("merges SharePoint reference attachments with hosted content", async () => {
|
||||
const { downloadMSTeamsGraphMedia } = await load();
|
||||
const hostedBase64 = Buffer.from("png").toString("base64");
|
||||
const shareUrl = "https://contoso.sharepoint.com/site/file";
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
@@ -469,7 +451,6 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
|
||||
const { downloadMSTeamsGraphMedia } = await load();
|
||||
const shareUrl = "https://contoso.sharepoint.com/site/file";
|
||||
const escapedUrl = "https://evil.example/internal.pdf";
|
||||
fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
|
||||
@@ -553,7 +534,6 @@ describe("msteams attachments", () => {
|
||||
|
||||
describe("buildMSTeamsMediaPayload", () => {
|
||||
it("returns single and multi-file fields", async () => {
|
||||
const { buildMSTeamsMediaPayload } = await load();
|
||||
const payload = buildMSTeamsMediaPayload([
|
||||
{ path: "/tmp/a.png", contentType: "image/png" },
|
||||
{ path: "/tmp/b.png", contentType: "image/png" },
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAnthropicBillingError } from "./live-auth-keys.js";
|
||||
|
||||
describe("isAnthropicBillingError", () => {
|
||||
it("does not false-positive on plain 'a 402' prose", () => {
|
||||
const samples = [
|
||||
"Use a 402 stainless bolt",
|
||||
"Book a 402 room",
|
||||
"There is a 402 near me",
|
||||
"The building at 402 Main Street",
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
expect(isAnthropicBillingError(sample)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("matches real 402 billing payload contexts including JSON keys", () => {
|
||||
const samples = [
|
||||
"HTTP 402 Payment Required",
|
||||
"status: 402",
|
||||
"error code 402",
|
||||
'{"status":402,"type":"error"}',
|
||||
'{"code":402,"message":"payment required"}',
|
||||
'{"error":{"code":402,"message":"billing hard limit reached"}}',
|
||||
"got a 402 from the API",
|
||||
"returned 402",
|
||||
"received a 402 response",
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
expect(isAnthropicBillingError(sample)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isModernModelRef } from "./live-model-filter.js";
|
||||
import { normalizeModelCompat } from "./model-compat.js";
|
||||
import { resolveForwardCompatModel } from "./model-forward-compat.js";
|
||||
import type { ModelRegistry } from "./pi-model-discovery.js";
|
||||
|
||||
const baseModel = (): Model<Api> =>
|
||||
({
|
||||
@@ -17,6 +19,28 @@ const baseModel = (): Model<Api> =>
|
||||
maxTokens: 1024,
|
||||
}) as Model<Api>;
|
||||
|
||||
function createTemplateModel(provider: string, id: string): Model<Api> {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
provider,
|
||||
api: "anthropic-messages",
|
||||
input: ["text"],
|
||||
reasoning: true,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8_192,
|
||||
} as Model<Api>;
|
||||
}
|
||||
|
||||
function createRegistry(models: Record<string, Model<Api>>): ModelRegistry {
|
||||
return {
|
||||
find(provider: string, modelId: string) {
|
||||
return models[`${provider}/${modelId}`] ?? null;
|
||||
},
|
||||
} as ModelRegistry;
|
||||
}
|
||||
|
||||
describe("normalizeModelCompat", () => {
|
||||
it("forces supportsDeveloperRole off for z.ai models", () => {
|
||||
const model = baseModel();
|
||||
@@ -59,3 +83,36 @@ describe("isModernModelRef", () => {
|
||||
expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveForwardCompatModel", () => {
|
||||
it("resolves anthropic opus 4.6 via 4.5 template", () => {
|
||||
const registry = createRegistry({
|
||||
"anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"),
|
||||
});
|
||||
const model = resolveForwardCompatModel("anthropic", "claude-opus-4-6", registry);
|
||||
expect(model?.id).toBe("claude-opus-4-6");
|
||||
expect(model?.name).toBe("claude-opus-4-6");
|
||||
expect(model?.provider).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("resolves anthropic sonnet 4.6 dot variant with suffix", () => {
|
||||
const registry = createRegistry({
|
||||
"anthropic/claude-sonnet-4.5-20260219": createTemplateModel(
|
||||
"anthropic",
|
||||
"claude-sonnet-4.5-20260219",
|
||||
),
|
||||
});
|
||||
const model = resolveForwardCompatModel("anthropic", "claude-sonnet-4.6-20260219", registry);
|
||||
expect(model?.id).toBe("claude-sonnet-4.6-20260219");
|
||||
expect(model?.name).toBe("claude-sonnet-4.6-20260219");
|
||||
expect(model?.provider).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("does not resolve anthropic 4.6 fallback for other providers", () => {
|
||||
const registry = createRegistry({
|
||||
"anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"),
|
||||
});
|
||||
const model = resolveForwardCompatModel("openai", "claude-opus-4-6", registry);
|
||||
expect(model).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { AuthProfileStore } from "./auth-profiles.js";
|
||||
import { saveAuthProfileStore } from "./auth-profiles.js";
|
||||
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
|
||||
import { isAnthropicBillingError } from "./live-auth-keys.js";
|
||||
import { runWithModelFallback } from "./model-fallback.js";
|
||||
import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js";
|
||||
|
||||
@@ -656,3 +657,36 @@ describe("runWithModelFallback", () => {
|
||||
expect(result.model).toBe("gpt-4.1-mini");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAnthropicBillingError", () => {
|
||||
it("does not false-positive on plain 'a 402' prose", () => {
|
||||
const samples = [
|
||||
"Use a 402 stainless bolt",
|
||||
"Book a 402 room",
|
||||
"There is a 402 near me",
|
||||
"The building at 402 Main Street",
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
expect(isAnthropicBillingError(sample)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("matches real 402 billing payload contexts including JSON keys", () => {
|
||||
const samples = [
|
||||
"HTTP 402 Payment Required",
|
||||
"status: 402",
|
||||
"error code 402",
|
||||
'{"status":402,"type":"error"}',
|
||||
'{"code":402,"message":"payment required"}',
|
||||
'{"error":{"code":402,"message":"billing hard limit reached"}}',
|
||||
"got a 402 from the API",
|
||||
"returned 402",
|
||||
"received a 402 response",
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
expect(isAnthropicBillingError(sample)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveForwardCompatModel } from "./model-forward-compat.js";
|
||||
import type { ModelRegistry } from "./pi-model-discovery.js";
|
||||
|
||||
function createTemplateModel(provider: string, id: string): Model<Api> {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
provider,
|
||||
api: "anthropic-messages",
|
||||
input: ["text"],
|
||||
reasoning: true,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8_192,
|
||||
} as Model<Api>;
|
||||
}
|
||||
|
||||
function createRegistry(models: Record<string, Model<Api>>): ModelRegistry {
|
||||
return {
|
||||
find(provider: string, modelId: string) {
|
||||
return models[`${provider}/${modelId}`] ?? null;
|
||||
},
|
||||
} as ModelRegistry;
|
||||
}
|
||||
|
||||
describe("agents/model-forward-compat", () => {
|
||||
it("resolves anthropic opus 4.6 via 4.5 template", () => {
|
||||
const registry = createRegistry({
|
||||
"anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"),
|
||||
});
|
||||
const model = resolveForwardCompatModel("anthropic", "claude-opus-4-6", registry);
|
||||
expect(model?.id).toBe("claude-opus-4-6");
|
||||
expect(model?.name).toBe("claude-opus-4-6");
|
||||
expect(model?.provider).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("resolves anthropic sonnet 4.6 dot variant with suffix", () => {
|
||||
const registry = createRegistry({
|
||||
"anthropic/claude-sonnet-4.5-20260219": createTemplateModel(
|
||||
"anthropic",
|
||||
"claude-sonnet-4.5-20260219",
|
||||
),
|
||||
});
|
||||
const model = resolveForwardCompatModel("anthropic", "claude-sonnet-4.6-20260219", registry);
|
||||
expect(model?.id).toBe("claude-sonnet-4.6-20260219");
|
||||
expect(model?.name).toBe("claude-sonnet-4.6-20260219");
|
||||
expect(model?.provider).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("does not resolve anthropic 4.6 fallback for other providers", () => {
|
||||
const registry = createRegistry({
|
||||
"anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"),
|
||||
});
|
||||
const model = resolveForwardCompatModel("openai", "claude-opus-4-6", registry);
|
||||
expect(model).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
|
||||
import {
|
||||
createBaseWebFetchToolConfig,
|
||||
installWebFetchSsrfHarness,
|
||||
} from "./web-fetch.test-harness.js";
|
||||
import "./web-fetch.test-mocks.js";
|
||||
import { createWebFetchTool } from "./web-tools.js";
|
||||
|
||||
const baseToolConfig = createBaseWebFetchToolConfig({ maxResponseBytes: 1024 });
|
||||
installWebFetchSsrfHarness();
|
||||
|
||||
describe("web_fetch response size limits", () => {
|
||||
it("caps response bytes and does not hang on endless streams", async () => {
|
||||
const chunk = new TextEncoder().encode("<html><body><div>hi</div></body></html>");
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
const response = new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
|
||||
const fetchSpy = vi.fn().mockResolvedValue(response);
|
||||
global.fetch = withFetchPreconnect(fetchSpy);
|
||||
|
||||
const tool = createWebFetchTool(baseToolConfig);
|
||||
const result = await tool?.execute?.("call", { url: "https://example.com/stream" });
|
||||
const details = result?.details as { warning?: string } | undefined;
|
||||
expect(details?.warning).toContain("Response body truncated");
|
||||
});
|
||||
});
|
||||
@@ -233,6 +233,29 @@ describe("web_fetch extraction fallbacks", () => {
|
||||
expect(details.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("caps response bytes and does not hang on endless streams", async () => {
|
||||
const chunk = new TextEncoder().encode("<html><body><div>hi</div></body></html>");
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
const response = new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
const fetchSpy = vi.fn().mockResolvedValue(response);
|
||||
global.fetch = withFetchPreconnect(fetchSpy);
|
||||
|
||||
const tool = createFetchTool({
|
||||
maxResponseBytes: 1024,
|
||||
firecrawl: { enabled: false },
|
||||
});
|
||||
const result = await tool?.execute?.("call", { url: "https://example.com/stream" });
|
||||
const details = result?.details as { warning?: string } | undefined;
|
||||
expect(details?.warning).toContain("Response body truncated");
|
||||
});
|
||||
|
||||
// NOTE: Test for wrapping url/finalUrl/warning fields requires DNS mocking.
|
||||
// The sanitization of these fields is verified by external-content.test.ts tests.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user