@frockbot/plugin-testkit 0.0.0 → 0.1.1

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/package.json CHANGED
@@ -1,14 +1,29 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-testkit",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "scripts": {
10
+ "test": "bun test src",
11
+ "typecheck": "tsc --noEmit -p tsconfig.json"
12
+ },
13
+ "dependencies": {
14
+ "@frockbot/kernel-composition": "0.1.1",
15
+ "cordis": "4.0.0-rc.8"
16
+ },
17
+ "devDependencies": {
18
+ "@types/bun": "1.4.0",
19
+ "typescript": "^7.0.2"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
6
24
  "repository": {
7
25
  "type": "git",
8
26
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
27
  "directory": "packages/plugin-testkit"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
28
  }
14
29
  }
@@ -0,0 +1,175 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type Context, Service, type Plugin } from "cordis";
3
+ import { createPluginHarness, verifyPluginPackage } from "./index.js";
4
+
5
+ class MarkerService extends Service {
6
+ constructor(ctx: Context) {
7
+ super(ctx, "marker");
8
+ }
9
+ }
10
+
11
+ declare module "cordis" {
12
+ interface Context {
13
+ marker: MarkerService;
14
+ }
15
+ }
16
+
17
+ const fixtureManifest = {
18
+ schemaVersion: 2,
19
+ id: "fixture",
20
+ displayName: "Fixture",
21
+ version: "1.2.3",
22
+ compatibility: { frockbot: ">=0.0.1" },
23
+ contributions: {
24
+ runtime: { entry: "./agent" },
25
+ desktop: {
26
+ entry: "./desktop",
27
+ execution: "sandboxed-renderer",
28
+ commands: ["fixture.read"],
29
+ },
30
+ },
31
+ permissions: ["fixture:read"],
32
+ };
33
+
34
+ const fixturePackage = {
35
+ name: "@frockbot/plugin-fixture",
36
+ version: "1.2.3",
37
+ private: true,
38
+ exports: {
39
+ ".": "./src/index.ts",
40
+ "./agent": "./src/agent.ts",
41
+ "./desktop": "./src/desktop.ts",
42
+ "./manifest": "./src/manifest.ts",
43
+ "./frockbot.json": "./frockbot.json",
44
+ "./package.json": "./package.json",
45
+ },
46
+ frockbot: { manifest: "./frockbot.json" },
47
+ };
48
+
49
+ describe("verifyPluginPackage", () => {
50
+ test("verifies package identity and contribution exports", () => {
51
+ expect(
52
+ verifyPluginPackage({
53
+ packageJson: fixturePackage,
54
+ manifest: fixtureManifest,
55
+ }),
56
+ ).toMatchObject({
57
+ name: "@frockbot/plugin-fixture",
58
+ contributionKinds: ["runtime", "desktop"],
59
+ manifest: { id: "fixture", version: "1.2.3" },
60
+ });
61
+ });
62
+
63
+ test("reports all package-level compliance failures together", () => {
64
+ let failure: unknown;
65
+ try {
66
+ verifyPluginPackage({
67
+ packageJson: {
68
+ ...fixturePackage,
69
+ name: "fixture",
70
+ version: "9.0.0",
71
+ private: false,
72
+ exports: { ".": "./src/index.ts" },
73
+ frockbot: { manifest: "./other.json" },
74
+ },
75
+ manifest: {
76
+ ...fixtureManifest,
77
+ permissions: ["fixture:read", "fixture:read"],
78
+ },
79
+ });
80
+ } catch (error) {
81
+ failure = error;
82
+ }
83
+
84
+ expect(failure).toBeInstanceOf(AggregateError);
85
+ const message = failure instanceof Error ? failure.message : "";
86
+ expect(message).toContain("package name must be");
87
+ expect(message).toContain("versions must match");
88
+ expect(message).toContain("must be private");
89
+ expect(message).toContain("./manifest");
90
+ expect(message).toContain("must not contain duplicates");
91
+ });
92
+
93
+ test("verifies the mobile contribution export", () => {
94
+ expect(
95
+ verifyPluginPackage({
96
+ packageJson: {
97
+ ...fixturePackage,
98
+ exports: { ...fixturePackage.exports, "./mobile": "./src/mobile.ts" },
99
+ },
100
+ manifest: {
101
+ ...fixtureManifest,
102
+ contributions: {
103
+ ...fixtureManifest.contributions,
104
+ mobile: { entry: "./mobile" },
105
+ },
106
+ },
107
+ }),
108
+ ).toMatchObject({
109
+ contributionKinds: ["runtime", "desktop", "mobile"],
110
+ });
111
+ });
112
+
113
+ test("reports a missing mobile contribution export", () => {
114
+ let failure: unknown;
115
+ try {
116
+ verifyPluginPackage({
117
+ packageJson: fixturePackage,
118
+ manifest: {
119
+ ...fixtureManifest,
120
+ contributions: {
121
+ ...fixtureManifest.contributions,
122
+ mobile: { entry: "./mobile" },
123
+ },
124
+ },
125
+ });
126
+ } catch (error) {
127
+ failure = error;
128
+ }
129
+
130
+ expect(failure).toBeInstanceOf(AggregateError);
131
+ expect(failure instanceof Error ? failure.message : "").toContain(
132
+ 'package exports must include "./mobile"',
133
+ );
134
+ });
135
+ });
136
+
137
+ describe("PluginHarness", () => {
138
+ test("mounts injected plugins and disposes their effects", async () => {
139
+ let active = false;
140
+ const dependent: Plugin.Function = () => {
141
+ active = true;
142
+ return () => {
143
+ active = false;
144
+ };
145
+ };
146
+ dependent.inject = ["marker"];
147
+ const harness = await createPluginHarness([MarkerService]);
148
+
149
+ await harness.mount(dependent);
150
+ expect(active).toBeTrue();
151
+
152
+ await harness.dispose();
153
+ expect(active).toBeFalse();
154
+ });
155
+
156
+ test("disposes setup plugins when later setup fails", async () => {
157
+ let cleaned = false;
158
+ const tracked: Plugin.Function = () => () => {
159
+ cleaned = true;
160
+ };
161
+ const failing: Plugin.Function = () => {
162
+ throw new Error("setup failed");
163
+ };
164
+ let failure: unknown;
165
+
166
+ try {
167
+ await createPluginHarness([tracked, failing]);
168
+ } catch (error) {
169
+ failure = error;
170
+ }
171
+
172
+ expect(failure).toBeInstanceOf(Error);
173
+ expect(cleaned).toBeTrue();
174
+ });
175
+ });
package/src/index.ts ADDED
@@ -0,0 +1,128 @@
1
+ import {
2
+ type ContributionKind,
3
+ decodeFrockBotManifest,
4
+ declaredContributionKinds,
5
+ type FrockBotManifest,
6
+ } from "@frockbot/kernel-composition";
7
+ import { Context, type Plugin } from "cordis";
8
+
9
+ export interface PluginPackageFixture {
10
+ packageJson: unknown;
11
+ manifest: unknown;
12
+ }
13
+
14
+ export interface VerifiedPluginPackage {
15
+ name: string;
16
+ manifest: FrockBotManifest;
17
+ contributionKinds: ContributionKind[];
18
+ }
19
+
20
+ function record(value: unknown, label: string): Record<string, unknown> {
21
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
22
+ throw new Error(`${label} must be an object`);
23
+ }
24
+ return value as Record<string, unknown>;
25
+ }
26
+
27
+ function nonEmptyString(value: unknown, label: string): string {
28
+ if (typeof value !== "string" || !value.trim()) {
29
+ throw new Error(`${label} must be a non-empty string`);
30
+ }
31
+ return value;
32
+ }
33
+
34
+ function requireExport(
35
+ exports: Record<string, unknown>,
36
+ key: string,
37
+ issues: string[],
38
+ ): void {
39
+ if (!(key in exports)) issues.push(`package exports must include "${key}"`);
40
+ }
41
+
42
+ export function verifyPluginPackage(
43
+ fixture: PluginPackageFixture,
44
+ ): VerifiedPluginPackage {
45
+ const manifest = decodeFrockBotManifest(fixture.manifest);
46
+ const packageJson = record(fixture.packageJson, "package.json");
47
+ const name = nonEmptyString(packageJson.name, "package.json name");
48
+ const version = nonEmptyString(packageJson.version, "package.json version");
49
+ const exports = record(packageJson.exports, "package.json exports");
50
+ const frockbot = record(packageJson.frockbot, "package.json frockbot field");
51
+ const issues: string[] = [];
52
+
53
+ const expectedName = `@frockbot/plugin-${manifest.id}`;
54
+ if (name !== expectedName) {
55
+ issues.push(`package name must be "${expectedName}"`);
56
+ }
57
+ if (version !== manifest.version) {
58
+ issues.push("package and manifest versions must match");
59
+ }
60
+ if (packageJson.private !== true) {
61
+ issues.push("plugin workspace packages must be private");
62
+ }
63
+ if (frockbot.manifest !== "./frockbot.json") {
64
+ issues.push('package.json frockbot.manifest must be "./frockbot.json"');
65
+ }
66
+
67
+ requireExport(exports, ".", issues);
68
+ requireExport(exports, "./manifest", issues);
69
+ requireExport(exports, "./frockbot.json", issues);
70
+ requireExport(exports, "./package.json", issues);
71
+ for (const contribution of [
72
+ manifest.contributions.runtime,
73
+ manifest.contributions.client,
74
+ manifest.contributions.desktop,
75
+ manifest.contributions.mobile,
76
+ ]) {
77
+ if (contribution) requireExport(exports, contribution.entry, issues);
78
+ }
79
+ if (new Set(manifest.permissions).size !== manifest.permissions.length) {
80
+ issues.push("manifest permissions must not contain duplicates");
81
+ }
82
+
83
+ if (issues.length > 0) {
84
+ throw new AggregateError(
85
+ issues.map((issue) => new Error(issue)),
86
+ `plugin package "${manifest.id}" is invalid:\n- ${issues.join("\n- ")}`,
87
+ );
88
+ }
89
+
90
+ return {
91
+ name,
92
+ manifest,
93
+ contributionKinds: declaredContributionKinds(manifest),
94
+ };
95
+ }
96
+
97
+ export class PluginHarness {
98
+ readonly root: Context;
99
+ private disposed = false;
100
+
101
+ constructor(root: Context = new Context()) {
102
+ this.root = root;
103
+ }
104
+
105
+ async mount(plugin: Plugin) {
106
+ if (this.disposed) throw new Error("plugin harness is disposed");
107
+ return await this.root.plugin(plugin);
108
+ }
109
+
110
+ async dispose(): Promise<void> {
111
+ if (this.disposed) return;
112
+ this.disposed = true;
113
+ await this.root.fiber.dispose();
114
+ }
115
+ }
116
+
117
+ export async function createPluginHarness(
118
+ setup: readonly Plugin[] = [],
119
+ ): Promise<PluginHarness> {
120
+ const harness = new PluginHarness();
121
+ try {
122
+ for (const plugin of setup) await harness.mount(plugin);
123
+ return harness;
124
+ } catch (error) {
125
+ await harness.dispose();
126
+ throw error;
127
+ }
128
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "skipLibCheck": true,
10
+ "lib": ["ES2023", "DOM"],
11
+ "types": ["bun"]
12
+ },
13
+ "include": ["src/**/*.ts"]
14
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # @frockbot/plugin-testkit
2
-
3
- Placeholder reserving this name. See https://github.com/timoconnellaus/frockbot.