test: dedupe infra and plugin-sdk utility suites

This commit is contained in:
Peter Steinberger
2026-03-27 23:08:54 +00:00
parent 0558f2470d
commit 8a788e2c0c
33 changed files with 1337 additions and 1021 deletions

View File

@@ -22,6 +22,14 @@ function createFakeServer(): FakeServer {
return server;
}
async function expectTaskPending(task: Promise<unknown>) {
const early = await Promise.race([
task.then(() => "resolved"),
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 25)),
]);
expect(early).toBe("pending");
}
describe("plugin-sdk channel lifecycle helpers", () => {
it("binds account id onto status patches", () => {
const setStatus = vi.fn();
@@ -42,12 +50,7 @@ describe("plugin-sdk channel lifecycle helpers", () => {
it("resolves waitUntilAbort when signal aborts", async () => {
const abort = new AbortController();
const task = waitUntilAbort(abort.signal);
const early = await Promise.race([
task.then(() => "resolved"),
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 25)),
]);
expect(early).toBe("pending");
await expectTaskPending(task);
abort.abort();
await expect(task).resolves.toBeUndefined();
@@ -75,11 +78,7 @@ describe("plugin-sdk channel lifecycle helpers", () => {
},
});
const early = await Promise.race([
task.then(() => "resolved"),
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 25)),
]);
expect(early).toBe("pending");
await expectTaskPending(task);
expect(stop).not.toHaveBeenCalled();
abort.abort();
@@ -90,12 +89,7 @@ describe("plugin-sdk channel lifecycle helpers", () => {
it("keeps server task pending until close, then resolves", async () => {
const server = createFakeServer();
const task = keepHttpServerTaskAlive({ server });
const early = await Promise.race([
task.then(() => "resolved"),
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 25)),
]);
expect(early).toBe("pending");
await expectTaskPending(task);
server.close();
await expect(task).resolves.toBeUndefined();

View File

@@ -5,14 +5,21 @@ import {
createChannelPairingController,
} from "./channel-pairing.js";
function createReplyCollector() {
const replies: string[] = [];
return {
replies,
sendPairingReply: vi.fn(async (text: string) => {
replies.push(text);
}),
};
}
describe("createChannelPairingController", () => {
it("scopes store access and issues pairing challenges through the scoped store", async () => {
const readAllowFromStore = vi.fn(async () => ["alice"]);
const upsertPairingRequest = vi.fn(async () => ({ code: "123456", created: true }));
const replies: string[] = [];
const sendPairingReply = vi.fn(async (text: string) => {
replies.push(text);
});
const { replies, sendPairingReply } = createReplyCollector();
const runtime = {
channel: {
pairing: {
@@ -53,7 +60,7 @@ describe("createChannelPairingController", () => {
describe("createChannelPairingChallengeIssuer", () => {
it("binds a channel and scoped pairing store to challenge issuance", async () => {
const upsertPairingRequest = vi.fn(async () => ({ code: "654321", created: true }));
const replies: string[] = [];
const { replies, sendPairingReply } = createReplyCollector();
const issueChallenge = createChannelPairingChallengeIssuer({
channel: "signal",
upsertPairingRequest,
@@ -62,9 +69,7 @@ describe("createChannelPairingChallengeIssuer", () => {
await issueChallenge({
senderId: "user-2",
senderIdLine: "Your id: user-2",
sendPairingReply: async (text: string) => {
replies.push(text);
},
sendPairingReply,
});
expect(upsertPairingRequest).toHaveBeenCalledWith({

View File

@@ -6,45 +6,45 @@ const baseCfg = {
commands: { useAccessGroups: true },
} as unknown as OpenClawConfig;
describe("plugin-sdk/command-auth", () => {
it("authorizes group commands from explicit group allowlist", async () => {
const result = await resolveSenderCommandAuthorization({
cfg: baseCfg,
rawBody: "/status",
isGroup: true,
dmPolicy: "pairing",
configuredAllowFrom: ["dm-owner"],
configuredGroupAllowFrom: ["group-owner"],
senderId: "group-owner",
isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(senderId),
readAllowFromStore: async () => ["paired-user"],
shouldComputeCommandAuthorized: () => true,
resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
});
expect(result.commandAuthorized).toBe(true);
expect(result.senderAllowedForCommands).toBe(true);
expect(result.effectiveAllowFrom).toEqual(["dm-owner"]);
expect(result.effectiveGroupAllowFrom).toEqual(["group-owner"]);
async function resolveAuthorization(params: {
senderId: string;
configuredAllowFrom?: string[];
configuredGroupAllowFrom?: string[];
}) {
return resolveSenderCommandAuthorization({
cfg: baseCfg,
rawBody: "/status",
isGroup: true,
dmPolicy: "pairing",
configuredAllowFrom: params.configuredAllowFrom ?? ["dm-owner"],
configuredGroupAllowFrom: params.configuredGroupAllowFrom ?? ["group-owner"],
senderId: params.senderId,
isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(senderId),
readAllowFromStore: async () => ["paired-user"],
shouldComputeCommandAuthorized: () => true,
resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
});
}
it("keeps pairing-store identities DM-only for group command auth", async () => {
const result = await resolveSenderCommandAuthorization({
cfg: baseCfg,
rawBody: "/status",
isGroup: true,
dmPolicy: "pairing",
configuredAllowFrom: ["dm-owner"],
configuredGroupAllowFrom: ["group-owner"],
describe("plugin-sdk/command-auth", () => {
it.each([
{
name: "authorizes group commands from explicit group allowlist",
senderId: "group-owner",
expectedAuthorized: true,
expectedSenderAllowed: true,
},
{
name: "keeps pairing-store identities DM-only for group command auth",
senderId: "paired-user",
isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(senderId),
readAllowFromStore: async () => ["paired-user"],
shouldComputeCommandAuthorized: () => true,
resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
});
expect(result.commandAuthorized).toBe(false);
expect(result.senderAllowedForCommands).toBe(false);
expectedAuthorized: false,
expectedSenderAllowed: false,
},
])("$name", async ({ senderId, expectedAuthorized, expectedSenderAllowed }) => {
const result = await resolveAuthorization({ senderId });
expect(result.commandAuthorized).toBe(expectedAuthorized);
expect(result.senderAllowedForCommands).toBe(expectedSenderAllowed);
expect(result.effectiveAllowFrom).toEqual(["dm-owner"]);
expect(result.effectiveGroupAllowFrom).toEqual(["group-owner"]);
});

View File

@@ -11,6 +11,39 @@ const baseCfg = {
commands: { useAccessGroups: true },
} as unknown as OpenClawConfig;
function createDirectDmRuntime() {
const recordInboundSession = vi.fn(async () => {});
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "reply text" });
});
return {
recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher,
runtime: {
channel: {
routing: {
resolveAgentRoute: vi.fn(({ accountId, peer }) => ({
agentId: "agent-main",
accountId,
sessionKey: `dm:${peer.id}`,
})),
},
session: {
resolveStorePath: vi.fn(() => "/tmp/direct-dm-session-store"),
readSessionUpdatedAt: vi.fn(() => 1234),
recordInboundSession,
},
reply: {
resolveEnvelopeFormatOptions: vi.fn(() => ({ mode: "agent" })),
formatAgentEnvelope: vi.fn(({ body }) => `env:${body}`),
finalizeInboundContext: vi.fn((ctx) => ctx),
dispatchReplyWithBufferedBlockDispatcher,
},
},
} as never,
};
}
describe("plugin-sdk/direct-dm", () => {
it("resolves inbound DM access and command auth through one helper", async () => {
const result = await resolveInboundDirectDmAccessWithRuntime({
@@ -62,17 +95,17 @@ describe("plugin-sdk/direct-dm", () => {
});
await expect(
authorizer({
senderId: "pair-me",
reply: async () => {},
}),
).resolves.toBe("pairing");
await expect(
authorizer({
senderId: "blocked",
reply: async () => {},
}),
).resolves.toBe("block");
Promise.all([
authorizer({
senderId: "pair-me",
reply: async () => {},
}),
authorizer({
senderId: "blocked",
reply: async () => {},
}),
]),
).resolves.toEqual(["pairing", "block"]);
expect(issuePairingChallenge).toHaveBeenCalledTimes(1);
expect(onBlocked).toHaveBeenCalledWith({
@@ -98,38 +131,15 @@ describe("plugin-sdk/direct-dm", () => {
});
it("dispatches direct DMs through the standard route/session/reply pipeline", async () => {
const recordInboundSession = vi.fn(async () => {});
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "reply text" });
});
const { recordInboundSession, dispatchReplyWithBufferedBlockDispatcher, runtime } =
createDirectDmRuntime();
const deliver = vi.fn(async () => {});
const result = await dispatchInboundDirectDmWithRuntime({
cfg: {
session: { store: { type: "jsonl" } },
} as never,
runtime: {
channel: {
routing: {
resolveAgentRoute: vi.fn(({ accountId, peer }) => ({
agentId: "agent-main",
accountId,
sessionKey: `dm:${peer.id}`,
})),
},
session: {
resolveStorePath: vi.fn(() => "/tmp/direct-dm-session-store"),
readSessionUpdatedAt: vi.fn(() => 1234),
recordInboundSession,
},
reply: {
resolveEnvelopeFormatOptions: vi.fn(() => ({ mode: "agent" })),
formatAgentEnvelope: vi.fn(({ body }) => `env:${body}`),
finalizeInboundContext: vi.fn((ctx) => ctx),
dispatchReplyWithBufferedBlockDispatcher,
},
},
} as never,
runtime,
channel: "nostr",
channelLabel: "Nostr",
accountId: "default",

View File

@@ -14,60 +14,81 @@ describe("fetchWithBearerAuthScopeFallback", () => {
).rejects.toThrow("URL must use HTTPS");
});
it("returns immediately when the first attempt succeeds", async () => {
const fetchFn = vi.fn(async () => new Response("ok", { status: 200 }));
const tokenProvider = { getAccessToken: vi.fn(async () => "unused") };
const response = await fetchWithBearerAuthScopeFallback({
it.each([
{
name: "returns immediately when the first attempt succeeds",
url: "https://example.com/file",
scopes: ["https://graph.microsoft.com"],
fetchFn: asFetch(fetchFn),
tokenProvider,
});
expect(response.status).toBe(200);
expect(fetchFn).toHaveBeenCalledTimes(1);
expect(tokenProvider.getAccessToken).not.toHaveBeenCalled();
});
it("retries with auth scopes after a 401 response", async () => {
const fetchFn = vi
.fn()
.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }))
.mockResolvedValueOnce(new Response("ok", { status: 200 }));
const tokenProvider = { getAccessToken: vi.fn(async () => "token-1") };
const response = await fetchWithBearerAuthScopeFallback({
responses: [new Response("ok", { status: 200 })],
shouldAttachAuth: undefined,
expectedStatus: 200,
expectedFetchCalls: 1,
expectedTokenCalls: [] as string[],
expectedAuthHeader: null,
},
{
name: "retries with auth scopes after a 401 response",
url: "https://graph.microsoft.com/v1.0/me",
scopes: ["https://graph.microsoft.com", "https://api.botframework.com"],
fetchFn: asFetch(fetchFn),
tokenProvider,
});
expect(response.status).toBe(200);
expect(fetchFn).toHaveBeenCalledTimes(2);
expect(tokenProvider.getAccessToken).toHaveBeenCalledWith("https://graph.microsoft.com");
const secondCall = fetchFn.mock.calls[1] as [string, RequestInit | undefined];
const secondHeaders = new Headers(secondCall[1]?.headers);
expect(secondHeaders.get("authorization")).toBe("Bearer token-1");
});
it("does not attach auth when host predicate rejects url", async () => {
const fetchFn = vi.fn(async () => new Response("unauthorized", { status: 401 }));
const tokenProvider = { getAccessToken: vi.fn(async () => "token-1") };
const response = await fetchWithBearerAuthScopeFallback({
responses: [
new Response("unauthorized", { status: 401 }),
new Response("ok", { status: 200 }),
],
shouldAttachAuth: undefined,
expectedStatus: 200,
expectedFetchCalls: 2,
expectedTokenCalls: ["https://graph.microsoft.com"],
expectedAuthHeader: "Bearer token-1",
},
{
name: "does not attach auth when host predicate rejects url",
url: "https://example.com/file",
scopes: ["https://graph.microsoft.com"],
fetchFn: asFetch(fetchFn),
tokenProvider,
responses: [new Response("unauthorized", { status: 401 })],
shouldAttachAuth: () => false,
});
expectedStatus: 401,
expectedFetchCalls: 1,
expectedTokenCalls: [] as string[],
expectedAuthHeader: null,
},
])(
"$name",
async ({
url,
scopes,
responses,
shouldAttachAuth,
expectedStatus,
expectedFetchCalls,
expectedTokenCalls,
expectedAuthHeader,
}) => {
const fetchFn = vi.fn();
for (const response of responses) {
fetchFn.mockResolvedValueOnce(response);
}
const tokenProvider = { getAccessToken: vi.fn(async () => "token-1") };
expect(response.status).toBe(401);
expect(fetchFn).toHaveBeenCalledTimes(1);
expect(tokenProvider.getAccessToken).not.toHaveBeenCalled();
});
const response = await fetchWithBearerAuthScopeFallback({
url,
scopes,
fetchFn: asFetch(fetchFn),
tokenProvider,
shouldAttachAuth,
});
expect(response.status).toBe(expectedStatus);
expect(fetchFn).toHaveBeenCalledTimes(expectedFetchCalls);
const tokenCalls = tokenProvider.getAccessToken.mock.calls as unknown as Array<[string]>;
expect(tokenCalls.map(([scope]) => scope)).toEqual(expectedTokenCalls);
if (expectedAuthHeader === null) {
return;
}
const secondCallInit = fetchFn.mock.calls.at(1)?.[1] as RequestInit | undefined;
const secondHeaders = new Headers(secondCallInit?.headers);
expect(secondHeaders.get("authorization")).toBe(expectedAuthHeader);
},
);
it("continues across scopes when token retrieval fails", async () => {
const fetchFn = vi

View File

@@ -42,9 +42,13 @@ async function collectRuntimeExports(filePath: string, seen = new Set<string>())
return exportNames;
}
async function readIndexRuntimeExports() {
return await collectRuntimeExports(path.join(import.meta.dirname, "index.ts"));
}
describe("plugin-sdk exports", () => {
it("does not expose runtime modules", async () => {
const runtimeExports = await collectRuntimeExports(path.join(import.meta.dirname, "index.ts"));
const runtimeExports = await readIndexRuntimeExports();
const forbidden = [
"chunkMarkdownText",
"chunkText",
@@ -87,7 +91,7 @@ describe("plugin-sdk exports", () => {
});
it("keeps the root runtime surface intentionally small", async () => {
const runtimeExports = await collectRuntimeExports(path.join(import.meta.dirname, "index.ts"));
const runtimeExports = await readIndexRuntimeExports();
expect([...runtimeExports].toSorted()).toEqual([
"delegateCompactionToRuntime",
"emptyPluginConfigSchema",

View File

@@ -57,23 +57,25 @@ describe("enqueueKeyedTask", () => {
it("keeps queue alive after task failures", async () => {
const tails = new Map<string, Promise<void>>();
await expect(
enqueueKeyedTask({
tails,
key: "a",
task: async () => {
throw new Error("boom");
},
}),
).rejects.toThrow("boom");
const runs = [
() =>
enqueueKeyedTask({
tails,
key: "a",
task: async () => {
throw new Error("boom");
},
}),
() =>
enqueueKeyedTask({
tails,
key: "a",
task: async () => "ok",
}),
];
await expect(
enqueueKeyedTask({
tails,
key: "a",
task: async () => "ok",
}),
).resolves.toBe("ok");
await expect(runs[0]()).rejects.toThrow("boom");
await expect(runs[1]()).resolves.toBe("ok");
});
it("runs enqueue/settle hooks once per task", async () => {

View File

@@ -12,6 +12,15 @@ async function makeTmpRoot(): Promise<string> {
return root;
}
function createDedupe(root: string, overrides?: { ttlMs?: number }) {
return createPersistentDedupe({
ttlMs: overrides?.ttlMs ?? 24 * 60 * 60 * 1000,
memoryMaxSize: 100,
fileMaxEntries: 1000,
resolveFilePath: (namespace) => path.join(root, `${namespace}.json`),
});
}
afterEach(async () => {
await Promise.all(
tmpRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
@@ -21,35 +30,18 @@ afterEach(async () => {
describe("createPersistentDedupe", () => {
it("deduplicates keys and persists across instances", async () => {
const root = await makeTmpRoot();
const resolveFilePath = (namespace: string) => path.join(root, `${namespace}.json`);
const first = createPersistentDedupe({
ttlMs: 24 * 60 * 60 * 1000,
memoryMaxSize: 100,
fileMaxEntries: 1000,
resolveFilePath,
});
const first = createDedupe(root);
expect(await first.checkAndRecord("m1", { namespace: "a" })).toBe(true);
expect(await first.checkAndRecord("m1", { namespace: "a" })).toBe(false);
const second = createPersistentDedupe({
ttlMs: 24 * 60 * 60 * 1000,
memoryMaxSize: 100,
fileMaxEntries: 1000,
resolveFilePath,
});
const second = createDedupe(root);
expect(await second.checkAndRecord("m1", { namespace: "a" })).toBe(false);
expect(await second.checkAndRecord("m1", { namespace: "b" })).toBe(true);
});
it("guards concurrent calls for the same key", async () => {
const root = await makeTmpRoot();
const dedupe = createPersistentDedupe({
ttlMs: 10_000,
memoryMaxSize: 100,
fileMaxEntries: 1000,
resolveFilePath: (namespace) => path.join(root, `${namespace}.json`),
});
const dedupe = createDedupe(root, { ttlMs: 10_000 });
const [first, second] = await Promise.all([
dedupe.checkAndRecord("race-key", { namespace: "feishu" }),
@@ -73,23 +65,11 @@ describe("createPersistentDedupe", () => {
it("warmup loads persisted entries into memory", async () => {
const root = await makeTmpRoot();
const resolveFilePath = (namespace: string) => path.join(root, `${namespace}.json`);
const writer = createPersistentDedupe({
ttlMs: 24 * 60 * 60 * 1000,
memoryMaxSize: 100,
fileMaxEntries: 1000,
resolveFilePath,
});
const writer = createDedupe(root);
expect(await writer.checkAndRecord("msg-1", { namespace: "acct" })).toBe(true);
expect(await writer.checkAndRecord("msg-2", { namespace: "acct" })).toBe(true);
const reader = createPersistentDedupe({
ttlMs: 24 * 60 * 60 * 1000,
memoryMaxSize: 100,
fileMaxEntries: 1000,
resolveFilePath,
});
const reader = createDedupe(root);
const loaded = await reader.warmup("acct");
expect(loaded).toBe(2);
expect(await reader.checkAndRecord("msg-1", { namespace: "acct" })).toBe(false);
@@ -97,42 +77,37 @@ describe("createPersistentDedupe", () => {
expect(await reader.checkAndRecord("msg-3", { namespace: "acct" })).toBe(true);
});
it("warmup returns 0 when no disk file exists", async () => {
it.each([
{
name: "returns 0 when no disk file exists",
setup: async (root: string) => createDedupe(root, { ttlMs: 10_000 }),
namespace: "nonexistent",
expectedLoaded: 0,
verify: async () => undefined,
},
{
name: "skips expired entries",
setup: async (root: string) => {
const writer = createDedupe(root, { ttlMs: 1000 });
const oldNow = Date.now() - 2000;
expect(await writer.checkAndRecord("old-msg", { namespace: "acct", now: oldNow })).toBe(
true,
);
expect(await writer.checkAndRecord("new-msg", { namespace: "acct" })).toBe(true);
return createDedupe(root, { ttlMs: 1000 });
},
namespace: "acct",
expectedLoaded: 1,
verify: async (reader: ReturnType<typeof createDedupe>) => {
expect(await reader.checkAndRecord("old-msg", { namespace: "acct" })).toBe(true);
expect(await reader.checkAndRecord("new-msg", { namespace: "acct" })).toBe(false);
},
},
])("warmup $name", async ({ setup, namespace, expectedLoaded, verify }) => {
const root = await makeTmpRoot();
const dedupe = createPersistentDedupe({
ttlMs: 10_000,
memoryMaxSize: 100,
fileMaxEntries: 1000,
resolveFilePath: (ns) => path.join(root, `${ns}.json`),
});
const loaded = await dedupe.warmup("nonexistent");
expect(loaded).toBe(0);
});
it("warmup skips expired entries", async () => {
const root = await makeTmpRoot();
const resolveFilePath = (namespace: string) => path.join(root, `${namespace}.json`);
const ttlMs = 1000;
const writer = createPersistentDedupe({
ttlMs,
memoryMaxSize: 100,
fileMaxEntries: 1000,
resolveFilePath,
});
const oldNow = Date.now() - 2000;
expect(await writer.checkAndRecord("old-msg", { namespace: "acct", now: oldNow })).toBe(true);
expect(await writer.checkAndRecord("new-msg", { namespace: "acct" })).toBe(true);
const reader = createPersistentDedupe({
ttlMs,
memoryMaxSize: 100,
fileMaxEntries: 1000,
resolveFilePath,
});
const loaded = await reader.warmup("acct");
expect(loaded).toBe(1);
expect(await reader.checkAndRecord("old-msg", { namespace: "acct" })).toBe(true);
expect(await reader.checkAndRecord("new-msg", { namespace: "acct" })).toBe(false);
const reader = await setup(root);
const loaded = await reader.warmup(namespace);
expect(loaded).toBe(expectedLoaded);
await verify(reader);
});
});

View File

@@ -35,6 +35,16 @@ function createCatalogContext(
};
}
async function captureProviderEntry(params: {
entry: ReturnType<typeof defineSingleProviderPluginEntry>;
config?: ProviderCatalogContext["config"];
}) {
const captured = capturePluginRegistration(params.entry);
const provider = captured.providers[0];
const catalog = await provider?.catalog?.run(createCatalogContext(params.config));
return { captured, provider, catalog };
}
describe("defineSingleProviderPluginEntry", () => {
it("registers a single provider with default wizard metadata", async () => {
const entry = defineSingleProviderPluginEntry({
@@ -66,9 +76,8 @@ describe("defineSingleProviderPluginEntry", () => {
},
});
const captured = capturePluginRegistration(entry);
const { captured, provider, catalog } = await captureProviderEntry({ entry });
expect(captured.providers).toHaveLength(1);
const provider = captured.providers[0];
expect(provider).toMatchObject({
id: "demo",
label: "Demo",
@@ -90,7 +99,6 @@ describe("defineSingleProviderPluginEntry", () => {
methodId: "api-key",
});
const catalog = await provider?.catalog?.run(createCatalogContext());
expect(catalog).toEqual({
provider: {
api: "openai-completions",
@@ -159,11 +167,22 @@ describe("defineSingleProviderPluginEntry", () => {
},
});
const captured = capturePluginRegistration(entry);
const { captured, provider, catalog } = await captureProviderEntry({
entry,
config: {
models: {
providers: {
gateway: {
baseUrl: "https://override.test/v1",
models: [createModel("router", "Router")],
},
},
},
},
});
expect(captured.providers).toHaveLength(1);
expect(captured.webSearchProviders).toHaveLength(1);
const provider = captured.providers[0];
expect(provider).toMatchObject({
id: "gateway",
label: "Gateway",
@@ -180,18 +199,6 @@ describe("defineSingleProviderPluginEntry", () => {
groupHint: "Primary key",
});
const catalog = await provider?.catalog?.run(
createCatalogContext({
models: {
providers: {
gateway: {
baseUrl: "https://override.test/v1",
models: [createModel("router", "Router")],
},
},
},
}),
);
expect(catalog).toEqual({
provider: {
api: "openai-completions",

View File

@@ -2,16 +2,23 @@ import { describe, expect, it } from "vitest";
import { resolveRequestUrl } from "./request-url.js";
describe("resolveRequestUrl", () => {
it("resolves string input", () => {
expect(resolveRequestUrl("https://example.com/a")).toBe("https://example.com/a");
});
it("resolves URL input", () => {
expect(resolveRequestUrl(new URL("https://example.com/b"))).toBe("https://example.com/b");
});
it("resolves object input with url field", () => {
const requestLike = { url: "https://example.com/c" } as unknown as RequestInfo;
expect(resolveRequestUrl(requestLike)).toBe("https://example.com/c");
it.each([
{
name: "resolves string input",
input: "https://example.com/a",
expected: "https://example.com/a",
},
{
name: "resolves URL input",
input: new URL("https://example.com/b"),
expected: "https://example.com/b",
},
{
name: "resolves object input with url field",
input: { url: "https://example.com/c" } as unknown as RequestInfo,
expected: "https://example.com/c",
},
])("$name", ({ input, expected }) => {
expect(resolveRequestUrl(input)).toBe(expected);
});
});

View File

@@ -108,6 +108,14 @@ function loadRootAliasWithStubs(options?: {
};
}
function createPackageRoot() {
return path.dirname(path.dirname(rootAliasPath));
}
function createDistAliasPath() {
return path.join(createPackageRoot(), "dist", "plugin-sdk", "root-alias.cjs");
}
describe("plugin-sdk root alias", () => {
it("exposes the fast empty config schema helper", () => {
const factory = rootSdk.emptyPluginConfigSchema as (() => EmptySchema) | undefined;
@@ -149,7 +157,7 @@ describe("plugin-sdk root alias", () => {
it("loads legacy root exports on demand and preserves reflection", () => {
const lazyModule = loadRootAliasWithStubs({
monolithicExports: {
slowHelper: () => "loaded",
slowHelper: (): string => "loaded",
},
});
const lazyRootSdk = lazyModule.moduleExports;
@@ -164,40 +172,44 @@ describe("plugin-sdk root alias", () => {
expect(Object.getOwnPropertyDescriptor(lazyRootSdk, "slowHelper")).toBeDefined();
});
it("prefers native loading when compat resolves to dist", () => {
const lazyModule = loadRootAliasWithStubs({
distExists: true,
monolithicExports: {
slowHelper: () => "loaded",
it.each([
{
name: "prefers native loading when compat resolves to dist",
options: {
distExists: true,
monolithicExports: {
slowHelper: (): string => "loaded",
},
},
});
expectedTryNative: true,
},
{
name: "prefers source loading under vitest even when compat resolves to dist",
options: {
distExists: true,
env: { VITEST: "1" },
monolithicExports: {
slowHelper: (): string => "loaded",
},
},
expectedTryNative: false,
},
])("$name", ({ options, expectedTryNative }) => {
const lazyModule = loadRootAliasWithStubs(options);
expect((lazyModule.moduleExports.slowHelper as () => string)()).toBe("loaded");
expect(lazyModule.createJitiOptions.at(-1)?.tryNative).toBe(true);
});
it("prefers source loading under vitest even when compat resolves to dist", () => {
const lazyModule = loadRootAliasWithStubs({
distExists: true,
env: { VITEST: "1" },
monolithicExports: {
slowHelper: () => "loaded",
},
});
expect((lazyModule.moduleExports.slowHelper as () => string)()).toBe("loaded");
expect(lazyModule.createJitiOptions.at(-1)?.tryNative).toBe(false);
expect(lazyModule.createJitiOptions.at(-1)?.tryNative).toBe(expectedTryNative);
});
it("falls back to src files even when the alias itself is loaded from dist", () => {
const packageRoot = path.dirname(path.dirname(rootAliasPath));
const distAliasPath = path.join(packageRoot, "dist", "plugin-sdk", "root-alias.cjs");
const packageRoot = createPackageRoot();
const distAliasPath = createDistAliasPath();
const lazyModule = loadRootAliasWithStubs({
aliasPath: distAliasPath,
distExists: false,
monolithicExports: {
onDiagnosticEvent: () => () => undefined,
slowHelper: () => "loaded",
onDiagnosticEvent: (): (() => void) => () => undefined,
slowHelper: (): string => "loaded",
},
});
@@ -216,15 +228,15 @@ describe("plugin-sdk root alias", () => {
});
it("prefers hashed dist diagnostic events chunks before falling back to src", () => {
const packageRoot = path.dirname(path.dirname(rootAliasPath));
const distAliasPath = path.join(packageRoot, "dist", "plugin-sdk", "root-alias.cjs");
const packageRoot = createPackageRoot();
const distAliasPath = createDistAliasPath();
const lazyModule = loadRootAliasWithStubs({
aliasPath: distAliasPath,
distExists: false,
distEntries: ["diagnostic-events-W3Hz61fI.js"],
monolithicExports: {
r: () => () => undefined,
slowHelper: () => "loaded",
r: (): (() => void) => () => undefined,
slowHelper: (): string => "loaded",
},
});
@@ -241,36 +253,42 @@ describe("plugin-sdk root alias", () => {
);
});
it("forwards delegateCompactionToRuntime through the compat-backed root alias", () => {
const delegateCompactionToRuntime = () => "delegated";
it.each([
{
name: "forwards delegateCompactionToRuntime through the compat-backed root alias",
exportName: "delegateCompactionToRuntime",
exportValue: () => "delegated",
expectIdentity: true,
assertForwarded: (value: unknown) => {
expect(typeof value).toBe("function");
expect((value as () => string)()).toBe("delegated");
},
},
{
name: "forwards onDiagnosticEvent through the compat-backed root alias",
exportName: "onDiagnosticEvent",
exportValue: () => () => undefined,
expectIdentity: false,
assertForwarded: (value: unknown) => {
expect(typeof value).toBe("function");
expect(typeof (value as (listener: () => void) => () => void)(() => undefined)).toBe(
"function",
);
},
},
])("$name", ({ exportName, exportValue, expectIdentity, assertForwarded }) => {
const lazyModule = loadRootAliasWithStubs({
monolithicExports: {
delegateCompactionToRuntime,
[exportName]: exportValue,
},
});
const lazyRootSdk = lazyModule.moduleExports;
const forwarded = lazyModule.moduleExports[exportName];
expect(typeof lazyRootSdk.delegateCompactionToRuntime).toBe("function");
expect(lazyRootSdk.delegateCompactionToRuntime).toBe(delegateCompactionToRuntime);
expect("delegateCompactionToRuntime" in lazyRootSdk).toBe(true);
});
it("forwards onDiagnosticEvent through the compat-backed root alias", () => {
const onDiagnosticEvent = () => () => undefined;
const lazyModule = loadRootAliasWithStubs({
monolithicExports: {
onDiagnosticEvent,
},
});
const lazyRootSdk = lazyModule.moduleExports;
expect(typeof lazyRootSdk.onDiagnosticEvent).toBe("function");
expect(
typeof (lazyRootSdk.onDiagnosticEvent as (listener: () => void) => () => void)(
() => undefined,
),
).toBe("function");
expect("onDiagnosticEvent" in lazyRootSdk).toBe(true);
assertForwarded(forwarded);
if (expectIdentity) {
expect(forwarded).toBe(exportValue);
}
expect(exportName in lazyModule.moduleExports).toBe(true);
});
it("loads legacy root exports through the merged root wrapper", { timeout: 240_000 }, () => {

View File

@@ -6,19 +6,27 @@ import {
} from "./secret-input.js";
describe("plugin-sdk secret input helpers", () => {
it("accepts undefined for optional secret input", () => {
expect(buildOptionalSecretInputSchema().safeParse(undefined).success).toBe(true);
});
it("accepts arrays of secret inputs", () => {
const result = buildSecretInputArraySchema().safeParse([
"sk-plain",
{ source: "env", provider: "default", id: "OPENAI_API_KEY" },
]);
expect(result.success).toBe(true);
});
it("normalizes plaintext secret strings", () => {
expect(normalizeSecretInputString(" sk-test ")).toBe("sk-test");
it.each([
{
name: "accepts undefined for optional secret input",
run: () => buildOptionalSecretInputSchema().safeParse(undefined).success,
expected: true,
},
{
name: "accepts arrays of secret inputs",
run: () =>
buildSecretInputArraySchema().safeParse([
"sk-plain",
{ source: "env", provider: "default", id: "OPENAI_API_KEY" },
]).success,
expected: true,
},
{
name: "normalizes plaintext secret strings",
run: () => normalizeSecretInputString(" sk-test "),
expected: "sk-test",
},
])("$name", ({ run, expected }) => {
expect(run()).toEqual(expected);
});
});

View File

@@ -4,31 +4,49 @@ import { describe, expect, it } from "vitest";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js";
describe("buildRandomTempFilePath", () => {
it("builds deterministic paths when now/uuid are provided", () => {
const result = buildRandomTempFilePath({
prefix: "line-media",
extension: ".jpg",
tmpDir: "/tmp",
now: 123,
uuid: "abc",
});
expect(result).toBe(path.join("/tmp", "line-media-123-abc.jpg"));
});
function expectPathInsideTmpRoot(resultPath: string) {
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
const resolved = path.resolve(resultPath);
const rel = path.relative(tmpRoot, resolved);
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
expect(resultPath).not.toContain("..");
}
it("sanitizes prefix and extension to avoid path traversal segments", () => {
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
const result = buildRandomTempFilePath({
prefix: "../../channels/../media",
extension: "/../.jpg",
now: 123,
uuid: "abc",
});
const resolved = path.resolve(result);
const rel = path.relative(tmpRoot, resolved);
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
expect(path.basename(result)).toBe("channels-media-123-abc.jpg");
expect(result).not.toContain("..");
describe("buildRandomTempFilePath", () => {
it.each([
{
name: "builds deterministic paths when now/uuid are provided",
input: {
prefix: "line-media",
extension: ".jpg",
tmpDir: "/tmp",
now: 123,
uuid: "abc",
},
expectedPath: path.join("/tmp", "line-media-123-abc.jpg"),
expectedBasename: "line-media-123-abc.jpg",
verifyInsideTmpRoot: false,
},
{
name: "sanitizes prefix and extension to avoid path traversal segments",
input: {
prefix: "../../channels/../media",
extension: "/../.jpg",
now: 123,
uuid: "abc",
},
expectedBasename: "channels-media-123-abc.jpg",
verifyInsideTmpRoot: true,
},
])("$name", ({ input, expectedPath, expectedBasename, verifyInsideTmpRoot }) => {
const result = buildRandomTempFilePath(input);
if (expectedPath) {
expect(result).toBe(expectedPath);
}
expect(path.basename(result)).toBe(expectedBasename);
if (verifyInsideTmpRoot) {
expectPathInsideTmpRoot(result);
}
});
});
@@ -50,7 +68,6 @@ describe("withTempDownloadPath", () => {
});
it("sanitizes prefix and fileName", async () => {
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
let capturedPath = "";
await withTempDownloadPath(
{
@@ -62,10 +79,7 @@ describe("withTempDownloadPath", () => {
},
);
const resolved = path.resolve(capturedPath);
const rel = path.relative(tmpRoot, resolved);
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
expectPathInsideTmpRoot(capturedPath);
expect(path.basename(capturedPath)).toBe("evil.bin");
expect(capturedPath).not.toContain("..");
});
});

View File

@@ -2,15 +2,26 @@ import { describe, expect, it } from "vitest";
import { chunkTextForOutbound } from "./text-chunking.js";
describe("chunkTextForOutbound", () => {
it("returns empty for empty input", () => {
expect(chunkTextForOutbound("", 10)).toEqual([]);
});
it("splits on newline or whitespace boundaries", () => {
expect(chunkTextForOutbound("alpha\nbeta gamma", 8)).toEqual(["alpha", "beta", "gamma"]);
});
it("falls back to hard limit when no separator exists", () => {
expect(chunkTextForOutbound("abcdefghij", 4)).toEqual(["abcd", "efgh", "ij"]);
it.each([
{
name: "returns empty for empty input",
text: "",
maxLen: 10,
expected: [],
},
{
name: "splits on newline or whitespace boundaries",
text: "alpha\nbeta gamma",
maxLen: 8,
expected: ["alpha", "beta", "gamma"],
},
{
name: "falls back to hard limit when no separator exists",
text: "abcdefghij",
maxLen: 4,
expected: ["abcd", "efgh", "ij"],
},
])("$name", ({ text, maxLen, expected }) => {
expect(chunkTextForOutbound(text, maxLen)).toEqual(expected);
});
});