mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 16:30:23 +00:00
fix(plugins): keep test helpers out of contract barrels (#63311)
Merged via squash.
Prepared head SHA: 769e90c6af
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Security/dependency audit: force `basic-ftp` to `5.2.1` to pick up the CRLF command-injection fix from GHSA-chqc-8p9q-pq6q.
|
- Security/dependency audit: force `basic-ftp` to `5.2.1` to pick up the CRLF command-injection fix from GHSA-chqc-8p9q-pq6q.
|
||||||
- Security/dependency audit: bump Hono to `4.12.12` and `@hono/node-server` to `1.19.13` in production resolution paths.
|
- Security/dependency audit: bump Hono to `4.12.12` and `@hono/node-server` to `1.19.13` in production resolution paths.
|
||||||
- Slack/partial streaming: keep the final fallback reply path active when preview finalization fails so stale preview text cannot suppress the actual final answer. (#62859) Thanks @gumadeiras.
|
- Slack/partial streaming: keep the final fallback reply path active when preview finalization fails so stale preview text cannot suppress the actual final answer. (#62859) Thanks @gumadeiras.
|
||||||
|
- Plugins/contracts: keep test-only helpers out of production contract barrels, load shared contract harnesses through bundled test surfaces, and harden guardrails so indirect re-exports and canonical `*.test.ts` files stay blocked. (#63311) Thanks @altaywtf.
|
||||||
|
|
||||||
## 2026.4.8
|
## 2026.4.8
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export { createIMessageTestPlugin } from "./src/test-plugin.js";
|
|
||||||
export {
|
export {
|
||||||
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
|
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
|
||||||
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
|
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
|
||||||
|
|||||||
@@ -5,20 +5,32 @@ import { collectStatusIssuesFromLastError } from "openclaw/plugin-sdk/status-hel
|
|||||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||||
|
|
||||||
function normalizeIMessageTestHandle(raw: string): string {
|
function normalizeIMessageTestHandle(raw: string): string {
|
||||||
const trimmed = raw.trim();
|
let trimmed = raw.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const lowered = normalizeLowercaseStringOrEmpty(trimmed);
|
|
||||||
if (lowered.startsWith("imessage:")) {
|
while (trimmed) {
|
||||||
return normalizeIMessageTestHandle(trimmed.slice("imessage:".length));
|
const lowered = normalizeLowercaseStringOrEmpty(trimmed);
|
||||||
|
if (lowered.startsWith("imessage:")) {
|
||||||
|
trimmed = trimmed.slice("imessage:".length).trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lowered.startsWith("sms:")) {
|
||||||
|
trimmed = trimmed.slice("sms:".length).trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lowered.startsWith("auto:")) {
|
||||||
|
trimmed = trimmed.slice("auto:".length).trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (lowered.startsWith("sms:")) {
|
|
||||||
return normalizeIMessageTestHandle(trimmed.slice("sms:".length));
|
if (!trimmed) {
|
||||||
}
|
return "";
|
||||||
if (lowered.startsWith("auto:")) {
|
|
||||||
return normalizeIMessageTestHandle(trimmed.slice("auto:".length));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^(chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) {
|
if (/^(chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) {
|
||||||
return trimmed.replace(/^(chat_id:|chat_guid:|chat_identifier:)/i, (match) =>
|
return trimmed.replace(/^(chat_id:|chat_guid:|chat_identifier:)/i, (match) =>
|
||||||
normalizeLowercaseStringOrEmpty(match),
|
normalizeLowercaseStringOrEmpty(match),
|
||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
listImportedBundledPluginFacadeIds,
|
listImportedBundledPluginFacadeIds,
|
||||||
resetFacadeRuntimeStateForTest,
|
resetFacadeRuntimeStateForTest,
|
||||||
} from "../../../src/plugin-sdk/facade-runtime.js";
|
} from "../../../src/plugin-sdk/facade-runtime.js";
|
||||||
import { createIMessageTestPlugin } from "./test-plugin.js";
|
import { createIMessageTestPlugin } from "./imessage.test-plugin.js";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetFacadeRuntimeStateForTest();
|
resetFacadeRuntimeStateForTest();
|
||||||
@@ -21,4 +21,11 @@ describe("createIMessageTestPlugin", () => {
|
|||||||
|
|
||||||
expect(listImportedBundledPluginFacadeIds()).toEqual([]);
|
expect(listImportedBundledPluginFacadeIds()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes repeated transport prefixes without recursive stack growth", () => {
|
||||||
|
const plugin = createIMessageTestPlugin();
|
||||||
|
const prefixedHandle = `${"imessage:".repeat(5000)}+44 20 7946 0958`;
|
||||||
|
|
||||||
|
expect(plugin.messaging?.normalizeTarget?.(prefixedHandle)).toBe("+442079460958");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
1
extensions/imessage/test-api.ts
Normal file
1
extensions/imessage/test-api.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { createIMessageTestPlugin } from "./src/imessage.test-plugin.js";
|
||||||
@@ -3,7 +3,6 @@ export {
|
|||||||
collectRuntimeConfigAssignments,
|
collectRuntimeConfigAssignments,
|
||||||
secretTargetRegistryEntries,
|
secretTargetRegistryEntries,
|
||||||
} from "./src/secret-contract.js";
|
} from "./src/secret-contract.js";
|
||||||
export { createSlackOutboundPayloadHarness } from "./src/outbound-payload-harness.js";
|
|
||||||
export type {
|
export type {
|
||||||
SlackInteractiveHandlerContext,
|
SlackInteractiveHandlerContext,
|
||||||
SlackInteractiveHandlerRegistration,
|
SlackInteractiveHandlerRegistration,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createSlackOutboundPayloadHarness } from "../contract-api.js";
|
import { createSlackOutboundPayloadHarness } from "../test-api.js";
|
||||||
|
|
||||||
function createHarness(params: {
|
function createHarness(params: {
|
||||||
payload: ReplyPayload;
|
payload: ReplyPayload;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type { SlackMessageEvent } from "./src/types.js";
|
|||||||
export { slackPlugin } from "./src/channel.js";
|
export { slackPlugin } from "./src/channel.js";
|
||||||
export { setSlackRuntime } from "./src/runtime.js";
|
export { setSlackRuntime } from "./src/runtime.js";
|
||||||
export { createSlackActions } from "./src/channel-actions.js";
|
export { createSlackActions } from "./src/channel-actions.js";
|
||||||
|
export { createSlackOutboundPayloadHarness } from "./src/outbound-payload.test-harness.js";
|
||||||
export { prepareSlackMessage } from "./src/monitor/message-handler/prepare.js";
|
export { prepareSlackMessage } from "./src/monitor/message-handler/prepare.js";
|
||||||
export { createInboundSlackTestContext } from "./src/monitor/message-handler/prepare.test-helpers.js";
|
export { createInboundSlackTestContext } from "./src/monitor/message-handler/prepare.test-helpers.js";
|
||||||
export { slackOutbound } from "./src/outbound-adapter.js";
|
export { slackOutbound } from "./src/outbound-adapter.js";
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import {
|
|||||||
isWhatsAppGroupJid as isWhatsAppGroupJidImpl,
|
isWhatsAppGroupJid as isWhatsAppGroupJidImpl,
|
||||||
normalizeWhatsAppTarget as normalizeWhatsAppTargetImpl,
|
normalizeWhatsAppTarget as normalizeWhatsAppTargetImpl,
|
||||||
} from "./src/normalize-target.js";
|
} from "./src/normalize-target.js";
|
||||||
import {
|
|
||||||
createWhatsAppPollFixture as createWhatsAppPollFixtureImpl,
|
|
||||||
expectWhatsAppPollSent as expectWhatsAppPollSentImpl,
|
|
||||||
} from "./src/outbound-test-support.js";
|
|
||||||
import { resolveWhatsAppRuntimeGroupPolicy as resolveWhatsAppRuntimeGroupPolicyImpl } from "./src/runtime-group-policy.js";
|
import { resolveWhatsAppRuntimeGroupPolicy as resolveWhatsAppRuntimeGroupPolicyImpl } from "./src/runtime-group-policy.js";
|
||||||
import {
|
import {
|
||||||
canonicalizeLegacySessionKey as canonicalizeLegacySessionKeyImpl,
|
canonicalizeLegacySessionKey as canonicalizeLegacySessionKeyImpl,
|
||||||
@@ -20,8 +16,6 @@ export {
|
|||||||
} from "./src/security-contract.js";
|
} from "./src/security-contract.js";
|
||||||
|
|
||||||
export const canonicalizeLegacySessionKey = canonicalizeLegacySessionKeyImpl;
|
export const canonicalizeLegacySessionKey = canonicalizeLegacySessionKeyImpl;
|
||||||
export const createWhatsAppPollFixture = createWhatsAppPollFixtureImpl;
|
|
||||||
export const expectWhatsAppPollSent = expectWhatsAppPollSentImpl;
|
|
||||||
export const isLegacyGroupSessionKey = isLegacyGroupSessionKeyImpl;
|
export const isLegacyGroupSessionKey = isLegacyGroupSessionKeyImpl;
|
||||||
export const isWhatsAppGroupJid = isWhatsAppGroupJidImpl;
|
export const isWhatsAppGroupJid = isWhatsAppGroupJidImpl;
|
||||||
export const normalizeWhatsAppTarget = normalizeWhatsAppTargetImpl;
|
export const normalizeWhatsAppTarget = normalizeWhatsAppTargetImpl;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createWhatsAppPollFixture, expectWhatsAppPollSent } from "../contract-api.js";
|
import { createWhatsAppPollFixture, expectWhatsAppPollSent } from "../test-api.js";
|
||||||
import { createWhatsAppOutboundBase } from "./outbound-base.js";
|
import { createWhatsAppOutboundBase } from "./outbound-base.js";
|
||||||
|
|
||||||
describe("createWhatsAppOutboundBase", () => {
|
describe("createWhatsAppOutboundBase", () => {
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { whatsappOutbound } from "./src/outbound-adapter.js";
|
export { whatsappOutbound } from "./src/outbound-adapter.js";
|
||||||
export { resolveWhatsAppRuntimeGroupPolicy } from "./src/runtime-group-policy.js";
|
export { resolveWhatsAppRuntimeGroupPolicy } from "./src/runtime-group-policy.js";
|
||||||
|
export { createWhatsAppPollFixture, expectWhatsAppPollSent } from "./src/outbound-test-support.js";
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
loadPluginManifestRegistry,
|
loadPluginManifestRegistry,
|
||||||
type PluginManifestRecord,
|
type PluginManifestRecord,
|
||||||
} from "../plugins/manifest-registry.js";
|
} from "../plugins/manifest-registry.js";
|
||||||
|
import { normalizeBundledPluginArtifactSubpath } from "../plugins/public-surface-runtime.js";
|
||||||
|
|
||||||
const ALWAYS_ALLOWED_RUNTIME_DIR_NAMES = new Set([
|
const ALWAYS_ALLOWED_RUNTIME_DIR_NAMES = new Set([
|
||||||
"image-generation-core",
|
"image-generation-core",
|
||||||
@@ -166,7 +167,7 @@ export function resolveRegistryPluginModuleLocation(params: {
|
|||||||
(plugin) => path.basename(plugin.rootDir) === params.dirName,
|
(plugin) => path.basename(plugin.rootDir) === params.dirName,
|
||||||
(plugin) => plugin.channels.includes(params.dirName),
|
(plugin) => plugin.channels.includes(params.dirName),
|
||||||
];
|
];
|
||||||
const artifactBasename = params.artifactBasename.replace(/^\.\//u, "");
|
const artifactBasename = normalizeBundledPluginArtifactSubpath(params.artifactBasename);
|
||||||
const sourceBaseName = artifactBasename.replace(/\.js$/u, "");
|
const sourceBaseName = artifactBasename.replace(/\.js$/u, "");
|
||||||
for (const matchFn of tiers) {
|
for (const matchFn of tiers) {
|
||||||
for (const record of registry.filter(matchFn)) {
|
for (const record of registry.filter(matchFn)) {
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
|
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
|
||||||
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
|
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
|
||||||
import { resolveBundledPluginPublicSurfacePath } from "../plugins/public-surface-runtime.js";
|
import {
|
||||||
|
normalizeBundledPluginArtifactSubpath,
|
||||||
|
resolveBundledPluginPublicSurfacePath,
|
||||||
|
} from "../plugins/public-surface-runtime.js";
|
||||||
import {
|
import {
|
||||||
buildPluginLoaderJitiOptions,
|
buildPluginLoaderJitiOptions,
|
||||||
resolvePluginLoaderJitiConfig,
|
resolvePluginLoaderJitiConfig,
|
||||||
@@ -62,7 +65,8 @@ function resolveSourceFirstPublicSurfacePath(params: {
|
|||||||
dirName: string;
|
dirName: string;
|
||||||
artifactBasename: string;
|
artifactBasename: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const sourceBaseName = params.artifactBasename.replace(/\.js$/u, "");
|
const artifactBasename = normalizeBundledPluginArtifactSubpath(params.artifactBasename);
|
||||||
|
const sourceBaseName = artifactBasename.replace(/\.js$/u, "");
|
||||||
const sourceRoot =
|
const sourceRoot =
|
||||||
params.bundledPluginsDir ?? path.resolve(getOpenClawPackageRoot(), "extensions");
|
params.bundledPluginsDir ?? path.resolve(getOpenClawPackageRoot(), "extensions");
|
||||||
for (const ext of PUBLIC_SURFACE_SOURCE_EXTENSIONS) {
|
for (const ext of PUBLIC_SURFACE_SOURCE_EXTENSIONS) {
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
|
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
|
||||||
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
|
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
|
||||||
import { resolveBundledPluginPublicSurfacePath } from "../plugins/public-surface-runtime.js";
|
import {
|
||||||
|
normalizeBundledPluginArtifactSubpath,
|
||||||
|
resolveBundledPluginPublicSurfacePath,
|
||||||
|
} from "../plugins/public-surface-runtime.js";
|
||||||
import { resolveLoaderPackageRoot } from "../plugins/sdk-alias.js";
|
import { resolveLoaderPackageRoot } from "../plugins/sdk-alias.js";
|
||||||
import {
|
import {
|
||||||
loadBundledPluginPublicSurfaceModuleSync as loadBundledPluginPublicSurfaceModuleSyncLight,
|
loadBundledPluginPublicSurfaceModuleSync as loadBundledPluginPublicSurfaceModuleSyncLight,
|
||||||
@@ -44,7 +47,8 @@ function resolveSourceFirstPublicSurfacePath(params: {
|
|||||||
dirName: string;
|
dirName: string;
|
||||||
artifactBasename: string;
|
artifactBasename: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const sourceBaseName = params.artifactBasename.replace(/\.js$/u, "");
|
const artifactBasename = normalizeBundledPluginArtifactSubpath(params.artifactBasename);
|
||||||
|
const sourceBaseName = artifactBasename.replace(/\.js$/u, "");
|
||||||
const sourceRoot = params.bundledPluginsDir ?? path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions");
|
const sourceRoot = params.bundledPluginsDir ?? path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions");
|
||||||
for (const ext of PUBLIC_SURFACE_SOURCE_EXTENSIONS) {
|
for (const ext of PUBLIC_SURFACE_SOURCE_EXTENSIONS) {
|
||||||
const candidate = path.resolve(sourceRoot, params.dirName, `${sourceBaseName}${ext}`);
|
const candidate = path.resolve(sourceRoot, params.dirName, `${sourceBaseName}${ext}`);
|
||||||
@@ -66,7 +70,7 @@ function resolveRegistryPluginModuleLocationFromRegistry(params: {
|
|||||||
(plugin) => path.basename(plugin.rootDir) === params.dirName,
|
(plugin) => path.basename(plugin.rootDir) === params.dirName,
|
||||||
(plugin) => plugin.channels.includes(params.dirName),
|
(plugin) => plugin.channels.includes(params.dirName),
|
||||||
];
|
];
|
||||||
const artifactBasename = params.artifactBasename.replace(/^\.\//u, "");
|
const artifactBasename = normalizeBundledPluginArtifactSubpath(params.artifactBasename);
|
||||||
const sourceBaseName = artifactBasename.replace(/\.js$/u, "");
|
const sourceBaseName = artifactBasename.replace(/\.js$/u, "");
|
||||||
for (const matchFn of tiers) {
|
for (const matchFn of tiers) {
|
||||||
for (const record of params.registry.filter(matchFn)) {
|
for (const record of params.registry.filter(matchFn)) {
|
||||||
|
|||||||
@@ -1,11 +1,30 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import path, { dirname, relative, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import ts from "typescript";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { listBundledPluginMetadata } from "../bundled-plugin-metadata.js";
|
||||||
import { loadPluginManifestRegistry } from "../manifest-registry.js";
|
import { loadPluginManifestRegistry } from "../manifest-registry.js";
|
||||||
const CORE_PLUGIN_ENTRY_IMPORT_RE =
|
|
||||||
/import\s*\{[^}]*\bdefinePluginEntry\b[^}]*\}\s*from\s*"openclaw\/plugin-sdk\/core"/;
|
|
||||||
const RUNTIME_ENTRY_HELPER_RE = /(^|\/)plugin-entry\.runtime\.[cm]?[jt]s$/;
|
|
||||||
|
|
||||||
|
const REPO_ROOT = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
||||||
|
const RUNTIME_ENTRY_HELPER_RE = /(^|\/)plugin-entry\.runtime\.[cm]?[jt]s$/;
|
||||||
|
const SOURCE_MODULE_EXTENSIONS = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"] as const;
|
||||||
|
const FORBIDDEN_CONTRACT_MODULE_SPECIFIER_PATTERNS = [
|
||||||
|
/^vitest$/u,
|
||||||
|
/^openclaw\/plugin-sdk\/testing$/u,
|
||||||
|
/(^|\/)test-api(?:\.[cm]?[jt]s)?$/u,
|
||||||
|
/(^|\/)__tests__(\/|$)/u,
|
||||||
|
/(^|\/)test-support(\/|$)/u,
|
||||||
|
/(^|\/)[^/]*\.test(?:[-.][^/]*)?(?:\.[cm]?[jt]s)?$/u,
|
||||||
|
/(^|\/)[^/]*(?:test-harness|test-plugin|test-helper|test-support|harness)[^/]*(?:\.[cm]?[jt]s)?$/u,
|
||||||
|
] as const;
|
||||||
|
const FORBIDDEN_CONTRACT_MODULE_PATH_PATTERNS = [
|
||||||
|
/(^|\/)__tests__(\/|$)/u,
|
||||||
|
/(^|\/)test-support(\/|$)/u,
|
||||||
|
/(^|\/)test-api\.[cm]?[jt]s$/u,
|
||||||
|
/(^|\/)[^/]*\.test(?:[-.][^/]*)?\.[cm]?[jt]s$/u,
|
||||||
|
/(^|\/)[^/]*(?:test-harness|test-plugin|test-helper|test-support|harness)[^/]*\.[cm]?[jt]s$/u,
|
||||||
|
] as const;
|
||||||
function listBundledPluginRoots() {
|
function listBundledPluginRoots() {
|
||||||
return loadPluginManifestRegistry({})
|
return loadPluginManifestRegistry({})
|
||||||
.plugins.filter((plugin) => plugin.origin === "bundled")
|
.plugins.filter((plugin) => plugin.origin === "bundled")
|
||||||
@@ -16,6 +35,189 @@ function listBundledPluginRoots() {
|
|||||||
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId));
|
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePublicSurfaceSourcePath(
|
||||||
|
pluginDir: string,
|
||||||
|
artifactBasename: string,
|
||||||
|
): string | null {
|
||||||
|
const stem = artifactBasename.replace(/\.[^.]+$/u, "");
|
||||||
|
for (const extension of SOURCE_MODULE_EXTENSIONS) {
|
||||||
|
const candidate = resolve(pluginDir, `${stem}${extension}`);
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGuardedContractArtifactBasename(artifactBasename: string): boolean {
|
||||||
|
return (
|
||||||
|
artifactBasename === "channel-config-api.js" || artifactBasename.endsWith("contract-api.js")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectProductionContractEntryPaths(): Array<{
|
||||||
|
pluginId: string;
|
||||||
|
entryPath: string;
|
||||||
|
pluginRoot: string;
|
||||||
|
}> {
|
||||||
|
return listBundledPluginMetadata({ rootDir: REPO_ROOT }).flatMap((plugin) => {
|
||||||
|
const pluginRoot = resolve(REPO_ROOT, "extensions", plugin.dirName);
|
||||||
|
const entryPaths = new Set<string>();
|
||||||
|
for (const artifact of plugin.publicSurfaceArtifacts ?? []) {
|
||||||
|
if (!isGuardedContractArtifactBasename(artifact)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sourcePath = resolvePublicSurfaceSourcePath(pluginRoot, artifact);
|
||||||
|
if (sourcePath) {
|
||||||
|
entryPaths.add(sourcePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...entryPaths].map((entryPath) => ({
|
||||||
|
pluginId: plugin.manifest.id,
|
||||||
|
entryPath,
|
||||||
|
pluginRoot,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRepoRelativePath(filePath: string): string {
|
||||||
|
return relative(REPO_ROOT, filePath).replaceAll(path.sep, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyzeSourceModule(params: { filePath: string; source: string }): {
|
||||||
|
specifiers: string[];
|
||||||
|
relativeSpecifiers: string[];
|
||||||
|
importsDefinePluginEntryFromCore: boolean;
|
||||||
|
} {
|
||||||
|
const sourceFile = ts.createSourceFile(
|
||||||
|
params.filePath,
|
||||||
|
params.source,
|
||||||
|
ts.ScriptTarget.Latest,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const specifiers = new Set<string>();
|
||||||
|
let importsDefinePluginEntryFromCore = false;
|
||||||
|
|
||||||
|
for (const statement of sourceFile.statements) {
|
||||||
|
if (ts.isImportDeclaration(statement)) {
|
||||||
|
const specifier = ts.isStringLiteral(statement.moduleSpecifier)
|
||||||
|
? statement.moduleSpecifier.text
|
||||||
|
: undefined;
|
||||||
|
if (specifier) {
|
||||||
|
specifiers.add(specifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
specifier === "openclaw/plugin-sdk/core" &&
|
||||||
|
statement.importClause?.namedBindings &&
|
||||||
|
ts.isNamedImports(statement.importClause.namedBindings) &&
|
||||||
|
statement.importClause.namedBindings.elements.some(
|
||||||
|
(element) => (element.propertyName?.text ?? element.name.text) === "definePluginEntry",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
importsDefinePluginEntryFromCore = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ts.isExportDeclaration(statement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) {
|
||||||
|
specifiers.add(statement.moduleSpecifier.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSpecifiers = [...specifiers];
|
||||||
|
return {
|
||||||
|
specifiers: nextSpecifiers,
|
||||||
|
relativeSpecifiers: nextSpecifiers.filter((specifier) => specifier.startsWith(".")),
|
||||||
|
importsDefinePluginEntryFromCore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesForbiddenContractSpecifier(specifier: string): boolean {
|
||||||
|
return FORBIDDEN_CONTRACT_MODULE_SPECIFIER_PATTERNS.some((pattern) => pattern.test(specifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectForbiddenContractSpecifiers(specifiers: readonly string[]): string[] {
|
||||||
|
return specifiers.filter((specifier) => matchesForbiddenContractSpecifier(specifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRelativeSourceModulePath(fromPath: string, specifier: string): string | null {
|
||||||
|
const rawTargetPath = resolve(dirname(fromPath), specifier);
|
||||||
|
const candidates = new Set<string>();
|
||||||
|
const rawExtension = path.extname(rawTargetPath);
|
||||||
|
if (rawExtension) {
|
||||||
|
candidates.add(rawTargetPath);
|
||||||
|
const stem = rawTargetPath.slice(0, -rawExtension.length);
|
||||||
|
for (const extension of SOURCE_MODULE_EXTENSIONS) {
|
||||||
|
candidates.add(`${stem}${extension}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const extension of SOURCE_MODULE_EXTENSIONS) {
|
||||||
|
candidates.add(`${rawTargetPath}${extension}`);
|
||||||
|
candidates.add(resolve(rawTargetPath, `index${extension}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findForbiddenContractModuleGraphPaths(params: {
|
||||||
|
entryPath: string;
|
||||||
|
pluginRoot: string;
|
||||||
|
}): string[] {
|
||||||
|
const failures: string[] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const pending = [params.entryPath];
|
||||||
|
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const currentPath = pending.pop();
|
||||||
|
if (!currentPath || visited.has(currentPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visited.add(currentPath);
|
||||||
|
|
||||||
|
const repoRelativePath = formatRepoRelativePath(currentPath);
|
||||||
|
for (const pattern of FORBIDDEN_CONTRACT_MODULE_PATH_PATTERNS) {
|
||||||
|
if (pattern.test(repoRelativePath)) {
|
||||||
|
failures.push(`${repoRelativePath} matched ${pattern}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = readFileSync(currentPath, "utf8");
|
||||||
|
const analysis = analyzeSourceModule({ filePath: currentPath, source });
|
||||||
|
for (const specifier of collectForbiddenContractSpecifiers(analysis.specifiers)) {
|
||||||
|
failures.push(`${repoRelativePath} imported ${specifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const specifier of analysis.relativeSpecifiers) {
|
||||||
|
const resolvedModulePath = resolveRelativeSourceModulePath(currentPath, specifier);
|
||||||
|
if (!resolvedModulePath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (resolvedModulePath === currentPath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!resolvedModulePath.startsWith(params.pluginRoot + path.sep)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pending.push(resolvedModulePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
describe("plugin entry guardrails", () => {
|
describe("plugin entry guardrails", () => {
|
||||||
it("keeps bundled extension entry modules off direct definePluginEntry imports from core", () => {
|
it("keeps bundled extension entry modules off direct definePluginEntry imports from core", () => {
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
@@ -24,7 +226,7 @@ describe("plugin entry guardrails", () => {
|
|||||||
const indexPath = resolve(plugin.rootDir, "index.ts");
|
const indexPath = resolve(plugin.rootDir, "index.ts");
|
||||||
try {
|
try {
|
||||||
const source = readFileSync(indexPath, "utf8");
|
const source = readFileSync(indexPath, "utf8");
|
||||||
if (CORE_PLUGIN_ENTRY_IMPORT_RE.test(source)) {
|
if (analyzeSourceModule({ filePath: indexPath, source }).importsDefinePluginEntryFromCore) {
|
||||||
failures.push(`extensions/${plugin.pluginId}/index.ts`);
|
failures.push(`extensions/${plugin.pluginId}/index.ts`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -59,4 +261,62 @@ describe("plugin entry guardrails", () => {
|
|||||||
|
|
||||||
expect(failures).toEqual([]);
|
expect(failures).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps bundled production contract barrels off test-only imports and re-exports", () => {
|
||||||
|
const failures = collectProductionContractEntryPaths().flatMap(
|
||||||
|
({ pluginId, entryPath, pluginRoot }) =>
|
||||||
|
findForbiddenContractModuleGraphPaths({
|
||||||
|
entryPath,
|
||||||
|
pluginRoot,
|
||||||
|
}).map((failure) => `${pluginId}: ${failure}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(failures).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows relative import edges while scanning guarded contract graphs", () => {
|
||||||
|
expect(
|
||||||
|
analyzeSourceModule({
|
||||||
|
filePath: "guardrail-fixture.ts",
|
||||||
|
source: `
|
||||||
|
import { x } from "./safe.js";
|
||||||
|
import "./setup.js";
|
||||||
|
export { x };
|
||||||
|
export * from "./barrel.js";
|
||||||
|
import { y } from "openclaw/plugin-sdk/testing";
|
||||||
|
`,
|
||||||
|
}).relativeSpecifiers.toSorted(),
|
||||||
|
).toEqual(["./barrel.js", "./safe.js", "./setup.js"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards contract-style production artifacts beyond the legacy allowlist", () => {
|
||||||
|
expect(isGuardedContractArtifactBasename("channel-config-api.js")).toBe(true);
|
||||||
|
expect(isGuardedContractArtifactBasename("contract-api.js")).toBe(true);
|
||||||
|
expect(isGuardedContractArtifactBasename("doctor-contract-api.js")).toBe(true);
|
||||||
|
expect(isGuardedContractArtifactBasename("web-search-contract-api.js")).toBe(true);
|
||||||
|
expect(isGuardedContractArtifactBasename("test-api.js")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags test-support directory hops in guarded contract graphs", () => {
|
||||||
|
expect(collectForbiddenContractSpecifiers(["./test-support/index.js"])).toEqual([
|
||||||
|
"./test-support/index.js",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
FORBIDDEN_CONTRACT_MODULE_PATH_PATTERNS.some((pattern) =>
|
||||||
|
pattern.test("extensions/demo/src/test-support/index.ts"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects aliased definePluginEntry imports from core", () => {
|
||||||
|
expect(
|
||||||
|
analyzeSourceModule({
|
||||||
|
filePath: "aliased-plugin-entry.ts",
|
||||||
|
source: `
|
||||||
|
import { definePluginEntry as dpe } from "openclaw/plugin-sdk/core";
|
||||||
|
import { somethingElse } from "openclaw/plugin-sdk/core";
|
||||||
|
`,
|
||||||
|
}).importsDefinePluginEntryFromCore,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
32
src/plugins/public-surface-runtime.test.ts
Normal file
32
src/plugins/public-surface-runtime.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizeBundledPluginArtifactSubpath } from "./public-surface-runtime.js";
|
||||||
|
|
||||||
|
describe("bundled plugin public surface runtime", () => {
|
||||||
|
it("allows plugin-local nested artifact paths", () => {
|
||||||
|
expect(normalizeBundledPluginArtifactSubpath("src/outbound-adapter.js")).toBe(
|
||||||
|
"src/outbound-adapter.js",
|
||||||
|
);
|
||||||
|
expect(normalizeBundledPluginArtifactSubpath("./test-api.js")).toBe("test-api.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects artifact paths that escape the plugin root", () => {
|
||||||
|
expect(() => normalizeBundledPluginArtifactSubpath("../outside.js")).toThrow(
|
||||||
|
/must stay plugin-local/,
|
||||||
|
);
|
||||||
|
expect(() => normalizeBundledPluginArtifactSubpath("src/../outside.js")).toThrow(
|
||||||
|
/must stay plugin-local/,
|
||||||
|
);
|
||||||
|
expect(() => normalizeBundledPluginArtifactSubpath("/tmp/outside.js")).toThrow(
|
||||||
|
/must stay plugin-local/,
|
||||||
|
);
|
||||||
|
expect(() => normalizeBundledPluginArtifactSubpath("..\\outside.js")).toThrow(
|
||||||
|
/must stay plugin-local/,
|
||||||
|
);
|
||||||
|
expect(() => normalizeBundledPluginArtifactSubpath("C:outside.js")).toThrow(
|
||||||
|
/must stay plugin-local/,
|
||||||
|
);
|
||||||
|
expect(() => normalizeBundledPluginArtifactSubpath("src/C:outside.js")).toThrow(
|
||||||
|
/must stay plugin-local/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,33 @@ import { resolveBundledPluginsDir } from "./bundled-dir.js";
|
|||||||
|
|
||||||
const PUBLIC_SURFACE_SOURCE_EXTENSIONS = [".ts", ".mts", ".js", ".mjs", ".cts", ".cjs"] as const;
|
const PUBLIC_SURFACE_SOURCE_EXTENSIONS = [".ts", ".mts", ".js", ".mjs", ".cts", ".cjs"] as const;
|
||||||
|
|
||||||
|
export function normalizeBundledPluginArtifactSubpath(artifactBasename: string): string {
|
||||||
|
if (
|
||||||
|
path.posix.isAbsolute(artifactBasename) ||
|
||||||
|
path.win32.isAbsolute(artifactBasename) ||
|
||||||
|
artifactBasename.includes("\\")
|
||||||
|
) {
|
||||||
|
throw new Error(`Bundled plugin artifact path must stay plugin-local: ${artifactBasename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = artifactBasename.replace(/^\.\//u, "");
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("Bundled plugin artifact path must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = normalized.split("/");
|
||||||
|
if (
|
||||||
|
segments.some(
|
||||||
|
(segment) =>
|
||||||
|
segment.length === 0 || segment === "." || segment === ".." || segment.includes(":"),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(`Bundled plugin artifact path must stay plugin-local: ${artifactBasename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveBundledPluginPublicSurfacePath(params: {
|
export function resolveBundledPluginPublicSurfacePath(params: {
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
dirName: string;
|
dirName: string;
|
||||||
@@ -11,10 +38,7 @@ export function resolveBundledPluginPublicSurfacePath(params: {
|
|||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
bundledPluginsDir?: string;
|
bundledPluginsDir?: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const artifactBasename = params.artifactBasename.replace(/^\.\//u, "");
|
const artifactBasename = normalizeBundledPluginArtifactSubpath(params.artifactBasename);
|
||||||
if (!artifactBasename) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const explicitBundledPluginsDir =
|
const explicitBundledPluginsDir =
|
||||||
params.bundledPluginsDir ?? resolveBundledPluginsDir(params.env ?? process.env);
|
params.bundledPluginsDir ?? resolveBundledPluginsDir(params.env ?? process.env);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
findBundledPluginMetadataById,
|
findBundledPluginMetadataById,
|
||||||
type BundledPluginMetadata,
|
type BundledPluginMetadata,
|
||||||
} from "../plugins/bundled-plugin-metadata.js";
|
} from "../plugins/bundled-plugin-metadata.js";
|
||||||
|
import { normalizeBundledPluginArtifactSubpath } from "../plugins/public-surface-runtime.js";
|
||||||
import { resolveLoaderPackageRoot } from "../plugins/sdk-alias.js";
|
import { resolveLoaderPackageRoot } from "../plugins/sdk-alias.js";
|
||||||
|
|
||||||
const OPENCLAW_PACKAGE_ROOT =
|
const OPENCLAW_PACKAGE_ROOT =
|
||||||
@@ -28,7 +29,7 @@ export function loadBundledPluginPublicSurfaceSync<T extends object>(params: {
|
|||||||
const metadata = findBundledPluginMetadata(params.pluginId);
|
const metadata = findBundledPluginMetadata(params.pluginId);
|
||||||
return loadBundledPluginPublicSurfaceModuleSync<T>({
|
return loadBundledPluginPublicSurfaceModuleSync<T>({
|
||||||
dirName: metadata.dirName,
|
dirName: metadata.dirName,
|
||||||
artifactBasename: params.artifactBasename,
|
artifactBasename: normalizeBundledPluginArtifactSubpath(params.artifactBasename),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,11 +47,12 @@ export function resolveRelativeBundledPluginPublicModuleId(params: {
|
|||||||
}): string {
|
}): string {
|
||||||
const metadata = findBundledPluginMetadata(params.pluginId);
|
const metadata = findBundledPluginMetadata(params.pluginId);
|
||||||
const fromFilePath = fileURLToPath(params.fromModuleUrl);
|
const fromFilePath = fileURLToPath(params.fromModuleUrl);
|
||||||
|
const artifactBasename = normalizeBundledPluginArtifactSubpath(params.artifactBasename);
|
||||||
const targetPath = path.resolve(
|
const targetPath = path.resolve(
|
||||||
OPENCLAW_PACKAGE_ROOT,
|
OPENCLAW_PACKAGE_ROOT,
|
||||||
"extensions",
|
"extensions",
|
||||||
metadata.dirName,
|
metadata.dirName,
|
||||||
params.artifactBasename,
|
artifactBasename,
|
||||||
);
|
);
|
||||||
const relativePath = path
|
const relativePath = path
|
||||||
.relative(path.dirname(fromFilePath), targetPath)
|
.relative(path.dirname(fromFilePath), targetPath)
|
||||||
|
|||||||
@@ -1 +1,19 @@
|
|||||||
export { createIMessageTestPlugin } from "../../../extensions/imessage/contract-api.js";
|
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||||
|
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||||
|
import { loadBundledPluginTestApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||||
|
|
||||||
|
type CreateIMessageTestPlugin = (params?: { outbound?: ChannelOutboundAdapter }) => ChannelPlugin;
|
||||||
|
|
||||||
|
let createIMessageTestPluginCache: CreateIMessageTestPlugin | undefined;
|
||||||
|
|
||||||
|
function getCreateIMessageTestPlugin(): CreateIMessageTestPlugin {
|
||||||
|
if (!createIMessageTestPluginCache) {
|
||||||
|
({ createIMessageTestPlugin: createIMessageTestPluginCache } = loadBundledPluginTestApiSync<{
|
||||||
|
createIMessageTestPlugin: CreateIMessageTestPlugin;
|
||||||
|
}>("imessage"));
|
||||||
|
}
|
||||||
|
return createIMessageTestPluginCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createIMessageTestPlugin: CreateIMessageTestPlugin = (...args) =>
|
||||||
|
getCreateIMessageTestPlugin()(...args);
|
||||||
|
|||||||
@@ -1,30 +1,77 @@
|
|||||||
import { beforeEach, expect, it, type Mock, vi } from "vitest";
|
import { beforeEach, expect, it, type Mock, vi } from "vitest";
|
||||||
import { createSlackOutboundPayloadHarness } from "../../../extensions/slack/contract-api.js";
|
|
||||||
import { whatsappOutbound } from "../../../extensions/whatsapp/test-api.js";
|
|
||||||
import {
|
|
||||||
chunkTextForOutbound as chunkZaloTextForOutbound,
|
|
||||||
sendPayloadWithChunkedTextAndMedia as sendZaloPayloadWithChunkedTextAndMedia,
|
|
||||||
} from "../../../extensions/zalo/runtime-api.js";
|
|
||||||
import { sendPayloadWithChunkedTextAndMedia as sendZalouserPayloadWithChunkedTextAndMedia } from "../../../extensions/zalouser/runtime-api.js";
|
|
||||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||||
import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/test-helpers.js";
|
import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/test-helpers.js";
|
||||||
import { createDirectTextMediaOutbound } from "../../../src/channels/plugins/outbound/direct-text-media.js";
|
import { createDirectTextMediaOutbound } from "../../../src/channels/plugins/outbound/direct-text-media.js";
|
||||||
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
|
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
|
||||||
import { resetGlobalHookRunner } from "../../../src/plugins/hook-runner-global.js";
|
import { resetGlobalHookRunner } from "../../../src/plugins/hook-runner-global.js";
|
||||||
import {
|
import {
|
||||||
|
loadBundledPluginPublicSurfaceSync,
|
||||||
loadBundledPluginTestApiSync,
|
loadBundledPluginTestApiSync,
|
||||||
resolveRelativeBundledPluginPublicModuleId,
|
resolveRelativeBundledPluginPublicModuleId,
|
||||||
} from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
} from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||||
type ParseZalouserOutboundTarget = (raw: string) => { threadId: string; isGroup: boolean };
|
type ParseZalouserOutboundTarget = (raw: string) => { threadId: string; isGroup: boolean };
|
||||||
|
type CreateSlackOutboundPayloadHarness = (params: PayloadHarnessParams) => {
|
||||||
|
run: () => Promise<Record<string, unknown>>;
|
||||||
|
sendMock: Mock;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
type ChunkZaloTextForOutbound = (text: string, maxLength?: number) => string[];
|
||||||
|
type SendPayloadWithChunkedTextAndMedia = (params: {
|
||||||
|
ctx: {
|
||||||
|
cfg: unknown;
|
||||||
|
to: string;
|
||||||
|
text: string;
|
||||||
|
payload: ReplyPayload;
|
||||||
|
};
|
||||||
|
sendText: (ctx: {
|
||||||
|
cfg: unknown;
|
||||||
|
to: string;
|
||||||
|
text: string;
|
||||||
|
payload: ReplyPayload;
|
||||||
|
}) => Promise<{ channel: string; messageId: string }>;
|
||||||
|
sendMedia: (ctx: {
|
||||||
|
cfg: unknown;
|
||||||
|
to: string;
|
||||||
|
text: string;
|
||||||
|
payload: ReplyPayload;
|
||||||
|
mediaUrl?: string;
|
||||||
|
}) => Promise<{ channel: string; messageId: string }>;
|
||||||
|
emptyResult: { channel: string; messageId: string };
|
||||||
|
textChunkLimit?: number;
|
||||||
|
chunker?: ChunkZaloTextForOutbound | null;
|
||||||
|
}) => Promise<{ channel: string; messageId: string }>;
|
||||||
|
|
||||||
const discordOutboundAdapterModuleId = resolveRelativeBundledPluginPublicModuleId({
|
const discordOutboundAdapterModuleId = resolveRelativeBundledPluginPublicModuleId({
|
||||||
fromModuleUrl: import.meta.url,
|
fromModuleUrl: import.meta.url,
|
||||||
pluginId: "discord",
|
pluginId: "discord",
|
||||||
artifactBasename: "src/outbound-adapter.js",
|
artifactBasename: "src/outbound-adapter.js",
|
||||||
});
|
});
|
||||||
|
const slackTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({
|
||||||
|
fromModuleUrl: import.meta.url,
|
||||||
|
pluginId: "slack",
|
||||||
|
artifactBasename: "test-api.js",
|
||||||
|
});
|
||||||
|
const whatsappTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({
|
||||||
|
fromModuleUrl: import.meta.url,
|
||||||
|
pluginId: "whatsapp",
|
||||||
|
artifactBasename: "test-api.js",
|
||||||
|
});
|
||||||
|
|
||||||
let discordOutboundCache: Promise<ChannelOutboundAdapter> | undefined;
|
let discordOutboundCache: Promise<ChannelOutboundAdapter> | undefined;
|
||||||
let parseZalouserOutboundTargetCache: ParseZalouserOutboundTarget | undefined;
|
let parseZalouserOutboundTargetCache: ParseZalouserOutboundTarget | undefined;
|
||||||
|
let slackTestApiPromise:
|
||||||
|
| Promise<{
|
||||||
|
createSlackOutboundPayloadHarness: CreateSlackOutboundPayloadHarness;
|
||||||
|
}>
|
||||||
|
| undefined;
|
||||||
|
let whatsappTestApiPromise:
|
||||||
|
| Promise<{
|
||||||
|
whatsappOutbound: ChannelOutboundAdapter;
|
||||||
|
}>
|
||||||
|
| undefined;
|
||||||
|
let chunkZaloTextForOutboundCache: ChunkZaloTextForOutbound | undefined;
|
||||||
|
let sendZaloPayloadWithChunkedTextAndMediaCache: SendPayloadWithChunkedTextAndMedia | undefined;
|
||||||
|
let sendZalouserPayloadWithChunkedTextAndMediaCache: SendPayloadWithChunkedTextAndMedia | undefined;
|
||||||
|
|
||||||
async function getDiscordOutbound(): Promise<ChannelOutboundAdapter> {
|
async function getDiscordOutbound(): Promise<ChannelOutboundAdapter> {
|
||||||
discordOutboundCache ??= (async () => {
|
discordOutboundCache ??= (async () => {
|
||||||
@@ -36,6 +83,47 @@ async function getDiscordOutbound(): Promise<ChannelOutboundAdapter> {
|
|||||||
return await discordOutboundCache;
|
return await discordOutboundCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getCreateSlackOutboundPayloadHarness(): Promise<CreateSlackOutboundPayloadHarness> {
|
||||||
|
slackTestApiPromise ??= import(slackTestApiModuleId) as Promise<{
|
||||||
|
createSlackOutboundPayloadHarness: CreateSlackOutboundPayloadHarness;
|
||||||
|
}>;
|
||||||
|
const { createSlackOutboundPayloadHarness } = await slackTestApiPromise;
|
||||||
|
return createSlackOutboundPayloadHarness;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWhatsAppOutboundAsync(): Promise<ChannelOutboundAdapter> {
|
||||||
|
whatsappTestApiPromise ??= import(whatsappTestApiModuleId) as Promise<{
|
||||||
|
whatsappOutbound: ChannelOutboundAdapter;
|
||||||
|
}>;
|
||||||
|
const { whatsappOutbound } = await whatsappTestApiPromise;
|
||||||
|
return whatsappOutbound;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChunkZaloTextForOutbound(): ChunkZaloTextForOutbound {
|
||||||
|
if (!chunkZaloTextForOutboundCache) {
|
||||||
|
({ chunkTextForOutbound: chunkZaloTextForOutboundCache } = loadBundledPluginPublicSurfaceSync<{
|
||||||
|
chunkTextForOutbound: ChunkZaloTextForOutbound;
|
||||||
|
}>({
|
||||||
|
pluginId: "zalo",
|
||||||
|
artifactBasename: "runtime-api.js",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return chunkZaloTextForOutboundCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSendZaloPayloadWithChunkedTextAndMedia(): SendPayloadWithChunkedTextAndMedia {
|
||||||
|
if (!sendZaloPayloadWithChunkedTextAndMediaCache) {
|
||||||
|
({ sendPayloadWithChunkedTextAndMedia: sendZaloPayloadWithChunkedTextAndMediaCache } =
|
||||||
|
loadBundledPluginPublicSurfaceSync<{
|
||||||
|
sendPayloadWithChunkedTextAndMedia: SendPayloadWithChunkedTextAndMedia;
|
||||||
|
}>({
|
||||||
|
pluginId: "zalo",
|
||||||
|
artifactBasename: "runtime-api.js",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return sendZaloPayloadWithChunkedTextAndMediaCache;
|
||||||
|
}
|
||||||
|
|
||||||
function getParseZalouserOutboundTarget(): ParseZalouserOutboundTarget {
|
function getParseZalouserOutboundTarget(): ParseZalouserOutboundTarget {
|
||||||
if (!parseZalouserOutboundTargetCache) {
|
if (!parseZalouserOutboundTargetCache) {
|
||||||
({ parseZalouserOutboundTarget: parseZalouserOutboundTargetCache } =
|
({ parseZalouserOutboundTarget: parseZalouserOutboundTargetCache } =
|
||||||
@@ -46,6 +134,19 @@ function getParseZalouserOutboundTarget(): ParseZalouserOutboundTarget {
|
|||||||
return parseZalouserOutboundTargetCache;
|
return parseZalouserOutboundTargetCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSendZalouserPayloadWithChunkedTextAndMedia(): SendPayloadWithChunkedTextAndMedia {
|
||||||
|
if (!sendZalouserPayloadWithChunkedTextAndMediaCache) {
|
||||||
|
({ sendPayloadWithChunkedTextAndMedia: sendZalouserPayloadWithChunkedTextAndMediaCache } =
|
||||||
|
loadBundledPluginPublicSurfaceSync<{
|
||||||
|
sendPayloadWithChunkedTextAndMedia: SendPayloadWithChunkedTextAndMedia;
|
||||||
|
}>({
|
||||||
|
pluginId: "zalouser",
|
||||||
|
artifactBasename: "runtime-api.js",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return sendZalouserPayloadWithChunkedTextAndMediaCache;
|
||||||
|
}
|
||||||
|
|
||||||
type PayloadHarnessParams = {
|
type PayloadHarnessParams = {
|
||||||
payload: ReplyPayload;
|
payload: ReplyPayload;
|
||||||
sendResults?: Array<{ messageId: string }>;
|
sendResults?: Array<{ messageId: string }>;
|
||||||
@@ -76,18 +177,24 @@ type ChunkingMode =
|
|||||||
function installChannelOutboundPayloadContractSuite(params: {
|
function installChannelOutboundPayloadContractSuite(params: {
|
||||||
channel: string;
|
channel: string;
|
||||||
chunking: ChunkingMode;
|
chunking: ChunkingMode;
|
||||||
createHarness: (params: { payload: PayloadLike; sendResults?: SendResultLike[] }) => {
|
createHarness: (params: { payload: PayloadLike; sendResults?: SendResultLike[] }) =>
|
||||||
run: () => Promise<Record<string, unknown>>;
|
| {
|
||||||
sendMock: Mock;
|
run: () => Promise<Record<string, unknown>>;
|
||||||
to: string;
|
sendMock: Mock;
|
||||||
};
|
to: string;
|
||||||
|
}
|
||||||
|
| Promise<{
|
||||||
|
run: () => Promise<Record<string, unknown>>;
|
||||||
|
sendMock: Mock;
|
||||||
|
to: string;
|
||||||
|
}>;
|
||||||
}) {
|
}) {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetGlobalHookRunner();
|
resetGlobalHookRunner();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("text-only delegates to sendText", async () => {
|
it("text-only delegates to sendText", async () => {
|
||||||
const { run, sendMock, to } = params.createHarness({
|
const { run, sendMock, to } = await params.createHarness({
|
||||||
payload: { text: "hello" },
|
payload: { text: "hello" },
|
||||||
});
|
});
|
||||||
const result = await run();
|
const result = await run();
|
||||||
@@ -98,7 +205,7 @@ function installChannelOutboundPayloadContractSuite(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("single media delegates to sendMedia", async () => {
|
it("single media delegates to sendMedia", async () => {
|
||||||
const { run, sendMock, to } = params.createHarness({
|
const { run, sendMock, to } = await params.createHarness({
|
||||||
payload: { text: "cap", mediaUrl: "https://example.com/a.jpg" },
|
payload: { text: "cap", mediaUrl: "https://example.com/a.jpg" },
|
||||||
});
|
});
|
||||||
const result = await run();
|
const result = await run();
|
||||||
@@ -113,7 +220,7 @@ function installChannelOutboundPayloadContractSuite(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("multi-media iterates URLs with caption on first", async () => {
|
it("multi-media iterates URLs with caption on first", async () => {
|
||||||
const { run, sendMock, to } = params.createHarness({
|
const { run, sendMock, to } = await params.createHarness({
|
||||||
payload: {
|
payload: {
|
||||||
text: "caption",
|
text: "caption",
|
||||||
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
||||||
@@ -139,7 +246,7 @@ function installChannelOutboundPayloadContractSuite(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("empty payload returns no-op", async () => {
|
it("empty payload returns no-op", async () => {
|
||||||
const { run, sendMock } = params.createHarness({ payload: {} });
|
const { run, sendMock } = await params.createHarness({ payload: {} });
|
||||||
const result = await run();
|
const result = await run();
|
||||||
|
|
||||||
expect(sendMock).not.toHaveBeenCalled();
|
expect(sendMock).not.toHaveBeenCalled();
|
||||||
@@ -149,7 +256,7 @@ function installChannelOutboundPayloadContractSuite(params: {
|
|||||||
if (params.chunking.mode === "passthrough") {
|
if (params.chunking.mode === "passthrough") {
|
||||||
it("text exceeding chunk limit is sent as-is when chunker is null", async () => {
|
it("text exceeding chunk limit is sent as-is when chunker is null", async () => {
|
||||||
const text = "a".repeat(params.chunking.longTextLength);
|
const text = "a".repeat(params.chunking.longTextLength);
|
||||||
const { run, sendMock, to } = params.createHarness({ payload: { text } });
|
const { run, sendMock, to } = await params.createHarness({ payload: { text } });
|
||||||
const result = await run();
|
const result = await run();
|
||||||
|
|
||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
@@ -163,7 +270,7 @@ function installChannelOutboundPayloadContractSuite(params: {
|
|||||||
|
|
||||||
it("chunking splits long text", async () => {
|
it("chunking splits long text", async () => {
|
||||||
const text = "a".repeat(chunking.longTextLength);
|
const text = "a".repeat(chunking.longTextLength);
|
||||||
const { run, sendMock } = params.createHarness({
|
const { run, sendMock } = await params.createHarness({
|
||||||
payload: { text },
|
payload: { text },
|
||||||
sendResults: [{ messageId: "c-1" }, { messageId: "c-2" }],
|
sendResults: [{ messageId: "c-1" }, { messageId: "c-2" }],
|
||||||
});
|
});
|
||||||
@@ -220,7 +327,7 @@ function createWhatsAppHarness(params: PayloadHarnessParams) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
run: async () => await whatsappOutbound.sendPayload!(ctx),
|
run: async () => await (await getWhatsAppOutboundAsync()).sendPayload!(ctx),
|
||||||
sendMock: sendWhatsApp,
|
sendMock: sendWhatsApp,
|
||||||
to: ctx.to,
|
to: ctx.to,
|
||||||
};
|
};
|
||||||
@@ -260,10 +367,10 @@ function createZaloHarness(params: PayloadHarnessParams) {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
run: async () =>
|
run: async () =>
|
||||||
await sendZaloPayloadWithChunkedTextAndMedia({
|
await getSendZaloPayloadWithChunkedTextAndMedia()({
|
||||||
ctx,
|
ctx,
|
||||||
textChunkLimit: 2000,
|
textChunkLimit: 2000,
|
||||||
chunker: chunkZaloTextForOutbound,
|
chunker: getChunkZaloTextForOutbound(),
|
||||||
sendText: async (nextCtx) =>
|
sendText: async (nextCtx) =>
|
||||||
buildChannelSendResult(
|
buildChannelSendResult(
|
||||||
"zalo",
|
"zalo",
|
||||||
@@ -299,7 +406,7 @@ function createZalouserHarness(params: PayloadHarnessParams) {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
run: async () =>
|
run: async () =>
|
||||||
await sendZalouserPayloadWithChunkedTextAndMedia({
|
await getSendZalouserPayloadWithChunkedTextAndMedia()({
|
||||||
ctx,
|
ctx,
|
||||||
sendText: async (nextCtx) => {
|
sendText: async (nextCtx) => {
|
||||||
const target = getParseZalouserOutboundTarget()(nextCtx.to);
|
const target = getParseZalouserOutboundTarget()(nextCtx.to);
|
||||||
@@ -339,7 +446,7 @@ export function installSlackOutboundPayloadContractSuite() {
|
|||||||
installChannelOutboundPayloadContractSuite({
|
installChannelOutboundPayloadContractSuite({
|
||||||
channel: "slack",
|
channel: "slack",
|
||||||
chunking: { mode: "passthrough", longTextLength: 5000 },
|
chunking: { mode: "passthrough", longTextLength: 5000 },
|
||||||
createHarness: createSlackOutboundPayloadHarness,
|
createHarness: async (params) => (await getCreateSlackOutboundPayloadHarness())(params),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user