@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 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
+ }