@dolphy-app/extension-sdk 0.4.0 → 0.6.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.
@@ -0,0 +1,158 @@
1
+ # Recipe: hooks before a session and a batch
2
+
3
+ Step into the learning loop. A `before` hook runs before the engine does
4
+ something and may change its input or cancel it. This recipe reorders the
5
+ exercises of a session so that reviews come before new material and the most
6
+ forgotten come first, and refuses to
7
+ start a session in the small hours. There is no template for it; start from
8
+ `blank` and replace the files below, which are checked as a whole project. See
9
+ [quick-start.md](quick-start.md) for the commands. Events, which only observe,
10
+ are in [recipe-event-storage.md](recipe-event-storage.md).
11
+
12
+ ## The manifest
13
+
14
+ File `extension.json` (hooks):
15
+
16
+ ```json
17
+ {
18
+ "$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
19
+ "id": "acme.hello",
20
+ "version": "0.1.0",
21
+ "apiVersion": 1,
22
+ "name": "Reviews first",
23
+ "description": "Puts reviews before new exercises and keeps sessions out of the small hours.",
24
+ "author": "your-github-login",
25
+ "tags": ["learning"]
26
+ }
27
+ ```
28
+
29
+ Nothing about hooks is in the manifest: `server.before` is the registration.
30
+
31
+ ## The code
32
+
33
+ File `src/index.ts` (hooks):
34
+
35
+ ```ts
36
+ import { defineServer } from '@dolphy-app/extension-sdk';
37
+
38
+ const REVIEW_FIRST = { review: 0, remediation: 1, new: 2 } as const;
39
+
40
+ export const server = defineServer((s) => {
41
+ // may only cancel: throw to stop the session from starting
42
+ s.before('session.start', ({ now }) => {
43
+ if (new Date(now).getHours() < 6) {
44
+ throw new Error('Sessions are closed until six in the morning');
45
+ }
46
+ });
47
+
48
+ // gets the batch the engine built and returns the one to use
49
+ // reviews before new material, the most forgotten first within a group
50
+ s.before('practice.batch', ({ exerciseIds, reasons, memory }) => {
51
+ const items = exerciseIds
52
+ .map((id, index) => ({
53
+ id,
54
+ reason: reasons[index] ?? 'new',
55
+ // no attempts yet: nothing to forget
56
+ retrievability: memory[index]?.retrievability ?? 1,
57
+ }))
58
+ .sort(
59
+ (a, b) =>
60
+ REVIEW_FIRST[a.reason] - REVIEW_FIRST[b.reason] ||
61
+ a.retrievability - b.retrievability,
62
+ );
63
+ return {
64
+ exerciseIds: items.map(({ id }) => id),
65
+ reasons: items.map(({ reason }) => reason),
66
+ };
67
+ });
68
+ });
69
+ ```
70
+
71
+ - `server.before(name, handler)` registers a hook. The names are a closed list:
72
+ `session.start` and `practice.batch`; another name fails the registration.
73
+ One handler per name; the call returns a `Disposable`.
74
+ - `session.start` gets `{ now }` (epoch milliseconds) and returns nothing. The
75
+ only way to act is to throw: the session is not created and the learner sees
76
+ the message of your error.
77
+ - `practice.batch` gets `{ sessionId, exerciseIds, reasons, memory, source }`
78
+ (`sessionId` is `null` while the session does not exist yet, as in the daily
79
+ plan before it starts).
80
+ `reasons[i]` (`review`, `new` or `remediation`) belongs to `exerciseIds[i]`;
81
+ `source` is `batch` for `practice.getBatch` and `plan` for the daily plan the
82
+ window builds a session from. `memory[i]` is what the engine remembers of
83
+ `exerciseIds[i]`, read-only: `retrievability` (0..1 chance to recall now),
84
+ `lastAttemptAt` (epoch milliseconds), `attempts`, and the FSRS `stability`
85
+ (days) and `difficulty`. An exercise with no attempts has `attempts: 0` and
86
+ `null` in the other fields. Return `{ exerciseIds, reasons }` of the same
87
+ length. You may reorder, drop and add exercises; every id must exist in the
88
+ library, at most 500 ids.
89
+ - Extensions with a hook run one after another in the order of their ids; each
90
+ gets the result of the previous one. A handler has 30 seconds.
91
+ - An error, a timeout or an answer the engine rejects (a missing exercise,
92
+ unequal lengths, too many ids) cancels the whole operation: no session, no
93
+ batch, nothing written to the journal. The caller gets the engine error
94
+ `EXTENSION_HOOK_FAILED` with the extension id and your message, and the window
95
+ shows it like any other failure to start a session.
96
+
97
+ ## The test
98
+
99
+ File `test/index.test.ts` (hooks):
100
+
101
+ ```ts
102
+ import { createTestServer } from '@dolphy-app/extension-sdk/testing';
103
+ import { expect, it } from 'vitest';
104
+ import { server } from '../src/index.ts';
105
+
106
+ const start = () => createTestServer(server, { extensionId: 'acme.hello' });
107
+
108
+ it('puts reviews before new exercises', async () => {
109
+ const running = await start();
110
+ const batch = await running.hook('practice.batch', {
111
+ sessionId: 's1',
112
+ exerciseIds: ['c::l::a', 'c::l::b', 'c::l::c'],
113
+ reasons: ['new', 'review', 'remediation'],
114
+ memory: [
115
+ { retrievability: null, lastAttemptAt: null, attempts: 0, stability: null, difficulty: null },
116
+ { retrievability: 0.8, lastAttemptAt: 1_000, attempts: 3, stability: 9, difficulty: 4 },
117
+ { retrievability: 0.3, lastAttemptAt: 2_000, attempts: 1, stability: 2, difficulty: 6 },
118
+ ],
119
+ source: 'batch',
120
+ });
121
+ expect(batch).toEqual({
122
+ exerciseIds: ['c::l::b', 'c::l::c', 'c::l::a'],
123
+ reasons: ['review', 'remediation', 'new'],
124
+ });
125
+ await running.dispose();
126
+ });
127
+
128
+ it('puts the most forgotten exercises first within a group', async () => {
129
+ const running = await start();
130
+ const batch = await running.hook('practice.batch', {
131
+ sessionId: 's1',
132
+ exerciseIds: ['c::l::a', 'c::l::b', 'c::l::c'],
133
+ reasons: ['new', 'review', 'review'],
134
+ memory: [
135
+ { retrievability: null, lastAttemptAt: null, attempts: 0, stability: null, difficulty: null },
136
+ { retrievability: 0.8, lastAttemptAt: 1_000, attempts: 3, stability: 9, difficulty: 4 },
137
+ { retrievability: 0.3, lastAttemptAt: 2_000, attempts: 1, stability: 2, difficulty: 6 },
138
+ ],
139
+ source: 'batch',
140
+ });
141
+ expect(batch.exerciseIds).toEqual(['c::l::c', 'c::l::b', 'c::l::a']);
142
+ expect(batch.reasons).toEqual(['review', 'review', 'new']);
143
+ await running.dispose();
144
+ });
145
+
146
+ it('refuses to start a session in the small hours', async () => {
147
+ const running = await start();
148
+ const night = new Date(2026, 9, 7, 3).getTime();
149
+ await expect(running.hook('session.start', { now: night })).rejects.toThrow(
150
+ 'Sessions are closed until six in the morning',
151
+ );
152
+ const morning = new Date(2026, 9, 7, 9).getTime();
153
+ await expect(
154
+ running.hook('session.start', { now: morning }),
155
+ ).resolves.toBeUndefined();
156
+ await running.dispose();
157
+ });
158
+ ```
@@ -20,34 +20,19 @@ File `extension.json` (import-export):
20
20
  "name": "Hello cards",
21
21
  "description": "Import flashcards from a CSV file and export a course back to CSV.",
22
22
  "author": "your-github-login",
23
- "tags": ["content"],
24
- "contributes": {
25
- "importers": [
26
- { "id": "acme.hello.import", "title": "Cards from CSV", "accept": [".csv"] }
27
- ],
28
- "exporters": [
29
- { "id": "acme.hello.export", "title": "Course to CSV", "scope": "course" }
30
- ]
31
- }
23
+ "tags": ["content"]
32
24
  }
33
25
  ```
34
26
 
35
- - An importer has an `id`, a `title` (up to 60 characters), `accept` (1–8
36
- lower-case file extensions such as `.csv`) and an optional `input`: `text`
37
- (the default, the handler gets the file as a UTF-8 string) or `bytes` (a
38
- `Uint8Array`). An exporter has `scope`: `course` or `progress`.
39
- - No permission is needed: the user choosing the file is the consent, and your
40
- code never sees a path. A `progress` exporter reads `ctx.stats` and needs the
41
- `learning.stats` permission.
42
- - The importer appears in the palette as "Import: Cards from CSV", the exporter
43
- as "Export: Course to CSV", and both have buttons in Settings → Library.
27
+ The manifest holds the identity only; the importer and the exporter are
28
+ registered by the code.
44
29
 
45
30
  ## The code
46
31
 
47
32
  File `src/index.ts` (import-export):
48
33
 
49
34
  ```ts
50
- import { defineExtension } from '@dolphy-app/extension-sdk';
35
+ import { defineServer } from '@dolphy-app/extension-sdk';
51
36
  import type {
52
37
  CourseExportInput,
53
38
  TextImportInput,
@@ -62,11 +47,15 @@ const courseIdOf = (fileName: string): string =>
62
47
  .replace(/[^a-z0-9]+/g, '-')
63
48
  .replace(/^-|-$/g, '') || 'cards';
64
49
 
65
- export const host = defineExtension({
66
- importers: {
50
+ export const server = defineServer((s) => {
51
+ s.registerImporter({
52
+ id: 'acme.hello.import',
53
+ title: { en: 'Cards from CSV', ru: 'Карточки из CSV' },
54
+ accept: ['.csv'],
55
+ input: 'text',
67
56
  // one line "front,back" is one flashcard; the paths are relative to the
68
57
  // new course directory the app creates
69
- 'acme.hello.import': ({ name, text }: TextImportInput) => {
58
+ run: ({ name, text }: TextImportInput) => {
70
59
  const rows = text.split(/\r?\n/).filter((line) => line.trim() !== '');
71
60
  if (rows.length === 0) throw new Error('The file has no rows');
72
61
  const course = courseIdOf(name);
@@ -109,11 +98,15 @@ export const host = defineExtension({
109
98
  });
110
99
  return { files };
111
100
  },
112
- },
113
- exporters: {
101
+ });
102
+
103
+ s.registerExporter({
104
+ id: 'acme.hello.export',
105
+ title: { en: 'Course to CSV', ru: 'Курс в CSV' },
106
+ scope: 'course',
114
107
  // the snapshot holds the text files of the chosen course, paths relative
115
108
  // to the course directory
116
- 'acme.hello.export': ({ title, files }: CourseExportInput) => {
109
+ run: ({ title, files }: CourseExportInput) => {
117
110
  const cell = (text: string): string => text.trim().replace(/\s+/g, ' ');
118
111
  const fronts = Object.keys(files)
119
112
  .filter((path) => path.endsWith('/front.md'))
@@ -127,10 +120,20 @@ export const host = defineExtension({
127
120
  text: `${rows.join('\n')}\n`,
128
121
  };
129
122
  },
130
- },
123
+ });
131
124
  });
132
125
  ```
133
126
 
127
+ - `server.registerImporter({ id, title, accept, input, run })`: `title` (up to 60
128
+ characters) is a `LocalizedText`; `accept` is 1–8 unique lower-case file
129
+ extensions such as `.csv`; `input` is `text` (the handler gets the file as a
130
+ UTF-8 string) or `bytes` (a `Uint8Array`). `server.registerExporter({ id,
131
+ title, scope, run })` has a `scope`: `course` or `progress`. At most 8 of each
132
+ per extension.
133
+ - The user choosing the file is the consent, and your code never sees a path. A
134
+ `progress` exporter reads `server.stats`.
135
+ - The importer appears in the palette as "Import: Cards from CSV", the exporter
136
+ as "Export: Course to CSV", and both have buttons in Settings → Library.
134
137
  - An importer handler gets `{ name, text }` (or `{ name, bytes }`) and returns
135
138
  `{ files: Record<path, text> }`: the files of a new directory in the library
136
139
  (`imported/<extension id>-<file name>`). At most 5000 files, 2 MiB each and
@@ -147,30 +150,24 @@ export const host = defineExtension({
147
150
  and is at most 120 characters. The app asks the user where to save it.
148
151
  - A handler has 30 seconds. Throw an `Error` to refuse: its message reaches the
149
152
  user, and nothing is written.
150
- - A handler that needs `ctx` is registered in `activate` with
151
- `ctx.importers.register(id, handler)` / `ctx.exporters.register(id, handler)`
152
- (`inActivate` in the record, as for commands).
153
153
 
154
154
  ## The tests
155
155
 
156
156
  File `test/index.test.ts` (import-export):
157
157
 
158
158
  ```ts
159
- import {
160
- loadExporters,
161
- loadImporters,
162
- } from '@dolphy-app/extension-sdk/testing';
159
+ import { createTestServer } from '@dolphy-app/extension-sdk/testing';
163
160
  import { describe, expect, it } from 'vitest';
164
- import { host } from '../src/index.ts';
161
+ import { server } from '../src/index.ts';
165
162
 
166
163
  const CSV = 'hola,hello\nadiós,goodbye\n';
167
164
 
165
+ const start = () => createTestServer(server, { extensionId: 'acme.hello' });
166
+
168
167
  describe('acme.hello: importer', () => {
169
168
  it('turns every row into a flashcard of one lesson', async () => {
170
- const importers = await loadImporters(host, {
171
- declaredImporters: [{ id: 'acme.hello.import' }],
172
- });
173
- const { files } = await importers.run('acme.hello.import', {
169
+ const running = await start();
170
+ const { files } = await running.importer('acme.hello.import').run({
174
171
  name: 'Spanish basics.csv',
175
172
  text: CSV,
176
173
  });
@@ -185,26 +182,24 @@ describe('acme.hello: importer', () => {
185
182
  'spanish-basics/course_manifest.json',
186
183
  ]);
187
184
  expect(files['spanish-basics/cards/c2/front.md']).toBe('adiós\n');
188
- await importers.dispose();
185
+ await running.dispose();
189
186
  });
190
187
 
191
188
  it('refuses a row without an answer', async () => {
192
- const importers = await loadImporters(host, {
193
- declaredImporters: [{ id: 'acme.hello.import' }],
194
- });
189
+ const running = await start();
195
190
  await expect(
196
- importers.run('acme.hello.import', { name: 'x.csv', text: 'hola\n' }),
191
+ running
192
+ .importer('acme.hello.import')
193
+ .run({ name: 'x.csv', text: 'hola\n' }),
197
194
  ).rejects.toThrow('Row 1');
198
- await importers.dispose();
195
+ await running.dispose();
199
196
  });
200
197
  });
201
198
 
202
199
  describe('acme.hello: exporter', () => {
203
200
  it('writes the cards of the snapshot back to CSV', async () => {
204
- const exporters = await loadExporters(host, {
205
- declaredExporters: [{ id: 'acme.hello.export', scope: 'course' }],
206
- });
207
- const result = await exporters.run('acme.hello.export', {
201
+ const running = await start();
202
+ const result = await running.exporter('acme.hello.export').run({
208
203
  scope: 'course',
209
204
  courseId: 'deck',
210
205
  title: 'Deck / Spanish',
@@ -221,18 +216,18 @@ describe('acme.hello: exporter', () => {
221
216
  filename: 'Deck - Spanish.csv',
222
217
  text: 'hola,hello\nadiós,goodbye\ndiez,ten\n',
223
218
  });
224
- await exporters.dispose();
219
+ await running.dispose();
225
220
  });
226
221
  });
227
222
  ```
228
223
 
229
- `loadImporters` and `loadExporters` activate the module in memory and run a
230
- handler with the rules of the host: the `text`/`bytes` form of a declared
231
- importer, the `scope` of a declared exporter, the size of the input and the
224
+ `running.importer(id).run(input)` and `running.exporter(id).run(input)` run a
225
+ handler with the rules of the host: the `text`/`bytes` form the importer
226
+ declares, the `scope` the exporter declares, the size of the input and the
232
227
  shape and limits of the result. A broken result rejects with
233
228
  `invalid import result: …` / `invalid export result: …`, so the test fails the
234
229
  way the app would refuse it. For a `progress` exporter pass
235
- `stats: createMemoryStats(…)`.
230
+ `stats: createMemoryStats(…)` to `createTestServer`.
236
231
 
237
232
  ## Try and ship
238
233
 
@@ -0,0 +1,322 @@
1
+ # Recipe: a component of any framework
2
+
3
+ The app draws a panel, an injection, an answer view or a markdown block with
4
+ Vue, or with a `Mountable`: an object with `mount(el, ctx)` that draws into the
5
+ element the app gives it and returns the cleanup. Nothing in a `Mountable`
6
+ depends on Vue, so it is the base for Svelte, Solid, Lit or plain DOM. This
7
+ recipe builds a panel on plain DOM, the smallest case, and tests it with
8
+ `mountForTest`. React has a preset built on the same contract:
9
+ [recipe-react.md](recipe-react.md). The command and panel basics are in
10
+ [recipe-command-panel.md](recipe-command-panel.md); the contracts use `zod`, so
11
+ the project lists `zod` in its `dependencies`.
12
+
13
+ ## The manifest
14
+
15
+ File `extension.json` (mountable):
16
+
17
+ ```json
18
+ {
19
+ "id": "acme.counter",
20
+ "version": "1.0.0",
21
+ "apiVersion": 1
22
+ }
23
+ ```
24
+
25
+ ## The parts
26
+
27
+ File `src/index.ts` (mountable):
28
+
29
+ ```ts
30
+ export { client } from './client.ts';
31
+ export { server } from './server.ts';
32
+ ```
33
+
34
+ File `src/shared/rpc.ts` (mountable):
35
+
36
+ ```ts
37
+ import { defineRpc } from '@dolphy-app/extension-sdk';
38
+ import { z } from 'zod';
39
+
40
+ export const sayHello = defineRpc({
41
+ name: 'greeting.say-hello',
42
+ input: z.object({ name: z.string() }),
43
+ output: z.object({ text: z.string() }),
44
+ });
45
+ ```
46
+
47
+ File `src/server.ts` (mountable):
48
+
49
+ ```ts
50
+ import { defineServer } from '@dolphy-app/extension-sdk';
51
+ import { sayHello } from './shared/rpc.ts';
52
+
53
+ export const server = defineServer((s) => {
54
+ s.handle(sayHello, async ({ name }) => ({ text: `Hello, ${name}!` }));
55
+ });
56
+ ```
57
+
58
+ ## The component
59
+
60
+ File `src/hello-panel.ts` (mountable):
61
+
62
+ ```ts
63
+ import { defineMountable } from '@dolphy-app/extension-sdk';
64
+ import type { PanelHandle, PanelProps } from '@dolphy-app/extension-sdk';
65
+ import { sayHello } from './shared/rpc.ts';
66
+
67
+ // plain DOM: the element is yours, any framework mounts into it the same way
68
+ export const HelloPanel = defineMountable<PanelProps, PanelHandle>(
69
+ (el, ctx) => {
70
+ const title = document.createElement('h2');
71
+ const text = document.createElement('p');
72
+ const button = document.createElement('button');
73
+ button.type = 'button';
74
+ button.textContent = 'Ask the server';
75
+ el.append(title, text, button);
76
+
77
+ const nameOf = ({ props }: PanelProps) =>
78
+ typeof props === 'string' ? props : 'world';
79
+ const draw = (props: PanelProps) => {
80
+ title.textContent = `Hello, ${nameOf(props)}!`;
81
+ };
82
+ draw(ctx.props);
83
+ // the app opens the panel again with new properties
84
+ const stopProps = ctx.onProps(draw);
85
+
86
+ // the window is light or dark; the element is inside it
87
+ const paint = ({ dark }: { dark: boolean }) => {
88
+ el.dataset.dark = String(dark);
89
+ };
90
+ paint(ctx.theme);
91
+ const stopTheme = ctx.onTheme(paint);
92
+
93
+ // the server part answers a contract; an error is shown in place of the panel
94
+ const ask = () => {
95
+ ctx.callRpc(sayHello, { name: nameOf(ctx.props) }).then(
96
+ (answer) => {
97
+ if (!ctx.signal.aborted) text.textContent = answer.text;
98
+ },
99
+ (error: unknown) => ctx.reportError(error),
100
+ );
101
+ };
102
+ button.addEventListener('click', ask);
103
+
104
+ return () => {
105
+ stopProps();
106
+ stopTheme();
107
+ button.removeEventListener('click', ask);
108
+ el.replaceChildren();
109
+ };
110
+ },
111
+ );
112
+ ```
113
+
114
+ File `src/client.ts` (mountable):
115
+
116
+ ```ts
117
+ import { defineClient } from '@dolphy-app/extension-sdk';
118
+ import { HelloPanel } from './hello-panel.ts';
119
+
120
+ export const client = defineClient((c) => {
121
+ c.addPanel({
122
+ id: 'acme.counter.view',
123
+ title: 'Hello',
124
+ component: HelloPanel,
125
+ });
126
+ });
127
+ ```
128
+
129
+ - `defineMountable<Props, Handle>(mount)` builds the object and gives it the
130
+ brand that `isMountable(value)` checks. `mount(el, ctx)` may return the
131
+ cleanup or a promise of it. The app calls the cleanup when it removes the
132
+ element: a route change, an extension turned off or removed, an injection
133
+ whose target is gone.
134
+ - `Props` is the type of `ctx.props`: `PanelProps` (`panelId`, `props`,
135
+ `context`) in a panel, `InjectionProps` (`target`, `position`) in an injection,
136
+ `AnswerViewProps` in an answer view and `MarkdownBlockProps` (`source`,
137
+ `language`) in a markdown renderer. `Handle` is the type of `ctx.handle`:
138
+ `PanelHandle` in a panel, `InjectionHandle` in an injection and `undefined`
139
+ elsewhere.
140
+ - The same component fits `client.addPanel`, `client.addInjection`,
141
+ `client.addAnswerView` and `client.addMarkdownRenderer` as its `component`,
142
+ where a Vue component fits.
143
+
144
+ ## The context
145
+
146
+ `ctx` (`MountContext`) is everything a Vue component gets from `usePanel`,
147
+ `useApp`, `useEngine` and `useRpc`, without Vue:
148
+
149
+ | Field | What it is |
150
+ | ----------------------- | ------------------------------------------------------------------------------------------------------ |
151
+ | `props`, `onProps(fn)` | The current props, a snapshot that is never changed in place, and a listener for the next one. |
152
+ | `theme`, `onTheme(fn)` | `{ id, dark }` of the window and a listener for a change. |
153
+ | `locale`, `onLocale(fn)` | `'en'` or `'ru'` and a listener for a change. |
154
+ | `emit(event, payload?)` | Sends an event to the app. Only an answer view has events (see below); elsewhere it does nothing. |
155
+ | `app`, `engine` | The window API (`AppApi`) and the engine client, the same objects as `client.app` and `client.engine`. |
156
+ | `callRpc(contract, input)` | Calls the server part: validates the input and the answer with the contract; a failure rejects. |
157
+ | `extensionId` | The id of this extension. |
158
+ | `signal` | An `AbortSignal` aborted when the element is removed: pass it to `fetch`, check it after an `await`. |
159
+ | `reportError(error)` | Shows the card "Extension <name>: <error>" with a "Retry" button in place of the component. |
160
+ | `handle` | The panel handle (`panelId`, `props`, `context`, `call(commandId, args?)`) or the injection handle. |
161
+
162
+ Every `on…` returns the function that stops listening; call it in the cleanup.
163
+ An exception that `mount` throws is reported the same way as `reportError`.
164
+
165
+ An answer view tells the app about the answer with `emit`:
166
+
167
+ <!-- fragment -->
168
+
169
+ ```ts
170
+ import { defineMountable } from '@dolphy-app/extension-sdk';
171
+ import type { AnswerChange, AnswerViewProps } from '@dolphy-app/extension-api';
172
+
173
+ export const Choice = defineMountable<AnswerViewProps<string[], string>>(
174
+ (el, ctx) => {
175
+ const select = document.createElement('select');
176
+ select.append(...ctx.props.view.map((option) => new Option(option)));
177
+ select.disabled = ctx.props.disabled;
178
+ select.addEventListener('change', () => {
179
+ const change: AnswerChange<string> = {
180
+ value: select.value,
181
+ complete: true,
182
+ };
183
+ ctx.emit('change', change);
184
+ });
185
+ el.append(select);
186
+ return () => el.replaceChildren();
187
+ },
188
+ );
189
+ ```
190
+
191
+ `emit('change', { value, complete })` reports the current answer and
192
+ `emit('submit')` asks the app to check it.
193
+
194
+ ## The tests
195
+
196
+ File `test/index.test.ts` (mountable):
197
+
198
+ ```ts
199
+ // @vitest-environment happy-dom
200
+ import { isMountable } from '@dolphy-app/extension-sdk';
201
+ import type { ExtensionEngine, PanelProps } from '@dolphy-app/extension-sdk';
202
+ import {
203
+ createTestClient,
204
+ createTestServer,
205
+ mountForTest,
206
+ } from '@dolphy-app/extension-sdk/testing';
207
+ import { afterEach, describe, expect, it, vi } from 'vitest';
208
+ import { client, server } from '../src/index.ts';
209
+ import { HelloPanel } from '../src/hello-panel.ts';
210
+ import { sayHello } from '../src/shared/rpc.ts';
211
+
212
+ const disposables: { dispose(): unknown }[] = [];
213
+ afterEach(async () => {
214
+ await Promise.all(disposables.splice(0).map((item) => item.dispose()));
215
+ });
216
+
217
+ describe('acme.counter: server', () => {
218
+ it('answers the contract', async () => {
219
+ const running = await createTestServer(server, {
220
+ extensionId: 'acme.counter',
221
+ });
222
+ disposables.push(running);
223
+ expect(await running.rpc(sayHello, { name: 'Ada' })).toEqual({
224
+ text: 'Hello, Ada!',
225
+ });
226
+ });
227
+ });
228
+
229
+ describe('acme.counter: client', () => {
230
+ it('adds the panel as a mountable', async () => {
231
+ const running = await createTestClient(client, {
232
+ extensionId: 'acme.counter',
233
+ });
234
+ disposables.push(running);
235
+ expect(running.panels.map((panel) => panel.id)).toEqual([
236
+ 'acme.counter.view',
237
+ ]);
238
+ expect(isMountable(running.panels[0]?.component)).toBe(true);
239
+ });
240
+ });
241
+
242
+ const panelProps = (props: PanelProps['props']): PanelProps => ({
243
+ panelId: 'acme.counter.view',
244
+ props,
245
+ context: { courseId: null },
246
+ });
247
+
248
+ // the engine of the window: `ctx.callRpc` goes through `extensions.invokeRpc`
249
+ const engineAnswering = (
250
+ answer: (request: { name: string; input: unknown }) => Promise<unknown>,
251
+ ) => ({ extensions: { invokeRpc: answer } }) as unknown as ExtensionEngine;
252
+
253
+ const mountPanel = async (
254
+ props: PanelProps['props'],
255
+ engine: ExtensionEngine,
256
+ ) => {
257
+ const mounted = await mountForTest(HelloPanel, {
258
+ props: panelProps(props),
259
+ handle: { ...panelProps(props), call: async () => undefined },
260
+ engine,
261
+ extensionId: 'acme.counter',
262
+ });
263
+ disposables.push({ dispose: () => mounted.unmount() });
264
+ return mounted;
265
+ };
266
+
267
+ describe('acme.counter: panel', () => {
268
+ it('greets, follows the props and the theme, asks the server', async () => {
269
+ const asked: unknown[] = [];
270
+ const mounted = await mountPanel(
271
+ 'Ada',
272
+ engineAnswering(async (request) => {
273
+ asked.push(request.input);
274
+ return { text: 'Hello from the server' };
275
+ }),
276
+ );
277
+ expect(mounted.el.querySelector('h2')?.textContent).toBe('Hello, Ada!');
278
+ expect(mounted.el.dataset.dark).toBe('false');
279
+
280
+ mounted.setProps(panelProps('Grace'));
281
+ mounted.setTheme({ id: 'night', dark: true });
282
+ expect(mounted.el.querySelector('h2')?.textContent).toBe('Hello, Grace!');
283
+ expect(mounted.el.dataset.dark).toBe('true');
284
+
285
+ mounted.el.querySelector('button')?.click();
286
+ await vi.waitFor(() =>
287
+ expect(mounted.el.querySelector('p')?.textContent).toBe(
288
+ 'Hello from the server',
289
+ ),
290
+ );
291
+ expect(asked).toEqual([{ name: 'Grace' }]);
292
+ });
293
+
294
+ it('reports a failed call and cleans up on unmount', async () => {
295
+ const mounted = await mountPanel(
296
+ undefined,
297
+ engineAnswering(async () => {
298
+ throw new Error('server is down');
299
+ }),
300
+ );
301
+ mounted.el.querySelector('button')?.click();
302
+ await vi.waitFor(() => expect(mounted.errors).toHaveLength(1));
303
+
304
+ await mounted.unmount();
305
+ expect(mounted.ctx.signal.aborted).toBe(true);
306
+ expect(mounted.el.childElementCount).toBe(0);
307
+ });
308
+ });
309
+ ```
310
+
311
+ `mountForTest(mountable, { props, handle, engine, … })` mounts the component
312
+ into a new `<div>` on a context the test controls. `setProps`, `setTheme` and
313
+ `setLocale` change the context and call the `on…` listeners; `emitted` lists
314
+ the `ctx.emit` calls as `[event, payload]`, `errors` the `ctx.reportError`
315
+ calls; `unmount()` aborts `ctx.signal` and runs the cleanup. Without `app` and
316
+ `engine` any use of them throws, so pass the ones the component touches. The
317
+ `engine` is also what `ctx.callRpc` talks to, as `useRpc` does in a window.
318
+
319
+ ## Try and ship
320
+
321
+ Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS` and open the "Hello"
322
+ panel from the sidebar menu.