@openforge-app/plugin-sdk 0.1.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/dist/backend.d.ts +3 -0
- package/dist/backend.js +3 -0
- package/dist/context.d.ts +14 -0
- package/dist/context.js +30 -0
- package/dist/domain.d.ts +579 -0
- package/dist/domain.js +323 -0
- package/dist/frontend.d.ts +7 -0
- package/dist/frontend.js +9 -0
- package/dist/helpers.d.ts +4 -0
- package/dist/helpers.js +9 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/manifest.d.ts +58 -0
- package/dist/manifest.js +93 -0
- package/dist/markdown.d.ts +5 -0
- package/dist/markdown.js +54 -0
- package/dist/numberParsing.d.ts +1 -0
- package/dist/numberParsing.js +8 -0
- package/dist/openforgePackageMetadataSchema.json +70 -0
- package/dist/prStatusPresentation.d.ts +30 -0
- package/dist/prStatusPresentation.js +151 -0
- package/dist/projectFileTree.d.ts +68 -0
- package/dist/projectFileTree.js +141 -0
- package/dist/sanitize.d.ts +6 -0
- package/dist/sanitize.js +13 -0
- package/dist/svelteHostRuntimeContract.d.mts +18 -0
- package/dist/svelteHostRuntimeContract.mjs +81 -0
- package/dist/testing.d.ts +223 -0
- package/dist/testing.js +625 -0
- package/dist/types.d.ts +350 -0
- package/dist/types.js +22 -0
- package/dist/ui/MarkdownContent.svelte +30 -0
- package/dist/ui/ResizablePanel.svelte +146 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.js +78 -0
- package/package.json +81 -0
package/dist/testing.js
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
export class TestingSubscriptionSink {
|
|
2
|
+
subscriptions = [];
|
|
3
|
+
add(subscription) {
|
|
4
|
+
if (typeof subscription === 'function') {
|
|
5
|
+
this.subscriptions.push(createDisposable(subscription));
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
if (!subscription || typeof subscription.dispose !== 'function') {
|
|
9
|
+
throw new Error('context.subscriptions.add requires a disposable or cleanup function');
|
|
10
|
+
}
|
|
11
|
+
this.subscriptions.push(subscription);
|
|
12
|
+
}
|
|
13
|
+
async disposeAll() {
|
|
14
|
+
const subscriptions = this.subscriptions.splice(0).reverse();
|
|
15
|
+
for (const subscription of subscriptions) {
|
|
16
|
+
await subscription.dispose();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class TestingOpenForgeRegistryFake {
|
|
21
|
+
pluginId;
|
|
22
|
+
projectId;
|
|
23
|
+
taskId;
|
|
24
|
+
viewId;
|
|
25
|
+
packageMetadata;
|
|
26
|
+
calls;
|
|
27
|
+
storage;
|
|
28
|
+
frontendSubscriptions = new TestingSubscriptionSink();
|
|
29
|
+
backendSubscriptions = new TestingSubscriptionSink();
|
|
30
|
+
commands = new Map();
|
|
31
|
+
views = new Map();
|
|
32
|
+
taskPaneTabs = new Map();
|
|
33
|
+
settingsSections = new Map();
|
|
34
|
+
eventListeners = new Map();
|
|
35
|
+
eventHandlers = new Map();
|
|
36
|
+
backendMethods = new Map();
|
|
37
|
+
backgroundServices = new Map();
|
|
38
|
+
claimedIds = new Set();
|
|
39
|
+
config = new Map();
|
|
40
|
+
eventListenerSequence = 0;
|
|
41
|
+
cachedFrontendApi = null;
|
|
42
|
+
cachedBackendApi = null;
|
|
43
|
+
constructor(options = {}) {
|
|
44
|
+
this.pluginId = options.pluginId ?? 'test-plugin';
|
|
45
|
+
this.projectId = options.projectId ?? null;
|
|
46
|
+
this.taskId = options.taskId ?? null;
|
|
47
|
+
this.viewId = options.viewId ?? 'board';
|
|
48
|
+
this.packageMetadata = options.packageMetadata ?? {
|
|
49
|
+
id: this.pluginId,
|
|
50
|
+
apiVersion: 1,
|
|
51
|
+
displayName: this.pluginId,
|
|
52
|
+
description: '',
|
|
53
|
+
};
|
|
54
|
+
this.calls = createTestingCalls();
|
|
55
|
+
this.storage = options.storage ?? createMemoryPluginStorage(this.calls);
|
|
56
|
+
}
|
|
57
|
+
get frontendApi() {
|
|
58
|
+
return this.createFrontendApi();
|
|
59
|
+
}
|
|
60
|
+
get backendApi() {
|
|
61
|
+
return this.createBackendApi();
|
|
62
|
+
}
|
|
63
|
+
get snapshot() {
|
|
64
|
+
return this.getSnapshot();
|
|
65
|
+
}
|
|
66
|
+
createFrontendApi() {
|
|
67
|
+
if (this.cachedFrontendApi)
|
|
68
|
+
return this.cachedFrontendApi;
|
|
69
|
+
const api = {
|
|
70
|
+
...this.createCommonApi(),
|
|
71
|
+
views: {
|
|
72
|
+
register: (registration) => this.registerView(registration),
|
|
73
|
+
},
|
|
74
|
+
taskPane: {
|
|
75
|
+
registerTab: (registration) => this.registerTaskPaneTab(registration),
|
|
76
|
+
},
|
|
77
|
+
settings: {
|
|
78
|
+
registerSection: (registration) => this.registerSettingsSection(registration),
|
|
79
|
+
},
|
|
80
|
+
backend: {
|
|
81
|
+
state: 'ready',
|
|
82
|
+
whenReady: async () => undefined,
|
|
83
|
+
onReady: (handler) => {
|
|
84
|
+
handler();
|
|
85
|
+
return createDisposable(() => undefined);
|
|
86
|
+
},
|
|
87
|
+
invoke: async (method, payload) => this.invokeBackend(method, payload),
|
|
88
|
+
},
|
|
89
|
+
__testing: {
|
|
90
|
+
calls: this.calls,
|
|
91
|
+
registry: this,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
this.cachedFrontendApi = api;
|
|
95
|
+
return api;
|
|
96
|
+
}
|
|
97
|
+
createBackendApi() {
|
|
98
|
+
if (this.cachedBackendApi)
|
|
99
|
+
return this.cachedBackendApi;
|
|
100
|
+
const api = {
|
|
101
|
+
...this.createCommonApi(),
|
|
102
|
+
backend: {
|
|
103
|
+
registerMethod: (method, registration) => this.registerBackendMethod(method, registration),
|
|
104
|
+
},
|
|
105
|
+
background: {
|
|
106
|
+
register: (registration) => this.registerBackgroundService(registration),
|
|
107
|
+
},
|
|
108
|
+
__testing: {
|
|
109
|
+
calls: this.calls,
|
|
110
|
+
registry: this,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
this.cachedBackendApi = api;
|
|
114
|
+
return api;
|
|
115
|
+
}
|
|
116
|
+
createFrontendContext() {
|
|
117
|
+
return this.createContext(this.frontendSubscriptions);
|
|
118
|
+
}
|
|
119
|
+
createBackendContext() {
|
|
120
|
+
return this.createContext(this.backendSubscriptions);
|
|
121
|
+
}
|
|
122
|
+
async activateFrontend(plugin) {
|
|
123
|
+
await plugin.activate(this.frontendApi, this.createFrontendContext());
|
|
124
|
+
}
|
|
125
|
+
async activateBackend(plugin) {
|
|
126
|
+
const existingServices = new Set(this.backgroundServices.keys());
|
|
127
|
+
await plugin.activate(this.backendApi, this.createBackendContext());
|
|
128
|
+
await this.startBackgroundServices(existingServices);
|
|
129
|
+
}
|
|
130
|
+
async disposeAll() {
|
|
131
|
+
await this.backendSubscriptions.disposeAll();
|
|
132
|
+
await this.frontendSubscriptions.disposeAll();
|
|
133
|
+
}
|
|
134
|
+
getSnapshot() {
|
|
135
|
+
return {
|
|
136
|
+
pluginId: this.pluginId,
|
|
137
|
+
projectId: this.projectId,
|
|
138
|
+
views: Array.from(this.views.values()),
|
|
139
|
+
taskPaneTabs: Array.from(this.taskPaneTabs.values()),
|
|
140
|
+
settingsSections: Array.from(this.settingsSections.values()),
|
|
141
|
+
commands: Array.from(this.commands.values()),
|
|
142
|
+
eventListeners: Array.from(this.eventListeners.values()),
|
|
143
|
+
backendMethods: Array.from(this.backendMethods.values()),
|
|
144
|
+
backgroundServices: Array.from(this.backgroundServices.values()),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
createContext(subscriptions) {
|
|
148
|
+
return {
|
|
149
|
+
pluginId: this.pluginId,
|
|
150
|
+
apiVersion: 1,
|
|
151
|
+
packageMetadata: this.packageMetadata,
|
|
152
|
+
subscriptions,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
startPromptContributions(projectId) {
|
|
156
|
+
const raw = this.config.get(`project:${projectId}:start_prompt_contributions`);
|
|
157
|
+
return Array.isArray(raw) ? raw.filter((entry) => Boolean(entry) && typeof entry === 'object' && typeof entry.id === 'string' && typeof entry.content === 'string') : [];
|
|
158
|
+
}
|
|
159
|
+
createCommonApi() {
|
|
160
|
+
return {
|
|
161
|
+
commands: {
|
|
162
|
+
register: (registration) => this.registerCommand(registration),
|
|
163
|
+
invoke: async (id, payload) => this.invokeCommand(id, payload),
|
|
164
|
+
invokeGlobal: async (qualifiedId, payload) => this.invokeGlobalCommand(qualifiedId, payload),
|
|
165
|
+
list: async () => Array.from(this.commands.values()).map(commandDescriptor),
|
|
166
|
+
},
|
|
167
|
+
events: {
|
|
168
|
+
on: (event, handler) => this.registerEventListener(event, handler, false),
|
|
169
|
+
onGlobal: (qualifiedEvent, handler) => this.registerEventListener(qualifiedEvent, handler, true),
|
|
170
|
+
emit: async (event, payload) => this.emitEvent(event, payload, false),
|
|
171
|
+
emitGlobal: async (qualifiedEvent, payload) => this.emitEvent(qualifiedEvent, payload, true),
|
|
172
|
+
},
|
|
173
|
+
storage: this.storage,
|
|
174
|
+
context: {
|
|
175
|
+
getSnapshot: () => this.getContextSnapshot(),
|
|
176
|
+
},
|
|
177
|
+
tasks: {
|
|
178
|
+
list: async () => [],
|
|
179
|
+
get: async (taskId) => { throw new Error(`Mock task not found: ${taskId}`); },
|
|
180
|
+
create: async (request) => {
|
|
181
|
+
this.calls.taskCreations.push(request);
|
|
182
|
+
return {
|
|
183
|
+
id: `mock-task-${this.calls.taskCreations.length}`,
|
|
184
|
+
initial_prompt: request.initialPrompt,
|
|
185
|
+
status: 'backlog',
|
|
186
|
+
prompt: null,
|
|
187
|
+
title: null,
|
|
188
|
+
title_source: null,
|
|
189
|
+
title_generated_at: null,
|
|
190
|
+
summary: null,
|
|
191
|
+
agent: null,
|
|
192
|
+
permission_mode: null,
|
|
193
|
+
worktree_source: null,
|
|
194
|
+
worktree_branch: null,
|
|
195
|
+
handoff_notes_enabled: true,
|
|
196
|
+
depends_on: request.dependsOn ?? [],
|
|
197
|
+
project_id: request.projectId,
|
|
198
|
+
created_at: 0,
|
|
199
|
+
updated_at: 0,
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
updateSummary: async (taskId, summary) => {
|
|
203
|
+
this.calls.taskSummaryUpdates.push({ taskId, summary });
|
|
204
|
+
},
|
|
205
|
+
updateStatus: async (taskId, status) => {
|
|
206
|
+
this.calls.taskStatusUpdates.push({ taskId, status });
|
|
207
|
+
},
|
|
208
|
+
listStartPromptContributions: async (projectId) => this.startPromptContributions(projectId),
|
|
209
|
+
configureStartPromptContribution: async (request) => {
|
|
210
|
+
this.calls.startPromptContributionConfigurations.push(request);
|
|
211
|
+
const existing = this.startPromptContributions(request.projectId).filter((entry) => entry.id !== request.id);
|
|
212
|
+
const next = [...existing, request].sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id));
|
|
213
|
+
this.config.set(`project:${request.projectId}:start_prompt_contributions`, next);
|
|
214
|
+
return next;
|
|
215
|
+
},
|
|
216
|
+
startImplementation: async (request) => {
|
|
217
|
+
this.calls.taskImplementationStarts.push(request);
|
|
218
|
+
return {
|
|
219
|
+
taskId: request.taskId,
|
|
220
|
+
workspacePath: '/mock-workspace',
|
|
221
|
+
sessionId: 'mock-session',
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
getWorkspace: async () => null,
|
|
225
|
+
getLatestSession: async () => null,
|
|
226
|
+
},
|
|
227
|
+
projects: {
|
|
228
|
+
list: async () => [],
|
|
229
|
+
get: async () => null,
|
|
230
|
+
},
|
|
231
|
+
fs: {
|
|
232
|
+
readDir: async () => [],
|
|
233
|
+
readFile: async () => ({ type: 'text', content: '', mimeType: null, size: 0 }),
|
|
234
|
+
writeFile: async (request) => {
|
|
235
|
+
this.calls.fsWrites.push(request);
|
|
236
|
+
},
|
|
237
|
+
searchFiles: async () => [],
|
|
238
|
+
},
|
|
239
|
+
shell: {
|
|
240
|
+
spawn: async (request) => {
|
|
241
|
+
this.calls.shellSpawns.push(request);
|
|
242
|
+
return 0;
|
|
243
|
+
},
|
|
244
|
+
write: async (request) => {
|
|
245
|
+
this.calls.shellWrites.push(request);
|
|
246
|
+
},
|
|
247
|
+
resize: async (request) => {
|
|
248
|
+
this.calls.shellResizes.push(request);
|
|
249
|
+
},
|
|
250
|
+
kill: async (request) => {
|
|
251
|
+
this.calls.shellKills.push(request);
|
|
252
|
+
},
|
|
253
|
+
getBuffer: async (request) => {
|
|
254
|
+
this.calls.shellBuffers.push(request);
|
|
255
|
+
return null;
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
notifications: {
|
|
259
|
+
notify: async (request) => {
|
|
260
|
+
this.calls.notify.push(request);
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
attention: {
|
|
264
|
+
listProjects: async () => [],
|
|
265
|
+
},
|
|
266
|
+
system: {
|
|
267
|
+
openUrl: async (url) => {
|
|
268
|
+
this.calls.openUrl.push(url);
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
navigation: {
|
|
272
|
+
get: () => this.getNavigationSnapshot(),
|
|
273
|
+
navigate: async (request) => {
|
|
274
|
+
this.calls.navigationRequests.push(request);
|
|
275
|
+
return this.getNavigationSnapshot(request);
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
config: {
|
|
279
|
+
get: async (key) => this.config.has(`global:${key}`) ? this.config.get(`global:${key}`) : null,
|
|
280
|
+
set: async (key, value) => {
|
|
281
|
+
this.config.set(`global:${key}`, value);
|
|
282
|
+
this.calls.configWrites.push({ key, value, projectId: null });
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
projectConfig: {
|
|
286
|
+
get: async (key, projectId = this.projectId ?? '') => this.config.has(`project:${projectId}:${key}`) ? this.config.get(`project:${projectId}:${key}`) : null,
|
|
287
|
+
set: async (key, value, projectId = this.projectId ?? '') => {
|
|
288
|
+
this.config.set(`project:${projectId}:${key}`, value);
|
|
289
|
+
this.calls.configWrites.push({ key, value, projectId });
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
getContextSnapshot() {
|
|
295
|
+
return {
|
|
296
|
+
pluginId: this.pluginId,
|
|
297
|
+
projectId: this.projectId,
|
|
298
|
+
...(this.taskId === null ? {} : { taskId: this.taskId }),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
getNavigationSnapshot(overrides = {}) {
|
|
302
|
+
return {
|
|
303
|
+
activeProjectId: overrides.projectId ?? this.projectId,
|
|
304
|
+
currentView: overrides.viewId ?? this.viewId,
|
|
305
|
+
selectedTaskId: overrides.taskId ?? this.taskId,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
localQualifiedId(kind, id) {
|
|
309
|
+
assertLocalId(kind, id);
|
|
310
|
+
return `${this.pluginId}.${id.trim()}`;
|
|
311
|
+
}
|
|
312
|
+
claim(kind, qualifiedId) {
|
|
313
|
+
const key = kind === 'commands' ? `commands:${qualifiedId}` : `${kind}:${qualifiedId}`;
|
|
314
|
+
if (this.claimedIds.has(key)) {
|
|
315
|
+
throw new Error(`Duplicate runtime contribution id: ${qualifiedId}`);
|
|
316
|
+
}
|
|
317
|
+
this.claimedIds.add(key);
|
|
318
|
+
}
|
|
319
|
+
release(kind, qualifiedId) {
|
|
320
|
+
const key = kind === 'commands' ? `commands:${qualifiedId}` : `${kind}:${qualifiedId}`;
|
|
321
|
+
this.claimedIds.delete(key);
|
|
322
|
+
}
|
|
323
|
+
registerCommand(registration) {
|
|
324
|
+
const qualifiedId = this.localQualifiedId('commands', registration.id);
|
|
325
|
+
assertTitle('commands', registration.title);
|
|
326
|
+
assertFunction('commands', 'handler', registration.handler);
|
|
327
|
+
this.claim('commands', qualifiedId);
|
|
328
|
+
const contribution = {
|
|
329
|
+
...registration,
|
|
330
|
+
id: registration.id.trim(),
|
|
331
|
+
title: registration.title.trim(),
|
|
332
|
+
qualifiedId,
|
|
333
|
+
pluginId: this.pluginId,
|
|
334
|
+
projectId: this.projectId,
|
|
335
|
+
handler: registration.handler,
|
|
336
|
+
};
|
|
337
|
+
this.commands.set(qualifiedId, contribution);
|
|
338
|
+
return createDisposable(() => {
|
|
339
|
+
this.commands.delete(qualifiedId);
|
|
340
|
+
this.release('commands', qualifiedId);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
registerView(registration) {
|
|
344
|
+
const qualifiedId = this.localQualifiedId('views', registration.id);
|
|
345
|
+
assertTitle('views', registration.title);
|
|
346
|
+
assertFunction('views', 'component', registration.component);
|
|
347
|
+
this.claim('views', qualifiedId);
|
|
348
|
+
const contribution = {
|
|
349
|
+
...registration,
|
|
350
|
+
id: registration.id.trim(),
|
|
351
|
+
title: registration.title.trim(),
|
|
352
|
+
qualifiedId,
|
|
353
|
+
pluginId: this.pluginId,
|
|
354
|
+
projectId: this.projectId,
|
|
355
|
+
};
|
|
356
|
+
this.views.set(qualifiedId, contribution);
|
|
357
|
+
return createDisposable(() => {
|
|
358
|
+
this.views.delete(qualifiedId);
|
|
359
|
+
this.release('views', qualifiedId);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
registerTaskPaneTab(registration) {
|
|
363
|
+
const qualifiedId = this.localQualifiedId('taskPane', registration.id);
|
|
364
|
+
assertTitle('taskPane', registration.title);
|
|
365
|
+
assertFunction('taskPane', 'component', registration.component);
|
|
366
|
+
this.claim('taskPane', qualifiedId);
|
|
367
|
+
const contribution = {
|
|
368
|
+
...registration,
|
|
369
|
+
id: registration.id.trim(),
|
|
370
|
+
title: registration.title.trim(),
|
|
371
|
+
qualifiedId,
|
|
372
|
+
pluginId: this.pluginId,
|
|
373
|
+
projectId: this.projectId,
|
|
374
|
+
};
|
|
375
|
+
this.taskPaneTabs.set(qualifiedId, contribution);
|
|
376
|
+
return createDisposable(() => {
|
|
377
|
+
this.taskPaneTabs.delete(qualifiedId);
|
|
378
|
+
this.release('taskPane', qualifiedId);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
registerSettingsSection(registration) {
|
|
382
|
+
const qualifiedId = this.localQualifiedId('settings', registration.id);
|
|
383
|
+
assertTitle('settings', registration.title);
|
|
384
|
+
assertFunction('settings', 'component', registration.component);
|
|
385
|
+
this.claim('settings', qualifiedId);
|
|
386
|
+
const contribution = {
|
|
387
|
+
...registration,
|
|
388
|
+
id: registration.id.trim(),
|
|
389
|
+
title: registration.title.trim(),
|
|
390
|
+
qualifiedId,
|
|
391
|
+
pluginId: this.pluginId,
|
|
392
|
+
projectId: this.projectId,
|
|
393
|
+
};
|
|
394
|
+
this.settingsSections.set(qualifiedId, contribution);
|
|
395
|
+
return createDisposable(() => {
|
|
396
|
+
this.settingsSections.delete(qualifiedId);
|
|
397
|
+
this.release('settings', qualifiedId);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
registerBackendMethod(method, registration) {
|
|
401
|
+
const qualifiedId = this.localQualifiedId('backend', method);
|
|
402
|
+
assertFunction('backend', 'handler', registration.handler);
|
|
403
|
+
this.claim('backend', qualifiedId);
|
|
404
|
+
const contribution = {
|
|
405
|
+
id: method.trim(),
|
|
406
|
+
qualifiedId,
|
|
407
|
+
pluginId: this.pluginId,
|
|
408
|
+
projectId: this.projectId,
|
|
409
|
+
registration,
|
|
410
|
+
};
|
|
411
|
+
this.backendMethods.set(qualifiedId, contribution);
|
|
412
|
+
return createDisposable(() => {
|
|
413
|
+
this.backendMethods.delete(qualifiedId);
|
|
414
|
+
this.release('backend', qualifiedId);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
registerBackgroundService(registration) {
|
|
418
|
+
const qualifiedId = this.localQualifiedId('background', registration.id);
|
|
419
|
+
if (registration.scope !== 'global' && registration.scope !== 'project' && registration.scope !== 'task') {
|
|
420
|
+
throw new Error('background registration requires scope to be global, project, or task');
|
|
421
|
+
}
|
|
422
|
+
assertFunction('background', 'start', registration.start);
|
|
423
|
+
this.claim('background', qualifiedId);
|
|
424
|
+
const contribution = {
|
|
425
|
+
...registration,
|
|
426
|
+
id: registration.id.trim(),
|
|
427
|
+
qualifiedId,
|
|
428
|
+
pluginId: this.pluginId,
|
|
429
|
+
projectId: this.projectId,
|
|
430
|
+
started: false,
|
|
431
|
+
};
|
|
432
|
+
this.backgroundServices.set(qualifiedId, contribution);
|
|
433
|
+
return createDisposable(async () => {
|
|
434
|
+
this.backgroundServices.delete(qualifiedId);
|
|
435
|
+
this.release('background', qualifiedId);
|
|
436
|
+
if (contribution.started) {
|
|
437
|
+
await contribution.stop?.();
|
|
438
|
+
contribution.started = false;
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
registerEventListener(event, handler, global) {
|
|
443
|
+
const qualifiedId = global ? event : this.localQualifiedId('events', event);
|
|
444
|
+
if (qualifiedId.trim().length === 0) {
|
|
445
|
+
throw new Error('events registration requires a non-empty id');
|
|
446
|
+
}
|
|
447
|
+
assertFunction('events', 'handler', handler);
|
|
448
|
+
const handlers = this.eventHandlers.get(qualifiedId) ?? new Set();
|
|
449
|
+
handlers.add(handler);
|
|
450
|
+
this.eventHandlers.set(qualifiedId, handlers);
|
|
451
|
+
const listenerKey = `${qualifiedId}#${++this.eventListenerSequence}`;
|
|
452
|
+
const contribution = {
|
|
453
|
+
id: event,
|
|
454
|
+
qualifiedId,
|
|
455
|
+
pluginId: this.pluginId,
|
|
456
|
+
projectId: this.projectId,
|
|
457
|
+
handler,
|
|
458
|
+
global,
|
|
459
|
+
};
|
|
460
|
+
this.eventListeners.set(listenerKey, contribution);
|
|
461
|
+
return createDisposable(() => {
|
|
462
|
+
handlers.delete(handler);
|
|
463
|
+
if (handlers.size === 0) {
|
|
464
|
+
this.eventHandlers.delete(qualifiedId);
|
|
465
|
+
}
|
|
466
|
+
this.eventListeners.delete(listenerKey);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
async startBackgroundServices(existingServices) {
|
|
470
|
+
for (const [key, service] of this.backgroundServices.entries()) {
|
|
471
|
+
if (existingServices.has(key) || service.started)
|
|
472
|
+
continue;
|
|
473
|
+
await service.start();
|
|
474
|
+
service.started = true;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
async invokeCommand(id, payload) {
|
|
478
|
+
const qualifiedId = this.localQualifiedId('commands', id);
|
|
479
|
+
this.calls.commandInvocations.push({ id, qualifiedId, payload });
|
|
480
|
+
return this.invokeGlobalCommand(qualifiedId, payload);
|
|
481
|
+
}
|
|
482
|
+
async invokeGlobalCommand(qualifiedId, payload) {
|
|
483
|
+
this.calls.globalCommandInvocations.push({ qualifiedId, payload });
|
|
484
|
+
const command = this.commands.get(qualifiedId);
|
|
485
|
+
if (!command) {
|
|
486
|
+
throw new Error(`Unknown command: ${qualifiedId}`);
|
|
487
|
+
}
|
|
488
|
+
return await command.handler(payload);
|
|
489
|
+
}
|
|
490
|
+
async invokeBackend(method, payload) {
|
|
491
|
+
const qualifiedId = this.localQualifiedId('backend', method);
|
|
492
|
+
this.calls.backendInvocations.push({ method, qualifiedId, payload });
|
|
493
|
+
const contribution = this.backendMethods.get(qualifiedId);
|
|
494
|
+
if (!contribution) {
|
|
495
|
+
throw new Error(`Backend method is not registered: ${qualifiedId}`);
|
|
496
|
+
}
|
|
497
|
+
return await contribution.registration.handler(payload);
|
|
498
|
+
}
|
|
499
|
+
async emitEvent(event, payload, global) {
|
|
500
|
+
const qualifiedEvent = global ? event : this.localQualifiedId('events', event);
|
|
501
|
+
if (global) {
|
|
502
|
+
this.calls.emittedGlobalEvents.push({ qualifiedEvent, payload });
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
this.calls.emittedEvents.push({ event, qualifiedEvent, payload });
|
|
506
|
+
}
|
|
507
|
+
for (const handler of Array.from(this.eventHandlers.get(qualifiedEvent) ?? [])) {
|
|
508
|
+
handler(payload);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
export function createOpenForgeRegistryFake(options = {}) {
|
|
513
|
+
return new TestingOpenForgeRegistryFake(options);
|
|
514
|
+
}
|
|
515
|
+
export function createMockOpenForgeApi(options = {}) {
|
|
516
|
+
return createMockFrontendOpenForgeApi(options);
|
|
517
|
+
}
|
|
518
|
+
export function createMockFrontendOpenForgeApi(options = {}) {
|
|
519
|
+
return createOpenForgeRegistryFake(options).frontendApi;
|
|
520
|
+
}
|
|
521
|
+
export function createMockBackendOpenForgeApi(options = {}) {
|
|
522
|
+
return createOpenForgeRegistryFake(options).backendApi;
|
|
523
|
+
}
|
|
524
|
+
export function createMockPluginContext(options = {}) {
|
|
525
|
+
return createOpenForgeRegistryFake(options).createFrontendContext();
|
|
526
|
+
}
|
|
527
|
+
export function createMemoryPluginStorage(calls = createTestingCalls()) {
|
|
528
|
+
const values = new Map();
|
|
529
|
+
function scope(scopeKind, scopeId) {
|
|
530
|
+
const prefix = `${scopeKind}:${scopeId ?? ''}:`;
|
|
531
|
+
return {
|
|
532
|
+
async get(key) {
|
|
533
|
+
calls.storageGets.push({ scope: scopeKind, scopeId, key });
|
|
534
|
+
return values.has(`${prefix}${key}`) ? values.get(`${prefix}${key}`) : null;
|
|
535
|
+
},
|
|
536
|
+
async set(key, value) {
|
|
537
|
+
values.set(`${prefix}${key}`, value);
|
|
538
|
+
calls.storageSets.push({ scope: scopeKind, scopeId, key, value });
|
|
539
|
+
},
|
|
540
|
+
async delete(key) {
|
|
541
|
+
values.delete(`${prefix}${key}`);
|
|
542
|
+
calls.storageDeletes.push({ scope: scopeKind, scopeId, key });
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
global: scope('global', null),
|
|
548
|
+
project: (projectId) => scope('project', projectId),
|
|
549
|
+
task: (taskId) => scope('task', taskId),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
export function createTestingCalls() {
|
|
553
|
+
return {
|
|
554
|
+
commandInvocations: [],
|
|
555
|
+
globalCommandInvocations: [],
|
|
556
|
+
backendInvocations: [],
|
|
557
|
+
emittedEvents: [],
|
|
558
|
+
emittedGlobalEvents: [],
|
|
559
|
+
openUrl: [],
|
|
560
|
+
navigationRequests: [],
|
|
561
|
+
notify: [],
|
|
562
|
+
taskCreations: [],
|
|
563
|
+
startPromptContributionConfigurations: [],
|
|
564
|
+
taskImplementationStarts: [],
|
|
565
|
+
taskSummaryUpdates: [],
|
|
566
|
+
taskStatusUpdates: [],
|
|
567
|
+
configWrites: [],
|
|
568
|
+
fsWrites: [],
|
|
569
|
+
shellSpawns: [],
|
|
570
|
+
shellWrites: [],
|
|
571
|
+
shellResizes: [],
|
|
572
|
+
shellKills: [],
|
|
573
|
+
shellBuffers: [],
|
|
574
|
+
storageGets: [],
|
|
575
|
+
storageSets: [],
|
|
576
|
+
storageDeletes: [],
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
function commandDescriptor(command) {
|
|
580
|
+
return {
|
|
581
|
+
id: command.id,
|
|
582
|
+
qualifiedId: command.qualifiedId,
|
|
583
|
+
pluginId: command.pluginId,
|
|
584
|
+
projectId: command.projectId,
|
|
585
|
+
title: command.title,
|
|
586
|
+
icon: command.icon,
|
|
587
|
+
shortcut: command.shortcut,
|
|
588
|
+
discoverable: command.discoverable ?? true,
|
|
589
|
+
input: command.input,
|
|
590
|
+
output: command.output,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
function createDisposable(dispose) {
|
|
594
|
+
let disposed = false;
|
|
595
|
+
return {
|
|
596
|
+
async dispose() {
|
|
597
|
+
if (disposed)
|
|
598
|
+
return;
|
|
599
|
+
disposed = true;
|
|
600
|
+
await dispose();
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
function assertLocalId(kind, id) {
|
|
605
|
+
if (typeof id !== 'string' || id.trim().length === 0) {
|
|
606
|
+
throw new Error(`${kind} registration requires a non-empty id`);
|
|
607
|
+
}
|
|
608
|
+
const trimmed = id.trim();
|
|
609
|
+
if (trimmed.startsWith('openforge.')) {
|
|
610
|
+
throw new Error(`${kind} registration cannot use openforge.* reserved namespace`);
|
|
611
|
+
}
|
|
612
|
+
if (trimmed.includes(':') || trimmed.startsWith('.') || trimmed.endsWith('.') || trimmed.includes('..')) {
|
|
613
|
+
throw new Error(`${kind} registration has invalid id "${trimmed}"`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
function assertTitle(kind, title) {
|
|
617
|
+
if (typeof title !== 'string' || title.trim().length === 0) {
|
|
618
|
+
throw new Error(`${kind} registration requires a non-empty title`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
function assertFunction(kind, field, value) {
|
|
622
|
+
if (typeof value !== 'function') {
|
|
623
|
+
throw new Error(`${kind} registration requires a ${field} function`);
|
|
624
|
+
}
|
|
625
|
+
}
|