@h-rig/plugin-testkit 0.0.6-alpha.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/src/behavior.d.ts +32 -0
- package/dist/src/behavior.js +81 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.js +153 -0
- package/package.json +29 -0
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# @h-rig/plugin-testkit
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavior-level test helpers — invoke a plugin's executable channels
|
|
3
|
+
* (validators, task-source factories, typed hooks) with mock inputs and
|
|
4
|
+
* assert the results have the contract shape, modeling the assertions in
|
|
5
|
+
* @rig/core's plugin-host.test.ts.
|
|
6
|
+
*
|
|
7
|
+
* Unlike `pluginContractTests` these are runner-agnostic: they throw plain
|
|
8
|
+
* Errors instead of using vitest assertions, so they work the same under
|
|
9
|
+
* vitest and `bun test`.
|
|
10
|
+
*/
|
|
11
|
+
import type { HookContext, HookResult, RegisteredTaskSource, TaskSourceConfig } from "@rig/contracts";
|
|
12
|
+
import type { RigPluginWithRuntime, ValidatorContext, ValidatorResult } from "@rig/core";
|
|
13
|
+
/**
|
|
14
|
+
* Invoke the plugin's executable validator `id` with a mock
|
|
15
|
+
* ValidatorContext (missing fields filled with testkit defaults), assert
|
|
16
|
+
* the result shape (id/passed/summary present, id matches), and return it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function testValidatorContext(plugin: RigPluginWithRuntime, id: string, mockCtx?: Partial<ValidatorContext>): Promise<ValidatorResult>;
|
|
19
|
+
/**
|
|
20
|
+
* Assert the plugin ships an executable task-source factory for `kind`,
|
|
21
|
+
* instantiate it (with `config` or a minimal `{ kind }`), assert the
|
|
22
|
+
* returned RegisteredTaskSource carries the required surface (kind, id,
|
|
23
|
+
* list()), and return it for further assertions.
|
|
24
|
+
*/
|
|
25
|
+
export declare function expectTaskSourceRuntime(plugin: RigPluginWithRuntime, kind: string, config?: TaskSourceConfig): RegisteredTaskSource;
|
|
26
|
+
/**
|
|
27
|
+
* Invoke the plugin's typed hook implementation `id` with a mock
|
|
28
|
+
* HookContext (missing fields filled with testkit defaults; the event
|
|
29
|
+
* defaults to the hook's metadata registration), assert the HookResult
|
|
30
|
+
* shape, and return it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function testHook(plugin: RigPluginWithRuntime, id: string, mockInput?: Partial<HookContext>): Promise<HookResult>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/plugin-testkit/src/behavior.ts
|
|
3
|
+
function fail(plugin, message) {
|
|
4
|
+
throw new Error(`plugin "${plugin.name}": ${message}`);
|
|
5
|
+
}
|
|
6
|
+
var DEFAULT_VALIDATOR_CONTEXT = {
|
|
7
|
+
taskId: "testkit-task",
|
|
8
|
+
workspaceRoot: "/tmp/testkit-workspace",
|
|
9
|
+
scope: []
|
|
10
|
+
};
|
|
11
|
+
async function testValidatorContext(plugin, id, mockCtx = {}) {
|
|
12
|
+
const validator = plugin.__runtime?.validators?.find((v) => v.id === id);
|
|
13
|
+
if (!validator) {
|
|
14
|
+
fail(plugin, `no executable validator "${id}" in the runtime channel`);
|
|
15
|
+
}
|
|
16
|
+
const result = await validator.run({ ...DEFAULT_VALIDATOR_CONTEXT, ...mockCtx });
|
|
17
|
+
if (typeof result?.id !== "string" || result.id !== id) {
|
|
18
|
+
fail(plugin, `validator "${id}" returned id "${result?.id}" \u2014 must echo its own id`);
|
|
19
|
+
}
|
|
20
|
+
if (typeof result.passed !== "boolean") {
|
|
21
|
+
fail(plugin, `validator "${id}" result is missing a boolean "passed"`);
|
|
22
|
+
}
|
|
23
|
+
if (typeof result.summary !== "string") {
|
|
24
|
+
fail(plugin, `validator "${id}" result is missing a string "summary"`);
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
function expectTaskSourceRuntime(plugin, kind, config) {
|
|
29
|
+
const entry = plugin.__runtime?.taskSources?.find((s) => s.kind === kind);
|
|
30
|
+
if (!entry) {
|
|
31
|
+
fail(plugin, `no executable task-source factory for kind "${kind}" in the runtime channel`);
|
|
32
|
+
}
|
|
33
|
+
const source = entry.factory(config ?? { kind });
|
|
34
|
+
if (!source || typeof source !== "object") {
|
|
35
|
+
fail(plugin, `task-source factory for kind "${kind}" did not return an object`);
|
|
36
|
+
}
|
|
37
|
+
if (source.kind !== kind) {
|
|
38
|
+
fail(plugin, `task source for kind "${kind}" reports kind "${source.kind}"`);
|
|
39
|
+
}
|
|
40
|
+
if (typeof source.id !== "string" || source.id.length === 0) {
|
|
41
|
+
fail(plugin, `task source for kind "${kind}" is missing a non-empty id`);
|
|
42
|
+
}
|
|
43
|
+
if (typeof source.list !== "function") {
|
|
44
|
+
fail(plugin, `task source for kind "${kind}" is missing the required list() method`);
|
|
45
|
+
}
|
|
46
|
+
return source;
|
|
47
|
+
}
|
|
48
|
+
async function testHook(plugin, id, mockInput = {}) {
|
|
49
|
+
const implementation = plugin.__runtime?.hooks?.[id];
|
|
50
|
+
if (!implementation) {
|
|
51
|
+
fail(plugin, `no typed hook implementation "${id}" in the runtime channel`);
|
|
52
|
+
}
|
|
53
|
+
const registration = (plugin.contributes?.hooks ?? []).find((h) => h.id === id);
|
|
54
|
+
if (!registration) {
|
|
55
|
+
fail(plugin, `typed hook "${id}" has no metadata entry in contributes.hooks`);
|
|
56
|
+
}
|
|
57
|
+
const input = {
|
|
58
|
+
event: registration.event,
|
|
59
|
+
toolInput: {},
|
|
60
|
+
filePaths: [],
|
|
61
|
+
projectRoot: "/tmp/testkit-project",
|
|
62
|
+
taskId: "testkit-task",
|
|
63
|
+
...mockInput
|
|
64
|
+
};
|
|
65
|
+
const result = await implementation(input);
|
|
66
|
+
if (result?.decision !== "allow" && result?.decision !== "block") {
|
|
67
|
+
fail(plugin, `typed hook "${id}" returned decision "${result?.decision}" \u2014 must be "allow" or "block"`);
|
|
68
|
+
}
|
|
69
|
+
if (result.reason !== undefined && typeof result.reason !== "string") {
|
|
70
|
+
fail(plugin, `typed hook "${id}" returned a non-string "reason"`);
|
|
71
|
+
}
|
|
72
|
+
if (result.systemMessage !== undefined && typeof result.systemMessage !== "string") {
|
|
73
|
+
fail(plugin, `typed hook "${id}" returned a non-string "systemMessage"`);
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
export {
|
|
78
|
+
testValidatorContext,
|
|
79
|
+
testHook,
|
|
80
|
+
expectTaskSourceRuntime
|
|
81
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RigPlugin } from "@rig/contracts";
|
|
2
|
+
export { expectTaskSourceRuntime, testHook, testValidatorContext, } from "./behavior";
|
|
3
|
+
/**
|
|
4
|
+
* Run a standard contract-test suite against a plugin instance.
|
|
5
|
+
* Plugin authors use this to verify their plugin is well-formed before publishing.
|
|
6
|
+
*
|
|
7
|
+
* Usage in plugin author's test file:
|
|
8
|
+
* import { pluginContractTests } from "@rig/plugin-testkit";
|
|
9
|
+
* import myPlugin from "../src";
|
|
10
|
+
* pluginContractTests(myPlugin());
|
|
11
|
+
*/
|
|
12
|
+
export declare function pluginContractTests(plugin: RigPlugin): void;
|
|
13
|
+
/**
|
|
14
|
+
* Assert that a plugin contributes at least one item of the named kind.
|
|
15
|
+
* Useful inside additional test suites.
|
|
16
|
+
*/
|
|
17
|
+
export declare function expectContribution(plugin: RigPlugin, kind: keyof NonNullable<RigPlugin["contributes"]>): void;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/plugin-testkit/src/index.ts
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { Schema } from "effect";
|
|
5
|
+
import { RigPlugin as RigPluginSchema } from "@rig/contracts";
|
|
6
|
+
|
|
7
|
+
// packages/plugin-testkit/src/behavior.ts
|
|
8
|
+
function fail(plugin, message) {
|
|
9
|
+
throw new Error(`plugin "${plugin.name}": ${message}`);
|
|
10
|
+
}
|
|
11
|
+
var DEFAULT_VALIDATOR_CONTEXT = {
|
|
12
|
+
taskId: "testkit-task",
|
|
13
|
+
workspaceRoot: "/tmp/testkit-workspace",
|
|
14
|
+
scope: []
|
|
15
|
+
};
|
|
16
|
+
async function testValidatorContext(plugin, id, mockCtx = {}) {
|
|
17
|
+
const validator = plugin.__runtime?.validators?.find((v) => v.id === id);
|
|
18
|
+
if (!validator) {
|
|
19
|
+
fail(plugin, `no executable validator "${id}" in the runtime channel`);
|
|
20
|
+
}
|
|
21
|
+
const result = await validator.run({ ...DEFAULT_VALIDATOR_CONTEXT, ...mockCtx });
|
|
22
|
+
if (typeof result?.id !== "string" || result.id !== id) {
|
|
23
|
+
fail(plugin, `validator "${id}" returned id "${result?.id}" \u2014 must echo its own id`);
|
|
24
|
+
}
|
|
25
|
+
if (typeof result.passed !== "boolean") {
|
|
26
|
+
fail(plugin, `validator "${id}" result is missing a boolean "passed"`);
|
|
27
|
+
}
|
|
28
|
+
if (typeof result.summary !== "string") {
|
|
29
|
+
fail(plugin, `validator "${id}" result is missing a string "summary"`);
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
function expectTaskSourceRuntime(plugin, kind, config) {
|
|
34
|
+
const entry = plugin.__runtime?.taskSources?.find((s) => s.kind === kind);
|
|
35
|
+
if (!entry) {
|
|
36
|
+
fail(plugin, `no executable task-source factory for kind "${kind}" in the runtime channel`);
|
|
37
|
+
}
|
|
38
|
+
const source = entry.factory(config ?? { kind });
|
|
39
|
+
if (!source || typeof source !== "object") {
|
|
40
|
+
fail(plugin, `task-source factory for kind "${kind}" did not return an object`);
|
|
41
|
+
}
|
|
42
|
+
if (source.kind !== kind) {
|
|
43
|
+
fail(plugin, `task source for kind "${kind}" reports kind "${source.kind}"`);
|
|
44
|
+
}
|
|
45
|
+
if (typeof source.id !== "string" || source.id.length === 0) {
|
|
46
|
+
fail(plugin, `task source for kind "${kind}" is missing a non-empty id`);
|
|
47
|
+
}
|
|
48
|
+
if (typeof source.list !== "function") {
|
|
49
|
+
fail(plugin, `task source for kind "${kind}" is missing the required list() method`);
|
|
50
|
+
}
|
|
51
|
+
return source;
|
|
52
|
+
}
|
|
53
|
+
async function testHook(plugin, id, mockInput = {}) {
|
|
54
|
+
const implementation = plugin.__runtime?.hooks?.[id];
|
|
55
|
+
if (!implementation) {
|
|
56
|
+
fail(plugin, `no typed hook implementation "${id}" in the runtime channel`);
|
|
57
|
+
}
|
|
58
|
+
const registration = (plugin.contributes?.hooks ?? []).find((h) => h.id === id);
|
|
59
|
+
if (!registration) {
|
|
60
|
+
fail(plugin, `typed hook "${id}" has no metadata entry in contributes.hooks`);
|
|
61
|
+
}
|
|
62
|
+
const input = {
|
|
63
|
+
event: registration.event,
|
|
64
|
+
toolInput: {},
|
|
65
|
+
filePaths: [],
|
|
66
|
+
projectRoot: "/tmp/testkit-project",
|
|
67
|
+
taskId: "testkit-task",
|
|
68
|
+
...mockInput
|
|
69
|
+
};
|
|
70
|
+
const result = await implementation(input);
|
|
71
|
+
if (result?.decision !== "allow" && result?.decision !== "block") {
|
|
72
|
+
fail(plugin, `typed hook "${id}" returned decision "${result?.decision}" \u2014 must be "allow" or "block"`);
|
|
73
|
+
}
|
|
74
|
+
if (result.reason !== undefined && typeof result.reason !== "string") {
|
|
75
|
+
fail(plugin, `typed hook "${id}" returned a non-string "reason"`);
|
|
76
|
+
}
|
|
77
|
+
if (result.systemMessage !== undefined && typeof result.systemMessage !== "string") {
|
|
78
|
+
fail(plugin, `typed hook "${id}" returned a non-string "systemMessage"`);
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// packages/plugin-testkit/src/index.ts
|
|
84
|
+
function pluginContractTests(plugin) {
|
|
85
|
+
describe(`plugin contract: ${plugin.name}@${plugin.version}`, () => {
|
|
86
|
+
it("conforms to the RigPlugin schema", () => {
|
|
87
|
+
expect(() => Schema.decodeUnknownSync(RigPluginSchema)(plugin)).not.toThrow();
|
|
88
|
+
});
|
|
89
|
+
it("declares a non-empty name", () => {
|
|
90
|
+
expect(plugin.name).toBeTruthy();
|
|
91
|
+
expect(plugin.name.length).toBeGreaterThan(0);
|
|
92
|
+
});
|
|
93
|
+
it("declares a semver-ish version", () => {
|
|
94
|
+
expect(plugin.version).toMatch(/^\d+\.\d+\.\d+/);
|
|
95
|
+
});
|
|
96
|
+
it("registration ids are unique within each contribution type", () => {
|
|
97
|
+
const c = plugin.contributes;
|
|
98
|
+
if (!c)
|
|
99
|
+
return;
|
|
100
|
+
const types = [
|
|
101
|
+
["validators", c.validators],
|
|
102
|
+
["hooks", c.hooks],
|
|
103
|
+
["skills", c.skills],
|
|
104
|
+
["repoSources", c.repoSources],
|
|
105
|
+
["agentRoles", c.agentRoles],
|
|
106
|
+
["taskFieldSchemas", c.taskFieldSchemas],
|
|
107
|
+
["taskSources", c.taskSources],
|
|
108
|
+
["cliCommands", c.cliCommands]
|
|
109
|
+
];
|
|
110
|
+
for (const [name, items] of types) {
|
|
111
|
+
if (!items)
|
|
112
|
+
continue;
|
|
113
|
+
const ids = items.map((i) => i.id);
|
|
114
|
+
const unique = new Set(ids);
|
|
115
|
+
expect(unique.size, `${name} had duplicate ids`).toBe(ids.length);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
it("registration ids are namespaced (contain a ':' separator)", () => {
|
|
119
|
+
const c = plugin.contributes;
|
|
120
|
+
if (!c)
|
|
121
|
+
return;
|
|
122
|
+
const allIds = [
|
|
123
|
+
...c.validators ?? [],
|
|
124
|
+
...c.hooks ?? [],
|
|
125
|
+
...c.skills ?? [],
|
|
126
|
+
...c.repoSources ?? [],
|
|
127
|
+
...c.agentRoles ?? [],
|
|
128
|
+
...c.taskFieldSchemas ?? [],
|
|
129
|
+
...c.taskSources ?? [],
|
|
130
|
+
...c.cliCommands ?? []
|
|
131
|
+
].map((i) => i.id);
|
|
132
|
+
for (const id of allIds) {
|
|
133
|
+
expect(id, `id "${id}" should be namespaced like "plugin-name:local-id"`).toMatch(/^[a-z][a-z0-9-]*:[a-z0-9][a-z0-9-:]*$/i);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function expectContribution(plugin, kind) {
|
|
139
|
+
const c = plugin.contributes;
|
|
140
|
+
if (!c)
|
|
141
|
+
throw new Error(`plugin "${plugin.name}" has no contributes block`);
|
|
142
|
+
const arr = c[kind];
|
|
143
|
+
if (!arr || arr.length === 0) {
|
|
144
|
+
throw new Error(`plugin "${plugin.name}" contributes no ${String(kind)}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export {
|
|
148
|
+
testValidatorContext,
|
|
149
|
+
testHook,
|
|
150
|
+
pluginContractTests,
|
|
151
|
+
expectTaskSourceRuntime,
|
|
152
|
+
expectContribution
|
|
153
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@h-rig/plugin-testkit",
|
|
3
|
+
"version": "0.0.6-alpha.79",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Rig package",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/src/index.d.ts",
|
|
14
|
+
"import": "./dist/src/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"bun": ">=1.3.11"
|
|
19
|
+
},
|
|
20
|
+
"main": "./dist/src/index.js",
|
|
21
|
+
"module": "./dist/src/index.js",
|
|
22
|
+
"types": "./dist/src/index.d.ts",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@rig/contracts": "npm:@h-rig/contracts@0.0.6-alpha.79",
|
|
25
|
+
"@rig/core": "npm:@h-rig/core@0.0.6-alpha.79",
|
|
26
|
+
"effect": "4.0.0-beta.78",
|
|
27
|
+
"vitest": "^4.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|