@dolphy-app/extension-sdk 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -1
- package/dist/index.d.ts +14 -6
- package/dist/index.js +7 -1
- package/dist/runtime.d.ts +4 -1
- package/dist/runtime.js +7 -1
- package/dist/testing.d.ts +190 -6
- package/dist/testing.js +462 -34
- package/docs/debugging.md +210 -0
- package/docs/no-build.md +99 -0
- package/docs/quick-start.md +172 -0
- package/docs/recipe-command-panel.md +211 -0
- package/docs/recipe-event-storage.md +266 -0
- package/docs/recipe-exercise-type.md +379 -0
- package/docs/recipe-import-export.md +241 -0
- package/docs/recipe-settings.md +161 -0
- package/docs/recipe-theme.md +116 -0
- package/docs/recipe-ui-kit.md +172 -0
- package/docs/recipe-when-dependencies.md +158 -0
- package/package.json +4 -3
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as createAnswerElementClass } from "./answer-element-BOQcuxYh.js";
|
|
2
|
-
import { ANSWER_EVENT, EXTENSION_COMMAND_LIMITS, EXTENSION_STORAGE_LIMITS, InvalidCommandResultError, PermissionError, StorageQuotaError, normalizeCommandResult } from "@dolphy-app/extension-api";
|
|
2
|
+
import { ANSWER_EVENT, COLOR_SETTING_PATTERN, EXTENSION_COMMAND_LIMITS, EXTENSION_NOTIFICATION_LIMITS, EXTENSION_SECRET_LIMITS, EXTENSION_STATS_LIMITS, EXTENSION_STORAGE_LIMITS, EXTENSION_TRANSFER_LIMITS, InvalidCommandResultError, InvalidTransferResultError, NotificationRateLimitError, PermissionError, SETTING_LIMITS, SecretsUnavailableError, StorageQuotaError, normalizeCommandResult, normalizeExportResult, normalizeImportResult } from "@dolphy-app/extension-api";
|
|
3
3
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
4
4
|
|
|
5
5
|
//#region packages/extension-sdk/src/testing.ts
|
|
@@ -69,10 +69,59 @@ const createMemoryStorage = () => {
|
|
|
69
69
|
keys: async () => [...entries.keys()].sort(compareKeys)
|
|
70
70
|
};
|
|
71
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* In-memory secrets with the engine's limits and errors
|
|
74
|
+
* (`EXTENSION_SECRET_LIMITS`, `StorageQuotaError`, `SecretsUnavailableError`):
|
|
75
|
+
* without a key store `set` and `get` of an existing key throw, `get` of a
|
|
76
|
+
* missing key gives `undefined` and `delete` works.
|
|
77
|
+
*/
|
|
78
|
+
const createMemorySecrets = (options = {}) => {
|
|
79
|
+
const limits = EXTENSION_SECRET_LIMITS;
|
|
80
|
+
const entries = /* @__PURE__ */ new Map();
|
|
81
|
+
let available = options.available ?? true;
|
|
82
|
+
const requireStore = () => {
|
|
83
|
+
if (!available) throw new SecretsUnavailableError();
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
setAvailable: (value) => {
|
|
87
|
+
available = value;
|
|
88
|
+
},
|
|
89
|
+
get: async (key) => {
|
|
90
|
+
const value = entries.get(key);
|
|
91
|
+
if (value !== void 0) requireStore();
|
|
92
|
+
return value;
|
|
93
|
+
},
|
|
94
|
+
set: async (key, value) => {
|
|
95
|
+
if (typeof key !== "string" || key.length === 0) throw new Error("secret key must be a non-empty string");
|
|
96
|
+
if (typeof value !== "string") throw new Error("secret value must be a string");
|
|
97
|
+
if (key.length > limits.keyLength) throw new StorageQuotaError("key-length", limits.keyLength);
|
|
98
|
+
if (Buffer.byteLength(value) > limits.valueBytes) throw new StorageQuotaError("value-size", limits.valueBytes);
|
|
99
|
+
requireStore();
|
|
100
|
+
if (!entries.has(key) && entries.size >= limits.keys) throw new StorageQuotaError("key-count", limits.keys);
|
|
101
|
+
entries.set(key, value);
|
|
102
|
+
},
|
|
103
|
+
delete: async (key) => entries.delete(key)
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
/** The value as the host stores it: a color in lower case, a list copied. */
|
|
107
|
+
const storedForm = (definition, value) => {
|
|
108
|
+
if (definition.type === "color" && typeof value === "string") return value.toLowerCase();
|
|
109
|
+
return Array.isArray(value) ? [...value] : value;
|
|
110
|
+
};
|
|
111
|
+
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
112
|
/** Why a value does not fit the setting definition; `null` if it fits. */
|
|
73
113
|
const findSettingProblem = (definition, value) => {
|
|
74
114
|
switch (definition.type) {
|
|
75
115
|
case "boolean": return typeof value === "boolean" ? null : "must be a boolean";
|
|
116
|
+
case "color": return typeof value === "string" && COLOR_SETTING_PATTERN.test(value) ? null : "must be a color #rrggbb";
|
|
117
|
+
case "list": {
|
|
118
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return "must be an array of strings";
|
|
119
|
+
const maxItems = definition.maxItems ?? SETTING_LIMITS.listItems;
|
|
120
|
+
if (value.length > maxItems) return `has more than ${maxItems} items`;
|
|
121
|
+
const itemMax = definition.itemMaxLength ?? SETTING_LIMITS.listItemLength;
|
|
122
|
+
return value.some((item) => item.length > itemMax) ? `has an item longer than ${itemMax} characters` : null;
|
|
123
|
+
}
|
|
124
|
+
case "text":
|
|
76
125
|
case "string":
|
|
77
126
|
if (typeof value !== "string") return "must be a string";
|
|
78
127
|
return definition.maxLength !== void 0 && value.length > definition.maxLength ? `is longer than ${definition.maxLength} characters` : null;
|
|
@@ -87,7 +136,7 @@ const findSettingProblem = (definition, value) => {
|
|
|
87
136
|
/** In-memory settings from manifest definitions; `initial` provides user values in place of `default`. */
|
|
88
137
|
const createMemorySettings = (definitions, initial = {}) => {
|
|
89
138
|
const byId = new Map(definitions.map((definition) => [definition.id, definition]));
|
|
90
|
-
const values = new Map(definitions.map((definition) => [definition.id, definition.default]));
|
|
139
|
+
const values = new Map(definitions.map((definition) => [definition.id, storedForm(definition, definition.default)]));
|
|
91
140
|
const handlers = /* @__PURE__ */ new Set();
|
|
92
141
|
const known = (id) => {
|
|
93
142
|
const definition = byId.get(id);
|
|
@@ -97,21 +146,18 @@ const createMemorySettings = (definitions, initial = {}) => {
|
|
|
97
146
|
const checked = (id, value) => {
|
|
98
147
|
const problem = findSettingProblem(known(id), value);
|
|
99
148
|
if (problem !== null) throw new Error(`setting '${id}' ${problem}`);
|
|
100
|
-
return value;
|
|
149
|
+
return storedForm(known(id), value);
|
|
101
150
|
};
|
|
102
151
|
for (const [id, value] of Object.entries(initial)) values.set(id, checked(id, value));
|
|
103
152
|
return {
|
|
104
|
-
get: (id) =>
|
|
105
|
-
known(id);
|
|
106
|
-
return values.get(id);
|
|
107
|
-
},
|
|
153
|
+
get: (id) => storedForm(known(id), values.get(id)),
|
|
108
154
|
onDidChange(handler) {
|
|
109
155
|
handlers.add(handler);
|
|
110
156
|
return { dispose: () => void handlers.delete(handler) };
|
|
111
157
|
},
|
|
112
158
|
async set(id, value) {
|
|
113
159
|
const next = checked(id, value);
|
|
114
|
-
if (
|
|
160
|
+
if (sameSettingValue(values.get(id), next)) return;
|
|
115
161
|
values.set(id, next);
|
|
116
162
|
for (const handler of [...handlers]) await handler({
|
|
117
163
|
id,
|
|
@@ -165,14 +211,271 @@ const createMemoryCommands = (options = {}) => {
|
|
|
165
211
|
ids: () => [...handlers.keys()]
|
|
166
212
|
};
|
|
167
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
|
+
const DAY_MS = 864e5;
|
|
305
|
+
const STATS_CORRECT_GRADE_MIN = 3;
|
|
306
|
+
const dayNumberOf = (year, month, day) => {
|
|
307
|
+
const date = /* @__PURE__ */ new Date(0);
|
|
308
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
309
|
+
return Math.floor(date.getTime() / DAY_MS);
|
|
310
|
+
};
|
|
311
|
+
const dayLabelOf = (dayNumber) => (/* @__PURE__ */ new Date(dayNumber * DAY_MS)).toISOString().slice(0, 10);
|
|
312
|
+
const parseDay = (field, value) => {
|
|
313
|
+
const match = typeof value === "string" ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) : null;
|
|
314
|
+
const day = match === null ? null : dayNumberOf(Number(match[1]), Number(match[2]), Number(match[3]));
|
|
315
|
+
if (day === null || dayLabelOf(day) !== value) throw new Error(`'${field}' must be a date as YYYY-MM-DD`);
|
|
316
|
+
return day;
|
|
317
|
+
};
|
|
318
|
+
/**
|
|
319
|
+
* In-memory statistics with the semantics of the app: local days in a time zone,
|
|
320
|
+
* correct at grade 3 or higher, `current` streak not broken while today has no
|
|
321
|
+
* attempts yet, one `daily` entry per date (at most `EXTENSION_STATS_LIMITS.dailyDays`).
|
|
322
|
+
*/
|
|
323
|
+
const createMemoryStats = (options = {}) => {
|
|
324
|
+
const timeZone = options.timeZone ?? new Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
325
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
326
|
+
timeZone,
|
|
327
|
+
calendar: "gregory",
|
|
328
|
+
numberingSystem: "latn",
|
|
329
|
+
year: "numeric",
|
|
330
|
+
month: "2-digit",
|
|
331
|
+
day: "2-digit"
|
|
332
|
+
});
|
|
333
|
+
const localDay = (at) => {
|
|
334
|
+
const parts = Object.fromEntries(formatter.formatToParts(at).map(({ type, value }) => [type, value]));
|
|
335
|
+
return dayNumberOf(Number(parts.year), Number(parts.month), Number(parts.day));
|
|
336
|
+
};
|
|
337
|
+
const attempts = [];
|
|
338
|
+
const record = (attempt) => {
|
|
339
|
+
const at = new Date(attempt.at).getTime();
|
|
340
|
+
if (Number.isNaN(at)) throw new Error("attempt.at is not a valid time");
|
|
341
|
+
attempts.push({
|
|
342
|
+
day: localDay(at),
|
|
343
|
+
correct: attempt.grade >= STATS_CORRECT_GRADE_MIN,
|
|
344
|
+
...attempt.courseId !== void 0 && { courseId: attempt.courseId }
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
for (const attempt of options.attempts ?? []) record(attempt);
|
|
348
|
+
const guard = () => {
|
|
349
|
+
if (options.permitted === false) throw new PermissionError("learning.stats");
|
|
350
|
+
};
|
|
351
|
+
const countsOf = (courseId) => {
|
|
352
|
+
const days = /* @__PURE__ */ new Map();
|
|
353
|
+
for (const item of attempts) {
|
|
354
|
+
if (courseId !== void 0 && item.courseId !== courseId) continue;
|
|
355
|
+
const counts = days.get(item.day) ?? {
|
|
356
|
+
attempts: 0,
|
|
357
|
+
correct: 0
|
|
358
|
+
};
|
|
359
|
+
counts.attempts += 1;
|
|
360
|
+
if (item.correct) counts.correct += 1;
|
|
361
|
+
days.set(item.day, counts);
|
|
362
|
+
}
|
|
363
|
+
return days;
|
|
364
|
+
};
|
|
365
|
+
return {
|
|
366
|
+
record,
|
|
367
|
+
async streak(streakOptions) {
|
|
368
|
+
guard();
|
|
369
|
+
const days = countsOf(streakOptions?.courseId);
|
|
370
|
+
const today = localDay((options.now ?? Date.now)());
|
|
371
|
+
let longest = 0;
|
|
372
|
+
let run = 0;
|
|
373
|
+
let previous = NaN;
|
|
374
|
+
for (const day of [...days.keys()].sort((a, b) => a - b)) {
|
|
375
|
+
run = day === previous + 1 ? run + 1 : 1;
|
|
376
|
+
longest = Math.max(longest, run);
|
|
377
|
+
previous = day;
|
|
378
|
+
}
|
|
379
|
+
let cursor = days.has(today) ? today : today - 1;
|
|
380
|
+
let current = 0;
|
|
381
|
+
while (days.has(cursor)) {
|
|
382
|
+
current += 1;
|
|
383
|
+
cursor -= 1;
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
current,
|
|
387
|
+
longest
|
|
388
|
+
};
|
|
389
|
+
},
|
|
390
|
+
async daily({ from, to, courseId }) {
|
|
391
|
+
guard();
|
|
392
|
+
const first = parseDay("from", from);
|
|
393
|
+
const last = parseDay("to", to);
|
|
394
|
+
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`);
|
|
395
|
+
const days = countsOf(courseId);
|
|
396
|
+
const result = [];
|
|
397
|
+
for (let day = first; day <= last; day += 1) {
|
|
398
|
+
const { attempts: made, correct } = days.get(day) ?? {
|
|
399
|
+
attempts: 0,
|
|
400
|
+
correct: 0
|
|
401
|
+
};
|
|
402
|
+
result.push({
|
|
403
|
+
date: dayLabelOf(day),
|
|
404
|
+
attempts: made,
|
|
405
|
+
correct,
|
|
406
|
+
accuracy: made === 0 ? null : correct / made
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return result;
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
};
|
|
413
|
+
/** Control characters (except the line feed), line and paragraph separators and text direction marks. */
|
|
414
|
+
const NOTIFICATION_CONTROL = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u2028\u2029\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu;
|
|
415
|
+
const notificationText = (field, value, max) => {
|
|
416
|
+
if (typeof value !== "string") throw new Error(`notification ${field} must be a string`);
|
|
417
|
+
const unified = value.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ");
|
|
418
|
+
const text = (field === "body" ? unified : unified.replace(/\n/gu, " ")).replace(NOTIFICATION_CONTROL, "").trim();
|
|
419
|
+
if (field === "title" && text === "" || [...text].length > max) throw new Error(`notification ${field} must be ${field === "title" ? "1 to " : "up to "}${max} characters`);
|
|
420
|
+
return text;
|
|
421
|
+
};
|
|
422
|
+
/**
|
|
423
|
+
* In-memory notifications with the engine's rules: the text is sanitized and
|
|
424
|
+
* limited, `perMinute`/`perHour` windows throw `NotificationRateLimitError`,
|
|
425
|
+
* a switched-off extension or an unsupporting system resolves `false` (and
|
|
426
|
+
* does not use the rate limit). `shown` is the log the app's fake notifier
|
|
427
|
+
* would keep.
|
|
428
|
+
*/
|
|
429
|
+
const createMemoryNotifications = (options = {}) => {
|
|
430
|
+
const now = options.now ?? Date.now;
|
|
431
|
+
let enabled = options.enabled !== false;
|
|
432
|
+
let supported = options.supported !== false;
|
|
433
|
+
const shown = [];
|
|
434
|
+
const times = [];
|
|
435
|
+
const limits = EXTENSION_NOTIFICATION_LIMITS;
|
|
436
|
+
return {
|
|
437
|
+
shown,
|
|
438
|
+
setEnabled: (value) => {
|
|
439
|
+
enabled = value;
|
|
440
|
+
},
|
|
441
|
+
setSupported: (value) => {
|
|
442
|
+
supported = value;
|
|
443
|
+
},
|
|
444
|
+
async show(notification) {
|
|
445
|
+
if (options.permitted === false) throw new PermissionError("notifications");
|
|
446
|
+
const title = notificationText("title", notification?.title, limits.titleLength);
|
|
447
|
+
const body = notificationText("body", notification?.body, limits.bodyLength);
|
|
448
|
+
if (!enabled) return false;
|
|
449
|
+
const at = now();
|
|
450
|
+
const recent = times.filter((time) => at - time < 36e5);
|
|
451
|
+
times.length = 0;
|
|
452
|
+
times.push(...recent);
|
|
453
|
+
if (recent.filter((time) => at - time < 6e4).length >= limits.perMinute) throw new NotificationRateLimitError("minute", limits.perMinute);
|
|
454
|
+
if (recent.length >= limits.perHour) throw new NotificationRateLimitError("hour", limits.perHour);
|
|
455
|
+
times.push(at);
|
|
456
|
+
if (!supported) return false;
|
|
457
|
+
shown.push({
|
|
458
|
+
title,
|
|
459
|
+
body
|
|
460
|
+
});
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
};
|
|
168
465
|
const contextOf = (options, registrars) => ({
|
|
169
466
|
extensionId: "test",
|
|
170
467
|
logger: options.logger ?? silentLogger,
|
|
171
468
|
library: options.library ?? createMemoryLibrary({}),
|
|
172
469
|
storage: options.storage ?? createMemoryStorage(),
|
|
470
|
+
secrets: options.secrets ?? createMemorySecrets(),
|
|
173
471
|
settings: options.settings ?? createMemorySettings([]),
|
|
174
472
|
events: options.events ?? createMemoryEvents(),
|
|
473
|
+
stats: options.stats ?? createMemoryStats(),
|
|
474
|
+
notifications: options.notifications ?? createMemoryNotifications(),
|
|
475
|
+
schedule: options.schedule ?? createMemorySchedule(),
|
|
175
476
|
commands: options.commands ?? createMemoryCommands(),
|
|
477
|
+
importers: options.importers ?? createMemoryImporters(),
|
|
478
|
+
exporters: options.exporters ?? createMemoryExporters(),
|
|
176
479
|
...registrars
|
|
177
480
|
});
|
|
178
481
|
const createSchemaValidator = (schema) => {
|
|
@@ -303,11 +606,16 @@ const loadGradePolicy = async (module, id, options = {}) => {
|
|
|
303
606
|
const loadEvents = async (module, options = {}) => {
|
|
304
607
|
const events = createMemoryEvents(options);
|
|
305
608
|
const storage = options.storage ?? createMemoryStorage();
|
|
609
|
+
const secrets = options.secrets ?? createMemorySecrets();
|
|
306
610
|
const settings = createMemorySettings(options.settings ?? [], options.settingValues);
|
|
307
611
|
const context = contextOf({
|
|
308
612
|
...options.library !== void 0 && { library: options.library },
|
|
309
613
|
...options.logger !== void 0 && { logger: options.logger },
|
|
614
|
+
...options.stats !== void 0 && { stats: options.stats },
|
|
615
|
+
...options.notifications !== void 0 && { notifications: options.notifications },
|
|
616
|
+
...options.schedule !== void 0 && { schedule: options.schedule },
|
|
310
617
|
storage,
|
|
618
|
+
secrets,
|
|
311
619
|
settings,
|
|
312
620
|
events
|
|
313
621
|
}, {
|
|
@@ -318,6 +626,7 @@ const loadEvents = async (module, options = {}) => {
|
|
|
318
626
|
return {
|
|
319
627
|
emit: events.emit,
|
|
320
628
|
storage,
|
|
629
|
+
secrets,
|
|
321
630
|
settings,
|
|
322
631
|
dispose: async () => {
|
|
323
632
|
await module.deactivate?.();
|
|
@@ -331,8 +640,12 @@ const loadCommands = async (module, options = {}) => {
|
|
|
331
640
|
...options.library !== void 0 && { library: options.library },
|
|
332
641
|
...options.logger !== void 0 && { logger: options.logger },
|
|
333
642
|
...options.storage !== void 0 && { storage: options.storage },
|
|
643
|
+
...options.secrets !== void 0 && { secrets: options.secrets },
|
|
334
644
|
...options.settings !== void 0 && { settings: options.settings },
|
|
335
645
|
...options.events !== void 0 && { events: options.events },
|
|
646
|
+
...options.stats !== void 0 && { stats: options.stats },
|
|
647
|
+
...options.notifications !== void 0 && { notifications: options.notifications },
|
|
648
|
+
...options.schedule !== void 0 && { schedule: options.schedule },
|
|
336
649
|
commands
|
|
337
650
|
}, {
|
|
338
651
|
registerExerciseType: () => ({ dispose: () => void 0 }),
|
|
@@ -347,6 +660,76 @@ const loadCommands = async (module, options = {}) => {
|
|
|
347
660
|
}
|
|
348
661
|
};
|
|
349
662
|
};
|
|
663
|
+
/** The context parts a test replaced, without the keys it left unset. */
|
|
664
|
+
const loadOptionsOf = (options) => ({
|
|
665
|
+
...options.library !== void 0 && { library: options.library },
|
|
666
|
+
...options.logger !== void 0 && { logger: options.logger },
|
|
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
|
+
}
|
|
693
|
+
};
|
|
694
|
+
};
|
|
695
|
+
/** Activates the module with in-memory exporters and lets the test run them like the host; `stats` feeds `ctx.stats` of a progress exporter. */
|
|
696
|
+
const loadExporters = async (module, options = {}) => {
|
|
697
|
+
const exporters = createMemoryExporters(options);
|
|
698
|
+
const context = contextOf({
|
|
699
|
+
...loadOptionsOf(options),
|
|
700
|
+
exporters
|
|
701
|
+
}, {
|
|
702
|
+
registerExerciseType: () => ({ dispose: () => void 0 }),
|
|
703
|
+
registerGradePolicy: () => ({ dispose: () => void 0 })
|
|
704
|
+
});
|
|
705
|
+
await module.activate(context);
|
|
706
|
+
return {
|
|
707
|
+
run: exporters.run,
|
|
708
|
+
ids: exporters.ids,
|
|
709
|
+
dispose: async () => {
|
|
710
|
+
await module.deactivate?.();
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
};
|
|
714
|
+
/** Activates the module with in-memory schedules and lets the test fire them like the host. */
|
|
715
|
+
const loadSchedules = async (module, options = {}) => {
|
|
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);
|
|
725
|
+
return {
|
|
726
|
+
fire: schedule.fire,
|
|
727
|
+
ids: schedule.ids,
|
|
728
|
+
dispose: async () => {
|
|
729
|
+
await module.deactivate?.();
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
};
|
|
350
733
|
const requireDocument = (helper) => {
|
|
351
734
|
if (typeof document === "undefined") throw new Error(`${helper} needs a DOM: run the test in a DOM environment (happy-dom or jsdom)`);
|
|
352
735
|
return document;
|
|
@@ -400,47 +783,92 @@ const loadView = async (views, id, options = {}) => {
|
|
|
400
783
|
}
|
|
401
784
|
};
|
|
402
785
|
};
|
|
786
|
+
/** The part of the context a panel and a widget share, with the controls a test needs. */
|
|
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 };
|
|
793
|
+
return {
|
|
794
|
+
context: {
|
|
795
|
+
get context() {
|
|
796
|
+
return current;
|
|
797
|
+
},
|
|
798
|
+
signal: controller.signal,
|
|
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
|
+
}
|
|
811
|
+
},
|
|
812
|
+
frame: {
|
|
813
|
+
container,
|
|
814
|
+
calls,
|
|
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
|
+
}
|
|
826
|
+
},
|
|
827
|
+
container
|
|
828
|
+
};
|
|
829
|
+
};
|
|
403
830
|
/** Mounts a panel from `panels[id]` in the test DOM environment with the same context the frame provides. */
|
|
404
831
|
const loadPanel = async (panels, id, options = {}) => {
|
|
405
832
|
const doc = requireDocument("loadPanel");
|
|
406
833
|
const panel = panels[id];
|
|
407
834
|
if (panel === void 0) throw new Error(`panel '${id}' was not exported`);
|
|
408
|
-
const
|
|
835
|
+
const { context, frame, container } = createFrameContext(options, doc);
|
|
409
836
|
const listeners = /* @__PURE__ */ new Set();
|
|
410
|
-
const controller = new AbortController();
|
|
411
|
-
const container = options.container ?? doc.body.appendChild(doc.createElement("div"));
|
|
412
837
|
await panel.mount(container, {
|
|
838
|
+
get context() {
|
|
839
|
+
return context.context;
|
|
840
|
+
},
|
|
841
|
+
signal: context.signal,
|
|
842
|
+
call: context.call,
|
|
843
|
+
onContextChange: context.onContextChange,
|
|
413
844
|
panelId: id,
|
|
414
845
|
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
846
|
onProps: (listener) => {
|
|
425
847
|
listeners.add(listener);
|
|
426
848
|
return () => void listeners.delete(listener);
|
|
427
849
|
}
|
|
428
850
|
});
|
|
429
|
-
return {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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;
|
|
437
864
|
},
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
};
|
|
865
|
+
signal: context.signal,
|
|
866
|
+
call: context.call,
|
|
867
|
+
onContextChange: context.onContextChange,
|
|
868
|
+
widgetId: id
|
|
869
|
+
});
|
|
870
|
+
return frame;
|
|
443
871
|
};
|
|
444
872
|
|
|
445
873
|
//#endregion
|
|
446
|
-
export { createMemoryCommands, createMemoryEvents, createMemoryLibrary, createMemorySettings, createMemoryStorage, createSchemaValidator, loadCommands, loadEvents, loadExerciseType, loadGradePolicy, loadPanel, loadView };
|
|
874
|
+
export { createMemoryCommands, createMemoryEvents, createMemoryExporters, createMemoryImporters, createMemoryLibrary, createMemoryNotifications, createMemorySchedule, createMemorySecrets, createMemorySettings, createMemoryStats, createMemoryStorage, createSchemaValidator, loadCommands, loadEvents, loadExerciseType, loadExporters, loadGradePolicy, loadImporters, loadPanel, loadSchedules, loadView, loadWidget };
|