test: stabilize browser and provider ci shards

This commit is contained in:
Peter Steinberger 2026-04-06 03:20:09 +01:00
parent 332e7d9d7b
commit b1c98e8469
No known key found for this signature in database
34 changed files with 345 additions and 542 deletions

View File

@ -1,4 +1,4 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", async () => {
const { mockNodeBuiltinModule } = await import("../../../../test/helpers/node-builtin-mocks.js");
@ -31,11 +31,11 @@ vi.mock("node:os", async () => {
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import os from "node:os";
const { resolveBrowserExecutableForPlatform } = await import("./chrome.executables.js");
describe("browser default executable detection", () => {
const launchServicesPlist = "com.apple.launchservices.secure.plist";
const chromeExecutablePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
let resolveBrowserExecutableForPlatform: typeof import("./chrome.executables.js").resolveBrowserExecutableForPlatform;
function mockMacDefaultBrowser(bundleId: string, appPath = ""): void {
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
@ -63,10 +63,6 @@ describe("browser default executable detection", () => {
});
}
beforeAll(async () => {
({ resolveBrowserExecutableForPlatform } = await import("./chrome.executables.js"));
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(os.homedir).mockReturnValue("/Users/test");

View File

@ -1,4 +1,4 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserDispatchResponse } from "./routes/dispatcher.js";
function okDispatchResponse(): BrowserDispatchResponse {
@ -49,7 +49,7 @@ vi.mock("./routes/dispatcher.js", () => ({
})),
}));
let fetchBrowserJson: typeof import("./client-fetch.js").fetchBrowserJson;
const { fetchBrowserJson } = await import("./client-fetch.js");
function stubJsonFetchOk() {
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
@ -85,10 +85,6 @@ async function expectThrownBrowserFetchError(
}
describe("fetchBrowserJson loopback auth", () => {
beforeAll(async () => {
({ fetchBrowserJson } = await import("./client-fetch.js"));
});
beforeEach(() => {
vi.restoreAllMocks();
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "loopback-token");

View File

@ -1,4 +1,4 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
ensureBrowserControlAuth: vi.fn(async () => ({ generatedToken: false })),
@ -42,13 +42,9 @@ vi.mock("./runtime-lifecycle.js", () => ({
stopBrowserRuntime: vi.fn(async () => {}),
}));
let startBrowserControlServiceFromConfig: typeof import("../control-service.js").startBrowserControlServiceFromConfig;
const { startBrowserControlServiceFromConfig } = await import("../control-service.js");
describe("startBrowserControlServiceFromConfig", () => {
beforeAll(async () => {
({ startBrowserControlServiceFromConfig } = await import("../control-service.js"));
});
beforeEach(() => {
mocks.ensureBrowserControlAuth.mockClear();
mocks.createBrowserRuntimeState.mockClear();

View File

@ -1,6 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveOpenClawUserDataDir } from "./chrome.js";
import type { BrowserRouteContext, BrowserServerState } from "./server-context.js";
@ -23,8 +23,10 @@ vi.mock("./chrome.js", () => ({
resolveOpenClawUserDataDir: vi.fn(() => "/tmp/openclaw-test/openclaw/user-data"),
}));
let resolveBrowserConfig: typeof import("./config.js").resolveBrowserConfig;
let createBrowserProfilesService: typeof import("./profiles-service.js").createBrowserProfilesService;
const [{ resolveBrowserConfig }, { createBrowserProfilesService }] = await Promise.all([
import("./config.js"),
import("./profiles-service.js"),
]);
function createCtx(resolved: BrowserServerState["resolved"]) {
const state: BrowserServerState = {
@ -57,11 +59,6 @@ async function createWorkProfileWithConfig(params: {
}
describe("BrowserProfilesService", () => {
beforeAll(async () => {
({ resolveBrowserConfig } = await import("./config.js"));
({ createBrowserProfilesService } = await import("./profiles-service.js"));
});
beforeEach(() => {
vi.clearAllMocks();
});

View File

@ -1,4 +1,4 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
@ -6,13 +6,9 @@ import {
} from "./pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
let mod: typeof import("./pw-tools-core.js");
const mod = await import("./pw-tools-core.js");
describe("pw-tools-core", () => {
beforeAll(async () => {
mod = await import("./pw-tools-core.js");
});
it("clamps timeoutMs for scrollIntoView", async () => {
const scrollIntoViewIfNeeded = vi.fn(async () => {});
setPwToolsCoreCurrentRefLocator({ scrollIntoViewIfNeeded });

View File

@ -1,4 +1,4 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
let page: { evaluate: ReturnType<typeof vi.fn> } | null = null;
@ -31,13 +31,9 @@ vi.mock("./pw-tools-core.snapshot.js", () => ({
resizeViewportViaPlaywright,
}));
let batchViaPlaywright: typeof import("./pw-tools-core.interactions.js").batchViaPlaywright;
const { batchViaPlaywright } = await import("./pw-tools-core.interactions.js");
describe("batchViaPlaywright", () => {
beforeAll(async () => {
({ batchViaPlaywright } = await import("./pw-tools-core.interactions.js"));
});
beforeEach(() => {
vi.clearAllMocks();
page = {

View File

@ -1,4 +1,4 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
let page: { evaluate: ReturnType<typeof vi.fn> } | null = null;
let locator: { evaluate: ReturnType<typeof vi.fn> } | null = null;
@ -29,7 +29,7 @@ vi.mock("./pw-session.js", () => {
};
});
let evaluateViaPlaywright: typeof import("./pw-tools-core.interactions.js").evaluateViaPlaywright;
const { evaluateViaPlaywright } = await import("./pw-tools-core.interactions.js");
function createPendingEval() {
let evalCalled!: () => void;
@ -43,10 +43,6 @@ function createPendingEval() {
}
describe("evaluateViaPlaywright (abort)", () => {
beforeAll(async () => {
({ evaluateViaPlaywright } = await import("./pw-tools-core.interactions.js"));
});
beforeEach(() => {
vi.clearAllMocks();
page = null;

View File

@ -39,7 +39,7 @@ vi.mock("./paths.js", () => {
};
});
let setInputFilesViaPlaywright: typeof import("./pw-tools-core.interactions.js").setInputFilesViaPlaywright;
const { setInputFilesViaPlaywright } = await import("./pw-tools-core.interactions.js");
function seedSingleLocatorPage(): { setInputFiles: ReturnType<typeof vi.fn> } {
const setInputFiles = vi.fn(async () => {});
@ -54,9 +54,7 @@ function seedSingleLocatorPage(): { setInputFiles: ReturnType<typeof vi.fn> } {
}
describe("setInputFilesViaPlaywright", () => {
beforeEach(async () => {
vi.resetModules();
({ setInputFilesViaPlaywright } = await import("./pw-tools-core.interactions.js"));
beforeEach(() => {
vi.clearAllMocks();
page = null;
locator = null;

View File

@ -1,7 +1,7 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_UPLOAD_DIR } from "./paths.js";
import {
installPwToolsCoreTestHooks,
@ -9,13 +9,9 @@ import {
} from "./pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
let mod: typeof import("./pw-tools-core.js");
const mod = await import("./pw-tools-core.js");
describe("pw-tools-core", () => {
beforeAll(async () => {
mod = await import("./pw-tools-core.js");
});
it("last file-chooser arm wins", async () => {
const firstPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-arm-1-${crypto.randomUUID()}.txt`);
const secondPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-arm-2-${crypto.randomUUID()}.txt`);

View File

@ -1,7 +1,7 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_UPLOAD_DIR } from "./paths.js";
import {
getPwToolsCoreSessionMocks,
@ -12,7 +12,7 @@ import {
installPwToolsCoreTestHooks();
const sessionMocks = getPwToolsCoreSessionMocks();
let mod: typeof import("./pw-tools-core.js");
const mod = await import("./pw-tools-core.js");
function createFileChooserPageMocks() {
const fileChooser = { setFiles: vi.fn(async () => {}) };
@ -26,10 +26,6 @@ function createFileChooserPageMocks() {
}
describe("pw-tools-core", () => {
beforeAll(async () => {
mod = await import("./pw-tools-core.js");
});
beforeEach(() => {
vi.clearAllMocks();
});

View File

@ -1,4 +1,4 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
import type { BrowserRequest } from "./types.js";
@ -94,13 +94,8 @@ vi.mock("./agent.shared.js", () => ({
}),
}));
let registerBrowserAgentActRoutes: typeof import("./agent.act.js").registerBrowserAgentActRoutes;
let registerBrowserAgentSnapshotRoutes: typeof import("./agent.snapshot.js").registerBrowserAgentSnapshotRoutes;
beforeAll(async () => {
({ registerBrowserAgentActRoutes } = await import("./agent.act.js"));
({ registerBrowserAgentSnapshotRoutes } = await import("./agent.snapshot.js"));
});
const { registerBrowserAgentActRoutes } = await import("./agent.act.js");
const { registerBrowserAgentSnapshotRoutes } = await import("./agent.snapshot.js");
function getSnapshotGetHandler() {
const { app, getHandlers } = createBrowserRouteApp();

View File

@ -1,12 +1,12 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
vi.mock("../chrome-mcp.js", () => ({
getChromeMcpPid: vi.fn(() => 4321),
}));
let registerBrowserBasicRoutes: typeof import("./basic.js").registerBrowserBasicRoutes;
let BrowserProfileUnavailableError: typeof import("../errors.js").BrowserProfileUnavailableError;
const { BrowserProfileUnavailableError } = await import("../errors.js");
const { registerBrowserBasicRoutes } = await import("./basic.js");
function createExistingSessionProfileState(params?: { isHttpReachable?: () => Promise<boolean> }) {
return {
@ -52,11 +52,6 @@ async function callBasicRouteWithState(params: {
return response;
}
beforeAll(async () => {
({ BrowserProfileUnavailableError } = await import("../errors.js"));
({ registerBrowserBasicRoutes } = await import("./basic.js"));
});
describe("basic browser routes", () => {
it("maps existing-session status failures to JSON browser errors", async () => {
const response = await callBasicRouteWithState({

View File

@ -1,5 +1,5 @@
import fs from "node:fs";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserServerState } from "./server-context.js";
vi.mock("./chrome-mcp.js", () => ({
@ -19,8 +19,8 @@ vi.mock("./chrome-mcp.js", () => ({
getChromeMcpPid: vi.fn(() => 4321),
}));
let createBrowserRouteContext: typeof import("./server-context.js").createBrowserRouteContext;
let chromeMcp: typeof import("./chrome-mcp.js");
const { createBrowserRouteContext } = await import("./server-context.js");
const chromeMcp = await import("./chrome-mcp.js");
function makeState(): BrowserServerState {
return {
@ -58,11 +58,6 @@ function makeState(): BrowserServerState {
};
}
beforeAll(async () => {
({ createBrowserRouteContext } = await import("./server-context.js"));
chromeMcp = await import("./chrome-mcp.js");
});
beforeEach(() => {
vi.clearAllMocks();
});

View File

@ -61,19 +61,13 @@ vi.mock("./config-refresh-source.js", () => ({
loadBrowserConfigForRuntimeRefresh: () => buildConfig(),
}));
describe("server-context hot-reload profiles", () => {
let loadConfig: typeof import("../config/config.js").loadConfig;
let resolveBrowserConfig: typeof import("./config.js").resolveBrowserConfig;
let resolveProfile: typeof import("./config.js").resolveProfile;
let refreshResolvedBrowserConfigFromDisk: typeof import("./resolved-config-refresh.js").refreshResolvedBrowserConfigFromDisk;
let resolveBrowserProfileWithHotReload: typeof import("./resolved-config-refresh.js").resolveBrowserProfileWithHotReload;
const { loadConfig } = await import("../config/config.js");
const { resolveBrowserConfig, resolveProfile } = await import("./config.js");
const { refreshResolvedBrowserConfigFromDisk, resolveBrowserProfileWithHotReload } =
await import("./resolved-config-refresh.js");
beforeEach(async () => {
vi.resetModules();
({ loadConfig } = await import("../config/config.js"));
({ resolveBrowserConfig, resolveProfile } = await import("./config.js"));
({ refreshResolvedBrowserConfigFromDisk, resolveBrowserProfileWithHotReload } =
await import("./resolved-config-refresh.js"));
describe("server-context hot-reload profiles", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.cfgProfiles = {
openclaw: { cdpPort: 18800, color: "#FF4500" },

View File

@ -1,14 +1,10 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
loadRemoteProfileTestDeps,
type RemoteProfileTestDeps,
} from "./server-context.remote-profile-tab-ops.shared.js";
let deps: RemoteProfileTestDeps;
beforeAll(async () => {
deps = await loadRemoteProfileTestDeps();
});
const deps: RemoteProfileTestDeps = await loadRemoteProfileTestDeps();
beforeEach(() => {
vi.clearAllMocks();

View File

@ -1,14 +1,10 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
loadRemoteProfileTestDeps,
type RemoteProfileTestDeps,
} from "./server-context.remote-profile-tab-ops.shared.js";
let deps: RemoteProfileTestDeps;
beforeAll(async () => {
deps = await loadRemoteProfileTestDeps();
});
const deps: RemoteProfileTestDeps = await loadRemoteProfileTestDeps();
beforeEach(() => {
vi.clearAllMocks();

View File

@ -1,4 +1,4 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { stopOpenClawChromeMock } = vi.hoisted(() => ({
stopOpenClawChromeMock: vi.fn(async () => {}),
@ -18,13 +18,8 @@ vi.mock("./server-context.js", () => ({
listKnownProfileNames: listKnownProfileNamesMock,
}));
let ensureExtensionRelayForProfiles: typeof import("./server-lifecycle.js").ensureExtensionRelayForProfiles;
let stopKnownBrowserProfiles: typeof import("./server-lifecycle.js").stopKnownBrowserProfiles;
beforeAll(async () => {
({ ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } =
await import("./server-lifecycle.js"));
});
const { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } =
await import("./server-lifecycle.js");
beforeEach(() => {
createBrowserRouteContextMock.mockClear();

View File

@ -400,6 +400,7 @@ export async function cleanupBrowserControlServerTestContext(): Promise<void> {
}
export function installBrowserControlServerHooks() {
const hookTimeoutMs = process.platform === "win32" ? 300_000 : 240_000;
beforeEach(async () => {
vi.useRealTimers();
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
@ -463,7 +464,7 @@ export function installBrowserControlServerHooks() {
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
}, hookTimeoutMs);
afterEach(async () => {
await cleanupBrowserControlServerTestContext();

View File

@ -1,4 +1,4 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getBrowserTestFetch } from "./test-fetch.js";
import { getFreePort } from "./test-port.js";
@ -65,15 +65,10 @@ vi.mock("./server-context.js", async () => {
};
});
let startBrowserControlServerFromConfig: typeof import("./server.js").startBrowserControlServerFromConfig;
let stopBrowserControlServer: typeof import("./server.js").stopBrowserControlServer;
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
await import("./server.js");
describe("browser control evaluate gating", () => {
beforeAll(async () => {
({ startBrowserControlServerFromConfig, stopBrowserControlServer } =
await import("./server.js"));
});
beforeEach(async () => {
testPort = await getFreePort();
prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;

View File

@ -5,22 +5,6 @@ import type { IMessageRpcClient } from "./client.js";
import { imessageOutbound } from "./outbound-adapter.js";
import { sendMessageIMessage } from "./send.js";
function requireIMessageSendText() {
const sendText = imessagePlugin.outbound?.sendText;
if (!sendText) {
throw new Error("imessage outbound.sendText unavailable");
}
return sendText;
}
function requireIMessageSendMedia() {
const sendMedia = imessagePlugin.outbound?.sendMedia;
if (!sendMedia) {
throw new Error("imessage outbound.sendMedia unavailable");
}
return sendMedia;
}
function requireIMessageChunker() {
const chunker = imessagePlugin.outbound?.chunker;
if (!chunker) {
@ -63,136 +47,43 @@ function getSentParams() {
return requestMock.mock.calls[0]?.[1] as Record<string, unknown>;
}
async function expectDirectOutboundResult(params: {
invoke: () => Promise<{ channel: string; messageId: string }>;
sendIMessage: ReturnType<typeof vi.fn>;
to: string;
text: string;
expectedOptions: Record<string, unknown>;
expectedResult: { channel: string; messageId: string };
}) {
const result = await params.invoke();
expect(params.sendIMessage).toHaveBeenCalledWith(
params.to,
params.text,
expect.objectContaining(params.expectedOptions),
);
expect(result).toEqual(params.expectedResult);
}
async function expectReplyToTextForwarding(params: {
invoke: () => Promise<{ channel: string; messageId: string }>;
sendIMessage: ReturnType<typeof vi.fn>;
}) {
await expectDirectOutboundResult({
invoke: params.invoke,
sendIMessage: params.sendIMessage,
to: "chat_id:12",
text: "hello",
expectedOptions: {
const result = await params.invoke();
expect(params.sendIMessage).toHaveBeenCalledWith(
"chat_id:12",
"hello",
expect.objectContaining({
accountId: "default",
replyToId: "reply-1",
maxBytes: 3 * 1024 * 1024,
},
expectedResult: { channel: "imessage", messageId: "m-text" },
});
}),
);
expect(result).toEqual({ channel: "imessage", messageId: "m-text" });
}
async function expectMediaLocalRootsForwarding(params: {
invoke: () => Promise<{ channel: string; messageId: string }>;
sendIMessage: ReturnType<typeof vi.fn>;
}) {
await expectDirectOutboundResult({
invoke: params.invoke,
sendIMessage: params.sendIMessage,
to: "chat_id:88",
text: "caption",
expectedOptions: {
const result = await params.invoke();
expect(params.sendIMessage).toHaveBeenCalledWith(
"chat_id:88",
"caption",
expect.objectContaining({
mediaUrl: "/tmp/workspace/pic.png",
mediaLocalRoots: ["/tmp/workspace"],
accountId: "acct-1",
replyToId: "reply-2",
maxBytes: 3 * 1024 * 1024,
},
expectedResult: { channel: "imessage", messageId: "m-media-local" },
});
}),
);
expect(result).toEqual({ channel: "imessage", messageId: "m-media-local" });
}
describe("imessagePlugin outbound", () => {
const cfg = {
channels: {
imessage: {
mediaMaxMb: 3,
},
},
};
it("forwards replyToId on direct sendText adapter path", async () => {
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "m-text" });
const sendText = requireIMessageSendText();
await expectReplyToTextForwarding({
invoke: async () =>
await sendText({
cfg,
to: "chat_id:12",
text: "hello",
accountId: "default",
replyToId: "reply-1",
deps: { sendIMessage },
}),
sendIMessage,
});
});
it("forwards replyToId on direct sendMedia adapter path", async () => {
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "m-media" });
const sendMedia = requireIMessageSendMedia();
const result = await sendMedia({
cfg,
to: "chat_id:77",
text: "caption",
mediaUrl: "https://example.com/pic.png",
accountId: "acct-1",
replyToId: "reply-2",
deps: { sendIMessage },
});
expect(sendIMessage).toHaveBeenCalledWith(
"chat_id:77",
"caption",
expect.objectContaining({
mediaUrl: "https://example.com/pic.png",
accountId: "acct-1",
replyToId: "reply-2",
maxBytes: 3 * 1024 * 1024,
}),
);
expect(result).toEqual({ channel: "imessage", messageId: "m-media" });
});
it("forwards mediaLocalRoots on direct sendMedia adapter path", async () => {
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "m-media-local" });
const sendMedia = requireIMessageSendMedia();
const mediaLocalRoots = ["/tmp/workspace"];
await expectMediaLocalRootsForwarding({
invoke: async () =>
await sendMedia({
cfg,
to: "chat_id:88",
text: "caption",
mediaUrl: "/tmp/workspace/pic.png",
mediaLocalRoots,
accountId: "acct-1",
replyToId: "reply-2",
deps: { sendIMessage },
}),
sendIMessage,
});
});
it("chunks outbound text without requiring iMessage runtime initialization", () => {
const chunker = requireIMessageChunker();
@ -258,146 +149,4 @@ describe("sendMessageIMessage", () => {
expect(params.chat_id).toBe(123);
expect(params.text).toBe("hi");
});
it("applies sms service prefix", async () => {
requestMock.mockClear().mockResolvedValue({ ok: true });
stopMock.mockClear().mockResolvedValue(undefined);
await sendWithDefaults("sms:+1555", "hello");
const params = getSentParams();
expect(params.service).toBe("sms");
expect(params.to).toBe("+1555");
});
it("adds file attachment with placeholder text", async () => {
requestMock.mockClear().mockResolvedValue({ ok: true });
stopMock.mockClear().mockResolvedValue(undefined);
await sendWithDefaults("chat_id:7", "", {
mediaUrl: "http://x/y.jpg",
resolveAttachmentImpl: async () => ({
path: "/tmp/imessage-media.jpg",
contentType: "image/jpeg",
}),
});
const params = getSentParams();
expect(params.file).toBe("/tmp/imessage-media.jpg");
expect(params.text).toBe("<media:image>");
});
it("normalizes mixed-case parameterized MIME for attachment placeholder text", async () => {
requestMock.mockClear().mockResolvedValue({ ok: true });
stopMock.mockClear().mockResolvedValue(undefined);
await sendWithDefaults("chat_id:7", "", {
mediaUrl: "http://x/voice",
resolveAttachmentImpl: async () => ({
path: "/tmp/imessage-media.ogg",
contentType: " Audio/Ogg; codecs=opus ",
}),
});
const params = getSentParams();
expect(params.file).toBe("/tmp/imessage-media.ogg");
expect(params.text).toBe("<media:audio>");
});
it("returns message id when rpc provides one", async () => {
requestMock.mockClear().mockResolvedValue({ ok: true, id: 123 });
stopMock.mockClear().mockResolvedValue(undefined);
const result = await sendWithDefaults("chat_id:7", "hello");
expect(result.messageId).toBe("123");
});
it("passes replyToId as separate reply_to param instead of embedding in text", async () => {
requestMock.mockClear().mockResolvedValue({ ok: true });
stopMock.mockClear().mockResolvedValue(undefined);
await sendWithDefaults("chat_id:123", "hello world", {
replyToId: "abc-123",
});
const params = getSentParams();
expect(params.text).toBe("hello world");
expect(params.reply_to).toBe("abc-123");
});
it("strips inline reply tags from text and passes replyToId as reply_to param", async () => {
requestMock.mockClear().mockResolvedValue({ ok: true });
stopMock.mockClear().mockResolvedValue(undefined);
await sendWithDefaults("chat_id:123", " [[reply_to:old-id]] hello", {
replyToId: "new-id",
});
const params = getSentParams();
expect(params.text).toBe("hello");
expect(params.reply_to).toBe("new-id");
});
it("sanitizes replyToId before passing as reply_to param", async () => {
requestMock.mockClear().mockResolvedValue({ ok: true });
stopMock.mockClear().mockResolvedValue(undefined);
await sendWithDefaults("chat_id:123", "hello", {
replyToId: " [ab]\n\u0000c\td ] ",
});
const params = getSentParams();
expect(params.text).toBe("hello");
expect(params.reply_to).toBe("abcd");
});
it("omits reply_to param when sanitized replyToId is empty", async () => {
requestMock.mockClear().mockResolvedValue({ ok: true });
stopMock.mockClear().mockResolvedValue(undefined);
await sendWithDefaults("chat_id:123", "hello", {
replyToId: "[]\u0000\n\r",
});
const params = getSentParams();
expect(params.text).toBe("hello");
expect(params.reply_to).toBeUndefined();
});
it("strips stray [[reply_to:...]] tags from text even without replyToId option", async () => {
requestMock.mockClear().mockResolvedValue({ ok: true });
stopMock.mockClear().mockResolvedValue(undefined);
await sendWithDefaults("chat_id:123", "[[reply_to:65]] Great question");
const params = getSentParams();
expect(params.text).toBe("Great question");
expect(params.reply_to).toBeUndefined();
});
it("strips [[audio_as_voice]] tags from outbound text", async () => {
requestMock.mockClear().mockResolvedValue({ ok: true });
stopMock.mockClear().mockResolvedValue(undefined);
await sendWithDefaults("chat_id:123", "hello [[audio_as_voice]] world");
const params = getSentParams();
expect(params.text).toBe("hello world");
});
it("throws when text is only directive tags and no media", async () => {
requestMock.mockClear().mockResolvedValue({ ok: true });
stopMock.mockClear().mockResolvedValue(undefined);
await expect(sendWithDefaults("chat_id:123", "[[reply_to:65]]")).rejects.toThrow(
"iMessage send requires text or media",
);
});
it("normalizes string message_id values from rpc result", async () => {
requestMock.mockClear().mockResolvedValue({ ok: true, message_id: " guid-1 " });
stopMock.mockClear().mockResolvedValue(undefined);
const result = await sendWithDefaults("chat_id:7", "hello");
expect(result.messageId).toBe("guid-1");
});
it("does not stop an injected client", async () => {
requestMock.mockClear().mockResolvedValue({ ok: true });
stopMock.mockClear().mockResolvedValue(undefined);
await sendWithDefaults("chat_id:123", "hello");
expect(stopMock).not.toHaveBeenCalled();
});
});

View File

@ -31,6 +31,7 @@ describe("ollama web search provider", () => {
const webSearchProviders: unknown[] = [];
plugin.register({
registerMemoryEmbeddingProvider() {},
registerProvider() {},
registerWebSearchProvider(provider: unknown) {
webSearchProviders.push(provider);

View File

@ -1,4 +1,4 @@
import { beforeAll, beforeEach, describe, it, vi } from "vitest";
import { beforeEach, describe, it, vi } from "vitest";
import {
expectAugmentedCodexCatalog,
expectCodexBuiltInSuppression,
@ -38,46 +38,42 @@ vi.mock("../../../src/plugins/providers.runtime.js", () => ({
}));
export function describeOpenAIProviderCatalogContract() {
let augmentModelCatalogWithProviderPlugins: Awaited<
ReturnType<typeof importProviderRuntimeCatalogModule>
>["augmentModelCatalogWithProviderPlugins"];
let resetProviderRuntimeHookCacheForTest: Awaited<
ReturnType<typeof importProviderRuntimeCatalogModule>
>["resetProviderRuntimeHookCacheForTest"];
let resolveProviderBuiltInModelSuppression: Awaited<
ReturnType<typeof importProviderRuntimeCatalogModule>
>["resolveProviderBuiltInModelSuppression"];
let openaiProviders: ProviderPlugin[];
let openaiProvider: ProviderPlugin;
const contractDepsPromise = (async () => {
vi.resetModules();
const openaiPlugin = loadBundledPluginPublicSurfaceSync<{
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
}>({
pluginId: "openai",
artifactBasename: "index.js",
});
const openaiProviders = (
await registerProviderPlugin({
plugin: openaiPlugin.default,
id: "openai",
name: "OpenAI",
})
).providers;
const openaiProvider = requireRegisteredProvider(openaiProviders, "openai", "provider");
const {
augmentModelCatalogWithProviderPlugins,
resetProviderRuntimeHookCacheForTest,
resolveProviderBuiltInModelSuppression,
} = await importProviderRuntimeCatalogModule();
return {
augmentModelCatalogWithProviderPlugins,
resetProviderRuntimeHookCacheForTest,
resolveProviderBuiltInModelSuppression,
openaiProviders,
openaiProvider,
};
})();
describe(
"openai provider catalog contract",
{ timeout: PROVIDER_CATALOG_CONTRACT_TIMEOUT_MS },
() => {
beforeAll(async () => {
vi.resetModules();
const openaiPlugin = loadBundledPluginPublicSurfaceSync<{
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
}>({
pluginId: "openai",
artifactBasename: "index.js",
});
openaiProviders = (
await registerProviderPlugin({
plugin: openaiPlugin.default,
id: "openai",
name: "OpenAI",
})
).providers;
openaiProvider = requireRegisteredProvider(openaiProviders, "openai", "provider");
({
augmentModelCatalogWithProviderPlugins,
resetProviderRuntimeHookCacheForTest,
resolveProviderBuiltInModelSuppression,
} = await importProviderRuntimeCatalogModule());
});
beforeEach(() => {
beforeEach(async () => {
const { resetProviderRuntimeHookCacheForTest, openaiProviders } = await contractDepsPromise;
resetProviderRuntimeHookCacheForTest();
resolvePluginProvidersMock.mockReset();
@ -105,17 +101,20 @@ export function describeOpenAIProviderCatalogContract() {
resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["openai"]);
});
it("keeps codex-only missing-auth hints wired through the provider runtime", () => {
it("keeps codex-only missing-auth hints wired through the provider runtime", async () => {
const { openaiProvider } = await contractDepsPromise;
expectCodexMissingAuthHint(
(params) => openaiProvider.buildMissingAuthMessage?.(params.context) ?? undefined,
);
});
it("keeps built-in model suppression wired through the provider runtime", () => {
it("keeps built-in model suppression wired through the provider runtime", async () => {
const { resolveProviderBuiltInModelSuppression } = await contractDepsPromise;
expectCodexBuiltInSuppression(resolveProviderBuiltInModelSuppression);
});
it("keeps bundled model augmentation wired through the provider runtime", async () => {
const { augmentModelCatalogWithProviderPlugins } = await contractDepsPromise;
await expectAugmentedCodexCatalog(augmentModelCatalogWithProviderPlugins);
});
},

View File

@ -1,8 +1,11 @@
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/test-helpers.js";
let createBaseSignalEventHandlerDeps: typeof import("./event-handler.test-harness.js").createBaseSignalEventHandlerDeps;
let createSignalReceiveEvent: typeof import("./event-handler.test-harness.js").createSignalReceiveEvent;
vi.useRealTimers();
const [
{ createBaseSignalEventHandlerDeps, createSignalReceiveEvent },
{ createSignalEventHandler },
] = await Promise.all([import("./event-handler.test-harness.js"), import("./event-handler.js")]);
const { sendTypingMock, sendReadReceiptMock, dispatchInboundMessageMock, capture } = vi.hoisted(
() => {
@ -48,16 +51,7 @@ vi.mock("../../../../src/pairing/pairing-store.js", () => ({
upsertChannelPairingRequest: vi.fn(),
}));
let createSignalEventHandler: typeof import("./event-handler.js").createSignalEventHandler;
describe("signal createSignalEventHandler inbound context", () => {
beforeAll(async () => {
vi.useRealTimers();
({ createBaseSignalEventHandlerDeps, createSignalReceiveEvent } =
await import("./event-handler.test-harness.js"));
({ createSignalEventHandler } = await import("./event-handler.js"));
});
beforeEach(() => {
capture.ctx = undefined;
sendTypingMock.mockReset().mockResolvedValue(true);

View File

@ -1,6 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildDispatchInboundCaptureMock } from "../../../../src/channels/plugins/contracts/inbound-testkit.js";
type SignalMsgContext = Pick<MsgContext, "Body" | "WasMentioned"> & {
@ -23,14 +23,15 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
});
});
let createBaseSignalEventHandlerDeps: typeof import("./event-handler.test-harness.js").createBaseSignalEventHandlerDeps;
let createSignalReceiveEvent: typeof import("./event-handler.test-harness.js").createSignalReceiveEvent;
let createSignalEventHandler: typeof import("./event-handler.js").createSignalEventHandler;
let renderSignalMentions: typeof import("./mentions.js").renderSignalMentions;
beforeAll(async () => {
({ renderSignalMentions } = await import("./mentions.js"));
});
const [
{ createBaseSignalEventHandlerDeps, createSignalReceiveEvent },
{ createSignalEventHandler },
{ renderSignalMentions },
] = await Promise.all([
import("./event-handler.test-harness.js"),
import("./event-handler.js"),
import("./mentions.js"),
]);
type GroupEventOpts = {
message?: string;
@ -106,12 +107,6 @@ async function expectSkippedGroupHistory(opts: GroupEventOpts, expectedBody: str
}
describe("signal mention gating", () => {
beforeAll(async () => {
({ createBaseSignalEventHandlerDeps, createSignalReceiveEvent } =
await import("./event-handler.test-harness.js"));
({ createSignalEventHandler } = await import("./event-handler.js"));
});
beforeEach(() => {
capturedCtx = undefined;
});

View File

@ -1,11 +1,11 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { expectPairingReplyText } from "../../../test/helpers/pairing-reply.js";
import {
defaultSlackTestConfig,
getSlackTestState,
getSlackHandlerOrThrow,
getSlackClient,
getSlackHandlers,
getSlackHandlerOrThrow,
flush,
resetSlackTestState,
runSlackMessageOnce,
@ -13,21 +13,21 @@ import {
stopSlackMonitor,
} from "./monitor.test-helpers.js";
let resetInboundDedupe: typeof import("openclaw/plugin-sdk/reply-runtime").resetInboundDedupe;
let HISTORY_CONTEXT_MARKER: typeof import("../../../src/auto-reply/reply/history.js").HISTORY_CONTEXT_MARKER;
let CURRENT_MESSAGE_MARKER: typeof import("../../../src/auto-reply/reply/mentions.js").CURRENT_MESSAGE_MARKER;
let monitorSlackProvider: typeof import("./monitor.js").monitorSlackProvider;
const [
{ resetInboundDedupe },
{ HISTORY_CONTEXT_MARKER },
{ CURRENT_MESSAGE_MARKER },
{ monitorSlackProvider },
] = await Promise.all([
import("openclaw/plugin-sdk/reply-runtime"),
import("../../../src/auto-reply/reply/history.js"),
import("../../../src/auto-reply/reply/mentions.js"),
import("./monitor/provider.js"),
]);
const slackTestState = getSlackTestState();
const { sendMock, replyMock, reactMock, upsertPairingRequestMock } = slackTestState;
beforeAll(async () => {
({ resetInboundDedupe } = await import("openclaw/plugin-sdk/reply-runtime"));
({ HISTORY_CONTEXT_MARKER } = await import("../../../src/auto-reply/reply/history.js"));
({ CURRENT_MESSAGE_MARKER } = await import("../../../src/auto-reply/reply/mentions.js"));
({ monitorSlackProvider } = await import("./monitor.js"));
});
beforeEach(() => {
resetInboundDedupe();
resetSlackTestState(defaultSlackTestConfig());

View File

@ -1,11 +1,11 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const enqueueMock = vi.fn(async (_entry: unknown) => {});
const flushKeyMock = vi.fn(async (_key: string) => {});
const resolveThreadTsMock = vi.fn(async ({ message }: { message: Record<string, unknown> }) => ({
...message,
}));
let createSlackMessageHandler: typeof import("./message-handler.js").createSlackMessageHandler;
const { createSlackMessageHandler } = await import("./message-handler.js");
vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/channel-inbound")>(
@ -72,10 +72,6 @@ async function handleDirectMessage(
}
describe("createSlackMessageHandler", () => {
beforeAll(async () => {
({ createSlackMessageHandler } = await import("./message-handler.js"));
});
beforeEach(() => {
enqueueMock.mockClear();
flushKeyMock.mockClear();

View File

@ -3,30 +3,17 @@ import os from "node:os";
import path from "node:path";
import type { App } from "@slack/bolt";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, describe, expect, it, vi } from "vitest";
import type { SlackMessageEvent } from "../../types.js";
type PrepareSlackMessage = typeof import("./prepare.js").prepareSlackMessage;
type CreateInboundSlackTestContext =
typeof import("./prepare.test-helpers.js").createInboundSlackTestContext;
type CreateSlackTestAccount = typeof import("./prepare.test-helpers.js").createSlackTestAccount;
let prepareSlackMessage: PrepareSlackMessage;
let createInboundSlackTestContext: CreateInboundSlackTestContext;
let createSlackTestAccount: CreateSlackTestAccount;
let fixtureRoot = "";
const [{ prepareSlackMessage }, helpers] = await Promise.all([
import("./prepare.js"),
import("./prepare.test-helpers.js"),
]);
const { createInboundSlackTestContext, createSlackTestAccount } = helpers;
let fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-slack-room-thread-context-"));
let caseId = 0;
async function loadSlackPrepareModules() {
const [{ prepareSlackMessage: loadedPrepareSlackMessage }, helpers] = await Promise.all([
import("./prepare.js"),
import("./prepare.test-helpers.js"),
]);
prepareSlackMessage = loadedPrepareSlackMessage;
createInboundSlackTestContext = helpers.createInboundSlackTestContext;
createSlackTestAccount = helpers.createSlackTestAccount;
}
function makeTmpStorePath() {
if (!fixtureRoot) {
throw new Error("fixtureRoot missing");
@ -37,11 +24,6 @@ function makeTmpStorePath() {
}
describe("prepareSlackMessage thread context allowlists", () => {
beforeAll(async () => {
await loadSlackPrepareModules();
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-slack-room-thread-context-"));
});
afterAll(() => {
if (fixtureRoot) {
fs.rmSync(fixtureRoot, { recursive: true, force: true });

View File

@ -1,26 +1,13 @@
import type { App } from "@slack/bolt";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SlackMessageEvent } from "../../types.js";
type PrepareSlackMessage = typeof import("./prepare.js").prepareSlackMessage;
type CreateInboundSlackTestContext =
typeof import("./prepare.test-helpers.js").createInboundSlackTestContext;
type CreateSlackTestAccount = typeof import("./prepare.test-helpers.js").createSlackTestAccount;
let prepareSlackMessage: PrepareSlackMessage;
let createInboundSlackTestContext: CreateInboundSlackTestContext;
let createSlackTestAccount: CreateSlackTestAccount;
async function loadSlackPrepareModules() {
const [{ prepareSlackMessage: loadedPrepareSlackMessage }, helpers] = await Promise.all([
import("./prepare.js"),
import("./prepare.test-helpers.js"),
]);
prepareSlackMessage = loadedPrepareSlackMessage;
createInboundSlackTestContext = helpers.createInboundSlackTestContext;
createSlackTestAccount = helpers.createSlackTestAccount;
}
const [{ prepareSlackMessage }, helpers] = await Promise.all([
import("./prepare.js"),
import("./prepare.test-helpers.js"),
]);
const { createInboundSlackTestContext, createSlackTestAccount } = helpers;
function buildCtx(overrides?: { replyToMode?: "all" | "first" | "off" }) {
const replyToMode = overrides?.replyToMode ?? "all";
@ -48,10 +35,6 @@ function buildChannelMessage(overrides?: Partial<SlackMessageEvent>): SlackMessa
}
describe("thread-level session keys", () => {
beforeAll(async () => {
await loadSlackPrepareModules();
});
it("keeps top-level channel turns in one session when replyToMode=off", async () => {
const ctx = buildCtx({ replyToMode: "off" });
ctx.resolveUserName = async () => ({ name: "Alice" });

View File

@ -180,16 +180,12 @@ vi.mock("./slash-commands.runtime.js", () => {
});
type RegisterFn = (params: { ctx: unknown; account: unknown }) => Promise<void>;
let registerSlackMonitorSlashCommands: RegisterFn;
const { registerSlackMonitorSlashCommands } = (await import("./slash.js")) as {
registerSlackMonitorSlashCommands: RegisterFn;
};
const { dispatchMock } = getSlackSlashMocks();
beforeAll(async () => {
({ registerSlackMonitorSlashCommands } = (await import("./slash.js")) as {
registerSlackMonitorSlashCommands: RegisterFn;
});
});
beforeEach(() => {
resetSlackSlashMocks();
});

View File

@ -7,6 +7,7 @@ import {
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
import { resolveBundledPluginRepoEntryPath } from "./bundled-plugin-metadata.js";
import { createCapturedPluginRegistration } from "./captured-registration.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import type { PluginLoadOptions } from "./loader.js";
@ -213,6 +214,7 @@ export function loadBundledCapabilityRuntimeRegistry(params: {
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
);
const seenPluginIds = new Set<string>();
const repoRoot = process.cwd();
for (const candidate of discovery.candidates) {
const manifest = manifestByRoot.get(candidate.rootDir);
@ -229,15 +231,22 @@ export function loadBundledCapabilityRuntimeRegistry(params: {
name: manifest.name,
description: manifest.description,
version: manifest.version,
source: candidate.source,
source:
env?.VITEST && params.pluginSdkResolution === "dist"
? (resolveBundledPluginRepoEntryPath({
rootDir: repoRoot,
pluginId: manifest.id,
preferBuilt: true,
}) ?? candidate.source)
: candidate.source,
rootDir: candidate.rootDir,
workspaceDir: candidate.workspaceDir,
});
const opened = openBoundaryFileSync({
absolutePath: candidate.source,
rootPath: candidate.rootDir,
boundaryLabel: "plugin root",
absolutePath: record.source,
rootPath: record.source === candidate.source ? candidate.rootDir : repoRoot,
boundaryLabel: record.source === candidate.source ? "plugin root" : "repo root",
rejectHardlinks: false,
skipLexicalRootCheck: true,
});

View File

@ -5,6 +5,7 @@ import {
clearBundledPluginMetadataCache,
listBundledPluginMetadata,
resolveBundledPluginGeneratedPath,
resolveBundledPluginRepoEntryPath,
} from "./bundled-plugin-metadata.js";
import {
createGeneratedPluginTempRoot,
@ -175,6 +176,45 @@ describe("bundled plugin metadata", () => {
expectGeneratedPathResolution(tempRoot, path.join("plugin", "index.js"));
});
it("resolves bundled repo entry paths from dist before workspace source", () => {
const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-repo-entry-");
const pluginRoot = path.join(tempRoot, "extensions", "alpha");
const distPluginRoot = path.join(tempRoot, "dist", "extensions", "alpha");
writeJson(path.join(pluginRoot, "package.json"), {
name: "@openclaw/alpha",
version: "0.0.1",
openclaw: {
extensions: ["./index.ts"],
},
});
writeJson(path.join(pluginRoot, "openclaw.plugin.json"), {
id: "alpha",
configSchema: { type: "object" },
});
fs.writeFileSync(path.join(pluginRoot, "index.ts"), "export const source = true;\n", "utf8");
expect(
resolveBundledPluginRepoEntryPath({
rootDir: tempRoot,
pluginId: "alpha",
preferBuilt: true,
}),
).toBe(path.join(pluginRoot, "index.ts"));
fs.mkdirSync(distPluginRoot, { recursive: true });
fs.writeFileSync(path.join(distPluginRoot, "index.js"), "export const built = true;\n", "utf8");
clearBundledPluginMetadataCache();
expect(
resolveBundledPluginRepoEntryPath({
rootDir: tempRoot,
pluginId: "alpha",
preferBuilt: true,
}),
).toBe(path.join(distPluginRoot, "index.js"));
});
it("merges runtime channel schema metadata with manifest-owned channel config fields", () => {
const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-channel-configs-");

View File

@ -243,3 +243,37 @@ export function resolveBundledPluginGeneratedPath(
}
return null;
}
function normalizeRelativePluginEntryPath(entryPath: string): string {
return entryPath.replace(/^\.\//u, "");
}
export function resolveBundledPluginRepoEntryPath(params: {
rootDir: string;
pluginId: string;
preferBuilt?: boolean;
}): string | null {
const metadata = findBundledPluginMetadataById(params.pluginId, { rootDir: params.rootDir });
if (!metadata) {
return null;
}
const entryOrder = params.preferBuilt
? [metadata.source.built, metadata.source.source]
: [metadata.source.source, metadata.source.built];
const baseDirs = [
path.resolve(params.rootDir, "dist", "extensions", metadata.dirName),
path.resolve(params.rootDir, "extensions", metadata.dirName),
];
for (const baseDir of baseDirs) {
for (const entryPath of entryOrder) {
const candidate = path.resolve(baseDir, normalizeRelativePluginEntryPath(entryPath));
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
return null;
}

View File

@ -15,6 +15,7 @@ import type {
WebFetchProviderPlugin,
WebSearchProviderPlugin,
} from "../types.js";
import { BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS } from "./inventory/bundled-capability-metadata.js";
import {
loadVitestImageGenerationProviderContractRegistry,
loadVitestMediaUnderstandingProviderContractRegistry,
@ -91,6 +92,21 @@ function uniqueStrings(values: readonly string[]): string[] {
}
function resolveBundledManifestContracts(): PluginRegistrationContractEntry[] {
if (process.env.VITEST) {
return BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.map((entry) => ({
pluginId: entry.pluginId,
providerIds: [...entry.providerIds],
speechProviderIds: [...entry.speechProviderIds],
realtimeTranscriptionProviderIds: [...entry.realtimeTranscriptionProviderIds],
realtimeVoiceProviderIds: [...entry.realtimeVoiceProviderIds],
mediaUnderstandingProviderIds: [...entry.mediaUnderstandingProviderIds],
imageGenerationProviderIds: [...entry.imageGenerationProviderIds],
videoGenerationProviderIds: [...entry.videoGenerationProviderIds],
webFetchProviderIds: [...entry.webFetchProviderIds],
webSearchProviderIds: [...entry.webSearchProviderIds],
toolNames: [...entry.toolNames],
}));
}
return loadPluginManifestRegistry({})
.plugins.filter(
(plugin) =>

View File

@ -1,5 +1,8 @@
import { createJiti } from "jiti";
import { loadBundledCapabilityRuntimeRegistry } from "../bundled-capability-runtime.js";
import { resolveManifestContractPluginIds } from "../manifest-registry.js";
import { resolveBundledPluginRepoEntryPath } from "../bundled-plugin-metadata.js";
import { createCapturedPluginRegistration } from "../captured-registration.js";
import type { OpenClawPluginDefinition } from "../types.js";
import type {
ImageGenerationProviderPlugin,
MediaUnderstandingProviderPlugin,
@ -9,6 +12,7 @@ import type {
SpeechProviderPlugin,
VideoGenerationProviderPlugin,
} from "../types.js";
import { BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS } from "./inventory/bundled-capability-metadata.js";
export type SpeechProviderContractEntry = {
pluginId: string;
@ -54,6 +58,67 @@ type ManifestContractKey =
| "videoGenerationProviders"
| "musicGenerationProviders";
const VITEST_CONTRACT_PLUGIN_IDS = {
imageGenerationProviders: BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter(
(entry) => entry.imageGenerationProviderIds.length > 0,
).map((entry) => entry.pluginId),
speechProviders: BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter(
(entry) => entry.speechProviderIds.length > 0,
).map((entry) => entry.pluginId),
mediaUnderstandingProviders: BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter(
(entry) => entry.mediaUnderstandingProviderIds.length > 0,
).map((entry) => entry.pluginId),
realtimeVoiceProviders: BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter(
(entry) => entry.realtimeVoiceProviderIds.length > 0,
).map((entry) => entry.pluginId),
realtimeTranscriptionProviders: BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter(
(entry) => entry.realtimeTranscriptionProviderIds.length > 0,
).map((entry) => entry.pluginId),
videoGenerationProviders: BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter(
(entry) => entry.videoGenerationProviderIds.length > 0,
).map((entry) => entry.pluginId),
} satisfies Record<ManifestContractKey, string[]>;
function loadVitestVideoGenerationFallbackEntries(
pluginIds: readonly string[],
): VideoGenerationProviderContractEntry[] {
const jiti = createJiti(import.meta.url, {
interopDefault: true,
moduleCache: false,
fsCache: false,
});
const repoRoot = process.cwd();
return pluginIds.flatMap((pluginId) => {
const modulePath = resolveBundledPluginRepoEntryPath({
rootDir: repoRoot,
pluginId,
preferBuilt: true,
});
if (!modulePath) {
return [];
}
try {
const mod = jiti(modulePath) as
| OpenClawPluginDefinition
| { default?: OpenClawPluginDefinition };
const plugin =
(mod as { default?: OpenClawPluginDefinition }).default ??
(mod as OpenClawPluginDefinition);
if (typeof plugin?.register !== "function") {
return [];
}
const captured = createCapturedPluginRegistration();
void plugin.register(captured.api);
return captured.videoGenerationProviders.map((provider) => ({
pluginId,
provider,
}));
} catch {
return [];
}
});
}
function loadVitestCapabilityContractEntries<T>(params: {
contract: ManifestContractKey;
pickEntries: (registry: ReturnType<typeof loadBundledCapabilityRuntimeRegistry>) => Array<{
@ -61,19 +126,30 @@ function loadVitestCapabilityContractEntries<T>(params: {
provider: T;
}>;
}): Array<{ pluginId: string; provider: T }> {
const pluginIds = resolveManifestContractPluginIds({
contract: params.contract,
origin: "bundled",
});
const pluginIds = VITEST_CONTRACT_PLUGIN_IDS[params.contract];
if (pluginIds.length === 0) {
return [];
}
return params.pickEntries(
const bulkEntries = params.pickEntries(
loadBundledCapabilityRuntimeRegistry({
pluginIds,
pluginSdkResolution: "dist",
}),
);
const coveredPluginIds = new Set(bulkEntries.map((entry) => entry.pluginId));
if (coveredPluginIds.size === pluginIds.length) {
return bulkEntries;
}
return pluginIds.flatMap((pluginId) =>
params
.pickEntries(
loadBundledCapabilityRuntimeRegistry({
pluginIds: [pluginId],
pluginSdkResolution: "dist",
}),
)
.filter((entry) => entry.pluginId === pluginId),
);
}
export function loadVitestSpeechProviderContractRegistry(): SpeechProviderContractEntry[] {
@ -132,7 +208,7 @@ export function loadVitestImageGenerationProviderContractRegistry(): ImageGenera
}
export function loadVitestVideoGenerationProviderContractRegistry(): VideoGenerationProviderContractEntry[] {
return loadVitestCapabilityContractEntries({
const entries = loadVitestCapabilityContractEntries({
contract: "videoGenerationProviders",
pickEntries: (registry) =>
registry.videoGenerationProviders.map((entry) => ({
@ -140,6 +216,14 @@ export function loadVitestVideoGenerationProviderContractRegistry(): VideoGenera
provider: entry.provider,
})),
});
const coveredPluginIds = new Set(entries.map((entry) => entry.pluginId));
const missingPluginIds = VITEST_CONTRACT_PLUGIN_IDS.videoGenerationProviders.filter(
(pluginId) => !coveredPluginIds.has(pluginId),
);
if (missingPluginIds.length === 0) {
return entries;
}
return [...entries, ...loadVitestVideoGenerationFallbackEntries(missingPluginIds)];
}
export function loadVitestMusicGenerationProviderContractRegistry(): MusicGenerationProviderContractEntry[] {