@openvcs/sdk 0.3.0-nightly.20260423.29 → 0.3.0
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/lib/runtime/index.d.ts +1 -1
- package/lib/runtime/index.js +2 -1
- package/lib/runtime/menu.d.ts +18 -8
- package/lib/runtime/menu.js +50 -18
- package/lib/runtime/registration.js +5 -2
- package/lib/types/menubar.d.ts +2 -2
- package/lib/types/plugin.d.ts +1 -3
- package/package.json +2 -2
- package/src/lib/runtime/index.ts +1 -0
- package/src/lib/runtime/menu.ts +59 -39
- package/src/lib/runtime/registration.ts +9 -2
- package/src/lib/types/menubar.ts +2 -2
- package/src/lib/types/plugin.ts +1 -3
- package/test/runtime.test.js +321 -2
package/lib/runtime/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export { ModalBuilder } from './modal';
|
|
|
9
9
|
export { bootstrapPluginModule, createRegisteredPluginRuntime, } from './registration';
|
|
10
10
|
export { VcsDelegateBase } from './vcs-delegate-base';
|
|
11
11
|
export type { VcsDelegateAssignments } from './vcs-delegate-metadata';
|
|
12
|
-
export { getMenu, getOrCreateMenu, createMenu, addMenuItem, addMenuSeparator, removeMenu, hideMenu, showMenu, registerAction, invoke, notify, } from './menu';
|
|
12
|
+
export { getMenu, getOrCreateMenu, createMenu, addMenuItem, addMenuSeparator, removeMenu, hideMenu, showMenu, registerAction, resetMenuRegistry, invoke, notify, } from './menu';
|
|
13
13
|
export type { MenuHandle } from './menu';
|
|
14
14
|
/** Starts a previously created plugin runtime on process stdio. */
|
|
15
15
|
export declare function startPluginRuntime(runtime: PluginRuntime, transport?: PluginRuntimeTransport): void;
|
package/lib/runtime/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright © 2025-2026 OpenVCS Contributors
|
|
3
3
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
4
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
-
exports.notify = exports.invoke = exports.registerAction = exports.showMenu = exports.hideMenu = exports.removeMenu = exports.addMenuSeparator = exports.addMenuItem = exports.createMenu = exports.getOrCreateMenu = exports.getMenu = exports.VcsDelegateBase = exports.createRegisteredPluginRuntime = exports.bootstrapPluginModule = exports.ModalBuilder = exports.createHost = exports.createPluginRuntime = exports.pluginError = exports.isPluginFailure = exports.createRuntimeDispatcher = exports.createDefaultPluginDelegates = void 0;
|
|
5
|
+
exports.notify = exports.invoke = exports.resetMenuRegistry = exports.registerAction = exports.showMenu = exports.hideMenu = exports.removeMenu = exports.addMenuSeparator = exports.addMenuItem = exports.createMenu = exports.getOrCreateMenu = exports.getMenu = exports.VcsDelegateBase = exports.createRegisteredPluginRuntime = exports.bootstrapPluginModule = exports.ModalBuilder = exports.createHost = exports.createPluginRuntime = exports.pluginError = exports.isPluginFailure = exports.createRuntimeDispatcher = exports.createDefaultPluginDelegates = void 0;
|
|
6
6
|
exports.startPluginRuntime = startPluginRuntime;
|
|
7
7
|
var dispatcher_1 = require("./dispatcher");
|
|
8
8
|
Object.defineProperty(exports, "createDefaultPluginDelegates", { enumerable: true, get: function () { return dispatcher_1.createDefaultPluginDelegates; } });
|
|
@@ -31,6 +31,7 @@ Object.defineProperty(exports, "removeMenu", { enumerable: true, get: function (
|
|
|
31
31
|
Object.defineProperty(exports, "hideMenu", { enumerable: true, get: function () { return menu_1.hideMenu; } });
|
|
32
32
|
Object.defineProperty(exports, "showMenu", { enumerable: true, get: function () { return menu_1.showMenu; } });
|
|
33
33
|
Object.defineProperty(exports, "registerAction", { enumerable: true, get: function () { return menu_1.registerAction; } });
|
|
34
|
+
Object.defineProperty(exports, "resetMenuRegistry", { enumerable: true, get: function () { return menu_1.resetMenuRegistry; } });
|
|
34
35
|
Object.defineProperty(exports, "invoke", { enumerable: true, get: function () { return menu_1.invoke; } });
|
|
35
36
|
Object.defineProperty(exports, "notify", { enumerable: true, get: function () { return menu_1.notify; } });
|
|
36
37
|
/** Starts a previously created plugin runtime on process stdio. */
|
package/lib/runtime/menu.d.ts
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
import type { MenubarItem } from '../types/menubar.js';
|
|
2
|
-
import type { PluginDelegates } from '../types/plugin.js';
|
|
2
|
+
import type { PluginDelegates, PluginHandleActionParams } from '../types/plugin.js';
|
|
3
3
|
import type { PluginRuntimeContext } from './contracts.js';
|
|
4
4
|
type MenubarMenuOptions = {
|
|
5
5
|
before?: string;
|
|
6
6
|
after?: string;
|
|
7
7
|
};
|
|
8
|
+
/** UI surface targeted by a menu: 'menubar' (top-level bar) or 'settings' (preferences panel). */
|
|
8
9
|
type MenuSurface = 'menubar' | 'settings';
|
|
10
|
+
/** Validates and returns the incoming plugin action id. */
|
|
11
|
+
export declare function requireActionId(params: PluginHandleActionParams): string;
|
|
12
|
+
/** Clears all registered menus and action handlers.
|
|
13
|
+
* @internal
|
|
14
|
+
* Called by bootstrap to prevent state leaking between in-process plugin setup runs.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resetMenuRegistry(): void;
|
|
17
|
+
/** Returns whether one registered action handler exists. */
|
|
18
|
+
export declare function hasRegisteredAction(actionId: string): boolean;
|
|
9
19
|
/** Runs a registered action handler by id. */
|
|
10
20
|
export declare function runRegisteredAction(actionId: string, ...args: unknown[]): Promise<unknown>;
|
|
11
21
|
export interface MenuHandle {
|
|
12
22
|
id: string;
|
|
13
23
|
addItem(item: MenubarItem): void;
|
|
14
|
-
addSeparator(beforeAction?: string): void;
|
|
24
|
+
addSeparator(beforeAction?: string, afterAction?: string): void;
|
|
15
25
|
removeItem(actionId: string): void;
|
|
16
26
|
hideItem(actionId: string): void;
|
|
17
27
|
showItem(actionId: string): void;
|
|
@@ -19,19 +29,19 @@ export interface MenuHandle {
|
|
|
19
29
|
/** Returns a menu by id, or null when it does not exist. */
|
|
20
30
|
export declare function getMenu(menuId: string): MenuHandle | null;
|
|
21
31
|
/** Returns a menu by id, creating it if needed.
|
|
22
|
-
* @param menuId - Menu identifier
|
|
23
|
-
* @param label - User-visible label
|
|
24
|
-
* @param options -
|
|
32
|
+
* @param menuId - Menu identifier.
|
|
33
|
+
* @param label - User-visible label.
|
|
34
|
+
* @param options - Placement options, including the required `surface` so the runtime can serialize the menu for the correct host UI surface.
|
|
25
35
|
*/
|
|
26
36
|
export declare function getOrCreateMenu(menuId: string, label: string, options: MenubarMenuOptions & {
|
|
27
37
|
surface: MenuSurface;
|
|
28
38
|
}): MenuHandle | null;
|
|
29
39
|
/** Creates a menu at a specific position (alias for getOrCreateMenu). */
|
|
30
40
|
export declare const createMenu: typeof getOrCreateMenu;
|
|
31
|
-
/** Adds one item to a menu. */
|
|
41
|
+
/** Adds one item to a menu, silently no-op if the menu does not exist. */
|
|
32
42
|
export declare function addMenuItem(menuId: string, item: MenubarItem): void;
|
|
33
|
-
/** Adds one separator to a menu. */
|
|
34
|
-
export declare function addMenuSeparator(menuId: string, beforeAction?: string): void;
|
|
43
|
+
/** Adds one separator to a menu, silently no-op if the menu does not exist. */
|
|
44
|
+
export declare function addMenuSeparator(menuId: string, beforeAction?: string, afterAction?: string): void;
|
|
35
45
|
/** Removes one menu from the registry. */
|
|
36
46
|
export declare function removeMenu(menuId: string): void;
|
|
37
47
|
/** Hides one menu from the registry. */
|
package/lib/runtime/menu.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
4
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
5
|
exports.createMenu = void 0;
|
|
6
|
+
exports.requireActionId = requireActionId;
|
|
7
|
+
exports.resetMenuRegistry = resetMenuRegistry;
|
|
8
|
+
exports.hasRegisteredAction = hasRegisteredAction;
|
|
6
9
|
exports.runRegisteredAction = runRegisteredAction;
|
|
7
10
|
exports.getMenu = getMenu;
|
|
8
11
|
exports.getOrCreateMenu = getOrCreateMenu;
|
|
@@ -15,10 +18,29 @@ exports.registerAction = registerAction;
|
|
|
15
18
|
exports.invoke = invoke;
|
|
16
19
|
exports.notify = notify;
|
|
17
20
|
exports.createMenuPluginDelegates = createMenuPluginDelegates;
|
|
21
|
+
const errors_js_1 = require("./errors.js");
|
|
18
22
|
const menus = new Map();
|
|
19
23
|
const menuOrder = [];
|
|
20
24
|
const actionHandlers = new Map();
|
|
21
25
|
let syntheticId = 0;
|
|
26
|
+
/** Validates and returns the incoming plugin action id. */
|
|
27
|
+
function requireActionId(params) {
|
|
28
|
+
const actionId = typeof params?.action_id === 'string' ? params.action_id.trim() : '';
|
|
29
|
+
if (!actionId) {
|
|
30
|
+
throw (0, errors_js_1.pluginError)('plugin-invalid-action-id', 'plugin.handle_action requires params.action_id to be a non-empty string');
|
|
31
|
+
}
|
|
32
|
+
return actionId;
|
|
33
|
+
}
|
|
34
|
+
/** Clears all registered menus and action handlers.
|
|
35
|
+
* @internal
|
|
36
|
+
* Called by bootstrap to prevent state leaking between in-process plugin setup runs.
|
|
37
|
+
*/
|
|
38
|
+
function resetMenuRegistry() {
|
|
39
|
+
menus.clear();
|
|
40
|
+
menuOrder.splice(0, menuOrder.length);
|
|
41
|
+
actionHandlers.clear();
|
|
42
|
+
syntheticId = 0;
|
|
43
|
+
}
|
|
22
44
|
/** Returns the host-side OpenVCS helper, when the environment provides one. */
|
|
23
45
|
function getOpenVCS() {
|
|
24
46
|
return globalThis.OpenVCS;
|
|
@@ -157,6 +179,13 @@ function serializeMenus() {
|
|
|
157
179
|
})
|
|
158
180
|
.filter((menu) => Boolean(menu));
|
|
159
181
|
}
|
|
182
|
+
/** Returns whether one registered action handler exists. */
|
|
183
|
+
function hasRegisteredAction(actionId) {
|
|
184
|
+
const id = String(actionId || '').trim();
|
|
185
|
+
if (!id)
|
|
186
|
+
return false;
|
|
187
|
+
return actionHandlers.has(id);
|
|
188
|
+
}
|
|
160
189
|
/** Runs a registered action handler by id. */
|
|
161
190
|
async function runRegisteredAction(actionId, ...args) {
|
|
162
191
|
const id = String(actionId || '').trim();
|
|
@@ -190,7 +219,7 @@ function createMenuHandle(menuId) {
|
|
|
190
219
|
action,
|
|
191
220
|
}, item.before, item.after);
|
|
192
221
|
},
|
|
193
|
-
addSeparator(beforeAction) {
|
|
222
|
+
addSeparator(beforeAction, afterAction) {
|
|
194
223
|
let menu = getStoredMenu(this.id);
|
|
195
224
|
if (!menu) {
|
|
196
225
|
// Default to menubar for internal menu handle operations.
|
|
@@ -201,7 +230,7 @@ function createMenuHandle(menuId) {
|
|
|
201
230
|
id: allocateSyntheticId(`${menu.id}-separator`),
|
|
202
231
|
label: 'Separator',
|
|
203
232
|
content: '—',
|
|
204
|
-
}, beforeAction);
|
|
233
|
+
}, beforeAction, afterAction);
|
|
205
234
|
},
|
|
206
235
|
removeItem(actionId) {
|
|
207
236
|
const menu = getStoredMenu(this.id);
|
|
@@ -236,25 +265,29 @@ function getMenu(menuId) {
|
|
|
236
265
|
return createMenuHandle(stored.id);
|
|
237
266
|
}
|
|
238
267
|
/** Returns a menu by id, creating it if needed.
|
|
239
|
-
* @param menuId - Menu identifier
|
|
240
|
-
* @param label - User-visible label
|
|
241
|
-
* @param options -
|
|
268
|
+
* @param menuId - Menu identifier.
|
|
269
|
+
* @param label - User-visible label.
|
|
270
|
+
* @param options - Placement options, including the required `surface` so the runtime can serialize the menu for the correct host UI surface.
|
|
242
271
|
*/
|
|
243
272
|
function getOrCreateMenu(menuId, label, options) {
|
|
244
|
-
const surface = options
|
|
245
|
-
const { surface: _, ...restOptions } = options;
|
|
273
|
+
const { surface, ...restOptions } = options;
|
|
246
274
|
const stored = ensureStoredMenu(menuId, label, restOptions, surface);
|
|
247
275
|
return createMenuHandle(stored.id);
|
|
248
276
|
}
|
|
249
277
|
/** Creates a menu at a specific position (alias for getOrCreateMenu). */
|
|
250
278
|
exports.createMenu = getOrCreateMenu;
|
|
251
|
-
/** Adds one item to a menu. */
|
|
279
|
+
/** Adds one item to a menu, silently no-op if the menu does not exist. */
|
|
252
280
|
function addMenuItem(menuId, item) {
|
|
253
|
-
createMenuHandle(menuId)
|
|
281
|
+
const handle = createMenuHandle(menuId);
|
|
282
|
+
if (!getStoredMenu(menuId))
|
|
283
|
+
return;
|
|
284
|
+
handle.addItem(item);
|
|
254
285
|
}
|
|
255
|
-
/** Adds one separator to a menu. */
|
|
256
|
-
function addMenuSeparator(menuId, beforeAction) {
|
|
257
|
-
|
|
286
|
+
/** Adds one separator to a menu, silently no-op if the menu does not exist. */
|
|
287
|
+
function addMenuSeparator(menuId, beforeAction, afterAction) {
|
|
288
|
+
if (!getStoredMenu(menuId))
|
|
289
|
+
return;
|
|
290
|
+
createMenuHandle(menuId).addSeparator(beforeAction, afterAction);
|
|
258
291
|
}
|
|
259
292
|
/** Removes one menu from the registry. */
|
|
260
293
|
function removeMenu(menuId) {
|
|
@@ -292,7 +325,10 @@ function invoke(cmd, args) {
|
|
|
292
325
|
/** Emits a notification when the host helper is available. */
|
|
293
326
|
function notify(msg) {
|
|
294
327
|
const openvcs = getOpenVCS();
|
|
295
|
-
openvcs
|
|
328
|
+
if (!openvcs) {
|
|
329
|
+
throw new Error('OpenVCS host is not available in this runtime');
|
|
330
|
+
}
|
|
331
|
+
openvcs.notify(msg);
|
|
296
332
|
}
|
|
297
333
|
/** Builds SDK delegates from the local menu/action registries. */
|
|
298
334
|
function createMenuPluginDelegates() {
|
|
@@ -301,11 +337,7 @@ function createMenuPluginDelegates() {
|
|
|
301
337
|
return serializeMenus();
|
|
302
338
|
},
|
|
303
339
|
async 'plugin.handle_action'(params) {
|
|
304
|
-
|
|
305
|
-
if (actionId) {
|
|
306
|
-
return await runRegisteredAction(actionId, params?.payload);
|
|
307
|
-
}
|
|
308
|
-
return null;
|
|
340
|
+
return await runRegisteredAction(requireActionId(params), params?.payload);
|
|
309
341
|
},
|
|
310
342
|
};
|
|
311
343
|
}
|
|
@@ -26,8 +26,8 @@ function createRegisteredPluginRuntime(definition = {}) {
|
|
|
26
26
|
];
|
|
27
27
|
},
|
|
28
28
|
'plugin.handle_action': async (params, ctx) => {
|
|
29
|
-
const actionId =
|
|
30
|
-
if (actionId) {
|
|
29
|
+
const actionId = (0, menu_1.requireActionId)(params);
|
|
30
|
+
if ((0, menu_1.hasRegisteredAction)(actionId)) {
|
|
31
31
|
const result = await (0, menu_1.runRegisteredAction)(actionId, params?.payload);
|
|
32
32
|
if (result !== null && result !== undefined) {
|
|
33
33
|
return result;
|
|
@@ -36,6 +36,7 @@ function createRegisteredPluginRuntime(definition = {}) {
|
|
|
36
36
|
if (explicitHandleAction) {
|
|
37
37
|
return explicitHandleAction(params, ctx);
|
|
38
38
|
}
|
|
39
|
+
ctx.host.info(`plugin.handle_action ignored unhandled action_id '${actionId}'`);
|
|
39
40
|
return null;
|
|
40
41
|
},
|
|
41
42
|
},
|
|
@@ -54,6 +55,8 @@ async function bootstrapPluginModule(options) {
|
|
|
54
55
|
if (typeof onPluginStart !== 'function') {
|
|
55
56
|
throw new Error(`plugin module '${options.modulePath}' must export OnPluginStart()`);
|
|
56
57
|
}
|
|
58
|
+
// Reset internal state so repeated in-process setups do not leak menu or action state.
|
|
59
|
+
(0, menu_1.resetMenuRegistry)();
|
|
57
60
|
try {
|
|
58
61
|
await onPluginStart();
|
|
59
62
|
}
|
package/lib/types/menubar.d.ts
CHANGED
|
@@ -22,8 +22,8 @@ export interface MenubarMenu {
|
|
|
22
22
|
id: string;
|
|
23
23
|
/** Adds an item to this menu. */
|
|
24
24
|
addItem(item: MenubarItem): void;
|
|
25
|
-
/** Adds a separator to this menu. */
|
|
26
|
-
addSeparator(beforeAction?: string): void;
|
|
25
|
+
/** Adds a separator to this menu before or after an existing action. */
|
|
26
|
+
addSeparator(beforeAction?: string, afterAction?: string): void;
|
|
27
27
|
/** Removes an item by action ID. */
|
|
28
28
|
removeItem(actionId: string): void;
|
|
29
29
|
/** Hides an item by action ID. */
|
package/lib/types/plugin.d.ts
CHANGED
|
@@ -54,9 +54,7 @@ export interface PluginMenuDefinition {
|
|
|
54
54
|
export type PluginSettingsValue = Record<string, unknown>;
|
|
55
55
|
/** Describes the params shape for plugin action handling. */
|
|
56
56
|
export interface PluginHandleActionParams extends RequestParams {
|
|
57
|
-
/** Stores the action id selected by the user
|
|
58
|
-
id?: string;
|
|
59
|
-
/** Stores the action id selected by the user. */
|
|
57
|
+
/** Stores the action id selected by the user. Runtime handlers require a non-empty string. */
|
|
60
58
|
action_id?: string;
|
|
61
59
|
/** Stores an optional payload supplied by the triggering UI. */
|
|
62
60
|
payload?: Record<string, unknown>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openvcs/sdk",
|
|
3
|
-
"version": "0.3.0
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "OpenVCS SDK CLI for plugin scaffolding and runtime asset builds",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"homepage": "https://openvcs.app/",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/node": "^25.3.3",
|
|
43
|
-
"typescript": "^6.0.
|
|
43
|
+
"typescript": "^6.0.3"
|
|
44
44
|
},
|
|
45
45
|
"files": [
|
|
46
46
|
"src/",
|
package/src/lib/runtime/index.ts
CHANGED
package/src/lib/runtime/menu.ts
CHANGED
|
@@ -5,13 +5,16 @@ import type { MenubarItem } from '../types/menubar.js';
|
|
|
5
5
|
import type {
|
|
6
6
|
PluginDelegates,
|
|
7
7
|
PluginHandleActionParams,
|
|
8
|
+
PluginMenuElement,
|
|
8
9
|
PluginMenuDefinition,
|
|
9
10
|
} from '../types/plugin.js';
|
|
10
11
|
|
|
11
12
|
import type { PluginRuntimeContext } from './contracts.js';
|
|
13
|
+
import { pluginError } from './errors.js';
|
|
12
14
|
|
|
13
15
|
type MenubarMenuOptions = { before?: string; after?: string };
|
|
14
16
|
type MenuEntryKind = 'button' | 'text' | 'separator';
|
|
17
|
+
/** UI surface targeted by a menu: 'menubar' (top-level bar) or 'settings' (preferences panel). */
|
|
15
18
|
type MenuSurface = 'menubar' | 'settings';
|
|
16
19
|
|
|
17
20
|
type OpenVCSGlobal = typeof globalThis & {
|
|
@@ -27,6 +30,7 @@ interface StoredMenuItem {
|
|
|
27
30
|
label: string;
|
|
28
31
|
title?: string;
|
|
29
32
|
content?: string;
|
|
33
|
+
/** Action id dispatched back to the plugin when the user activates this item. */
|
|
30
34
|
action?: string;
|
|
31
35
|
hidden?: boolean;
|
|
32
36
|
}
|
|
@@ -40,26 +44,34 @@ interface StoredMenuState {
|
|
|
40
44
|
items: StoredMenuItem[];
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
interface SerializedMenuItem {
|
|
44
|
-
type: 'button' | 'text';
|
|
45
|
-
id: string;
|
|
46
|
-
label?: string;
|
|
47
|
-
content?: string;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
interface SerializedMenuDefinition {
|
|
51
|
-
id: string;
|
|
52
|
-
label: string;
|
|
53
|
-
order: number;
|
|
54
|
-
surface: 'menubar' | 'settings';
|
|
55
|
-
elements: SerializedMenuItem[];
|
|
56
|
-
}
|
|
57
|
-
|
|
58
47
|
const menus = new Map<string, StoredMenuState>();
|
|
59
48
|
const menuOrder: string[] = [];
|
|
60
49
|
const actionHandlers = new Map<string, (...args: unknown[]) => unknown>();
|
|
61
50
|
let syntheticId = 0;
|
|
62
51
|
|
|
52
|
+
/** Validates and returns the incoming plugin action id. */
|
|
53
|
+
export function requireActionId(params: PluginHandleActionParams): string {
|
|
54
|
+
const actionId = typeof params?.action_id === 'string' ? params.action_id.trim() : '';
|
|
55
|
+
if (!actionId) {
|
|
56
|
+
throw pluginError(
|
|
57
|
+
'plugin-invalid-action-id',
|
|
58
|
+
'plugin.handle_action requires params.action_id to be a non-empty string',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return actionId;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Clears all registered menus and action handlers.
|
|
65
|
+
* @internal
|
|
66
|
+
* Called by bootstrap to prevent state leaking between in-process plugin setup runs.
|
|
67
|
+
*/
|
|
68
|
+
export function resetMenuRegistry(): void {
|
|
69
|
+
menus.clear();
|
|
70
|
+
menuOrder.splice(0, menuOrder.length);
|
|
71
|
+
actionHandlers.clear();
|
|
72
|
+
syntheticId = 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
/** Returns the host-side OpenVCS helper, when the environment provides one. */
|
|
64
76
|
function getOpenVCS() {
|
|
65
77
|
return (globalThis as OpenVCSGlobal).OpenVCS;
|
|
@@ -177,7 +189,7 @@ function insertMenuItem(menu: StoredMenuState, item: StoredMenuItem, before?: st
|
|
|
177
189
|
}
|
|
178
190
|
|
|
179
191
|
/** Converts one stored item into a serializable menu payload element. */
|
|
180
|
-
function serializeMenuItem(item: StoredMenuItem):
|
|
192
|
+
function serializeMenuItem(item: StoredMenuItem): PluginMenuElement | null {
|
|
181
193
|
if (item.hidden) return null;
|
|
182
194
|
|
|
183
195
|
if (item.kind === 'separator') {
|
|
@@ -204,7 +216,7 @@ function serializeMenuItem(item: StoredMenuItem): SerializedMenuItem | null {
|
|
|
204
216
|
}
|
|
205
217
|
|
|
206
218
|
/** Serializes the local registry into plugin menu payloads. */
|
|
207
|
-
function serializeMenus():
|
|
219
|
+
function serializeMenus(): PluginMenuDefinition[] {
|
|
208
220
|
return menuOrder
|
|
209
221
|
.map((menuId, index) => {
|
|
210
222
|
const menu = menus.get(menuId);
|
|
@@ -216,10 +228,17 @@ function serializeMenus(): SerializedMenuDefinition[] {
|
|
|
216
228
|
surface: menu.surface,
|
|
217
229
|
elements: menu.items
|
|
218
230
|
.map((item) => serializeMenuItem(item))
|
|
219
|
-
.filter((item): item is
|
|
231
|
+
.filter((item): item is PluginMenuElement => Boolean(item)),
|
|
220
232
|
};
|
|
221
233
|
})
|
|
222
|
-
.filter((menu): menu is
|
|
234
|
+
.filter((menu): menu is PluginMenuDefinition => Boolean(menu));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Returns whether one registered action handler exists. */
|
|
238
|
+
export function hasRegisteredAction(actionId: string): boolean {
|
|
239
|
+
const id = String(actionId || '').trim();
|
|
240
|
+
if (!id) return false;
|
|
241
|
+
return actionHandlers.has(id);
|
|
223
242
|
}
|
|
224
243
|
|
|
225
244
|
/** Runs a registered action handler by id. */
|
|
@@ -236,7 +255,7 @@ export async function runRegisteredAction(actionId: string, ...args: unknown[]):
|
|
|
236
255
|
export interface MenuHandle {
|
|
237
256
|
id: string;
|
|
238
257
|
addItem(item: MenubarItem): void;
|
|
239
|
-
addSeparator(beforeAction?: string): void;
|
|
258
|
+
addSeparator(beforeAction?: string, afterAction?: string): void;
|
|
240
259
|
removeItem(actionId: string): void;
|
|
241
260
|
hideItem(actionId: string): void;
|
|
242
261
|
showItem(actionId: string): void;
|
|
@@ -266,7 +285,7 @@ function createMenuHandle(menuId: string): MenuHandle {
|
|
|
266
285
|
action,
|
|
267
286
|
}, item.before, item.after);
|
|
268
287
|
},
|
|
269
|
-
addSeparator(beforeAction?: string) {
|
|
288
|
+
addSeparator(beforeAction?: string, afterAction?: string) {
|
|
270
289
|
let menu = getStoredMenu(this.id);
|
|
271
290
|
if (!menu) {
|
|
272
291
|
// Default to menubar for internal menu handle operations.
|
|
@@ -277,7 +296,7 @@ function createMenuHandle(menuId: string): MenuHandle {
|
|
|
277
296
|
id: allocateSyntheticId(`${menu.id}-separator`),
|
|
278
297
|
label: 'Separator',
|
|
279
298
|
content: '—',
|
|
280
|
-
}, beforeAction);
|
|
299
|
+
}, beforeAction, afterAction);
|
|
281
300
|
},
|
|
282
301
|
removeItem(actionId: string) {
|
|
283
302
|
const menu = getStoredMenu(this.id);
|
|
@@ -308,17 +327,16 @@ export function getMenu(menuId: string): MenuHandle | null {
|
|
|
308
327
|
}
|
|
309
328
|
|
|
310
329
|
/** Returns a menu by id, creating it if needed.
|
|
311
|
-
* @param menuId - Menu identifier
|
|
312
|
-
* @param label - User-visible label
|
|
313
|
-
* @param options -
|
|
330
|
+
* @param menuId - Menu identifier.
|
|
331
|
+
* @param label - User-visible label.
|
|
332
|
+
* @param options - Placement options, including the required `surface` so the runtime can serialize the menu for the correct host UI surface.
|
|
314
333
|
*/
|
|
315
334
|
export function getOrCreateMenu(
|
|
316
335
|
menuId: string,
|
|
317
336
|
label: string,
|
|
318
337
|
options: MenubarMenuOptions & { surface: MenuSurface },
|
|
319
338
|
): MenuHandle | null {
|
|
320
|
-
const surface = options
|
|
321
|
-
const { surface: _, ...restOptions } = options;
|
|
339
|
+
const { surface, ...restOptions } = options;
|
|
322
340
|
const stored = ensureStoredMenu(menuId, label, restOptions, surface);
|
|
323
341
|
return createMenuHandle(stored.id);
|
|
324
342
|
}
|
|
@@ -326,14 +344,17 @@ export function getOrCreateMenu(
|
|
|
326
344
|
/** Creates a menu at a specific position (alias for getOrCreateMenu). */
|
|
327
345
|
export const createMenu = getOrCreateMenu;
|
|
328
346
|
|
|
329
|
-
/** Adds one item to a menu. */
|
|
347
|
+
/** Adds one item to a menu, silently no-op if the menu does not exist. */
|
|
330
348
|
export function addMenuItem(menuId: string, item: MenubarItem): void {
|
|
331
|
-
createMenuHandle(menuId)
|
|
349
|
+
const handle = createMenuHandle(menuId);
|
|
350
|
+
if (!getStoredMenu(menuId)) return;
|
|
351
|
+
handle.addItem(item);
|
|
332
352
|
}
|
|
333
353
|
|
|
334
|
-
/** Adds one separator to a menu. */
|
|
335
|
-
export function addMenuSeparator(menuId: string, beforeAction?: string): void {
|
|
336
|
-
|
|
354
|
+
/** Adds one separator to a menu, silently no-op if the menu does not exist. */
|
|
355
|
+
export function addMenuSeparator(menuId: string, beforeAction?: string, afterAction?: string): void {
|
|
356
|
+
if (!getStoredMenu(menuId)) return;
|
|
357
|
+
createMenuHandle(menuId).addSeparator(beforeAction, afterAction);
|
|
337
358
|
}
|
|
338
359
|
|
|
339
360
|
/** Removes one menu from the registry. */
|
|
@@ -374,21 +395,20 @@ export function invoke<T = unknown>(cmd: string, args?: unknown): Promise<T> {
|
|
|
374
395
|
/** Emits a notification when the host helper is available. */
|
|
375
396
|
export function notify(msg: string): void {
|
|
376
397
|
const openvcs = getOpenVCS();
|
|
377
|
-
openvcs
|
|
398
|
+
if (!openvcs) {
|
|
399
|
+
throw new Error('OpenVCS host is not available in this runtime');
|
|
400
|
+
}
|
|
401
|
+
openvcs.notify(msg);
|
|
378
402
|
}
|
|
379
403
|
|
|
380
404
|
/** Builds SDK delegates from the local menu/action registries. */
|
|
381
405
|
export function createMenuPluginDelegates(): PluginDelegates<PluginRuntimeContext> {
|
|
382
406
|
return {
|
|
383
407
|
async 'plugin.get_menus'(): Promise<PluginMenuDefinition[]> {
|
|
384
|
-
return serializeMenus()
|
|
408
|
+
return serializeMenus();
|
|
385
409
|
},
|
|
386
410
|
async 'plugin.handle_action'(params: PluginHandleActionParams): Promise<unknown> {
|
|
387
|
-
|
|
388
|
-
if (actionId) {
|
|
389
|
-
return await runRegisteredAction(actionId, params?.payload);
|
|
390
|
-
}
|
|
391
|
-
return null;
|
|
411
|
+
return await runRegisteredAction(requireActionId(params), params?.payload);
|
|
392
412
|
},
|
|
393
413
|
};
|
|
394
414
|
}
|
|
@@ -16,6 +16,9 @@ import type {
|
|
|
16
16
|
import { createPluginRuntime } from './factory';
|
|
17
17
|
import {
|
|
18
18
|
createMenuPluginDelegates,
|
|
19
|
+
hasRegisteredAction,
|
|
20
|
+
requireActionId,
|
|
21
|
+
resetMenuRegistry,
|
|
19
22
|
runRegisteredAction,
|
|
20
23
|
} from './menu';
|
|
21
24
|
|
|
@@ -79,8 +82,8 @@ export function createRegisteredPluginRuntime(
|
|
|
79
82
|
];
|
|
80
83
|
},
|
|
81
84
|
'plugin.handle_action': async (params, ctx) => {
|
|
82
|
-
const actionId =
|
|
83
|
-
if (actionId) {
|
|
85
|
+
const actionId = requireActionId(params);
|
|
86
|
+
if (hasRegisteredAction(actionId)) {
|
|
84
87
|
const result = await runRegisteredAction(actionId, params?.payload);
|
|
85
88
|
if (result !== null && result !== undefined) {
|
|
86
89
|
return result;
|
|
@@ -89,6 +92,7 @@ export function createRegisteredPluginRuntime(
|
|
|
89
92
|
if (explicitHandleAction) {
|
|
90
93
|
return explicitHandleAction(params, ctx);
|
|
91
94
|
}
|
|
95
|
+
ctx.host.info(`plugin.handle_action ignored unhandled action_id '${actionId}'`);
|
|
92
96
|
return null;
|
|
93
97
|
},
|
|
94
98
|
},
|
|
@@ -114,6 +118,9 @@ export async function bootstrapPluginModule(
|
|
|
114
118
|
);
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
// Reset internal state so repeated in-process setups do not leak menu or action state.
|
|
122
|
+
resetMenuRegistry();
|
|
123
|
+
|
|
117
124
|
try {
|
|
118
125
|
await onPluginStart();
|
|
119
126
|
} catch (error) {
|
package/src/lib/types/menubar.ts
CHANGED
|
@@ -23,8 +23,8 @@ export interface MenubarMenu {
|
|
|
23
23
|
id: string;
|
|
24
24
|
/** Adds an item to this menu. */
|
|
25
25
|
addItem(item: MenubarItem): void;
|
|
26
|
-
/** Adds a separator to this menu. */
|
|
27
|
-
addSeparator(beforeAction?: string): void;
|
|
26
|
+
/** Adds a separator to this menu before or after an existing action. */
|
|
27
|
+
addSeparator(beforeAction?: string, afterAction?: string): void;
|
|
28
28
|
/** Removes an item by action ID. */
|
|
29
29
|
removeItem(actionId: string): void;
|
|
30
30
|
/** Hides an item by action ID. */
|
package/src/lib/types/plugin.ts
CHANGED
|
@@ -67,9 +67,7 @@ export type PluginSettingsValue = Record<string, unknown>;
|
|
|
67
67
|
|
|
68
68
|
/** Describes the params shape for plugin action handling. */
|
|
69
69
|
export interface PluginHandleActionParams extends RequestParams {
|
|
70
|
-
/** Stores the action id selected by the user
|
|
71
|
-
id?: string;
|
|
72
|
-
/** Stores the action id selected by the user. */
|
|
70
|
+
/** Stores the action id selected by the user. Runtime handlers require a non-empty string. */
|
|
73
71
|
action_id?: string;
|
|
74
72
|
/** Stores an optional payload supplied by the triggering UI. */
|
|
75
73
|
payload?: Record<string, unknown>;
|
package/test/runtime.test.js
CHANGED
|
@@ -4,7 +4,21 @@ const test = require("node:test");
|
|
|
4
4
|
|
|
5
5
|
const {
|
|
6
6
|
bootstrapPluginModule,
|
|
7
|
-
|
|
7
|
+
createRegisteredPluginRuntime,
|
|
8
|
+
resetMenuRegistry,
|
|
9
|
+
} = require("../lib/runtime");
|
|
10
|
+
const {
|
|
11
|
+
addMenuItem,
|
|
12
|
+
addMenuSeparator,
|
|
13
|
+
createMenu,
|
|
14
|
+
getMenu,
|
|
15
|
+
registerAction,
|
|
16
|
+
removeMenu,
|
|
17
|
+
hideMenu,
|
|
18
|
+
showMenu,
|
|
19
|
+
} = require("../lib/runtime/menu");
|
|
20
|
+
const {
|
|
21
|
+
createMenu: createMenuFromRoot,
|
|
8
22
|
} = require("../lib/runtime");
|
|
9
23
|
const {
|
|
10
24
|
parseFramedMessages,
|
|
@@ -20,7 +34,7 @@ function createRuntimeHarness(options) {
|
|
|
20
34
|
return true;
|
|
21
35
|
},
|
|
22
36
|
};
|
|
23
|
-
const runtime =
|
|
37
|
+
const runtime = createRegisteredPluginRuntime(options);
|
|
24
38
|
runtime.start({ stdin, stdout });
|
|
25
39
|
|
|
26
40
|
return {
|
|
@@ -233,3 +247,308 @@ test("bootstrapPluginModule rejects when OnPluginStart throws", async () => {
|
|
|
233
247
|
/plugin startup failed/
|
|
234
248
|
);
|
|
235
249
|
});
|
|
250
|
+
|
|
251
|
+
test("resetMenuRegistry clears all menus and action handlers", () => {
|
|
252
|
+
createMenu("reset-test", "Reset Test", { surface: "menubar" });
|
|
253
|
+
registerAction("reset-test-action", () => "ok");
|
|
254
|
+
assert.notEqual(getMenu("reset-test"), null);
|
|
255
|
+
|
|
256
|
+
resetMenuRegistry();
|
|
257
|
+
|
|
258
|
+
assert.equal(getMenu("reset-test"), null);
|
|
259
|
+
// After reset, addMenuItem/addMenuSeparator must silently no-op for unknown menus.
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("addMenuItem silently ignores when menu does not exist", () => {
|
|
263
|
+
resetMenuRegistry();
|
|
264
|
+
// Must not throw.
|
|
265
|
+
addMenuItem("nonexistent-menu", {
|
|
266
|
+
label: "Irrelevant",
|
|
267
|
+
action: "test-action",
|
|
268
|
+
});
|
|
269
|
+
assert.equal(getMenu("nonexistent-menu"), null);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("addMenuSeparator silently ignores when menu does not exist", () => {
|
|
273
|
+
resetMenuRegistry();
|
|
274
|
+
// Must not throw.
|
|
275
|
+
addMenuSeparator("nonexistent-menu");
|
|
276
|
+
assert.equal(getMenu("nonexistent-menu"), null);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("addMenuItem and addMenuSeparator do not implicitly create menus", () => {
|
|
280
|
+
resetMenuRegistry();
|
|
281
|
+
createMenu("existing-menu", "Existing Menu", { surface: "menubar" });
|
|
282
|
+
addMenuItem("existing-menu", {
|
|
283
|
+
label: "Test Item",
|
|
284
|
+
action: "test-action",
|
|
285
|
+
});
|
|
286
|
+
addMenuSeparator("existing-menu");
|
|
287
|
+
assert.notEqual(getMenu("existing-menu"), null);
|
|
288
|
+
// Verify state was not leaked from prior tests.
|
|
289
|
+
assert.equal(getMenu("reset-test"), null);
|
|
290
|
+
removeMenu("existing-menu");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("hideMenu and showMenu toggle menu visibility", () => {
|
|
294
|
+
resetMenuRegistry();
|
|
295
|
+
createMenu("visible-menu", "Visible Menu", { surface: "menubar" });
|
|
296
|
+
addMenuItem("visible-menu", {
|
|
297
|
+
label: "Test Item",
|
|
298
|
+
action: "visibility-action",
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const menu = getMenu("visible-menu");
|
|
302
|
+
assert.notEqual(menu, null);
|
|
303
|
+
hideMenu("visible-menu");
|
|
304
|
+
assert.notEqual(getMenu("visible-menu"), null);
|
|
305
|
+
|
|
306
|
+
showMenu("visible-menu");
|
|
307
|
+
assert.notEqual(getMenu("visible-menu"), null);
|
|
308
|
+
|
|
309
|
+
removeMenu("visible-menu");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("hideMenu and showMenu affect serialized menus", async () => {
|
|
313
|
+
resetMenuRegistry();
|
|
314
|
+
createMenu("serial-menu", "Serial Menu", { surface: "menubar" });
|
|
315
|
+
addMenuItem("serial-menu", {
|
|
316
|
+
label: "Serial Item",
|
|
317
|
+
action: "serial-action",
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const harness = createRuntimeHarness({ plugin: {} });
|
|
321
|
+
|
|
322
|
+
hideMenu("serial-menu");
|
|
323
|
+
let hidden = await harness.request({
|
|
324
|
+
jsonrpc: "2.0",
|
|
325
|
+
id: 30,
|
|
326
|
+
method: "plugin.get_menus",
|
|
327
|
+
params: {},
|
|
328
|
+
});
|
|
329
|
+
assert.deepEqual(hidden, [{ jsonrpc: "2.0", id: 30, result: [] }]);
|
|
330
|
+
|
|
331
|
+
showMenu("serial-menu");
|
|
332
|
+
const visible = await harness.request({
|
|
333
|
+
jsonrpc: "2.0",
|
|
334
|
+
id: 31,
|
|
335
|
+
method: "plugin.get_menus",
|
|
336
|
+
params: {},
|
|
337
|
+
});
|
|
338
|
+
assert.deepEqual(visible, [{
|
|
339
|
+
jsonrpc: "2.0",
|
|
340
|
+
id: 31,
|
|
341
|
+
result: [{
|
|
342
|
+
id: "serial-menu",
|
|
343
|
+
label: "Serial Menu",
|
|
344
|
+
order: 1,
|
|
345
|
+
surface: "menubar",
|
|
346
|
+
elements: [{ type: "button", id: "serial-action", label: "Serial Item" }],
|
|
347
|
+
}],
|
|
348
|
+
}]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("addMenuSeparator supports afterAction positioning", async () => {
|
|
352
|
+
resetMenuRegistry();
|
|
353
|
+
createMenu("separator-menu", "Separator Menu", { surface: "menubar" });
|
|
354
|
+
addMenuItem("separator-menu", {
|
|
355
|
+
label: "First Item",
|
|
356
|
+
action: "first-action",
|
|
357
|
+
});
|
|
358
|
+
addMenuItem("separator-menu", {
|
|
359
|
+
label: "Second Item",
|
|
360
|
+
action: "second-action",
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
addMenuSeparator("separator-menu", undefined, "first-action");
|
|
364
|
+
|
|
365
|
+
const harness = createRuntimeHarness({ plugin: {} });
|
|
366
|
+
const messages = await harness.request({
|
|
367
|
+
jsonrpc: "2.0",
|
|
368
|
+
id: 32,
|
|
369
|
+
method: "plugin.get_menus",
|
|
370
|
+
params: {},
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
assert.deepEqual(messages, [{
|
|
374
|
+
jsonrpc: "2.0",
|
|
375
|
+
id: 32,
|
|
376
|
+
result: [{
|
|
377
|
+
id: "separator-menu",
|
|
378
|
+
label: "Separator Menu",
|
|
379
|
+
order: 1,
|
|
380
|
+
surface: "menubar",
|
|
381
|
+
elements: [
|
|
382
|
+
{ type: "button", id: "first-action", label: "First Item" },
|
|
383
|
+
{ type: "text", id: "separator-menu-separator-1", content: "—" },
|
|
384
|
+
{ type: "button", id: "second-action", label: "Second Item" },
|
|
385
|
+
],
|
|
386
|
+
}],
|
|
387
|
+
}]);
|
|
388
|
+
|
|
389
|
+
removeMenu("separator-menu");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("plugin.handle_action rejects missing action_id", async () => {
|
|
393
|
+
resetMenuRegistry();
|
|
394
|
+
const harness = createRuntimeHarness({ plugin: {} });
|
|
395
|
+
|
|
396
|
+
const missing = await harness.request({
|
|
397
|
+
jsonrpc: "2.0",
|
|
398
|
+
id: 20,
|
|
399
|
+
method: "plugin.handle_action",
|
|
400
|
+
params: {},
|
|
401
|
+
});
|
|
402
|
+
assert.deepEqual(missing, [
|
|
403
|
+
{
|
|
404
|
+
jsonrpc: "2.0",
|
|
405
|
+
id: 20,
|
|
406
|
+
error: {
|
|
407
|
+
code: -32001,
|
|
408
|
+
message: "plugin.handle_action requires params.action_id to be a non-empty string",
|
|
409
|
+
data: {
|
|
410
|
+
code: "plugin-invalid-action-id",
|
|
411
|
+
message: "plugin.handle_action requires params.action_id to be a non-empty string",
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
]);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("plugin.handle_action rejects empty action_id", async () => {
|
|
419
|
+
resetMenuRegistry();
|
|
420
|
+
const harness = createRuntimeHarness({ plugin: {} });
|
|
421
|
+
|
|
422
|
+
const empty = await harness.request({
|
|
423
|
+
jsonrpc: "2.0",
|
|
424
|
+
id: 21,
|
|
425
|
+
method: "plugin.handle_action",
|
|
426
|
+
params: { action_id: "" },
|
|
427
|
+
});
|
|
428
|
+
assert.deepEqual(empty, [
|
|
429
|
+
{
|
|
430
|
+
jsonrpc: "2.0",
|
|
431
|
+
id: 21,
|
|
432
|
+
error: {
|
|
433
|
+
code: -32001,
|
|
434
|
+
message: "plugin.handle_action requires params.action_id to be a non-empty string",
|
|
435
|
+
data: {
|
|
436
|
+
code: "plugin-invalid-action-id",
|
|
437
|
+
message: "plugin.handle_action requires params.action_id to be a non-empty string",
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("createMenu is available from runtime root export", () => {
|
|
445
|
+
resetMenuRegistry();
|
|
446
|
+
const menu = createMenuFromRoot("root-export-menu", "Root Export Menu", { surface: "menubar" });
|
|
447
|
+
assert.notEqual(menu, null);
|
|
448
|
+
assert.equal(menu.id, "root-export-menu");
|
|
449
|
+
|
|
450
|
+
resetMenuRegistry();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("plugin.handle_action dispatches only on action_id (not id)", async () => {
|
|
454
|
+
resetMenuRegistry();
|
|
455
|
+
// Register after reset so the handler is present.
|
|
456
|
+
registerAction("test-action-id", (payload) => {
|
|
457
|
+
if (typeof payload === "object" && payload != null) {
|
|
458
|
+
return (payload).message;
|
|
459
|
+
}
|
|
460
|
+
return null;
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const harness = createRuntimeHarness({
|
|
464
|
+
plugin: {},
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Valid dispatch via action_id.
|
|
468
|
+
const valid = await harness.request({
|
|
469
|
+
jsonrpc: "2.0",
|
|
470
|
+
id: 10,
|
|
471
|
+
method: "plugin.handle_action",
|
|
472
|
+
params: { action_id: "test-action-id", payload: { message: "hello" } },
|
|
473
|
+
});
|
|
474
|
+
assert.deepEqual(valid, [
|
|
475
|
+
{ jsonrpc: "2.0", id: 10, result: "hello" },
|
|
476
|
+
]);
|
|
477
|
+
|
|
478
|
+
// `id` is ignored; only `action_id` is valid.
|
|
479
|
+
const wrongField = await harness.request({
|
|
480
|
+
jsonrpc: "2.0",
|
|
481
|
+
id: 12,
|
|
482
|
+
method: "plugin.handle_action",
|
|
483
|
+
params: { id: "test-action-id" },
|
|
484
|
+
});
|
|
485
|
+
assert.deepEqual(wrongField, [
|
|
486
|
+
{
|
|
487
|
+
jsonrpc: "2.0",
|
|
488
|
+
id: 12,
|
|
489
|
+
error: {
|
|
490
|
+
code: -32001,
|
|
491
|
+
message: "plugin.handle_action requires params.action_id to be a non-empty string",
|
|
492
|
+
data: {
|
|
493
|
+
code: "plugin-invalid-action-id",
|
|
494
|
+
message: "plugin.handle_action requires params.action_id to be a non-empty string",
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
]);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("plugin.handle_action logs unhandled actions without explicit handler", async () => {
|
|
502
|
+
resetMenuRegistry();
|
|
503
|
+
const harness = createRuntimeHarness({ plugin: {} });
|
|
504
|
+
|
|
505
|
+
const messages = await harness.request({
|
|
506
|
+
jsonrpc: "2.0",
|
|
507
|
+
id: 13,
|
|
508
|
+
method: "plugin.handle_action",
|
|
509
|
+
params: { action_id: "missing-action" },
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
assert.deepEqual(messages, [
|
|
513
|
+
{
|
|
514
|
+
jsonrpc: "2.0",
|
|
515
|
+
method: "host.log",
|
|
516
|
+
params: {
|
|
517
|
+
level: "info",
|
|
518
|
+
target: "openvcs.plugin",
|
|
519
|
+
message: "plugin.handle_action ignored unhandled action_id 'missing-action'",
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
{ jsonrpc: "2.0", id: 13, result: null },
|
|
523
|
+
]);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("bootstrapPluginModule resets menu registry before OnPluginStart", async () => {
|
|
527
|
+
resetMenuRegistry();
|
|
528
|
+
// Pre-populate state that should be cleared.
|
|
529
|
+
createMenu("leak-test", "Leak Test", { surface: "menubar" });
|
|
530
|
+
registerAction("leak-action", () => "leaked");
|
|
531
|
+
|
|
532
|
+
const stdin = new EventEmitter();
|
|
533
|
+
const chunks = [];
|
|
534
|
+
const stdout = {
|
|
535
|
+
write(chunk) {
|
|
536
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
537
|
+
return true;
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
await bootstrapPluginModule({
|
|
542
|
+
modulePath: "./plugin.js",
|
|
543
|
+
transport: { stdin, stdout },
|
|
544
|
+
async importPluginModule() {
|
|
545
|
+
return {
|
|
546
|
+
PluginDefinition: { plugin: {} },
|
|
547
|
+
OnPluginStart() {},
|
|
548
|
+
};
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// Menu from before bootstrap must not be present.
|
|
553
|
+
assert.equal(getMenu("leak-test"), null);
|
|
554
|
+
});
|