mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-25 23:47:20 +00:00
test: trim more local test startup overhead
This commit is contained in:
62
src/cron/isolated-agent.helpers.test.ts
Normal file
62
src/cron/isolated-agent.helpers.test.ts
Normal 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(/…$/);
|
||||
});
|
||||
});
|
||||
@@ -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: "" });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user