@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.
- package/README.md +40 -12
- 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 -103
- package/dist/index.js +5 -80
- 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 +248 -121
- package/dist/testing.js +779 -241
- package/docs/debugging.md +228 -0
- package/docs/no-build.md +135 -0
- package/docs/quick-start.md +194 -0
- package/docs/recipe-command-panel.md +355 -0
- package/docs/recipe-event-storage.md +326 -0
- package/docs/recipe-exercise-type.md +467 -0
- package/docs/recipe-hooks.md +158 -0
- package/docs/recipe-import-export.md +236 -0
- 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 +183 -0
- package/docs/recipe-theme.md +148 -0
- package/docs/recipe-when-dependencies.md +264 -0
- package/package.json +31 -8
- package/dist/answer-element-BOQcuxYh.js +0 -114
- package/dist/answer-view-D6wnyThb.d.ts +0 -28
- package/dist/runtime.d.ts +0 -14
- package/dist/runtime.js +0 -18
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 {
|
|
@@ -69,10 +160,59 @@ const createMemoryStorage = () => {
|
|
|
69
160
|
keys: async () => [...entries.keys()].sort(compareKeys)
|
|
70
161
|
};
|
|
71
162
|
};
|
|
163
|
+
/**
|
|
164
|
+
* In-memory secrets with the engine's limits and errors
|
|
165
|
+
* (`EXTENSION_SECRET_LIMITS`, `StorageQuotaError`, `SecretsUnavailableError`):
|
|
166
|
+
* without a key store `set` and `get` of an existing key throw, `get` of a
|
|
167
|
+
* missing key gives `undefined` and `delete` works.
|
|
168
|
+
*/
|
|
169
|
+
const createMemorySecrets = (options = {}) => {
|
|
170
|
+
const limits = EXTENSION_SECRET_LIMITS;
|
|
171
|
+
const entries = /* @__PURE__ */ new Map();
|
|
172
|
+
let available = options.available ?? true;
|
|
173
|
+
const requireStore = () => {
|
|
174
|
+
if (!available) throw new SecretsUnavailableError();
|
|
175
|
+
};
|
|
176
|
+
return {
|
|
177
|
+
setAvailable: (value) => {
|
|
178
|
+
available = value;
|
|
179
|
+
},
|
|
180
|
+
get: async (key) => {
|
|
181
|
+
const value = entries.get(key);
|
|
182
|
+
if (value !== void 0) requireStore();
|
|
183
|
+
return value;
|
|
184
|
+
},
|
|
185
|
+
set: async (key, value) => {
|
|
186
|
+
if (typeof key !== "string" || key.length === 0) throw new Error("secret key must be a non-empty string");
|
|
187
|
+
if (typeof value !== "string") throw new Error("secret value must be a string");
|
|
188
|
+
if (key.length > limits.keyLength) throw new StorageQuotaError("key-length", limits.keyLength);
|
|
189
|
+
if (Buffer.byteLength(value) > limits.valueBytes) throw new StorageQuotaError("value-size", limits.valueBytes);
|
|
190
|
+
requireStore();
|
|
191
|
+
if (!entries.has(key) && entries.size >= limits.keys) throw new StorageQuotaError("key-count", limits.keys);
|
|
192
|
+
entries.set(key, value);
|
|
193
|
+
},
|
|
194
|
+
delete: async (key) => entries.delete(key)
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
/** The value as the host stores it: a color in lower case, a list copied. */
|
|
198
|
+
const storedForm = (definition, value) => {
|
|
199
|
+
if (definition.type === "color" && typeof value === "string") return value.toLowerCase();
|
|
200
|
+
return Array.isArray(value) ? [...value] : value;
|
|
201
|
+
};
|
|
202
|
+
const sameSettingValue = (a, b) => Array.isArray(a) && Array.isArray(b) ? a.length === b.length && a.every((item, index) => item === b[index]) : Object.is(a, b);
|
|
72
203
|
/** Why a value does not fit the setting definition; `null` if it fits. */
|
|
73
204
|
const findSettingProblem = (definition, value) => {
|
|
74
205
|
switch (definition.type) {
|
|
75
206
|
case "boolean": return typeof value === "boolean" ? null : "must be a boolean";
|
|
207
|
+
case "color": return typeof value === "string" && COLOR_SETTING_PATTERN.test(value) ? null : "must be a color #rrggbb";
|
|
208
|
+
case "list": {
|
|
209
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return "must be an array of strings";
|
|
210
|
+
const maxItems = definition.maxItems ?? SETTING_LIMITS.listItems;
|
|
211
|
+
if (value.length > maxItems) return `has more than ${maxItems} items`;
|
|
212
|
+
const itemMax = definition.itemMaxLength ?? SETTING_LIMITS.listItemLength;
|
|
213
|
+
return value.some((item) => item.length > itemMax) ? `has an item longer than ${itemMax} characters` : null;
|
|
214
|
+
}
|
|
215
|
+
case "text":
|
|
76
216
|
case "string":
|
|
77
217
|
if (typeof value !== "string") return "must be a string";
|
|
78
218
|
return definition.maxLength !== void 0 && value.length > definition.maxLength ? `is longer than ${definition.maxLength} characters` : null;
|
|
@@ -84,34 +224,51 @@ const findSettingProblem = (definition, value) => {
|
|
|
84
224
|
default: return typeof value === "string" && definition.options.some((option) => option.value === value) ? null : "must be one of the options";
|
|
85
225
|
}
|
|
86
226
|
};
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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();
|
|
91
235
|
const handlers = /* @__PURE__ */ new Set();
|
|
92
236
|
const known = (id) => {
|
|
93
237
|
const definition = byId.get(id);
|
|
94
|
-
if (definition === void 0) throw new Error(`setting '${id}' is not
|
|
238
|
+
if (definition === void 0) throw new Error(`setting '${id}' is not registered`);
|
|
95
239
|
return definition;
|
|
96
240
|
};
|
|
97
241
|
const checked = (id, value) => {
|
|
98
242
|
const problem = findSettingProblem(known(id), value);
|
|
99
243
|
if (problem !== null) throw new Error(`setting '${id}' ${problem}`);
|
|
100
|
-
return value;
|
|
244
|
+
return storedForm(known(id), value);
|
|
101
245
|
};
|
|
102
|
-
|
|
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);
|
|
103
262
|
return {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return values.get(id);
|
|
107
|
-
},
|
|
263
|
+
register,
|
|
264
|
+
get: (id) => storedForm(known(id), values.get(id)),
|
|
108
265
|
onDidChange(handler) {
|
|
109
266
|
handlers.add(handler);
|
|
110
267
|
return { dispose: () => void handlers.delete(handler) };
|
|
111
268
|
},
|
|
112
269
|
async set(id, value) {
|
|
113
270
|
const next = checked(id, value);
|
|
114
|
-
if (
|
|
271
|
+
if (sameSettingValue(values.get(id), next)) return;
|
|
115
272
|
values.set(id, next);
|
|
116
273
|
for (const handler of [...handlers]) await handler({
|
|
117
274
|
id,
|
|
@@ -120,61 +277,161 @@ const createMemorySettings = (definitions, initial = {}) => {
|
|
|
120
277
|
}
|
|
121
278
|
};
|
|
122
279
|
};
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
280
|
+
const DAY_MS = 864e5;
|
|
281
|
+
const STATS_CORRECT_GRADE_MIN = 3;
|
|
282
|
+
const dayNumberOf = (year, month, day) => {
|
|
283
|
+
const date = /* @__PURE__ */ new Date(0);
|
|
284
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
285
|
+
return Math.floor(date.getTime() / DAY_MS);
|
|
286
|
+
};
|
|
287
|
+
const dayLabelOf = (dayNumber) => (/* @__PURE__ */ new Date(dayNumber * DAY_MS)).toISOString().slice(0, 10);
|
|
288
|
+
const parseDay = (field, value) => {
|
|
289
|
+
const match = typeof value === "string" ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) : null;
|
|
290
|
+
const day = match === null ? null : dayNumberOf(Number(match[1]), Number(match[2]), Number(match[3]));
|
|
291
|
+
if (day === null || dayLabelOf(day) !== value) throw new Error(`'${field}' must be a date as YYYY-MM-DD`);
|
|
292
|
+
return day;
|
|
293
|
+
};
|
|
294
|
+
/**
|
|
295
|
+
* In-memory statistics with the semantics of the app: local days in a time zone,
|
|
296
|
+
* correct at grade 3 or higher, `current` streak not broken while today has no
|
|
297
|
+
* attempts yet, one `daily` entry per date (at most `EXTENSION_STATS_LIMITS.dailyDays`).
|
|
298
|
+
*/
|
|
299
|
+
const createMemoryStats = (options = {}) => {
|
|
300
|
+
const timeZone = options.timeZone ?? new Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
301
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
302
|
+
timeZone,
|
|
303
|
+
calendar: "gregory",
|
|
304
|
+
numberingSystem: "latn",
|
|
305
|
+
year: "numeric",
|
|
306
|
+
month: "2-digit",
|
|
307
|
+
day: "2-digit"
|
|
308
|
+
});
|
|
309
|
+
const localDay = (at) => {
|
|
310
|
+
const parts = Object.fromEntries(formatter.formatToParts(at).map(({ type, value }) => [type, value]));
|
|
311
|
+
return dayNumberOf(Number(parts.year), Number(parts.month), Number(parts.day));
|
|
312
|
+
};
|
|
313
|
+
const attempts = [];
|
|
314
|
+
const record = (attempt) => {
|
|
315
|
+
const at = new Date(attempt.at).getTime();
|
|
316
|
+
if (Number.isNaN(at)) throw new Error("attempt.at is not a valid time");
|
|
317
|
+
attempts.push({
|
|
318
|
+
day: localDay(at),
|
|
319
|
+
correct: attempt.grade >= STATS_CORRECT_GRADE_MIN,
|
|
320
|
+
...attempt.courseId !== void 0 && { courseId: attempt.courseId }
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
for (const attempt of options.attempts ?? []) record(attempt);
|
|
324
|
+
const countsOf = (courseId) => {
|
|
325
|
+
const days = /* @__PURE__ */ new Map();
|
|
326
|
+
for (const item of attempts) {
|
|
327
|
+
if (courseId !== void 0 && item.courseId !== courseId) continue;
|
|
328
|
+
const counts = days.get(item.day) ?? {
|
|
329
|
+
attempts: 0,
|
|
330
|
+
correct: 0
|
|
331
|
+
};
|
|
332
|
+
counts.attempts += 1;
|
|
333
|
+
if (item.correct) counts.correct += 1;
|
|
334
|
+
days.set(item.day, counts);
|
|
335
|
+
}
|
|
336
|
+
return days;
|
|
337
|
+
};
|
|
126
338
|
return {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
339
|
+
record,
|
|
340
|
+
async streak(streakOptions) {
|
|
341
|
+
const days = countsOf(streakOptions?.courseId);
|
|
342
|
+
const today = localDay((options.now ?? Date.now)());
|
|
343
|
+
let longest = 0;
|
|
344
|
+
let run = 0;
|
|
345
|
+
let previous = NaN;
|
|
346
|
+
for (const day of [...days.keys()].sort((a, b) => a - b)) {
|
|
347
|
+
run = day === previous + 1 ? run + 1 : 1;
|
|
348
|
+
longest = Math.max(longest, run);
|
|
349
|
+
previous = day;
|
|
350
|
+
}
|
|
351
|
+
let cursor = days.has(today) ? today : today - 1;
|
|
352
|
+
let current = 0;
|
|
353
|
+
while (days.has(cursor)) {
|
|
354
|
+
current += 1;
|
|
355
|
+
cursor -= 1;
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
current,
|
|
359
|
+
longest
|
|
360
|
+
};
|
|
135
361
|
},
|
|
136
|
-
async
|
|
137
|
-
|
|
362
|
+
async daily({ from, to, courseId }) {
|
|
363
|
+
const first = parseDay("from", from);
|
|
364
|
+
const last = parseDay("to", to);
|
|
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`);
|
|
366
|
+
const days = countsOf(courseId);
|
|
367
|
+
const result = [];
|
|
368
|
+
for (let day = first; day <= last; day += 1) {
|
|
369
|
+
const { attempts: made, correct } = days.get(day) ?? {
|
|
370
|
+
attempts: 0,
|
|
371
|
+
correct: 0
|
|
372
|
+
};
|
|
373
|
+
result.push({
|
|
374
|
+
date: dayLabelOf(day),
|
|
375
|
+
attempts: made,
|
|
376
|
+
correct,
|
|
377
|
+
accuracy: made === 0 ? null : correct / made
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return result;
|
|
138
381
|
}
|
|
139
382
|
};
|
|
140
383
|
};
|
|
141
|
-
/**
|
|
142
|
-
const
|
|
143
|
-
|
|
384
|
+
/** Control characters (except the line feed), line and paragraph separators and text direction marks. */
|
|
385
|
+
const NOTIFICATION_CONTROL = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u2028\u2029\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu;
|
|
386
|
+
const notificationText = (field, value, max) => {
|
|
387
|
+
if (typeof value !== "string") throw new Error(`notification ${field} must be a string`);
|
|
388
|
+
const unified = value.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ");
|
|
389
|
+
const text = (field === "body" ? unified : unified.replace(/\n/gu, " ")).replace(NOTIFICATION_CONTROL, "").trim();
|
|
390
|
+
if (field === "title" && text === "" || [...text].length > max) throw new Error(`notification ${field} must be ${field === "title" ? "1 to " : "up to "}${max} characters`);
|
|
391
|
+
return text;
|
|
392
|
+
};
|
|
393
|
+
/**
|
|
394
|
+
* In-memory notifications with the engine's rules: the text is sanitized and
|
|
395
|
+
* limited, `perMinute`/`perHour` windows throw `NotificationRateLimitError`,
|
|
396
|
+
* a switched-off extension or an unsupporting system resolves `false` (and
|
|
397
|
+
* does not use the rate limit). `shown` is the log the app's fake notifier
|
|
398
|
+
* would keep.
|
|
399
|
+
*/
|
|
400
|
+
const createMemoryNotifications = (options = {}) => {
|
|
401
|
+
const now = options.now ?? Date.now;
|
|
402
|
+
let enabled = options.enabled !== false;
|
|
403
|
+
let supported = options.supported !== false;
|
|
404
|
+
const shown = [];
|
|
405
|
+
const times = [];
|
|
406
|
+
const limits = EXTENSION_NOTIFICATION_LIMITS;
|
|
144
407
|
return {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
handlers.set(id, handler);
|
|
149
|
-
return { dispose: () => {
|
|
150
|
-
if (handlers.get(id) === handler) handlers.delete(id);
|
|
151
|
-
} };
|
|
408
|
+
shown,
|
|
409
|
+
setEnabled: (value) => {
|
|
410
|
+
enabled = value;
|
|
152
411
|
},
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (handler === void 0) throw new Error(`command '${id}' was not registered`);
|
|
156
|
-
if ((JSON.stringify(args)?.length ?? 0) > EXTENSION_COMMAND_LIMITS.argsChars) throw new Error(`args are longer than ${EXTENSION_COMMAND_LIMITS.argsChars} characters`);
|
|
157
|
-
const result = await handler(args);
|
|
158
|
-
try {
|
|
159
|
-
return normalizeCommandResult(result, options.declaredPanels);
|
|
160
|
-
} catch (error) {
|
|
161
|
-
if (error instanceof InvalidCommandResultError) throw new Error(`invalid command result: ${error.message}`);
|
|
162
|
-
throw error;
|
|
163
|
-
}
|
|
412
|
+
setSupported: (value) => {
|
|
413
|
+
supported = value;
|
|
164
414
|
},
|
|
165
|
-
|
|
415
|
+
async show(notification) {
|
|
416
|
+
const title = notificationText("title", notification?.title, limits.titleLength);
|
|
417
|
+
const body = notificationText("body", notification?.body, limits.bodyLength);
|
|
418
|
+
if (!enabled) return false;
|
|
419
|
+
const at = now();
|
|
420
|
+
const recent = times.filter((time) => at - time < 36e5);
|
|
421
|
+
times.length = 0;
|
|
422
|
+
times.push(...recent);
|
|
423
|
+
if (recent.filter((time) => at - time < 6e4).length >= limits.perMinute) throw new NotificationRateLimitError("minute", limits.perMinute);
|
|
424
|
+
if (recent.length >= limits.perHour) throw new NotificationRateLimitError("hour", limits.perHour);
|
|
425
|
+
times.push(at);
|
|
426
|
+
if (!supported) return false;
|
|
427
|
+
shown.push({
|
|
428
|
+
title,
|
|
429
|
+
body
|
|
430
|
+
});
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
166
433
|
};
|
|
167
434
|
};
|
|
168
|
-
const contextOf = (options, registrars) => ({
|
|
169
|
-
extensionId: "test",
|
|
170
|
-
logger: options.logger ?? silentLogger,
|
|
171
|
-
library: options.library ?? createMemoryLibrary({}),
|
|
172
|
-
storage: options.storage ?? createMemoryStorage(),
|
|
173
|
-
settings: options.settings ?? createMemorySettings([]),
|
|
174
|
-
events: options.events ?? createMemoryEvents(),
|
|
175
|
-
commands: options.commands ?? createMemoryCommands(),
|
|
176
|
-
...registrars
|
|
177
|
-
});
|
|
178
435
|
const createSchemaValidator = (schema) => {
|
|
179
436
|
const validate = new Ajv2020({
|
|
180
437
|
allErrors: true,
|
|
@@ -228,219 +485,500 @@ const findGradeResultProblem = (result) => {
|
|
|
228
485
|
if (extra !== void 0) return `unexpected key '${extra}'`;
|
|
229
486
|
return (outcome === "passed" ? null : reasonProblem(result)) ?? textProblem(result, "feedback") ?? textProblem(result, "detail");
|
|
230
487
|
};
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
await module.activate(context);
|
|
241
|
-
const handler = handlers.get(type);
|
|
242
|
-
if (handler === void 0) throw new Error(`exercise type '${type}' was not registered`);
|
|
243
|
-
return {
|
|
244
|
-
project: async (spec, { exerciseId = DEFAULT_EXERCISE_ID } = {}) => handler.project({
|
|
245
|
-
exerciseId,
|
|
246
|
-
spec
|
|
247
|
-
}),
|
|
248
|
-
grade: async ({ spec, answer, exerciseId = DEFAULT_EXERCISE_ID, timeoutMs = DEFAULT_TIMEOUT_MS, authorMode = false }) => {
|
|
249
|
-
const result = await handler.grade({
|
|
250
|
-
exerciseId,
|
|
251
|
-
spec,
|
|
252
|
-
answer,
|
|
253
|
-
timeoutMs,
|
|
254
|
-
authorMode
|
|
255
|
-
});
|
|
256
|
-
const problem = findGradeResultProblem(result);
|
|
257
|
-
if (problem !== null) throw new Error(`invalid grade result: ${problem}`);
|
|
258
|
-
return result;
|
|
259
|
-
},
|
|
260
|
-
referenceAnswer: async (spec, { exerciseId = DEFAULT_EXERCISE_ID } = {}) => {
|
|
261
|
-
const answer = await handler.referenceAnswer?.({
|
|
262
|
-
exerciseId,
|
|
263
|
-
spec
|
|
264
|
-
});
|
|
265
|
-
return answer === void 0 ? { found: false } : {
|
|
266
|
-
found: true,
|
|
267
|
-
answer
|
|
268
|
-
};
|
|
269
|
-
},
|
|
270
|
-
dispose: async () => {
|
|
271
|
-
await module.deactivate?.();
|
|
272
|
-
}
|
|
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
|
|
273
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
|
+
}
|
|
274
534
|
};
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
const handler = handlers.get(id);
|
|
287
|
-
if (handler === void 0) throw new Error(`grade policy '${id}' was not registered`);
|
|
288
|
-
return {
|
|
289
|
-
evaluate: async (input) => {
|
|
290
|
-
const result = await handler(input);
|
|
291
|
-
if (result !== null && !isGradeValue(result)) throw new Error(`invalid grade policy result: ${JSON.stringify(result)} is not an integer 1..5 or null`);
|
|
292
|
-
return result;
|
|
293
|
-
},
|
|
294
|
-
dispose: async () => {
|
|
295
|
-
await module.deactivate?.();
|
|
296
|
-
}
|
|
297
|
-
};
|
|
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;
|
|
298
546
|
};
|
|
299
547
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
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`.
|
|
302
552
|
*/
|
|
303
|
-
const
|
|
304
|
-
const
|
|
553
|
+
const createTestServer = async (entry, options = {}) => {
|
|
554
|
+
const extensionId = options.extensionId ?? "test";
|
|
555
|
+
const library = options.library ?? createMemoryLibrary({});
|
|
305
556
|
const storage = options.storage ?? createMemoryStorage();
|
|
306
|
-
const
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
557
|
+
const secrets = options.secrets ?? createMemorySecrets();
|
|
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,
|
|
310
587
|
storage,
|
|
588
|
+
secrets,
|
|
311
589
|
settings,
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
+
}
|
|
316
640
|
});
|
|
317
|
-
|
|
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`);
|
|
318
642
|
return {
|
|
319
|
-
|
|
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,
|
|
320
687
|
storage,
|
|
688
|
+
secrets,
|
|
321
689
|
settings,
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
};
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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();
|
|
347
812
|
}
|
|
348
813
|
};
|
|
349
814
|
};
|
|
350
|
-
const requireDocument = (helper) => {
|
|
351
|
-
if (typeof document === "undefined") throw new Error(`${helper} needs a DOM: run the test in a DOM environment (happy-dom or jsdom)`);
|
|
352
|
-
return document;
|
|
353
|
-
};
|
|
354
|
-
const microtask = () => Promise.resolve();
|
|
355
|
-
let viewCounter = 0;
|
|
356
815
|
/**
|
|
357
|
-
*
|
|
358
|
-
*
|
|
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.
|
|
359
820
|
*/
|
|
360
|
-
const
|
|
361
|
-
const
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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}.'`);
|
|
831
|
+
};
|
|
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) };
|
|
837
|
+
};
|
|
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) };
|
|
390
862
|
},
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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);
|
|
394
867
|
},
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
868
|
+
addTheme: (reg) => addTo(themes, "theme", reg),
|
|
869
|
+
addCommand: (reg) => addTo(commands, "command", reg)
|
|
870
|
+
});
|
|
871
|
+
return {
|
|
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();
|
|
400
888
|
}
|
|
401
889
|
};
|
|
402
890
|
};
|
|
403
|
-
|
|
404
|
-
const loadPanel = async (panels, id, options = {}) => {
|
|
405
|
-
const doc = requireDocument("loadPanel");
|
|
406
|
-
const panel = panels[id];
|
|
407
|
-
if (panel === void 0) throw new Error(`panel '${id}' was not exported`);
|
|
408
|
-
const calls = [];
|
|
891
|
+
const createNotifier = () => {
|
|
409
892
|
const listeners = /* @__PURE__ */ new Set();
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
await panel.mount(container, {
|
|
413
|
-
panelId: id,
|
|
414
|
-
props: options.props,
|
|
415
|
-
signal: controller.signal,
|
|
416
|
-
call: async (commandId, args) => {
|
|
417
|
-
calls.push({
|
|
418
|
-
commandId,
|
|
419
|
-
args
|
|
420
|
-
});
|
|
421
|
-
if (options.call === void 0) throw new Error(`command '${commandId}' is not available in this test`);
|
|
422
|
-
return options.call(commandId, args);
|
|
423
|
-
},
|
|
424
|
-
onProps: (listener) => {
|
|
893
|
+
return {
|
|
894
|
+
subscribe: (listener) => {
|
|
425
895
|
listeners.add(listener);
|
|
426
896
|
return () => void listeners.delete(listener);
|
|
897
|
+
},
|
|
898
|
+
notify: (value) => {
|
|
899
|
+
for (const listener of [...listeners]) listener(value);
|
|
427
900
|
}
|
|
428
|
-
}
|
|
901
|
+
};
|
|
902
|
+
};
|
|
903
|
+
/**
|
|
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`.
|
|
908
|
+
*/
|
|
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;
|
|
933
|
+
},
|
|
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;
|
|
944
|
+
},
|
|
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
|
|
954
|
+
};
|
|
955
|
+
const unmountComponent = await mountable.mount(el, ctx);
|
|
956
|
+
let unmounted = false;
|
|
429
957
|
return {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
958
|
+
el,
|
|
959
|
+
ctx,
|
|
960
|
+
setProps(next) {
|
|
961
|
+
props = next;
|
|
962
|
+
propsListeners.notify(next);
|
|
963
|
+
},
|
|
964
|
+
setTheme(next) {
|
|
965
|
+
theme = next;
|
|
966
|
+
themeListeners.notify(next);
|
|
434
967
|
},
|
|
435
|
-
|
|
436
|
-
|
|
968
|
+
setLocale(next) {
|
|
969
|
+
locale = next;
|
|
970
|
+
localeListeners.notify(next);
|
|
437
971
|
},
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
972
|
+
emitted,
|
|
973
|
+
errors,
|
|
974
|
+
async unmount() {
|
|
975
|
+
if (unmounted) return;
|
|
976
|
+
unmounted = true;
|
|
977
|
+
abort.abort();
|
|
978
|
+
await unmountComponent();
|
|
441
979
|
}
|
|
442
980
|
};
|
|
443
981
|
};
|
|
444
982
|
|
|
445
983
|
//#endregion
|
|
446
|
-
export {
|
|
984
|
+
export { createMemoryLibrary, createMemoryNotifications, createMemorySecrets, createMemorySettings, createMemoryStats, createMemoryStorage, createSchemaValidator, createTestClient, createTestServer, mountForTest };
|