@dolphy-app/extension-sdk 0.3.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.
@@ -0,0 +1,355 @@
1
+ # Recipe: a command and a panel
2
+
3
+ A command appears in the command palette and runs your code; a panel is a
4
+ screen the command can open: a Vue single-file component the app draws inside
5
+ its own window. This recipe is the `command-panel` template
6
+ (`npx @dolphy-app/create-extension <dir> --id acme.hello --template command-panel`).
7
+ The files below are exactly what the generator writes for the id `acme.hello`.
8
+ See [quick-start.md](quick-start.md) for the commands.
9
+
10
+ ## The manifest
11
+
12
+ File `extension.json` (command-panel):
13
+
14
+ ```json
15
+ {
16
+ "$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
17
+ "id": "acme.hello",
18
+ "version": "0.1.0",
19
+ "apiVersion": 1,
20
+ "name": "Hello panel",
21
+ "description": "Palette commands that greet the learner and open a small panel.",
22
+ "author": "your-github-login",
23
+ "tags": ["productivity"]
24
+ }
25
+ ```
26
+
27
+ The commands and the panel are not in the manifest: the two parts register
28
+ them. `dolphy-ext build` writes `main` and `client` into the built manifest.
29
+
30
+ ## The server part
31
+
32
+ File `src/index.ts` (command-panel):
33
+
34
+ ```ts
35
+ export { client } from './client.ts';
36
+ export { server } from './server.ts';
37
+ ```
38
+
39
+ File `src/server.ts` (command-panel):
40
+
41
+ ```ts
42
+ import { defineServer, notify, openPanel } from '@dolphy-app/extension-sdk';
43
+
44
+ // runs in the extension host: every call registers a contribution
45
+ export const server = defineServer((s) => {
46
+ // palette command: shows a notification
47
+ s.registerCommand({
48
+ id: 'acme.hello.hello',
49
+ title: { en: 'Say hello', ru: 'Поздороваться' },
50
+ category: 'Hello',
51
+ run: (args) => {
52
+ const name = typeof args === 'string' ? args : 'world';
53
+ return notify(`Hello, ${name}!`);
54
+ },
55
+ });
56
+
57
+ // palette command: opens the panel (registered by the client) with properties
58
+ s.registerCommand({
59
+ id: 'acme.hello.open',
60
+ title: { en: 'Open the hello panel', ru: 'Открыть панель' },
61
+ category: 'Hello',
62
+ run: () => openPanel('acme.hello.view', { name: 'Dolphy' }),
63
+ });
64
+
65
+ // hidden from the palette (palette: false): the panel asks for data
66
+ s.registerCommand({
67
+ id: 'acme.hello.data',
68
+ title: 'Hello panel data',
69
+ palette: false,
70
+ run: () => ({ message: 'Hello from acme.hello' }),
71
+ });
72
+ });
73
+ ```
74
+
75
+ - `server.registerCommand({ id, title, run })` adds a command. `title` and
76
+ `category` are what the palette shows (`LocalizedText`: a string or
77
+ `{ en, ru }`). A command with `palette: false` is hidden from the palette: it
78
+ is a handler only your panel calls.
79
+ - `run(args)` returns what the app does next: `notify(text)` shows a
80
+ notification, `openPanel(id, props)` opens a panel with properties, any JSON
81
+ value is data for the caller, nothing is fine. `args` is whatever the caller
82
+ passes, so check its type. A handler has 10 seconds.
83
+ - Optional fields of a command: `description`, `icon`, `keybindings` and `when`
84
+ (see [visibility conditions](recipe-when-dependencies.md)).
85
+
86
+ ## The client part
87
+
88
+ File `src/client.ts` (command-panel):
89
+
90
+ ```ts
91
+ import { defineClient } from '@dolphy-app/extension-sdk';
92
+ import StatusPanel from './StatusPanel.vue';
93
+
94
+ // runs in the app window: the panel is a Vue component the app draws
95
+ export const client = defineClient((c) => {
96
+ c.addPanel({
97
+ id: 'acme.hello.view',
98
+ title: { en: 'Hello', ru: 'Привет' },
99
+ component: StatusPanel,
100
+ });
101
+ });
102
+ ```
103
+
104
+ File `src/StatusPanel.vue` (command-panel):
105
+
106
+ ```vue
107
+ <script setup lang="ts">
108
+ import { usePanel } from '@dolphy-app/extension-sdk/client';
109
+ import { computed, ref } from 'vue';
110
+
111
+ // `usePanel()` gives the panel the properties it was opened with and `call`
112
+ // for the commands of the extension
113
+ const panel = usePanel();
114
+ const message = ref('');
115
+ // the app opens the panel again with new properties: `panel.props` is
116
+ // reactive, the title follows it
117
+ const name = computed(() => {
118
+ const { props } = panel;
119
+ return typeof props === 'object' && props !== null && 'name' in props
120
+ ? String(props.name)
121
+ : 'world';
122
+ });
123
+
124
+ const load = async () => {
125
+ const data = await panel.call('acme.hello.data');
126
+ message.value = (data as { message: string }).message;
127
+ };
128
+ void load();
129
+ </script>
130
+
131
+ <template>
132
+ <section class="status-panel">
133
+ <h2>Hello, {{ name }}!</h2>
134
+ <p>{{ message }}</p>
135
+ <v-btn color="primary" @click="load">Reload</v-btn>
136
+ </section>
137
+ </template>
138
+
139
+ <style scoped>
140
+ .status-panel {
141
+ padding: 16px;
142
+ }
143
+ </style>
144
+ ```
145
+
146
+ - `client.addPanel({ id, title, component })` adds the screen and its entry in
147
+ the sidebar menu. The component is a Vue component, here a single-file
148
+ component (`.vue`), or a `Mountable` that draws with another framework (see
149
+ [recipe-mountable.md](recipe-mountable.md) and [recipe-react.md](recipe-react.md)).
150
+ `vue` is the app's own instance, so the panel shares its theme and language.
151
+ `openPanel('<id>', props)` of a command opens it.
152
+ - A `.vue` file is built as it is: `<script setup lang="ts">`, `<template>`
153
+ and `<style>`. Vuetify components are written as tags (`<v-btn>`,
154
+ `<v-card>`) and Vuetify directives as `v-ripple`, with no import: the build
155
+ turns the ones a template uses into imports from the app's own Vuetify, so
156
+ the theme and the language of the app apply and the bundle does not carry
157
+ Vuetify. A `.vue` file in the server part is a build error.
158
+ - A `<style>` block is not tied to the panel: when `client.mjs` loads, the
159
+ styles of all its components go into one `<style data-dolphy-ext="<extension
160
+ id>">` tag of the window document, so a plain `<style>` reaches the whole
161
+ window. Write `<style scoped>`, as the template does. `<style module>` is
162
+ not supported.
163
+ - `pnpm typecheck` runs `vue-tsc --noEmit`: it checks the `<script setup
164
+ lang="ts">` and the `<template>` of a `.vue` file, which plain `tsc` does not
165
+ look into, and knows `.vue` imports without a `declare module` shim.
166
+ - Inside the component `usePanel()` from `@dolphy-app/extension-sdk/client`
167
+ returns the handle: `panelId`, the reactive `props` the panel was opened with
168
+ (a repeated `openPanel` with new properties updates them in place, so a
169
+ `computed` over `panel.props` follows), the reactive `context`
170
+ (`{ courseId }`) and `call(commandId, args)`.
171
+ - The code of a panel runs in the app window, and `call` is how it reaches the
172
+ commands of the server part, including those with `palette: false`. That is
173
+ why the data command exists.
174
+ - `client.addCommand({ id, title, run })` adds a command whose handler runs in
175
+ the window instead; `run` takes no arguments and returns nothing.
176
+ - The build writes `server` to `main.mjs` and `client` to `client.mjs`.
177
+
178
+ ## The tests
179
+
180
+ File `vitest.config.ts` (command-panel):
181
+
182
+ ```ts
183
+ import vue from '@vitejs/plugin-vue';
184
+ import { defineConfig } from 'vitest/config';
185
+
186
+ export default defineConfig({ plugins: [vue()] });
187
+ ```
188
+
189
+ File `test/index.test.ts` (command-panel):
190
+
191
+ ```ts
192
+ // @vitest-environment happy-dom
193
+ import { PANEL_HANDLE_KEY } from '@dolphy-app/extension-sdk';
194
+ import type { JsonValue, PanelHandle } from '@dolphy-app/extension-sdk';
195
+ import {
196
+ createTestClient,
197
+ createTestServer,
198
+ } from '@dolphy-app/extension-sdk/testing';
199
+ import { afterEach, describe, expect, it } from 'vitest';
200
+ import { createApp, defineComponent, h, nextTick, shallowReactive } from 'vue';
201
+ import { client, server } from '../src/index.ts';
202
+ import StatusPanel from '../src/StatusPanel.vue';
203
+
204
+ const disposables: { dispose(): unknown }[] = [];
205
+ afterEach(async () => {
206
+ await Promise.all(disposables.splice(0).map((item) => item.dispose()));
207
+ });
208
+
209
+ const start = async () => {
210
+ const running = await createTestServer(server, { extensionId: 'acme.hello' });
211
+ disposables.push(running);
212
+ return running;
213
+ };
214
+
215
+ describe('acme.hello: server', () => {
216
+ it('hello greets the name from the arguments, "world" without them', async () => {
217
+ const running = await start();
218
+ expect(await running.commands.run('acme.hello.hello', 'Ada')).toEqual({
219
+ kind: 'notify',
220
+ text: 'Hello, Ada!',
221
+ });
222
+ expect(await running.commands.run('acme.hello.hello')).toEqual({
223
+ kind: 'notify',
224
+ text: 'Hello, world!',
225
+ });
226
+ });
227
+
228
+ it('open asks the app to open the panel with properties', async () => {
229
+ const running = await start();
230
+ expect(await running.commands.run('acme.hello.open')).toEqual({
231
+ kind: 'openPanel',
232
+ panelId: 'acme.hello.view',
233
+ props: { name: 'Dolphy' },
234
+ });
235
+ });
236
+
237
+ it('data returns what the panel shows and stays out of the palette', async () => {
238
+ const running = await start();
239
+ expect(await running.commands.run('acme.hello.data')).toEqual({
240
+ kind: 'data',
241
+ value: { message: 'Hello from acme.hello' },
242
+ });
243
+ const hidden = running.registration.commands.find(
244
+ (command) => command.id === 'acme.hello.data',
245
+ );
246
+ expect(hidden?.palette).toBe(false);
247
+ });
248
+ });
249
+
250
+ describe('acme.hello: client', () => {
251
+ it('adds the panel that the open command points to', async () => {
252
+ const running = await createTestClient(client, { extensionId: 'acme.hello' });
253
+ disposables.push(running);
254
+ expect(running.panels.map((panel) => panel.id)).toEqual(['acme.hello.view']);
255
+ expect(running.panels[0]?.component).toBe(StatusPanel);
256
+ });
257
+ });
258
+
259
+ // the app draws `<v-btn>` with its Vuetify; the test gives the panel a plain button
260
+ const VBtn = defineComponent({
261
+ setup: (_props, { slots }) => () => h('button', slots['default']?.()),
262
+ });
263
+
264
+ // draws the panel the way the app does: the handle is provided to the component
265
+ const mountPanel = async (
266
+ props: JsonValue | undefined,
267
+ call: PanelHandle['call'],
268
+ ) => {
269
+ const handle = shallowReactive({
270
+ panelId: 'acme.hello.view',
271
+ props,
272
+ context: { courseId: null },
273
+ call,
274
+ });
275
+ const host = document.createElement('div');
276
+ document.body.append(host);
277
+ const app = createApp({ render: () => h(StatusPanel) });
278
+ app.component('v-btn', VBtn);
279
+ app.provide(PANEL_HANDLE_KEY, handle);
280
+ app.mount(host);
281
+ disposables.push({
282
+ dispose: () => {
283
+ app.unmount();
284
+ host.remove();
285
+ },
286
+ });
287
+ // the panel asks a command: wait for the reply, then for the redraw
288
+ const settle = async () => {
289
+ await new Promise((resolve) => setTimeout(resolve, 0));
290
+ await nextTick();
291
+ };
292
+ await settle();
293
+ return {
294
+ host,
295
+ reopen: async (next: JsonValue) => {
296
+ handle.props = next;
297
+ await settle();
298
+ },
299
+ };
300
+ };
301
+
302
+ describe('acme.hello: panel', () => {
303
+ it('shows the data command reply and follows new properties', async () => {
304
+ const calls: string[] = [];
305
+ const panel = await mountPanel({ name: 'Ada' }, async (commandId) => {
306
+ calls.push(commandId);
307
+ return { message: 'Hello from the test' };
308
+ });
309
+ expect(panel.host.querySelector('h2')?.textContent).toBe('Hello, Ada!');
310
+ expect(panel.host.querySelector('p')?.textContent).toBe(
311
+ 'Hello from the test',
312
+ );
313
+ expect(calls).toEqual(['acme.hello.data']);
314
+
315
+ await panel.reopen({ name: 'Grace' });
316
+ expect(panel.host.querySelector('h2')?.textContent).toBe('Hello, Grace!');
317
+ });
318
+
319
+ it('asks the data command again when the button is pressed', async () => {
320
+ const calls: string[] = [];
321
+ const panel = await mountPanel(undefined, async (commandId) => {
322
+ calls.push(commandId);
323
+ return { message: 'x' };
324
+ });
325
+ panel.host.querySelector('button')?.click();
326
+ await panel.reopen({});
327
+ expect(calls).toEqual(['acme.hello.data', 'acme.hello.data']);
328
+ });
329
+
330
+ it('greets the world when it is opened without properties', async () => {
331
+ const panel = await mountPanel(undefined, async () => ({ message: 'x' }));
332
+ expect(panel.host.querySelector('h2')?.textContent).toBe('Hello, world!');
333
+ });
334
+ });
335
+ ```
336
+
337
+ `createTestServer(server, { extensionId })` runs commands as the host does,
338
+ with the same rules for results: `running.commands.run(id, args)` resolves to
339
+ `{ kind: 'notify' | 'openPanel' | 'data' | 'none', … }`. `running.registration`
340
+ lists what the server registered, which is how the test sees that `data` is
341
+ hidden from the palette. `createTestClient(client, { extensionId })` records the
342
+ panels, so the test checks that the id the `open` command points to exists.
343
+
344
+ A panel is tested like any Vue component: `createApp` mounts it in `happy-dom`,
345
+ and `app.provide(PANEL_HANDLE_KEY, handle)` gives it the handle the app would
346
+ provide. The `call` of the handle answers `panel.call`; here it is a stub, and
347
+ in the events recipe it is wired to the real handlers. `vitest.config.ts` adds
348
+ `@vitejs/plugin-vue` so that the test can import the `.vue` file. The app gives
349
+ `<v-btn>` its Vuetify; the test registers a plain button under that name
350
+ (`app.component('v-btn', …)`) and tests the panel, not Vuetify.
351
+
352
+ ## Try and ship
353
+
354
+ Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS`, open the palette and
355
+ run "Open the hello panel".
@@ -0,0 +1,326 @@
1
+ # Recipe: events and storage
2
+
3
+ React to what the learner does and remember it. This recipe is the `events`
4
+ template
5
+ (`npx @dolphy-app/create-extension <dir> --id acme.hello --template events`): a
6
+ day streak counter that listens to closed attempts, keeps the streak in storage
7
+ and shows it in a panel. The files below are exactly what the generator writes
8
+ for the id `acme.hello`. See [quick-start.md](quick-start.md) for the commands.
9
+
10
+ ## The manifest
11
+
12
+ File `extension.json` (events):
13
+
14
+ ```json
15
+ {
16
+ "$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
17
+ "id": "acme.hello",
18
+ "version": "0.1.0",
19
+ "apiVersion": 1,
20
+ "name": "Day streak",
21
+ "description": "Counts the days in a row with a closed attempt and shows the streak.",
22
+ "author": "your-github-login",
23
+ "tags": ["learning"]
24
+ }
25
+ ```
26
+
27
+ Nothing about events is in the manifest: `server.on` is the subscription.
28
+
29
+ ## The server part
30
+
31
+ File `src/index.ts` (events):
32
+
33
+ ```ts
34
+ export { client } from './client.ts';
35
+ export { server } from './server.ts';
36
+ ```
37
+
38
+ File `src/streak.ts` (events):
39
+
40
+ ```ts
41
+ // a `type`, not an `interface`: an interface has no index signature and is
42
+ // not JSON for `server.storage`
43
+ export type Streak = {
44
+ days: number;
45
+ last: string;
46
+ };
47
+
48
+ const DAY_MS = 86_400_000;
49
+
50
+ const dayOf = (at: number): string => new Date(at).toISOString().slice(0, 10);
51
+
52
+ // the streak grows when an attempt is closed the day after the last one;
53
+ // the same day changes nothing, a skipped day starts over
54
+ export const advance = (streak: Streak | undefined, at: number): Streak => {
55
+ const day = dayOf(at);
56
+ if (streak?.last === day) return streak;
57
+ const continues =
58
+ streak !== undefined && dayOf(Date.parse(streak.last) + DAY_MS) === day;
59
+ return { days: continues ? streak.days + 1 : 1, last: day };
60
+ };
61
+ ```
62
+
63
+ File `src/server.ts` (events):
64
+
65
+ ```ts
66
+ import { defineServer, notify, openPanel } from '@dolphy-app/extension-sdk';
67
+ import { advance } from './streak.ts';
68
+ import type { Streak } from './streak.ts';
69
+
70
+ const KEY = 'streak';
71
+
72
+ // runs in the extension host: every call registers a contribution
73
+ export const server = defineServer((s) => {
74
+ // delivered asynchronously, once per recorded attempt
75
+ s.on('attempt.closed', async ({ at, outcome }) => {
76
+ if (outcome === 'gave-up') return;
77
+ const streak = await s.storage.get<Streak>(KEY);
78
+ await s.storage.set(KEY, advance(streak, at));
79
+ });
80
+
81
+ // data for the panel: hidden from the palette, the panel calls it
82
+ s.registerCommand({
83
+ id: 'acme.hello.data',
84
+ title: 'Streak data',
85
+ palette: false,
86
+ run: async () =>
87
+ (await s.storage.get<Streak>(KEY)) ?? { days: 0, last: '' },
88
+ });
89
+
90
+ s.registerCommand({
91
+ id: 'acme.hello.show',
92
+ title: { en: 'Show the streak', ru: 'Показать серию' },
93
+ category: 'Streak',
94
+ run: async () => {
95
+ const streak = await s.storage.get<Streak>(KEY);
96
+ if (streak === undefined) {
97
+ return notify('No streak yet: finish your first exercise.');
98
+ }
99
+ return openPanel('acme.hello.view', { days: streak.days });
100
+ },
101
+ });
102
+ });
103
+ ```
104
+
105
+ - `server.on(name, handler)` subscribes to a learning event: `session.started`,
106
+ `session.finished` or `attempt.closed`. The payloads carry ids, the grade, the
107
+ outcome and the time, never the learner's answer or the exercise text. One
108
+ handler per event. Delivery is asynchronous, in order and at most once; a
109
+ handler gets 2 seconds, the queue holds 100 events per extension, and a
110
+ failing handler only reaches the log.
111
+ - `server.storage` is JSON under string keys, private to the extension, kept
112
+ across restarts and updates. The value must be JSON: that is why `Streak` is a
113
+ `type`, not an `interface`. Ceilings: key 128 characters, value 64 KiB, 256
114
+ keys, 1 MiB in total; exceeding one throws `StorageQuotaError`.
115
+ - `advance` is a pure function in its own file, so the test can import it. The
116
+ hidden command `acme.hello.data` is how the panel reads the stored value.
117
+
118
+ ## The client part
119
+
120
+ File `src/client.ts` (events):
121
+
122
+ ```ts
123
+ import { defineClient } from '@dolphy-app/extension-sdk';
124
+ import { StreakPanel } from './streak-panel.ts';
125
+
126
+ // runs in the app window: the panel is a Vue component the app draws
127
+ export const client = defineClient((c) => {
128
+ c.addPanel({
129
+ id: 'acme.hello.view',
130
+ title: { en: 'Streak', ru: 'Серия' },
131
+ component: StreakPanel,
132
+ });
133
+ });
134
+ ```
135
+
136
+ File `src/streak-panel.ts` (events):
137
+
138
+ ```ts
139
+ import { usePanel } from '@dolphy-app/extension-sdk/client';
140
+ import { defineComponent, h, ref, watchEffect } from 'vue';
141
+ import type { Streak } from './streak.ts';
142
+
143
+ // the only way to the data is `panel.call` to the commands of the server
144
+ export const StreakPanel = defineComponent({
145
+ setup() {
146
+ const panel = usePanel();
147
+ const text = ref('');
148
+ // the command opens the panel again with new properties: ask again
149
+ watchEffect(async () => {
150
+ void panel.props;
151
+ const streak = (await panel.call('acme.hello.data')) as Streak;
152
+ text.value =
153
+ streak.days === 0
154
+ ? 'No streak yet.'
155
+ : `Streak: ${streak.days} days, last day ${streak.last}`;
156
+ });
157
+ return () => h('p', text.value);
158
+ },
159
+ });
160
+ ```
161
+
162
+ - The panel is a Vue component registered with `client.addPanel`: `usePanel()`
163
+ gives it `call` for the commands of the server part and the reactive `props`.
164
+ Reading `panel.props` inside `watchEffect` makes the panel ask again when the
165
+ `show` command opens it with new properties.
166
+
167
+ ## The tests
168
+
169
+ File `test/index.test.ts` (events):
170
+
171
+ ```ts
172
+ // @vitest-environment happy-dom
173
+ import { PANEL_HANDLE_KEY } from '@dolphy-app/extension-sdk';
174
+ import type {
175
+ JsonValue,
176
+ LearningEventPayloads,
177
+ PanelHandle,
178
+ } from '@dolphy-app/extension-sdk';
179
+ import {
180
+ createTestClient,
181
+ createTestServer,
182
+ } from '@dolphy-app/extension-sdk/testing';
183
+ import { afterEach, describe, expect, it } from 'vitest';
184
+ import { createApp, h, nextTick, shallowReactive } from 'vue';
185
+ import { client, server } from '../src/index.ts';
186
+ import { StreakPanel } from '../src/streak-panel.ts';
187
+
188
+ const disposables: { dispose(): unknown }[] = [];
189
+ afterEach(async () => {
190
+ await Promise.all(disposables.splice(0).map((item) => item.dispose()));
191
+ });
192
+
193
+ type Attempt = LearningEventPayloads['attempt.closed'];
194
+
195
+ const attempt = (day: string, outcome: Attempt['outcome'] = 'passed'): Attempt => ({
196
+ exerciseId: 'e',
197
+ courseId: 'c',
198
+ lessonId: 'l',
199
+ grade: 4,
200
+ outcome,
201
+ source: 'runner',
202
+ at: Date.parse(`${day}T12:00:00Z`),
203
+ });
204
+
205
+ const start = async () => {
206
+ const running = await createTestServer(server, { extensionId: 'acme.hello' });
207
+ disposables.push(running);
208
+ return running;
209
+ };
210
+
211
+ // draws the panel the way the app does: the handle is provided to the component
212
+ const mountPanel = async (call: PanelHandle['call']) => {
213
+ const handle = shallowReactive({
214
+ panelId: 'acme.hello.view',
215
+ props: undefined as JsonValue | undefined,
216
+ context: { courseId: null },
217
+ call,
218
+ });
219
+ const host = document.createElement('div');
220
+ document.body.append(host);
221
+ const app = createApp({ render: () => h(StreakPanel) });
222
+ app.provide(PANEL_HANDLE_KEY, handle);
223
+ app.mount(host);
224
+ disposables.push({
225
+ dispose: () => {
226
+ app.unmount();
227
+ host.remove();
228
+ },
229
+ });
230
+ // the panel asks a command: wait for the reply, then for the redraw
231
+ await new Promise((resolve) => setTimeout(resolve, 0));
232
+ await nextTick();
233
+ return host;
234
+ };
235
+
236
+ describe('acme.hello: events and storage', () => {
237
+ it('subscribes to attempt.closed', async () => {
238
+ const running = await start();
239
+ expect(running.registration.events).toEqual(['attempt.closed']);
240
+ });
241
+
242
+ it('counts consecutive days, ignores a repeat on the same day', async () => {
243
+ const running = await start();
244
+ await running.events.emit('attempt.closed', attempt('2026-10-01'));
245
+ await running.events.emit('attempt.closed', attempt('2026-10-01'));
246
+ await running.events.emit('attempt.closed', attempt('2026-10-02'));
247
+ expect(await running.storage.get('streak')).toEqual({
248
+ days: 2,
249
+ last: '2026-10-02',
250
+ });
251
+ });
252
+
253
+ it('a skipped day starts over; giving up leaves the streak alone', async () => {
254
+ const running = await start();
255
+ await running.events.emit('attempt.closed', attempt('2026-10-01'));
256
+ await running.events.emit('attempt.closed', attempt('2026-10-02'));
257
+ await running.events.emit('attempt.closed', attempt('2026-10-03', 'gave-up'));
258
+ expect(await running.storage.get('streak')).toEqual({
259
+ days: 2,
260
+ last: '2026-10-02',
261
+ });
262
+ await running.events.emit('attempt.closed', attempt('2026-10-05'));
263
+ expect(await running.storage.get('streak')).toEqual({
264
+ days: 1,
265
+ last: '2026-10-05',
266
+ });
267
+ });
268
+ });
269
+
270
+ describe('acme.hello: commands and panel', () => {
271
+ it('without a streak the show command notifies, the data command returns zeros', async () => {
272
+ const running = await start();
273
+ expect(await running.commands.run('acme.hello.show')).toMatchObject({
274
+ kind: 'notify',
275
+ });
276
+ expect(await running.commands.run('acme.hello.data')).toEqual({
277
+ kind: 'data',
278
+ value: { days: 0, last: '' },
279
+ });
280
+ });
281
+
282
+ it('with a streak the show command opens the panel with the days', async () => {
283
+ const running = await start();
284
+ await running.events.emit('attempt.closed', attempt('2026-10-01'));
285
+ expect(await running.commands.run('acme.hello.show')).toEqual({
286
+ kind: 'openPanel',
287
+ panelId: 'acme.hello.view',
288
+ props: { days: 1 },
289
+ });
290
+ });
291
+
292
+ it('the client adds the panel the show command opens', async () => {
293
+ const running = await createTestClient(client, { extensionId: 'acme.hello' });
294
+ disposables.push(running);
295
+ expect(running.panels.map((panel) => panel.id)).toEqual(['acme.hello.view']);
296
+ });
297
+
298
+ it('the panel shows what the data command returns', async () => {
299
+ const running = await start();
300
+ await running.events.emit('attempt.closed', attempt('2026-10-01'));
301
+ const panel = await mountPanel(async (commandId) => {
302
+ const result = await running.commands.run(commandId);
303
+ return result.kind === 'data' ? (result.value as JsonValue) : undefined;
304
+ });
305
+ expect(panel.querySelector('p')?.textContent).toBe(
306
+ 'Streak: 1 days, last day 2026-10-01',
307
+ );
308
+ });
309
+ });
310
+ ```
311
+
312
+ `createTestServer(server, { extensionId })` starts `server`, and
313
+ `running.events.emit(name, payload)` delivers an event the way the host does and
314
+ awaits the handler; `running.registration.events` lists the subscriptions.
315
+ `running.storage` is in memory with the same ceilings as the app, and the test
316
+ reads what the handler wrote. Unlike the host, the harness does not swallow a
317
+ handler's failure and does not count the 2 seconds.
318
+
319
+ The panel is mounted with `createApp` in `happy-dom`;
320
+ `app.provide(PANEL_HANDLE_KEY, handle)` gives it a handle whose `call` runs the
321
+ real command handlers of the running server.
322
+
323
+ ## Try and ship
324
+
325
+ Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS`, close an attempt in
326
+ a lesson, then run "Show the streak" from the palette.