test: trim more local test startup overhead

This commit is contained in:
Peter Steinberger
2026-03-22 09:34:41 +00:00
parent 3382ef2724
commit b70b7b0d94
13 changed files with 382 additions and 404 deletions

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { resolveCronPayloadOutcome } from "./isolated-agent/helpers.js";
describe("resolveCronPayloadOutcome", () => {
it("uses the last non-empty non-error payload as summary and output", () => {
const result = resolveCronPayloadOutcome({
payloads: [{ text: "first" }, { text: " " }, { text: " last " }],
});
expect(result.summary).toBe("last");
expect(result.outputText).toBe("last");
expect(result.hasFatalErrorPayload).toBe(false);
});
it("returns a fatal error from the last error payload when no success follows", () => {
const result = resolveCronPayloadOutcome({
payloads: [
{
text: "⚠️ 🛠️ Exec failed: /bin/bash: line 1: python: command not found",
isError: true,
},
],
});
expect(result.hasFatalErrorPayload).toBe(true);
expect(result.embeddedRunError).toContain("command not found");
expect(result.summary).toContain("Exec failed");
});
it("treats transient error payloads as non-fatal when a later success exists", () => {
const result = resolveCronPayloadOutcome({
payloads: [
{ text: "⚠️ ✍️ Write: failed", isError: true },
{ text: "Write completed successfully.", isError: false },
],
});
expect(result.hasFatalErrorPayload).toBe(false);
expect(result.summary).toBe("Write completed successfully.");
});
it("keeps error payloads fatal when the run also reported a run-level error", () => {
const result = resolveCronPayloadOutcome({
payloads: [
{ text: "Model context overflow", isError: true },
{ text: "Partial assistant text before error" },
],
runLevelError: { kind: "context_overflow", message: "exceeded context window" },
});
expect(result.hasFatalErrorPayload).toBe(true);
expect(result.embeddedRunError).toContain("Model context overflow");
});
it("truncates long summaries", () => {
const result = resolveCronPayloadOutcome({
payloads: [{ text: "a".repeat(2001) }],
});
expect(String(result.summary ?? "")).toMatch(/…$/);
});
});

View File

@@ -180,81 +180,6 @@ describe("runCronIsolatedAgentTurn", () => {
});
});
it("uses last non-empty agent text as summary", async () => {
await withTempHome(async (home) => {
const { res } = await runCronTurn(home, {
jobPayload: DEFAULT_AGENT_TURN_PAYLOAD,
mockTexts: ["first", " ", " last "],
});
expect(res.status).toBe("ok");
expect(res.summary).toBe("last");
});
});
it("returns error when embedded run payload is marked as error", async () => {
await withTempHome(async (home) => {
mockEmbeddedPayloads([
{
text: "⚠️ 🛠️ Exec failed: /bin/bash: line 1: python: command not found",
isError: true,
},
]);
const { res } = await runCronTurn(home, {
jobPayload: DEFAULT_AGENT_TURN_PAYLOAD,
mockTexts: null,
});
expect(res.status).toBe("error");
expect(res.error).toContain("command not found");
expect(res.summary).toContain("Exec failed");
});
});
it("treats transient error payloads as non-fatal when a later success payload exists", async () => {
await withTempHome(async (home) => {
mockEmbeddedPayloads([
{
text: "⚠️ ✍️ Write: failed",
isError: true,
},
{
text: "Write completed successfully.",
isError: false,
},
]);
const { res } = await runCronTurn(home, {
jobPayload: DEFAULT_AGENT_TURN_PAYLOAD,
mockTexts: null,
});
expect(res.status).toBe("ok");
expect(res.summary).toBe("Write completed successfully.");
});
});
it("keeps error status when run-level error accompanies post-error text", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [
{ text: "Model context overflow", isError: true },
{ text: "Partial assistant text before error" },
],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
error: { kind: "context_overflow", message: "exceeded context window" },
},
});
const { res } = await runCronTurn(home, {
jobPayload: DEFAULT_AGENT_TURN_PAYLOAD,
mockTexts: null,
});
expect(res.status).toBe("error");
});
});
it("passes resolved agentDir to runEmbeddedPiAgent", async () => {
await withTempHome(async (home) => {
const { res } = await runCronTurn(home, {
@@ -519,19 +444,6 @@ describe("runCronIsolatedAgentTurn", () => {
});
});
it("truncates long summaries", async () => {
await withTempHome(async (home) => {
const long = "a".repeat(2001);
const { res } = await runCronTurn(home, {
jobPayload: DEFAULT_AGENT_TURN_PAYLOAD,
mockTexts: [long],
});
expect(res.status).toBe("ok");
expect(String(res.summary ?? "")).toMatch(/…$/);
});
});
it("starts a fresh session id for each cron run", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });

View File

@@ -9,6 +9,17 @@ type DeliveryPayload = Pick<
"text" | "mediaUrl" | "mediaUrls" | "interactive" | "channelData" | "isError"
>;
export type CronPayloadOutcome = {
summary?: string;
outputText?: string;
synthesizedText?: string;
deliveryPayload?: DeliveryPayload;
deliveryPayloads: DeliveryPayload[];
deliveryPayloadHasStructuredContent: boolean;
hasFatalErrorPayload: boolean;
embeddedRunError?: string;
};
export function pickSummaryFromOutput(text: string | undefined) {
const clean = (text ?? "").trim();
if (!clean) {
@@ -94,3 +105,52 @@ export function resolveHeartbeatAckMaxChars(agentCfg?: { heartbeat?: { ackMaxCha
const raw = agentCfg?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS;
return Math.max(0, raw);
}
export function resolveCronPayloadOutcome(params: {
payloads: DeliveryPayload[];
runLevelError?: unknown;
}): CronPayloadOutcome {
const firstText = params.payloads[0]?.text ?? "";
const summary = pickSummaryFromPayloads(params.payloads) ?? pickSummaryFromOutput(firstText);
const outputText = pickLastNonEmptyTextFromPayloads(params.payloads);
const synthesizedText = outputText?.trim() || summary?.trim() || undefined;
const deliveryPayload = pickLastDeliverablePayload(params.payloads);
const deliveryPayloads =
deliveryPayload !== undefined
? [deliveryPayload]
: synthesizedText
? [{ text: synthesizedText }]
: [];
const deliveryPayloadHasStructuredContent =
deliveryPayload?.mediaUrl !== undefined ||
(deliveryPayload?.mediaUrls?.length ?? 0) > 0 ||
(deliveryPayload?.interactive?.blocks?.length ?? 0) > 0 ||
Object.keys(deliveryPayload?.channelData ?? {}).length > 0;
const hasErrorPayload = params.payloads.some((payload) => payload?.isError === true);
const lastErrorPayloadIndex = params.payloads.findLastIndex(
(payload) => payload?.isError === true,
);
const hasSuccessfulPayloadAfterLastError =
!params.runLevelError &&
lastErrorPayloadIndex >= 0 &&
params.payloads
.slice(lastErrorPayloadIndex + 1)
.some((payload) => payload?.isError !== true && Boolean(payload?.text?.trim()));
const hasFatalErrorPayload = hasErrorPayload && !hasSuccessfulPayloadAfterLastError;
const lastErrorPayloadText = [...params.payloads]
.toReversed()
.find((payload) => payload?.isError === true && Boolean(payload?.text?.trim()))
?.text?.trim();
return {
summary,
outputText,
synthesizedText,
deliveryPayload,
deliveryPayloads,
deliveryPayloadHasStructuredContent,
hasFatalErrorPayload,
embeddedRunError: hasFatalErrorPayload
? (lastErrorPayloadText ?? "cron isolated run returned an error payload")
: undefined,
};
}

View File

@@ -43,6 +43,7 @@ export const logWarnMock = createMock();
export const countActiveDescendantRunsMock = createMock();
export const listDescendantRunsForRequesterMock = createMock();
export const pickLastNonEmptyTextFromPayloadsMock = createMock();
export const resolveCronPayloadOutcomeMock = createMock();
export const resolveCronDeliveryPlanMock = createMock();
export const resolveDeliveryTargetMock = createMock();
@@ -285,6 +286,7 @@ vi.mock("./helpers.js", () => ({
pickLastNonEmptyTextFromPayloads: pickLastNonEmptyTextFromPayloadsMock,
pickSummaryFromOutput: vi.fn().mockReturnValue("summary"),
pickSummaryFromPayloads: vi.fn().mockReturnValue("summary"),
resolveCronPayloadOutcome: resolveCronPayloadOutcomeMock,
resolveHeartbeatAckMaxChars: vi.fn().mockReturnValue(100),
}));
@@ -387,6 +389,26 @@ export function resetRunCronIsolatedAgentTurnHarness(): void {
listDescendantRunsForRequesterMock.mockReturnValue([]);
pickLastNonEmptyTextFromPayloadsMock.mockReset();
pickLastNonEmptyTextFromPayloadsMock.mockReturnValue("test output");
resolveCronPayloadOutcomeMock.mockReset();
resolveCronPayloadOutcomeMock.mockImplementation(
({ payloads }: { payloads: Array<{ isError?: boolean }> }) => {
const outputText = pickLastNonEmptyTextFromPayloadsMock(payloads);
const synthesizedText = outputText?.trim() || "summary";
const hasFatalErrorPayload = payloads.some((payload) => payload?.isError === true);
return {
summary: "summary",
outputText,
synthesizedText,
deliveryPayload: undefined,
deliveryPayloads: synthesizedText ? [{ text: synthesizedText }] : [],
deliveryPayloadHasStructuredContent: false,
hasFatalErrorPayload,
embeddedRunError: hasFatalErrorPayload
? "cron isolated run returned an error payload"
: undefined,
};
},
);
resolveCronDeliveryPlanMock.mockReset();
resolveCronDeliveryPlanMock.mockReturnValue({ requested: false, mode: "none" });
resolveDeliveryTargetMock.mockReset();

View File

@@ -1,4 +1,3 @@
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import {
resolveAgentConfig,
resolveAgentDir,
@@ -58,10 +57,7 @@ import {
import { resolveDeliveryTarget } from "./delivery-target.js";
import {
isHeartbeatOnlyResponse,
pickLastDeliverablePayload,
pickLastNonEmptyTextFromPayloads,
pickSummaryFromOutput,
pickSummaryFromPayloads,
resolveCronPayloadOutcome,
resolveHeartbeatAckMaxChars,
} from "./helpers.js";
import { resolveCronModelSelection } from "./model-selection.js";
@@ -561,12 +557,14 @@ export async function runCronIsolatedAgentTurn(params: {
if (!isAborted()) {
const interimRunResult = runResult;
const interimPayloads = interimRunResult.payloads ?? [];
const interimDeliveryPayload = pickLastDeliverablePayload(interimPayloads);
const interimPayloadHasStructuredContent =
(interimDeliveryPayload
? resolveSendableOutboundReplyParts(interimDeliveryPayload).hasMedia
: false) || Object.keys(interimDeliveryPayload?.channelData ?? {}).length > 0;
const interimText = pickLastNonEmptyTextFromPayloads(interimPayloads)?.trim() ?? "";
const {
deliveryPayloadHasStructuredContent: interimPayloadHasStructuredContent,
outputText: interimOutputText,
} = resolveCronPayloadOutcome({
payloads: interimPayloads,
runLevelError: interimRunResult.meta?.error,
});
const interimText = interimOutputText?.trim() ?? "";
const hasDescendantsSinceRunStart = listDescendantRunsForRequester(agentSessionKey).some(
(entry) => {
const descendantStartedAt =
@@ -690,41 +688,19 @@ export async function runCronIsolatedAgentTurn(params: {
if (isAborted()) {
return withRunSession({ status: "error", error: abortReason(), ...telemetry });
}
const firstText = payloads[0]?.text ?? "";
let summary = pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText);
let outputText = pickLastNonEmptyTextFromPayloads(payloads);
let synthesizedText = outputText?.trim() || summary?.trim() || undefined;
const deliveryPayload = pickLastDeliverablePayload(payloads);
let deliveryPayloads =
deliveryPayload !== undefined
? [deliveryPayload]
: synthesizedText
? [{ text: synthesizedText }]
: [];
const deliveryPayloadHasStructuredContent =
(deliveryPayload ? resolveSendableOutboundReplyParts(deliveryPayload).hasMedia : false) ||
Object.keys(deliveryPayload?.channelData ?? {}).length > 0;
let {
summary,
outputText,
synthesizedText,
deliveryPayloads,
deliveryPayloadHasStructuredContent,
hasFatalErrorPayload,
embeddedRunError,
} = resolveCronPayloadOutcome({
payloads,
runLevelError: finalRunResult.meta?.error,
});
const deliveryBestEffort = resolveCronDeliveryBestEffort(params.job);
const hasErrorPayload = payloads.some((payload) => payload?.isError === true);
const runLevelError = finalRunResult.meta?.error;
const lastErrorPayloadIndex = payloads.findLastIndex((payload) => payload?.isError === true);
const hasSuccessfulPayloadAfterLastError =
!runLevelError &&
lastErrorPayloadIndex >= 0 &&
payloads
.slice(lastErrorPayloadIndex + 1)
.some((payload) => payload?.isError !== true && Boolean(payload?.text?.trim()));
// Tool wrappers can emit transient/false-positive error payloads before a valid final
// assistant payload. Only treat payload errors as recoverable when (a) the run itself
// did not report a model/context-level error and (b) a non-error payload follows.
const hasFatalErrorPayload = hasErrorPayload && !hasSuccessfulPayloadAfterLastError;
const lastErrorPayloadText = [...payloads]
.toReversed()
.find((payload) => payload?.isError === true && Boolean(payload?.text?.trim()))
?.text?.trim();
const embeddedRunError = hasFatalErrorPayload
? (lastErrorPayloadText ?? "cron isolated run returned an error payload")
: undefined;
const resolveRunOutcome = (params?: { delivered?: boolean; deliveryAttempted?: boolean }) =>
withRunSession({
status: hasFatalErrorPayload ? "error" : "ok",

View File

@@ -382,10 +382,6 @@ describe("installHooksFromNpmSpec", () => {
expect(fs.existsSync(packTmpDir)).toBe(false);
});
it("rejects non-registry npm specs", async () => {
await expectUnsupportedNpmSpec((spec) => installHooksFromNpmSpec({ spec }));
});
it("aborts when integrity drift callback rejects the fetched artifact", async () => {
const run = vi.mocked(runCommandWithTimeout);
mockNpmPackMetadataResult(run, {
@@ -411,7 +407,9 @@ describe("installHooksFromNpmSpec", () => {
});
});
it("rejects bare npm specs that resolve to prerelease versions", async () => {
it("rejects invalid npm spec shapes", async () => {
await expectUnsupportedNpmSpec((spec) => installHooksFromNpmSpec({ spec }));
const run = vi.mocked(runCommandWithTimeout);
mockNpmPackMetadataResult(run, {
id: "@openclaw/test-hooks@0.0.2-beta.1",

View File

@@ -91,23 +91,20 @@ describe("loader", () => {
expect(getRegisteredEventKeys()).not.toContain("command:new");
};
it("should return 0 when hooks are not enabled", async () => {
const cfg: OpenClawConfig = {
hooks: {
internal: {
enabled: false,
it("should return 0 when hooks are disabled or missing", async () => {
for (const cfg of [
{
hooks: {
internal: {
enabled: false,
},
},
},
};
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
});
it("should return 0 when hooks config is missing", async () => {
const cfg: OpenClawConfig = {};
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
} satisfies OpenClawConfig,
{} satisfies OpenClawConfig,
]) {
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
}
});
it("should load a handler from a module", async () => {
@@ -171,36 +168,29 @@ describe("loader", () => {
expect(count).toBe(1);
});
it("should handle module loading errors gracefully", async () => {
const cfg = createEnabledHooksConfig([
{
event: "command:new",
module: "missing-handler.js",
},
]);
// Should not throw and should return 0 (handler failed to load)
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
});
it("should handle non-function exports", async () => {
// Create a module with a non-function export
const handlerPath = await writeHandlerModule(
it("should treat invalid handlers as non-loadable", async () => {
const badExportPath = await writeHandlerModule(
"bad-export.js",
'export default "not a function";',
);
const cfg = createEnabledHooksConfig([
{
event: "command:new",
module: path.basename(handlerPath),
},
]);
// Should not throw and should return 0 (handler is not a function)
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
for (const cfg of [
createEnabledHooksConfig([
{
event: "command:new",
module: "missing-handler.js",
},
]),
createEnabledHooksConfig([
{
event: "command:new",
module: path.basename(badExportPath),
},
]),
]) {
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
}
});
it("should handle relative paths", async () => {

View File

@@ -176,35 +176,30 @@ describe("fs-safe", () => {
expect((err as SafeOpenError).message).not.toMatch(/EISDIR/i);
});
it("reads a file within root", async () => {
it("reads files within root through all read helpers", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
await fs.writeFile(path.join(root, "inside.txt"), "inside");
const result = await readFileWithinRoot({
const byRelativePath = await readFileWithinRoot({
rootDir: root,
relativePath: "inside.txt",
});
expect(result.buffer.toString("utf8")).toBe("inside");
expect(result.realPath).toContain("inside.txt");
expect(result.stat.size).toBe(6);
});
expect(byRelativePath.buffer.toString("utf8")).toBe("inside");
expect(byRelativePath.realPath).toContain("inside.txt");
expect(byRelativePath.stat.size).toBe(6);
it("reads an absolute path within root via readPathWithinRoot", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
const insidePath = path.join(root, "absolute.txt");
await fs.writeFile(insidePath, "absolute");
const result = await readPathWithinRoot({
const absolutePath = path.join(root, "absolute.txt");
await fs.writeFile(absolutePath, "absolute");
const byAbsolutePath = await readPathWithinRoot({
rootDir: root,
filePath: insidePath,
filePath: absolutePath,
});
expect(result.buffer.toString("utf8")).toBe("absolute");
});
expect(byAbsolutePath.buffer.toString("utf8")).toBe("absolute");
it("creates a root-scoped read callback", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
const insidePath = path.join(root, "scoped.txt");
await fs.writeFile(insidePath, "scoped");
const scopedPath = path.join(root, "scoped.txt");
await fs.writeFile(scopedPath, "scoped");
const readScoped = createRootScopedReadFile({ rootDir: root });
await expect(readScoped(insidePath)).resolves.toEqual(Buffer.from("scoped"));
await expect(readScoped(scopedPath)).resolves.toEqual(Buffer.from("scoped"));
});
it.runIf(process.platform !== "win32")("blocks symlink escapes under root", async () => {

View File

@@ -7,11 +7,7 @@ import { buildPluginSdkEntrySources, pluginSdkEntrypoints } from "./entrypoints.
const require = createRequire(import.meta.url);
const tsdownModuleUrl = pathToFileURL(require.resolve("tsdown")).href;
const bundledRepresentativeEntrypoints = [
"channel-runtime",
"matrix-runtime-heavy",
"windows-spawn",
] as const;
const bundledRepresentativeEntrypoints = ["outbound-runtime", "matrix-runtime-heavy"] as const;
const bundledCoverageEntrySources = buildPluginSdkEntrySources(bundledRepresentativeEntrypoints);
describe("plugin-sdk bundled exports", () => {

View File

@@ -122,6 +122,10 @@ function expectSourceContract(
expect(present, `${subpath} leaked exports`).toEqual([]);
}
function expectSourceContains(subpath: string, snippet: string) {
expect(readPluginSdkSource(subpath)).toContain(snippet);
}
describe("plugin-sdk subpath exports", () => {
it("keeps the curated public list free of internal implementation subpaths", () => {
for (const deniedSubpath of [
@@ -148,7 +152,7 @@ describe("plugin-sdk subpath exports", () => {
}
});
it("keeps core focused on generic shared exports", () => {
it("keeps helper subpaths aligned", () => {
expectSourceMentions("core", [
"emptyPluginConfigSchema",
"definePluginEntry",
@@ -164,9 +168,6 @@ describe("plugin-sdk subpath exports", () => {
"createLoggerBackedRuntime",
"registerSandboxBackend",
]);
});
it("keeps generic helper subpaths aligned", () => {
expectSourceContract("routing", {
mentions: [
"buildAgentSessionKey",
@@ -350,9 +351,6 @@ describe("plugin-sdk subpath exports", () => {
"isRecord",
"resolveEnabledConfiguredAccountId",
]);
});
it("keeps channel helper subpaths aligned", () => {
expectSourceMentions("channel-inbound", [
"buildMentionRegexes",
"createChannelInboundDebouncer",
@@ -442,6 +440,11 @@ describe("plugin-sdk subpath exports", () => {
"isRecord",
"resolveEnabledConfiguredAccountId",
]);
expectSourceMentions("outbound-runtime", [
"createRuntimeOutboundDelegates",
"resolveOutboundSendDep",
"resolveAgentOutboundIdentity",
]);
expectSourceMentions("command-auth", [
"buildCommandTextFromArgs",
"buildCommandsPaginationKeyboard",
@@ -459,24 +462,6 @@ describe("plugin-sdk subpath exports", () => {
"shouldComputeCommandAuthorized",
"shouldHandleTextCommands",
]);
});
it("keeps channel contract types on the dedicated subpath", () => {
expectTypeOf<ContractBaseProbeResult>().toMatchTypeOf<BaseProbeResult>();
expectTypeOf<ContractBaseTokenResolution>().toMatchTypeOf<BaseTokenResolution>();
expectTypeOf<ContractChannelAgentTool>().toMatchTypeOf<ChannelAgentTool>();
expectTypeOf<ContractChannelAccountSnapshot>().toMatchTypeOf<ChannelAccountSnapshot>();
expectTypeOf<ContractChannelGroupContext>().toMatchTypeOf<ChannelGroupContext>();
expectTypeOf<ContractChannelMessageActionAdapter>().toMatchTypeOf<ChannelMessageActionAdapter>();
expectTypeOf<ContractChannelMessageActionContext>().toMatchTypeOf<ChannelMessageActionContext>();
expectTypeOf<ContractChannelMessageActionName>().toMatchTypeOf<ChannelMessageActionName>();
expectTypeOf<ContractChannelMessageToolDiscovery>().toMatchTypeOf<ChannelMessageToolDiscovery>();
expectTypeOf<ContractChannelStatusIssue>().toMatchTypeOf<ChannelStatusIssue>();
expectTypeOf<ContractChannelThreadingContext>().toMatchTypeOf<ChannelThreadingContext>();
expectTypeOf<ContractChannelThreadingToolContext>().toMatchTypeOf<ChannelThreadingToolContext>();
});
it("keeps source-only helper subpaths aligned", () => {
expectSourceMentions("channel-send-result", [
"attachChannelToResult",
"buildChannelSendResult",
@@ -571,7 +556,19 @@ describe("plugin-sdk subpath exports", () => {
expectSourceMentions("testing", ["removeAckReactionAfterReply", "shouldAckReaction"]);
});
it("keeps core shared types aligned", () => {
it("keeps shared plugin-sdk types aligned", () => {
expectTypeOf<ContractBaseProbeResult>().toMatchTypeOf<BaseProbeResult>();
expectTypeOf<ContractBaseTokenResolution>().toMatchTypeOf<BaseTokenResolution>();
expectTypeOf<ContractChannelAgentTool>().toMatchTypeOf<ChannelAgentTool>();
expectTypeOf<ContractChannelAccountSnapshot>().toMatchTypeOf<ChannelAccountSnapshot>();
expectTypeOf<ContractChannelGroupContext>().toMatchTypeOf<ChannelGroupContext>();
expectTypeOf<ContractChannelMessageActionAdapter>().toMatchTypeOf<ChannelMessageActionAdapter>();
expectTypeOf<ContractChannelMessageActionContext>().toMatchTypeOf<ChannelMessageActionContext>();
expectTypeOf<ContractChannelMessageActionName>().toMatchTypeOf<ChannelMessageActionName>();
expectTypeOf<ContractChannelMessageToolDiscovery>().toMatchTypeOf<ChannelMessageToolDiscovery>();
expectTypeOf<ContractChannelStatusIssue>().toMatchTypeOf<ChannelStatusIssue>();
expectTypeOf<ContractChannelThreadingContext>().toMatchTypeOf<ChannelThreadingContext>();
expectTypeOf<ContractChannelThreadingToolContext>().toMatchTypeOf<ChannelThreadingToolContext>();
expectTypeOf<CoreOpenClawPluginApi>().toMatchTypeOf<OpenClawPluginApi>();
expectTypeOf<CorePluginRuntime>().toMatchTypeOf<PluginRuntime>();
expectTypeOf<CoreChannelMessageActionContext>().toMatchTypeOf<ChannelMessageActionContext>();
@@ -584,7 +581,6 @@ describe("plugin-sdk subpath exports", () => {
const [
coreSdk,
pluginEntrySdk,
infraRuntimeSdk,
channelLifecycleSdk,
channelPairingSdk,
channelReplyPipelineSdk,
@@ -592,7 +588,6 @@ describe("plugin-sdk subpath exports", () => {
] = await Promise.all([
importResolvedPluginSdkSubpath("openclaw/plugin-sdk/core"),
importResolvedPluginSdkSubpath("openclaw/plugin-sdk/plugin-entry"),
importResolvedPluginSdkSubpath("openclaw/plugin-sdk/infra-runtime"),
importResolvedPluginSdkSubpath("openclaw/plugin-sdk/channel-lifecycle"),
importResolvedPluginSdkSubpath("openclaw/plugin-sdk/channel-pairing"),
importResolvedPluginSdkSubpath("openclaw/plugin-sdk/channel-reply-pipeline"),
@@ -603,8 +598,8 @@ describe("plugin-sdk subpath exports", () => {
expect(coreSdk.definePluginEntry).toBe(pluginEntrySdk.definePluginEntry);
expect(typeof infraRuntimeSdk.createRuntimeOutboundDelegates).toBe("function");
expect(typeof infraRuntimeSdk.resolveOutboundSendDep).toBe("function");
expectSourceMentions("infra-runtime", ["createRuntimeOutboundDelegates"]);
expectSourceContains("infra-runtime", "../infra/outbound/send-deps.js");
expect(typeof channelLifecycleSdk.createDraftStreamLoop).toBe("function");
expect(typeof channelLifecycleSdk.createFinalizableDraftLifecycle).toBe("function");

View File

@@ -583,18 +583,13 @@ describe("installPluginFromArchive", () => {
expect(manifest.version).toBe("0.0.2");
});
it("rejects traversal-like plugin names", async () => {
await expectArchiveInstallReservedSegmentRejection({
packageName: "@evil/..",
outName: "traversal.tgz",
});
});
it("rejects reserved plugin ids", async () => {
await expectArchiveInstallReservedSegmentRejection({
packageName: "@evil/.",
outName: "reserved.tgz",
});
it("rejects reserved archive package ids", async () => {
for (const params of [
{ packageName: "@evil/..", outName: "traversal.tgz" },
{ packageName: "@evil/.", outName: "reserved.tgz" },
]) {
await expectArchiveInstallReservedSegmentRejection(params);
}
});
it("rejects packages without openclaw.extensions", async () => {
@@ -802,42 +797,45 @@ describe("installPluginFromDir", () => {
).toBe(true);
});
it("preserves scoped manifest ids as install keys", async () => {
const { pluginDir, extensionsDir } = setupManifestInstallFixture({
manifestId: "@team/memory-cognee",
});
it("keeps scoped install ids aligned across manifest and package-name cases", async () => {
const scenarios = [
{
setup: () => setupManifestInstallFixture({ manifestId: "@team/memory-cognee" }),
expectedPluginId: "@team/memory-cognee",
install: (pluginDir: string, extensionsDir: string) =>
installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
expectedPluginId: "@team/memory-cognee",
logger: { info: () => {}, warn: () => {} },
}),
},
{
setup: () => setupInstallPluginFromDirFixture(),
expectedPluginId: "@openclaw/test-plugin",
install: (pluginDir: string, extensionsDir: string) =>
installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
}),
},
{
setup: () => setupInstallPluginFromDirFixture(),
expectedPluginId: "@openclaw/test-plugin",
install: (pluginDir: string, extensionsDir: string) =>
installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
expectedPluginId: "test-plugin",
}),
},
] as const;
const res = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
expectedPluginId: "@team/memory-cognee",
logger: { info: () => {}, warn: () => {} },
});
expectInstalledWithPluginId(res, extensionsDir, "@team/memory-cognee");
});
it("preserves scoped package names when no plugin manifest id is present", async () => {
const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture();
const res = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expectInstalledWithPluginId(res, extensionsDir, "@openclaw/test-plugin");
});
it("accepts legacy unscoped expected ids for scoped package names without manifest ids", async () => {
const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture();
const res = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
expectedPluginId: "test-plugin",
});
expectInstalledWithPluginId(res, extensionsDir, "@openclaw/test-plugin");
for (const scenario of scenarios) {
const { pluginDir, extensionsDir } = scenario.setup();
const res = await scenario.install(pluginDir, extensionsDir);
expectInstalledWithPluginId(res, extensionsDir, scenario.expectedPluginId);
}
});
it("keeps scoped install-dir validation aligned", () => {
@@ -1153,77 +1151,74 @@ describe("installPluginFromNpmSpec", () => {
}
});
it("rejects bare npm specs that resolve to prerelease versions", async () => {
const run = vi.mocked(runCommandWithTimeout);
mockNpmPackMetadataResult(run, {
it("handles prerelease npm specs correctly", async () => {
const prereleaseMetadata = {
id: "@openclaw/voice-call@0.0.2-beta.1",
name: "@openclaw/voice-call",
version: "0.0.2-beta.1",
filename: "voice-call-0.0.2-beta.1.tgz",
integrity: "sha512-beta",
shasum: "betashasum",
});
};
const result = await installPluginFromNpmSpec({
spec: "@openclaw/voice-call",
logger: { info: () => {}, warn: () => {} },
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("prerelease version 0.0.2-beta.1");
expect(result.error).toContain('"@openclaw/voice-call@beta"');
}
});
{
const run = vi.mocked(runCommandWithTimeout);
mockNpmPackMetadataResult(run, prereleaseMetadata);
it("allows explicit prerelease npm tags", async () => {
const run = vi.mocked(runCommandWithTimeout);
let packTmpDir = "";
const packedName = "voice-call-0.0.2-beta.1.tgz";
const voiceCallArchiveBuffer = VOICE_CALL_ARCHIVE_V1_BUFFER;
run.mockImplementation(async (argv, opts) => {
if (argv[0] === "npm" && argv[1] === "pack") {
packTmpDir = String(typeof opts === "number" ? "" : (opts.cwd ?? ""));
fs.writeFileSync(path.join(packTmpDir, packedName), voiceCallArchiveBuffer);
return {
code: 0,
stdout: JSON.stringify([
{
id: "@openclaw/voice-call@0.0.2-beta.1",
name: "@openclaw/voice-call",
version: "0.0.2-beta.1",
filename: packedName,
integrity: "sha512-beta",
shasum: "betashasum",
},
]),
stderr: "",
signal: null,
killed: false,
termination: "exit",
};
const result = await installPluginFromNpmSpec({
spec: "@openclaw/voice-call",
logger: { info: () => {}, warn: () => {} },
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("prerelease version 0.0.2-beta.1");
expect(result.error).toContain('"@openclaw/voice-call@beta"');
}
throw new Error(`unexpected command: ${argv.join(" ")}`);
});
const { extensionsDir } = await setupVoiceCallArchiveInstall({
outName: "voice-call-0.0.2-beta.1.tgz",
version: "0.0.1",
});
const result = await installPluginFromNpmSpec({
spec: "@openclaw/voice-call@beta",
extensionsDir,
logger: { info: () => {}, warn: () => {} },
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.npmResolution?.version).toBe("0.0.2-beta.1");
expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1");
expectSingleNpmPackIgnoreScriptsCall({
calls: run.mock.calls,
expectedSpec: "@openclaw/voice-call@beta",
});
expect(packTmpDir).not.toBe("");
vi.clearAllMocks();
{
const run = vi.mocked(runCommandWithTimeout);
let packTmpDir = "";
const packedName = "voice-call-0.0.2-beta.1.tgz";
const voiceCallArchiveBuffer = VOICE_CALL_ARCHIVE_V1_BUFFER;
run.mockImplementation(async (argv, opts) => {
if (argv[0] === "npm" && argv[1] === "pack") {
packTmpDir = String(typeof opts === "number" ? "" : (opts.cwd ?? ""));
fs.writeFileSync(path.join(packTmpDir, packedName), voiceCallArchiveBuffer);
return {
code: 0,
stdout: JSON.stringify([prereleaseMetadata]),
stderr: "",
signal: null,
killed: false,
termination: "exit",
};
}
throw new Error(`unexpected command: ${argv.join(" ")}`);
});
const { extensionsDir } = await setupVoiceCallArchiveInstall({
outName: "voice-call-0.0.2-beta.1.tgz",
version: "0.0.1",
});
const result = await installPluginFromNpmSpec({
spec: "@openclaw/voice-call@beta",
extensionsDir,
logger: { info: () => {}, warn: () => {} },
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.npmResolution?.version).toBe("0.0.2-beta.1");
expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1");
expectSingleNpmPackIgnoreScriptsCall({
calls: run.mock.calls,
expectedSpec: "@openclaw/voice-call@beta",
});
expect(packTmpDir).not.toBe("");
}
});
});

View File

@@ -49,19 +49,13 @@ function createCapturedIo() {
}
describe("extension src outside plugin-sdk boundary inventory", () => {
it("is currently empty", async () => {
it("stays empty and sorted", async () => {
const inventory = await srcOutsideInventoryPromise;
const jsonResult = await srcOutsideJsonOutputPromise;
expect(inventory).toEqual([]);
});
it("produces stable sorted output", async () => {
const first = await srcOutsideInventoryPromise;
const second = await srcOutsideInventoryPromise;
expect(second).toEqual(first);
expect(
[...first].toSorted(
[...inventory].toSorted(
(left, right) =>
left.file.localeCompare(right.file) ||
left.line - right.line ||
@@ -70,46 +64,33 @@ describe("extension src outside plugin-sdk boundary inventory", () => {
left.resolvedPath.localeCompare(right.resolvedPath) ||
left.reason.localeCompare(right.reason),
),
).toEqual(first);
});
it("script json output is empty", async () => {
const result = await srcOutsideJsonOutputPromise;
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe("");
expect(result.json).toEqual([]);
).toEqual(inventory);
expect(jsonResult.exitCode).toBe(0);
expect(jsonResult.stderr).toBe("");
expect(jsonResult.json).toEqual([]);
});
});
describe("extension plugin-sdk-internal boundary inventory", () => {
it("is currently empty", async () => {
it("stays empty", async () => {
const inventory = await pluginSdkInternalInventoryPromise;
const jsonResult = await pluginSdkInternalJsonOutputPromise;
expect(inventory).toEqual([]);
});
it("script json output is empty", async () => {
const result = await pluginSdkInternalJsonOutputPromise;
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe("");
expect(result.json).toEqual([]);
expect(jsonResult.exitCode).toBe(0);
expect(jsonResult.stderr).toBe("");
expect(jsonResult.json).toEqual([]);
});
});
describe("extension relative-outside-package boundary inventory", () => {
it("is currently empty", async () => {
it("stays empty", async () => {
const inventory = await relativeOutsidePackageInventoryPromise;
const jsonResult = await relativeOutsidePackageJsonOutputPromise;
expect(inventory).toEqual([]);
});
it("script json output is empty", async () => {
const result = await relativeOutsidePackageJsonOutputPromise;
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe("");
expect(result.json).toEqual([]);
expect(jsonResult.exitCode).toBe(0);
expect(jsonResult.stderr).toBe("");
expect(jsonResult.json).toEqual([]);
});
});

View File

@@ -4,6 +4,9 @@ import {
main,
} from "../scripts/check-web-search-provider-boundaries.mjs";
const inventoryPromise = collectWebSearchProviderBoundaryInventory();
const jsonOutputPromise = getJsonOutput();
function createCapturedIo() {
let stdout = "";
let stderr = "";
@@ -25,41 +28,34 @@ function createCapturedIo() {
};
}
async function getJsonOutput() {
const captured = createCapturedIo();
const exitCode = await main(["--json"], captured.io);
return {
exitCode,
stderr: captured.readStderr(),
json: JSON.parse(captured.readStdout()),
};
}
describe("web search provider boundary inventory", () => {
it("has no remaining production inventory in core", async () => {
const inventory = await collectWebSearchProviderBoundaryInventory();
it("stays empty, core-only, and sorted", async () => {
const inventory = await inventoryPromise;
const jsonOutput = await jsonOutputPromise;
expect(inventory).toEqual([]);
});
it("ignores extension-owned registrations", async () => {
const inventory = await collectWebSearchProviderBoundaryInventory();
expect(inventory.some((entry) => entry.file.startsWith("extensions/"))).toBe(false);
});
it("produces stable sorted output", async () => {
const first = await collectWebSearchProviderBoundaryInventory();
const second = await collectWebSearchProviderBoundaryInventory();
expect(second).toEqual(first);
expect(
[...first].toSorted(
[...inventory].toSorted(
(left, right) =>
left.provider.localeCompare(right.provider) ||
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.reason.localeCompare(right.reason),
),
).toEqual(first);
});
it("script json output is empty", async () => {
const captured = createCapturedIo();
const exitCode = await main(["--json"], captured.io);
expect(exitCode).toBe(0);
expect(captured.readStderr()).toBe("");
expect(JSON.parse(captured.readStdout())).toEqual([]);
).toEqual(inventory);
expect(jsonOutput.exitCode).toBe(0);
expect(jsonOutput.stderr).toBe("");
expect(jsonOutput.json).toEqual([]);
});
});