@dolphy-app/extension-sdk 0.2.0 → 0.4.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 +25 -8
- package/dist/answer-element-BOQcuxYh.js +114 -0
- package/dist/answer-view-D6wnyThb.d.ts +28 -0
- package/dist/index.d.ts +107 -27
- package/dist/index.js +48 -117
- package/dist/runtime.d.ts +17 -0
- package/dist/runtime.js +24 -0
- package/dist/testing.d.ts +330 -11
- package/dist/testing.js +722 -11
- package/docs/debugging.md +210 -0
- package/docs/no-build.md +99 -0
- package/docs/quick-start.md +172 -0
- package/docs/recipe-command-panel.md +211 -0
- package/docs/recipe-event-storage.md +266 -0
- package/docs/recipe-exercise-type.md +379 -0
- package/docs/recipe-import-export.md +241 -0
- package/docs/recipe-settings.md +161 -0
- package/docs/recipe-theme.md +116 -0
- package/docs/recipe-ui-kit.md +172 -0
- package/docs/recipe-when-dependencies.md +158 -0
- package/package.json +10 -4
|
@@ -0,0 +1,211 @@
|
|
|
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 in an isolated frame that the command can open. This recipe is the
|
|
5
|
+
`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
|
+
"contributes": {
|
|
25
|
+
"commands": [
|
|
26
|
+
{ "id": "acme.hello.hello", "title": "Say hello", "category": "Hello" },
|
|
27
|
+
{ "id": "acme.hello.open", "title": "Open the hello panel", "category": "Hello" },
|
|
28
|
+
{ "id": "acme.hello.data", "title": "Hello panel data", "palette": false }
|
|
29
|
+
],
|
|
30
|
+
"panels": [{ "id": "acme.hello.view", "title": "Hello" }]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- `commands[]`: `title` and `category` are what the palette shows. A command
|
|
36
|
+
with `"palette": false` is hidden from the palette: it is a handler only your
|
|
37
|
+
panel calls.
|
|
38
|
+
- `panels[]` declares the screen; its module defaults to `./panel.mjs`, which
|
|
39
|
+
the build writes.
|
|
40
|
+
- No permissions: commands, panels and `notify` need none.
|
|
41
|
+
|
|
42
|
+
## The code
|
|
43
|
+
|
|
44
|
+
File `src/index.ts` (command-panel):
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import {
|
|
48
|
+
defineExtension,
|
|
49
|
+
defineExtensionPanel,
|
|
50
|
+
notify,
|
|
51
|
+
openPanel,
|
|
52
|
+
} from '@dolphy-app/extension-sdk';
|
|
53
|
+
import type { ExtensionPanels } from '@dolphy-app/extension-sdk';
|
|
54
|
+
|
|
55
|
+
// extension code: `host` runs in the extension process of the app
|
|
56
|
+
// the ids come from extension.json: a misspelt id or a declared id without a
|
|
57
|
+
// handler fails `pnpm typecheck`
|
|
58
|
+
export const host = defineExtension({
|
|
59
|
+
commands: {
|
|
60
|
+
// palette command: shows a notification
|
|
61
|
+
'acme.hello.hello': (args) => {
|
|
62
|
+
const name = typeof args === 'string' ? args : 'world';
|
|
63
|
+
return notify(`Hello, ${name}!`);
|
|
64
|
+
},
|
|
65
|
+
// palette command: opens the panel with properties
|
|
66
|
+
'acme.hello.open': () => openPanel('acme.hello.view', { name: 'Dolphy' }),
|
|
67
|
+
// hidden from the palette (palette: false): the panel asks for data
|
|
68
|
+
'acme.hello.data': () => ({ message: 'Hello from acme.hello' }),
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// the panel runs in an isolated frame of the app window: no network, no
|
|
73
|
+
// window.dolphy; the only way out is `ctx.call` to the commands above
|
|
74
|
+
export const panels = {
|
|
75
|
+
'acme.hello.view': defineExtensionPanel({
|
|
76
|
+
async mount(container, ctx) {
|
|
77
|
+
const doc = container.ownerDocument;
|
|
78
|
+
const title = doc.createElement('h2');
|
|
79
|
+
const line = doc.createElement('p');
|
|
80
|
+
container.append(title, line);
|
|
81
|
+
const name = (props: unknown): string =>
|
|
82
|
+
typeof props === 'object' && props !== null && 'name' in props
|
|
83
|
+
? String(props.name)
|
|
84
|
+
: 'world';
|
|
85
|
+
title.textContent = `Hello, ${name(ctx.props)}!`;
|
|
86
|
+
// the app opens the panel again with new properties: redraw the title
|
|
87
|
+
ctx.signal.addEventListener(
|
|
88
|
+
'abort',
|
|
89
|
+
ctx.onProps((props) => {
|
|
90
|
+
title.textContent = `Hello, ${name(props)}!`;
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
const data = (await ctx.call('acme.hello.data')) as { message: string };
|
|
94
|
+
line.textContent = data.message;
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
} satisfies ExtensionPanels;
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- `commands` maps every declared id to a handler. The result is what the app
|
|
101
|
+
does next: `notify(text)` shows a notification, `openPanel(id, props)` opens a
|
|
102
|
+
panel with properties, any JSON value is data for the caller, nothing is fine.
|
|
103
|
+
`args` is whatever the caller passes, so check its type.
|
|
104
|
+
- A panel is `defineExtensionPanel({ mount(container, ctx) })`. `ctx` has
|
|
105
|
+
`panelId`, `props`, `signal` (aborted when the panel closes), `onProps(listener)`
|
|
106
|
+
for new properties when the command opens the panel again, and
|
|
107
|
+
`ctx.call(commandId, args)`.
|
|
108
|
+
- A panel runs in an isolated frame with no network and no access to the app;
|
|
109
|
+
`ctx.call` to a declared command is the only way out. That is why the data
|
|
110
|
+
command exists.
|
|
111
|
+
- The build writes `host` to `main.mjs` and `panels` to `panel.mjs`.
|
|
112
|
+
|
|
113
|
+
## The tests
|
|
114
|
+
|
|
115
|
+
File `test/index.test.ts` (command-panel):
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
// @vitest-environment happy-dom
|
|
119
|
+
import {
|
|
120
|
+
loadCommands,
|
|
121
|
+
loadPanel,
|
|
122
|
+
} from '@dolphy-app/extension-sdk/testing';
|
|
123
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
124
|
+
import { host, panels } from '../src/index.ts';
|
|
125
|
+
|
|
126
|
+
const disposables: { dispose(): unknown }[] = [];
|
|
127
|
+
afterEach(async () => {
|
|
128
|
+
await Promise.all(disposables.splice(0).map((item) => item.dispose()));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const load = async () => {
|
|
132
|
+
const commands = await loadCommands(host, {
|
|
133
|
+
declaredCommands: ['acme.hello.hello', 'acme.hello.open', 'acme.hello.data'],
|
|
134
|
+
declaredPanels: ['acme.hello.view'],
|
|
135
|
+
});
|
|
136
|
+
disposables.push(commands);
|
|
137
|
+
return commands;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
describe('acme.hello: commands', () => {
|
|
141
|
+
it('hello greets the name from the arguments, "world" without them', async () => {
|
|
142
|
+
const commands = await load();
|
|
143
|
+
expect(await commands.run('acme.hello.hello', 'Ada')).toEqual({
|
|
144
|
+
kind: 'notify',
|
|
145
|
+
text: 'Hello, Ada!',
|
|
146
|
+
});
|
|
147
|
+
expect(await commands.run('acme.hello.hello')).toEqual({
|
|
148
|
+
kind: 'notify',
|
|
149
|
+
text: 'Hello, world!',
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('open asks the app to open the panel with properties', async () => {
|
|
154
|
+
const commands = await load();
|
|
155
|
+
expect(await commands.run('acme.hello.open')).toEqual({
|
|
156
|
+
kind: 'openPanel',
|
|
157
|
+
panelId: 'acme.hello.view',
|
|
158
|
+
props: { name: 'Dolphy' },
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('data returns what the panel shows', async () => {
|
|
163
|
+
const commands = await load();
|
|
164
|
+
expect(await commands.run('acme.hello.data')).toEqual({
|
|
165
|
+
kind: 'data',
|
|
166
|
+
value: { message: 'Hello from acme.hello' },
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('acme.hello: panel', () => {
|
|
172
|
+
it('shows the data command reply and follows new properties', async () => {
|
|
173
|
+
const panel = await loadPanel(panels, 'acme.hello.view', {
|
|
174
|
+
props: { name: 'Ada' },
|
|
175
|
+
call: () => ({ message: 'Hello from the test' }),
|
|
176
|
+
});
|
|
177
|
+
disposables.push(panel);
|
|
178
|
+
expect(panel.container.querySelector('h2')?.textContent).toBe('Hello, Ada!');
|
|
179
|
+
expect(panel.container.querySelector('p')?.textContent).toBe(
|
|
180
|
+
'Hello from the test',
|
|
181
|
+
);
|
|
182
|
+
expect(panel.calls).toEqual([{ commandId: 'acme.hello.data', args: undefined }]);
|
|
183
|
+
|
|
184
|
+
panel.setProps({ name: 'Grace' });
|
|
185
|
+
expect(panel.container.querySelector('h2')?.textContent).toBe(
|
|
186
|
+
'Hello, Grace!',
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('stops listening for properties when the panel closes', async () => {
|
|
191
|
+
const panel = await loadPanel(panels, 'acme.hello.view', {
|
|
192
|
+
call: () => ({ message: 'x' }),
|
|
193
|
+
});
|
|
194
|
+
const heading = panel.container.querySelector('h2');
|
|
195
|
+
panel.dispose();
|
|
196
|
+
expect(panel.aborted).toBe(true);
|
|
197
|
+
panel.setProps({ name: 'Late' });
|
|
198
|
+
expect(heading?.textContent).toBe('Hello, world!');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`loadCommands` runs commands as the host does, with the same rules for results.
|
|
204
|
+
`loadPanel` mounts a panel with a context like the frame's; its `call` option
|
|
205
|
+
answers `ctx.call`, and here it is wired to the real handlers so the panel is
|
|
206
|
+
tested against the command it depends on.
|
|
207
|
+
|
|
208
|
+
## Try and ship
|
|
209
|
+
|
|
210
|
+
Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS`, open the palette and
|
|
211
|
+
run "Open the hello panel".
|
|
@@ -0,0 +1,266 @@
|
|
|
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
|
+
"permissions": ["learning.events"],
|
|
24
|
+
"tags": ["learning"],
|
|
25
|
+
"contributes": {
|
|
26
|
+
"events": [{ "event": "attempt.closed" }],
|
|
27
|
+
"commands": [
|
|
28
|
+
{ "id": "acme.hello.show", "title": "Show the streak", "category": "Streak" },
|
|
29
|
+
{ "id": "acme.hello.data", "title": "Streak data", "palette": false }
|
|
30
|
+
],
|
|
31
|
+
"panels": [{ "id": "acme.hello.view", "title": "Streak" }]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `permissions: ["learning.events"]` and `contributes.events` are both required
|
|
37
|
+
to receive an event. The events are `session.started`, `session.finished` and
|
|
38
|
+
`attempt.closed`. The payloads carry ids, the grade, the outcome and the time,
|
|
39
|
+
never the learner's answer or the exercise text.
|
|
40
|
+
- Mention the permission in your `README.md` and say what it is for: the catalog
|
|
41
|
+
review asks.
|
|
42
|
+
- Storage and settings need no permission.
|
|
43
|
+
|
|
44
|
+
## The code
|
|
45
|
+
|
|
46
|
+
File `src/index.ts` (events):
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import {
|
|
50
|
+
defineExtension,
|
|
51
|
+
defineExtensionPanel,
|
|
52
|
+
inActivate,
|
|
53
|
+
notify,
|
|
54
|
+
openPanel,
|
|
55
|
+
} from '@dolphy-app/extension-sdk';
|
|
56
|
+
import type { ExtensionPanels } from '@dolphy-app/extension-sdk';
|
|
57
|
+
|
|
58
|
+
// a `type`, not an `interface`: an interface has no index signature and is
|
|
59
|
+
// not JSON for `ctx.storage`
|
|
60
|
+
export type Streak = {
|
|
61
|
+
days: number;
|
|
62
|
+
last: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const KEY = 'streak';
|
|
66
|
+
const DAY_MS = 86_400_000;
|
|
67
|
+
|
|
68
|
+
const dayOf = (at: number): string => new Date(at).toISOString().slice(0, 10);
|
|
69
|
+
|
|
70
|
+
// the streak grows when an attempt is closed the day after the last one;
|
|
71
|
+
// the same day changes nothing, a skipped day starts over
|
|
72
|
+
export const advance = (streak: Streak | undefined, at: number): Streak => {
|
|
73
|
+
const day = dayOf(at);
|
|
74
|
+
if (streak?.last === day) return streak;
|
|
75
|
+
const continues =
|
|
76
|
+
streak !== undefined && dayOf(Date.parse(streak.last) + DAY_MS) === day;
|
|
77
|
+
return { days: continues ? streak.days + 1 : 1, last: day };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// every event and command declared in extension.json is listed here;
|
|
81
|
+
// `inActivate` means "registered in `activate`": the handlers need `ctx`
|
|
82
|
+
export const host = defineExtension({
|
|
83
|
+
events: { 'attempt.closed': inActivate },
|
|
84
|
+
commands: { 'acme.hello.show': inActivate, 'acme.hello.data': inActivate },
|
|
85
|
+
activate(ctx) {
|
|
86
|
+
// delivered asynchronously, once per recorded attempt
|
|
87
|
+
ctx.events.on('attempt.closed', async ({ at, outcome }) => {
|
|
88
|
+
if (outcome === 'gave-up') return;
|
|
89
|
+
const streak = await ctx.storage.get<Streak>(KEY);
|
|
90
|
+
await ctx.storage.set(KEY, advance(streak, at));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// data for the panel: hidden from the palette, the panel calls it
|
|
94
|
+
ctx.commands.register(
|
|
95
|
+
'acme.hello.data',
|
|
96
|
+
async () => (await ctx.storage.get<Streak>(KEY)) ?? { days: 0, last: '' },
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
ctx.commands.register('acme.hello.show', async () => {
|
|
100
|
+
const streak = await ctx.storage.get<Streak>(KEY);
|
|
101
|
+
if (streak === undefined) {
|
|
102
|
+
return notify('No streak yet: finish your first exercise.');
|
|
103
|
+
}
|
|
104
|
+
return openPanel('acme.hello.view', { days: streak.days });
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// the panel runs in an isolated frame: no network, the only way out is `ctx.call`
|
|
110
|
+
export const panels = {
|
|
111
|
+
'acme.hello.view': defineExtensionPanel({
|
|
112
|
+
async mount(container, ctx) {
|
|
113
|
+
const line = container.ownerDocument.createElement('p');
|
|
114
|
+
container.append(line);
|
|
115
|
+
const render = async () => {
|
|
116
|
+
const streak = (await ctx.call('acme.hello.data')) as Streak;
|
|
117
|
+
line.textContent =
|
|
118
|
+
streak.days === 0
|
|
119
|
+
? 'No streak yet.'
|
|
120
|
+
: `Streak: ${streak.days} days, last day ${streak.last}`;
|
|
121
|
+
};
|
|
122
|
+
// the command opens the panel again with new properties: redraw
|
|
123
|
+
ctx.signal.addEventListener(
|
|
124
|
+
'abort',
|
|
125
|
+
ctx.onProps(() => void render()),
|
|
126
|
+
);
|
|
127
|
+
await render();
|
|
128
|
+
},
|
|
129
|
+
}),
|
|
130
|
+
} satisfies ExtensionPanels;
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- `ctx.events.on(name, handler)`: one handler per event. Delivery is
|
|
134
|
+
asynchronous, in order and at most once; a handler gets 2 seconds, the queue
|
|
135
|
+
holds 100 events per extension, and a failing handler only reaches the log.
|
|
136
|
+
- `ctx.storage` is JSON under string keys, private to the extension, kept across
|
|
137
|
+
restarts and updates. The value must be JSON: that is why `Streak` is a
|
|
138
|
+
`type`, not an `interface`. Ceilings: key 128 characters, value 64 KiB, 256
|
|
139
|
+
keys, 1 MiB in total; exceeding one throws `StorageQuotaError`.
|
|
140
|
+
- `advance` is a pure function exported for the test. The hidden command
|
|
141
|
+
`acme.hello.data` is how the panel reads the stored value.
|
|
142
|
+
|
|
143
|
+
## The tests
|
|
144
|
+
|
|
145
|
+
File `test/index.test.ts` (events):
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// @vitest-environment happy-dom
|
|
149
|
+
import type { LearningEventPayloads } from '@dolphy-app/extension-api';
|
|
150
|
+
import {
|
|
151
|
+
createMemoryStorage,
|
|
152
|
+
loadCommands,
|
|
153
|
+
loadEvents,
|
|
154
|
+
loadPanel,
|
|
155
|
+
} from '@dolphy-app/extension-sdk/testing';
|
|
156
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
157
|
+
import { host, panels } from '../src/index.ts';
|
|
158
|
+
|
|
159
|
+
const disposables: { dispose(): unknown }[] = [];
|
|
160
|
+
afterEach(async () => {
|
|
161
|
+
await Promise.all(disposables.splice(0).map((item) => item.dispose()));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
type Attempt = LearningEventPayloads['attempt.closed'];
|
|
165
|
+
|
|
166
|
+
const attempt = (day: string, outcome: Attempt['outcome'] = 'passed'): Attempt => ({
|
|
167
|
+
exerciseId: 'e',
|
|
168
|
+
courseId: 'c',
|
|
169
|
+
lessonId: 'l',
|
|
170
|
+
grade: 4,
|
|
171
|
+
outcome,
|
|
172
|
+
source: 'runner',
|
|
173
|
+
at: Date.parse(`${day}T12:00:00Z`),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const load = async () => {
|
|
177
|
+
const storage = createMemoryStorage();
|
|
178
|
+
const events = await loadEvents(host, {
|
|
179
|
+
storage,
|
|
180
|
+
declared: ['attempt.closed'],
|
|
181
|
+
});
|
|
182
|
+
const commands = await loadCommands(host, {
|
|
183
|
+
storage,
|
|
184
|
+
declaredCommands: ['acme.hello.show', 'acme.hello.data'],
|
|
185
|
+
declaredPanels: ['acme.hello.view'],
|
|
186
|
+
});
|
|
187
|
+
disposables.push(events, commands);
|
|
188
|
+
return { storage, events, commands };
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
describe('acme.hello: events and storage', () => {
|
|
192
|
+
it('counts consecutive days, ignores a repeat on the same day', async () => {
|
|
193
|
+
const { storage, events } = await load();
|
|
194
|
+
await events.emit('attempt.closed', attempt('2026-10-01'));
|
|
195
|
+
await events.emit('attempt.closed', attempt('2026-10-01'));
|
|
196
|
+
await events.emit('attempt.closed', attempt('2026-10-02'));
|
|
197
|
+
expect(await storage.get('streak')).toEqual({
|
|
198
|
+
days: 2,
|
|
199
|
+
last: '2026-10-02',
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('a skipped day starts over; giving up leaves the streak alone', async () => {
|
|
204
|
+
const { storage, events } = await load();
|
|
205
|
+
await events.emit('attempt.closed', attempt('2026-10-01'));
|
|
206
|
+
await events.emit('attempt.closed', attempt('2026-10-02'));
|
|
207
|
+
await events.emit('attempt.closed', attempt('2026-10-03', 'gave-up'));
|
|
208
|
+
expect(await storage.get('streak')).toEqual({
|
|
209
|
+
days: 2,
|
|
210
|
+
last: '2026-10-02',
|
|
211
|
+
});
|
|
212
|
+
await events.emit('attempt.closed', attempt('2026-10-05'));
|
|
213
|
+
expect(await storage.get('streak')).toEqual({
|
|
214
|
+
days: 1,
|
|
215
|
+
last: '2026-10-05',
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('acme.hello: commands and panel', () => {
|
|
221
|
+
it('without a streak the show command notifies, the data command returns zeros', async () => {
|
|
222
|
+
const { commands } = await load();
|
|
223
|
+
expect(await commands.run('acme.hello.show')).toMatchObject({ kind: 'notify' });
|
|
224
|
+
expect(await commands.run('acme.hello.data')).toEqual({
|
|
225
|
+
kind: 'data',
|
|
226
|
+
value: { days: 0, last: '' },
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('with a streak the show command opens the panel with the days', async () => {
|
|
231
|
+
const { events, commands } = await load();
|
|
232
|
+
await events.emit('attempt.closed', attempt('2026-10-01'));
|
|
233
|
+
expect(await commands.run('acme.hello.show')).toEqual({
|
|
234
|
+
kind: 'openPanel',
|
|
235
|
+
panelId: 'acme.hello.view',
|
|
236
|
+
props: { days: 1 },
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('the panel shows what the data command returns', async () => {
|
|
241
|
+
const { events, commands } = await load();
|
|
242
|
+
await events.emit('attempt.closed', attempt('2026-10-01'));
|
|
243
|
+
const panel = await loadPanel(panels, 'acme.hello.view', {
|
|
244
|
+
call: async (commandId) => {
|
|
245
|
+
const result = await commands.run(commandId);
|
|
246
|
+
return result.kind === 'data' ? result.value : undefined;
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
disposables.push(panel);
|
|
250
|
+
expect(panel.container.querySelector('p')?.textContent).toBe(
|
|
251
|
+
'Streak: 1 days, last day 2026-10-01',
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`loadEvents` activates the extension and gives `emit(name, payload)`, so the test
|
|
258
|
+
sends events the way the host delivers them. `createMemoryStorage` has the same
|
|
259
|
+
ceilings as the app. Pass the same `storage` to `loadEvents` and `loadCommands`
|
|
260
|
+
and both see one state. Unlike the host, the helpers do not swallow a handler's
|
|
261
|
+
failure and do not count the 2 seconds.
|
|
262
|
+
|
|
263
|
+
## Try and ship
|
|
264
|
+
|
|
265
|
+
Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS`, close an attempt in
|
|
266
|
+
a lesson, then run "Show the streak" from the palette.
|