@dolphy-app/extension-sdk 0.4.0 → 0.5.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/README.md +28 -14
- package/dist/client.d.ts +40 -0
- package/dist/client.js +67 -0
- package/dist/define-entry-BTz2F3qv.js +22 -0
- package/dist/define-entry-lsuxzKdD.d.ts +77 -0
- package/dist/index-I_CXC6Lj.d.ts +1958 -0
- package/dist/index.d.ts +7 -111
- package/dist/index.js +5 -86
- package/dist/react.d.ts +51 -0
- package/dist/react.js +112 -0
- package/dist/rpc.d.ts +28 -0
- package/dist/rpc.js +37 -0
- package/dist/testing.d.ts +184 -241
- package/dist/testing.js +574 -464
- package/docs/debugging.md +82 -64
- package/docs/no-build.md +84 -48
- package/docs/quick-start.md +78 -56
- package/docs/recipe-command-panel.md +262 -118
- package/docs/recipe-event-storage.md +188 -128
- package/docs/recipe-exercise-type.md +271 -183
- package/docs/recipe-hooks.md +158 -0
- package/docs/recipe-import-export.md +48 -53
- package/docs/recipe-mountable.md +322 -0
- package/docs/recipe-react.md +352 -0
- package/docs/recipe-rpc-and-app.md +366 -0
- package/docs/recipe-settings.md +122 -100
- package/docs/recipe-theme.md +72 -40
- package/docs/recipe-when-dependencies.md +188 -82
- package/package.json +29 -7
- package/dist/answer-element-BOQcuxYh.js +0 -114
- package/dist/answer-view-D6wnyThb.d.ts +0 -28
- package/dist/runtime.d.ts +0 -17
- package/dist/runtime.js +0 -24
- package/docs/recipe-ui-kit.md +0 -172
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# Recipe: calls between the parts, the engine and the window
|
|
2
|
+
|
|
3
|
+
A component of the client part can ask the server part for something, call the
|
|
4
|
+
engine itself and drive the window: open a course, show a toast, mount a
|
|
5
|
+
component into an element. This recipe builds a panel that lists the courses
|
|
6
|
+
and records an attempt, with `defineRpc`, `useRpc`, `useEngine`, `useApp` and
|
|
7
|
+
`server.engine`. There is no template for it; add the pieces to any project
|
|
8
|
+
(see [quick-start.md](quick-start.md)). The contracts use `zod`, so the project
|
|
9
|
+
lists `zod` in its `dependencies`; the build puts it into the bundles. The
|
|
10
|
+
command and panel basics are in [recipe-command-panel.md](recipe-command-panel.md).
|
|
11
|
+
|
|
12
|
+
## The manifest
|
|
13
|
+
|
|
14
|
+
File `extension.json` (rpc-and-app):
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"id": "acme.direct",
|
|
19
|
+
"version": "1.0.0",
|
|
20
|
+
"apiVersion": 1
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## The contracts
|
|
25
|
+
|
|
26
|
+
File `src/index.ts` (rpc-and-app):
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
export { client } from './client.ts';
|
|
30
|
+
export { server } from './server.ts';
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
File `src/shared/rpc.ts` (rpc-and-app):
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { defineRpc } from '@dolphy-app/extension-sdk';
|
|
37
|
+
import { z } from 'zod';
|
|
38
|
+
|
|
39
|
+
// imported by both parts: the name and the schemas of a call
|
|
40
|
+
export const courseNames = defineRpc({
|
|
41
|
+
name: 'courses.names',
|
|
42
|
+
input: z.object({}),
|
|
43
|
+
output: z.object({ names: z.array(z.string()) }),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const markKnown = defineRpc({
|
|
47
|
+
name: 'attempts.mark-known',
|
|
48
|
+
input: z.object({ exerciseId: z.string().min(1) }),
|
|
49
|
+
output: z.object({ eventId: z.string() }),
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- `defineRpc({ name, input, output })` returns the contract as is. `name` is
|
|
54
|
+
lower-case segments separated by dots, at least two, up to 120 characters
|
|
55
|
+
(`greeting.say-hello`); a malformed name throws. `input` and `output` are
|
|
56
|
+
`zod` schemas. `defineRpc` does not import `vue`, so server code imports it
|
|
57
|
+
too (it is also exported from `@dolphy-app/extension-sdk/rpc`).
|
|
58
|
+
- Both sides validate: `useRpc` checks the input before the call and the
|
|
59
|
+
answer after it, the server checks the input before the handler and the
|
|
60
|
+
result after it. The data crosses the process boundary as JSON, so keep it
|
|
61
|
+
plain values (at most 200 000 characters of input).
|
|
62
|
+
|
|
63
|
+
## The server part
|
|
64
|
+
|
|
65
|
+
File `src/server.ts` (rpc-and-app):
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { defineServer } from '@dolphy-app/extension-sdk';
|
|
69
|
+
import { courseNames, markKnown } from './shared/rpc.ts';
|
|
70
|
+
|
|
71
|
+
export const server = defineServer((s) => {
|
|
72
|
+
s.handle(courseNames, async () => {
|
|
73
|
+
const page = await s.engine.library.listCourses();
|
|
74
|
+
return { names: page.items.map((course) => course.name) };
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
s.handle(markKnown, async ({ exerciseId }) => {
|
|
78
|
+
const result = await s.engine.practice.recordAttempt({
|
|
79
|
+
requestId: crypto.randomUUID(),
|
|
80
|
+
exerciseId,
|
|
81
|
+
grade: 5,
|
|
82
|
+
});
|
|
83
|
+
return { eventId: result.eventId };
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- `server.handle(contract, handler)` answers a contract: one handler per name,
|
|
89
|
+
at most 64 per extension. The handler has 10 seconds. An error it throws
|
|
90
|
+
reaches the component with its message.
|
|
91
|
+
- `server.engine` is the engine client (`ExtensionEngine`): every method of the
|
|
92
|
+
engine contract, writing ones included, and `subscribe` for the engine
|
|
93
|
+
events, but not `close`. A write goes into the same log as the window's own
|
|
94
|
+
writes, so record only what the learner really did.
|
|
95
|
+
|
|
96
|
+
## The client part
|
|
97
|
+
|
|
98
|
+
File `src/client.ts` (rpc-and-app):
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { defineClient } from '@dolphy-app/extension-sdk';
|
|
102
|
+
import { Panel } from './panel.ts';
|
|
103
|
+
|
|
104
|
+
export const client = defineClient((c) => {
|
|
105
|
+
c.addPanel({
|
|
106
|
+
id: 'acme.direct.view',
|
|
107
|
+
title: { en: 'Direct', ru: 'Прямой доступ' },
|
|
108
|
+
component: Panel,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
File `src/panel.ts` (rpc-and-app):
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { useApp, useEngine, useRpc } from '@dolphy-app/extension-sdk/client';
|
|
117
|
+
import { defineComponent, h, ref } from 'vue';
|
|
118
|
+
import { courseNames, markKnown } from './shared/rpc.ts';
|
|
119
|
+
|
|
120
|
+
export const Panel = defineComponent({
|
|
121
|
+
setup() {
|
|
122
|
+
const app = useApp();
|
|
123
|
+
const engine = useEngine();
|
|
124
|
+
const loadNames = useRpc(courseNames);
|
|
125
|
+
const mark = useRpc(markKnown);
|
|
126
|
+
const names = ref<string[]>([]);
|
|
127
|
+
|
|
128
|
+
const report = (error: unknown) =>
|
|
129
|
+
app.notify(
|
|
130
|
+
error instanceof Error ? error.message : String(error),
|
|
131
|
+
'error',
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// through the server part: `courses.names`
|
|
135
|
+
const loadFromServer = async () => {
|
|
136
|
+
try {
|
|
137
|
+
names.value = (await loadNames({})).names;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
report(error);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// straight from the component: the same engine client
|
|
144
|
+
const loadHere = async () => {
|
|
145
|
+
try {
|
|
146
|
+
const page = await engine.library.listCourses();
|
|
147
|
+
names.value = page.items.map((course) => course.name);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
report(error);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const markExercise = async (exerciseId: string) => {
|
|
154
|
+
try {
|
|
155
|
+
await mark({ exerciseId });
|
|
156
|
+
app.notify('Recorded');
|
|
157
|
+
} catch (error) {
|
|
158
|
+
report(error);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return () =>
|
|
163
|
+
h('div', [
|
|
164
|
+
h(
|
|
165
|
+
'button',
|
|
166
|
+
{ 'data-role': 'server', onClick: loadFromServer },
|
|
167
|
+
'Server',
|
|
168
|
+
),
|
|
169
|
+
h('button', { 'data-role': 'here', onClick: loadHere }, 'Here'),
|
|
170
|
+
h(
|
|
171
|
+
'button',
|
|
172
|
+
{ 'data-role': 'mark', onClick: () => markExercise('') },
|
|
173
|
+
'Mark with an empty id',
|
|
174
|
+
),
|
|
175
|
+
h(
|
|
176
|
+
'ul',
|
|
177
|
+
names.value.map((name) => h('li', name)),
|
|
178
|
+
),
|
|
179
|
+
]);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
- `useRpc(contract)` is called in `setup` and returns `(input) => Promise<output>`.
|
|
185
|
+
A schema violation, an error of the handler and an unavailable server reject
|
|
186
|
+
the promise with an `Error` that carries the message.
|
|
187
|
+
- `useEngine()` is the client of the window itself: `engine.library`,
|
|
188
|
+
`engine.practice`, `engine.settings`, … and `engine.subscribe`.
|
|
189
|
+
- `useApp()` is a fixed list of window capabilities: `openCourse(courseId)`,
|
|
190
|
+
`openLesson(courseId, lessonId)`, `openExercise(courseId, lessonId, exerciseId)`,
|
|
191
|
+
`openPanel(extensionId, panelId, props?)`, `openSettings(extensionId?)`,
|
|
192
|
+
`notify(message, kind?)`, the reactive `theme` (`{ id, dark }`) and `locale`
|
|
193
|
+
(`'en' | 'ru'`), `runCommand(commandKey)` for a palette command
|
|
194
|
+
(`extension:<extension id>:<command id>`) and `mountAt(target, component,
|
|
195
|
+
props?)`. The window's stores and router are not reachable.
|
|
196
|
+
- `app.mountAt('#some-element', Component, props)` mounts a component into an
|
|
197
|
+
element of the window (a CSS selector is resolved once, at the call; a
|
|
198
|
+
missing element throws) and returns a `Disposable`. The component gets
|
|
199
|
+
Vuetify, the theme, `useApp()`, `useEngine()` and `useRpc()` like a panel.
|
|
200
|
+
For a component that follows the DOM, use `client.addInjection`.
|
|
201
|
+
- All three work only in a component the app draws; elsewhere they throw.
|
|
202
|
+
|
|
203
|
+
## The tests
|
|
204
|
+
|
|
205
|
+
File `test/index.test.ts` (rpc-and-app):
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
// @vitest-environment happy-dom
|
|
209
|
+
import {
|
|
210
|
+
APP_KEY,
|
|
211
|
+
ENGINE_KEY,
|
|
212
|
+
EXTENSION_ID_KEY,
|
|
213
|
+
} from '@dolphy-app/extension-sdk';
|
|
214
|
+
import type { AppApi, ExtensionEngine } from '@dolphy-app/extension-sdk';
|
|
215
|
+
import {
|
|
216
|
+
createTestClient,
|
|
217
|
+
createTestServer,
|
|
218
|
+
} from '@dolphy-app/extension-sdk/testing';
|
|
219
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
220
|
+
import { createApp, h, nextTick } from 'vue';
|
|
221
|
+
import { client, server } from '../src/index.ts';
|
|
222
|
+
import { Panel } from '../src/panel.ts';
|
|
223
|
+
import { courseNames, markKnown } from '../src/shared/rpc.ts';
|
|
224
|
+
|
|
225
|
+
const disposables: { dispose(): unknown }[] = [];
|
|
226
|
+
afterEach(async () => {
|
|
227
|
+
await Promise.all(disposables.splice(0).map((item) => item.dispose()));
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// only the methods the code under test calls
|
|
231
|
+
const recorded: unknown[] = [];
|
|
232
|
+
const engine = {
|
|
233
|
+
library: {
|
|
234
|
+
listCourses: async () => ({ items: [{ name: 'Git' }, { name: 'SQL' }] }),
|
|
235
|
+
},
|
|
236
|
+
practice: {
|
|
237
|
+
recordAttempt: async (request: unknown) => {
|
|
238
|
+
recorded.push(request);
|
|
239
|
+
return { eventId: 'event-1' };
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
} as unknown as ExtensionEngine;
|
|
243
|
+
|
|
244
|
+
describe('acme.direct: server', () => {
|
|
245
|
+
it('answers the contracts through the engine', async () => {
|
|
246
|
+
const running = await createTestServer(server, {
|
|
247
|
+
extensionId: 'acme.direct',
|
|
248
|
+
engine,
|
|
249
|
+
});
|
|
250
|
+
disposables.push(running);
|
|
251
|
+
expect(running.registration.rpcs).toEqual([
|
|
252
|
+
'courses.names',
|
|
253
|
+
'attempts.mark-known',
|
|
254
|
+
]);
|
|
255
|
+
expect(await running.rpc(courseNames, {})).toEqual({
|
|
256
|
+
names: ['Git', 'SQL'],
|
|
257
|
+
});
|
|
258
|
+
expect(await running.rpc(markKnown, { exerciseId: 'git::a::q1' })).toEqual({
|
|
259
|
+
eventId: 'event-1',
|
|
260
|
+
});
|
|
261
|
+
expect(recorded).toMatchObject([{ exerciseId: 'git::a::q1', grade: 5 }]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('rejects an input that breaks the contract before the handler runs', async () => {
|
|
265
|
+
const running = await createTestServer(server, {
|
|
266
|
+
extensionId: 'acme.direct',
|
|
267
|
+
engine,
|
|
268
|
+
});
|
|
269
|
+
disposables.push(running);
|
|
270
|
+
const before = recorded.length;
|
|
271
|
+
await expect(running.rpc(markKnown, { exerciseId: '' })).rejects.toThrow();
|
|
272
|
+
expect(recorded).toHaveLength(before);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('acme.direct: client', () => {
|
|
277
|
+
it('adds the panel', async () => {
|
|
278
|
+
const running = await createTestClient(client, {
|
|
279
|
+
extensionId: 'acme.direct',
|
|
280
|
+
});
|
|
281
|
+
disposables.push(running);
|
|
282
|
+
expect(running.panels.map((panel) => panel.id)).toEqual([
|
|
283
|
+
'acme.direct.view',
|
|
284
|
+
]);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// draws the panel the way the app does: the keys are provided to the component
|
|
289
|
+
const mountPanel = (invoked: string[], toasts: string[]) => {
|
|
290
|
+
const app = {
|
|
291
|
+
notify: (message: string, kind?: string) =>
|
|
292
|
+
toasts.push(`${kind ?? 'info'}: ${message}`),
|
|
293
|
+
} as unknown as AppApi;
|
|
294
|
+
const windowEngine = {
|
|
295
|
+
...engine,
|
|
296
|
+
extensions: {
|
|
297
|
+
invokeRpc: async (request: { name: string }) => {
|
|
298
|
+
invoked.push(request.name);
|
|
299
|
+
return { names: ['From the server'] };
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
} as unknown as ExtensionEngine;
|
|
303
|
+
const host = document.createElement('div');
|
|
304
|
+
document.body.append(host);
|
|
305
|
+
const root = createApp({ render: () => h(Panel) });
|
|
306
|
+
root.provide(EXTENSION_ID_KEY, 'acme.direct');
|
|
307
|
+
root.provide(APP_KEY, app);
|
|
308
|
+
root.provide(ENGINE_KEY, windowEngine);
|
|
309
|
+
root.mount(host);
|
|
310
|
+
disposables.push({
|
|
311
|
+
dispose: () => {
|
|
312
|
+
root.unmount();
|
|
313
|
+
host.remove();
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
return host;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const click = async (host: HTMLElement, role: string) => {
|
|
320
|
+
host.querySelector<HTMLButtonElement>(`[data-role="${role}"]`)?.click();
|
|
321
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
322
|
+
await nextTick();
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
describe('acme.direct: panel', () => {
|
|
326
|
+
it('lists the courses through the server and straight from the engine', async () => {
|
|
327
|
+
const invoked: string[] = [];
|
|
328
|
+
const host = mountPanel(invoked, []);
|
|
329
|
+
await click(host, 'server');
|
|
330
|
+
expect(invoked).toEqual(['courses.names']);
|
|
331
|
+
expect(host.querySelector('li')?.textContent).toBe('From the server');
|
|
332
|
+
|
|
333
|
+
await click(host, 'here');
|
|
334
|
+
expect(
|
|
335
|
+
[...host.querySelectorAll('li')].map((li) => li.textContent),
|
|
336
|
+
).toEqual(['Git', 'SQL']);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('shows a rejected call as an error toast and does not reach the server', async () => {
|
|
340
|
+
const invoked: string[] = [];
|
|
341
|
+
const toasts: string[] = [];
|
|
342
|
+
const host = mountPanel(invoked, toasts);
|
|
343
|
+
await click(host, 'mark');
|
|
344
|
+
expect(invoked).toEqual([]);
|
|
345
|
+
expect(toasts).toHaveLength(1);
|
|
346
|
+
expect(toasts[0]).toMatch(/^error: /);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
`createTestServer(server, { extensionId, engine })` gives the code the engine
|
|
352
|
+
you pass as `s.engine`; without it any use of `s.engine` throws. `running.rpc(contract, input)`
|
|
353
|
+
calls the handler the way the host does: the input must pass the contract's
|
|
354
|
+
schema and so must the result; an unregistered name and an error of the handler
|
|
355
|
+
reject the promise. `running.registration.rpcs` lists the names.
|
|
356
|
+
|
|
357
|
+
A component that uses `useRpc`, `useApp` or `useEngine` is mounted like any Vue
|
|
358
|
+
component in `happy-dom`: `app.provide(EXTENSION_ID_KEY, id)`,
|
|
359
|
+
`app.provide(APP_KEY, appApi)` and `app.provide(ENGINE_KEY, engine)` give it
|
|
360
|
+
what the app would. `createTestClient(client, { extensionId, app, engine })`
|
|
361
|
+
passes `app` and `engine` to the entry as `client.app` and `client.engine`.
|
|
362
|
+
|
|
363
|
+
## Try and ship
|
|
364
|
+
|
|
365
|
+
Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS` and open the "Direct"
|
|
366
|
+
panel from the sidebar menu.
|
package/docs/recipe-settings.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Recipe: settings
|
|
2
2
|
|
|
3
|
-
Let the user configure the extension in Settings → Extensions. The
|
|
4
|
-
|
|
5
|
-
your code reads them. This recipe has no template of its own:
|
|
6
|
-
`blank` and replace the three files below, which are checked as a
|
|
7
|
-
See [quick-start.md](quick-start.md) for the commands.
|
|
3
|
+
Let the user configure the extension in Settings → Extensions. The code
|
|
4
|
+
registers the definitions; the app draws the form, validates the values and
|
|
5
|
+
stores them; your code reads them. This recipe has no template of its own:
|
|
6
|
+
start from `blank` and replace the three files below, which are checked as a
|
|
7
|
+
whole project. See [quick-start.md](quick-start.md) for the commands.
|
|
8
8
|
|
|
9
9
|
## The manifest
|
|
10
10
|
|
|
@@ -19,84 +19,87 @@ File `extension.json` (settings):
|
|
|
19
19
|
"name": "Hello settings",
|
|
20
20
|
"description": "A greeting command whose text follows the user's settings.",
|
|
21
21
|
"author": "your-github-login",
|
|
22
|
-
"tags": ["productivity"]
|
|
23
|
-
"contributes": {
|
|
24
|
-
"commands": [{ "id": "acme.hello.greet", "title": "Greet" }],
|
|
25
|
-
"settings": [
|
|
26
|
-
{
|
|
27
|
-
"id": "acme.hello.name",
|
|
28
|
-
"type": "string",
|
|
29
|
-
"label": "Name to greet",
|
|
30
|
-
"default": "world",
|
|
31
|
-
"maxLength": 40
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
"id": "acme.hello.times",
|
|
35
|
-
"type": "number",
|
|
36
|
-
"label": "Exclamation marks",
|
|
37
|
-
"default": 1,
|
|
38
|
-
"min": 1,
|
|
39
|
-
"max": 5,
|
|
40
|
-
"integer": true
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
"id": "acme.hello.style",
|
|
44
|
-
"type": "enum",
|
|
45
|
-
"label": "Style",
|
|
46
|
-
"default": "plain",
|
|
47
|
-
"options": [
|
|
48
|
-
{ "value": "plain", "label": "Plain" },
|
|
49
|
-
{ "value": "loud", "label": "Loud" }
|
|
50
|
-
]
|
|
51
|
-
}
|
|
52
|
-
]
|
|
53
|
-
}
|
|
22
|
+
"tags": ["productivity"]
|
|
54
23
|
}
|
|
55
24
|
```
|
|
56
25
|
|
|
57
|
-
|
|
58
|
-
and `enum` (`options: [{ value, label }]`).
|
|
59
|
-
- A setting `id` is the extension id or starts with `<id>.`. `default` must
|
|
60
|
-
satisfy the constraints, otherwise the manifest is rejected.
|
|
61
|
-
- `label` (1–60 characters) and `description` (up to 500) are shown as written;
|
|
62
|
-
they are data of the extension and are not translated.
|
|
26
|
+
The manifest holds the identity only; the settings are registered by the code.
|
|
63
27
|
|
|
64
28
|
## The code
|
|
65
29
|
|
|
66
30
|
File `src/index.ts` (settings):
|
|
67
31
|
|
|
68
32
|
```ts
|
|
69
|
-
import {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
33
|
+
import { defineServer, notify } from '@dolphy-app/extension-sdk';
|
|
34
|
+
|
|
35
|
+
export const server = defineServer((s) => {
|
|
36
|
+
s.registerSettings([
|
|
37
|
+
{
|
|
38
|
+
id: 'acme.hello.name',
|
|
39
|
+
type: 'string',
|
|
40
|
+
label: { en: 'Name to greet', ru: 'Кого приветствовать' },
|
|
41
|
+
default: 'world',
|
|
42
|
+
maxLength: 40,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'acme.hello.times',
|
|
46
|
+
type: 'number',
|
|
47
|
+
label: { en: 'Exclamation marks', ru: 'Восклицательные знаки' },
|
|
48
|
+
default: 1,
|
|
49
|
+
min: 1,
|
|
50
|
+
max: 5,
|
|
51
|
+
integer: true,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'acme.hello.style',
|
|
55
|
+
type: 'enum',
|
|
56
|
+
label: { en: 'Style', ru: 'Стиль' },
|
|
57
|
+
default: 'plain',
|
|
58
|
+
options: [
|
|
59
|
+
{ value: 'plain', label: { en: 'Plain', ru: 'Обычный' } },
|
|
60
|
+
{ value: 'loud', label: { en: 'Loud', ru: 'Громкий' } },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
s.registerCommand({
|
|
66
|
+
id: 'acme.hello.greet',
|
|
67
|
+
title: { en: 'Greet', ru: 'Поприветствовать' },
|
|
68
|
+
run: () => {
|
|
79
69
|
// `get` is synchronous and returns the user's value or the default
|
|
80
|
-
const name =
|
|
81
|
-
const marks = '!'.repeat(
|
|
82
|
-
const style = ctx.settings.get('acme.hello.style');
|
|
70
|
+
const name = String(s.settings.get('acme.hello.name'));
|
|
71
|
+
const marks = '!'.repeat(Number(s.settings.get('acme.hello.times')));
|
|
83
72
|
const text = `Hello, ${name}${marks}`;
|
|
84
|
-
return notify(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
73
|
+
return notify(
|
|
74
|
+
s.settings.get('acme.hello.style') === 'loud'
|
|
75
|
+
? text.toUpperCase()
|
|
76
|
+
: text,
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// a change in Settings → Extensions reaches the running extension
|
|
82
|
+
s.settings.onDidChange((change) => {
|
|
83
|
+
s.logger.info({ id: change.id, value: change.value }, 'setting changed');
|
|
84
|
+
});
|
|
92
85
|
});
|
|
93
86
|
```
|
|
94
87
|
|
|
95
|
-
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
`
|
|
99
|
-
-
|
|
88
|
+
- `server.registerSettings(definitions)` adds the settings to Settings →
|
|
89
|
+
Extensions. Types: `boolean`, `string` (`maxLength`), `text` (a multi-line
|
|
90
|
+
string), `color` (`#rrggbb`), `list` (`maxItems`, `itemMaxLength`), `number`
|
|
91
|
+
(`min`, `max`, `integer`) and `enum` (`options: [{ value, label }]`).
|
|
92
|
+
- A setting `id` is the extension id or starts with `<id>.`, and is registered
|
|
93
|
+
once. `default` must satisfy the constraints, otherwise the registration
|
|
94
|
+
fails and the extension shows `load-failed`.
|
|
95
|
+
- `label`, `description` and `group` are `LocalizedText`: a string, or
|
|
96
|
+
`{ en, ru }` as here. `order` sorts the form; `visibleWhen: { setting, equals }`
|
|
97
|
+
hides a field while another setting of the extension has a different value.
|
|
98
|
+
- `server.settings.get(id)` returns the user's value or the default (an id
|
|
99
|
+
nobody registered throws) as a `SettingValue`; narrow it where you use it,
|
|
100
|
+
as `String(…)` and `Number(…)` do here. Read a value where you use it. Cache
|
|
101
|
+
it only if you also subscribe with `onDidChange`.
|
|
102
|
+
- The app validates every value (type, range, integer, length, `options`)
|
|
100
103
|
before it reaches you, so the code needs no checks of its own.
|
|
101
104
|
|
|
102
105
|
## The test
|
|
@@ -104,55 +107,74 @@ export const host = defineExtension({
|
|
|
104
107
|
File `test/index.test.ts` (settings):
|
|
105
108
|
|
|
106
109
|
```ts
|
|
107
|
-
import
|
|
108
|
-
import {
|
|
109
|
-
createMemorySettings,
|
|
110
|
-
loadCommands,
|
|
111
|
-
} from '@dolphy-app/extension-sdk/testing';
|
|
110
|
+
import { createTestServer } from '@dolphy-app/extension-sdk/testing';
|
|
112
111
|
import { expect, it } from 'vitest';
|
|
113
|
-
import
|
|
114
|
-
import { host } from '../src/index.ts';
|
|
112
|
+
import { server } from '../src/index.ts';
|
|
115
113
|
|
|
116
|
-
const
|
|
114
|
+
const start = (settingValues = {}) =>
|
|
115
|
+
createTestServer(server, { extensionId: 'acme.hello', settingValues });
|
|
117
116
|
|
|
118
117
|
it('the greeting follows the settings, also after a change', async () => {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
settings,
|
|
122
|
-
declaredCommands: ['acme.hello.greet'],
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
expect(await commands.run('acme.hello.greet')).toEqual({
|
|
118
|
+
const running = await start();
|
|
119
|
+
expect(await running.commands.run('acme.hello.greet')).toEqual({
|
|
126
120
|
kind: 'notify',
|
|
127
121
|
text: 'Hello, world!',
|
|
128
122
|
});
|
|
129
123
|
|
|
130
|
-
await settings.set('acme.hello.name', 'Ada');
|
|
131
|
-
await settings.set('acme.hello.times', 3);
|
|
132
|
-
await settings.set('acme.hello.style', 'loud');
|
|
133
|
-
expect(await commands.run('acme.hello.greet')).toEqual({
|
|
124
|
+
await running.settings.set('acme.hello.name', 'Ada');
|
|
125
|
+
await running.settings.set('acme.hello.times', 3);
|
|
126
|
+
await running.settings.set('acme.hello.style', 'loud');
|
|
127
|
+
expect(await running.commands.run('acme.hello.greet')).toEqual({
|
|
134
128
|
kind: 'notify',
|
|
135
129
|
text: 'HELLO, ADA!!!',
|
|
136
130
|
});
|
|
137
|
-
await
|
|
131
|
+
await running.dispose();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('a user value of a setting replaces the default', async () => {
|
|
135
|
+
const running = await start({ 'acme.hello.name': 'Grace' });
|
|
136
|
+
expect(await running.commands.run('acme.hello.greet')).toMatchObject({
|
|
137
|
+
text: 'Hello, Grace!',
|
|
138
|
+
});
|
|
139
|
+
await running.dispose();
|
|
138
140
|
});
|
|
139
141
|
|
|
140
|
-
it('the
|
|
141
|
-
const
|
|
142
|
-
await expect(settings.set('acme.hello.times', 9)).rejects.toThrow(
|
|
142
|
+
it('the test settings reject a value the app would reject', async () => {
|
|
143
|
+
const running = await start();
|
|
144
|
+
await expect(running.settings.set('acme.hello.times', 9)).rejects.toThrow(
|
|
143
145
|
'acme.hello.times',
|
|
144
146
|
);
|
|
145
|
-
await expect(
|
|
146
|
-
'acme.hello.style',
|
|
147
|
-
);
|
|
147
|
+
await expect(
|
|
148
|
+
running.settings.set('acme.hello.style', 'quiet'),
|
|
149
|
+
).rejects.toThrow('acme.hello.style');
|
|
150
|
+
await running.dispose();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('a change is logged', async () => {
|
|
154
|
+
const logged: object[] = [];
|
|
155
|
+
const logger = {
|
|
156
|
+
debug: () => undefined,
|
|
157
|
+
info: (fields: object) => void logged.push(fields),
|
|
158
|
+
warn: () => undefined,
|
|
159
|
+
error: () => undefined,
|
|
160
|
+
};
|
|
161
|
+
const running = await createTestServer(server, {
|
|
162
|
+
extensionId: 'acme.hello',
|
|
163
|
+
logger,
|
|
164
|
+
});
|
|
165
|
+
await running.settings.set('acme.hello.name', 'Ada');
|
|
166
|
+
expect(logged).toEqual([{ id: 'acme.hello.name', value: 'Ada' }]);
|
|
167
|
+
await running.dispose();
|
|
148
168
|
});
|
|
149
169
|
```
|
|
150
170
|
|
|
151
|
-
`
|
|
152
|
-
`
|
|
153
|
-
the value against the definition
|
|
154
|
-
|
|
155
|
-
|
|
171
|
+
`createTestServer` starts `server` with the settings in memory:
|
|
172
|
+
`running.settings.get` returns the default until a value is set, and
|
|
173
|
+
`running.settings.set(id, value)` checks the value against the definition the
|
|
174
|
+
way the app does and calls the `onDidChange` subscribers, so the test changes a
|
|
175
|
+
value as the user does in the dialog. `settingValues` starts the extension with
|
|
176
|
+
user values in place of the defaults; every id must be registered by `server`.
|
|
177
|
+
The `logger` option receives what `server.logger` is called with.
|
|
156
178
|
|
|
157
179
|
## Try and ship
|
|
158
180
|
|