mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-25 16:12:13 +00:00
test: dedupe infra and plugin-sdk utility suites
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }, () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("..");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user