mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
test(qa): cover plugin authoring contracts (#118821)
* test(qa): cover plugin SDK entrypoint discovery * test(qa): cover plugin manifest authoring * test(qa): cover plugin package metadata
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
title: Plugin manifest authoring contracts
|
||||
|
||||
scenario:
|
||||
id: plugin-manifest-metadata-scan
|
||||
surface: plugins
|
||||
category: plugins.authoring-and-packaging-plugins
|
||||
coverage:
|
||||
primary:
|
||||
- plugins.plugin-manifest
|
||||
- plugins.validation-feedback
|
||||
objective: Verify native plugin manifests expose identity, capabilities, and config schema before runtime loading and reject incomplete author metadata.
|
||||
successCriteria:
|
||||
- Metadata scanning preserves plugin identity, channel and provider capabilities, tool ownership, and config schema.
|
||||
- A manifest without an id fails with the canonical identity error.
|
||||
- A manifest without configSchema fails with the canonical schema error.
|
||||
docsRefs:
|
||||
- docs/plugins/building-plugins.md
|
||||
- docs/plugins/manifest.md
|
||||
codeRefs:
|
||||
- src/plugins/manifest.ts
|
||||
- src/plugins/manifest-metadata-scan.ts
|
||||
- src/plugins/manifest-metadata-scan.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: src/plugins/manifest-metadata-scan.test.ts
|
||||
summary: Run native manifest metadata discovery and fail-fast authoring validation contracts.
|
||||
@@ -0,0 +1,28 @@
|
||||
title: Plugin package metadata contracts
|
||||
|
||||
scenario:
|
||||
id: plugin-package-metadata-contracts
|
||||
surface: plugins
|
||||
category: plugins.authoring-and-packaging-plugins
|
||||
coverage:
|
||||
primary:
|
||||
- plugins.package-metadata
|
||||
- plugins.validation-feedback
|
||||
objective: Verify package.json exposes plugin discovery and release metadata and rejects malformed or inconsistent entrypoint declarations.
|
||||
successCriteria:
|
||||
- The openclaw package block exposes source, runtime, setup, compatibility, and install metadata.
|
||||
- Malformed openclaw and extensions fields fail with actionable field-specific errors.
|
||||
- Runtime extension counts must match source extension counts.
|
||||
- runtimeSetupEntry requires a matching setupEntry.
|
||||
docsRefs:
|
||||
- docs/plugins/building-plugins.md
|
||||
- docs/plugins/manifest.md
|
||||
- docs/plugins/reference.md
|
||||
codeRefs:
|
||||
- src/plugins/package-manifest.ts
|
||||
- src/plugins/package-entry-resolution.ts
|
||||
- src/plugins/contracts/package-manifest.contract.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: src/plugins/contracts/package-manifest.contract.test.ts
|
||||
summary: Run plugin package metadata discovery and fail-fast entrypoint validation contracts.
|
||||
@@ -0,0 +1,28 @@
|
||||
title: Plugin public entrypoint contracts
|
||||
|
||||
scenario:
|
||||
id: plugin-public-entrypoint-contracts
|
||||
surface: plugins
|
||||
category: plugins.authoring-and-packaging-plugins
|
||||
coverage:
|
||||
primary:
|
||||
- plugins.entrypoint-discovery
|
||||
objective: Verify plugin authors can discover public SDK entrypoints and support status from the maintained catalog and documentation.
|
||||
successCriteria:
|
||||
- Every public SDK catalog entry has a published package export.
|
||||
- Private-local SDK entries stay out of published package exports.
|
||||
- Deprecated entries remain public while their support status is maintained separately.
|
||||
- Documented SDK subpaths resolve to maintained catalog entries.
|
||||
docsRefs:
|
||||
- docs/plugins/sdk-entrypoints.md
|
||||
- docs/plugins/sdk-subpaths.md
|
||||
codeRefs:
|
||||
- scripts/lib/plugin-sdk-entrypoints.json
|
||||
- scripts/lib/plugin-sdk-private-local-only-subpaths.json
|
||||
- scripts/lib/plugin-sdk-deprecated-public-subpaths.json
|
||||
- src/plugin-sdk/entrypoints.ts
|
||||
- src/plugins/contracts/plugin-sdk-subpaths.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: src/plugins/contracts/plugin-sdk-subpaths.test.ts
|
||||
summary: Run the public SDK catalog, package export, and documentation classification contract.
|
||||
@@ -1,6 +1,13 @@
|
||||
// Package manifest contract tests cover plugin package manifest requirements.
|
||||
import { describePackageManifestContract } from "openclaw/plugin-sdk/plugin-test-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validatePackageExtensionEntriesForInstall } from "../package-entry-resolution.js";
|
||||
import {
|
||||
getPackageManifestMetadata,
|
||||
resolvePackageExtensionEntries,
|
||||
type PackageManifest,
|
||||
} from "../package-manifest.js";
|
||||
|
||||
// Package manifest contract tests cover plugin package manifest requirements.
|
||||
type PackageManifestContractParams = Parameters<typeof describePackageManifestContract>[0];
|
||||
|
||||
const packageManifestContractTests: PackageManifestContractParams[] = [
|
||||
@@ -87,3 +94,97 @@ const packageManifestContractTests: PackageManifestContractParams[] = [
|
||||
for (const params of packageManifestContractTests) {
|
||||
describePackageManifestContract(params);
|
||||
}
|
||||
|
||||
describe("plugin package authoring metadata", () => {
|
||||
it("exposes the declared discovery and release entrypoints", () => {
|
||||
const manifest: PackageManifest = {
|
||||
name: "@openclaw/example",
|
||||
version: "1.2.3",
|
||||
openclaw: {
|
||||
extensions: ["./src/index.ts"],
|
||||
runtimeExtensions: ["./dist/index.js"],
|
||||
setupEntry: "./src/setup.ts",
|
||||
runtimeSetupEntry: "./dist/setup.js",
|
||||
plugin: {
|
||||
id: "example",
|
||||
label: "Example",
|
||||
},
|
||||
compat: {
|
||||
pluginApi: ">=1",
|
||||
minGatewayVersion: "2026.8.1",
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@openclaw/example",
|
||||
minHostVersion: "2026.8.1",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(getPackageManifestMetadata(manifest)).toEqual(manifest.openclaw);
|
||||
expect(resolvePackageExtensionEntries(manifest)).toEqual({
|
||||
status: "ok",
|
||||
entries: ["./src/index.ts"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "non-object openclaw metadata",
|
||||
manifest: { openclaw: "invalid" } as unknown as PackageManifest,
|
||||
error: "package.json openclaw must be an object",
|
||||
},
|
||||
{
|
||||
name: "non-array extension metadata",
|
||||
manifest: { openclaw: { extensions: "./index.js" } } as unknown as PackageManifest,
|
||||
error: "package.json openclaw.extensions must be an array",
|
||||
},
|
||||
{
|
||||
name: "blank extension metadata",
|
||||
manifest: { openclaw: { extensions: [" "] } } as PackageManifest,
|
||||
error: "package.json openclaw.extensions[0] must be a non-empty string",
|
||||
},
|
||||
])("fails fast on $name", ({ manifest, error }) => {
|
||||
expect(resolvePackageExtensionEntries(manifest)).toEqual({
|
||||
status: "invalid",
|
||||
entries: [],
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects inconsistent source and runtime extension metadata", async () => {
|
||||
const result = await validatePackageExtensionEntriesForInstall({
|
||||
packageDir: process.cwd(),
|
||||
extensions: ["./src/one.ts", "./src/two.ts"],
|
||||
manifest: {
|
||||
openclaw: {
|
||||
extensions: ["./src/one.ts", "./src/two.ts"],
|
||||
runtimeExtensions: ["./dist/one.js"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error:
|
||||
"package.json openclaw.runtimeExtensions length (1) must match openclaw.extensions length (2)",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a runtime setup entry without a source setup entry", async () => {
|
||||
const result = await validatePackageExtensionEntriesForInstall({
|
||||
packageDir: process.cwd(),
|
||||
extensions: [],
|
||||
manifest: {
|
||||
openclaw: {
|
||||
extensions: [],
|
||||
runtimeSetupEntry: "./dist/setup.js",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: "package.json openclaw.runtimeSetupEntry requires openclaw.setupEntry",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,13 @@ import type {
|
||||
} from "../../plugin-sdk/channel-plugin-common.js";
|
||||
import * as channelReplyPipelineDirectSdk from "../../plugin-sdk/channel-reply-pipeline.js";
|
||||
import * as coreDirectSdk from "../../plugin-sdk/core.js";
|
||||
import { publicPluginSdkSubpaths as pluginSdkSubpaths } from "../../plugin-sdk/entrypoints.js";
|
||||
import {
|
||||
buildPluginSdkPackageExports,
|
||||
deprecatedPublicPluginSdkEntrypoints,
|
||||
pluginSdkEntrypoints,
|
||||
privateLocalOnlyPluginSdkEntrypoints,
|
||||
publicPluginSdkSubpaths as pluginSdkSubpaths,
|
||||
} from "../../plugin-sdk/entrypoints.js";
|
||||
import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js";
|
||||
import { listGitTrackedFiles, toRepoRelativePath } from "../../test-utils/repo-files.js";
|
||||
import type { PluginRuntime } from "../runtime/types.js";
|
||||
@@ -500,6 +506,32 @@ describe("plugin-sdk subpath exports", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the public entrypoint catalog, package exports, and support-status docs aligned", () => {
|
||||
const docs = readFileSync(resolve(REPO_ROOT, "docs/plugins/sdk-subpaths.md"), "utf8");
|
||||
const packageExports = buildPluginSdkPackageExports();
|
||||
const documentedSubpaths = new Set(
|
||||
[...docs.matchAll(/`plugin-sdk\/([a-z0-9-]+)`/gu)].map((match) => match[1]),
|
||||
);
|
||||
|
||||
expect(docs).toContain("scripts/lib/plugin-sdk-entrypoints.json");
|
||||
expect(docs).toContain("scripts/lib/plugin-sdk-private-local-only-subpaths.json");
|
||||
expect(docs).toContain("scripts/lib/plugin-sdk-deprecated-public-subpaths.json");
|
||||
expect(docs).toContain("private-local entries explicitly");
|
||||
|
||||
for (const subpath of pluginSdkSubpaths) {
|
||||
expect(packageExports).toHaveProperty(`./plugin-sdk/${subpath}`);
|
||||
}
|
||||
for (const subpath of privateLocalOnlyPluginSdkEntrypoints) {
|
||||
expect(packageExports).not.toHaveProperty(`./plugin-sdk/${subpath}`);
|
||||
}
|
||||
for (const subpath of deprecatedPublicPluginSdkEntrypoints) {
|
||||
expect(pluginSdkSubpaths).toContain(subpath);
|
||||
}
|
||||
for (const subpath of documentedSubpaths) {
|
||||
expect(pluginSdkEntrypoints).toContain(subpath);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the curated public list free of internal implementation subpaths", () => {
|
||||
for (const deniedSubpath of [
|
||||
"acpx",
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { writePersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js";
|
||||
import { listOpenClawPluginManifestMetadata } from "./manifest-metadata-scan.js";
|
||||
import { loadPluginManifest } from "./manifest.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
@@ -126,6 +127,60 @@ describe("listOpenClawPluginManifestMetadata", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves identity, capabilities, and config schema without loading plugin runtime", () => {
|
||||
const root = createTempRoot();
|
||||
const home = path.join(root, "home");
|
||||
const pluginDir = path.join(home, ".openclaw", "extensions", "authoring-contract");
|
||||
const manifest = {
|
||||
id: "authoring-contract",
|
||||
name: "Authoring contract",
|
||||
channels: ["authoring-channel"],
|
||||
providers: ["authoring-provider"],
|
||||
contracts: {
|
||||
tools: ["authoring_lookup"],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
endpoint: { type: "string" },
|
||||
},
|
||||
},
|
||||
};
|
||||
writeJson(path.join(pluginDir, "openclaw.plugin.json"), manifest);
|
||||
|
||||
const records = listOpenClawPluginManifestMetadata({
|
||||
OPENCLAW_HOME: home,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(root, "empty-bundled"),
|
||||
});
|
||||
|
||||
expect(records).toContainEqual({
|
||||
pluginDir,
|
||||
manifest,
|
||||
origin: "global",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "missing identity",
|
||||
manifest: { configSchema: { type: "object" } },
|
||||
error: "plugin manifest requires id",
|
||||
},
|
||||
{
|
||||
name: "missing config schema",
|
||||
manifest: { id: "missing-schema" },
|
||||
error: "plugin manifest requires configSchema",
|
||||
},
|
||||
])("fails fast on $name", ({ manifest, error }) => {
|
||||
const pluginDir = createTempRoot();
|
||||
writeJson(path.join(pluginDir, "openclaw.plugin.json"), manifest);
|
||||
|
||||
const result = loadPluginManifest(pluginDir, false);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error });
|
||||
});
|
||||
|
||||
it("skips oversized plugin manifests to prevent OOM during metadata scan", () => {
|
||||
const root = createTempRoot();
|
||||
const home = path.join(root, "home");
|
||||
|
||||
Reference in New Issue
Block a user