@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
package/dist/testing.js
CHANGED
|
@@ -1,7 +1,94 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { callRpc } from "./rpc.js";
|
|
2
|
+
import { COLOR_SETTING_PATTERN, DEFAULT_EXTENSION_ICON, EXTENSION_COMMAND_LIMITS, EXTENSION_HOOK_LIMITS, EXTENSION_HOOK_NAMES, EXTENSION_NOTIFICATION_LIMITS, EXTENSION_RPC_LIMITS, EXTENSION_SCHEDULE_LIMITS, EXTENSION_SECRET_LIMITS, EXTENSION_STATS_LIMITS, EXTENSION_STORAGE_LIMITS, EXTENSION_TRANSFER_LIMITS, INJECTION_LIMITS, INJECTION_POSITIONS, InvalidCommandResultError, InvalidTransferResultError, MARKDOWN_LANGUAGE_PATTERN, NotificationRateLimitError, RPC_NAME_PATTERN, SETTING_LIMITS, SecretsUnavailableError, StorageQuotaError, normalizeCommandResult, normalizeExportResult, normalizeImportResult } from "@dolphy-app/extension-api";
|
|
3
|
+
import { z } from "zod";
|
|
3
4
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
4
5
|
|
|
6
|
+
//#region packages/extension-api/src/hooks.ts
|
|
7
|
+
/** Limits on hooks; the host and the engine check the same numbers. */
|
|
8
|
+
const EXTENSION_HOOK_LIMITS$1 = Object.freeze({
|
|
9
|
+
/** Budget of one handler call, ms. */
|
|
10
|
+
timeoutMs: 3e4,
|
|
11
|
+
/** Hooks registered by one extension. */
|
|
12
|
+
hooks: 8,
|
|
13
|
+
/** Exercises in a `practice.batch` response. */
|
|
14
|
+
maxExercises: 500
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region packages/extension-api/src/hook-schemas.ts
|
|
19
|
+
/**
|
|
20
|
+
* Request and response schemas of the hooks in `hooks.ts`. They live apart
|
|
21
|
+
* from the names and types so that code which only registers or types a hook
|
|
22
|
+
* (every browser bundle imports this package) does not pull `zod` in; the host,
|
|
23
|
+
* the engine and the test server import them from
|
|
24
|
+
* `@dolphy-app/extension-api/hook-schemas`.
|
|
25
|
+
*/
|
|
26
|
+
const itemReason = z.enum([
|
|
27
|
+
"review",
|
|
28
|
+
"new",
|
|
29
|
+
"remediation"
|
|
30
|
+
]);
|
|
31
|
+
/** `null` fields: the exercise has no attempts yet, so there is no memory state. */
|
|
32
|
+
const exerciseMemory = z.object({
|
|
33
|
+
/** Probability of recall right now, 0..1. */
|
|
34
|
+
retrievability: z.number().nullable(),
|
|
35
|
+
/** Unix epoch milliseconds of the newest attempt. */
|
|
36
|
+
lastAttemptAt: z.number().nullable(),
|
|
37
|
+
/** Attempts that count (cancelled ones excluded). */
|
|
38
|
+
attempts: z.number(),
|
|
39
|
+
/** FSRS stability in days. */
|
|
40
|
+
stability: z.number().nullable(),
|
|
41
|
+
/** FSRS difficulty. */
|
|
42
|
+
difficulty: z.number().nullable()
|
|
43
|
+
});
|
|
44
|
+
/**
|
|
45
|
+
* Request and response schema of every hook. The handlers of the extensions
|
|
46
|
+
* that registered a hook run one after another in ascending order of the
|
|
47
|
+
* extension id; each gets the response of the previous one (for a hook
|
|
48
|
+
* without a response, the same request). A thrown error, an invalid
|
|
49
|
+
* response or a handler exceeding `EXTENSION_HOOK_LIMITS.timeoutMs` cancels
|
|
50
|
+
* the operation and the error message reaches the user.
|
|
51
|
+
*/
|
|
52
|
+
const EXTENSION_HOOKS = Object.freeze({
|
|
53
|
+
/**
|
|
54
|
+
* A learning session is about to start. The handler can only cancel the
|
|
55
|
+
* start by throwing.
|
|
56
|
+
*/
|
|
57
|
+
"session.start": {
|
|
58
|
+
request: z.object({
|
|
59
|
+
/** Unix epoch milliseconds. */
|
|
60
|
+
now: z.number() }),
|
|
61
|
+
response: z.void()
|
|
62
|
+
},
|
|
63
|
+
/**
|
|
64
|
+
* A batch of exercises or a day plan is about to be handed to the learner.
|
|
65
|
+
* `exerciseIds` and `reasons` are parallel arrays; the response can
|
|
66
|
+
* reorder, remove and add exercises (every id must exist in the library;
|
|
67
|
+
* `exerciseIds` and `reasons` keep the same length, at most
|
|
68
|
+
* `EXTENSION_HOOK_LIMITS.maxExercises`).
|
|
69
|
+
*/
|
|
70
|
+
"practice.batch": {
|
|
71
|
+
request: z.object({
|
|
72
|
+
/** The open session; `null` while none has started. */
|
|
73
|
+
sessionId: z.string().nullable(),
|
|
74
|
+
/** `batch` — `practice.getBatch`, `plan` — `plan.getDay`. */
|
|
75
|
+
source: z.enum(["batch", "plan"]),
|
|
76
|
+
exerciseIds: z.array(z.string()),
|
|
77
|
+
reasons: z.array(itemReason),
|
|
78
|
+
/**
|
|
79
|
+
* Parallel to `exerciseIds`: what the engine remembers of each
|
|
80
|
+
* exercise. Read-only; the FSRS state is not changed by the hook.
|
|
81
|
+
*/
|
|
82
|
+
memory: z.array(exerciseMemory)
|
|
83
|
+
}),
|
|
84
|
+
response: z.object({
|
|
85
|
+
exerciseIds: z.array(z.string()).max(EXTENSION_HOOK_LIMITS$1.maxExercises),
|
|
86
|
+
reasons: z.array(itemReason).max(EXTENSION_HOOK_LIMITS$1.maxExercises)
|
|
87
|
+
}).refine(({ exerciseIds, reasons }) => exerciseIds.length === reasons.length, { message: "exerciseIds and reasons must have the same length" })
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
//#endregion
|
|
5
92
|
//#region packages/extension-sdk/src/testing.ts
|
|
6
93
|
const MAX_MESSAGES = 6;
|
|
7
94
|
const MAX_REASON_CHARS = 100;
|
|
@@ -14,6 +101,10 @@ const silentLogger = {
|
|
|
14
101
|
warn: () => void 0,
|
|
15
102
|
error: () => void 0
|
|
16
103
|
};
|
|
104
|
+
/** What a test did not provide: every property read throws an error that names the option to pass. */
|
|
105
|
+
const unprovided = (name, option) => new Proxy({}, { get: (_target, property) => {
|
|
106
|
+
throw new Error(`${name}.${String(property)} is not available: pass options.${option}`);
|
|
107
|
+
} });
|
|
17
108
|
const createMemoryLibrary = (files) => {
|
|
18
109
|
const entries = new Map(Object.entries(files));
|
|
19
110
|
return {
|
|
@@ -133,14 +224,18 @@ const findSettingProblem = (definition, value) => {
|
|
|
133
224
|
default: return typeof value === "string" && definition.options.some((option) => option.value === value) ? null : "must be one of the options";
|
|
134
225
|
}
|
|
135
226
|
};
|
|
136
|
-
/**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
227
|
+
/**
|
|
228
|
+
* In-memory settings. `definitions` are registered at once, more come through
|
|
229
|
+
* `register`; `initial` provides user values in place of `default`, applied
|
|
230
|
+
* when the setting with that id is registered.
|
|
231
|
+
*/
|
|
232
|
+
const createMemorySettings = (definitions = [], initial = {}) => {
|
|
233
|
+
const byId = /* @__PURE__ */ new Map();
|
|
234
|
+
const values = /* @__PURE__ */ new Map();
|
|
140
235
|
const handlers = /* @__PURE__ */ new Set();
|
|
141
236
|
const known = (id) => {
|
|
142
237
|
const definition = byId.get(id);
|
|
143
|
-
if (definition === void 0) throw new Error(`setting '${id}' is not
|
|
238
|
+
if (definition === void 0) throw new Error(`setting '${id}' is not registered`);
|
|
144
239
|
return definition;
|
|
145
240
|
};
|
|
146
241
|
const checked = (id, value) => {
|
|
@@ -148,8 +243,24 @@ const createMemorySettings = (definitions, initial = {}) => {
|
|
|
148
243
|
if (problem !== null) throw new Error(`setting '${id}' ${problem}`);
|
|
149
244
|
return storedForm(known(id), value);
|
|
150
245
|
};
|
|
151
|
-
|
|
246
|
+
const register = (added) => {
|
|
247
|
+
for (const definition of added) {
|
|
248
|
+
if (byId.has(definition.id)) throw new Error(`setting '${definition.id}' is already registered`);
|
|
249
|
+
byId.set(definition.id, definition);
|
|
250
|
+
values.set(definition.id, storedForm(definition, definition.default));
|
|
251
|
+
const user = initial[definition.id];
|
|
252
|
+
if (user !== void 0) values.set(definition.id, checked(definition.id, user));
|
|
253
|
+
}
|
|
254
|
+
return { dispose: () => {
|
|
255
|
+
for (const definition of added) {
|
|
256
|
+
byId.delete(definition.id);
|
|
257
|
+
values.delete(definition.id);
|
|
258
|
+
}
|
|
259
|
+
} };
|
|
260
|
+
};
|
|
261
|
+
register(definitions);
|
|
152
262
|
return {
|
|
263
|
+
register,
|
|
153
264
|
get: (id) => storedForm(known(id), values.get(id)),
|
|
154
265
|
onDidChange(handler) {
|
|
155
266
|
handlers.add(handler);
|
|
@@ -166,141 +277,6 @@ const createMemorySettings = (definitions, initial = {}) => {
|
|
|
166
277
|
}
|
|
167
278
|
};
|
|
168
279
|
};
|
|
169
|
-
/** In-memory learning event subscriptions: one handler per event, as in the host. */
|
|
170
|
-
const createMemoryEvents = (options = {}) => {
|
|
171
|
-
const handlers = /* @__PURE__ */ new Map();
|
|
172
|
-
return {
|
|
173
|
-
on(name, handler) {
|
|
174
|
-
if (options.permitted === false) throw new PermissionError("learning.events");
|
|
175
|
-
if (options.declared !== void 0 && !options.declared.includes(name)) throw new Error(`event '${name}' is not declared in the manifest`);
|
|
176
|
-
if (handlers.has(name)) throw new Error(`event '${name}' is already subscribed`);
|
|
177
|
-
handlers.set(name, handler);
|
|
178
|
-
return { dispose: () => {
|
|
179
|
-
if (handlers.get(name) === handler) handlers.delete(name);
|
|
180
|
-
} };
|
|
181
|
-
},
|
|
182
|
-
async emit(name, payload) {
|
|
183
|
-
await handlers.get(name)?.(payload);
|
|
184
|
-
}
|
|
185
|
-
};
|
|
186
|
-
};
|
|
187
|
-
/** In-memory commands: the same registration rules and result parsing as the host. */
|
|
188
|
-
const createMemoryCommands = (options = {}) => {
|
|
189
|
-
const handlers = /* @__PURE__ */ new Map();
|
|
190
|
-
return {
|
|
191
|
-
register(id, handler) {
|
|
192
|
-
if (options.declaredCommands !== void 0 && !options.declaredCommands.includes(id)) throw new Error(`command '${id}' is not declared in the manifest`);
|
|
193
|
-
if (handlers.has(id)) throw new Error(`command '${id}' is already registered`);
|
|
194
|
-
handlers.set(id, handler);
|
|
195
|
-
return { dispose: () => {
|
|
196
|
-
if (handlers.get(id) === handler) handlers.delete(id);
|
|
197
|
-
} };
|
|
198
|
-
},
|
|
199
|
-
async run(id, args) {
|
|
200
|
-
const handler = handlers.get(id);
|
|
201
|
-
if (handler === void 0) throw new Error(`command '${id}' was not registered`);
|
|
202
|
-
if ((JSON.stringify(args)?.length ?? 0) > EXTENSION_COMMAND_LIMITS.argsChars) throw new Error(`args are longer than ${EXTENSION_COMMAND_LIMITS.argsChars} characters`);
|
|
203
|
-
const result = await handler(args);
|
|
204
|
-
try {
|
|
205
|
-
return normalizeCommandResult(result, options.declaredPanels);
|
|
206
|
-
} catch (error) {
|
|
207
|
-
if (error instanceof InvalidCommandResultError) throw new Error(`invalid command result: ${error.message}`);
|
|
208
|
-
throw error;
|
|
209
|
-
}
|
|
210
|
-
},
|
|
211
|
-
ids: () => [...handlers.keys()]
|
|
212
|
-
};
|
|
213
|
-
};
|
|
214
|
-
/** In-memory schedule subscriptions: one handler per schedule and no overlapping firings, as in the host. */
|
|
215
|
-
const createMemorySchedule = (options = {}) => {
|
|
216
|
-
const handlers = /* @__PURE__ */ new Map();
|
|
217
|
-
const running = /* @__PURE__ */ new Set();
|
|
218
|
-
return {
|
|
219
|
-
on(id, handler) {
|
|
220
|
-
if (options.declared !== void 0 && !options.declared.includes(id)) throw new Error(`schedule '${id}' is not declared in the manifest`);
|
|
221
|
-
if (handlers.has(id)) throw new Error(`schedule '${id}' is already subscribed`);
|
|
222
|
-
handlers.set(id, handler);
|
|
223
|
-
return { dispose: () => {
|
|
224
|
-
if (handlers.get(id) === handler) handlers.delete(id);
|
|
225
|
-
} };
|
|
226
|
-
},
|
|
227
|
-
async fire(id) {
|
|
228
|
-
const handler = handlers.get(id);
|
|
229
|
-
if (handler === void 0 || running.has(id)) return false;
|
|
230
|
-
running.add(id);
|
|
231
|
-
try {
|
|
232
|
-
await handler();
|
|
233
|
-
return true;
|
|
234
|
-
} finally {
|
|
235
|
-
running.delete(id);
|
|
236
|
-
}
|
|
237
|
-
},
|
|
238
|
-
ids: () => [...handlers.keys()]
|
|
239
|
-
};
|
|
240
|
-
};
|
|
241
|
-
/** In-memory importers: the same registration rules and result checks as the host. */
|
|
242
|
-
const createMemoryImporters = (options = {}) => {
|
|
243
|
-
const handlers = /* @__PURE__ */ new Map();
|
|
244
|
-
const declaredOf = (id) => options.declaredImporters?.find((entry) => entry.id === id);
|
|
245
|
-
return {
|
|
246
|
-
register(id, handler) {
|
|
247
|
-
if (options.declaredImporters !== void 0 && declaredOf(id) === void 0) throw new Error(`importer '${id}' is not declared in the manifest`);
|
|
248
|
-
if (handlers.has(id)) throw new Error(`importer '${id}' is already registered`);
|
|
249
|
-
handlers.set(id, handler);
|
|
250
|
-
return { dispose: () => {
|
|
251
|
-
if (handlers.get(id) === handler) handlers.delete(id);
|
|
252
|
-
} };
|
|
253
|
-
},
|
|
254
|
-
async run(id, input) {
|
|
255
|
-
const handler = handlers.get(id);
|
|
256
|
-
if (handler === void 0) throw new Error(`importer '${id}' was not registered`);
|
|
257
|
-
const expected = declaredOf(id)?.input ?? "text";
|
|
258
|
-
if (options.declaredImporters !== void 0 && ("text" in input ? "text" : "bytes") !== expected) throw new Error(`importer '${id}' takes ${expected} input`);
|
|
259
|
-
if (("text" in input ? new TextEncoder().encode(input.text).length : input.bytes.byteLength) > EXTENSION_TRANSFER_LIMITS.inputBytes) throw new Error(`the file is longer than ${EXTENSION_TRANSFER_LIMITS.inputBytes} bytes`);
|
|
260
|
-
const result = await handler(input);
|
|
261
|
-
try {
|
|
262
|
-
return normalizeImportResult(result);
|
|
263
|
-
} catch (error) {
|
|
264
|
-
if (error instanceof InvalidTransferResultError) throw new Error(`invalid import result: ${error.message}`);
|
|
265
|
-
throw error;
|
|
266
|
-
}
|
|
267
|
-
},
|
|
268
|
-
ids: () => [...handlers.keys()]
|
|
269
|
-
};
|
|
270
|
-
};
|
|
271
|
-
/** In-memory exporters: the same registration rules and result checks as the host. */
|
|
272
|
-
const createMemoryExporters = (options = {}) => {
|
|
273
|
-
const handlers = /* @__PURE__ */ new Map();
|
|
274
|
-
const declaredOf = (id) => options.declaredExporters?.find((entry) => entry.id === id);
|
|
275
|
-
return {
|
|
276
|
-
register(id, handler) {
|
|
277
|
-
if (options.declaredExporters !== void 0 && declaredOf(id) === void 0) throw new Error(`exporter '${id}' is not declared in the manifest`);
|
|
278
|
-
if (handlers.has(id)) throw new Error(`exporter '${id}' is already registered`);
|
|
279
|
-
handlers.set(id, handler);
|
|
280
|
-
return { dispose: () => {
|
|
281
|
-
if (handlers.get(id) === handler) handlers.delete(id);
|
|
282
|
-
} };
|
|
283
|
-
},
|
|
284
|
-
async run(id, input) {
|
|
285
|
-
const handler = handlers.get(id);
|
|
286
|
-
if (handler === void 0) throw new Error(`exporter '${id}' was not registered`);
|
|
287
|
-
const declared = declaredOf(id);
|
|
288
|
-
if (declared !== void 0 && declared.scope !== input.scope) throw new Error(`exporter '${id}' takes the ${declared.scope} scope`);
|
|
289
|
-
if (input.scope === "course") {
|
|
290
|
-
const encoder = new TextEncoder();
|
|
291
|
-
if (Object.values(input.files).reduce((sum, text) => sum + encoder.encode(text).length, 0) > EXTENSION_TRANSFER_LIMITS.totalBytes) throw new Error(`the course files are longer than ${EXTENSION_TRANSFER_LIMITS.totalBytes} bytes`);
|
|
292
|
-
}
|
|
293
|
-
const result = await handler(input);
|
|
294
|
-
try {
|
|
295
|
-
return normalizeExportResult(result);
|
|
296
|
-
} catch (error) {
|
|
297
|
-
if (error instanceof InvalidTransferResultError) throw new Error(`invalid export result: ${error.message}`);
|
|
298
|
-
throw error;
|
|
299
|
-
}
|
|
300
|
-
},
|
|
301
|
-
ids: () => [...handlers.keys()]
|
|
302
|
-
};
|
|
303
|
-
};
|
|
304
280
|
const DAY_MS = 864e5;
|
|
305
281
|
const STATS_CORRECT_GRADE_MIN = 3;
|
|
306
282
|
const dayNumberOf = (year, month, day) => {
|
|
@@ -345,9 +321,6 @@ const createMemoryStats = (options = {}) => {
|
|
|
345
321
|
});
|
|
346
322
|
};
|
|
347
323
|
for (const attempt of options.attempts ?? []) record(attempt);
|
|
348
|
-
const guard = () => {
|
|
349
|
-
if (options.permitted === false) throw new PermissionError("learning.stats");
|
|
350
|
-
};
|
|
351
324
|
const countsOf = (courseId) => {
|
|
352
325
|
const days = /* @__PURE__ */ new Map();
|
|
353
326
|
for (const item of attempts) {
|
|
@@ -365,7 +338,6 @@ const createMemoryStats = (options = {}) => {
|
|
|
365
338
|
return {
|
|
366
339
|
record,
|
|
367
340
|
async streak(streakOptions) {
|
|
368
|
-
guard();
|
|
369
341
|
const days = countsOf(streakOptions?.courseId);
|
|
370
342
|
const today = localDay((options.now ?? Date.now)());
|
|
371
343
|
let longest = 0;
|
|
@@ -388,7 +360,6 @@ const createMemoryStats = (options = {}) => {
|
|
|
388
360
|
};
|
|
389
361
|
},
|
|
390
362
|
async daily({ from, to, courseId }) {
|
|
391
|
-
guard();
|
|
392
363
|
const first = parseDay("from", from);
|
|
393
364
|
const last = parseDay("to", to);
|
|
394
365
|
if (last < first || last - first + 1 > EXTENSION_STATS_LIMITS.dailyDays) throw new Error(`the range must be ascending and cover at most ${EXTENSION_STATS_LIMITS.dailyDays} dates`);
|
|
@@ -442,7 +413,6 @@ const createMemoryNotifications = (options = {}) => {
|
|
|
442
413
|
supported = value;
|
|
443
414
|
},
|
|
444
415
|
async show(notification) {
|
|
445
|
-
if (options.permitted === false) throw new PermissionError("notifications");
|
|
446
416
|
const title = notificationText("title", notification?.title, limits.titleLength);
|
|
447
417
|
const body = notificationText("body", notification?.body, limits.bodyLength);
|
|
448
418
|
if (!enabled) return false;
|
|
@@ -462,22 +432,6 @@ const createMemoryNotifications = (options = {}) => {
|
|
|
462
432
|
}
|
|
463
433
|
};
|
|
464
434
|
};
|
|
465
|
-
const contextOf = (options, registrars) => ({
|
|
466
|
-
extensionId: "test",
|
|
467
|
-
logger: options.logger ?? silentLogger,
|
|
468
|
-
library: options.library ?? createMemoryLibrary({}),
|
|
469
|
-
storage: options.storage ?? createMemoryStorage(),
|
|
470
|
-
secrets: options.secrets ?? createMemorySecrets(),
|
|
471
|
-
settings: options.settings ?? createMemorySettings([]),
|
|
472
|
-
events: options.events ?? createMemoryEvents(),
|
|
473
|
-
stats: options.stats ?? createMemoryStats(),
|
|
474
|
-
notifications: options.notifications ?? createMemoryNotifications(),
|
|
475
|
-
schedule: options.schedule ?? createMemorySchedule(),
|
|
476
|
-
commands: options.commands ?? createMemoryCommands(),
|
|
477
|
-
importers: options.importers ?? createMemoryImporters(),
|
|
478
|
-
exporters: options.exporters ?? createMemoryExporters(),
|
|
479
|
-
...registrars
|
|
480
|
-
});
|
|
481
435
|
const createSchemaValidator = (schema) => {
|
|
482
436
|
const validate = new Ajv2020({
|
|
483
437
|
allErrors: true,
|
|
@@ -531,344 +485,500 @@ const findGradeResultProblem = (result) => {
|
|
|
531
485
|
if (extra !== void 0) return `unexpected key '${extra}'`;
|
|
532
486
|
return (outcome === "passed" ? null : reasonProblem(result)) ?? textProblem(result, "feedback") ?? textProblem(result, "detail");
|
|
533
487
|
};
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
await module.activate(context);
|
|
544
|
-
const handler = handlers.get(type);
|
|
545
|
-
if (handler === void 0) throw new Error(`exercise type '${type}' was not registered`);
|
|
546
|
-
return {
|
|
547
|
-
project: async (spec, { exerciseId = DEFAULT_EXERCISE_ID } = {}) => handler.project({
|
|
548
|
-
exerciseId,
|
|
549
|
-
spec
|
|
550
|
-
}),
|
|
551
|
-
grade: async ({ spec, answer, exerciseId = DEFAULT_EXERCISE_ID, timeoutMs = DEFAULT_TIMEOUT_MS, authorMode = false }) => {
|
|
552
|
-
const result = await handler.grade({
|
|
553
|
-
exerciseId,
|
|
554
|
-
spec,
|
|
555
|
-
answer,
|
|
556
|
-
timeoutMs,
|
|
557
|
-
authorMode
|
|
558
|
-
});
|
|
559
|
-
const problem = findGradeResultProblem(result);
|
|
560
|
-
if (problem !== null) throw new Error(`invalid grade result: ${problem}`);
|
|
561
|
-
return result;
|
|
562
|
-
},
|
|
563
|
-
referenceAnswer: async (spec, { exerciseId = DEFAULT_EXERCISE_ID } = {}) => {
|
|
564
|
-
const answer = await handler.referenceAnswer?.({
|
|
565
|
-
exerciseId,
|
|
566
|
-
spec
|
|
567
|
-
});
|
|
568
|
-
return answer === void 0 ? { found: false } : {
|
|
569
|
-
found: true,
|
|
570
|
-
answer
|
|
571
|
-
};
|
|
572
|
-
},
|
|
573
|
-
dispose: async () => {
|
|
574
|
-
await module.deactivate?.();
|
|
575
|
-
}
|
|
488
|
+
const isGradeValue = (value) => typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 5;
|
|
489
|
+
const registeredSettingOf = (definition) => {
|
|
490
|
+
const base = {
|
|
491
|
+
id: definition.id,
|
|
492
|
+
label: definition.label,
|
|
493
|
+
description: definition.description ?? null,
|
|
494
|
+
group: definition.group ?? null,
|
|
495
|
+
order: definition.order ?? 0,
|
|
496
|
+
visibleWhen: definition.visibleWhen ?? null
|
|
576
497
|
};
|
|
498
|
+
switch (definition.type) {
|
|
499
|
+
case "boolean":
|
|
500
|
+
case "color": return {
|
|
501
|
+
...base,
|
|
502
|
+
type: definition.type,
|
|
503
|
+
default: definition.default
|
|
504
|
+
};
|
|
505
|
+
case "string":
|
|
506
|
+
case "text": return {
|
|
507
|
+
...base,
|
|
508
|
+
type: definition.type,
|
|
509
|
+
default: definition.default,
|
|
510
|
+
maxLength: definition.maxLength ?? null
|
|
511
|
+
};
|
|
512
|
+
case "list": return {
|
|
513
|
+
...base,
|
|
514
|
+
type: "list",
|
|
515
|
+
default: [...definition.default],
|
|
516
|
+
maxItems: definition.maxItems ?? SETTING_LIMITS.listItems,
|
|
517
|
+
itemMaxLength: definition.itemMaxLength ?? SETTING_LIMITS.listItemLength
|
|
518
|
+
};
|
|
519
|
+
case "number": return {
|
|
520
|
+
...base,
|
|
521
|
+
type: "number",
|
|
522
|
+
default: definition.default,
|
|
523
|
+
min: definition.min ?? null,
|
|
524
|
+
max: definition.max ?? null,
|
|
525
|
+
integer: definition.integer ?? false
|
|
526
|
+
};
|
|
527
|
+
default: return {
|
|
528
|
+
...base,
|
|
529
|
+
type: "enum",
|
|
530
|
+
default: definition.default,
|
|
531
|
+
options: definition.options.map((option) => ({ ...option }))
|
|
532
|
+
};
|
|
533
|
+
}
|
|
577
534
|
};
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
const handler = handlers.get(id);
|
|
590
|
-
if (handler === void 0) throw new Error(`grade policy '${id}' was not registered`);
|
|
591
|
-
return {
|
|
592
|
-
evaluate: async (input) => {
|
|
593
|
-
const result = await handler(input);
|
|
594
|
-
if (result !== null && !isGradeValue(result)) throw new Error(`invalid grade policy result: ${JSON.stringify(result)} is not an integer 1..5 or null`);
|
|
595
|
-
return result;
|
|
596
|
-
},
|
|
597
|
-
dispose: async () => {
|
|
598
|
-
await module.deactivate?.();
|
|
599
|
-
}
|
|
600
|
-
};
|
|
535
|
+
const registeredKeybindingOf = (binding) => ({
|
|
536
|
+
key: binding.key,
|
|
537
|
+
mac: binding.mac ?? null,
|
|
538
|
+
windows: binding.windows ?? null,
|
|
539
|
+
linux: binding.linux ?? null,
|
|
540
|
+
when: binding.when ?? null
|
|
541
|
+
});
|
|
542
|
+
const registered = (map, kind, id) => {
|
|
543
|
+
const entry = map.get(id);
|
|
544
|
+
if (entry === void 0) throw new Error(`${kind} '${id}' was not registered`);
|
|
545
|
+
return entry;
|
|
601
546
|
};
|
|
602
547
|
/**
|
|
603
|
-
*
|
|
604
|
-
*
|
|
548
|
+
* Starts `entry` (the `server` export of an extension) with in-memory
|
|
549
|
+
* storage, secrets, settings, statistics, notifications and library, and
|
|
550
|
+
* returns a harness to call what it registered. Registration is all or
|
|
551
|
+
* nothing, as in the host: when `entry` throws, so does `createTestServer`.
|
|
605
552
|
*/
|
|
606
|
-
const
|
|
607
|
-
const
|
|
553
|
+
const createTestServer = async (entry, options = {}) => {
|
|
554
|
+
const extensionId = options.extensionId ?? "test";
|
|
555
|
+
const library = options.library ?? createMemoryLibrary({});
|
|
608
556
|
const storage = options.storage ?? createMemoryStorage();
|
|
609
557
|
const secrets = options.secrets ?? createMemorySecrets();
|
|
610
|
-
const
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
558
|
+
const stats = options.stats ?? createMemoryStats();
|
|
559
|
+
const notifications = options.notifications ?? createMemoryNotifications();
|
|
560
|
+
const settings = createMemorySettings([], options.settingValues);
|
|
561
|
+
const exerciseTypes = /* @__PURE__ */ new Map();
|
|
562
|
+
const gradePolicies = /* @__PURE__ */ new Map();
|
|
563
|
+
const commands = /* @__PURE__ */ new Map();
|
|
564
|
+
const schedules = /* @__PURE__ */ new Map();
|
|
565
|
+
const importers = /* @__PURE__ */ new Map();
|
|
566
|
+
const exporters = /* @__PURE__ */ new Map();
|
|
567
|
+
const rpcs = /* @__PURE__ */ new Map();
|
|
568
|
+
const engine = options.engine ?? unprovided("engine", "engine");
|
|
569
|
+
const events = /* @__PURE__ */ new Map();
|
|
570
|
+
const hooks = /* @__PURE__ */ new Map();
|
|
571
|
+
const definitions = [];
|
|
572
|
+
const running = /* @__PURE__ */ new Set();
|
|
573
|
+
const add = (map, kind, id, value, max) => {
|
|
574
|
+
if (typeof id !== "string" || id === "") throw new Error(`${kind} id must be a non-empty string`);
|
|
575
|
+
if (options.extensionId !== void 0 && id !== extensionId && !id.startsWith(`${extensionId}.`)) throw new Error(`${kind} id '${id}' must be '${extensionId}' or start with '${extensionId}.'`);
|
|
576
|
+
if (map.has(id)) throw new Error(`${kind} '${id}' is already registered`);
|
|
577
|
+
if (max !== void 0 && map.size >= max) throw new Error(`more than ${max} ${kind}s`);
|
|
578
|
+
map.set(id, value);
|
|
579
|
+
return { dispose: () => {
|
|
580
|
+
if (map.get(id) === value) map.delete(id);
|
|
581
|
+
} };
|
|
582
|
+
};
|
|
583
|
+
const cleanup = await entry({
|
|
584
|
+
extensionId,
|
|
585
|
+
logger: options.logger ?? silentLogger,
|
|
586
|
+
library,
|
|
617
587
|
storage,
|
|
618
588
|
secrets,
|
|
619
589
|
settings,
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
590
|
+
stats,
|
|
591
|
+
notifications,
|
|
592
|
+
engine,
|
|
593
|
+
registerExerciseType: (reg) => add(exerciseTypes, "exercise type", reg.id, reg),
|
|
594
|
+
registerGradePolicy: (reg) => add(gradePolicies, "grade policy", reg.id, reg),
|
|
595
|
+
registerSettings: (added) => {
|
|
596
|
+
const registeredSettings = settings.register(added);
|
|
597
|
+
definitions.push(...added);
|
|
598
|
+
return { dispose: () => {
|
|
599
|
+
registeredSettings.dispose();
|
|
600
|
+
for (const definition of added) definitions.splice(definitions.indexOf(definition), 1);
|
|
601
|
+
} };
|
|
602
|
+
},
|
|
603
|
+
on: (name, handler) => {
|
|
604
|
+
if (events.has(name)) throw new Error(`event '${name}' is already subscribed`);
|
|
605
|
+
events.set(name, handler);
|
|
606
|
+
return { dispose: () => {
|
|
607
|
+
if (events.get(name) === handler) events.delete(name);
|
|
608
|
+
} };
|
|
609
|
+
},
|
|
610
|
+
before: (name, handler) => {
|
|
611
|
+
if (!EXTENSION_HOOK_NAMES.includes(name)) throw new Error(`hook '${String(name)}' is not a known hook`);
|
|
612
|
+
if (hooks.has(name)) throw new Error(`hook '${name}' is already registered`);
|
|
613
|
+
if (hooks.size >= EXTENSION_HOOK_LIMITS.hooks) throw new Error(`more than ${EXTENSION_HOOK_LIMITS.hooks} hooks`);
|
|
614
|
+
hooks.set(name, handler);
|
|
615
|
+
return { dispose: () => {
|
|
616
|
+
if (hooks.get(name) === handler) hooks.delete(name);
|
|
617
|
+
} };
|
|
618
|
+
},
|
|
619
|
+
registerCommand: (reg) => add(commands, "command", reg.id, reg, EXTENSION_COMMAND_LIMITS.commands),
|
|
620
|
+
schedule: (reg, handler) => add(schedules, "schedule", reg.id, {
|
|
621
|
+
reg,
|
|
622
|
+
handler
|
|
623
|
+
}, EXTENSION_SCHEDULE_LIMITS.schedules),
|
|
624
|
+
registerImporter: (reg) => add(importers, "importer", reg.id, reg, EXTENSION_TRANSFER_LIMITS.importers),
|
|
625
|
+
registerExporter: (reg) => add(exporters, "exporter", reg.id, reg, EXTENSION_TRANSFER_LIMITS.exporters),
|
|
626
|
+
handle: (contract, handler) => {
|
|
627
|
+
const { name } = contract;
|
|
628
|
+
if (typeof name !== "string" || name.length > EXTENSION_RPC_LIMITS.nameLength || !RPC_NAME_PATTERN.test(name)) throw new Error(`rpc name '${String(name)}' is not valid`);
|
|
629
|
+
if (rpcs.has(name)) throw new Error(`rpc '${name}' is already registered`);
|
|
630
|
+
if (rpcs.size >= EXTENSION_RPC_LIMITS.rpcs) throw new Error(`more than ${EXTENSION_RPC_LIMITS.rpcs} rpcs`);
|
|
631
|
+
const entry = {
|
|
632
|
+
contract,
|
|
633
|
+
handler
|
|
634
|
+
};
|
|
635
|
+
rpcs.set(name, entry);
|
|
636
|
+
return { dispose: () => {
|
|
637
|
+
if (rpcs.get(name) === entry) rpcs.delete(name);
|
|
638
|
+
} };
|
|
639
|
+
}
|
|
624
640
|
});
|
|
625
|
-
|
|
641
|
+
for (const id of Object.keys(options.settingValues ?? {})) if (!definitions.some((definition) => definition.id === id)) throw new Error(`setting '${id}' of settingValues is not registered`);
|
|
626
642
|
return {
|
|
627
|
-
|
|
643
|
+
extensionId,
|
|
644
|
+
registration: {
|
|
645
|
+
exerciseTypes: [...exerciseTypes.values()].map((reg) => ({
|
|
646
|
+
id: reg.id,
|
|
647
|
+
title: reg.title ?? null,
|
|
648
|
+
specSchema: reg.specSchema,
|
|
649
|
+
answerSchema: reg.answerSchema
|
|
650
|
+
})),
|
|
651
|
+
gradePolicies: [...gradePolicies.values()].map((reg) => ({
|
|
652
|
+
id: reg.id,
|
|
653
|
+
label: reg.label
|
|
654
|
+
})),
|
|
655
|
+
settings: definitions.map(registeredSettingOf),
|
|
656
|
+
events: [...events.keys()],
|
|
657
|
+
commands: [...commands.values()].map((reg) => ({
|
|
658
|
+
id: reg.id,
|
|
659
|
+
title: reg.title,
|
|
660
|
+
description: reg.description ?? null,
|
|
661
|
+
category: reg.category ?? null,
|
|
662
|
+
palette: reg.palette ?? true,
|
|
663
|
+
icon: reg.icon ?? DEFAULT_EXTENSION_ICON,
|
|
664
|
+
keybindings: (reg.keybindings ?? []).map(registeredKeybindingOf),
|
|
665
|
+
when: reg.when ?? null
|
|
666
|
+
})),
|
|
667
|
+
schedules: [...schedules.values()].map(({ reg }) => ({
|
|
668
|
+
id: reg.id,
|
|
669
|
+
every: reg.every,
|
|
670
|
+
at: reg.every === "daily" ? reg.at : null
|
|
671
|
+
})),
|
|
672
|
+
importers: [...importers.values()].map((reg) => ({
|
|
673
|
+
id: reg.id,
|
|
674
|
+
title: reg.title,
|
|
675
|
+
accept: [...reg.accept],
|
|
676
|
+
input: reg.input
|
|
677
|
+
})),
|
|
678
|
+
exporters: [...exporters.values()].map((reg) => ({
|
|
679
|
+
id: reg.id,
|
|
680
|
+
title: reg.title,
|
|
681
|
+
scope: reg.scope
|
|
682
|
+
})),
|
|
683
|
+
rpcs: [...rpcs.keys()],
|
|
684
|
+
hooks: [...hooks.keys()]
|
|
685
|
+
},
|
|
686
|
+
library,
|
|
628
687
|
storage,
|
|
629
688
|
secrets,
|
|
630
689
|
settings,
|
|
631
|
-
|
|
632
|
-
|
|
690
|
+
stats,
|
|
691
|
+
notifications,
|
|
692
|
+
engine,
|
|
693
|
+
commands: { async run(id, args) {
|
|
694
|
+
const { run } = registered(commands, "command", id);
|
|
695
|
+
if ((JSON.stringify(args)?.length ?? 0) > EXTENSION_COMMAND_LIMITS.argsChars) throw new Error(`args are longer than ${EXTENSION_COMMAND_LIMITS.argsChars} characters`);
|
|
696
|
+
const result = await run(args);
|
|
697
|
+
try {
|
|
698
|
+
return normalizeCommandResult(result, void 0);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
if (error instanceof InvalidCommandResultError) throw new Error(`invalid command result: ${error.message}`);
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
} },
|
|
704
|
+
events: { async emit(name, payload) {
|
|
705
|
+
await events.get(name)?.(payload);
|
|
706
|
+
} },
|
|
707
|
+
schedule: { async fire(id) {
|
|
708
|
+
const { handler } = registered(schedules, "schedule", id);
|
|
709
|
+
if (running.has(id)) return false;
|
|
710
|
+
running.add(id);
|
|
711
|
+
try {
|
|
712
|
+
await handler();
|
|
713
|
+
return true;
|
|
714
|
+
} finally {
|
|
715
|
+
running.delete(id);
|
|
716
|
+
}
|
|
717
|
+
} },
|
|
718
|
+
async rpc(contract, input) {
|
|
719
|
+
const { contract: registeredContract, handler } = registered(rpcs, "rpc", contract.name);
|
|
720
|
+
if ((JSON.stringify(input)?.length ?? 0) > EXTENSION_RPC_LIMITS.inputChars) throw new Error(`input is longer than ${EXTENSION_RPC_LIMITS.inputChars} characters`);
|
|
721
|
+
const result = await handler(registeredContract.input.parse(input));
|
|
722
|
+
return registeredContract.output.parse(result);
|
|
723
|
+
},
|
|
724
|
+
async hook(name, request) {
|
|
725
|
+
const handler = registered(hooks, "hook", name);
|
|
726
|
+
const schemas = EXTENSION_HOOKS[name];
|
|
727
|
+
const result = await handler(schemas.request.parse(request));
|
|
728
|
+
return schemas.response.parse(result);
|
|
729
|
+
},
|
|
730
|
+
exerciseType(id) {
|
|
731
|
+
const reg = registered(exerciseTypes, "exercise type", id);
|
|
732
|
+
return {
|
|
733
|
+
project: async (spec, { exerciseId = DEFAULT_EXERCISE_ID } = {}) => reg.project({
|
|
734
|
+
exerciseId,
|
|
735
|
+
spec
|
|
736
|
+
}),
|
|
737
|
+
grade: async ({ spec, answer, exerciseId = DEFAULT_EXERCISE_ID, timeoutMs = DEFAULT_TIMEOUT_MS, authorMode = false }) => {
|
|
738
|
+
const result = await reg.grade({
|
|
739
|
+
exerciseId,
|
|
740
|
+
spec,
|
|
741
|
+
answer,
|
|
742
|
+
timeoutMs,
|
|
743
|
+
authorMode
|
|
744
|
+
});
|
|
745
|
+
const problem = findGradeResultProblem(result);
|
|
746
|
+
if (problem !== null) throw new Error(`invalid grade result: ${problem}`);
|
|
747
|
+
return result;
|
|
748
|
+
},
|
|
749
|
+
referenceAnswer: async (spec, { exerciseId = DEFAULT_EXERCISE_ID } = {}) => {
|
|
750
|
+
const answer = await reg.referenceAnswer?.({
|
|
751
|
+
exerciseId,
|
|
752
|
+
spec
|
|
753
|
+
});
|
|
754
|
+
return answer === void 0 ? { found: false } : {
|
|
755
|
+
found: true,
|
|
756
|
+
answer
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
},
|
|
761
|
+
gradePolicy(id) {
|
|
762
|
+
const reg = registered(gradePolicies, "grade policy", id);
|
|
763
|
+
return { evaluate: async (input) => {
|
|
764
|
+
const result = await reg.evaluate(input);
|
|
765
|
+
if (result !== null && !isGradeValue(result)) throw new Error(`invalid grade policy result: ${JSON.stringify(result)} is not an integer 1..5 or null`);
|
|
766
|
+
return result;
|
|
767
|
+
} };
|
|
768
|
+
},
|
|
769
|
+
importer(id) {
|
|
770
|
+
const reg = registered(importers, "importer", id);
|
|
771
|
+
return { async run(input) {
|
|
772
|
+
if (("text" in input ? "text" : "bytes") !== reg.input) throw new Error(`importer '${id}' takes ${reg.input} input`);
|
|
773
|
+
if (("text" in input ? new TextEncoder().encode(input.text).length : input.bytes.byteLength) > EXTENSION_TRANSFER_LIMITS.inputBytes) throw new Error(`the file is longer than ${EXTENSION_TRANSFER_LIMITS.inputBytes} bytes`);
|
|
774
|
+
const result = await reg.run(input);
|
|
775
|
+
try {
|
|
776
|
+
return normalizeImportResult(result);
|
|
777
|
+
} catch (error) {
|
|
778
|
+
if (error instanceof InvalidTransferResultError) throw new Error(`invalid import result: ${error.message}`);
|
|
779
|
+
throw error;
|
|
780
|
+
}
|
|
781
|
+
} };
|
|
782
|
+
},
|
|
783
|
+
exporter(id) {
|
|
784
|
+
const reg = registered(exporters, "exporter", id);
|
|
785
|
+
return { async run(input) {
|
|
786
|
+
if (reg.scope !== input.scope) throw new Error(`exporter '${id}' takes the ${reg.scope} scope`);
|
|
787
|
+
if (input.scope === "course") {
|
|
788
|
+
const encoder = new TextEncoder();
|
|
789
|
+
if (Object.values(input.files).reduce((sum, text) => sum + encoder.encode(text).length, 0) > EXTENSION_TRANSFER_LIMITS.totalBytes) throw new Error(`the course files are longer than ${EXTENSION_TRANSFER_LIMITS.totalBytes} bytes`);
|
|
790
|
+
}
|
|
791
|
+
const result = await reg.run(input);
|
|
792
|
+
try {
|
|
793
|
+
return normalizeExportResult(result);
|
|
794
|
+
} catch (error) {
|
|
795
|
+
if (error instanceof InvalidTransferResultError) throw new Error(`invalid export result: ${error.message}`);
|
|
796
|
+
throw error;
|
|
797
|
+
}
|
|
798
|
+
} };
|
|
799
|
+
},
|
|
800
|
+
async dispose() {
|
|
801
|
+
exerciseTypes.clear();
|
|
802
|
+
gradePolicies.clear();
|
|
803
|
+
commands.clear();
|
|
804
|
+
schedules.clear();
|
|
805
|
+
importers.clear();
|
|
806
|
+
exporters.clear();
|
|
807
|
+
rpcs.clear();
|
|
808
|
+
events.clear();
|
|
809
|
+
hooks.clear();
|
|
810
|
+
if (typeof cleanup === "function") await cleanup();
|
|
811
|
+
else if (cleanup !== void 0) await cleanup.dispose();
|
|
633
812
|
}
|
|
634
813
|
};
|
|
635
814
|
};
|
|
636
|
-
/**
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
registerGradePolicy: () => ({ dispose: () => void 0 })
|
|
653
|
-
});
|
|
654
|
-
await module.activate(context);
|
|
655
|
-
return {
|
|
656
|
-
run: commands.run,
|
|
657
|
-
ids: commands.ids,
|
|
658
|
-
dispose: async () => {
|
|
659
|
-
await module.deactivate?.();
|
|
660
|
-
}
|
|
815
|
+
/**
|
|
816
|
+
* Starts `entry` (the `client` export of an extension) on a context that
|
|
817
|
+
* records what it adds, so a test can mount the components and read the
|
|
818
|
+
* themes and commands. An id added twice and an injection with a bad target
|
|
819
|
+
* or position fail like in the window.
|
|
820
|
+
*/
|
|
821
|
+
const createTestClient = async (entry, options = {}) => {
|
|
822
|
+
const extensionId = options.extensionId ?? "test";
|
|
823
|
+
const panels = [];
|
|
824
|
+
const injections = [];
|
|
825
|
+
const answerViews = /* @__PURE__ */ new Map();
|
|
826
|
+
const markdownRenderers = /* @__PURE__ */ new Map();
|
|
827
|
+
const themes = [];
|
|
828
|
+
const commands = [];
|
|
829
|
+
const checkId = (kind, id) => {
|
|
830
|
+
if (options.extensionId !== void 0 && id !== extensionId && !id.startsWith(`${extensionId}.`)) throw new Error(`${kind} id '${id}' must be '${extensionId}' or start with '${extensionId}.'`);
|
|
661
831
|
};
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
...options.storage !== void 0 && { storage: options.storage },
|
|
668
|
-
...options.secrets !== void 0 && { secrets: options.secrets },
|
|
669
|
-
...options.settings !== void 0 && { settings: options.settings },
|
|
670
|
-
...options.events !== void 0 && { events: options.events },
|
|
671
|
-
...options.commands !== void 0 && { commands: options.commands },
|
|
672
|
-
...options.stats !== void 0 && { stats: options.stats },
|
|
673
|
-
...options.notifications !== void 0 && { notifications: options.notifications },
|
|
674
|
-
...options.schedule !== void 0 && { schedule: options.schedule }
|
|
675
|
-
});
|
|
676
|
-
/** Activates the module with in-memory importers and lets the test run them like the host. */
|
|
677
|
-
const loadImporters = async (module, options = {}) => {
|
|
678
|
-
const importers = createMemoryImporters(options);
|
|
679
|
-
const context = contextOf({
|
|
680
|
-
...loadOptionsOf(options),
|
|
681
|
-
importers
|
|
682
|
-
}, {
|
|
683
|
-
registerExerciseType: () => ({ dispose: () => void 0 }),
|
|
684
|
-
registerGradePolicy: () => ({ dispose: () => void 0 })
|
|
685
|
-
});
|
|
686
|
-
await module.activate(context);
|
|
687
|
-
return {
|
|
688
|
-
run: importers.run,
|
|
689
|
-
ids: importers.ids,
|
|
690
|
-
dispose: async () => {
|
|
691
|
-
await module.deactivate?.();
|
|
692
|
-
}
|
|
832
|
+
const addTo = (list, kind, value) => {
|
|
833
|
+
checkId(kind, value.id);
|
|
834
|
+
if (list.some((item) => item.id === value.id)) throw new Error(`${kind} '${value.id}' is already added`);
|
|
835
|
+
list.push(value);
|
|
836
|
+
return { dispose: () => void list.splice(list.indexOf(value), 1) };
|
|
693
837
|
};
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
838
|
+
const addToMap = (map, kind, key, component) => {
|
|
839
|
+
if (map.has(key)) throw new Error(`${kind} '${key}' is already added`);
|
|
840
|
+
map.set(key, component);
|
|
841
|
+
return { dispose: () => {
|
|
842
|
+
if (map.get(key) === component) map.delete(key);
|
|
843
|
+
} };
|
|
844
|
+
};
|
|
845
|
+
const cleanup = await entry({
|
|
846
|
+
extensionId,
|
|
847
|
+
app: options.app ?? unprovided("app", "app"),
|
|
848
|
+
engine: options.engine ?? unprovided("engine", "engine"),
|
|
849
|
+
addPanel: (reg) => addTo(panels, "panel", reg),
|
|
850
|
+
addInjection: (reg) => {
|
|
851
|
+
const { target } = reg;
|
|
852
|
+
if (target.length === 0 || target.length > INJECTION_LIMITS.selectorLength) throw new Error(`injection target must be 1–${INJECTION_LIMITS.selectorLength} characters`);
|
|
853
|
+
const position = reg.position ?? "append";
|
|
854
|
+
if (!INJECTION_POSITIONS.includes(position)) throw new Error(`injection position '${position}' is not valid`);
|
|
855
|
+
if (injections.some(({ id }) => id === reg.id)) throw new Error(`injection '${reg.id}' is already added`);
|
|
856
|
+
const added = {
|
|
857
|
+
...reg,
|
|
858
|
+
position
|
|
859
|
+
};
|
|
860
|
+
injections.push(added);
|
|
861
|
+
return { dispose: () => void injections.splice(injections.indexOf(added), 1) };
|
|
862
|
+
},
|
|
863
|
+
addAnswerView: (id, component) => addToMap(answerViews, "answer view", id, component),
|
|
864
|
+
addMarkdownRenderer: (language, component) => {
|
|
865
|
+
if (!MARKDOWN_LANGUAGE_PATTERN.test(language)) throw new Error(`markdown language '${language}' is not valid`);
|
|
866
|
+
return addToMap(markdownRenderers, "markdown renderer", language, component);
|
|
867
|
+
},
|
|
868
|
+
addTheme: (reg) => addTo(themes, "theme", reg),
|
|
869
|
+
addCommand: (reg) => addTo(commands, "command", reg)
|
|
704
870
|
});
|
|
705
|
-
await module.activate(context);
|
|
706
871
|
return {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
872
|
+
extensionId,
|
|
873
|
+
panels,
|
|
874
|
+
injections,
|
|
875
|
+
answerViews,
|
|
876
|
+
markdownRenderers,
|
|
877
|
+
themes,
|
|
878
|
+
commands,
|
|
879
|
+
async dispose() {
|
|
880
|
+
panels.length = 0;
|
|
881
|
+
injections.length = 0;
|
|
882
|
+
answerViews.clear();
|
|
883
|
+
markdownRenderers.clear();
|
|
884
|
+
themes.length = 0;
|
|
885
|
+
commands.length = 0;
|
|
886
|
+
if (typeof cleanup === "function") await cleanup();
|
|
887
|
+
else if (cleanup !== void 0) await cleanup.dispose();
|
|
711
888
|
}
|
|
712
889
|
};
|
|
713
890
|
};
|
|
714
|
-
|
|
715
|
-
const
|
|
716
|
-
const schedule = createMemorySchedule(options);
|
|
717
|
-
const context = contextOf({
|
|
718
|
-
...loadOptionsOf(options),
|
|
719
|
-
schedule
|
|
720
|
-
}, {
|
|
721
|
-
registerExerciseType: () => ({ dispose: () => void 0 }),
|
|
722
|
-
registerGradePolicy: () => ({ dispose: () => void 0 })
|
|
723
|
-
});
|
|
724
|
-
await module.activate(context);
|
|
891
|
+
const createNotifier = () => {
|
|
892
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
725
893
|
return {
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
894
|
+
subscribe: (listener) => {
|
|
895
|
+
listeners.add(listener);
|
|
896
|
+
return () => void listeners.delete(listener);
|
|
897
|
+
},
|
|
898
|
+
notify: (value) => {
|
|
899
|
+
for (const listener of [...listeners]) listener(value);
|
|
730
900
|
}
|
|
731
901
|
};
|
|
732
902
|
};
|
|
733
|
-
const requireDocument = (helper) => {
|
|
734
|
-
if (typeof document === "undefined") throw new Error(`${helper} needs a DOM: run the test in a DOM environment (happy-dom or jsdom)`);
|
|
735
|
-
return document;
|
|
736
|
-
};
|
|
737
|
-
const microtask = () => Promise.resolve();
|
|
738
|
-
let viewCounter = 0;
|
|
739
903
|
/**
|
|
740
|
-
* Mounts a
|
|
741
|
-
* the
|
|
904
|
+
* Mounts a `Mountable` into an element on a recording context, so a test can
|
|
905
|
+
* change the props, the theme and the language, read what the component
|
|
906
|
+
* emitted and reported, and unmount it. Rejects with what `mount` throws. Needs
|
|
907
|
+
* a DOM (`document`) or `options.el`.
|
|
742
908
|
*/
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
let
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
return {
|
|
768
|
-
element,
|
|
769
|
-
root,
|
|
770
|
-
changes,
|
|
771
|
-
get submissions() {
|
|
772
|
-
return submissions;
|
|
909
|
+
const mountForTest = async (mountable, options) => {
|
|
910
|
+
let el = options.el;
|
|
911
|
+
if (el === void 0) {
|
|
912
|
+
if (typeof document === "undefined") throw new Error("mountForTest needs a DOM: run the test in happy-dom or jsdom, or pass options.el");
|
|
913
|
+
el = document.createElement("div");
|
|
914
|
+
}
|
|
915
|
+
const extensionId = options.extensionId ?? "test";
|
|
916
|
+
const engine = options.engine ?? unprovided("engine", "engine");
|
|
917
|
+
const abort = new AbortController();
|
|
918
|
+
const emitted = [];
|
|
919
|
+
const errors = [];
|
|
920
|
+
const propsListeners = createNotifier();
|
|
921
|
+
const themeListeners = createNotifier();
|
|
922
|
+
const localeListeners = createNotifier();
|
|
923
|
+
let props = options.props;
|
|
924
|
+
let theme = options.theme ?? {
|
|
925
|
+
id: "light",
|
|
926
|
+
dark: false
|
|
927
|
+
};
|
|
928
|
+
let locale = options.locale ?? "en";
|
|
929
|
+
const handle = options.handle;
|
|
930
|
+
const ctx = {
|
|
931
|
+
get props() {
|
|
932
|
+
return props;
|
|
773
933
|
},
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
934
|
+
onProps: propsListeners.subscribe,
|
|
935
|
+
emit: (event, payload) => void emitted.push([event, payload]),
|
|
936
|
+
app: options.app ?? unprovided("app", "app"),
|
|
937
|
+
engine,
|
|
938
|
+
callRpc: (contract, input) => callRpc({
|
|
939
|
+
engine,
|
|
940
|
+
extensionId
|
|
941
|
+
}, contract, input),
|
|
942
|
+
get theme() {
|
|
943
|
+
return theme;
|
|
777
944
|
},
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
945
|
+
onTheme: themeListeners.subscribe,
|
|
946
|
+
get locale() {
|
|
947
|
+
return locale;
|
|
948
|
+
},
|
|
949
|
+
onLocale: localeListeners.subscribe,
|
|
950
|
+
extensionId,
|
|
951
|
+
signal: abort.signal,
|
|
952
|
+
reportError: (error) => void errors.push(error),
|
|
953
|
+
handle
|
|
784
954
|
};
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
const createFrameContext = (options, doc) => {
|
|
788
|
-
const calls = [];
|
|
789
|
-
const listeners = /* @__PURE__ */ new Set();
|
|
790
|
-
const controller = new AbortController();
|
|
791
|
-
const container = options.container ?? doc.body.appendChild(doc.createElement("div"));
|
|
792
|
-
let current = { courseId: options.context?.courseId ?? null };
|
|
955
|
+
const unmountComponent = await mountable.mount(el, ctx);
|
|
956
|
+
let unmounted = false;
|
|
793
957
|
return {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
call: async (commandId, args) => {
|
|
800
|
-
calls.push({
|
|
801
|
-
commandId,
|
|
802
|
-
args
|
|
803
|
-
});
|
|
804
|
-
if (options.call === void 0) throw new Error(`command '${commandId}' is not available in this test`);
|
|
805
|
-
return options.call(commandId, args);
|
|
806
|
-
},
|
|
807
|
-
onContextChange: (listener) => {
|
|
808
|
-
listeners.add(listener);
|
|
809
|
-
return () => void listeners.delete(listener);
|
|
810
|
-
}
|
|
958
|
+
el,
|
|
959
|
+
ctx,
|
|
960
|
+
setProps(next) {
|
|
961
|
+
props = next;
|
|
962
|
+
propsListeners.notify(next);
|
|
811
963
|
},
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
get aborted() {
|
|
816
|
-
return controller.signal.aborted;
|
|
817
|
-
},
|
|
818
|
-
setContext: (next) => {
|
|
819
|
-
current = { courseId: next.courseId };
|
|
820
|
-
for (const listener of [...listeners]) listener(current);
|
|
821
|
-
},
|
|
822
|
-
dispose: () => {
|
|
823
|
-
controller.abort();
|
|
824
|
-
if (options.container === void 0) container.remove();
|
|
825
|
-
}
|
|
964
|
+
setTheme(next) {
|
|
965
|
+
theme = next;
|
|
966
|
+
themeListeners.notify(next);
|
|
826
967
|
},
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
/** Mounts a panel from `panels[id]` in the test DOM environment with the same context the frame provides. */
|
|
831
|
-
const loadPanel = async (panels, id, options = {}) => {
|
|
832
|
-
const doc = requireDocument("loadPanel");
|
|
833
|
-
const panel = panels[id];
|
|
834
|
-
if (panel === void 0) throw new Error(`panel '${id}' was not exported`);
|
|
835
|
-
const { context, frame, container } = createFrameContext(options, doc);
|
|
836
|
-
const listeners = /* @__PURE__ */ new Set();
|
|
837
|
-
await panel.mount(container, {
|
|
838
|
-
get context() {
|
|
839
|
-
return context.context;
|
|
968
|
+
setLocale(next) {
|
|
969
|
+
locale = next;
|
|
970
|
+
localeListeners.notify(next);
|
|
840
971
|
},
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
return () => void listeners.delete(listener);
|
|
972
|
+
emitted,
|
|
973
|
+
errors,
|
|
974
|
+
async unmount() {
|
|
975
|
+
if (unmounted) return;
|
|
976
|
+
unmounted = true;
|
|
977
|
+
abort.abort();
|
|
978
|
+
await unmountComponent();
|
|
849
979
|
}
|
|
850
|
-
}
|
|
851
|
-
return Object.assign(frame, { setProps: (props) => {
|
|
852
|
-
for (const listener of [...listeners]) listener(props);
|
|
853
|
-
} });
|
|
854
|
-
};
|
|
855
|
-
/** Mounts a widget from `widgets[id]` in the test DOM environment with the same context the frame provides. */
|
|
856
|
-
const loadWidget = async (widgets, id, options = {}) => {
|
|
857
|
-
const doc = requireDocument("loadWidget");
|
|
858
|
-
const widget = widgets[id];
|
|
859
|
-
if (widget === void 0) throw new Error(`widget '${id}' was not exported`);
|
|
860
|
-
const { context, frame, container } = createFrameContext(options, doc);
|
|
861
|
-
await widget.mount(container, {
|
|
862
|
-
get context() {
|
|
863
|
-
return context.context;
|
|
864
|
-
},
|
|
865
|
-
signal: context.signal,
|
|
866
|
-
call: context.call,
|
|
867
|
-
onContextChange: context.onContextChange,
|
|
868
|
-
widgetId: id
|
|
869
|
-
});
|
|
870
|
-
return frame;
|
|
980
|
+
};
|
|
871
981
|
};
|
|
872
982
|
|
|
873
983
|
//#endregion
|
|
874
|
-
export {
|
|
984
|
+
export { createMemoryLibrary, createMemoryNotifications, createMemorySecrets, createMemorySettings, createMemoryStats, createMemoryStorage, createSchemaValidator, createTestClient, createTestServer, mountForTest };
|