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:
Vincent Koc
2026-08-04 02:31:14 +08:00
committed by GitHub
parent 819c241406
commit 3c678acc17
6 changed files with 272 additions and 2 deletions
@@ -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");