BREAKING CHANGE: Convert repository to IronClaw-only package with strict external dependency on globally installed `openclaw` runtime. ### Changes - Remove entire OpenClaw core source from repository (src/agents/*, src/acp/*, src/commands/*, and related modules) - Implement CLI delegation: non-bootstrap commands now delegate to global `openclaw` binary via external contract - Remove local OpenClaw path resolution from web app; always spawn global `openclaw` binary instead of local scripts - Rename package.json scripts: `pnpm openclaw` → `pnpm ironclaw`, `openclaw:rpc` → `ironclaw:rpc` - Update bootstrap flow to verify and install global OpenClaw when missing - Migrate web workspace/profile logic to align with OpenClaw state paths - Add migration contract tests for stream-json, session subscribe, and profile resolution behaviors - Update build/release pipeline for IronClaw-only artifacts - Update documentation for new peer + global installation model ### Architecture IronClaw is now strictly a frontend/UI/bootstrap layer: - `npx ironclaw` bootstraps OpenClaw (if missing), runs guided onboarding - IronClaw UI serves on localhost:3100 - OpenClaw Gateway runs on standard port 18789 - Communication via stable CLI contracts and Gateway WebSocket protocol only ### Migration Users must have `openclaw` installed globally: npm install -g openclaw Existing IronClaw profiles and sessions remain compatible through gateway protocol stability. Refs: bootstrap_dev_testing, ironclaw_frontend_split, strict-external-openclaw
227 lines
7.1 KiB
TypeScript
227 lines
7.1 KiB
TypeScript
import { afterAll, afterEach, beforeEach, vi } from "vitest";
|
|
|
|
// Ensure Vitest environment is properly set
|
|
process.env.VITEST = "true";
|
|
// Config validation walks plugin manifests; keep an aggressive cache in tests to avoid
|
|
// repeated filesystem discovery across suites/workers.
|
|
process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS ??= "60000";
|
|
// Vitest vm forks can load transitive lockfile helpers many times per worker.
|
|
// Raise listener budget to avoid noisy MaxListeners warnings and warning-stack overhead.
|
|
const TEST_PROCESS_MAX_LISTENERS = 128;
|
|
if (process.getMaxListeners() > 0 && process.getMaxListeners() < TEST_PROCESS_MAX_LISTENERS) {
|
|
process.setMaxListeners(TEST_PROCESS_MAX_LISTENERS);
|
|
}
|
|
|
|
import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js";
|
|
import { withIsolatedTestHome } from "./test-env.js";
|
|
|
|
// Set HOME/state isolation before importing any runtime OpenClaw modules.
|
|
const testEnv = withIsolatedTestHome();
|
|
afterAll(() => testEnv.cleanup());
|
|
|
|
const [{ installProcessWarningFilter }, { setActivePluginRegistry }, { createTestRegistry }] =
|
|
await Promise.all([
|
|
import("../src/infra/warning-filter.js"),
|
|
import("../src/plugins/runtime.js"),
|
|
import("../src/test-utils/channel-plugins.js"),
|
|
]);
|
|
|
|
installProcessWarningFilter();
|
|
|
|
// Define types locally since original modules were removed
|
|
type ChannelId = string;
|
|
type DeliveryMode = "direct" | "gateway";
|
|
type ChannelOutboundAdapter = {
|
|
deliveryMode: DeliveryMode;
|
|
sendText: (params: {
|
|
deps?: OutboundSendDeps;
|
|
to: string;
|
|
text: string;
|
|
}) => Promise<{ channel: string; messageId?: string }>;
|
|
sendMedia?: (params: {
|
|
deps?: OutboundSendDeps;
|
|
to: string;
|
|
text: string;
|
|
mediaUrl: string;
|
|
}) => Promise<{ channel: string; messageId?: string }>;
|
|
};
|
|
|
|
type ChannelPluginMeta = {
|
|
id: ChannelId;
|
|
label: string;
|
|
selectionLabel: string;
|
|
docsPath: string;
|
|
blurb: string;
|
|
aliases?: string[];
|
|
preferSessionLookupForAnnounceTarget?: boolean;
|
|
};
|
|
|
|
type ChannelPlugin = {
|
|
id: ChannelId;
|
|
meta: ChannelPluginMeta;
|
|
capabilities: { chatTypes: string[] };
|
|
config: {
|
|
listAccountIds: (cfg: Record<string, unknown>) => string[];
|
|
resolveAccount: (cfg: Record<string, unknown>, accountId?: string | null) => unknown;
|
|
isConfigured: (_account: unknown, cfg: Record<string, unknown>) => Promise<boolean>;
|
|
};
|
|
outbound: ChannelOutboundAdapter;
|
|
status?: {
|
|
buildChannelSummary: () => Promise<Record<string, unknown>>;
|
|
};
|
|
};
|
|
|
|
const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => {
|
|
switch (id) {
|
|
case "discord":
|
|
return deps?.sendDiscord;
|
|
case "slack":
|
|
return deps?.sendSlack;
|
|
case "telegram":
|
|
return deps?.sendTelegram;
|
|
case "whatsapp":
|
|
return deps?.sendWhatsApp;
|
|
case "signal":
|
|
return deps?.sendSignal;
|
|
case "imessage":
|
|
return deps?.sendIMessage;
|
|
default:
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const createStubOutbound = (
|
|
id: ChannelId,
|
|
deliveryMode: ChannelOutboundAdapter["deliveryMode"] = "direct",
|
|
): ChannelOutboundAdapter => ({
|
|
deliveryMode,
|
|
sendText: async ({ deps, to, text }) => {
|
|
const send = pickSendFn(id, deps);
|
|
if (send) {
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
const result = await send(to, text, { verbose: false } as any);
|
|
return { channel: id, ...result };
|
|
}
|
|
return { channel: id, messageId: "test" };
|
|
},
|
|
sendMedia: async ({ deps, to, text, mediaUrl }) => {
|
|
const send = pickSendFn(id, deps);
|
|
if (send) {
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
const result = await send(to, text, { verbose: false, mediaUrl } as any);
|
|
return { channel: id, ...result };
|
|
}
|
|
return { channel: id, messageId: "test" };
|
|
},
|
|
});
|
|
|
|
const createStubPlugin = (params: {
|
|
id: ChannelId;
|
|
label?: string;
|
|
aliases?: string[];
|
|
deliveryMode?: ChannelOutboundAdapter["deliveryMode"];
|
|
preferSessionLookupForAnnounceTarget?: boolean;
|
|
}): ChannelPlugin => ({
|
|
id: params.id,
|
|
meta: {
|
|
id: params.id,
|
|
label: params.label ?? String(params.id),
|
|
selectionLabel: params.label ?? String(params.id),
|
|
docsPath: `/channels/${params.id}`,
|
|
blurb: "test stub.",
|
|
aliases: params.aliases,
|
|
preferSessionLookupForAnnounceTarget: params.preferSessionLookupForAnnounceTarget,
|
|
},
|
|
capabilities: { chatTypes: ["direct", "group"] },
|
|
config: {
|
|
listAccountIds: (cfg: Record<string, unknown>) => {
|
|
const channels = cfg.channels as Record<string, unknown> | undefined;
|
|
const entry = channels?.[params.id];
|
|
if (!entry || typeof entry !== "object") {
|
|
return [];
|
|
}
|
|
const accounts = (entry as { accounts?: Record<string, unknown> }).accounts;
|
|
const ids = accounts ? Object.keys(accounts).filter(Boolean) : [];
|
|
return ids.length > 0 ? ids : ["default"];
|
|
},
|
|
resolveAccount: (cfg: Record<string, unknown>, accountId?: string | null) => {
|
|
const channels = cfg.channels as Record<string, unknown> | undefined;
|
|
const entry = channels?.[params.id];
|
|
if (!entry || typeof entry !== "object") {
|
|
return {};
|
|
}
|
|
const accounts = (entry as { accounts?: Record<string, unknown> }).accounts;
|
|
const match = accountId ? accounts?.[accountId] : undefined;
|
|
return (match && typeof match === "object") || typeof match === "string" ? match : entry;
|
|
},
|
|
isConfigured: async (_account, cfg: Record<string, unknown>) => {
|
|
const channels = cfg.channels as Record<string, unknown> | undefined;
|
|
return Boolean(channels?.[params.id]);
|
|
},
|
|
},
|
|
outbound: createStubOutbound(params.id, params.deliveryMode),
|
|
});
|
|
|
|
const createDefaultRegistry = () =>
|
|
createTestRegistry([
|
|
{
|
|
pluginId: "discord",
|
|
plugin: createStubPlugin({ id: "discord", label: "Discord" }),
|
|
source: "test",
|
|
},
|
|
{
|
|
pluginId: "slack",
|
|
plugin: createStubPlugin({ id: "slack", label: "Slack" }),
|
|
source: "test",
|
|
},
|
|
{
|
|
pluginId: "telegram",
|
|
plugin: {
|
|
...createStubPlugin({ id: "telegram", label: "Telegram" }),
|
|
status: {
|
|
buildChannelSummary: async () => ({
|
|
configured: false,
|
|
tokenSource: process.env.TELEGRAM_BOT_TOKEN ? "env" : "none",
|
|
}),
|
|
},
|
|
},
|
|
source: "test",
|
|
},
|
|
{
|
|
pluginId: "whatsapp",
|
|
plugin: createStubPlugin({
|
|
id: "whatsapp",
|
|
label: "WhatsApp",
|
|
deliveryMode: "gateway",
|
|
preferSessionLookupForAnnounceTarget: true,
|
|
}),
|
|
source: "test",
|
|
},
|
|
{
|
|
pluginId: "signal",
|
|
plugin: createStubPlugin({ id: "signal", label: "Signal" }),
|
|
source: "test",
|
|
},
|
|
{
|
|
pluginId: "imessage",
|
|
plugin: createStubPlugin({ id: "imessage", label: "iMessage", aliases: ["imsg"] }),
|
|
source: "test",
|
|
},
|
|
]);
|
|
|
|
// Creating a fresh registry before every single test was measurable overhead.
|
|
// The registry is treated as immutable by production code; tests that need a
|
|
// custom registry set it explicitly.
|
|
const DEFAULT_PLUGIN_REGISTRY = createDefaultRegistry();
|
|
|
|
beforeEach(() => {
|
|
setActivePluginRegistry(DEFAULT_PLUGIN_REGISTRY);
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Guard against leaked fake timers across test files/workers.
|
|
if (vi.isFakeTimers()) {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|